Skip to main content

Plan Enforcement

Runtime enforcement of workspace plan limits using Redis atomic counters, subscription-aware billing periods, lazy DB reconciliation, and fail-closed error handling.

Overview

Every workflow execution passes through PlanEnforcementService before it can run. The service checks:

CheckWhenLimit source
Execution quotaBefore execution startsWorkspacePlan.MaxExecutions
Concurrent executionsBefore execution startsWorkspacePlan.MaxConcurrentExecutions
Tasks per executionDuring Run() loopWorkspacePlan.MaxTasksPerExecution
Execution timeoutDuring Run() loopWorkspacePlan.MaxExecutionDurationMinutes
Payload sizeDuring Run() loopWorkspacePlan.MaxExecutionPayloadSizeMb

Pre-execution checks (quota + concurrent) use Redis atomic counters. Runtime checks (tasks, timeout, payload) query the in-memory plan cache directly.

Architecture

Component Overview

Data Model

Redis Key Schema

Execution Flow

Pre-Execution Enforcement

Runtime Enforcement (during Run loop)

Concurrent Counter Lifecycle

Caching Layer

WorkspacePlanCacheService

WorkspacePlanInfo DTO

record WorkspacePlanInfo(
WorkspacePlan? Plan,
Guid? ActiveSubscriptionId,
DateTime PeriodStart,
DateTime PeriodEnd
)
  • Plan — the workspace_plan row with all limit values
  • ActiveSubscriptionId — the subscription GUID used as Redis key discriminator
  • PeriodStart / PeriodEnd — billing period from active subscription; fallback to calendar month if no subscription

Lua Scripts

SeedAndIncrIfUnderLimitScript

Used for the execution quota counter. Handles lazy reconciliation atomically.

-- KEYS[1] = counter key
-- ARGV[1] = limit
-- ARGV[2] = TTL in seconds
-- ARGV[3] = seed value (-1 = skip seeding)

local exists = redis.call('EXISTS', KEYS[1])
if exists == 0 and tonumber(ARGV[3]) >= 0 then
redis.call('SET', KEYS[1], ARGV[3])
if tonumber(ARGV[2]) > 0 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
end
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
if current >= tonumber(ARGV[1]) then
return -1 -- limit reached
end
local newVal = redis.call('INCR', KEYS[1])
if tonumber(ARGV[2]) > 0 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
return newVal -- new count after increment

Behavior:

  1. If key doesn't exist and seed >= 0 → SET key to seed value
  2. Check if current count >= limit → return -1 (blocked)
  3. Otherwise INCR and return new value

IncrIfUnderLimitScript

Used for the concurrent execution counter (no seeding needed).

local current = tonumber(redis.call('GET', KEYS[1]) or '0')
if current >= tonumber(ARGV[1]) then
return -1
end
local newVal = redis.call('INCR', KEYS[1])
if tonumber(ARGV[2]) > 0 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
return newVal

UpdatePeakScript

Tracks the highest concurrent execution count seen in the billing period.

local current = tonumber(redis.call('GET', KEYS[1]) or '0')
local newVal = tonumber(ARGV[1])
if newVal > current then
redis.call('SET', KEYS[1], newVal)
if tonumber(ARGV[2]) > 0 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
end
return current

Subscription Lifecycle and Counter Reset

The execution counter is keyed by subscription GUID. When a subscription renews, a new row with a new GUID is created, which means the Redis counter key is naturally fresh.

Stripe Subscription Lifecycle

Enterprise / System Auto-Renewal

Error Codes

CodeEnumMeaning
9001PlanExecutionLimitReachedBilling period execution quota exhausted
9002PlanConcurrentExecutionLimitReachedToo many simultaneous executions
9003PlanTaskPerExecutionLimitReachedSingle execution exceeded max tasks
9004PlanExecutionTimeoutExecution exceeded max duration
9005PlanRateLimitExceededRate limit exceeded (reserved)
9006PlanPayloadSizeExceededRequest/response payload too large
9007PlanStorageLimitReachedBlob storage limit exceeded
9008PlanEnforcementUnavailableRedis unavailable, execution blocked (fail-closed)

Design Decisions

DecisionChoiceRationale
Redis key discriminatorActiveSubscriptionId (GUID)Auto-resets on renewal, no date ambiguity
Redis failure modeFail-closed (block execution)Prevents quota abuse when enforcement is unavailable
Reconciliation strategyLazy (on key miss)No cron needed; DB query only on rare cache miss
Seed atomicityLua script with EXISTS guardPrevents race conditions between concurrent requests
Subscription renewalNew DB row (new GUID)Redis counter key resets without explicit deletion
Cache invalidationRedis pub/subWorks across multiple engine instances
Cache TTL30 minutesBalances freshness vs DB load
Redis counter TTL45 daysOutlives any billing cycle; stale keys expire naturally
Concurrent counter TTL24 hoursSelf-heals if counter leaks due to crashes
No active subscriptionAllow with defaultsMatches existing behavior for default-plan workspaces

File Reference

ComponentPathRole
IPlanEnforcementServiceWorkflowEngine/Services/Interfaces/IPlanEnforcementService.csInterface: pre-start checks, runtime checks, decrement
PlanEnforcementServiceWorkflowEngine/Services/PlanEnforcementService.csRedis counters, Lua scripts, lazy reconciliation, fail-closed
WorkspacePlanCacheServiceWorkflowEngine/Services/WorkspacePlanCacheService.csIn-memory cache + Redis pub/sub invalidation
WorkspacePlanInfoWorkflowEngine/Services/WorkspacePlanCacheService.csDTO: Plan + SubscriptionId + PeriodStart + PeriodEnd
WorkflowServiceWorkflowEngine/Services/WorkflowService.csCaller: Start() and Run() loop enforcement
CoreControllerWorkflowEngine/Controllers/CoreController.cs/api/core/invalidate-plan/{wsId} endpoint
WorkspacePlanWorkflowEngineLibrary/Core/Models/WorkspacePlan.csPlan limits entity
WorkspaceSubscriptionWorkflowEngineLibrary/Core/Models/WorkspaceSubscription.csSubscription entity (billing period, status)
WorkspaceWorkflowEngineLibrary/Core/Models/Workspace.csActiveSubscriptionId FK
ErrorCodeWorkflowEngineLibrary/Core/Errors/ErrorCode.csPlan error codes (9001-9008)
BillingServiceWorkflowEngineWeb/Server/Services/Implementations/BillingService.csSubscription create/update/deactivate
StripeWebhookControllerWorkflowEngineWeb/Server/Controllers/StripeWebhookController.csStripe event handling
WorkspacePlanServiceWorkflowEngineWeb/Server/Services/Implementations/WorkspacePlanService.csPlan transitions + engine notification
SubscriptionLifecycleCronWorkflowEngineWeb/Server/Services/Implementations/SubscriptionLifecycleCron.csAuto-renewal, expiry, trial handling
DI RegistrationWorkflowEngine/Program.csSingleton: WorkspacePlanCacheService, Scoped: PlanEnforcementService
MockPlanEnforcementServiceWorkflowEngine.Test/Mocks/MockPlanEnforcementService.csTest mock: always allows all operations