diff --git a/app/Http/Controllers/Api/HistoryController.php b/app/Http/Controllers/Api/HistoryController.php index f782cee3..790023f0 100644 --- a/app/Http/Controllers/Api/HistoryController.php +++ b/app/Http/Controllers/Api/HistoryController.php @@ -4,6 +4,7 @@ use App\Support\ControlPlaneProtocol; use App\Support\ExternalPayloadEnvelopeService; +use App\Support\HistoryPageToken; use App\Support\LegacyV1Projection; use App\Support\LongPoller; use App\Support\LongPollSignalStore; @@ -50,7 +51,7 @@ public function show(Request $request, string $workflowId, string $runId): JsonR } $pageSize = $validated['page_size'] ?? 100; - $afterSequence = $this->decodePageToken($validated['next_page_token'] ?? null); + $afterSequence = HistoryPageToken::decode($validated['next_page_token'] ?? null); $waitNewEvent = (bool) ($validated['wait_new_event'] ?? false); $events = $waitNewEvent @@ -81,7 +82,7 @@ public function show(Request $request, string $workflowId, string $runId): JsonR 'payload' => $this->eventPayload($namespace, $run, $event), ])->all(), 'next_page_token' => $hasMore && $lastSequence !== null - ? self::encodePageToken((int) $lastSequence) + ? HistoryPageToken::encode((int) $lastSequence) : null, ]; @@ -229,26 +230,6 @@ private function compatibilityFleetReason(string $namespace, WorkflowRun $run): ); } - private function decodePageToken(?string $token): ?int - { - if (! is_string($token) || trim($token) === '') { - return null; - } - - $decoded = base64_decode($token, true); - - if (! is_string($decoded) || ! ctype_digit($decoded)) { - return null; - } - - return (int) $decoded; - } - - private static function encodePageToken(int $sequence): string - { - return base64_encode((string) $sequence); - } - /** * Surface the server-derived principal recorded on the underlying * command at the top of the event response so audit clients can diff --git a/app/Http/Controllers/Api/SystemController.php b/app/Http/Controllers/Api/SystemController.php index 08a06c99..7b778b0f 100644 --- a/app/Http/Controllers/Api/SystemController.php +++ b/app/Http/Controllers/Api/SystemController.php @@ -19,7 +19,9 @@ use App\Support\WorkflowTaskFailureMetrics; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; use Workflow\V2\Contracts\MatchingRole; +use Workflow\V2\Contracts\OperatorObservabilityRepository; use Workflow\V2\Support\HealthCheck; use Workflow\V2\Support\OperatorDashboardSummary; use Workflow\V2\Support\OperatorMetrics; @@ -185,14 +187,53 @@ public function boundedOperatorDashboard(Request $request): JsonResponse return $this->dashboardResponse($request, includeHistoryAudits: false); } - private function dashboardResponse(Request $request, bool $includeHistoryAudits): JsonResponse + public function workflowTypeOperatorDashboard(Request $request): JsonResponse + { + if ($response = ControlPlaneProtocol::rejectUnsupported($request)) { + return $response; + } + $validated = $request->validate([ + 'workflow_types' => ['required', 'string', 'json', 'max:4096'], + ]); + $encoded = $validated['workflow_types']; + $types = json_decode($encoded, flags: JSON_THROW_ON_ERROR); + if (strlen(rawurlencode($encoded)) > 4096 || ! is_array($types) || ! array_is_list($types)) { + throw ValidationException::withMessages([ + 'workflow_types' => 'Provide a JSON list of workflow types with at most 4096 encoded bytes.', + ]); + } + foreach ($types as $type) { + if (! is_string($type) || $type === '' || mb_strlen($type) > 255) { + throw ValidationException::withMessages([ + 'workflow_types' => 'Workflow types must be nonempty strings of at most 255 characters.', + ]); + } + } + + return $this->dashboardResponse($request, includeHistoryAudits: false, workflowTypes: $types); + } + + /** @param list|null $workflowTypes */ + private function dashboardResponse(Request $request, bool $includeHistoryAudits, ?array $workflowTypes = null): JsonResponse { if ($response = ControlPlaneProtocol::rejectUnsupported($request)) { return $response; } $namespace = (string) $request->attributes->get('namespace'); - $dashboard = OperatorDashboardSummary::snapshot(null, $namespace, $includeHistoryAudits); + if ($workflowTypes === null) { + $dashboard = OperatorDashboardSummary::snapshot(null, $namespace, $includeHistoryAudits); + } else { + $observer = app(OperatorObservabilityRepository::class); + if (! method_exists($observer, 'workflowTypeDashboardSummary')) { + return ControlPlaneProtocol::json([ + 'message' => 'The installed workflow observer cannot filter dashboard totals by workflow type.', + 'reason' => 'backend_capability_unavailable', + 'capability' => 'workflow_type_dashboard', + ], 501); + } + $dashboard = $observer->workflowTypeDashboardSummary($workflowTypes, namespace: $namespace); + } if (! $includeHistoryAudits) { $dashboard['operator_metrics']['capacity_evidence'] = $this->capacityEvidence->snapshot($namespace); } diff --git a/app/Support/HistoryPageToken.php b/app/Support/HistoryPageToken.php new file mode 100644 index 00000000..faed900f --- /dev/null +++ b/app/Support/HistoryPageToken.php @@ -0,0 +1,24 @@ +activityTaskQueues($namespace, $pendingActivities); $lastEvent = $this->lastEvent($run, $includeLastEventPayload); $nextScheduledEvent = $this->nextScheduledEvent($summary, $taskRows->all()); - $recentFailures = $this->recentFailures($run); + $failureRows = WorkflowFailure::query() + ->where('workflow_run_id', $run->id) + ->latest('created_at') + ->orderByDesc('id') + ->limit(self::FAILURE_LIMIT + 1) + ->get(); + $recentFailures = $this->recentFailures($run, $failureRows->take(self::FAILURE_LIMIT)); $latestWorkflowTaskFailure = $this->latestWorkflowTaskFailure($run); $payload = [ @@ -71,6 +79,7 @@ public function forRun(string $namespace, WorkflowRun $run, bool $includeLastEve 'task_queue' => $taskQueue, 'activity_task_queues' => $activityTaskQueues, 'recent_failures' => $recentFailures, + 'recent_failures_truncated' => $failureRows->count() > self::FAILURE_LIMIT, 'latest_workflow_task_failure' => $latestWorkflowTaskFailure, 'compatibility' => $this->compatibility($namespace, $run, $summary, $taskQueue), 'cancellation_cascade_supported' => class_exists(CancellationCascadeView::class), @@ -663,28 +672,81 @@ private function activityTaskQueues(string $namespace, array $pendingActivities) } /** + * @param Collection $failures * @return list> */ - private function recentFailures(WorkflowRun $run): array + private function recentFailures(WorkflowRun $run, Collection $failures): array { - return WorkflowFailure::query() + $events = $this->failureEvents($run, $failures->pluck('id')->all()); + + return $failures + ->map(function (WorkflowFailure $failure) use ($run, $events): array { + $event = $events->get($failure->id); + + return $this->compact([ + 'failure_id' => $failure->id, + 'source_kind' => $failure->source_kind, + 'source_id' => $failure->source_id, + 'propagation_kind' => $failure->propagation_kind, + 'failure_category' => $this->enumValue($failure->failure_category), + 'exception_class' => $failure->exception_class, + 'message' => $failure->message, + 'non_retryable' => (bool) $failure->non_retryable, + 'handled' => (bool) $failure->handled, + 'created_at' => $this->timestamp($failure->created_at), + 'supporting_event' => $event instanceof WorkflowHistoryEvent ? [ + 'state' => 'retained', + 'sequence' => (int) $event->sequence, + 'event_type' => $this->enumValue($event->event_type), + 'recorded_at' => $this->timestamp($event->recorded_at), + 'next_page_token' => HistoryPageToken::encode(max(0, (int) $event->sequence - 1)), + ] : [ + 'state' => $run->details_pruned_at === null ? 'unavailable' : 'pruned', + ], + ]); + }) + ->all(); + } + + /** + * @param list $failureIds + * @return Collection + */ + private function failureEvents(WorkflowRun $run, array $failureIds): Collection + { + if ($failureIds === []) { + return collect(); + } + + // Aggregate only the requested failure identities. The result contains + // at most ten scalar rows, even when the retained history is large. + $references = WorkflowHistoryEvent::query()->toBase() ->where('workflow_run_id', $run->id) - ->latest('created_at') - ->limit(self::FAILURE_LIMIT) + ->whereIn('payload->failure_id', $failureIds) + ->whereIn('event_type', [ + HistoryEventType::ActivityFailed->value, + HistoryEventType::ActivityTimedOut->value, + HistoryEventType::ChildRunFailed->value, + HistoryEventType::ChildRunCancelled->value, + HistoryEventType::ChildRunTerminated->value, + HistoryEventType::WorkflowFailed->value, + HistoryEventType::WorkflowTimedOut->value, + HistoryEventType::WorkflowCancelled->value, + HistoryEventType::WorkflowTerminated->value, + HistoryEventType::UpdateCompleted->value, + ]) + ->select('payload->failure_id as failure_id') + ->selectRaw('MAX(sequence) as sequence') + ->groupBy('payload->failure_id') + ->get(); + $failureBySequence = $references->pluck('failure_id', 'sequence'); + + return WorkflowHistoryEvent::query() + ->where('workflow_run_id', $run->id) + ->whereIn('sequence', $failureBySequence->keys()->all()) + ->select(['id', 'workflow_run_id', 'sequence', 'event_type', 'recorded_at']) ->get() - ->map(fn (WorkflowFailure $failure): array => $this->compact([ - 'failure_id' => $failure->id, - 'source_kind' => $failure->source_kind, - 'source_id' => $failure->source_id, - 'propagation_kind' => $failure->propagation_kind, - 'failure_category' => $this->enumValue($failure->failure_category), - 'exception_class' => $failure->exception_class, - 'message' => $failure->message, - 'non_retryable' => (bool) $failure->non_retryable, - 'handled' => (bool) $failure->handled, - 'created_at' => $this->timestamp($failure->created_at), - ])) - ->all(); + ->keyBy(fn (WorkflowHistoryEvent $event): string => (string) $failureBySequence->get($event->sequence)); } /** diff --git a/composer.json b/composer.json index 3ee91354..e70ec81e 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "require": { "php": "^8.2", "apache/avro": "^1.12", - "durable-workflow/workflow": "2.4.3", + "durable-workflow/workflow": "2.4.4", "laravel/framework": "^13.30", "laravel/tinker": "^3.0", "league/flysystem-aws-s3-v3": "^3.35.3" @@ -48,7 +48,7 @@ }, "extra": { "durable-workflow": { - "product-train": "2.5.3" + "product-train": "2.5.4" }, "laravel": { "dont-discover": [] diff --git a/composer.lock b/composer.lock index 171b9797..8ae770d4 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": "0528380eb427ffc6cf5874e5b689e7b0", + "content-hash": "7a823c5be8beccb9c22053510761acf3", "packages": [ { "name": "apache/avro", @@ -655,16 +655,16 @@ }, { "name": "durable-workflow/workflow", - "version": "2.4.3", + "version": "2.4.4", "source": { "type": "git", "url": "https://github.com/durable-workflow/workflow.git", - "reference": "8fcfb002de337e341ef19e601d73e9e1424e09ec" + "reference": "95fa7029ca675cd161ecdd16683bf2dda477cb25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/8fcfb002de337e341ef19e601d73e9e1424e09ec", - "reference": "8fcfb002de337e341ef19e601d73e9e1424e09ec", + "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/95fa7029ca675cd161ecdd16683bf2dda477cb25", + "reference": "95fa7029ca675cd161ecdd16683bf2dda477cb25", "shasum": "" }, "require": { @@ -697,7 +697,7 @@ "dev-main": "2.0.x-dev" }, "durable-workflow": { - "product-train": "2.4.3", + "product-train": "2.4.4", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" } @@ -724,9 +724,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.4.3" + "source": "https://github.com/durable-workflow/workflow/tree/2.4.4" }, - "time": "2026-10-06T17:56:06+00:00" + "time": "2026-10-06T19:27:02+00:00" }, { "name": "egulias/email-validator", diff --git a/docker-compose.dedicated-matching.yml b/docker-compose.dedicated-matching.yml index 216a7e2d..0a2795db 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.5.3}} +x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.5.4}} 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.5.3}} + APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.5.4}} 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 1ada3692..827f889c 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.5.3} + APP_VERSION: ${APP_VERSION:-2.5.4} successor: image: ${DW_MEMO_SUCCESSOR_IMAGE:-durable-workflow/server-memo-rolling:local} ports: !override [] environment: <<: *runtime-environment - APP_VERSION: ${APP_VERSION:-2.5.3} + APP_VERSION: ${APP_VERSION:-2.5.4} 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 3c10d7d7..03b98f18 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.5.3}} +x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.5.4}} 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.5.3}} + APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.5.4}} 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 805e0436..39ce1870 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.5.3} + APP_VERSION: ${APP_VERSION:-2.5.4} 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 080a50ab..ef488864 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.5.3}" + APP_VERSION: "${APP_VERSION:-2.5.4}" APP_DEBUG: "false" DB_CONNECTION: mysql DB_HOST: mysql @@ -62,7 +62,7 @@ services: APP_NAME: "Durable Workflow Server" APP_ENV: local DW_SERVER_KEY: "${DW_SERVER_KEY:-}" - APP_VERSION: "${APP_VERSION:-2.5.3}" + APP_VERSION: "${APP_VERSION:-2.5.4}" APP_DEBUG: "false" DB_CONNECTION: mysql DB_HOST: mysql @@ -124,7 +124,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.5.3}" + APP_VERSION: "${APP_VERSION:-2.5.4}" DB_CONNECTION: mysql DB_HOST: mysql DB_PORT: 3306 @@ -178,7 +178,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.5.3}" + APP_VERSION: "${APP_VERSION:-2.5.4}" DB_CONNECTION: mysql DB_HOST: mysql DB_PORT: 3306 diff --git a/docs/server-reference.md b/docs/server-reference.md index 3270d947..513b01fa 100644 --- a/docs/server-reference.md +++ b/docs/server-reference.md @@ -569,6 +569,7 @@ workflow-task command payload. - `GET /api/system/metrics` — Server metrics including bounded stuck workflow-task diagnostics - `GET /api/system/operator-metrics` — Full operator metrics snapshot (runs, tasks, backlog, repair, workers/fleet, backend, structural limits) for namespace-scoped rollout-safety coordination health - `GET /api/system/operator-dashboard/bounded` returns dashboard aggregates without decoding fleet-wide histories. History-audit counts are `null` and `operator_metrics.history_audit_evaluation` is `not_requested`. Use `/api/system/operator-dashboard` or `/api/system/operator-metrics` when a full history audit is needed. Both dashboard routes require operator authentication, the control-plane version header and a namespace. +- `GET /api/system/operator-dashboard/bounded/workflow-types?workflow_types=` restricts workflow counts, trends, failures, waits and workflow alerts to the specified exact workflow types in the authenticated namespace. URL-encode the JSON array. An empty array selects no workflow types. Worker, queue and storage metrics retain their namespace scope. The response identifies both scopes and the aggregation windows in `workflow_scope`, `operator_metrics_scope` and `time_windows`. - `GET /api/system/repair` — Task repair diagnostics - `POST /api/system/repair/pass` — Run task repair sweep - `GET /api/system/activity-timeouts` — Expired activity execution diagnostics @@ -639,6 +640,14 @@ metadata by default; add `include_last_event_payload=true` to include at most a 4 KiB JSON preview. Use the history endpoints when a full replay/debug archive is needed. +Each recent failure includes a `supporting_event` reference. A retained reference +contains the failure event's sequence, type, timestamp and an opaque +`next_page_token`. Pass that token unchanged to the selected run's history +endpoint to load a page starting at the event. A missing reference reports +`pruned` when run details were reclaimed, or `unavailable` otherwise. +`recent_failures_truncated` indicates that more than ten failure records exist. +These references load event metadata without decoding the complete run history. + The candidate cooperative-cancellation runtime adds `cancellation_cascade_supported` and `cancellation_cascade` to both debug responses. A supported runtime returns `null` when the selected run has no cooperative request. An older installed diff --git a/k8s/README.md b/k8s/README.md index 17484aac..17a3925a 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.5.3 +durableworkflow/server:2.5.4 ``` 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.5.3 + server=durableworkflow/server:2.5.4 kubectl set image -n durable-workflow deploy/durable-workflow-worker \ - worker=durableworkflow/server:2.5.3 + worker=durableworkflow/server:2.5.4 kubectl set image -n durable-workflow cronjob/durable-workflow-scheduler \ - scheduler=durableworkflow/server:2.5.3 + scheduler=durableworkflow/server:2.5.4 ``` GitHub Container Registry publishes the same release line at -`ghcr.io/durable-workflow/server:2.5.3`. Digest pinning is preferred for strict +`ghcr.io/durable-workflow/server:2.5.4`. 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 10e1941d..1e8f4cb7 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.140 +version: 0.1.141 # 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.5.3" +appVersion: "2.5.4" 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.5.3" + dev.durable-workflow.image-reference: "docker.io/durableworkflow/server:2.5.4" 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 a02886b4..ed778ed0 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.5.3" + tag: "2.5.4" # 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 72711e85..24f351ce 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.5.3" + tag: "2.5.4" 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 b9142739..4bdd5bcc 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.5.3" + tag: "2.5.4" 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 201a50e3..429322c9 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.5.3" + tag: "2.5.4" externalDatabase: connection: mysql diff --git a/k8s/helm/durable-workflow/templates/_helpers.tpl b/k8s/helm/durable-workflow/templates/_helpers.tpl index d58eb020..7ba3d0ee 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.5.3" -}} +{{- if eq $normalized "docker.io/durableworkflow/server:2.5.4" -}} 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 9a5c3876..f6a0d751 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.5.3" + tag: "2.5.4" # 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 8eed021c..158eaded 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.5.3" + tag: "2.5.4" externalDatabase: connection: mysql diff --git a/k8s/helm/examples/values-external-secrets-operator.yaml b/k8s/helm/examples/values-external-secrets-operator.yaml index 98e926d0..e67827a8 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.5.3" + tag: "2.5.4" externalDatabase: connection: pgsql diff --git a/k8s/helm/examples/values-production-existing-secrets.yaml b/k8s/helm/examples/values-production-existing-secrets.yaml index 8b40fff8..1de89c3e 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.5.3" + tag: "2.5.4" externalDatabase: connection: pgsql diff --git a/k8s/migration-job.yaml b/k8s/migration-job.yaml index 6beab458..1366396a 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.5.3 + image: durableworkflow/server:2.5.4 command: ["server-entrypoint"] args: ["server-bootstrap"] envFrom: diff --git a/k8s/scheduler-cronjob.yaml b/k8s/scheduler-cronjob.yaml index 0c00949a..cbbcbbab 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.5.3 + image: durableworkflow/server:2.5.4 command: ["server-entrypoint"] args: - sh diff --git a/k8s/secret.yaml b/k8s/secret.yaml index cbecb0f9..30ae5bc6 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.5.3" + APP_VERSION: "2.5.4" APP_ENV: production APP_DEBUG: "false" DB_CONNECTION: mysql diff --git a/k8s/server-deployment.yaml b/k8s/server-deployment.yaml index 5bb827bf..b5cf9131 100644 --- a/k8s/server-deployment.yaml +++ b/k8s/server-deployment.yaml @@ -23,7 +23,7 @@ spec: spec: containers: - name: server - image: durableworkflow/server:2.5.3 + image: durableworkflow/server:2.5.4 ports: - containerPort: 8080 name: http diff --git a/k8s/worker-deployment.yaml b/k8s/worker-deployment.yaml index eb278bfd..dba4e4ce 100644 --- a/k8s/worker-deployment.yaml +++ b/k8s/worker-deployment.yaml @@ -19,7 +19,7 @@ spec: spec: containers: - name: worker - image: durableworkflow/server:2.5.3 + image: durableworkflow/server:2.5.4 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 de4d62ba..9f21f8e9 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.5.3" + "version": "2.5.4" }, "helm_chart": { - "version": "0.1.140" + "version": "0.1.141" } } diff --git a/routes/api.php b/routes/api.php index a9c08544..63a0d70d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -406,6 +406,7 @@ Route::match(['get', 'post'], '/metrics', [SystemController::class, 'metrics']); Route::get('/operator-dashboard', [SystemController::class, 'operatorDashboard']); Route::get('/operator-dashboard/bounded', [SystemController::class, 'boundedOperatorDashboard']); + Route::get('/operator-dashboard/bounded/workflow-types', [SystemController::class, 'workflowTypeOperatorDashboard']); Route::get('/operator-metrics', [SystemController::class, 'operatorMetrics']); Route::get('/prometheus-metrics', [SystemController::class, 'prometheusMetrics']); Route::get('/repair', [SystemController::class, 'repairStatus']); diff --git a/scripts/k8s-kind-smoke.sh b/scripts/k8s-kind-smoke.sh index dd104b26..d2d03213 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.5.3" +manifest_image="durableworkflow/server:2.5.4" 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/ControlPlaneVersionCoverageTest.php b/tests/Feature/ControlPlaneVersionCoverageTest.php index 2c4db9c3..526c555e 100644 --- a/tests/Feature/ControlPlaneVersionCoverageTest.php +++ b/tests/Feature/ControlPlaneVersionCoverageTest.php @@ -151,6 +151,7 @@ public static function controlPlaneEndpointProvider(): array 'system.metrics_post' => ['method' => 'post', 'path' => '/api/system/metrics'], 'system.operator_dashboard' => ['method' => 'get', 'path' => '/api/system/operator-dashboard'], 'system.bounded_operator_dashboard' => ['method' => 'get', 'path' => '/api/system/operator-dashboard/bounded'], + 'system.workflow_type_dashboard' => ['method' => 'get', 'path' => '/api/system/operator-dashboard/bounded/workflow-types'], 'system.operator_metrics' => ['method' => 'get', 'path' => '/api/system/operator-metrics'], 'system.repair_status' => ['method' => 'get', 'path' => '/api/system/repair'], 'system.repair_pass' => ['method' => 'post', 'path' => '/api/system/repair/pass'], diff --git a/tests/Feature/SystemOperatorMetricsTest.php b/tests/Feature/SystemOperatorMetricsTest.php index 39b21db9..8d86f3a4 100644 --- a/tests/Feature/SystemOperatorMetricsTest.php +++ b/tests/Feature/SystemOperatorMetricsTest.php @@ -13,9 +13,11 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; +use PHPUnit\Framework\Attributes\DataProvider; use Tests\Feature\Concerns\ServerTestHelpers; use Tests\TestCase; use Workflow\V2\Models\WorkflowRun; +use Workflow\V2\Models\WorkflowRunSummary; use Workflow\V2\Support\WorkerCompatibilityFleet; class SystemOperatorMetricsTest extends TestCase @@ -403,6 +405,87 @@ public function test_bounded_dashboard_defers_history_audits_without_claiming_ze ->assertJsonPath('dashboard.operator_metrics.projections.run_waits.needs_rebuild', 0); } + public function test_workflow_type_dashboard_filters_volume_within_the_authenticated_namespace(): void + { + foreach ([ + ['maintenance', 'maintenance.scan', 'default', 'completed'], + ['order-ok', 'orders.import', 'default', 'completed'], + ['order-fail', 'orders.import', 'default', 'failed'], + ['outside-order', 'orders.import', 'other', 'failed'], + ] as [$id, $type, $namespace, $status]) { + $identity = [ + 'id' => $id, 'workflow_instance_id' => $id, 'run_number' => 1, + 'workflow_type' => $type, 'namespace' => $namespace, 'status' => $status, + 'closed_at' => now()->subMinute(), 'created_at' => now()->subMinutes(2), + ]; + WorkflowRun::query()->create([...$identity, 'workflow_class' => 'Tests\\Fixtures\\ScopeWorkflow']); + WorkflowRunSummary::query()->create([ + ...$identity, 'class' => 'Tests\\Fixtures\\ScopeWorkflow', + 'status_bucket' => $status, 'duration_ms' => 100, + ]); + } + $path = '/api/system/operator-dashboard/bounded/workflow-types?'.http_build_query([ + 'workflow_types' => '["orders.import"]', + ], encoding_type: PHP_QUERY_RFC3986); + + $this->getJson($path, $this->controlPlaneHeadersWithWorkerProtocol()) + ->assertOk() + ->assertJsonPath('namespace', 'default') + ->assertJsonPath('dashboard.flows', 2) + ->assertJsonPath('dashboard.workflow_scope.namespace', 'default') + ->assertJsonPath('dashboard.workflow_scope.workflow_types', ['orders.import']) + ->assertJsonPath('dashboard.operator_metrics_scope.workflow_types', null) + ->assertJsonPath('dashboard.operator_metrics.runs.total', 3) + ->assertJsonPath('dashboard.operator_metrics.history_audit_evaluation', 'not_requested') + ->assertJsonPath('dashboard.fleet_overview.trends.hour.completed', 1) + ->assertJsonPath('dashboard.fleet_overview.trends.hour.failed', 1); + + $this->getJson($path, $this->controlPlaneHeadersWithWorkerProtocol('other')) + ->assertOk() + ->assertJsonPath('dashboard.flows', 1) + ->assertJsonPath('dashboard.workflow_scope.namespace', 'other'); + $this->getJson('/api/system/operator-dashboard/bounded/workflow-types?workflow_types=%5B%5D', $this->controlPlaneHeadersWithWorkerProtocol()) + ->assertOk() + ->assertJsonPath('dashboard.flows', 0) + ->assertJsonPath('dashboard.workflow_scope.workflow_types', []) + ->assertJsonPath('dashboard.operator_metrics.runs.total', 3); + $this->getJson('/api/system/operator-dashboard/bounded', $this->controlPlaneHeadersWithWorkerProtocol()) + ->assertOk() + ->assertJsonPath('dashboard.flows', 3); + } + + #[DataProvider('invalidDashboardTypeSelections')] + public function test_workflow_type_dashboard_rejects_malformed_scope_before_reading(string $encoded): void + { + $this->getJson('/api/system/operator-dashboard/bounded/workflow-types?'.http_build_query([ + 'workflow_types' => $encoded, + ], encoding_type: PHP_QUERY_RFC3986), $this->controlPlaneHeadersWithWorkerProtocol()) + ->assertUnprocessable() + ->assertJsonPath('reason', 'validation_failed') + ->assertJsonValidationErrors('workflow_types'); + } + + public static function invalidDashboardTypeSelections(): array + { + return [ + 'malformed json' => ['['], + 'object' => ['{}'], + 'scalar' => ['"orders.import"'], + 'null' => ['null'], + 'empty type' => ['[""]'], + 'non-string type' => ['[1]'], + 'oversized type' => [json_encode([str_repeat('x', 256)], JSON_THROW_ON_ERROR)], + 'oversized encoded query' => [json_encode([str_repeat('/', 1500)], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)], + ]; + } + + public function test_workflow_type_dashboard_requires_control_plane_version_before_scope_validation(): void + { + $this->getJson('/api/system/operator-dashboard/bounded/workflow-types', ['X-Namespace' => 'default']) + ->assertStatus(400) + ->assertJsonPath('reason', 'missing_control_plane_version'); + } + public function test_bounded_dashboard_requires_control_plane_version_header(): void { $this->getJson('/api/system/operator-dashboard/bounded', [ diff --git a/tests/Feature/WorkflowDebugTest.php b/tests/Feature/WorkflowDebugTest.php index 4826cde8..0a5e61c9 100644 --- a/tests/Feature/WorkflowDebugTest.php +++ b/tests/Feature/WorkflowDebugTest.php @@ -79,6 +79,104 @@ public static function recoveredTerminalOutcomes(): array return ['completed' => ['completed'], 'cancelled' => ['cancelled']]; } + public function test_recent_failures_link_to_their_retained_history_without_loading_the_history(): void + { + [$runId] = $this->diagnosticTask('debug-failure-reference'); + $failures = []; + + for ($index = 0; $index < 12; $index++) { + $failure = WorkflowFailure::query()->create([ + 'workflow_run_id' => $runId, + 'source_kind' => 'activity', + 'source_id' => 'activity-'.$index, + 'propagation_kind' => 'workflow', + 'failure_category' => FailureCategory::TaskFailure->value, + 'non_retryable' => false, + 'handled' => true, + 'exception_class' => 'RuntimeException', + 'message' => 'Activity failed.', + 'file' => __FILE__, + 'line' => __LINE__, + 'created_at' => now()->addSeconds($index), + ]); + $failures[] = $failure; + WorkflowHistoryEvent::query()->create([ + 'workflow_run_id' => $runId, + 'sequence' => 1000 + $index, + 'event_type' => HistoryEventType::ActivityFailed, + 'payload' => ['failure_id' => $failure->id, 'private_detail' => 'do not include in diagnostics'], + 'recorded_at' => now(), + ]); + WorkflowHistoryEvent::query()->create([ + 'workflow_run_id' => $runId, + 'sequence' => 2000 + $index, + 'event_type' => HistoryEventType::FailureHandled, + 'payload' => ['failure_id' => $failure->id], + 'recorded_at' => now(), + ]); + } + + for ($index = 0; $index < 500; $index++) { + WorkflowHistoryEvent::query()->create([ + 'workflow_run_id' => $runId, + 'sequence' => 3000 + $index, + 'event_type' => HistoryEventType::SignalReceived, + 'payload' => [], + 'recorded_at' => now(), + ]); + } + + $hydratedEvents = 0; + WorkflowHistoryEvent::retrieved(static function () use (&$hydratedEvents): void { + $hydratedEvents++; + }); + $response = $this->getJson('/api/workflows/debug-failure-reference/debug', $this->controlPlaneHeadersWithWorkerProtocol()) + ->assertOk() + ->assertJsonCount(10, 'recent_failures') + ->assertJsonPath('recent_failures_truncated', true) + ->assertJsonPath('recent_failures.0.failure_id', $failures[11]->id) + ->assertJsonPath('recent_failures.0.supporting_event.state', 'retained') + ->assertJsonPath('recent_failures.0.supporting_event.sequence', 1011) + ->assertJsonPath('recent_failures.0.supporting_event.event_type', 'ActivityFailed') + ->assertJsonMissing(['private_detail' => 'do not include in diagnostics']); + $this->assertLessThanOrEqual(12, $hydratedEvents); + + $token = rawurlencode($response->json('recent_failures.0.supporting_event.next_page_token')); + $this->getJson("/api/workflows/debug-failure-reference/runs/{$runId}/history?page_size=1&next_page_token={$token}", $this->controlPlaneHeadersWithWorkerProtocol()) + ->assertOk() + ->assertJsonPath('events.0.sequence', 1011) + ->assertJsonPath('events.0.event_type', 'ActivityFailed') + ->assertJsonPath('events.0.payload.failure_id', $failures[11]->id); + } + + public function test_missing_failure_history_is_explicitly_unavailable_or_pruned(): void + { + [$runId] = $this->diagnosticTask('debug-missing-failure-reference'); + WorkflowFailure::query()->create([ + 'workflow_run_id' => $runId, + 'source_kind' => 'activity', + 'source_id' => 'activity-missing', + 'propagation_kind' => 'workflow', + 'failure_category' => FailureCategory::TaskFailure->value, + 'non_retryable' => false, + 'handled' => false, + 'exception_class' => 'RuntimeException', + 'message' => 'No supporting event retained.', + 'file' => __FILE__, + 'line' => __LINE__, + ]); + + $this->getJson('/api/workflows/debug-missing-failure-reference/debug', $this->controlPlaneHeadersWithWorkerProtocol()) + ->assertOk() + ->assertJsonPath('recent_failures_truncated', false) + ->assertJsonPath('recent_failures.0.supporting_event', ['state' => 'unavailable']); + + WorkflowRun::query()->findOrFail($runId)->forceFill(['details_pruned_at' => now()])->save(); + $this->getJson('/api/workflows/debug-missing-failure-reference/debug', $this->controlPlaneHeadersWithWorkerProtocol()) + ->assertOk() + ->assertJsonPath('recent_failures.0.supporting_event', ['state' => 'pruned']); + } + public function test_active_replay_failure_keeps_its_warning_and_error(): void { $workflowId = 'debug-active-replay';