diff --git a/app/Observers/NamespaceDurableStateObserver.php b/app/Observers/NamespaceDurableStateObserver.php index 24127712..74b59d2e 100644 --- a/app/Observers/NamespaceDurableStateObserver.php +++ b/app/Observers/NamespaceDurableStateObserver.php @@ -3,13 +3,25 @@ namespace App\Observers; use App\Models\WorkerRegistration; +use App\Models\WorkflowDurableStream; +use App\Models\WorkflowDurableStreamItem; +use App\Models\WorkflowInboundStream; +use App\Models\WorkflowInboundStreamItem; use App\Support\NamespaceDurableStateQuota; use Illuminate\Database\Eloquent\Model; +use InvalidArgumentException; use Workflow\V2\Enums\RunStatus; +use Workflow\V2\Enums\TaskStatus; +use Workflow\V2\Enums\TimerStatus; +use Workflow\V2\Models\WorkflowCommand; +use Workflow\V2\Models\WorkflowHistoryEvent; use Workflow\V2\Models\WorkflowInstance; use Workflow\V2\Models\WorkflowRun; +use Workflow\V2\Models\WorkflowRunWait; use Workflow\V2\Models\WorkflowSchedule; use Workflow\V2\Models\WorkflowScheduleHistoryEvent; +use Workflow\V2\Models\WorkflowTask; +use Workflow\V2\Models\WorkflowTimer; final class NamespaceDurableStateObserver { @@ -21,16 +33,11 @@ public function creating(Model $model): void { $resources = $this->resourcesFor($model); - if ($resources === []) { + if ($resources === [] || ! $this->quota->mayConstrain($resources)) { return; } - $namespace = $model->getAttribute('namespace'); - $namespace = is_string($namespace) && trim($namespace) !== '' - ? $namespace - : (string) config('server.default_namespace', 'default'); - - $this->quota->admitCreate($namespace, $resources); + $this->quota->admitCreate($this->namespaceFor($model), $resources); } /** @return list */ @@ -66,6 +73,104 @@ private function resourcesFor(Model $model): array return [NamespaceDurableStateQuota::WORKER_REGISTRATIONS]; } + if ($model instanceof WorkflowHistoryEvent) { + return [NamespaceDurableStateQuota::WORKFLOW_HISTORY_EVENTS]; + } + + if ($model instanceof WorkflowTask) { + $resources = [NamespaceDurableStateQuota::WORKFLOW_TASKS]; + $status = $model->getAttribute('status'); + $taskStatus = $status instanceof TaskStatus + ? $status + : (is_string($status) ? TaskStatus::tryFrom($status) : null); + + if ($taskStatus === null || $taskStatus === TaskStatus::Ready) { + $resources[] = NamespaceDurableStateQuota::PENDING_WORKFLOW_TASKS; + } + + return $resources; + } + + if ($model instanceof WorkflowTimer) { + $resources = [NamespaceDurableStateQuota::WORKFLOW_TIMERS]; + $status = $model->getAttribute('status'); + $timerStatus = $status instanceof TimerStatus + ? $status + : (is_string($status) ? TimerStatus::tryFrom($status) : null); + + if ($timerStatus === null || $timerStatus === TimerStatus::Pending) { + $resources[] = NamespaceDurableStateQuota::PENDING_WORKFLOW_TIMERS; + } + + return $resources; + } + + if ($model instanceof WorkflowRunWait) { + $resources = [NamespaceDurableStateQuota::WORKFLOW_RUN_WAITS]; + + if (($model->getAttribute('status') ?? 'open') === 'open') { + $resources[] = NamespaceDurableStateQuota::OPEN_WORKFLOW_RUN_WAITS; + } + + return $resources; + } + + if ($model instanceof WorkflowCommand) { + return [NamespaceDurableStateQuota::WORKFLOW_COMMANDS]; + } + + if ($model instanceof WorkflowDurableStream || $model instanceof WorkflowInboundStream) { + return [NamespaceDurableStateQuota::WORKFLOW_STREAMS]; + } + + if ($model instanceof WorkflowDurableStreamItem || $model instanceof WorkflowInboundStreamItem) { + return [NamespaceDurableStateQuota::WORKFLOW_STREAM_ITEMS]; + } + return []; } + + private function namespaceFor(Model $model): string + { + $namespace = $model->getAttribute('namespace'); + + if (is_string($namespace) && trim($namespace) !== '') { + return $namespace; + } + + $runId = $model->getAttribute('workflow_run_id'); + + if (is_string($runId) && $runId !== '') { + $namespace = WorkflowRun::query()->whereKey($runId)->value('namespace'); + + if (is_string($namespace) && $namespace !== '') { + return $namespace; + } + } + + $instanceId = $model->getAttribute('workflow_instance_id'); + + if (is_string($instanceId) && $instanceId !== '') { + $namespace = WorkflowInstance::query()->whereKey($instanceId)->value('namespace'); + + if (is_string($namespace) && $namespace !== '') { + return $namespace; + } + } + + $scheduleId = $model->getAttribute('workflow_schedule_id'); + + if (is_string($scheduleId) && $scheduleId !== '') { + $namespace = WorkflowSchedule::query()->whereKey($scheduleId)->value('namespace'); + + if (is_string($namespace) && $namespace !== '') { + return $namespace; + } + } + + throw new InvalidArgumentException(sprintf( + 'Namespace durable-state quota could not resolve ownership for [%s].', + $model::class, + )); + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ea7a2feb..e92e527d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -6,6 +6,10 @@ use App\Contracts\AuthProvider; use App\Contracts\RuntimeSignalControlPlane; use App\Models\WorkerRegistration; +use App\Models\WorkflowDurableStream; +use App\Models\WorkflowDurableStreamItem; +use App\Models\WorkflowInboundStream; +use App\Models\WorkflowInboundStreamItem; use App\Observers\NamespaceDurableStateObserver; use App\Observers\WorkflowHistoryEventObserver; use App\Observers\WorkflowTaskObserver; @@ -29,12 +33,15 @@ use Workflow\V2\Contracts\ServiceBoundaryPolicy; use Workflow\V2\Contracts\ServiceControlPlane; use Workflow\V2\Contracts\WorkflowControlPlane; +use Workflow\V2\Models\WorkflowCommand; use Workflow\V2\Models\WorkflowHistoryEvent; use Workflow\V2\Models\WorkflowInstance; use Workflow\V2\Models\WorkflowRun; +use Workflow\V2\Models\WorkflowRunWait; use Workflow\V2\Models\WorkflowSchedule; use Workflow\V2\Models\WorkflowScheduleHistoryEvent; use Workflow\V2\Models\WorkflowTask; +use Workflow\V2\Models\WorkflowTimer; use Workflow\V2\Models\WorkflowUpdate; use Workflow\V2\Support\DefaultServiceControlPlane; use Workflow\V2\Support\DefaultWorkflowControlPlane; @@ -178,5 +185,14 @@ public function boot(): void WorkflowSchedule::observe(NamespaceDurableStateObserver::class); WorkflowScheduleHistoryEvent::observe(NamespaceDurableStateObserver::class); WorkerRegistration::observe(NamespaceDurableStateObserver::class); + WorkflowHistoryEvent::observe(NamespaceDurableStateObserver::class); + WorkflowTask::observe(NamespaceDurableStateObserver::class); + WorkflowTimer::observe(NamespaceDurableStateObserver::class); + WorkflowRunWait::observe(NamespaceDurableStateObserver::class); + WorkflowCommand::observe(NamespaceDurableStateObserver::class); + WorkflowDurableStream::observe(NamespaceDurableStateObserver::class); + WorkflowDurableStreamItem::observe(NamespaceDurableStateObserver::class); + WorkflowInboundStream::observe(NamespaceDurableStateObserver::class); + WorkflowInboundStreamItem::observe(NamespaceDurableStateObserver::class); } } diff --git a/app/Support/NamespaceDurableStateQuota.php b/app/Support/NamespaceDurableStateQuota.php index e77a355b..6fd9631f 100644 --- a/app/Support/NamespaceDurableStateQuota.php +++ b/app/Support/NamespaceDurableStateQuota.php @@ -3,6 +3,10 @@ namespace App\Support; use App\Models\WorkerRegistration; +use App\Models\WorkflowDurableStream; +use App\Models\WorkflowDurableStreamItem; +use App\Models\WorkflowInboundStream; +use App\Models\WorkflowInboundStreamItem; use App\Models\WorkflowNamespace; use Closure; use Illuminate\Support\Facades\DB; @@ -10,10 +14,17 @@ use InvalidArgumentException; use Throwable; use Workflow\V2\Enums\RunStatus; +use Workflow\V2\Enums\TaskStatus; +use Workflow\V2\Enums\TimerStatus; +use Workflow\V2\Models\WorkflowCommand; +use Workflow\V2\Models\WorkflowHistoryEvent; use Workflow\V2\Models\WorkflowInstance; use Workflow\V2\Models\WorkflowRun; +use Workflow\V2\Models\WorkflowRunWait; use Workflow\V2\Models\WorkflowSchedule; use Workflow\V2\Models\WorkflowScheduleHistoryEvent; +use Workflow\V2\Models\WorkflowTask; +use Workflow\V2\Models\WorkflowTimer; final class NamespaceDurableStateQuota { @@ -31,6 +42,26 @@ final class NamespaceDurableStateQuota public const WORKER_REGISTRATIONS = 'worker_registrations'; + public const WORKFLOW_HISTORY_EVENTS = 'workflow_history_events'; + + public const WORKFLOW_TASKS = 'workflow_tasks'; + + public const PENDING_WORKFLOW_TASKS = 'pending_workflow_tasks'; + + public const WORKFLOW_TIMERS = 'workflow_timers'; + + public const PENDING_WORKFLOW_TIMERS = 'pending_workflow_timers'; + + public const WORKFLOW_RUN_WAITS = 'workflow_run_waits'; + + public const OPEN_WORKFLOW_RUN_WAITS = 'open_workflow_run_waits'; + + public const WORKFLOW_COMMANDS = 'workflow_commands'; + + public const WORKFLOW_STREAMS = 'workflow_streams'; + + public const WORKFLOW_STREAM_ITEMS = 'workflow_stream_items'; + /** @var array */ private const LIMIT_FIELDS = [ self::WORKFLOW_INSTANCES => 'max_workflow_instances', @@ -39,6 +70,16 @@ final class NamespaceDurableStateQuota self::SCHEDULES => 'max_schedules', self::SCHEDULE_HISTORY_EVENTS => 'max_schedule_history_events', self::WORKER_REGISTRATIONS => 'max_worker_registrations', + self::WORKFLOW_HISTORY_EVENTS => 'max_workflow_history_events', + self::WORKFLOW_TASKS => 'max_workflow_tasks', + self::PENDING_WORKFLOW_TASKS => 'max_pending_workflow_tasks', + self::WORKFLOW_TIMERS => 'max_workflow_timers', + self::PENDING_WORKFLOW_TIMERS => 'max_pending_workflow_timers', + self::WORKFLOW_RUN_WAITS => 'max_workflow_run_waits', + self::OPEN_WORKFLOW_RUN_WAITS => 'max_open_workflow_run_waits', + self::WORKFLOW_COMMANDS => 'max_workflow_commands', + self::WORKFLOW_STREAMS => 'max_workflow_streams', + self::WORKFLOW_STREAM_ITEMS => 'max_workflow_stream_items', ]; /** @var array */ @@ -49,6 +90,16 @@ final class NamespaceDurableStateQuota self::SCHEDULES => 10_000_000, self::SCHEDULE_HISTORY_EVENTS => 1_000_000_000, self::WORKER_REGISTRATIONS => 10_000_000, + self::WORKFLOW_HISTORY_EVENTS => 1_000_000_000, + self::WORKFLOW_TASKS => 1_000_000_000, + self::PENDING_WORKFLOW_TASKS => 100_000_000, + self::WORKFLOW_TIMERS => 1_000_000_000, + self::PENDING_WORKFLOW_TIMERS => 100_000_000, + self::WORKFLOW_RUN_WAITS => 1_000_000_000, + self::OPEN_WORKFLOW_RUN_WAITS => 100_000_000, + self::WORKFLOW_COMMANDS => 1_000_000_000, + self::WORKFLOW_STREAMS => 100_000_000, + self::WORKFLOW_STREAM_ITEMS => 1_000_000_000, ]; /** @var array */ @@ -59,6 +110,16 @@ final class NamespaceDurableStateQuota self::SCHEDULES => false, self::SCHEDULE_HISTORY_EVENTS => false, self::WORKER_REGISTRATIONS => true, + self::WORKFLOW_HISTORY_EVENTS => false, + self::WORKFLOW_TASKS => false, + self::PENDING_WORKFLOW_TASKS => true, + self::WORKFLOW_TIMERS => false, + self::PENDING_WORKFLOW_TIMERS => true, + self::WORKFLOW_RUN_WAITS => false, + self::OPEN_WORKFLOW_RUN_WAITS => true, + self::WORKFLOW_COMMANDS => false, + self::WORKFLOW_STREAMS => true, + self::WORKFLOW_STREAM_ITEMS => true, ]; private const UNAVAILABLE_REASON = 'namespace_durable_state_quota_unavailable'; @@ -67,6 +128,38 @@ public function __construct( private readonly ServerPollingCache $cache, ) {} + /** @param list $resources */ + public function mayConstrain(array $resources): bool + { + try { + $fields = array_map( + static fn (string $resource): string => self::LIMIT_FIELDS[$resource], + $this->normalizeResources($resources), + ); + $defaults = $this->validatedConfiguredObject('limits'); + $hardLimits = $this->validatedConfiguredObject('hard_limits'); + $overrides = $this->configuredOverrides(); + } catch (NamespaceDurableStateException $exception) { + throw $exception; + } catch (Throwable $exception) { + throw $this->unavailable((string) config('server.default_namespace', 'default'), $exception); + } + + foreach ($fields as $field) { + if (($defaults[$field] ?? null) !== null || ($hardLimits[$field] ?? null) !== null) { + return true; + } + + foreach ($overrides as $override) { + if (($override[$field] ?? null) !== null) { + return true; + } + } + } + + return false; + } + /** @param list $resources */ public function constrains(string $namespace, array $resources): bool { @@ -310,7 +403,7 @@ public function metrics(string $namespace): array 'measurement_status' => $measurementStatus, 'label_cardinality_policy' => [ 'namespace' => 'request_scope_not_label', - 'reason' => 'finite_seven_reason_inventory', + 'reason' => 'finite_reason_inventory', ], ]; } @@ -319,15 +412,15 @@ public function metrics(string $namespace): array public function resourceLimits(string $namespace): array { $namespace = $this->normalizeNamespace($namespace); - $defaults = $this->configuredObject('limits'); - $hardLimits = $this->configuredObject('hard_limits'); + $defaults = $this->validatedConfiguredObject('limits'); + $hardLimits = $this->validatedConfiguredObject('hard_limits'); $overrides = $this->configuredOverrides(); $namespaceOverride = $overrides[$namespace] ?? []; $resolved = []; foreach (self::LIMIT_FIELDS as $resource => $field) { - $default = $this->limitValue($defaults[$field] ?? null, "server.namespace_durable_state.limits.{$field}"); - $hard = $this->limitValue($hardLimits[$field] ?? null, "server.namespace_durable_state.hard_limits.{$field}"); + $default = $defaults[$field] ?? null; + $hard = $hardLimits[$field] ?? null; $override = array_key_exists($field, $namespaceOverride) ? $this->limitValue( $namespaceOverride[$field], @@ -378,6 +471,43 @@ private function usage(string $namespace, array $resources): array self::WORKER_REGISTRATIONS => (int) WorkerRegistration::query() ->where('namespace', $namespace) ->count(), + self::WORKFLOW_HISTORY_EVENTS => (int) WorkflowHistoryEvent::query() + ->whereIn('workflow_run_id', $this->runIdsForNamespace($namespace)) + ->count(), + self::WORKFLOW_TASKS => (int) WorkflowTask::query() + ->where('namespace', $namespace) + ->count(), + self::PENDING_WORKFLOW_TASKS => (int) WorkflowTask::query() + ->where('namespace', $namespace) + ->where('status', TaskStatus::Ready->value) + ->count(), + self::WORKFLOW_TIMERS => (int) WorkflowTimer::query() + ->whereIn('workflow_run_id', $this->runIdsForNamespace($namespace)) + ->count(), + self::PENDING_WORKFLOW_TIMERS => (int) WorkflowTimer::query() + ->whereIn('workflow_run_id', $this->runIdsForNamespace($namespace)) + ->where('status', TimerStatus::Pending->value) + ->count(), + self::WORKFLOW_RUN_WAITS => (int) WorkflowRunWait::query() + ->whereIn('workflow_instance_id', $this->instanceIdsForNamespace($namespace)) + ->count(), + self::OPEN_WORKFLOW_RUN_WAITS => (int) WorkflowRunWait::query() + ->whereIn('workflow_instance_id', $this->instanceIdsForNamespace($namespace)) + ->where('status', 'open') + ->count(), + self::WORKFLOW_COMMANDS => (int) WorkflowCommand::query() + ->whereIn('workflow_instance_id', $this->instanceIdsForNamespace($namespace)) + ->count(), + self::WORKFLOW_STREAMS => (int) WorkflowDurableStream::query() + ->where('namespace', $namespace) + ->count() + (int) WorkflowInboundStream::query() + ->where('namespace', $namespace) + ->count(), + self::WORKFLOW_STREAM_ITEMS => (int) WorkflowDurableStreamItem::query() + ->where('namespace', $namespace) + ->count() + (int) WorkflowInboundStreamItem::query() + ->where('namespace', $namespace) + ->count(), default => throw new InvalidArgumentException("Unknown namespace durable-state resource [{$resource}]."), }; } @@ -385,6 +515,36 @@ private function usage(string $namespace, array $resources): array return $usage; } + private function runIdsForNamespace(string $namespace) + { + return WorkflowRun::query() + ->select('id') + ->where('namespace', $namespace); + } + + private function instanceIdsForNamespace(string $namespace) + { + return WorkflowInstance::query() + ->select('id') + ->where('namespace', $namespace); + } + + /** @return array */ + private function validatedConfiguredObject(string $name): array + { + $configured = $this->configuredObject($name); + $validated = []; + + foreach ($configured as $field => $value) { + $validated[$field] = $this->limitValue( + $value, + "server.namespace_durable_state.{$name}.{$field}", + ); + } + + return $validated; + } + /** @return array */ private function configuredObject(string $name): array { diff --git a/composer.json b/composer.json index 3753d66e..e7568fb4 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "require": { "php": "^8.2", "apache/avro": "^1.12", - "durable-workflow/workflow": "2.0.0", + "durable-workflow/workflow": "2.0.3", "laravel/framework": "^13.0", "laravel/tinker": "^3.0" }, @@ -47,7 +47,7 @@ }, "extra": { "durable-workflow": { - "product-train": "2.0.2" + "product-train": "2.0.3" }, "laravel": { "dont-discover": [] diff --git a/composer.lock b/composer.lock index 6dc8985b..286a2585 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1c156ae1a4f723e61e782de6fe41b790", + "content-hash": "9eec1cee595983587c9e39b8d694f9ef", "packages": [ { "name": "apache/avro", @@ -504,16 +504,16 @@ }, { "name": "durable-workflow/workflow", - "version": "2.0.0", + "version": "2.0.3", "source": { "type": "git", "url": "https://github.com/durable-workflow/workflow.git", - "reference": "746edb32aef314d3d0e5db62c305c7b4b3c1f3e9" + "reference": "9e351efdd3eff38d2305eec248c90392846cec35" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/746edb32aef314d3d0e5db62c305c7b4b3c1f3e9", - "reference": "746edb32aef314d3d0e5db62c305c7b4b3c1f3e9", + "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/9e351efdd3eff38d2305eec248c90392846cec35", + "reference": "9e351efdd3eff38d2305eec248c90392846cec35", "shasum": "" }, "require": { @@ -542,8 +542,11 @@ "Workflow\\Providers\\WorkflowServiceProvider" ] }, + "branch-alias": { + "dev-main": "2.0.x-dev" + }, "durable-workflow": { - "product-train": "2.0.0", + "product-train": "2.0.3", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" } @@ -570,9 +573,9 @@ "description": "Embedded durable workflow runtime and orchestration engine for Laravel applications.", "support": { "issues": "https://github.com/durable-workflow/workflow/issues", - "source": "https://github.com/durable-workflow/workflow/tree/2.0.0" + "source": "https://github.com/durable-workflow/workflow/tree/2.0.3" }, - "time": "2026-09-01T00:07:11+00:00" + "time": "2026-09-02T19:15:12+00:00" }, { "name": "egulias/email-validator", diff --git a/config/dw-bounded-growth.php b/config/dw-bounded-growth.php index a37f9561..954f73d8 100644 --- a/config/dw-bounded-growth.php +++ b/config/dw-bounded-growth.php @@ -365,9 +365,9 @@ 'surface' => 'GET /api/system/metrics', 'dimensions' => [ 'namespace' => 'request_scope_not_label', - 'reason' => 'finite_seven_reason_inventory', + 'reason' => 'finite_reason_inventory', ], - 'cardinality' => 'The request namespace is the query scope rather than a label; usage fields are fixed to six durable resource kinds and rejection counters use six exhaustion reasons plus quota unavailability.', + 'cardinality' => 'The request namespace is the query scope rather than a label; usage and rejection fields come from the fixed durable resource inventory plus quota unavailability.', 'selection' => 'Current durable row usage and current-minute rejection counters for the requested namespace.', 'suppression' => 'No suppression is needed because each response contains a fixed resource and reason inventory.', ], diff --git a/config/dw-contract.php b/config/dw-contract.php index 5a97bfb6..50d63652 100644 --- a/config/dw-contract.php +++ b/config/dw-contract.php @@ -375,7 +375,7 @@ 'legacy' => 'WORKFLOW_SERVER_NAMESPACE_ADMISSION_OVERRIDES', ], 'DW_NAMESPACE_DURABLE_LIMITS' => [ - 'description' => 'JSON object of default per-namespace durable row limits: max_workflow_instances, max_workflow_runs, max_open_workflow_runs, max_schedules, max_schedule_history_events, and max_worker_registrations. An empty object is unlimited.', + 'description' => 'JSON object of default per-namespace durable row limits across workflow instances, runs, history, tasks, timers, waits, commands, schedules, workers, and streams. Lifetime and pending/open limits are independent; pending workflow tasks count queued ready rows because active leases have separate admission limits. An empty object is unlimited.', 'default' => '{}', 'since' => '2.0.3', 'legacy' => 'WORKFLOW_SERVER_NAMESPACE_DURABLE_LIMITS', diff --git a/config/server.php b/config/server.php index b56c6c53..b053f9f3 100644 --- a/config/server.php +++ b/config/server.php @@ -401,11 +401,12 @@ | Namespace Durable State |-------------------------------------------------------------------------- | - | Durable row counts can be bounded independently per namespace. Empty - | objects are explicitly unlimited for backwards compatibility. Namespace - | overrides may raise or lower defaults, but hard limits remain - | non-bypassable. A configured limit is enforced against database state - | while holding the namespace row lock. + | Durable row counts can be bounded independently per namespace. Pending + | task limits count queued ready rows; active leases are bounded separately + | by task-queue admission. Empty objects are explicitly unlimited for + | backwards compatibility. Namespace overrides may raise or lower defaults, + | but hard limits remain non-bypassable. A configured limit is enforced + | against database state while holding the namespace row lock. | */ diff --git a/docker-compose.dedicated-matching.yml b/docker-compose.dedicated-matching.yml index 8e0aada3..9ab088ec 100644 --- a/docker-compose.dedicated-matching.yml +++ b/docker-compose.dedicated-matching.yml @@ -32,13 +32,13 @@ name: durable-workflow-server # daemon reports `shape: dedicated`. # Generated by scripts/ci/sync-source-release.mjs. Do not edit the fallback. -x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.0.2}} +x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.0.3}} x-server-environment: &server-environment APP_NAME: "Durable Workflow Server" APP_ENV: ${APP_ENV:-local} DW_SERVER_KEY: ${DW_SERVER_KEY:-} - APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.0.2}} + APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.0.3}} APP_DEBUG: ${APP_DEBUG:-false} DB_CONNECTION: mysql DB_HOST: mysql diff --git a/docker-compose.memo-rolling.yml b/docker-compose.memo-rolling.yml index 3365803d..8ec229de 100644 --- a/docker-compose.memo-rolling.yml +++ b/docker-compose.memo-rolling.yml @@ -49,14 +49,14 @@ services: command: ["server-bootstrap"] environment: <<: *runtime-environment - APP_VERSION: ${APP_VERSION:-2.0.2} + APP_VERSION: ${APP_VERSION:-2.0.3} successor: image: ${DW_MEMO_SUCCESSOR_IMAGE:-durable-workflow/server-memo-rolling:local} ports: !override [] environment: <<: *runtime-environment - APP_VERSION: ${APP_VERSION:-2.0.2} + APP_VERSION: ${APP_VERSION:-2.0.3} DW_SERVER_ID: memo-successor DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: server_http_node diff --git a/docker-compose.published.yml b/docker-compose.published.yml index 605ce8c5..f4dae198 100644 --- a/docker-compose.published.yml +++ b/docker-compose.published.yml @@ -1,13 +1,13 @@ name: durable-workflow-server # Generated by scripts/ci/sync-source-release.mjs. Do not edit the fallback. -x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.0.2}} +x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.0.3}} x-server-environment: &server-environment APP_NAME: "Durable Workflow Server" APP_ENV: ${APP_ENV:-local} DW_SERVER_KEY: ${DW_SERVER_KEY:-} - APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.0.2}} + APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.0.3}} APP_DEBUG: ${APP_DEBUG:-false} LOG_CHANNEL: ${LOG_CHANNEL:-stderr} LOG_LEVEL: ${LOG_LEVEL:-info} diff --git a/docker-compose.small-cluster.yml b/docker-compose.small-cluster.yml index 32a1841d..b8db3f64 100644 --- a/docker-compose.small-cluster.yml +++ b/docker-compose.small-cluster.yml @@ -12,7 +12,7 @@ x-server-build: &server-build x-server-environment: &server-environment APP_NAME: "Durable Workflow Server" APP_ENV: testing - APP_VERSION: ${APP_VERSION:-2.0.2} + APP_VERSION: ${APP_VERSION:-2.0.3} APP_DEBUG: "false" DW_SERVER_KEY: ${DW_SERVER_KEY:-base64:5Zt4nUhlCm3DD0nLXZJQdHiwPfb56yGo9gNV/g3jYbY=} DB_CONNECTION: ${DW_SMALL_CLUSTER_DB:-mysql} diff --git a/docker-compose.yml b/docker-compose.yml index 432ada00..1856dd2d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: DW_SERVER_KEY: "${DW_SERVER_KEY:-}" DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: server_http_node - APP_VERSION: "${APP_VERSION:-2.0.2}" + APP_VERSION: "${APP_VERSION:-2.0.3}" APP_DEBUG: "false" DB_CONNECTION: mysql DB_HOST: mysql @@ -54,7 +54,7 @@ services: APP_NAME: "Durable Workflow Server" APP_ENV: local DW_SERVER_KEY: "${DW_SERVER_KEY:-}" - APP_VERSION: "${APP_VERSION:-2.0.2}" + APP_VERSION: "${APP_VERSION:-2.0.3}" APP_DEBUG: "false" DB_CONNECTION: mysql DB_HOST: mysql @@ -107,7 +107,7 @@ services: DW_SERVER_KEY: "${DW_SERVER_KEY:-}" DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: worker_node - APP_VERSION: "${APP_VERSION:-2.0.2}" + APP_VERSION: "${APP_VERSION:-2.0.3}" DB_CONNECTION: mysql DB_HOST: mysql DB_PORT: 3306 @@ -153,7 +153,7 @@ services: DW_SERVER_KEY: "${DW_SERVER_KEY:-}" DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: scheduler_node - APP_VERSION: "${APP_VERSION:-2.0.2}" + APP_VERSION: "${APP_VERSION:-2.0.3}" DB_CONNECTION: mysql DB_HOST: mysql DB_PORT: 3306 diff --git a/docs/bounded-growth.md b/docs/bounded-growth.md index 489e07c3..8bc9c977 100644 --- a/docs/bounded-growth.md +++ b/docs/bounded-growth.md @@ -100,7 +100,7 @@ retention bound. | `dw_workflow_task_consecutive_failures` | `GET /api/system/metrics` | `namespace` is request-scoped rather than a label. `workflow_type` series are limited by `server.metrics.workflow_task_failure_type_limit`, default 20 and hard-clamped to 100; suppressed type/task counts are reported in the payload. | | `dw_projection_drift_total` | `GET /api/system/metrics` | `namespace` is server-scoped rather than a label. `table` is fixed to the finite projection inventory: `run_summaries`, `run_waits`, `run_timeline_entries`, `run_timer_entries`, and `run_lineage_entries`. Alert on non-zero `needs_rebuild` per table. | | `dw_namespace_request_admission_rejections` | `GET /api/system/metrics` | `namespace` is request-scoped rather than a label. Rejection counters use a fixed three-reason inventory for the current minute. | -| `dw_namespace_durable_state_usage` | `GET /api/system/metrics` | `namespace` is request-scoped rather than a label. Durable usage uses six fixed resource fields; rejection counters use six exhaustion reasons plus quota unavailability. | +| `dw_namespace_durable_state_usage` | `GET /api/system/metrics` | `namespace` is request-scoped rather than a label. Usage and rejection counters use the fixed durable resource inventory plus quota unavailability. | | `dw_runtime_external_payload_namespace_usage` | `GET /api/system/metrics` | `namespace` is request-scoped rather than a label. Durable byte/object usage has no dynamic labels; rejection counters use a fixed three-reason inventory for the current minute. | | `dw_workflow_runs_total` | `GET /api/system/prometheus-metrics` | `namespace` is request-scoped rather than a label. `task_queue`/`workflow_type` series are limited by `server.metrics.prometheus_workflow_series_limit`, default 100 and hard-clamped to 500. Scrape-time discovery reads at most `limit + 1` label sets; `cardinality.series_limits.workflows` reports exact counts until the cap is exceeded, then lower bounds. | | `dw_workflow_run_latency_seconds` | `GET /api/system/prometheus-metrics` | Shares the bounded `task_queue`/`workflow_type` series set used by `dw_workflow_runs_total`; latency buckets are emitted only for reported workflow series. | diff --git a/k8s/README.md b/k8s/README.md index 94d478ae..68d8940c 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -13,7 +13,7 @@ The checked-in manifests are synchronized with the repository's stable source release and pin its Docker Hub tag: ```text -durableworkflow/server:2.0.2 +durableworkflow/server:2.0.3 ``` Before production use, patch every workload image to the exact published tag or @@ -21,15 +21,15 @@ digest you intend to run: ```bash kubectl set image -n durable-workflow deploy/durable-workflow-server \ - server=durableworkflow/server:2.0.2 + server=durableworkflow/server:2.0.3 kubectl set image -n durable-workflow deploy/durable-workflow-worker \ - worker=durableworkflow/server:2.0.2 + worker=durableworkflow/server:2.0.3 kubectl set image -n durable-workflow cronjob/durable-workflow-scheduler \ - scheduler=durableworkflow/server:2.0.2 + scheduler=durableworkflow/server:2.0.3 ``` GitHub Container Registry publishes the same release line at -`ghcr.io/durable-workflow/server:2.0.2`. Digest pinning is preferred for strict +`ghcr.io/durable-workflow/server:2.0.3`. Digest pinning is preferred for strict change control. The manifests expect you to provide: diff --git a/k8s/helm/durable-workflow/Chart.yaml b/k8s/helm/durable-workflow/Chart.yaml index 03d2596c..3998596b 100644 --- a/k8s/helm/durable-workflow/Chart.yaml +++ b/k8s/helm/durable-workflow/Chart.yaml @@ -5,11 +5,11 @@ type: application # The chart's own semver version. Bumped on every chart release; treated as # independent of the server image version (appVersion). Breaking-change rules # for this version live in docs/helm-upgrading.md alongside the chart. -version: 0.1.73 +version: 0.1.74 # The immutable Durable Workflow Server identity this chart release packages. # The onboarding default in values.yaml and appVersion are generated from the # checked-in source release record. -appVersion: "2.0.2" +appVersion: "2.0.3" kubeVersion: ">=1.27.0-0" home: https://durable-workflow.github.io/docs/2.0/deployment sources: @@ -30,7 +30,7 @@ annotations: # exact commit that most recently changed the packaged chart. org.opencontainers.image.source: https://github.com/durable-workflow/server dev.durable-workflow.source-revision: "unreleased" - dev.durable-workflow.image-reference: "docker.io/durableworkflow/server:2.0.2" + dev.durable-workflow.image-reference: "docker.io/durableworkflow/server:2.0.3" artifacthub.io/license: MIT artifacthub.io/category: integration-delivery # Free-form changelog for the current chart release shown by Artifact Hub. diff --git a/k8s/helm/durable-workflow/README.md b/k8s/helm/durable-workflow/README.md index 89eed40c..d9f2abc1 100644 --- a/k8s/helm/durable-workflow/README.md +++ b/k8s/helm/durable-workflow/README.md @@ -63,7 +63,7 @@ helm install durable-workflow ./k8s/helm/durable-workflow \ ```yaml image: - tag: "2.0.2" + tag: "2.0.3" # Pin a digest in production: # digest: "sha256:abc123..." # memoPayloadStorage: "raw-json-v1" # Required for a digest or custom image. diff --git a/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml b/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml index 5196cf57..37cec9bc 100644 --- a/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml +++ b/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml @@ -1,7 +1,7 @@ # CI fixture: GitOps / externally-managed-secret path. The chart consumes # existing Secrets and renders no Secret resources of its own. image: - tag: "2.0.2" + tag: "2.0.3" externalDatabase: connection: pgsql diff --git a/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml b/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml index 2e90a34a..32a14392 100644 --- a/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml +++ b/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml @@ -1,6 +1,6 @@ # CI fixture: ingress + autoscaling enabled. Exercises optional templates. image: - tag: "2.0.2" + tag: "2.0.3" externalDatabase: connection: mysql diff --git a/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml b/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml index 53f25599..8f3e2f98 100644 --- a/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml +++ b/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml @@ -2,7 +2,7 @@ # chart's render path is exercised end-to-end. Real deployments should use # existingSecret instead. image: - tag: "2.0.2" + tag: "2.0.3" externalDatabase: connection: mysql diff --git a/k8s/helm/durable-workflow/templates/_helpers.tpl b/k8s/helm/durable-workflow/templates/_helpers.tpl index 47854de1..cb296837 100644 --- a/k8s/helm/durable-workflow/templates/_helpers.tpl +++ b/k8s/helm/durable-workflow/templates/_helpers.tpl @@ -88,7 +88,7 @@ resolved by an explicit capability declaration or an existing workload marker. {{- define "durable-workflow.memoPayloadStorageForImage" -}} {{- $image := toString . -}} {{- $normalized := regexReplaceAll "^index\\.docker\\.io/" $image "docker.io/" -}} -{{- if eq $normalized "docker.io/durableworkflow/server:2.0.2" -}} +{{- if eq $normalized "docker.io/durableworkflow/server:2.0.3" -}} dual-v1 {{- else if regexMatch "^docker\\.io/durableworkflow/server:2\\.0\\.0-rc\\.[0-9]+$" $normalized -}} {{- $releaseCandidate := atoi (regexFind "[0-9]+$" $normalized) -}} diff --git a/k8s/helm/durable-workflow/values.yaml b/k8s/helm/durable-workflow/values.yaml index 4e0bfde6..34d45a72 100644 --- a/k8s/helm/durable-workflow/values.yaml +++ b/k8s/helm/durable-workflow/values.yaml @@ -21,7 +21,7 @@ image: registry: docker.io repository: durableworkflow/server # Generated by scripts/ci/sync-source-release.mjs. Do not edit this default. - tag: "2.0.2" + tag: "2.0.3" # Optional digest pin. When set, takes precedence over tag for change control. # Example: "sha256:abc123..." digest: "" diff --git a/k8s/helm/examples/values-dev.yaml b/k8s/helm/examples/values-dev.yaml index 15d9ce10..1544b1b4 100644 --- a/k8s/helm/examples/values-dev.yaml +++ b/k8s/helm/examples/values-dev.yaml @@ -3,7 +3,7 @@ # shape in production. image: - tag: "2.0.2" + tag: "2.0.3" externalDatabase: connection: mysql diff --git a/k8s/helm/examples/values-external-secrets-operator.yaml b/k8s/helm/examples/values-external-secrets-operator.yaml index 3a53a47c..c9f6e812 100644 --- a/k8s/helm/examples/values-external-secrets-operator.yaml +++ b/k8s/helm/examples/values-external-secrets-operator.yaml @@ -5,7 +5,7 @@ # concern. image: - tag: "2.0.2" + tag: "2.0.3" externalDatabase: connection: pgsql diff --git a/k8s/helm/examples/values-production-existing-secrets.yaml b/k8s/helm/examples/values-production-existing-secrets.yaml index e16bf19a..a46a8a75 100644 --- a/k8s/helm/examples/values-production-existing-secrets.yaml +++ b/k8s/helm/examples/values-production-existing-secrets.yaml @@ -10,7 +10,7 @@ image: repository: durable-workflow/server # Pin a digest in production for change-control auditability. digest: "" # e.g. "sha256:abc123..." - tag: "2.0.2" + tag: "2.0.3" externalDatabase: connection: pgsql diff --git a/k8s/migration-job.yaml b/k8s/migration-job.yaml index 8f9c2131..1be2c78b 100644 --- a/k8s/migration-job.yaml +++ b/k8s/migration-job.yaml @@ -13,7 +13,7 @@ spec: restartPolicy: OnFailure containers: - name: migrate - image: durableworkflow/server:2.0.2 + image: durableworkflow/server:2.0.3 command: ["server-entrypoint"] args: ["server-bootstrap"] envFrom: diff --git a/k8s/scheduler-cronjob.yaml b/k8s/scheduler-cronjob.yaml index 4da56dfe..5ac90d03 100644 --- a/k8s/scheduler-cronjob.yaml +++ b/k8s/scheduler-cronjob.yaml @@ -24,7 +24,7 @@ spec: restartPolicy: Never containers: - name: scheduler - image: durableworkflow/server:2.0.2 + image: durableworkflow/server:2.0.3 command: ["server-entrypoint"] args: ["sh", "-c", "php artisan schedule:evaluate --limit=100 --json; php artisan activity:timeout-enforce --limit=100; if php artisan list --raw | grep -q '^external-payloads:cleanup '; then php artisan external-payloads:cleanup --limit=100 --json; fi; php artisan history:prune --limit=100"] envFrom: diff --git a/k8s/secret.yaml b/k8s/secret.yaml index 0a841979..33b03e89 100644 --- a/k8s/secret.yaml +++ b/k8s/secret.yaml @@ -12,7 +12,7 @@ metadata: app.kubernetes.io/name: durable-workflow data: APP_NAME: "Durable Workflow Server" - APP_VERSION: "2.0.2" + APP_VERSION: "2.0.3" APP_ENV: production APP_DEBUG: "false" DB_CONNECTION: mysql diff --git a/k8s/server-deployment.yaml b/k8s/server-deployment.yaml index 7664c0a7..422eea63 100644 --- a/k8s/server-deployment.yaml +++ b/k8s/server-deployment.yaml @@ -23,7 +23,7 @@ spec: spec: containers: - name: server - image: durableworkflow/server:2.0.2 + image: durableworkflow/server:2.0.3 ports: - containerPort: 8080 name: http diff --git a/k8s/worker-deployment.yaml b/k8s/worker-deployment.yaml index b7d9b84f..74fdc5cf 100644 --- a/k8s/worker-deployment.yaml +++ b/k8s/worker-deployment.yaml @@ -19,7 +19,7 @@ spec: spec: containers: - name: worker - image: durableworkflow/server:2.0.2 + image: durableworkflow/server:2.0.3 command: ["server-entrypoint"] args: ["php", "artisan", "queue:work", "--sleep=1", "--tries=3", "--max-time=3600"] envFrom: diff --git a/resources/release/source-release.json b/resources/release/source-release.json index 59cf8531..f6f98c78 100644 --- a/resources/release/source-release.json +++ b/resources/release/source-release.json @@ -1,9 +1,9 @@ { "schema": "durable-workflow.server.source-release/v1", "server": { - "version": "2.0.2" + "version": "2.0.3" }, "helm_chart": { - "version": "0.1.73" + "version": "0.1.74" } } diff --git a/scripts/k8s-kind-smoke.sh b/scripts/k8s-kind-smoke.sh index 0599f22a..69dddf14 100755 --- a/scripts/k8s-kind-smoke.sh +++ b/scripts/k8s-kind-smoke.sh @@ -7,7 +7,7 @@ cluster="${K8S_SMOKE_CLUSTER:-durable-workflow-server-smoke}" image="${K8S_SMOKE_IMAGE:-durableworkflow/server:k8s-smoke}" # Generated by scripts/ci/sync-source-release.mjs so the smoke replaces the # same default shipped by the public manifests. -manifest_image="durableworkflow/server:2.0.2" +manifest_image="durableworkflow/server:2.0.3" kind_node_image="${K8S_SMOKE_KIND_NODE_IMAGE:-kindest/node:v1.29.4}" artifact_dir="${K8S_SMOKE_ARTIFACT_DIR:-/tmp/durable-workflow-k8s-kind-smoke-artifacts}" rendered_dir="${artifact_dir}/rendered-manifests" diff --git a/tests/Feature/NamespaceDurableStateQuotaTest.php b/tests/Feature/NamespaceDurableStateQuotaTest.php index c6532e53..61d8b2da 100644 --- a/tests/Feature/NamespaceDurableStateQuotaTest.php +++ b/tests/Feature/NamespaceDurableStateQuotaTest.php @@ -4,17 +4,30 @@ namespace Tests\Feature; +use App\Models\WorkflowDurableStream; +use App\Models\WorkflowDurableStreamItem; +use App\Models\WorkflowInboundStream; +use App\Models\WorkflowInboundStreamItem; +use App\Support\NamespaceDurableStateException; use App\Support\NamespaceDurableStateQuota; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\DB; use Illuminate\Testing\TestResponse; use Tests\Feature\Concerns\ServerTestHelpers; use Tests\Fixtures\ExternalGreetingWorkflow; +use Tests\Fixtures\InteractiveCommandWorkflow; use Tests\TestCase; use Workflow\Serializers\Serializer; +use Workflow\V2\Enums\TimerStatus; +use Workflow\V2\Models\WorkflowCommand; +use Workflow\V2\Models\WorkflowHistoryEvent; use Workflow\V2\Models\WorkflowInstance; use Workflow\V2\Models\WorkflowRun; +use Workflow\V2\Models\WorkflowRunWait; use Workflow\V2\Models\WorkflowSchedule; use Workflow\V2\Models\WorkflowScheduleHistoryEvent; +use Workflow\V2\Models\WorkflowTask; +use Workflow\V2\Models\WorkflowTimer; final class NamespaceDurableStateQuotaTest extends TestCase { @@ -30,6 +43,7 @@ protected function setUp(): void $this->createNamespace('tenant-b'); $this->configureWorkflowTypes([ 'tests.external-greeting-workflow' => ExternalGreetingWorkflow::class, + 'tests.interactive-command-workflow' => InteractiveCommandWorkflow::class, ]); } @@ -241,6 +255,249 @@ public function test_worker_registration_limit_allows_refresh_but_rejects_new_id $this->registerWorkerThroughApi('tenant-b', 'worker-one')->assertCreated(); } + public function test_pending_workflow_task_limit_rolls_back_a_start_without_blocking_another_namespace(): void + { + config(['server.namespace_durable_state.overrides' => [ + 'default' => ['max_pending_workflow_tasks' => 1], + ]]); + + $this->startWorkflow('default', 'task-capacity-one')->assertCreated(); + + $this->startWorkflow('default', 'task-capacity-two') + ->assertStatus(429) + ->assertHeader('Retry-After', '60') + ->assertJsonPath('reason', 'namespace_pending_workflow_tasks_exhausted') + ->assertJsonPath('resource', NamespaceDurableStateQuota::PENDING_WORKFLOW_TASKS) + ->assertJsonPath('retryable', true); + + $this->assertFalse(WorkflowInstance::query()->whereKey('task-capacity-two')->exists()); + $this->assertSame(1, WorkflowTask::query()->where('namespace', 'default')->count()); + + $this->startWorkflow('tenant-b', 'tenant-b-task-capacity')->assertCreated(); + } + + public function test_pending_workflow_task_limit_allows_net_neutral_task_replacement(): void + { + config(['server.namespace_durable_state.overrides' => [ + 'default' => ['max_pending_workflow_tasks' => 1], + ]]); + + $runId = (string) $this->startWorkflow('default', 'task-replacement') + ->assertCreated() + ->json('run_id'); + + $this->runReadyWorkflowTask($runId); + + $this->assertSame(1, WorkflowTask::query() + ->where('namespace', 'default') + ->where('status', 'ready') + ->count()); + $this->assertDatabaseHas('workflow_tasks', [ + 'workflow_run_id' => $runId, + 'task_type' => 'activity', + 'status' => 'ready', + ]); + } + + public function test_command_and_history_limits_derive_namespace_and_roll_back_signals(): void + { + $start = $this->withHeaders($this->apiHeaders()) + ->postJson('/api/workflows', [ + 'workflow_id' => 'command-history-capacity', + 'workflow_type' => 'tests.interactive-command-workflow', + 'task_queue' => 'default', + ]) + ->assertCreated(); + + $runId = (string) $start->json('run_id'); + $commandCount = WorkflowCommand::query() + ->where('workflow_instance_id', 'command-history-capacity') + ->count(); + $historyCount = WorkflowHistoryEvent::query() + ->where('workflow_run_id', $runId) + ->count(); + + config(['server.namespace_durable_state.overrides' => [ + 'default' => ['max_workflow_commands' => $commandCount], + ]]); + + $this->withHeaders($this->apiHeaders()) + ->postJson('/api/workflows/command-history-capacity/signal/advance', [ + 'input' => ['Ada'], + 'request_id' => 'command-capacity-signal', + ]) + ->assertStatus(429) + ->assertJsonPath('reason', 'namespace_workflow_commands_exhausted'); + + $this->assertSame($commandCount, WorkflowCommand::query() + ->where('workflow_instance_id', 'command-history-capacity') + ->count()); + $this->assertSame($historyCount, WorkflowHistoryEvent::query() + ->where('workflow_run_id', $runId) + ->count()); + + config(['server.namespace_durable_state.overrides' => [ + 'default' => ['max_workflow_history_events' => $historyCount], + ]]); + + $this->withHeaders($this->apiHeaders()) + ->postJson('/api/workflows/command-history-capacity/signal/advance', [ + 'input' => ['Grace'], + 'request_id' => 'history-capacity-signal', + ]) + ->assertStatus(429) + ->assertJsonPath('reason', 'namespace_workflow_history_events_exhausted'); + + $this->assertSame($commandCount, WorkflowCommand::query() + ->where('workflow_instance_id', 'command-history-capacity') + ->count()); + $this->assertSame($historyCount, WorkflowHistoryEvent::query() + ->where('workflow_run_id', $runId) + ->count()); + + $this->withHeaders($this->apiHeaders('tenant-b')) + ->postJson('/api/workflows', [ + 'workflow_id' => 'tenant-b-command-history', + 'workflow_type' => 'tests.interactive-command-workflow', + 'task_queue' => 'default', + ]) + ->assertCreated(); + + $this->withHeaders($this->apiHeaders('tenant-b')) + ->postJson('/api/workflows/tenant-b-command-history/signal/advance', [ + 'input' => ['Katherine'], + 'request_id' => 'tenant-b-signal', + ]) + ->assertAccepted(); + } + + public function test_timer_and_wait_limits_cover_lifetime_and_active_rows(): void + { + $defaultRunId = (string) $this->startWorkflow('default', 'timer-wait-default') + ->assertCreated() + ->json('run_id'); + $tenantRunId = (string) $this->startWorkflow('tenant-b', 'timer-wait-tenant') + ->assertCreated() + ->json('run_id'); + + config(['server.namespace_durable_state.overrides' => [ + 'default' => ['max_workflow_timers' => 0], + ]]); + $this->assertQuotaRejection( + 'namespace_workflow_timers_exhausted', + fn () => $this->createTimer($defaultRunId, 1), + ); + + config(['server.namespace_durable_state.overrides' => [ + 'default' => ['max_pending_workflow_timers' => 0], + ]]); + $this->assertQuotaRejection( + 'namespace_pending_workflow_timers_exhausted', + fn () => $this->createTimer($defaultRunId, 2), + ); + + config(['server.namespace_durable_state.overrides' => [ + 'default' => ['max_workflow_run_waits' => 0], + ]]); + $this->assertQuotaRejection( + 'namespace_workflow_run_waits_exhausted', + fn () => $this->createWait('timer-wait-default', $defaultRunId, 'lifetime-wait'), + ); + + config(['server.namespace_durable_state.overrides' => [ + 'default' => ['max_open_workflow_run_waits' => 0], + ]]); + $this->assertQuotaRejection( + 'namespace_open_workflow_run_waits_exhausted', + fn () => $this->createWait('timer-wait-default', $defaultRunId, 'open-wait'), + ); + + $this->createTimer($tenantRunId, 1); + $this->createWait('timer-wait-tenant', $tenantRunId, 'tenant-wait'); + + $this->assertSame(0, WorkflowTimer::query()->where('workflow_run_id', $defaultRunId)->count()); + $this->assertSame(0, WorkflowRunWait::query()->where('workflow_run_id', $defaultRunId)->count()); + $this->assertSame(1, WorkflowTimer::query()->where('workflow_run_id', $tenantRunId)->count()); + $this->assertSame(1, WorkflowRunWait::query()->where('workflow_run_id', $tenantRunId)->count()); + } + + public function test_stream_limits_combine_inbound_and_outbound_growth(): void + { + $outbound = DB::transaction(fn () => WorkflowDurableStream::query()->create([ + 'namespace' => 'default', + 'workflow_instance_id' => 'stream-owner', + 'workflow_run_id' => 'stream-run', + 'stream_name' => 'outbound', + 'status' => WorkflowDurableStream::STATUS_OPEN, + ])); + $inbound = DB::transaction(fn () => WorkflowInboundStream::query()->create([ + 'namespace' => 'default', + 'workflow_instance_id' => 'stream-owner', + 'stream_name' => 'inbound', + ])); + + config(['server.namespace_durable_state.overrides' => [ + 'default' => ['max_workflow_streams' => 2], + ]]); + $this->assertQuotaRejection( + 'namespace_workflow_streams_exhausted', + fn () => WorkflowInboundStream::query()->create([ + 'namespace' => 'default', + 'workflow_instance_id' => 'stream-owner', + 'stream_name' => 'second-inbound', + ]), + ); + + config(['server.namespace_durable_state.overrides' => []]); + DB::transaction(fn () => WorkflowDurableStreamItem::query()->create([ + 'stream_id' => $outbound->id, + 'namespace' => 'default', + 'workflow_run_id' => 'stream-run', + 'stream_name' => 'outbound', + 'offset' => 0, + 'emitted_at' => now(), + ])); + + config(['server.namespace_durable_state.overrides' => [ + 'default' => ['max_workflow_stream_items' => 1], + ]]); + $this->assertQuotaRejection( + 'namespace_workflow_stream_items_exhausted', + fn () => WorkflowInboundStreamItem::query()->create([ + 'stream_id' => $inbound->id, + 'namespace' => 'default', + 'workflow_instance_id' => 'stream-owner', + 'stream_name' => 'inbound', + 'message_id' => 'message-one', + 'position' => 1, + 'payload_codec' => 'avro', + 'payload_blob' => 'payload', + 'payload_hash' => hash('sha256', 'payload'), + ]), + ); + + $tenantStream = DB::transaction(fn () => WorkflowInboundStream::query()->create([ + 'namespace' => 'tenant-b', + 'workflow_instance_id' => 'tenant-stream-owner', + 'stream_name' => 'inbound', + ])); + DB::transaction(fn () => WorkflowInboundStreamItem::query()->create([ + 'stream_id' => $tenantStream->id, + 'namespace' => 'tenant-b', + 'workflow_instance_id' => 'tenant-stream-owner', + 'stream_name' => 'inbound', + 'message_id' => 'tenant-message-one', + 'position' => 1, + 'payload_codec' => 'avro', + 'payload_blob' => 'payload', + 'payload_hash' => hash('sha256', 'payload'), + ])); + + $this->assertSame(1, WorkflowDurableStreamItem::query()->where('namespace', 'default')->count()); + $this->assertSame(0, WorkflowInboundStreamItem::query()->where('namespace', 'default')->count()); + $this->assertSame(1, WorkflowInboundStreamItem::query()->where('namespace', 'tenant-b')->count()); + } + public function test_override_cannot_exceed_hard_limit_and_metrics_report_usage(): void { config([ @@ -260,6 +517,8 @@ public function test_override_cannot_exceed_hard_limit_and_metrics_report_usage( $this->startWorkflow('default', 'metrics-one')->assertCreated(); + $historyCount = WorkflowHistoryEvent::query()->count(); + $this->withHeaders($this->apiHeaders()) ->getJson('/api/system/metrics') ->assertOk() @@ -274,6 +533,22 @@ public function test_override_cannot_exceed_hard_limit_and_metrics_report_usage( ->assertJsonPath( 'metrics.'.NamespaceDurableStateQuota::METRIC_NAME.'.remaining.workflow_runs', 1, + ) + ->assertJsonPath( + 'metrics.'.NamespaceDurableStateQuota::METRIC_NAME.'.usage.workflow_history_events', + $historyCount, + ) + ->assertJsonPath( + 'metrics.'.NamespaceDurableStateQuota::METRIC_NAME.'.usage.workflow_tasks', + 1, + ) + ->assertJsonPath( + 'metrics.'.NamespaceDurableStateQuota::METRIC_NAME.'.usage.pending_workflow_tasks', + 1, + ) + ->assertJsonPath( + 'metrics.'.NamespaceDurableStateQuota::METRIC_NAME.'.usage.workflow_streams', + 0, ); } @@ -366,4 +641,40 @@ private function registerWorkerThroughApi( 'capability_manifest' => $this->portableWorkerAffinityRefusalManifest(), ]); } + + private function createTimer(string $runId, int $sequence): WorkflowTimer + { + return DB::transaction(fn () => WorkflowTimer::query()->create([ + 'workflow_run_id' => $runId, + 'sequence' => $sequence, + 'status' => TimerStatus::Pending->value, + 'delay_seconds' => 60, + 'fire_at' => now()->addMinute(), + ])); + } + + private function createWait(string $workflowId, string $runId, string $waitId): WorkflowRunWait + { + return DB::transaction(fn () => WorkflowRunWait::query()->create([ + 'id' => $runId.'-'.$waitId, + 'workflow_run_id' => $runId, + 'workflow_instance_id' => $workflowId, + 'wait_id' => $waitId, + 'position' => 1, + 'kind' => 'signal', + 'status' => 'open', + 'opened_at' => now(), + ])); + } + + /** @param callable(): mixed $mutation */ + private function assertQuotaRejection(string $reason, callable $mutation): void + { + try { + DB::transaction($mutation); + $this->fail("Expected namespace durable-state rejection [{$reason}]."); + } catch (NamespaceDurableStateException $exception) { + $this->assertSame($reason, $exception->reason); + } + } }