From 49cfc1bd2bb127839d6e4fd6d08378d11e042bfd Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Wed, 14 Oct 2026 19:35:24 +0000 Subject: [PATCH 1/4] Report lifetime history-cache counters in worker shutdown diagnostics --- docs/sdk-reference.md | 6 ++++ src/Worker.php | 1 + tests/PortableWorkerAffinityTest.php | 51 +++++++++++++++++++++++++++- tests/compat/history-cache.php | 16 +++++++-- tests/compat/laravel.php | 13 ++++++- tests/compat/symfony.php | 11 +++++- 6 files changed, 93 insertions(+), 5 deletions(-) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 081834ab..0505e645 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -462,6 +462,12 @@ retry, shutdown, and handler failures then use the application's normal logging pipeline. The optional `diagnosticListener` receives the same event names and structured context. +The `worker.stopped` context includes `sticky_cache` counters for `hit`, `miss`, +`eviction` and `forced_cold_replay` over that worker process's lifetime. Clearing +retained history during shutdown preserves these counters. Laravel and Symfony +forward the same context through `WorkerDiagnosticEvent`. A cache hit still +replays the workflow; these counters do not measure retained live execution. + Signal methods are signature declarations for server admission and are not invoked. The workflow reads their committed values with `$context->signals('set-language')` during replay. Query and update methods are diff --git a/src/Worker.php b/src/Worker.php index 4591ea05..ac4eae7d 100644 --- a/src/Worker.php +++ b/src/Worker.php @@ -399,6 +399,7 @@ public function run(int $pollTimeoutSeconds = 5): void $this->diagnostic('worker.stopped', [ 'worker_id' => $this->workerId, 'task_queue' => $this->taskQueue, + 'sticky_cache' => $this->stickyCacheMetrics(), ]); } } diff --git a/tests/PortableWorkerAffinityTest.php b/tests/PortableWorkerAffinityTest.php index ee146ddc..10bd391c 100644 --- a/tests/PortableWorkerAffinityTest.php +++ b/tests/PortableWorkerAffinityTest.php @@ -1071,6 +1071,35 @@ public function testWorkerFactoryCacheConfigurationExpiresAndFetchesAuthoritativ self::assertSame(1, $outcome['metrics']['eviction']); } + public function testStoppedDiagnosticAndLogPreserveActualCacheCountersAfterCleanup(): void + { + $diagnostics = []; + $logger = new class extends \Psr\Log\AbstractLogger { + /** @var array> */ + public array $contexts = []; + + public function log($level, $message, array $context = []): void + { + $this->contexts[(string) $message] = $context; + } + }; + $outcome = $this->runStickyColdReplayScenario( + 'diagnostics', + logger: $logger, + diagnosticListener: static function (string $name, array $context) use (&$diagnostics): void { + $diagnostics[$name] = $context; + }, + afterPolling: static fn (Worker $worker) => $worker->run(0), + ); + + self::assertSame(['hit' => 1, 'miss' => 3, 'eviction' => 2, 'forced_cold_replay' => 1], $outcome['metrics']); + self::assertSame(1, $outcome['history_fetches']); + self::assertSame($outcome['initial_commands'], $outcome['fallback_commands']); + self::assertSame($outcome['metrics'], $diagnostics['worker.stopped']['sticky_cache'] ?? null); + self::assertSame($diagnostics['worker.stopped'], $logger->contexts['worker.stopped'] ?? null); + self::assertSame(['worker_id', 'task_queue', 'sticky_cache'], array_keys($diagnostics['worker.stopped'])); + } + public function testStickyCacheNeverPromotesAnUnreplayableRuntimeSuffix(): void { $cache = new StickyWorkflowCache(capacity: 1, ttlSeconds: 300); @@ -1806,6 +1835,9 @@ private function runStickyColdReplayScenario( string $scenario, bool $acceptedStart = false, bool $useFactory = false, + ?\Psr\Log\LoggerInterface $logger = null, + ?\Closure $diagnosticListener = null, + ?\Closure $afterPolling = null, ): array { $codec = new AvroPayloadCodec(); @@ -1846,6 +1878,12 @@ private function runStickyColdReplayScenario( 'sticky_replay_mode' => $replayMode, ]; $tasks = match ($scenario) { + 'diagnostics' => [ + $task('target-initial', 'target-workflow', 'target-run', $fullHistory, 'cold_replay'), + $task('target-hit', 'target-workflow', 'target-run', [], 'sticky_hit_expected'), + $task('other-initial', 'other-workflow', 'other-run', $fullHistory, 'cold_replay'), + $task('target-fallback', 'target-workflow', 'target-run', [], 'sticky_hit_expected'), + ], 'eviction' => [ $task('target-initial', 'target-workflow', 'target-run', $fullHistory, 'cold_replay'), $task('other-initial', 'other-workflow', 'other-run', $fullHistory, 'cold_replay'), @@ -1869,8 +1907,14 @@ private function runStickyColdReplayScenario( array $_headers, ?array $body, ) use (&$tasks, &$commands, &$historyFetches, $fullHistory, $useFactory, $factoryTtlSeconds): array { + if (str_ends_with($uri, '/api/worker/register')) { + return ['registered' => true, 'heartbeat_interval_seconds' => 10]; + } if (str_ends_with($uri, '/api/worker/workflow-tasks/poll')) { $next = array_shift($tasks); + if ($next === null) { + return ['task' => null, 'poll_status' => 'stopped', 'reason' => 'worker_stopped']; + } return ['poll_status' => 'leased', 'task' => $next]; } @@ -1918,6 +1962,8 @@ private function runStickyColdReplayScenario( 'sticky-workers', stickyCacheCapacity: 1, stickyCacheTtlSeconds: $factoryTtlSeconds, + logger: $logger, + diagnosticListener: $diagnosticListener, ) : new Worker( $client, 'sticky-workers', @@ -1926,8 +1972,10 @@ private function runStickyColdReplayScenario( clock: static function () use (&$now): float { return $now; }, - stickyCacheCapacity: $scenario === 'eviction' ? 1 : 10, + stickyCacheCapacity: in_array($scenario, ['eviction', 'diagnostics'], true) ? 1 : 10, stickyCacheTtlSeconds: $scenario === 'expiry' ? 1 : 300, + logger: $logger, + diagnosticListener: $diagnosticListener, ); $worker->registerWorkflow( 'sticky.workflow', @@ -1951,6 +1999,7 @@ private function runStickyColdReplayScenario( } } } + $afterPolling?->__invoke($worker); $initial = $commands['target-initial']['commands'] ?? null; $fallback = $commands['target-fallback']['commands'] ?? null; diff --git a/tests/compat/history-cache.php b/tests/compat/history-cache.php index 3c643de7..4ce9ab8d 100644 --- a/tests/compat/history-cache.php +++ b/tests/compat/history-cache.php @@ -17,8 +17,14 @@ final class ConfiguredHistoryCacheTransport implements Transport public function send(string $method, string $uri, array $headers, ?array $body = null): array { $history = [['event_type' => 'WorkflowStarted', 'payload' => ['sequence' => 0]]]; + if (str_ends_with($uri, '/worker/register')) { + return ['registered' => true, 'heartbeat_interval_seconds' => 10]; + } if (str_ends_with($uri, '/workflow-tasks/poll')) { ++$this->polls; + if ($this->polls > 3) { + return ['poll_status' => 'stopped', 'reason' => 'worker_stopped', 'task' => null]; + } $identity = $this->polls === 2 ? 'other' : 'original'; return ['poll_status' => 'leased', 'task' => [ @@ -63,8 +69,11 @@ public function send(string $method, string $uri, array $headers, ?array $body = } } -/** @param Closure(Transport): Worker $makeWorker */ -function verifyConfiguredHistoryCache(Closure $makeWorker): void +/** + * @param Closure(Transport): Worker $makeWorker + * @return array{hit: int, miss: int, eviction: int, forced_cold_replay: int} + */ +function verifyConfiguredHistoryCache(Closure $makeWorker): array { $transport = new ConfiguredHistoryCacheTransport(); $worker = $makeWorker($transport); @@ -72,9 +81,12 @@ function verifyConfiguredHistoryCache(Closure $makeWorker): void for ($index = 0; $index < 12 && $transport->completions < 3; ++$index) { $worker->tick(0); } + $worker->run(0); $metrics = $worker->stickyCacheMetrics(); if ($transport->historyFetches !== 1 || $transport->completions !== 3 || $metrics['eviction'] < 1 || $metrics['forced_cold_replay'] !== 1) { throw new RuntimeException('Configured capacity did not evict and fetch authoritative cold history.'); } + + return $metrics; } diff --git a/tests/compat/laravel.php b/tests/compat/laravel.php index 0b21df63..11ebae38 100644 --- a/tests/compat/laravel.php +++ b/tests/compat/laravel.php @@ -258,7 +258,14 @@ function laravelAuthenticationRejectsRole(Authentication $authentication, bool $ throw new RuntimeException('Laravel provider did not preserve role-specific client credentials.'); } $worker = $factory->make(); -verifyConfiguredHistoryCache(static function (Transport $transport) use ($app, $logger): DurableWorkflow\Worker { +$cacheStoppedContext = null; +$cacheEvents = new Dispatcher($app); +$cacheEvents->listen(WorkerDiagnosticEvent::class, static function (WorkerDiagnosticEvent $event) use (&$cacheStoppedContext): void { + if ($event->name === 'worker.stopped') { + $cacheStoppedContext = $event->context; + } +}); +$cacheMetrics = verifyConfiguredHistoryCache(static function (Transport $transport) use ($app, $logger, $cacheEvents): DurableWorkflow\Worker { $configuration = $app->make(DurableWorkflow\Bridge\ServiceConfiguration::class); return (new WorkerFactory( @@ -266,8 +273,12 @@ function laravelAuthenticationRejectsRole(Authentication $authentication, bool $ $configuration, $configuration->workerClient($transport), $logger, + $cacheEvents, ))->make(); }); +if (($cacheStoppedContext['sticky_cache'] ?? null) !== $cacheMetrics) { + throw new RuntimeException('Laravel stopped event omitted the actual lifetime history-cache counters.'); +} if ($worker->contracts()['workflows'] !== ['laravel.greeting', 'laravel.compensated-trip'] || $worker->contracts()['activities'] !== [ 'laravel.greet', diff --git a/tests/compat/symfony.php b/tests/compat/symfony.php index 1d0eda3f..bf7fb726 100644 --- a/tests/compat/symfony.php +++ b/tests/compat/symfony.php @@ -174,7 +174,12 @@ public function fake(): WorkflowClientFake throw new RuntimeException('Symfony extension did not preserve role-specific client credentials.'); } $worker = $factory->make(); -verifyConfiguredHistoryCache(static function (Transport $transport): DurableWorkflow\Worker { +$cacheStoppedContext = null; +$cacheEvents = new EventDispatcher(); +$cacheEvents->addListener('worker.stopped', static function (WorkerDiagnosticEvent $event) use (&$cacheStoppedContext): void { + $cacheStoppedContext = $event->context; +}); +$cacheMetrics = verifyConfiguredHistoryCache(static function (Transport $transport) use ($cacheEvents): DurableWorkflow\Worker { $configuration = DurableWorkflow\Bridge\ServiceConfiguration::fromArray([ 'endpoint' => 'http://localhost:8080', 'task_queue' => 'symfony-workers', @@ -185,8 +190,12 @@ public function fake(): WorkflowClientFake $configuration, $configuration->workerClient($transport), [new SymfonyGreetingHandler(new SymfonyGreetingDependency())], + events: $cacheEvents, ))->make(); }); +if (($cacheStoppedContext['sticky_cache'] ?? null) !== $cacheMetrics) { + throw new RuntimeException('Symfony stopped event omitted the actual lifetime history-cache counters.'); +} if ($worker->contracts()['workflows'] !== ['symfony.greeting'] || $worker->contracts()['activities'] !== ['symfony.greet'] ) { From 11cbdb53d17301be41042c5105260e4290416d30 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Wed, 14 Oct 2026 19:37:46 +0000 Subject: [PATCH 2/4] Isolate the cache lifecycle fixture logger --- tests/compat/laravel.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/compat/laravel.php b/tests/compat/laravel.php index 11ebae38..91f65c61 100644 --- a/tests/compat/laravel.php +++ b/tests/compat/laravel.php @@ -272,7 +272,7 @@ function laravelAuthenticationRejectsRole(Authentication $authentication, bool $ $app, $configuration, $configuration->workerClient($transport), - $logger, + new LaravelBridgeLogger(), $cacheEvents, ))->make(); }); From cfb2a32c99a88d2d3155c94fd9d79a157da8acf6 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Wed, 14 Oct 2026 19:39:37 +0000 Subject: [PATCH 3/4] Prepare compatible PHP SDK 2.4.1 cache diagnostics --- CHANGELOG.md | 8 ++++++++ composer.json | 2 +- docs/quickstart-contract.json | 4 ++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f9ecee5..6644449c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ project follows [Semantic Versioning](https://semver.org/). before acquiring more work. Task and cancellation authority deadlines retain their original bounds. +## [2.4.1] - 2026-10-14 + +### Fixed + +- Include actual lifetime history-cache counters in the worker's shutdown + diagnostic. Logs and Laravel/Symfony events preserve those counters after + retained history is cleared. + ## [2.4.0] - 2026-10-14 ### Added diff --git a/composer.json b/composer.json index ce50b632..63292ef6 100644 --- a/composer.json +++ b/composer.json @@ -82,7 +82,7 @@ } }, "durable-workflow": { - "product-train": "2.4.0", + "product-train": "2.4.1", "supported-server-versions": "2.5.0", "worker-protocol-version": "1.20", "control-plane-version": "2", diff --git a/docs/quickstart-contract.json b/docs/quickstart-contract.json index 9a878c4b..504a0519 100644 --- a/docs/quickstart-contract.json +++ b/docs/quickstart-contract.json @@ -3,8 +3,8 @@ "schema_version": 2, "package": { "name": "durable-workflow/sdk", - "published_version": "2.4.0", - "composer_requirement": "2.4.0", + "published_version": "2.4.1", + "composer_requirement": "2.4.1", "onboarding_requirement": "^2.4" }, "runtime_targets": { From 2d33bd2f005adc65bc10dd9a8836c06a80bed470 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Wed, 14 Oct 2026 19:40:03 +0000 Subject: [PATCH 4/4] Remove the unused shared logger capture --- tests/compat/laravel.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/compat/laravel.php b/tests/compat/laravel.php index 91f65c61..22f7cf58 100644 --- a/tests/compat/laravel.php +++ b/tests/compat/laravel.php @@ -265,7 +265,7 @@ function laravelAuthenticationRejectsRole(Authentication $authentication, bool $ $cacheStoppedContext = $event->context; } }); -$cacheMetrics = verifyConfiguredHistoryCache(static function (Transport $transport) use ($app, $logger, $cacheEvents): DurableWorkflow\Worker { +$cacheMetrics = verifyConfiguredHistoryCache(static function (Transport $transport) use ($app, $cacheEvents): DurableWorkflow\Worker { $configuration = $app->make(DurableWorkflow\Bridge\ServiceConfiguration::class); return (new WorkerFactory(