From e7213e81af2457a5697a548946bd7a55bd2f971c Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Wed, 14 Oct 2026 09:54:37 +0000 Subject: [PATCH 01/77] Prepare bounded optional trace carrier with real API controls --- composer.json | 4 +- src/V2/Support/TraceCarrier.php | 127 ++++++++++++++++++++ tests/Unit/V2/TraceCarrierTest.php | 187 +++++++++++++++++++++++++++++ 3 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 src/V2/Support/TraceCarrier.php create mode 100644 tests/Unit/V2/TraceCarrierTest.php diff --git a/composer.json b/composer.json index f943b44f5..b70247bf8 100644 --- a/composer.json +++ b/composer.json @@ -67,12 +67,14 @@ }, "require-dev": { "doctrine/dbal": "^3.7", + "open-telemetry/api": "^1.10", "orchestra/testbench": "^8.0|^11.0", "phpstan/phpstan": "^2.0", "symplify/easy-coding-standard": "^11.0" }, "suggest": { - "durable-workflow/sdk": "Framework-neutral client and remote-worker SDK for the standalone Durable Workflow server." + "durable-workflow/sdk": "Framework-neutral client and remote-worker SDK for the standalone Durable Workflow server.", + "open-telemetry/api": "Optional application-owned trace propagation API." }, "extra": { "branch-alias": { diff --git a/src/V2/Support/TraceCarrier.php b/src/V2/Support/TraceCarrier.php new file mode 100644 index 000000000..bc798aa3e --- /dev/null +++ b/src/V2/Support/TraceCarrier.php @@ -0,0 +1,127 @@ + 256, + 'tracestate' => 512, + 'baggage' => 2048, + ]; + + /** + * @param list $baggageAllowlist + * @return array{schema: string, version: int, carrier: array}|null + */ + public static function capture(array $baggageAllowlist = []): ?array + { + if (! class_exists(Globals::class)) { + return null; + } + + try { + $carrier = []; + Globals::propagator()->inject($carrier); + + // Applications may also inject unrelated propagation formats. + $carrier = array_intersect_key($carrier, self::LIMITS); + + return self::normalize([ + 'schema' => self::SCHEMA, + 'version' => 1, + 'carrier' => $carrier, + ], $baggageAllowlist); + } catch (Throwable) { + return null; + } + } + + /** + * @param list $baggageAllowlist + * @return array{schema: string, version: int, carrier: array}|null + */ + public static function normalize(mixed $value, array $baggageAllowlist = []): ?array + { + if (! is_array($value) || ($value['schema'] ?? null) !== self::SCHEMA + || ($value['version'] ?? null) !== 1 || ! is_array($value['carrier'] ?? null) + || array_diff(array_keys($value), ['schema', 'version', 'carrier']) !== [] + || ! class_exists(TraceContextPropagator::class)) { + return null; + } + + $carrier = $value['carrier']; + if (! isset($carrier['traceparent']) || array_diff(array_keys($carrier), array_keys(self::LIMITS)) !== []) { + return null; + } + foreach ($carrier as $key => $header) { + if (! is_string($header) || strlen($header) > self::LIMITS[$key]) { + return null; + } + } + + try { + if (strlen(json_encode($value, JSON_THROW_ON_ERROR)) > 4096) { + return null; + } + + // Invalid metadata must never fall back to a worker's ambient span. + $context = TraceContextPropagator::getInstance()->extract($carrier, context: Context::getRoot()); + if (! Span::fromContext($context)->getContext()->isValid()) { + return null; + } + + $normalized = []; + TraceContextPropagator::getInstance()->inject($normalized, context: $context); + if ($baggageAllowlist !== [] && isset($carrier['baggage'])) { + $baggageContext = BaggagePropagator::getInstance()->extract($carrier, context: Context::getRoot()); + $builder = Baggage::getBuilder(); + $count = 0; + foreach (Baggage::fromContext($baggageContext)->getAll() as $key => $entry) { + if (! in_array($key, $baggageAllowlist, true)) { + continue; + } + if (++$count > 32) { + return null; + } + $builder->set($key, $entry->getValue(), $entry->getMetadata()); + } + BaggagePropagator::getInstance()->inject( + $normalized, + context: $builder->build() + ->storeInContext(Context::getRoot()), + ); + } + + if (isset($normalized['baggage']) && strlen($normalized['baggage']) > self::LIMITS['baggage']) { + return null; + } + $result = [ + 'schema' => self::SCHEMA, + 'version' => 1, + 'carrier' => $normalized, + ]; + + return strlen(json_encode($result, JSON_THROW_ON_ERROR)) <= 4096 ? $result : null; + } catch (Throwable) { + return null; + } + } +} diff --git a/tests/Unit/V2/TraceCarrierTest.php b/tests/Unit/V2/TraceCarrierTest.php new file mode 100644 index 000000000..710d6a1c5 --- /dev/null +++ b/tests/Unit/V2/TraceCarrierTest.php @@ -0,0 +1,187 @@ + self::PARENT, + 'tracestate' => 'vendor=opaque', + ]); + $this->assertSame($input, TraceCarrier::normalize($input)); + } + + public function testBaggageIsAbsentUnlessExplicitlyAllowlisted(): void + { + $input = self::envelope([ + 'traceparent' => self::PARENT, + 'baggage' => 'approved=hello%20world;property=yes,private=secret', + ]); + $this->assertSame(self::envelope([ + 'traceparent' => self::PARENT, + ]), TraceCarrier::normalize($input)); + $result = TraceCarrier::normalize($input, ['approved']); + $this->assertNotNull($result); + $context = BaggagePropagator::getInstance()->extract($result['carrier'], context: Context::getRoot()); + $baggage = Baggage::fromContext($context); + $this->assertSame('hello world', $baggage->getValue('approved')); + $this->assertSame('property=yes', $baggage->getEntry('approved')?->getMetadata()->getValue()); + $this->assertNull($baggage->getEntry('private')); + $this->assertSame($result, TraceCarrier::normalize($result, ['approved'])); + } + + public function testExactlyThirtyTwoBaggageEntriesAreAllowed(): void + { + $keys = array_map(static fn (int $i): string => 'key' . $i, range(1, 33)); + $header = implode(',', array_map(static fn (string $key): string => $key . '=value', $keys)); + $input = self::envelope([ + 'traceparent' => self::PARENT, + 'baggage' => $header, + ]); + $this->assertNotNull(TraceCarrier::normalize($input, array_slice($keys, 0, 32))); + $this->assertNull(TraceCarrier::normalize($input, $keys)); + } + + public function testCaptureUsesTheApplicationPropagatorAndRestoresItsScope(): void + { + $initial = Context::getCurrent(); + $configured = Configurator::create()->withPropagator(new MultiTextMapPropagator([ + TraceContextPropagator::getInstance(), + BaggagePropagator::getInstance(), + ]))->activate(); + $parent = TraceContextPropagator::getInstance()->extract([ + 'traceparent' => self::PARENT, + ]); + $parent = Baggage::getBuilder()->set('approved', 'value')->set('private', 'secret')->build()->storeInContext( + $parent + ); + $scope = $parent->activate(); + try { + $result = TraceCarrier::capture(['approved']); + $this->assertNotNull($result); + $this->assertSame(self::PARENT, $result['carrier']['traceparent']); + $this->assertSame('approved=value', $result['carrier']['baggage']); + $this->assertSame($parent, Context::getCurrent()); + $this->assertNull(TraceCarrier::normalize(self::envelope([ + 'traceparent' => 'invalid', + ]))); + $this->assertSame($parent, Context::getCurrent()); + $this->assertTrue(Span::getCurrent()->getContext()->isValid()); + } finally { + $scope->detach(); + $configured->detach(); + } + $this->assertSame($initial, Context::getCurrent()); + } + + #[DataProvider('invalidCarriers')] + public function testInvalidTelemetryIsDiscarded(mixed $input): void + { + $this->assertNull(TraceCarrier::normalize($input, ['approved'])); + } + + public static function invalidCarriers(): array + { + return [ + 'absent' => [null], + 'string' => ['invalid'], + 'unknown schema' => [[ + 'schema' => 'other', + 'version' => 1, + 'carrier' => [ + 'traceparent' => self::PARENT, + ], + ]], + 'future version' => [[ + 'schema' => 'durable-workflow.trace-context', + 'version' => 2, + 'carrier' => [ + 'traceparent' => self::PARENT, + ], + ]], + 'string version' => [[ + 'schema' => 'durable-workflow.trace-context', + 'version' => '1', + 'carrier' => [ + 'traceparent' => self::PARENT, + ], + ]], + 'unknown metadata' => [ + self::envelope([ + 'traceparent' => self::PARENT, + ]) + [ + 'input' => 'private', + ]], + 'missing traceparent' => [ + self::envelope([ + 'tracestate' => 'vendor=value', + ])], + 'malformed traceparent' => [ + self::envelope([ + 'traceparent' => 'invalid', + ])], + 'zero trace identity' => [ + self::envelope([ + 'traceparent' => '00-00000000000000000000000000000000-00f067aa0ba902b7-01', + ])], + 'zero span identity' => [ + self::envelope([ + 'traceparent' => '00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01', + ])], + 'oversized traceparent' => [ + self::envelope([ + 'traceparent' => str_repeat('a', 257), + ])], + 'oversized tracestate' => [ + self::envelope([ + 'traceparent' => self::PARENT, + 'tracestate' => str_repeat('a', 513), + ])], + 'oversized baggage' => [ + self::envelope([ + 'traceparent' => self::PARENT, + 'baggage' => str_repeat('a', 2049), + ])], + 'array header' => [ + self::envelope([ + 'traceparent' => [self::PARENT], + ])], + 'unknown header' => [ + self::envelope([ + 'traceparent' => self::PARENT, + 'authorization' => 'private', + ])], + 'invalid utf8' => [ + self::envelope([ + 'traceparent' => self::PARENT, + 'baggage' => "approved=\xff", + ])], + ]; + } + + private static function envelope(array $carrier): array + { + return [ + 'schema' => 'durable-workflow.trace-context', + 'version' => 1, + 'carrier' => $carrier, + ]; + } +} From 2966be7a18de875d43c7f96a4fd15ae4953c0372 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 11:56:43 +0000 Subject: [PATCH 02/77] Add optional activity tracing scopes and logging correlation --- composer.json | 1 + src/V2/Jobs/RunActivityTask.php | 35 +++ src/V2/Support/ExecutionTracing.php | 156 ++++++++++ src/V2/Tracing/LogContext.php | 46 +++ src/config/workflows.php | 5 + .../Unit/V2/ActivityExecutionTracingTest.php | 211 +++++++++++++ tests/Unit/V2/ExecutionTracingTest.php | 292 ++++++++++++++++++ 7 files changed, 746 insertions(+) create mode 100644 src/V2/Support/ExecutionTracing.php create mode 100644 src/V2/Tracing/LogContext.php create mode 100644 tests/Unit/V2/ActivityExecutionTracingTest.php create mode 100644 tests/Unit/V2/ExecutionTracingTest.php diff --git a/composer.json b/composer.json index b70247bf8..1d8f7d836 100644 --- a/composer.json +++ b/composer.json @@ -68,6 +68,7 @@ "require-dev": { "doctrine/dbal": "^3.7", "open-telemetry/api": "^1.10", + "open-telemetry/sdk": "^1.0", "orchestra/testbench": "^8.0|^11.0", "phpstan/phpstan": "^2.0", "symplify/easy-coding-standard": "^11.0" diff --git a/src/V2/Jobs/RunActivityTask.php b/src/V2/Jobs/RunActivityTask.php index 20a8c9b1d..b839d3d71 100644 --- a/src/V2/Jobs/RunActivityTask.php +++ b/src/V2/Jobs/RunActivityTask.php @@ -17,6 +17,7 @@ use Workflow\V2\Support\ActivityTaskClaim; use Workflow\V2\Support\ActivityTaskClaimer; use Workflow\V2\Support\EntryMethod; +use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\RuntimeObjectFactory; use Workflow\V2\Support\TaskDispatcher; use Workflow\V2\Support\TypeRegistry; @@ -63,6 +64,37 @@ public function handle(): void return; } + if (config('workflows.tracing.enabled', false) !== true) { + $this->executeClaim($claim); + + return; + } + + $allowlist = config('workflows.tracing.baggage_allowlist', []); + $storedContext = array_key_exists('observability_context', $claim->run->getAttributes()) + ? $claim->run->getAttribute('observability_context') + : null; + ExecutionTracing::activity( + [ + 'workflow_instance_id' => $claim->run->workflow_instance_id, + 'workflow_run_id' => $claim->run->id, + 'workflow_type' => $claim->run->workflow_type, + 'namespace' => $claim->run->namespace, + 'task_id' => $claim->task->id, + 'activity_id' => $claim->execution->id, + 'activity_type' => $claim->execution->activity_type, + 'activity_attempt_id' => $claim->attemptId(), + 'activity_attempt_number' => $claim->attemptNumber(), + ], + $storedContext, + fn () => $this->executeClaim($claim), + is_array($allowlist) ? $allowlist : [], + true, + ); + } + + private function executeClaim(ActivityTaskClaim $claim): void + { $attemptId = $claim->attemptId(); $attemptCount = $claim->attemptNumber(); @@ -107,6 +139,9 @@ public function handle(): void } $maxAttempts = ActivityRetryPolicy::maxAttempts($execution, $activity); + if ($throwable !== null && config('workflows.tracing.enabled', false) === true) { + ExecutionTracing::recordFailure($throwable); + } $backoffSeconds = ActivityRetryPolicy::backoffSeconds($execution, $activity, $attemptCount); $outcome = ActivityOutcomeRecorder::record( diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php new file mode 100644 index 000000000..f4655213f --- /dev/null +++ b/src/V2/Support/ExecutionTracing.php @@ -0,0 +1,156 @@ + $attributes + * @param list $baggageAllowlist + */ + public static function activity( + array $attributes, + mixed $persistedContext, + callable $operation, + array $baggageAllowlist = [], + bool $enabled = false, + ): mixed { + if (! $enabled || ! class_exists(Globals::class)) { + return $operation(); + } + + $span = null; + $scope = null; + try { + // A queue worker's ambient trace is not the run's causal authority. + $parent = Context::getRoot(); + $carrier = TraceCarrier::normalize($persistedContext, $baggageAllowlist); + if ($carrier !== null) { + $parent = TraceContextPropagator::getInstance()->extract($carrier['carrier'], context: $parent); + $parent = BaggagePropagator::getInstance()->extract($carrier['carrier'], context: $parent); + } + // Preserve application providers without inheriting unrelated worker context. + $parent = Configurator::create() + ->withTracerProvider(Globals::tracerProvider()) + ->withMeterProvider(Globals::meterProvider()) + ->withLoggerProvider(Globals::loggerProvider()) + ->withPropagator(Globals::propagator()) + ->withResponsePropagator(Globals::responsePropagator()) + ->storeInContext($parent); + $fields = self::fields($attributes); + $version = InstalledVersions::isInstalled('durable-workflow/workflow') + ? InstalledVersions::getPrettyVersion('durable-workflow/workflow') + : null; + $span = Globals::tracerProvider()->getTracer('durable-workflow/workflow', $version) + ->spanBuilder('durable.activity.attempt') + ->setSpanKind(SpanKind::KIND_CONSUMER) + ->setParent($parent) + ->setAttributes($fields) + ->startSpan(); + if ($span->getContext()->isValid()) { + $fields['trace_id'] = $span->getContext()->getTraceId(); + $fields['span_id'] = $span->getContext()->getSpanId(); + } + self::$correlationKey ??= Context::createKey('durable-workflow.correlation'); + $scope = $span->storeInContext($parent) + ->with(self::$correlationKey, $fields)->activate(); + } catch (Throwable) { + self::close($scope, $span); + + return $operation(); + } + + try { + return $operation(); + } catch (Throwable $error) { + self::recordFailure($error); + throw $error; + } finally { + self::close($scope, $span); + } + } + + /** + * @return array + */ + public static function current(): array + { + if (self::$correlationKey === null || ! class_exists(Context::class)) { + return []; + } + + $fields = Context::getCurrent()->get(self::$correlationKey); + + return is_array($fields) ? $fields : []; + } + + public static function recordFailure(Throwable $error): void + { + try { + if (self::current() === []) { + return; + } + Span::getCurrent()->setStatus(StatusCode::STATUS_ERROR) + ->setAttribute('error.type', $error::class); + } catch (Throwable) { + // Telemetry cannot replace the application's original exception. + } + } + + /** + * @param array $attributes + * @return array + */ + private static function fields(array $attributes): array + { + $fields = []; + foreach (self::FIELDS as $key) { + $value = $attributes[$key] ?? null; + if (($key === 'activity_attempt_number' && is_int($value) && $value > 0) + || ($key !== 'activity_attempt_number' && is_string($value) && $value !== '' && strlen($value) <= 512 + && ! preg_match('/[\x00-\x1f\x7f]/', $value))) { + $fields[$key] = $value; + } + } + + return $fields; + } + + private static function close(?ScopeInterface $scope, ?SpanInterface $span): void + { + try { + $scope?->detach(); + } catch (Throwable) { + } + try { + // The application owns buffering and provider shutdown. Never flush per task. + $span?->end(); + } catch (Throwable) { + } + } +} diff --git a/src/V2/Tracing/LogContext.php b/src/V2/Tracing/LogContext.php new file mode 100644 index 000000000..3d118bb9b --- /dev/null +++ b/src/V2/Tracing/LogContext.php @@ -0,0 +1,46 @@ +getLogger(); + if (method_exists($target, 'pushProcessor')) { + $target->pushProcessor($this->process(...)); + } + } + + public function process(array|LogRecord $record): array|LogRecord + { + try { + $fields = ExecutionTracing::current(); + if ($fields === []) { + return $record; + } + $context = $record instanceof LogRecord ? $record->context : $record['context']; + if (array_key_exists('request_id', $context)) { + unset($fields['request_id']); + } + $context = $fields + $context; + + if ($record instanceof LogRecord) { + return $record->with(context: $context); + } + $record['context'] = $context; + } catch (Throwable) { + // A logging adapter must not disrupt the application's logging path. + } + + return $record; + } +} diff --git a/src/config/workflows.php b/src/config/workflows.php index f79efc780..d3f905647 100644 --- a/src/config/workflows.php +++ b/src/config/workflows.php @@ -20,6 +20,11 @@ return [ 'workflows_folder' => 'Workflows', + 'tracing' => [ + 'enabled' => false, + 'baggage_allowlist' => [], + ], + 'stored_workflow_model' => Workflow\Models\StoredWorkflow::class, 'stored_workflow_exception_model' => Workflow\Models\StoredWorkflowException::class, diff --git a/tests/Unit/V2/ActivityExecutionTracingTest.php b/tests/Unit/V2/ActivityExecutionTracingTest.php new file mode 100644 index 000000000..165577d8e --- /dev/null +++ b/tests/Unit/V2/ActivityExecutionTracingTest.php @@ -0,0 +1,211 @@ +artisan('migrate', [ + '--force' => true, + ])->run(); + DB::statement('CREATE TABLE IF NOT EXISTS jobs (id INTEGER PRIMARY KEY AUTOINCREMENT, + queue TEXT NOT NULL, payload TEXT NOT NULL, attempts INTEGER NOT NULL, + reserved_at INTEGER, available_at INTEGER NOT NULL, created_at INTEGER NOT NULL)'); + DB::statement('CREATE TABLE IF NOT EXISTS failed_jobs (id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, connection TEXT NOT NULL, queue TEXT NOT NULL, + payload TEXT NOT NULL, exception TEXT NOT NULL, failed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)'); + $this->exporter = new InMemoryExporter(); + $this->provider = new TracerProvider(new SimpleSpanProcessor($this->exporter)); + $this->configuration = Configurator::create()->withTracerProvider($this->provider)->activate(); + TracedActivity::$observations = []; + } + + protected function tearDown(): void + { + self::assertSame([], ExecutionTracing::current()); + $this->configuration->detach(); + $this->provider->shutdown(); + parent::tearDown(); + } + + public static function modes(): array + { + return [ + 'default disabled' => [false, false], + 'enabled legacy run' => [true, false], + 'enabled retry' => [true, true], + ]; + } + + #[DataProvider('modes')] + public function testDatabaseQueueScopesDependenciesAndPreservesOriginalTaskRetries(bool $enabled, bool $retry): void + { + config([ + 'workflows.tracing.enabled' => $enabled, + ]); + $stub = WorkflowStub::make(TracedWorkflow::class, 'tracing-original', '0'); + $stub->start($retry); + $this->workOnce(); + // Scheduling can inspect an activity's policy. Only actual execution is traced. + TracedActivity::$observations = []; + for ($remaining = 6; $remaining > 0 && DB::table('jobs')->count() > 0; --$remaining) { + $this->workOnce(); + self::assertSame([], ExecutionTracing::current()); + } + $stub->refresh(); + self::assertSame([9007199254740993, null, '雪'], $stub->output()); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + self::assertSame($retry ? 2 : 1, ActivityAttempt::query()->count()); + $spans = $this->exporter->getSpans(); + self::assertCount($enabled ? ($retry ? 2 : 1) : 0, $spans); + $attempts = []; + foreach (TracedActivity::$observations as [$phase, $fields]) { + if (! $enabled) { + self::assertSame([], $fields); + continue; + } + self::assertSame($stub->runId(), $fields['workflow_run_id']); + self::assertSame('tracing-original', $fields['workflow_instance_id']); + self::assertSame('0', $fields['namespace']); + self::assertNotEmpty($fields['trace_id']); + self::assertNotEmpty($fields['span_id']); + $attempts[$fields['activity_attempt_id']][$phase] = $fields; + } + if ($enabled) { + self::assertCount($retry ? 2 : 1, $attempts); + foreach ($attempts as $phases) { + self::assertSame($phases['dependency'], $phases['constructor']); + self::assertSame($phases['constructor'], $phases['callback']); + } + if ($retry) { + self::assertSame(StatusCode::STATUS_ERROR, $spans[0]->getStatus()->getCode()); + self::assertSame(RuntimeException::class, $spans[0]->getAttributes()->get('error.type')); + self::assertNotSame($spans[0]->getContext()->getSpanId(), $spans[1]->getContext()->getSpanId()); + self::assertSame( + $spans[0]->getAttributes()->get('activity_id'), + $spans[1]->getAttributes()->get('activity_id') + ); + } + } + } + + protected function getPackageProviders($app): array + { + return [TestDatabaseServiceProvider::class, WorkflowServiceProvider::class]; + } + + protected function defineEnvironment($app): void + { + $app['config']->set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'foreign_key_constraints' => true, + ]); + $app['config']->set('workflows.storage.connection', 'testing'); + $app['config']->set('workflows.v1.enabled', false); + $app['config']->set('workflows.v2.task_dispatch_mode', 'queue'); + $app['config']->set('workflows.v2.compatibility.namespace', '0'); + $app['config']->set('queue.default', 'database'); + $app['config']->set('queue.connections.database', [ + 'driver' => 'database', + 'table' => 'jobs', + 'queue' => 'default', + 'retry_after' => 90, + ]); + $app['config']->set('queue.failed', [ + 'driver' => 'database-uuids', + 'database' => 'testing', + 'table' => 'failed_jobs', + ]); + $app['config']->set('cache.default', 'array'); + $app['config']->set('logging.default', 'stderr'); + } + + private function workOnce(): void + { + self::assertSame(0, Artisan::call('queue:work', [ + 'connection' => 'database', + '--queue' => 'default', + '--once' => true, + '--tries' => 1, + '--timeout' => 30, + ])); + } +} + +final class TracedWorkflow extends Workflow +{ + public function handle(bool $retry): array + { + return self::activity(TracedActivity::class, $retry); + } +} + +final class TracedDependency +{ + public function __construct() + { + TracedActivity::$observations[] = ['dependency', ExecutionTracing::current()]; + } +} + +final class TracedActivity extends Activity +{ + public static array $observations = []; + + public int $tries = 2; + + public function __construct(TracedDependency $dependency) + { + self::$observations[] = ['constructor', ExecutionTracing::current()]; + } + + public function backoff(): int + { + return 0; + } + + public function handle(bool $retry, TracedDependency $dependency): array + { + self::$observations[] = ['callback', ExecutionTracing::current()]; + if ($retry && $this->attemptCount() === 1) { + throw new RuntimeException('business failure must not become exported text'); + } + + return [9007199254740993, null, '雪']; + } +} diff --git a/tests/Unit/V2/ExecutionTracingTest.php b/tests/Unit/V2/ExecutionTracingTest.php new file mode 100644 index 000000000..3aa603eaa --- /dev/null +++ b/tests/Unit/V2/ExecutionTracingTest.php @@ -0,0 +1,292 @@ +exporter = new InMemoryExporter(); + $this->provider = new TracerProvider(new SimpleSpanProcessor($this->exporter)); + $this->configuration = Configurator::create()->withTracerProvider($this->provider)->activate(); + $this->initial = Context::getCurrent(); + } + + protected function tearDown(): void + { + self::assertSame($this->initial, Context::getCurrent()); + self::assertSame([], ExecutionTracing::current()); + $this->configuration->detach(); + $this->provider->shutdown(); + } + + public function testRealProviderAndLoggingKeepDurableRequestAndTraceIdentitiesSeparate(): void + { + $handler = new TestHandler(); + $logger = new LaravelLogger(new Logger('application', [$handler])); + (new LogContext())($logger); + $foreign = Context::createKey('unrelated-worker-state'); + $ambient = Context::getCurrent()->with($foreign, 'private')->activate(); + try { + $result = ExecutionTracing::activity(self::fields(), self::carrier(), function () use ( + $logger, + $foreign + ): int { + self::assertSame($this->provider, Globals::tracerProvider()); + self::assertNull(Context::getCurrent()->get($foreign)); + $fields = ExecutionTracing::current(); + self::assertSame(self::TRACE_ID, $fields['trace_id']); + self::assertNotSame(self::PARENT_ID, $fields['span_id']); + self::assertSame('0', $fields['namespace']); + $logger->info('activity executing', [ + 'request_id' => 'application-request', + ]); + $client = Globals::tracerProvider()->getTracer('application')->spanBuilder('outbound')->startSpan(); + $client->end(); + + return 7; + }, enabled: true); + self::assertSame(7, $result); + self::assertSame('private', Context::getCurrent()->get($foreign)); + } finally { + $ambient->detach(); + } + $logger->info('after activity', [ + 'request_id' => 'outside', + ]); + $records = $handler->getRecords(); + self::assertSame('run-1', $records[0]['context']['workflow_run_id']); + self::assertSame('application-request', $records[0]['context']['request_id']); + self::assertArrayNotHasKey('arguments', $records[0]['context']); + self::assertSame([ + 'request_id' => 'outside', + ], $records[1]['context']); + $spans = $this->exporter->getSpans(); + self::assertCount(2, $spans); + self::assertSame($spans[1]->getContext()->getSpanId(), $spans[0]->getParentContext()->getSpanId()); + self::assertSame(self::PARENT_ID, $spans[1]->getParentContext()->getSpanId()); + self::assertSame('origin-request', $spans[1]->getAttributes()->get('request_id')); + self::assertNull($spans[1]->getAttributes()->get('arguments')); + } + + public function testAttemptsHaveDistinctSpansAndNestedScopesRestoreTheirCaller(): void + { + ExecutionTracing::activity(self::fields(), self::carrier(), static function (): void { + $outer = ExecutionTracing::current(); + ExecutionTracing::activity(self::fields('run-2', 'attempt-2'), self::carrier(), static function (): void { + self::assertSame('run-2', ExecutionTracing::current()['workflow_run_id']); + }, enabled: true); + self::assertSame($outer, ExecutionTracing::current()); + }, enabled: true); + $spans = $this->exporter->getSpans(); + self::assertCount(2, $spans); + self::assertNotSame($spans[0]->getContext()->getSpanId(), $spans[1]->getContext()->getSpanId()); + self::assertSame('activity-1', $spans[0]->getAttributes()->get('activity_id')); + self::assertSame('activity-1', $spans[1]->getAttributes()->get('activity_id')); + self::assertSame('attempt-2', $spans[0]->getAttributes()->get('activity_attempt_id')); + self::assertSame('attempt-1', $spans[1]->getAttributes()->get('activity_attempt_id')); + } + + public function testConcurrentFibersKeepIndependentExecutionContexts(): void + { + $handler = new TestHandler(); + $logger = new LaravelLogger(new Logger('fibers', [$handler])); + (new LogContext())($logger); + $operation = function (string $run) use ($logger): void { + // Configuration is execution-local too; each Fiber owns its provider scope. + $configured = Configurator::create()->withTracerProvider($this->provider) + ->storeInContext(Context::getRoot())->activate(); + try { + ExecutionTracing::activity(self::fields($run), self::carrier(), static function () use ( + $run, + $logger + ): void { + self::assertSame($run, ExecutionTracing::current()['workflow_run_id']); + $logger->info('before suspension'); + Fiber::suspend(); + self::assertSame($run, ExecutionTracing::current()['workflow_run_id']); + $logger->info('after resumption'); + }, enabled: true); + self::assertSame([], ExecutionTracing::current()); + } finally { + $configured->detach(); + } + }; + $first = new Fiber(static fn () => $operation('run-a')); + $second = new Fiber(static fn () => $operation('run-b')); + $first->start(); + self::assertSame([], ExecutionTracing::current()); + $second->start(); + self::assertSame([], ExecutionTracing::current()); + $first->resume(); + $second->resume(); + self::assertCount(2, $this->exporter->getSpans()); + self::assertSame(['run-a', 'run-b', 'run-a', 'run-b'], array_map( + static fn ($record) => $record['context']['workflow_run_id'], + $handler->getRecords(), + )); + } + + public function testLegacyAndMalformedCarriersStartSeparateSegmentsWithoutWorkerParentage(): void + { + foreach ([ + null, [ + 'schema' => 'invalid', + ]] as $carrier) { + ExecutionTracing::activity(self::fields(), $carrier, static function (): void { + self::assertNotSame(self::TRACE_ID, Span::getCurrent()->getContext()->getTraceId()); + }, enabled: true); + } + foreach ($this->exporter->getSpans() as $span) { + self::assertFalse($span->getParentContext()->isValid()); + } + } + + public function testDisabledInstrumentationLeavesTheApplicationContextUntouched(): void + { + $calls = 0; + $result = ExecutionTracing::activity(self::fields(), self::carrier(), function () use (&$calls): int { + ++$calls; + self::assertSame($this->initial, Context::getCurrent()); + + return 9; + }); + self::assertSame(9, $result); + self::assertSame(1, $calls); + self::assertSame([], $this->exporter->getSpans()); + } + + public function testOptionalApiAbsenceStillExecutesTheCallbackExactlyOnce(): void + { + $script = <<<'PHP' + require $argv[1]; + $calls = 0; + $result = \Workflow\V2\Support\ExecutionTracing::activity([], null, function () use (&$calls) { + ++$calls; + return [7, null, '雪']; + }, enabled: true); + echo json_encode([$calls, $result, \Workflow\V2\Support\ExecutionTracing::current()]); + PHP; + $process = new Process([ + PHP_BINARY, + '-n', + '-r', + $script, + __DIR__ . '/../../../src/V2/Support/ExecutionTracing.php', + ]); + $process->setTimeout(10); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + self::assertSame( + [1, [7, null, '雪'], []], + json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR) + ); + } + + public function testBusinessFailureKeepsItsIdentityAndDoesNotExportItsMessage(): void + { + $error = new RuntimeException('sensitive application detail'); + try { + ExecutionTracing::activity(self::fields(), self::carrier(), static function () use ($error): never { + throw $error; + }, enabled: true); + self::fail('The application exception must survive tracing.'); + } catch (RuntimeException $observed) { + self::assertSame($error, $observed); + } + $span = $this->exporter->getSpans()[0]; + self::assertSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + self::assertSame(RuntimeException::class, $span->getAttributes()->get('error.type')); + self::assertSame([], $span->getEvents()); + self::assertStringNotContainsString('sensitive', $span->getStatus()->getDescription()); + } + + public function testProcessorFailureCannotReplaceTheBusinessResult(): void + { + $processor = new class(new InMemoryExporter()) extends SimpleSpanProcessor { + public int $calls = 0; + + public function onEnd(ReadableSpanInterface $span): void + { + ++$this->calls; + throw new RuntimeException('collector unavailable'); + } + }; + $provider = new TracerProvider($processor); + $configuration = Configurator::create()->withTracerProvider($provider)->activate(); + try { + self::assertSame( + 7, + ExecutionTracing::activity(self::fields(), self::carrier(), static fn (): int => 7, enabled: true) + ); + self::assertSame(1, $processor->calls); + self::assertSame([], ExecutionTracing::current()); + } finally { + $configuration->detach(); + $provider->shutdown(); + } + } + + private static function fields(string $run = 'run-1', string $attempt = 'attempt-1'): array + { + return [ + 'workflow_instance_id' => 'instance-1', + 'workflow_run_id' => $run, + 'workflow_type' => 'test.workflow', + 'namespace' => '0', + 'task_id' => 'task-1', + 'activity_id' => 'activity-1', + 'activity_type' => 'test.activity', + 'activity_attempt_id' => $attempt, + 'activity_attempt_number' => 1, + 'request_id' => 'origin-request', + 'arguments' => 'must not be captured', + ]; + } + + private static function carrier(): array + { + return [ + 'schema' => 'durable-workflow.trace-context', + 'version' => 1, + 'carrier' => [ + 'traceparent' => '00-' . self::TRACE_ID . '-' . self::PARENT_ID . '-01', + ], + ]; + } +} From dd06d7147b6379f71e368229a0172a93f388a392 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 12:16:38 +0000 Subject: [PATCH 03/77] Persist embedded admission traces across cold activity workers --- .github/pr-test-selection.json | 2 + .github/workflows/php.yml | 5 + src/V2/Models/WorkflowRun.php | 1 + src/V2/WorkflowStub.php | 17 +- ...observability_context_to_workflow_runs.php | 28 +++ tests/Fixtures/V2/tracing-process.php | 223 +++++++++++++++++ .../Unit/V2/ActivityExecutionTracingTest.php | 227 +++++++++++++++++- 7 files changed, 501 insertions(+), 2 deletions(-) create mode 100644 src/migrations/2026_10_15_000100_add_observability_context_to_workflow_runs.php create mode 100644 tests/Fixtures/V2/tracing-process.php diff --git a/.github/pr-test-selection.json b/.github/pr-test-selection.json index 61d9b9f4b..419c8e623 100644 --- a/.github/pr-test-selection.json +++ b/.github/pr-test-selection.json @@ -8,11 +8,13 @@ "tests/Unit/V2/ActivitySessionHistoryTest.php", "tests/Unit/V2/CancellationContextTest.php", "tests/Unit/V2/CompiledWorkflowDefinitionTest.php", + "tests/Unit/V2/ExecutionTracingTest.php", "tests/Unit/V2/ExternalPayloadStorageTest.php", "tests/Unit/V2/PlatformProtocolSpecsTest.php", "tests/Unit/V2/ReadmeBranchDestinationsTest.php", "tests/Unit/V2/ScopedCancellationContextTest.php", "tests/Unit/V2/StartOptionsTest.php", + "tests/Unit/V2/TraceCarrierTest.php", "tests/Unit/V2/VersionMarkerWireFormatTest.php", "tests/Unit/V2/WorkflowFiberRunnerTest.php", "tests/Unit/V2/WorkflowInstanceIdTest.php", diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 7cb383294..0807474b6 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -353,6 +353,11 @@ jobs: env: REDIS_HOST: 127.0.0.1 + - name: Run optional embedded activity tracing controls + run: >- + vendor/bin/phpunit tests/Unit/V2/ActivityExecutionTracingTest.php + --fail-on-warning --log-junit build/test-results/pr-activity-tracing.xml + - name: Run cancellation timeline unit cases run: >- vendor/bin/phpunit tests/Unit/V2/CancellationHistoryTimelineTest.php diff --git a/src/V2/Models/WorkflowRun.php b/src/V2/Models/WorkflowRun.php index c36b5f71e..7862e4a56 100644 --- a/src/V2/Models/WorkflowRun.php +++ b/src/V2/Models/WorkflowRun.php @@ -37,6 +37,7 @@ class WorkflowRun extends Model protected $casts = [ 'status' => RunStatus::class, + 'observability_context' => 'array', 'visibility_labels' => 'array', 'priority' => 'integer', 'fairness_weight' => 'integer', diff --git a/src/V2/WorkflowStub.php b/src/V2/WorkflowStub.php index 34aedbb39..8961eb54b 100644 --- a/src/V2/WorkflowStub.php +++ b/src/V2/WorkflowStub.php @@ -78,6 +78,7 @@ use Workflow\V2\Support\TaskDispatcher; use Workflow\V2\Support\TaskRepair; use Workflow\V2\Support\TimerCancellation; +use Workflow\V2\Support\TraceCarrier; use Workflow\V2\Support\TypeRegistry; use Workflow\V2\Support\UpdateCommandGate; use Workflow\V2\Support\UpdateWaitPolicy; @@ -944,10 +945,23 @@ public function attemptStart(...$arguments): StartResult [$arguments, $startOptions] = $this->extractStartArguments($arguments); $metadata = WorkflowMetadata::fromStartArguments($arguments); + // Capture outside the storage transaction and preserve it across its retries. + $observabilityContext = null; + if (config('workflows.tracing.enabled', false) === true) { + $allowlist = config('workflows.tracing.baggage_allowlist', []); + $observabilityContext = TraceCarrier::capture(is_array($allowlist) ? $allowlist : []); + } $task = null; $startedRun = null; - SqliteTransaction::run(function () use ($metadata, $startOptions, &$task, &$command, &$startedRun): void { + SqliteTransaction::run(function () use ( + $metadata, + $startOptions, + $observabilityContext, + &$task, + &$command, + &$startedRun, + ): void { $task = null; $command = null; $startedRun = null; @@ -1077,6 +1091,7 @@ public function attemptStart(...$arguments): StartResult $run = self::runQuery()->create([ 'workflow_instance_id' => $instance->id, 'run_number' => $instance->run_count + 1, + 'observability_context' => $observabilityContext, 'workflow_class' => $workflowClass, 'workflow_type' => $instance->workflow_type, 'namespace' => $instance->namespace, diff --git a/src/migrations/2026_10_15_000100_add_observability_context_to_workflow_runs.php b/src/migrations/2026_10_15_000100_add_observability_context_to_workflow_runs.php new file mode 100644 index 000000000..518be586a --- /dev/null +++ b/src/migrations/2026_10_15_000100_add_observability_context_to_workflow_runs.php @@ -0,0 +1,28 @@ +json('observability_context') + ->nullable(); + }); + } + } + + public function down(): void + { + if (Schema::hasColumn('workflow_runs', 'observability_context')) { + Schema::table('workflow_runs', static function (Blueprint $table): void { + $table->dropColumn('observability_context'); + }); + } + } +}; diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php new file mode 100644 index 000000000..13bd94e82 --- /dev/null +++ b/tests/Fixtures/V2/tracing-process.php @@ -0,0 +1,223 @@ +attemptCount() === 1) { + throw new \RuntimeException('deliberate original attempt failure'); + } + + return [9007199254740993, null, '雪']; + } +} + +final class TracingProcessApplication extends Application +{ + protected function defineEnvironment($app): void + { + $directory = $app->basePath(); + $app['config']->set([ + 'database.default' => 'tracing', + 'database.migrations' => 'migrations', + 'database.connections.tracing' => [ + 'driver' => 'sqlite', + 'database' => $directory . '/database.sqlite', + 'prefix' => '', + 'foreign_key_constraints' => true, + ], + 'workflows.storage.connection' => 'tracing', + 'workflows.v1.enabled' => false, + 'workflows.v2.compatibility.namespace' => '0', + 'workflows.v2.task_dispatch_mode' => 'queue', + 'workflows.tracing.enabled' => true, + 'queue.default' => 'database', + 'queue.connections.database' => [ + 'driver' => 'database', + 'table' => 'jobs', + 'queue' => 'default', + 'retry_after' => 90, + ], + 'queue.failed' => [ + 'driver' => 'database-uuids', + 'database' => 'tracing', + 'table' => 'failed_jobs', + ], + 'cache.default' => 'array', + 'cache.stores.array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + 'logging.default' => 'single', + 'logging.channels.single' => [ + 'driver' => 'single', + 'path' => $directory . '/activity.log', + 'tap' => [LogContext::class], + 'formatter' => JsonFormatter::class, + ], + ]); + } +} + +$directory = $argv[1]; +$mode = $argv[2]; +foreach (['bootstrap/cache', 'storage/logs', 'storage/framework/views', 'config'] as $path) { + if (! is_dir($directory . '/' . $path)) { + mkdir($directory . '/' . $path, 0700, true); + } +} +if (! is_file($directory . '/database.sqlite')) { + touch($directory . '/database.sqlite'); +} +$app = TracingProcessApplication::create( + basePath: $directory, + options: [ + 'extra' => [ + 'providers' => [WorkflowServiceProvider::class], + 'dont-discover' => ['*'], + ], + ], +); +$exporter = new InMemoryExporter(); +$provider = new TracerProvider(new SimpleSpanProcessor($exporter)); +$configuration = Configurator::create()->withTracerProvider($provider) + ->withPropagator(TraceContextPropagator::getInstance())->activate(); +$initial = Context::getCurrent(); +try { + $parent = $mode === 'admit' + ? '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' + : '00-11111111111111111111111111111111-2222222222222222-01'; + $scope = TraceContextPropagator::getInstance()->extract([ + 'traceparent' => $parent, + ])->activate(); + try { + if ($mode === 'admit') { + if (Artisan::call('migrate', [ + '--force' => true, + ]) !== 0) { + throw new \RuntimeException(Artisan::output()); + } + DB::statement('CREATE TABLE jobs (id INTEGER PRIMARY KEY AUTOINCREMENT, queue TEXT NOT NULL, + payload TEXT NOT NULL, attempts INTEGER NOT NULL, reserved_at INTEGER, + available_at INTEGER NOT NULL, created_at INTEGER NOT NULL)'); + DB::statement('CREATE TABLE failed_jobs (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, + connection TEXT NOT NULL, queue TEXT NOT NULL, payload TEXT NOT NULL, exception TEXT NOT NULL, + failed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)'); + WorkflowStub::make(TracingProcessWorkflow::class, 'cold-tracing', '0')->start( + $retry, + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); + } elseif ($mode === 'duplicate') { + $result = WorkflowStub::make(TracingProcessWorkflow::class, 'cold-tracing', '0') + ->attemptStart($retry, StartOptions::returnExistingActive()); + if (! $result->returnedExistingActive()) { + throw new \RuntimeException('Expected the original active admission.'); + } + } elseif ($mode === 'work') { + if (Artisan::call('queue:work', [ + 'connection' => 'database', + '--queue' => 'default', + '--once' => true, + '--tries' => 1, + '--timeout' => 30, + ]) !== 0) { + throw new \RuntimeException(Artisan::output()); + } + Log::info('worker outside'); + } elseif ($mode !== 'inspect') { + throw new \RuntimeException('Unknown fixture command.'); + } + } finally { + $scope->detach(); + } + $run = WorkflowRun::query()->where('workflow_instance_id', 'cold-tracing')->sole(); + $output = [ + 'pid' => getmypid(), + 'framework' => $app->version(), + 'run_id' => $run->id, + 'status' => $run->status->value, + 'output' => $run->workflowOutput(), + 'carrier' => $run->getRawOriginal('observability_context'), + 'execution_deadline' => $run->getRawOriginal('execution_deadline_at'), + 'run_deadline' => $run->getRawOriginal('run_deadline_at'), + 'jobs' => DB::table('jobs')->count(), + 'failed_jobs' => DB::table('failed_jobs')->count(), + 'tasks' => WorkflowTask::query()->count(), + 'repairs' => (int) WorkflowTask::query()->sum('repair_count'), + 'outside_context' => ExecutionTracing::current(), + 'scope_restored' => Context::getCurrent() === $initial, + 'spans' => array_map(static fn ($span): array => [ + 'trace_id' => $span->getContext() + ->getTraceId(), + 'span_id' => $span->getContext() + ->getSpanId(), + 'parent_id' => $span->getParentContext() + ->getSpanId(), + 'fields' => $span->getAttributes() + ->toArray(), + ], $exporter->getSpans()), + ]; +} finally { + $configuration->detach(); + $provider->shutdown(); +} +echo json_encode($output, JSON_THROW_ON_ERROR), PHP_EOL; diff --git a/tests/Unit/V2/ActivityExecutionTracingTest.php b/tests/Unit/V2/ActivityExecutionTracingTest.php index 165577d8e..aff127b4c 100644 --- a/tests/Unit/V2/ActivityExecutionTracingTest.php +++ b/tests/Unit/V2/ActivityExecutionTracingTest.php @@ -4,9 +4,12 @@ namespace Tests\Unit\V2; +use Composer\Autoload\ClassLoader; +use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use OpenTelemetry\API\Instrumentation\Configurator; +use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator; use OpenTelemetry\API\Trace\StatusCode; use OpenTelemetry\Context\ScopeInterface; use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; @@ -15,11 +18,16 @@ use Orchestra\Testbench\TestCase; use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; +use Symfony\Component\Process\Process; use Tests\Support\TestDatabaseServiceProvider; use Workflow\Providers\WorkflowServiceProvider; +use Workflow\Serializers\Serializer; use Workflow\V2\Activity; use Workflow\V2\Models\ActivityAttempt; +use Workflow\V2\Models\WorkflowHistoryEvent; +use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Models\WorkflowTask; +use Workflow\V2\StartOptions; use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Workflow; use Workflow\V2\WorkflowStub; @@ -46,7 +54,8 @@ protected function setUp(): void payload TEXT NOT NULL, exception TEXT NOT NULL, failed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)'); $this->exporter = new InMemoryExporter(); $this->provider = new TracerProvider(new SimpleSpanProcessor($this->exporter)); - $this->configuration = Configurator::create()->withTracerProvider($this->provider)->activate(); + $this->configuration = Configurator::create()->withTracerProvider($this->provider) + ->withPropagator(TraceContextPropagator::getInstance())->activate(); TracedActivity::$observations = []; } @@ -67,6 +76,222 @@ public static function modes(): array ]; } + public function testAdmissionPersistsItsOriginalParentAndDuplicateCannotReplaceIt(): void + { + config([ + 'workflows.tracing.enabled' => true, + ]); + $parent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'; + $scope = TraceContextPropagator::getInstance()->extract([ + 'traceparent' => $parent, + ])->activate(); + try { + $stub = WorkflowStub::make(TracedWorkflow::class, 'captured-original', '0'); + $stub->start(false, new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45)); + } finally { + $scope->detach(); + } + $run = WorkflowRun::query()->findOrFail($stub->runId()); + self::assertSame($parent, $run->observability_context['carrier']['traceparent']); + self::assertSame([false], Serializer::unserializeWithCodec($run->payload_codec, $run->arguments)); + $original = $run->getRawOriginal('observability_context'); + $deadlines = [$run->getRawOriginal('execution_deadline_at'), $run->getRawOriginal('run_deadline_at')]; + $scope = TraceContextPropagator::getInstance()->extract([ + 'traceparent' => '00-11111111111111111111111111111111-2222222222222222-01', + ])->activate(); + try { + self::assertTrue( + $stub->attemptStart(false, StartOptions::returnExistingActive())->returnedExistingActive() + ); + $run->refresh(); + self::assertSame($original, $run->getRawOriginal('observability_context')); + self::assertSame( + $deadlines, + [$run->getRawOriginal('execution_deadline_at'), $run->getRawOriginal('run_deadline_at')] + ); + for ($remaining = 4; $remaining > 0 && DB::table('jobs')->count() > 0; --$remaining) { + $this->workOnce(); + self::assertSame([], ExecutionTracing::current()); + } + } finally { + $scope->detach(); + } + $stub->refresh(); + self::assertSame([9007199254740993, null, '雪'], $stub->output()); + $spans = $this->exporter->getSpans(); + self::assertCount(1, $spans); + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $spans[0]->getContext()->getTraceId()); + self::assertSame('00f067aa0ba902b7', $spans[0]->getParentContext()->getSpanId()); + self::assertStringNotContainsString('observability_context', json_encode( + WorkflowHistoryEvent::query()->where('workflow_run_id', $stub->runId())->pluck('payload')->all(), + JSON_THROW_ON_ERROR, + )); + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + self::assertSame(0, DB::table('failed_jobs')->count()); + } + + public function testExistingRunMigrationLeavesItsDataUntouchedAndAllowsExecutionWithoutAParent(): void + { + $scope = TraceContextPropagator::getInstance()->extract([ + 'traceparent' => '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + ])->activate(); + try { + $stub = WorkflowStub::make(TracedWorkflow::class, 'legacy-run', '0'); + $stub->start(false); + } finally { + $scope->detach(); + } + self::assertNull(WorkflowRun::query()->findOrFail($stub->runId())->observability_context); + $migration = require __DIR__ . '/../../../src/migrations/2026_10_15_000100_add_observability_context_to_workflow_runs.php'; + $migration->down(); + $before = (array) DB::table('workflow_runs')->where('id', $stub->runId())->sole(); + $migration->up(); + $migration->up(); + $after = (array) DB::table('workflow_runs')->where('id', $stub->runId())->sole(); + self::assertArrayHasKey('observability_context', $after); + self::assertNull($after['observability_context']); + unset($after['observability_context']); + self::assertSame($before, $after); + config([ + 'workflows.tracing.enabled' => true, + ]); + for ($remaining = 4; $remaining > 0 && DB::table('jobs')->count() > 0; --$remaining) { + $this->workOnce(); + } + self::assertSame([9007199254740993, null, '雪'], $stub->refresh()->output()); + $spans = $this->exporter->getSpans(); + self::assertCount(1, $spans); + self::assertFalse($spans[0]->getParentContext()->isValid()); + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + } + + public static function coldModes(): array + { + return [ + 'single attempt' => [false], + 'cold retry' => [true], + ]; + } + + #[DataProvider('coldModes')] + public function testIndependentQueueProcessesUseThePersistedAdmissionParent(bool $retry): void + { + $directory = sys_get_temp_dir() . '/dw-tracing-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $run = static function (string $mode) use ($directory, $retry): array { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, $retry ? 'retry' : 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ]); + $process->setTimeout(30); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + self::assertJson($process->getOutput(), $mode . ': ' . substr($process->getOutput(), 0, 2048)); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + try { + $admitted = $run('admit'); + $duplicate = $run('duplicate'); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { + self::assertSame($admitted[$key], $duplicate[$key]); + } + $workflow = $run('work'); + $activity = $run('work'); + $retried = $retry ? $run('work') : null; + $completion = $run('work'); + $workerPids = [$workflow['pid'], $activity['pid'], $completion['pid']]; + if ($retried !== null) { + $workerPids[] = $retried['pid']; + self::assertCount(1, $retried['spans']); + self::assertSame( + $activity['spans'][0]['fields']['activity_id'], + $retried['spans'][0]['fields']['activity_id'] + ); + self::assertNotSame( + $activity['spans'][0]['fields']['activity_attempt_id'], + $retried['spans'][0]['fields']['activity_attempt_id'] + ); + self::assertNotSame($activity['spans'][0]['span_id'], $retried['spans'][0]['span_id']); + self::assertSame($activity['spans'][0]['trace_id'], $retried['spans'][0]['trace_id']); + self::assertSame($activity['spans'][0]['parent_id'], $retried['spans'][0]['parent_id']); + } + self::assertCount($retry ? 4 : 3, array_unique($workerPids)); + self::assertSame([], $workflow['spans']); + self::assertSame([], $completion['spans']); + self::assertCount(1, $activity['spans']); + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $activity['spans'][0]['trace_id']); + self::assertSame('00f067aa0ba902b7', $activity['spans'][0]['parent_id']); + $finished = $run('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame($retry ? 4 : 3, $finished['tasks']); + self::assertSame(0, $finished['repairs']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { + self::assertSame($admitted[$key], $finished[$key]); + } + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); + $callbacks = array_values( + array_filter($records, static fn (array $record): bool => $record['message'] === 'callback') + ); + self::assertCount($retry ? 2 : 1, $callbacks); + foreach ($callbacks as $callback) { + self::assertSame($admitted['run_id'], $callback['context']['workflow_run_id']); + self::assertSame($activity['spans'][0]['trace_id'], $callback['context']['trace_id']); + self::assertArrayHasKey('activity_attempt_id', $callback['context']); + self::assertSame('0', $callback['context']['namespace']); + $constructed = array_values( + array_filter( + $records, + static fn (array $record): bool => $record['message'] === 'constructor' && ($record['context']['activity_attempt_id'] ?? null) === $callback['context']['activity_attempt_id'] + ) + ); + self::assertCount(1, $constructed); + self::assertSame($callback['context'], $constructed[0]['context']); + $dependencies = array_values( + array_filter( + $records, + static fn (array $record): bool => $record['message'] === 'dependency' && ($record['context']['activity_attempt_id'] ?? null) === $callback['context']['activity_attempt_id'] + ) + ); + self::assertCount(2, $dependencies); + foreach ($dependencies as $dependency) { + self::assertSame($callback['context'], $dependency['context']); + } + } + self::assertCount( + $retry ? 4 : 3, + array_filter($records, static fn (array $record): bool => $record['message'] === 'worker outside') + ); + foreach ($records as $record) { + if ($record['message'] === 'worker outside') { + self::assertArrayNotHasKey('workflow_run_id', $record['context']); + } elseif (isset($record['context']['activity_attempt_id'])) { + self::assertSame($admitted['run_id'], $record['context']['workflow_run_id']); + self::assertSame($activity['spans'][0]['trace_id'], $record['context']['trace_id']); + } + } + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } + #[DataProvider('modes')] public function testDatabaseQueueScopesDependenciesAndPreservesOriginalTaskRetries(bool $enabled, bool $retry): void { From 4b7f77a34673d4a8c66b6ba06658a87b57ec29e7 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 12:37:45 +0000 Subject: [PATCH 04/77] Add workflow task tracing and bind Fiber context --- scripts/ci/validate-regression-corpus.py | 2 + src/V2/Jobs/RunWorkflowTask.php | 46 ++++++- src/V2/Support/ExecutionTracing.php | 128 ++++++++++++++---- src/V2/Support/WorkflowExecution.php | 1 + src/V2/Support/WorkflowExecutor.php | 4 + .../workflow-fiber-observability-context.json | 36 +++++ .../Fixtures/V2/TestTracingReplayActivity.php | 15 ++ .../Fixtures/V2/TestTracingReplayWorkflow.php | 22 +++ tests/Fixtures/V2/tracing-process.php | 22 ++- .../Unit/V2/ActivityExecutionTracingTest.php | 102 +++++++++++++- tests/Unit/V2/ExecutionTracingTest.php | 73 ++++++++++ tests/Unit/V2/ReplayRegressionCorpusTest.php | 62 +++++++++ 12 files changed, 471 insertions(+), 42 deletions(-) create mode 100644 tests/Fixtures/V2/ReplayRegression/workflow-fiber-observability-context.json create mode 100644 tests/Fixtures/V2/TestTracingReplayActivity.php create mode 100644 tests/Fixtures/V2/TestTracingReplayWorkflow.php diff --git a/scripts/ci/validate-regression-corpus.py b/scripts/ci/validate-regression-corpus.py index ec5de78b9..6da02801b 100644 --- a/scripts/ci/validate-regression-corpus.py +++ b/scripts/ci/validate-regression-corpus.py @@ -159,6 +159,8 @@ "tests/Feature/V2/V2EmbeddedReplayRegressionCorpusTest.php", "tests/Fixtures/V2/TestConstructorInjectionActivity.php", "tests/Fixtures/V2/TestConstructorInjectionWorkflow.php", + "tests/Fixtures/V2/TestTracingReplayActivity.php", + "tests/Fixtures/V2/TestTracingReplayWorkflow.php", "tests/Fixtures/V2/TestPortableLocalActivityIdentityWorkflow.php", "tests/Fixtures/V2/TestParallelChildReplayWorkflow.php", "tests/Fixtures/V2/TestSequentialChildReplayWorkflow.php", diff --git a/src/V2/Jobs/RunWorkflowTask.php b/src/V2/Jobs/RunWorkflowTask.php index fdddd4ca4..4e36fc9af 100644 --- a/src/V2/Jobs/RunWorkflowTask.php +++ b/src/V2/Jobs/RunWorkflowTask.php @@ -16,6 +16,7 @@ use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Models\WorkflowTask; use Workflow\V2\Support\ConfiguredV2Models; +use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\ServiceCallWorkflowDelivery; use Workflow\V2\Support\SqliteTransaction; use Workflow\V2\Support\StickyExecution; @@ -48,10 +49,41 @@ public function handle(WorkflowExecutor $executor): void is_string($this->queue ?? null) ? $this->queue : null, ); - if (! $this->claimTask()) { + $task = $this->claimTask(); + if ($task === null) { return; } + if (config('workflows.tracing.enabled', false) !== true) { + $this->executeClaim($executor); + + return; + } + + /** @var WorkflowRun $run */ + $run = $task->getRelation('run'); + $allowlist = config('workflows.tracing.baggage_allowlist', []); + $storedContext = array_key_exists('observability_context', $run->getAttributes()) + ? $run->getAttribute('observability_context') + : null; + // Activate and end the segment outside the execution transaction. + ExecutionTracing::workflow( + [ + 'workflow_instance_id' => $run->workflow_instance_id, + 'workflow_run_id' => $run->id, + 'workflow_type' => $run->workflow_type, + 'namespace' => $run->namespace, + 'task_id' => $task->id, + ], + $storedContext, + fn () => $this->executeClaim($executor), + is_array($allowlist) ? $allowlist : [], + true, + ); + } + + private function executeClaim(WorkflowExecutor $executor): void + { $nextTask = null; try { @@ -113,16 +145,16 @@ public function handle(WorkflowExecutor $executor): void } } - private function claimTask(): bool + private function claimTask(): ?WorkflowTask { - return SqliteTransaction::forTask($this->taskId, function (): bool { + return SqliteTransaction::forTask($this->taskId, function (): ?WorkflowTask { /** @var WorkflowTask|null $task */ $task = ConfiguredV2Models::query('task_model', WorkflowTask::class) ->lockForUpdate() ->find($this->taskId); if ($task === null || $task->task_type !== TaskType::Workflow || $task->status !== TaskStatus::Ready) { - return false; + return null; } /** @var WorkflowRun $run */ @@ -133,13 +165,13 @@ private function claimTask(): bool if (TaskBackendCapabilities::recordClaimFailureIfUnsupported($task) !== null) { $this->projectRun($run->fresh(['instance', 'tasks', 'activityExecutions', 'failures'])); - return false; + return null; } if (! TaskCompatibility::supported($task, $run)) { $this->projectRun($run->fresh(['instance', 'tasks', 'activityExecutions', 'failures'])); - return false; + return null; } $task->forceFill([ @@ -157,7 +189,7 @@ private function claimTask(): bool ServiceCallWorkflowDelivery::recordForClaim($task, $run); $this->projectRun($run->fresh(['instance', 'tasks', 'activityExecutions', 'failures'])); - return true; + return $task->setRelation('run', $run); }); } diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php index f4655213f..ead5cb6d7 100644 --- a/src/V2/Support/ExecutionTracing.php +++ b/src/V2/Support/ExecutionTracing.php @@ -29,6 +29,35 @@ final class ExecutionTracing private static ?ContextKeyInterface $correlationKey = null; + public static function bind(callable $callback): callable + { + try { + if (self::current() === []) { + return $callback; + } + $context = Context::getCurrent(); + } catch (Throwable) { + return $callback; + } + + // Attach inside the new Fiber. Its scope must also unwind inside that Fiber. + return static function (mixed ...$arguments) use ($callback, $context): mixed { + $scope = null; + try { + $scope = $context->activate(); + } catch (Throwable) { + } + try { + return $callback(...$arguments); + } finally { + try { + $scope?->detach(); + } catch (Throwable) { + } + } + }; + } + /** * @param array $attributes * @param list $baggageAllowlist @@ -39,6 +68,76 @@ public static function activity( callable $operation, array $baggageAllowlist = [], bool $enabled = false, + ): mixed { + return self::segment( + 'durable.activity.attempt', + $attributes, + $persistedContext, + $operation, + $baggageAllowlist, + $enabled + ); + } + + /** + * @param array $attributes + * @param list $baggageAllowlist + */ + public static function workflow( + array $attributes, + mixed $persistedContext, + callable $operation, + array $baggageAllowlist = [], + bool $enabled = false, + ): mixed { + return self::segment( + 'durable.workflow.task', + $attributes, + $persistedContext, + $operation, + $baggageAllowlist, + $enabled + ); + } + + /** + * @return array + */ + public static function current(): array + { + if (self::$correlationKey === null || ! class_exists(Context::class)) { + return []; + } + + $fields = Context::getCurrent()->get(self::$correlationKey); + + return is_array($fields) ? $fields : []; + } + + public static function recordFailure(Throwable $error): void + { + try { + if (self::current() === []) { + return; + } + Span::getCurrent()->setStatus(StatusCode::STATUS_ERROR) + ->setAttribute('error.type', $error::class); + } catch (Throwable) { + // Telemetry cannot replace the application's original exception. + } + } + + /** + * @param array $attributes + * @param list $baggageAllowlist + */ + private static function segment( + string $name, + array $attributes, + mixed $persistedContext, + callable $operation, + array $baggageAllowlist, + bool $enabled, ): mixed { if (! $enabled || ! class_exists(Globals::class)) { return $operation(); @@ -67,7 +166,7 @@ public static function activity( ? InstalledVersions::getPrettyVersion('durable-workflow/workflow') : null; $span = Globals::tracerProvider()->getTracer('durable-workflow/workflow', $version) - ->spanBuilder('durable.activity.attempt') + ->spanBuilder($name) ->setSpanKind(SpanKind::KIND_CONSUMER) ->setParent($parent) ->setAttributes($fields) @@ -95,33 +194,6 @@ public static function activity( } } - /** - * @return array - */ - public static function current(): array - { - if (self::$correlationKey === null || ! class_exists(Context::class)) { - return []; - } - - $fields = Context::getCurrent()->get(self::$correlationKey); - - return is_array($fields) ? $fields : []; - } - - public static function recordFailure(Throwable $error): void - { - try { - if (self::current() === []) { - return; - } - Span::getCurrent()->setStatus(StatusCode::STATUS_ERROR) - ->setAttribute('error.type', $error::class); - } catch (Throwable) { - // Telemetry cannot replace the application's original exception. - } - } - /** * @param array $attributes * @return array diff --git a/src/V2/Support/WorkflowExecution.php b/src/V2/Support/WorkflowExecution.php index 23c7fdd7e..5c2ec1180 100644 --- a/src/V2/Support/WorkflowExecution.php +++ b/src/V2/Support/WorkflowExecution.php @@ -44,6 +44,7 @@ public static function startCallback( ?StraightLineWorkflowRequiredException $straightLineError = null, ?CarbonInterface $eventTime = null, ): self { + $callback = ExecutionTracing::bind($callback); $fiber = new Fiber(static function () use ($callback, $arguments): mixed { WorkflowFiberContext::enter(); diff --git a/src/V2/Support/WorkflowExecutor.php b/src/V2/Support/WorkflowExecutor.php index de346695a..b1ddae1a2 100644 --- a/src/V2/Support/WorkflowExecutor.php +++ b/src/V2/Support/WorkflowExecutor.php @@ -4355,6 +4355,10 @@ private function failRun( throw $throwable; } + if (config('workflows.tracing.enabled', false) === true) { + ExecutionTracing::recordFailure($throwable); + } + if ($throwable instanceof UnresolvedWorkflowFailureException) { $this->blockReplayUntilFailureCanBeRestored($run, $task, $throwable); diff --git a/tests/Fixtures/V2/ReplayRegression/workflow-fiber-observability-context.json b/tests/Fixtures/V2/ReplayRegression/workflow-fiber-observability-context.json new file mode 100644 index 000000000..7c783adbf --- /dev/null +++ b/tests/Fixtures/V2/ReplayRegression/workflow-fiber-observability-context.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://raw.githubusercontent.com/durable-workflow/.github/main/regression-corpus/evidence-schema.json", + "fixture_schema": "durable-workflow.replay-regression/v1", + "id": "workflow-fiber-observability-context", + "protocol_version": "1.0", + "bindings": ["php"], + "workflow": { + "type": "Tests\\Fixtures\\V2\\TestTracingReplayWorkflow", + "arguments": [], + "payload_codec": "avro" + }, + "command_sequence": [ + { + "completed": false, + "result": null, + "commands": [ + { + "type": "schedule_activity", + "activity_type": "Tests\\Fixtures\\V2\\TestTracingReplayActivity", + "payload_codec": "avro" + } + ], + "resume_with": [9007199254740993, null, "雪"] + }, + { + "completed": true, + "result": [9007199254740993, null, "雪"], + "commands": [{"type": "complete_workflow"}] + } + ], + "expected": { + "completed": true, + "result": [9007199254740993, null, "雪"], + "commands": [{"type": "complete_workflow"}] + } +} diff --git a/tests/Fixtures/V2/TestTracingReplayActivity.php b/tests/Fixtures/V2/TestTracingReplayActivity.php new file mode 100644 index 000000000..3005c11e8 --- /dev/null +++ b/tests/Fixtures/V2/TestTracingReplayActivity.php @@ -0,0 +1,15 @@ + ExecutionTracing::current(), 'scope_restored' => Context::getCurrent() === $initial, 'spans' => array_map(static fn ($span): array => [ + 'name' => $span->getName(), 'trace_id' => $span->getContext() ->getTraceId(), 'span_id' => $span->getContext() diff --git a/tests/Unit/V2/ActivityExecutionTracingTest.php b/tests/Unit/V2/ActivityExecutionTracingTest.php index aff127b4c..4342232a7 100644 --- a/tests/Unit/V2/ActivityExecutionTracingTest.php +++ b/tests/Unit/V2/ActivityExecutionTracingTest.php @@ -11,7 +11,10 @@ use OpenTelemetry\API\Instrumentation\Configurator; use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator; use OpenTelemetry\API\Trace\StatusCode; +use OpenTelemetry\Context\ContextInterface; use OpenTelemetry\Context\ScopeInterface; +use OpenTelemetry\SDK\Trace\ReadableSpanInterface; +use OpenTelemetry\SDK\Trace\ReadWriteSpanInterface; use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor; use OpenTelemetry\SDK\Trace\TracerProvider; @@ -118,7 +121,7 @@ public function testAdmissionPersistsItsOriginalParentAndDuplicateCannotReplaceI } $stub->refresh(); self::assertSame([9007199254740993, null, '雪'], $stub->output()); - $spans = $this->exporter->getSpans(); + $spans = $this->activitySpans(); self::assertCount(1, $spans); self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $spans[0]->getContext()->getTraceId()); self::assertSame('00f067aa0ba902b7', $spans[0]->getParentContext()->getSpanId()); @@ -159,7 +162,7 @@ public function testExistingRunMigrationLeavesItsDataUntouchedAndAllowsExecution $this->workOnce(); } self::assertSame([9007199254740993, null, '雪'], $stub->refresh()->output()); - $spans = $this->exporter->getSpans(); + $spans = $this->activitySpans(); self::assertCount(1, $spans); self::assertFalse($spans[0]->getParentContext()->isValid()); self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); @@ -173,6 +176,48 @@ public static function coldModes(): array ]; } + public function testFailedWorkflowExportsItsProcessingSegmentOutsideTheDatabaseTransaction(): void + { + config([ + 'workflows.tracing.enabled' => true, + ]); + $processor = new class($this->exporter) extends SimpleSpanProcessor { + public array $transactionLevels = []; + + public function onStart(ReadWriteSpanInterface $span, ContextInterface $parentContext): void + { + $this->transactionLevels[] = DB::transactionLevel(); + } + + public function onEnd(ReadableSpanInterface $span): void + { + $this->transactionLevels[] = DB::transactionLevel(); + parent::onEnd($span); + } + }; + $provider = new TracerProvider($processor); + $scope = Configurator::create()->withTracerProvider($provider)->activate(); + try { + $stub = WorkflowStub::make(FailingTracedWorkflow::class, 'failed-workflow', '0'); + $stub->start(); + $this->workOnce(); + self::assertSame('failed', WorkflowRun::query()->findOrFail($stub->runId())->status->value); + self::assertSame([0, 0], $processor->transactionLevels); + $spans = $this->exporter->getSpans(); + self::assertCount(1, $spans); + self::assertSame('durable.workflow.task', $spans[0]->getName()); + self::assertSame(StatusCode::STATUS_ERROR, $spans[0]->getStatus()->getCode()); + self::assertSame(RuntimeException::class, $spans[0]->getAttributes()->get('error.type')); + self::assertSame($stub->runId(), FailingTracedWorkflow::$fields['workflow_run_id']); + self::assertSame($spans[0]->getContext()->getSpanId(), FailingTracedWorkflow::$fields['span_id']); + self::assertSame([], ExecutionTracing::current()); + self::assertSame(0, DB::table('failed_jobs')->count()); + } finally { + $scope->detach(); + $provider->shutdown(); + } + } + #[DataProvider('coldModes')] public function testIndependentQueueProcessesUseThePersistedAdmissionParent(bool $retry): void { @@ -228,8 +273,19 @@ public function testIndependentQueueProcessesUseThePersistedAdmissionParent(bool self::assertSame($activity['spans'][0]['parent_id'], $retried['spans'][0]['parent_id']); } self::assertCount($retry ? 4 : 3, array_unique($workerPids)); - self::assertSame([], $workflow['spans']); - self::assertSame([], $completion['spans']); + foreach ([$workflow, $completion] as $segment) { + self::assertCount(1, $segment['spans']); + self::assertSame('durable.workflow.task', $segment['spans'][0]['name']); + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $segment['spans'][0]['trace_id']); + self::assertSame('00f067aa0ba902b7', $segment['spans'][0]['parent_id']); + self::assertSame($admitted['run_id'], $segment['spans'][0]['fields']['workflow_run_id']); + self::assertArrayNotHasKey('activity_attempt_id', $segment['spans'][0]['fields']); + } + self::assertNotSame($workflow['spans'][0]['span_id'], $completion['spans'][0]['span_id']); + self::assertNotSame( + $workflow['spans'][0]['fields']['task_id'], + $completion['spans'][0]['fields']['task_id'] + ); self::assertCount(1, $activity['spans']); self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $activity['spans'][0]['trace_id']); self::assertSame('00f067aa0ba902b7', $activity['spans'][0]['parent_id']); @@ -287,6 +343,23 @@ public function testIndependentQueueProcessesUseThePersistedAdmissionParent(bool self::assertSame($activity['spans'][0]['trace_id'], $record['context']['trace_id']); } } + $workflowLogs = array_values(array_filter( + $records, + static fn (array $record): bool => str_starts_with($record['message'], 'workflow ') + )); + self::assertCount(9, $workflowLogs); + $segments = []; + foreach ([$workflow, $completion] as $segment) { + $span = $segment['spans'][0]; + $segments[$span['fields']['task_id']] = $span['fields'] + [ + 'trace_id' => $span['trace_id'], + 'span_id' => $span['span_id'], + ]; + } + foreach ($workflowLogs as $record) { + self::assertArrayHasKey($record['context']['task_id'], $segments); + self::assertSame($segments[$record['context']['task_id']], $record['context']); + } } finally { (new Filesystem())->deleteDirectory($directory); } @@ -313,7 +386,7 @@ public function testDatabaseQueueScopesDependenciesAndPreservesOriginalTaskRetri self::assertSame(0, DB::table('failed_jobs')->count()); self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); self::assertSame($retry ? 2 : 1, ActivityAttempt::query()->count()); - $spans = $this->exporter->getSpans(); + $spans = $this->activitySpans(); self::assertCount($enabled ? ($retry ? 2 : 1) : 0, $spans); $attempts = []; foreach (TracedActivity::$observations as [$phase, $fields]) { @@ -390,6 +463,14 @@ private function workOnce(): void '--timeout' => 30, ])); } + + private function activitySpans(): array + { + return array_values(array_filter( + $this->exporter->getSpans(), + static fn ($span): bool => $span->getName() === 'durable.activity.attempt' + )); + } } final class TracedWorkflow extends Workflow @@ -400,6 +481,17 @@ public function handle(bool $retry): array } } +final class FailingTracedWorkflow extends Workflow +{ + public static array $fields = []; + + public function handle(): never + { + self::$fields = ExecutionTracing::current(); + throw new RuntimeException('application detail must not be exported'); + } +} + final class TracedDependency { public function __construct() diff --git a/tests/Unit/V2/ExecutionTracingTest.php b/tests/Unit/V2/ExecutionTracingTest.php index 3aa603eaa..92a790800 100644 --- a/tests/Unit/V2/ExecutionTracingTest.php +++ b/tests/Unit/V2/ExecutionTracingTest.php @@ -23,6 +23,7 @@ use RuntimeException; use Symfony\Component\Process\Process; use Workflow\V2\Support\ExecutionTracing; +use Workflow\V2\Support\WorkflowExecution; use Workflow\V2\Tracing\LogContext; final class ExecutionTracingTest extends TestCase @@ -177,6 +178,78 @@ public function testLegacyAndMalformedCarriersStartSeparateSegmentsWithoutWorker } } + public function testWorkflowFibersInheritTheirCreatorWithoutReplacingApplicationStorage(): void + { + $storage = Context::storage(); + $executions = []; + $observed = []; + foreach (['run-a', 'run-b'] as $run) { + ExecutionTracing::activity(self::fields($run), self::carrier(), function () use ( + $run, + &$executions, + &$observed + ): void { + $parent = ExecutionTracing::current(); + $executions[] = WorkflowExecution::startCallback(function () use ($run, $parent, &$observed): int { + self::assertSame($this->provider, Globals::tracerProvider()); + self::assertSame($parent, ExecutionTracing::current()); + $observed[] = ExecutionTracing::current()['workflow_run_id']; + Fiber::suspend(); + self::assertSame($parent, ExecutionTracing::current()); + $observed[] = ExecutionTracing::current()['workflow_run_id']; + + return $run === 'run-a' ? 7 : 9; + }); + self::assertSame($parent, ExecutionTracing::current()); + }, enabled: true); + self::assertSame($this->initial, Context::getCurrent()); + } + foreach ($executions as $execution) { + $execution->send(null); + self::assertSame($this->initial, Context::getCurrent()); + } + self::assertSame([7, 9], array_map(static fn ($execution) => $execution->getReturn(), $executions)); + self::assertSame(['run-a', 'run-b', 'run-a', 'run-b'], $observed); + self::assertSame($storage, Context::storage()); + } + + public function testWorkflowFiberFailureAndDiscardUnwindTheBoundScope(): void + { + $failure = new RuntimeException('application failure'); + $unwound = []; + $first = null; + $second = null; + ExecutionTracing::activity(self::fields(), self::carrier(), static function () use ( + &$first, + &$second, + &$unwound + ): void { + $callback = static function (string $id) use (&$unwound): void { + try { + Fiber::suspend(); + } finally { + $unwound[$id] = ExecutionTracing::current(); + } + }; + $first = WorkflowExecution::startCallback($callback, ['failure']); + $second = WorkflowExecution::startCallback($callback, ['discard']); + }, enabled: true); + try { + $first->throw($failure); + self::fail('The workflow exception must survive context cleanup.'); + } catch (RuntimeException $observed) { + self::assertSame($failure, $observed); + } + self::assertSame($this->initial, Context::getCurrent()); + unset($second); + self::assertSame($this->initial, Context::getCurrent()); + self::assertSame(['failure', 'discard'], array_keys($unwound)); + foreach ($unwound as $fields) { + self::assertSame('run-1', $fields['workflow_run_id']); + self::assertSame(self::TRACE_ID, $fields['trace_id']); + } + } + public function testDisabledInstrumentationLeavesTheApplicationContextUntouched(): void { $calls = 0; diff --git a/tests/Unit/V2/ReplayRegressionCorpusTest.php b/tests/Unit/V2/ReplayRegressionCorpusTest.php index a2227a88f..36fa3f7c5 100644 --- a/tests/Unit/V2/ReplayRegressionCorpusTest.php +++ b/tests/Unit/V2/ReplayRegressionCorpusTest.php @@ -7,9 +7,15 @@ use Illuminate\Config\Repository; use Illuminate\Container\Container; use Illuminate\Foundation\Application; +use OpenTelemetry\API\Instrumentation\Configurator; +use OpenTelemetry\Context\Context; +use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; +use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor; +use OpenTelemetry\SDK\Trace\TracerProvider; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Tests\Fixtures\V2\TestReplayMapOrderWorkflow; +use Tests\Fixtures\V2\TestTracingReplayWorkflow; use Throwable; use Workflow\Serializers\Serializer; use Workflow\V2\Enums\HistoryEventType; @@ -18,6 +24,7 @@ use Workflow\V2\Models\WorkflowServiceCall; use Workflow\V2\Support\ActivitySnapshot; use Workflow\V2\Support\CooperativeCancellationDelivery; +use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\HistoryEventPayloadContract; use Workflow\V2\Support\ServiceCallCancellationOrigin; use Workflow\V2\Support\WorkflowFiberRunner; @@ -112,6 +119,12 @@ public function testFixtureExecutesThroughColdReplayRunner(array $fixture): void sprintf('Replay fixture workflow [%s] must be an autoloadable V2 workflow.', $workflowClass), ); + if ($fixture['id'] === 'workflow-fiber-observability-context') { + $this->assertWorkflowFiberObservabilityContext($fixture); + + return; + } + if (isset($fixture['expected_failure'])) { try { WorkflowFiberRunner::forClass( @@ -331,6 +344,55 @@ public static function replayRegressionFixtures(): array return $fixtures; } + private function assertWorkflowFiberObservabilityContext(array $fixture): void + { + $exporter = new InMemoryExporter(); + $provider = new TracerProvider(new SimpleSpanProcessor($exporter)); + $scope = Configurator::create()->withTracerProvider($provider)->activate(); + $initial = Context::getCurrent(); + TestTracingReplayWorkflow::$contexts = []; + $runner = null; + $fields = []; + try { + $step = ExecutionTracing::workflow([ + 'workflow_run_id' => 'replay-origin', + 'namespace' => '0', + 'task_id' => 'original-task', + ], null, static function () use ($fixture, &$runner, &$fields): WorkflowStep { + $fields = ExecutionTracing::current(); + $runner = WorkflowFiberRunner::forClass( + $fixture['workflow']['type'], + 'replay-origin', + 'replay-origin', + $fixture['workflow']['arguments'], + $fixture['workflow']['payload_codec'], + ); + + return $runner->step(); + }, enabled: true); + $this->assertStepMatches($fixture['command_sequence'][0], $step, 'Original context first step'); + $this->assertSame($initial, Context::getCurrent()); + $step = ExecutionTracing::workflow([ + 'workflow_run_id' => 'unrelated-run', + 'task_id' => 'unrelated-task', + ], null, static fn (): WorkflowStep => $runner->step( + $fixture['command_sequence'][0]['resume_with'] + ), enabled: true); + $this->assertStepMatches($fixture['expected'], $step, 'Original context resumed step'); + $this->assertSame([$fields, $fields], TestTracingReplayWorkflow::$contexts); + $this->assertSame($initial, Context::getCurrent()); + $this->assertSame([], ExecutionTracing::current()); + // Replaying an activity outcome never executes or traces that activity again. + $this->assertCount(2, $exporter->getSpans()); + foreach ($exporter->getSpans() as $span) { + $this->assertSame('durable.workflow.task', $span->getName()); + } + } finally { + $scope->detach(); + $provider->shutdown(); + } + } + /** * @param array $fixture */ From a208207794937b4b2d2b62a1c18eb71f758e0009 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 12:43:13 +0000 Subject: [PATCH 05/77] Keep unbound Fibers outside durable logging context --- src/V2/Support/ExecutionTracing.php | 35 +++++++++++++++++++++++--- tests/Unit/V2/ExecutionTracingTest.php | 23 +++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php index ead5cb6d7..3a8ba0a7d 100644 --- a/src/V2/Support/ExecutionTracing.php +++ b/src/V2/Support/ExecutionTracing.php @@ -5,6 +5,7 @@ namespace Workflow\V2\Support; use Composer\InstalledVersions; +use Fiber; use OpenTelemetry\API\Baggage\Propagation\BaggagePropagator; use OpenTelemetry\API\Globals; use OpenTelemetry\API\Instrumentation\Configurator; @@ -17,6 +18,7 @@ use OpenTelemetry\Context\ContextKeyInterface; use OpenTelemetry\Context\ScopeInterface; use Throwable; +use WeakMap; /** @internal */ final class ExecutionTracing @@ -29,6 +31,11 @@ final class ExecutionTracing private static ?ContextKeyInterface $correlationKey = null; + /** + * @var WeakMap|null + */ + private static ?WeakMap $activeFibers = null; + public static function bind(callable $callback): callable { try { @@ -45,15 +52,13 @@ public static function bind(callable $callback): callable $scope = null; try { $scope = $context->activate(); + self::enterFiberScope(); } catch (Throwable) { } try { return $callback(...$arguments); } finally { - try { - $scope?->detach(); - } catch (Throwable) { - } + self::close($scope, null); } }; } @@ -109,6 +114,12 @@ public static function current(): array return []; } + // The SDK's compatibility fallback to the main Fiber is not a durable binding. + $fiber = Fiber::getCurrent(); + if ($fiber !== null && ! isset(self::$activeFibers[$fiber])) { + return []; + } + $fields = Context::getCurrent()->get(self::$correlationKey); return is_array($fields) ? $fields : []; @@ -178,6 +189,7 @@ private static function segment( self::$correlationKey ??= Context::createKey('durable-workflow.correlation'); $scope = $span->storeInContext($parent) ->with(self::$correlationKey, $fields)->activate(); + self::enterFiberScope(); } catch (Throwable) { self::close($scope, $span); @@ -219,10 +231,25 @@ private static function close(?ScopeInterface $scope, ?SpanInterface $span): voi $scope?->detach(); } catch (Throwable) { } + $fiber = Fiber::getCurrent(); + if ($scope !== null && $fiber !== null && isset(self::$activeFibers[$fiber])) { + if (--self::$activeFibers[$fiber] === 0) { + unset(self::$activeFibers[$fiber]); + } + } try { // The application owns buffering and provider shutdown. Never flush per task. $span?->end(); } catch (Throwable) { } } + + private static function enterFiberScope(): void + { + $fiber = Fiber::getCurrent(); + if ($fiber !== null) { + self::$activeFibers ??= new WeakMap(); + self::$activeFibers[$fiber] = (self::$activeFibers[$fiber] ?? 0) + 1; + } + } } diff --git a/tests/Unit/V2/ExecutionTracingTest.php b/tests/Unit/V2/ExecutionTracingTest.php index 92a790800..a96fddd52 100644 --- a/tests/Unit/V2/ExecutionTracingTest.php +++ b/tests/Unit/V2/ExecutionTracingTest.php @@ -250,6 +250,29 @@ public function testWorkflowFiberFailureAndDiscardUnwindTheBoundScope(): void } } + public function testUnboundFiberDoesNotReadTheMainFibersDurableContext(): void + { + set_error_handler(static function (int $severity, string $message): never { + throw new RuntimeException($message); + }); + try { + ExecutionTracing::activity(self::fields(), self::carrier(), static function (): void { + $fields = ExecutionTracing::current(); + $fiber = new Fiber(static function (): void { + self::assertSame([], ExecutionTracing::current()); + Fiber::suspend(); + self::assertSame([], ExecutionTracing::current()); + }); + $fiber->start(); + self::assertSame($fields, ExecutionTracing::current()); + $fiber->resume(); + self::assertSame($fields, ExecutionTracing::current()); + }, enabled: true); + } finally { + restore_error_handler(); + } + } + public function testDisabledInstrumentationLeavesTheApplicationContextUntouched(): void { $calls = 0; From 083df06005eb7b748b086cd36ae7116df7cb4f1c Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 13:03:40 +0000 Subject: [PATCH 06/77] Verify automatic HTTP tracing and correlate active client spans --- .github/tracing-http/bootstrap.php | 6 + .github/tracing-http/composer.json | 13 +++ .github/workflows/php.yml | 49 +++++++- src/V2/Support/ExecutionTracing.php | 12 +- src/V2/Tracing/LogContext.php | 2 +- tests/Fixtures/V2/tracing-http-router.php | 11 ++ tests/Fixtures/V2/tracing-process.php | 63 +++++++++- tests/Unit/V2/ExecutionTracingTest.php | 12 +- tests/Unit/V2/HttpExecutionTracingTest.php | 129 +++++++++++++++++++++ 9 files changed, 286 insertions(+), 11 deletions(-) create mode 100644 .github/tracing-http/bootstrap.php create mode 100644 .github/tracing-http/composer.json create mode 100644 tests/Fixtures/V2/tracing-http-router.php create mode 100644 tests/Unit/V2/HttpExecutionTracingTest.php diff --git a/.github/tracing-http/bootstrap.php b/.github/tracing-http/bootstrap.php new file mode 100644 index 000000000..b8fe5411c --- /dev/null +++ b/.github/tracing-http/bootstrap.php @@ -0,0 +1,6 @@ +- + vendor/bin/phpunit --no-configuration + --bootstrap .github/tracing-http/bootstrap.php + --do-not-record-test-run-history --fail-on-warning --fail-on-skipped + tests/Unit/V2/HttpExecutionTracingTest.php + pr-unit-contracts: needs: preflight if: ${{ github.event_name == 'pull_request' }} @@ -339,6 +374,11 @@ jobs: --weight-profile=mysql --output-dir=build/pr-selection + - name: Run optional embedded activity tracing controls + run: >- + vendor/bin/phpunit tests/Unit/V2/ActivityExecutionTracingTest.php + --fail-on-warning --log-junit build/test-results/pr-activity-tracing.xml + - name: Run database-independent unit contracts run: | mkdir -p build/test-results @@ -353,11 +393,6 @@ jobs: env: REDIS_HOST: 127.0.0.1 - - name: Run optional embedded activity tracing controls - run: >- - vendor/bin/phpunit tests/Unit/V2/ActivityExecutionTracingTest.php - --fail-on-warning --log-junit build/test-results/pr-activity-tracing.xml - - name: Run cancellation timeline unit cases run: >- vendor/bin/phpunit tests/Unit/V2/CancellationHistoryTimelineTest.php @@ -1283,6 +1318,7 @@ jobs: - preflight - regression-corpus - quality + - tracing-http - pr-unit-contracts - pr-feature-mysql - feature-mysql @@ -1303,6 +1339,9 @@ jobs: - name: Require repository preflight run: test "${{ needs.preflight.result }}" = "success" + - name: Require the actual HTTP tracing consumer + run: test "${{ needs.tracing-http.result }}" = "success" + - name: Check pull-request gate if: ${{ github.event_name == 'pull_request' }} run: | diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php index 3a8ba0a7d..032b012f2 100644 --- a/src/V2/Support/ExecutionTracing.php +++ b/src/V2/Support/ExecutionTracing.php @@ -121,8 +121,18 @@ public static function current(): array } $fields = Context::getCurrent()->get(self::$correlationKey); + if (! is_array($fields)) { + return []; + } + // Application instrumentation may activate a child span inside this durable scope. + unset($fields['trace_id'], $fields['span_id']); + $spanContext = Span::getCurrent()->getContext(); + if ($spanContext->isValid()) { + $fields['trace_id'] = $spanContext->getTraceId(); + $fields['span_id'] = $spanContext->getSpanId(); + } - return is_array($fields) ? $fields : []; + return $fields; } public static function recordFailure(Throwable $error): void diff --git a/src/V2/Tracing/LogContext.php b/src/V2/Tracing/LogContext.php index 3d118bb9b..490bb18ce 100644 --- a/src/V2/Tracing/LogContext.php +++ b/src/V2/Tracing/LogContext.php @@ -9,7 +9,7 @@ use Throwable; use Workflow\V2\Support\ExecutionTracing; -/** Laravel logging tap for activity execution correlation. */ +/** Laravel logging tap for scoped workflow and activity correlation. */ final class LogContext { public function __invoke(Logger $logger): void diff --git a/tests/Fixtures/V2/tracing-http-router.php b/tests/Fixtures/V2/tracing-http-router.php new file mode 100644 index 000000000..d72230261 --- /dev/null +++ b/tests/Fixtures/V2/tracing-http-router.php @@ -0,0 +1,11 @@ + $_SERVER['HTTP_TRACEPARENT'] ?? null, + 'tracestate' => $_SERVER['HTTP_TRACESTATE'] ?? null, + 'baggage' => $_SERVER['HTTP_BAGGAGE'] ?? null, + 'request_id' => $_SERVER['HTTP_X_REQUEST_ID'] ?? null, +], JSON_THROW_ON_ERROR); diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 915396c2e..4d6d3d498 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -4,13 +4,18 @@ namespace Tests\Fixtures\V2; +use GuzzleHttp\Client; +use GuzzleHttp\TransferStats; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Monolog\Formatter\JsonFormatter; +use OpenTelemetry\API\Baggage\Propagation\BaggagePropagator; use OpenTelemetry\API\Instrumentation\Configurator; use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator; +use OpenTelemetry\API\Trace\Span; use OpenTelemetry\Context\Context; +use OpenTelemetry\Context\Propagation\MultiTextMapPropagator; use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor; use OpenTelemetry\SDK\Trace\TracerProvider; @@ -65,6 +70,8 @@ public function __construct() final class TracingProcessActivity extends Activity { + public static ?array $httpResponse = null; + public int $tries = 2; public function __construct(TracingProcessDependency $dependency) @@ -84,6 +91,30 @@ public function handle(bool $retry, TracingProcessDependency $dependency): array throw new \RuntimeException('deliberate original attempt failure'); } + $url = getenv('DW_TEST_TRACING_HTTP_URL'); + if (is_string($url) && $url !== '') { + $response = app(Client::class)->post($url, [ + 'timeout' => 2, + 'connect_timeout' => 1, + 'headers' => [ + 'X-Request-ID' => 'activity-http-request', + 'Authorization' => 'Bearer test-auth-sentinel', + ], + 'json' => [ + 'private' => 'payload-private-sentinel', + ], + 'on_stats' => static function (TransferStats $stats): void { + $context = Span::getCurrent()->getContext(); + Log::info('http transfer', [ + 'request_id' => 'activity-http-request', + 'active_trace_id' => $context->getTraceId(), + 'active_span_id' => $context->getSpanId(), + ]); + }, + ]); + self::$httpResponse = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR); + } + return [9007199254740993, null, '雪']; } } @@ -107,6 +138,7 @@ protected function defineEnvironment($app): void 'workflows.v2.compatibility.namespace' => '0', 'workflows.v2.task_dispatch_mode' => 'queue', 'workflows.tracing.enabled' => true, + 'workflows.tracing.baggage_allowlist' => ['approved'], 'queue.default' => 'database', 'queue.connections.database' => [ 'driver' => 'database', @@ -156,16 +188,28 @@ protected function defineEnvironment($app): void ); $exporter = new InMemoryExporter(); $provider = new TracerProvider(new SimpleSpanProcessor($exporter)); +$propagator = new MultiTextMapPropagator([ + TraceContextPropagator::getInstance(), + BaggagePropagator::getInstance(), +]); $configuration = Configurator::create()->withTracerProvider($provider) - ->withPropagator(TraceContextPropagator::getInstance())->activate(); + ->withPropagator($propagator) + ->activate(); $initial = Context::getCurrent(); try { $parent = $mode === 'admit' ? '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' : '00-11111111111111111111111111111111-2222222222222222-01'; - $scope = TraceContextPropagator::getInstance()->extract([ + $headers = [ 'traceparent' => $parent, - ])->activate(); + ]; + if ($mode === 'admit' && getenv('DW_TEST_TRACING_HTTP_URL')) { + $headers['tracestate'] = 'dwtest=one'; + $headers['baggage'] = 'approved=tenant-a,private=must-not-propagate'; + } + $scope = $propagator->extract($headers) + ->activate(); + $outsideHttp = null; try { if ($mode === 'admit') { if (Artisan::call('migrate', [ @@ -200,6 +244,16 @@ protected function defineEnvironment($app): void throw new \RuntimeException(Artisan::output()); } Log::info('worker outside'); + if (TracingProcessActivity::$httpResponse !== null) { + $response = app(Client::class)->post(getenv('DW_TEST_TRACING_HTTP_URL'), [ + 'timeout' => 2, + 'connect_timeout' => 1, + 'headers' => [ + 'X-Request-ID' => 'outside-http-request', + ], + ]); + $outsideHttp = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR); + } } elseif ($mode !== 'inspect') { throw new \RuntimeException('Unknown fixture command.'); } @@ -222,8 +276,11 @@ protected function defineEnvironment($app): void 'repairs' => (int) WorkflowTask::query()->sum('repair_count'), 'outside_context' => ExecutionTracing::current(), 'scope_restored' => Context::getCurrent() === $initial, + 'http_response' => TracingProcessActivity::$httpResponse, + 'outside_http' => $outsideHttp, 'spans' => array_map(static fn ($span): array => [ 'name' => $span->getName(), + 'kind' => $span->getKind(), 'trace_id' => $span->getContext() ->getTraceId(), 'span_id' => $span->getContext() diff --git a/tests/Unit/V2/ExecutionTracingTest.php b/tests/Unit/V2/ExecutionTracingTest.php index a96fddd52..8bcae85ab 100644 --- a/tests/Unit/V2/ExecutionTracingTest.php +++ b/tests/Unit/V2/ExecutionTracingTest.php @@ -78,6 +78,15 @@ public function testRealProviderAndLoggingKeepDurableRequestAndTraceIdentitiesSe 'request_id' => 'application-request', ]); $client = Globals::tracerProvider()->getTracer('application')->spanBuilder('outbound')->startSpan(); + $clientScope = $client->activate(); + try { + $logger->info('outbound executing'); + self::assertSame($fields['workflow_run_id'], ExecutionTracing::current()['workflow_run_id']); + self::assertSame($client->getContext()->getSpanId(), ExecutionTracing::current()['span_id']); + } finally { + $clientScope->detach(); + } + self::assertSame($fields, ExecutionTracing::current()); $client->end(); return 7; @@ -96,9 +105,10 @@ public function testRealProviderAndLoggingKeepDurableRequestAndTraceIdentitiesSe self::assertArrayNotHasKey('arguments', $records[0]['context']); self::assertSame([ 'request_id' => 'outside', - ], $records[1]['context']); + ], $records[2]['context']); $spans = $this->exporter->getSpans(); self::assertCount(2, $spans); + self::assertSame($spans[0]->getContext()->getSpanId(), $records[1]['context']['span_id']); self::assertSame($spans[1]->getContext()->getSpanId(), $spans[0]->getParentContext()->getSpanId()); self::assertSame(self::PARENT_ID, $spans[1]->getParentContext()->getSpanId()); self::assertSame('origin-request', $spans[1]->getAttributes()->get('request_id')); diff --git a/tests/Unit/V2/HttpExecutionTracingTest.php b/tests/Unit/V2/HttpExecutionTracingTest.php new file mode 100644 index 000000000..780403e26 --- /dev/null +++ b/tests/Unit/V2/HttpExecutionTracingTest.php @@ -0,0 +1,129 @@ +setTimeout(5); + $server->start(); + try { + self::assertTrue($server->waitUntil(static fn (string $type, string $output): bool => str_contains( + $output, + 'Development Server' + ))); + $admitted = $this->runWorker($directory, 'admit', $address); + $workflow = $this->runWorker($directory, 'work', $address); + $activity = $this->runWorker($directory, 'work', $address); + $completion = $this->runWorker($directory, 'work', $address); + $finished = $this->runWorker($directory, 'inspect', $address); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame(0, $finished['repairs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(3, $finished['tasks']); + self::assertCount(3, array_unique([$workflow['pid'], $activity['pid'], $completion['pid']])); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { + self::assertSame($admitted[$key], $finished[$key]); + } + $attempts = array_values(array_filter( + $activity['spans'], + static fn (array $span): bool => $span['name'] === 'durable.activity.attempt' + )); + $clients = array_values(array_filter( + $activity['spans'], + static fn (array $span): bool => $span['kind'] === SpanKind::KIND_CLIENT + )); + self::assertCount(1, $attempts); + self::assertCount(2, $clients); + self::assertSame($attempts[0]['span_id'], $clients[0]['parent_id']); + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $clients[0]['trace_id']); + self::assertSame( + '00-' . $clients[0]['trace_id'] . '-' . $clients[0]['span_id'] . '-01', + $activity['http_response']['traceparent'] + ); + self::assertSame('dwtest=one', $activity['http_response']['tracestate']); + self::assertSame('approved=tenant-a', $activity['http_response']['baggage']); + self::assertSame('activity-http-request', $activity['http_response']['request_id']); + self::assertSame('11111111111111111111111111111111', $clients[1]['trace_id']); + self::assertSame('2222222222222222', $clients[1]['parent_id']); + self::assertSame( + '00-' . $clients[1]['trace_id'] . '-' . $clients[1]['span_id'] . '-01', + $activity['outside_http']['traceparent'] + ); + self::assertNull($activity['outside_http']['baggage']); + self::assertSame('outside-http-request', $activity['outside_http']['request_id']); + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); + $transfers = array_values(array_filter( + $records, + static fn (array $record): bool => $record['message'] === 'http transfer' + )); + self::assertCount(1, $transfers); + $fields = $transfers[0]['context']; + self::assertSame($admitted['run_id'], $fields['workflow_run_id']); + self::assertSame($attempts[0]['fields']['activity_attempt_id'], $fields['activity_attempt_id']); + self::assertSame('activity-http-request', $fields['request_id']); + self::assertSame($fields['active_trace_id'], $fields['trace_id']); + self::assertSame($fields['active_span_id'], $fields['span_id']); + $telemetry = json_encode($activity['spans'], JSON_THROW_ON_ERROR); + foreach (['test-auth-sentinel', 'payload-private-sentinel', 'must-not-propagate'] as $excluded) { + self::assertStringNotContainsString($excluded, $telemetry); + } + } finally { + $server->stop(); + (new Filesystem())->deleteDirectory($directory); + } + } + + private function runWorker(string $directory, string $mode, string $address): array + { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_HTTP_URL' => 'http://' . $address . '/headers', + ]); + $process->setTimeout(30); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + } +} From 5b59ac5d622b91e34061a58a43bb58ac190e8dcc Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 13:27:53 +0000 Subject: [PATCH 07/77] Exercise incoming HTTP traces across independent activity workers --- tests/Fixtures/V2/tracing-process.php | 64 ++++++++- tests/Unit/V2/IncomingHttpTracingTest.php | 150 ++++++++++++++++++++++ 2 files changed, 207 insertions(+), 7 deletions(-) create mode 100644 tests/Unit/V2/IncomingHttpTracingTest.php diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 4d6d3d498..0b63a185d 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -6,6 +6,9 @@ use GuzzleHttp\Client; use GuzzleHttp\TransferStats; +use Illuminate\Contracts\Http\Kernel; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -14,6 +17,7 @@ use OpenTelemetry\API\Instrumentation\Configurator; use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator; use OpenTelemetry\API\Trace\Span; +use OpenTelemetry\API\Trace\SpanKind; use OpenTelemetry\Context\Context; use OpenTelemetry\Context\Propagation\MultiTextMapPropagator; use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; @@ -30,6 +34,13 @@ use Workflow\V2\Workflow; use Workflow\V2\WorkflowStub; +$incomingHttp = PHP_SAPI === 'cli-server'; +if ($incomingHttp) { + $argv = [ + __FILE__, getenv('DW_TEST_TRACING_DIRECTORY'), 'admit', 'once', + ...json_decode(getenv('DW_TEST_TRACING_AUTOLOADS'), true, flags: JSON_THROW_ON_ERROR), + ]; +} $retry = $argv[3] === 'retry'; foreach (array_slice($argv, 4) as $autoload) { require_once $autoload; @@ -42,13 +53,13 @@ public function __construct(TracingProcessWorkflowDependency $dependency) Log::info('workflow constructor'); } - public function handle(bool $retry, TracingProcessWorkflowDependency $dependency): array + public function handle(bool $retry, TracingProcessWorkflowDependency $dependency, bool $twice = false): array { Log::info('workflow before activity'); $result = self::activity(TracingProcessActivity::class, $retry); Log::info('workflow after activity'); - return $result; + return $twice ? [$result, self::activity(TracingProcessActivity::class, $retry)] : $result; } } @@ -207,7 +218,22 @@ protected function defineEnvironment($app): void $headers['tracestate'] = 'dwtest=one'; $headers['baggage'] = 'approved=tenant-a,private=must-not-propagate'; } - $scope = $propagator->extract($headers) + $request = $incomingHttp ? Request::capture() : null; + if ($request !== null) { + $headers = []; + foreach (['traceparent', 'tracestate', 'baggage'] as $header) { + $value = $request->headers->get($header); + if ($value !== null) { + $headers[$header] = $value; + } + } + } + $parentContext = $propagator->extract($headers, context: $initial); + // The fixture application owns its HTTP instrumentation, as a real Laravel app would. + $requestSpan = $incomingHttp ? $provider->getTracer('test.application') + ->spanBuilder('POST /start') + ->setSpanKind(SpanKind::KIND_SERVER)->setParent($parentContext)->startSpan() : null; + $scope = ($requestSpan?->storeInContext($parentContext) ?? $parentContext) ->activate(); $outsideHttp = null; try { @@ -223,10 +249,33 @@ protected function defineEnvironment($app): void DB::statement('CREATE TABLE failed_jobs (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, connection TEXT NOT NULL, queue TEXT NOT NULL, payload TEXT NOT NULL, exception TEXT NOT NULL, failed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)'); - WorkflowStub::make(TracingProcessWorkflow::class, 'cold-tracing', '0')->start( - $retry, - new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), - ); + if ($request !== null) { + $app['router']->post('/start', static function () use ($retry): JsonResponse { + $stub = WorkflowStub::make(TracingProcessWorkflow::class, 'cold-tracing', '0'); + $stub->start( + $retry, + true, + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); + + return new JsonResponse([ + 'run_id' => $stub->runId(), + ], 201); + }); + $kernel = $app->make(Kernel::class); + $response = $kernel->handle($request); + $kernel->terminate($request, $response); + if ($response->getStatusCode() !== 201) { + throw new \RuntimeException('HTTP admission failed: ' . $response->getContent()); + } + http_response_code(201); + header('Content-Type: application/json'); + } else { + WorkflowStub::make(TracingProcessWorkflow::class, 'cold-tracing', '0')->start( + $retry, + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); + } } elseif ($mode === 'duplicate') { $result = WorkflowStub::make(TracingProcessWorkflow::class, 'cold-tracing', '0') ->attemptStart($retry, StartOptions::returnExistingActive()); @@ -259,6 +308,7 @@ protected function defineEnvironment($app): void } } finally { $scope->detach(); + $requestSpan?->end(); } $run = WorkflowRun::query()->where('workflow_instance_id', 'cold-tracing')->sole(); $output = [ diff --git a/tests/Unit/V2/IncomingHttpTracingTest.php b/tests/Unit/V2/IncomingHttpTracingTest.php new file mode 100644 index 000000000..e4410ea95 --- /dev/null +++ b/tests/Unit/V2/IncomingHttpTracingTest.php @@ -0,0 +1,150 @@ +autoloads(); + $socket = stream_socket_server('tcp://127.0.0.1:0'); + self::assertIsResource($socket); + $address = stream_socket_get_name($socket, false); + self::assertIsString($address); + fclose($socket); + $server = new Process([ + PHP_BINARY, '-S', $address, __DIR__ . '/../../Fixtures/V2/tracing-process.php', + ], env: [ + 'DW_TEST_TRACING_DIRECTORY' => $directory, + 'DW_TEST_TRACING_AUTOLOADS' => json_encode($autoloads, JSON_THROW_ON_ERROR), + ]); + $server->setTimeout(10); + $server->start(); + try { + self::assertTrue($server->waitUntil(static fn (string $type, string $output): bool => str_contains( + $output, + 'Development Server' + ))); + $response = (new Client())->post('http://' . $address . '/start', [ + 'timeout' => 10, + 'connect_timeout' => 1, + 'headers' => [ + 'traceparent' => '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + 'tracestate' => 'dwtest=one', + 'baggage' => 'approved=tenant-a,private=must-not-propagate', + 'X-Request-ID' => 'incoming-http-request', + ], + ]); + self::assertSame(201, $response->getStatusCode()); + $admitted = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR); + $server->stop(); + self::assertFalse($server->isRunning()); + self::assertSame([], $admitted['outside_context']); + self::assertTrue($admitted['scope_restored']); + self::assertCount(1, $admitted['spans']); + $requestSpan = $admitted['spans'][0]; + self::assertSame(SpanKind::KIND_SERVER, $requestSpan['kind']); + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $requestSpan['trace_id']); + self::assertSame('00f067aa0ba902b7', $requestSpan['parent_id']); + $carrier = json_decode($admitted['carrier'], true, flags: JSON_THROW_ON_ERROR)['carrier']; + self::assertSame( + '00-' . $requestSpan['trace_id'] . '-' . $requestSpan['span_id'] . '-01', + $carrier['traceparent'] + ); + self::assertSame('dwtest=one', $carrier['tracestate']); + self::assertSame('approved=tenant-a', $carrier['baggage']); + $workers = []; + for ($remaining = 5; $remaining > 0; --$remaining) { + $workers[] = $this->worker($directory, 'work', $autoloads); + } + self::assertCount(5, array_unique(array_column($workers, 'pid'))); + $activityIds = []; + $activitySpans = []; + foreach ($workers as $worker) { + self::assertSame([], $worker['outside_context']); + self::assertTrue($worker['scope_restored']); + self::assertCount(1, $worker['spans']); + $span = $worker['spans'][0]; + self::assertSame($requestSpan['trace_id'], $span['trace_id']); + self::assertSame($requestSpan['span_id'], $span['parent_id']); + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + if ($span['name'] === 'durable.activity.attempt') { + self::assertSame(1, $span['fields']['activity_attempt_number']); + $activityIds[] = $span['fields']['activity_id']; + $activitySpans[$span['fields']['activity_id']] = $span; + } + } + self::assertCount(2, array_unique($activityIds)); + $finished = $this->worker($directory, 'inspect', $autoloads); + self::assertSame('completed', $finished['status']); + self::assertSame([ + [9007199254740993, null, '雪'], [9007199254740993, null, '雪'], + ], $finished['output']); + self::assertSame(5, $finished['tasks']); + self::assertSame(0, $finished['repairs']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { + self::assertSame($admitted[$key], $finished[$key]); + } + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); + $callbacks = array_values(array_filter( + $records, + static fn (array $record): bool => $record['message'] === 'callback' + )); + self::assertCount(2, $callbacks); + foreach ($callbacks as $callback) { + $fields = $callback['context']; + self::assertSame($admitted['run_id'], $fields['workflow_run_id']); + self::assertArrayHasKey($fields['activity_id'], $activitySpans); + $span = $activitySpans[$fields['activity_id']]; + self::assertSame($span['fields']['activity_attempt_id'], $fields['activity_attempt_id']); + self::assertSame($requestSpan['trace_id'], $fields['trace_id']); + self::assertSame($span['span_id'], $fields['span_id']); + } + } finally { + $server->stop(); + (new Filesystem())->deleteDirectory($directory); + } + } + + private function worker(string $directory, string $mode, array $autoloads): array + { + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', ...$autoloads, + ]); + $process->setTimeout(30); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + + return json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + } + + private function autoloads(): array + { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + + return array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ); + } +} From b04052994fafca2a4238430abfedcb19764c57a3 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 13:32:51 +0000 Subject: [PATCH 08/77] Run incoming HTTP tracing control in pull request gates --- .github/workflows/php.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index b47b37248..d252871d1 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -374,9 +374,10 @@ jobs: --weight-profile=mysql --output-dir=build/pr-selection - - name: Run optional embedded activity tracing controls + - name: Run optional embedded tracing controls run: >- vendor/bin/phpunit tests/Unit/V2/ActivityExecutionTracingTest.php + tests/Unit/V2/IncomingHttpTracingTest.php --fail-on-warning --log-junit build/test-results/pr-activity-tracing.xml - name: Run database-independent unit contracts From 3a1e46ad738683ecd15d337893388e0abb422165 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 14:02:52 +0000 Subject: [PATCH 09/77] Verify tracing recovery after an actual activity worker crash --- .github/workflows/php.yml | 1 + tests/Fixtures/V2/tracing-process.php | 105 ++++++++- .../Unit/V2/RecoveryExecutionTracingTest.php | 215 ++++++++++++++++++ 3 files changed, 318 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/V2/RecoveryExecutionTracingTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index d252871d1..30224c7dc 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -378,6 +378,7 @@ jobs: run: >- vendor/bin/phpunit tests/Unit/V2/ActivityExecutionTracingTest.php tests/Unit/V2/IncomingHttpTracingTest.php + tests/Unit/V2/RecoveryExecutionTracingTest.php --fail-on-warning --log-junit build/test-results/pr-activity-tracing.xml - name: Run database-independent unit contracts diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 0b63a185d..03f4a5c24 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -26,9 +26,13 @@ use Orchestra\Testbench\Foundation\Application; use Workflow\Providers\WorkflowServiceProvider; use Workflow\V2\Activity; +use Workflow\V2\Models\ActivityAttempt; +use Workflow\V2\Models\ActivityExecution; +use Workflow\V2\Models\WorkflowHistoryEvent; use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Models\WorkflowTask; use Workflow\V2\StartOptions; +use Workflow\V2\Support\ActivityOptions; use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Tracing\LogContext; use Workflow\V2\Workflow; @@ -42,6 +46,7 @@ ]; } $retry = $argv[3] === 'retry'; +$recovery = getenv('DW_TEST_TRACING_RECOVERY') === 'true'; foreach (array_slice($argv, 4) as $autoload) { require_once $autoload; } @@ -71,6 +76,19 @@ public function __construct() } } +final class TracingRecoveryWorkflow extends Workflow +{ + public function handle(): array + { + return self::activity(TracingProcessActivity::class, new ActivityOptions( + maxAttempts: 2, + backoff: [0], + startToCloseTimeout: 2, + scheduleToCloseTimeout: 10, + ), false); + } +} + final class TracingProcessDependency { public function __construct() @@ -98,6 +116,27 @@ public function backoff(): int public function handle(bool $retry, TracingProcessDependency $dependency): array { Log::info('callback'); + if (getenv('DW_TEST_TRACING_CRASH_FIRST_ATTEMPT') === 'true' && $this->attemptCount() === 1) { + $marker = app() + ->basePath('claimed.json'); + file_put_contents($marker . '.tmp', json_encode( + ExecutionTracing::current() + [ + 'actor_pid' => getmypid(), + 'workflow_run_id' => $this->runId(), + 'activity_id' => $this->activityId(), + 'activity_attempt_id' => $this->attemptId(), + 'activity_attempt_number' => $this->attemptCount(), + 'task_id' => $this->taskId, + 'tracing_context' => ExecutionTracing::current(), + ], + JSON_THROW_ON_ERROR, + )); + rename($marker . '.tmp', $marker); + // The parent test kills this actual worker after observing its committed claim. + while (true) { + usleep(100_000); + } + } if ($retry && $this->attemptCount() === 1) { throw new \RuntimeException('deliberate original attempt failure'); } @@ -148,14 +187,14 @@ protected function defineEnvironment($app): void 'workflows.v1.enabled' => false, 'workflows.v2.compatibility.namespace' => '0', 'workflows.v2.task_dispatch_mode' => 'queue', - 'workflows.tracing.enabled' => true, + 'workflows.tracing.enabled' => getenv('DW_TEST_TRACING_ENABLED') !== 'false', 'workflows.tracing.baggage_allowlist' => ['approved'], 'queue.default' => 'database', 'queue.connections.database' => [ 'driver' => 'database', 'table' => 'jobs', 'queue' => 'default', - 'retry_after' => 90, + 'retry_after' => getenv('DW_TEST_TRACING_RECOVERY') === 'true' ? 3 : 90, ], 'queue.failed' => [ 'driver' => 'database-uuids', @@ -236,6 +275,7 @@ protected function defineEnvironment($app): void $scope = ($requestSpan?->storeInContext($parentContext) ?? $parentContext) ->activate(); $outsideHttp = null; + $repairReport = null; try { if ($mode === 'admit') { if (Artisan::call('migrate', [ @@ -270,6 +310,10 @@ protected function defineEnvironment($app): void } http_response_code(201); header('Content-Type: application/json'); + } elseif ($recovery) { + WorkflowStub::make(TracingRecoveryWorkflow::class, 'cold-tracing', '0')->start( + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); } else { WorkflowStub::make(TracingProcessWorkflow::class, 'cold-tracing', '0')->start( $retry, @@ -288,7 +332,7 @@ protected function defineEnvironment($app): void '--queue' => 'default', '--once' => true, '--tries' => 1, - '--timeout' => 30, + '--timeout' => $recovery ? 1 : 30, ]) !== 0) { throw new \RuntimeException(Artisan::output()); } @@ -303,6 +347,15 @@ protected function defineEnvironment($app): void ]); $outsideHttp = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR); } + } elseif ($mode === 'repair' && $recovery) { + $runId = WorkflowRun::query()->where('workflow_instance_id', 'cold-tracing')->sole()->id; + if (Artisan::call('workflow:v2:repair-pass', [ + '--run-id' => [$runId], + '--json' => true, + ]) !== 0) { + throw new \RuntimeException(Artisan::output()); + } + $repairReport = json_decode(Artisan::output(), true, flags: JSON_THROW_ON_ERROR); } elseif ($mode !== 'inspect') { throw new \RuntimeException('Unknown fixture command.'); } @@ -341,6 +394,52 @@ protected function defineEnvironment($app): void ->toArray(), ], $exporter->getSpans()), ]; + if ($recovery) { + $history = WorkflowHistoryEvent::query()->where('workflow_run_id', $run->id)->orderBy('sequence')->get(); + $output['recovery'] = [ + 'repair_report' => $repairReport, + 'failed_deliveries' => DB::table('failed_jobs')->get(['exception', 'payload'])->map( + static fn ($job): array => [ + 'exception' => substr($job->exception, 0, 700), + 'payload' => $job->payload, + ] + )->all(), + 'activities' => ActivityExecution::query()->orderBy('id')->get()->map(static fn ($activity): array => [ + 'id' => $activity->id, + 'status' => $activity->status->value, + 'attempt_count' => $activity->attempt_count, + 'retry_policy' => $activity->retry_policy, + 'schedule_deadline_at' => $activity->getRawOriginal('schedule_deadline_at'), + 'schedule_to_close_deadline_at' => $activity->getRawOriginal('schedule_to_close_deadline_at'), + 'close_deadline_at' => $activity->getRawOriginal('close_deadline_at'), + ])->all(), + 'attempts' => ActivityAttempt::query()->orderBy('attempt_number')->get()->map( + static fn ($attempt): array => [ + 'id' => $attempt->id, + 'activity_id' => $attempt->activity_execution_id, + 'task_id' => $attempt->workflow_task_id, + 'number' => $attempt->attempt_number, + 'status' => $attempt->status->value, + 'lease_expires_at' => $attempt->getRawOriginal('lease_expires_at'), + 'started_at' => $attempt->getRawOriginal('started_at'), + ] + )->all(), + 'history_types' => $history->map(static fn ($event): string => $event->event_type->value) + ->all(), + 'history_hashes' => $history->map(static fn ($event): string => hash( + 'sha256', + json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR), + ))->all(), + 'tasks' => WorkflowTask::query()->orderBy('created_at')->get()->map(static fn ($task): array => [ + 'id' => $task->id, + 'type' => $task->task_type->value, + 'status' => $task->status->value, + 'attempt_count' => $task->attempt_count, + 'repair_count' => $task->repair_count, + 'payload' => $task->payload, + ])->all(), + ]; + } } finally { $configuration->detach(); $provider->shutdown(); diff --git a/tests/Unit/V2/RecoveryExecutionTracingTest.php b/tests/Unit/V2/RecoveryExecutionTracingTest.php new file mode 100644 index 000000000..456111284 --- /dev/null +++ b/tests/Unit/V2/RecoveryExecutionTracingTest.php @@ -0,0 +1,215 @@ + [true], + 'disabled tracing control' => [false], + ]; + } + + #[DataProvider('tracingModes')] + public function testWorkerCrashThenAuthoredTimeoutRecoversContextInAColdRetry(bool $tracing): void + { + $this->tracing = $tracing; + $directory = sys_get_temp_dir() . '/dw-tracing-recovery-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $crashed = null; + try { + $admitted = $this->worker($directory, 'admit'); + $scheduled = $this->worker($directory, 'work'); + self::assertSame('waiting', $scheduled['status'], json_encode($scheduled, JSON_THROW_ON_ERROR)); + $crashed = $this->process($directory, 'work', true); + $crashed->start(); + $marker = $directory . '/claimed.json'; + $limit = hrtime(true) + 5_000_000_000; + while (! is_file($marker) && hrtime(true) < $limit && $crashed->isRunning()) { + usleep(20_000); + } + self::assertFileExists($marker, substr($crashed->getErrorOutput() . $crashed->getOutput(), -4096)); + $firstFields = json_decode(file_get_contents($marker), true, flags: JSON_THROW_ON_ERROR); + self::assertSame($crashed->getPid(), $firstFields['actor_pid']); + $crashed->signal(9); + try { + $crashed->wait(); + } catch (ProcessSignaledException $error) { + self::assertSame(9, $error->getSignal()); + } + self::assertTrue($crashed->hasBeenSignaled()); + self::assertSame(9, $crashed->getTermSignal()); + $first = $this->worker($directory, 'inspect'); + self::assertSame('running', $first['recovery']['attempts'][0]['status']); + self::assertSame($admitted['run_id'], $firstFields['workflow_run_id']); + if ($tracing) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $firstFields['trace_id']); + } else { + self::assertSame([], $firstFields['tracing_context']); + self::assertNull($admitted['carrier']); + } + self::assertSame(1, $firstFields['activity_attempt_number']); + self::assertSame($firstFields['activity_attempt_id'], $first['recovery']['attempts'][0]['id']); + self::assertSame($firstFields['task_id'], $first['recovery']['attempts'][0]['task_id']); + $deadline = $this->timestamp($first['recovery']['activities'][0]['close_deadline_at']); + $remaining = max(0.0, $deadline - microtime(true)); + self::assertLessThan(3.0, $remaining); + usleep((int) ceil($remaining * 1_000_000) + 50_000); + $repaired = $this->worker($directory, 'repair'); + self::assertSame(1, $repaired['recovery']['activities'][0]['attempt_count']); + self::assertSame(1, $repaired['recovery']['repair_report']['activity_timeouts_enforced']); + self::assertSame([], $repaired['recovery']['repair_report']['activity_timeout_failures']); + $workers = []; + for ($remainingWorkers = 6; $remainingWorkers > 0; --$remainingWorkers) { + $state = $this->worker($directory, 'inspect'); + if ($state['jobs'] === 0) { + break; + } + $workers[] = $this->worker($directory, 'work'); + } + $finished = $this->worker($directory, 'inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame(0, $finished['repairs'], json_encode($finished['recovery'], JSON_THROW_ON_ERROR)); + self::assertSame(0, $finished['jobs']); + // The killed Laravel delivery exhausts the job's fixed transport retry budget. + self::assertSame(1, $finished['failed_jobs'], json_encode($finished['recovery']['failed_deliveries'])); + self::assertStringStartsWith( + 'Illuminate\\Queue\\MaxAttemptsExceededException:', + $finished['recovery']['failed_deliveries'][0]['exception'] + ); + self::assertStringContainsString( + $firstFields['task_id'], + $finished['recovery']['failed_deliveries'][0]['payload'] + ); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { + self::assertSame($admitted[$key], $finished[$key]); + } + self::assertCount(1, $finished['recovery']['activities']); + $activity = $finished['recovery']['activities'][0]; + self::assertSame($firstFields['activity_id'], $activity['id']); + self::assertSame(2, $activity['attempt_count']); + self::assertSame('completed', $activity['status']); + self::assertSame($first['recovery']['activities'][0]['retry_policy'], $activity['retry_policy']); + self::assertSame( + $first['recovery']['activities'][0]['schedule_to_close_deadline_at'], + $activity['schedule_to_close_deadline_at'] + ); + self::assertCount(2, $finished['recovery']['attempts']); + $attempts = $finished['recovery']['attempts']; + self::assertSame($firstFields['activity_attempt_id'], $attempts[0]['id']); + self::assertSame('failed', $attempts[0]['status']); + self::assertSame('completed', $attempts[1]['status']); + self::assertSame(2, $attempts[1]['number']); + self::assertNotSame($attempts[0]['id'], $attempts[1]['id']); + self::assertGreaterThan( + 250, + $this->timestamp($first['recovery']['attempts'][0]['lease_expires_at']) + - $this->timestamp($first['recovery']['attempts'][0]['started_at']) + ); + $spans = []; + foreach ($workers as $worker) { + self::assertSame([], $worker['outside_context']); + self::assertTrue($worker['scope_restored']); + foreach ($worker['spans'] as $span) { + if ($span['name'] === 'durable.activity.attempt') { + $spans[] = $span; + } + } + } + self::assertCount($tracing ? 1 : 0, $spans); + if ($tracing) { + self::assertSame($firstFields['activity_id'], $spans[0]['fields']['activity_id']); + self::assertSame($attempts[1]['id'], $spans[0]['fields']['activity_attempt_id']); + self::assertSame($firstFields['trace_id'], $spans[0]['trace_id']); + self::assertSame('00f067aa0ba902b7', $spans[0]['parent_id']); + self::assertNotSame($firstFields['span_id'], $spans[0]['span_id']); + } + self::assertSame( + $first['recovery']['history_hashes'], + array_slice($finished['recovery']['history_hashes'], 0, count($first['recovery']['history_hashes'])) + ); + self::assertSame(1, array_count_values($finished['recovery']['history_types'])['ActivityRetryScheduled']); + self::assertSame(1, array_count_values($finished['recovery']['history_types'])['ActivityCompleted']); + self::assertSame(1, array_count_values($finished['recovery']['history_types'])['WorkflowCompleted']); + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); + $callbacks = array_values(array_filter( + $records, + static fn (array $record): bool => $record['message'] === 'callback' + )); + self::assertCount(2, $callbacks); + if ($tracing) { + self::assertSame($firstFields['activity_attempt_id'], $callbacks[0]['context']['activity_attempt_id']); + self::assertSame($attempts[1]['id'], $callbacks[1]['context']['activity_attempt_id']); + self::assertSame($firstFields['trace_id'], $callbacks[1]['context']['trace_id']); + self::assertSame($spans[0]['span_id'], $callbacks[1]['context']['span_id']); + } else { + foreach ($callbacks as $callback) { + self::assertArrayNotHasKey('workflow_run_id', $callback['context']); + self::assertArrayNotHasKey('trace_id', $callback['context']); + } + } + } finally { + $crashed?->stop(0, 9); + (new Filesystem())->deleteDirectory($directory); + } + } + + private function worker(string $directory, string $mode): array + { + $process = $this->process($directory, $mode); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + } + + private function process(string $directory, string $mode, bool $crash = false): Process + { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_RECOVERY' => 'true', + 'DW_TEST_TRACING_ENABLED' => $this->tracing ? 'true' : 'false', + 'DW_TEST_TRACING_CRASH_FIRST_ATTEMPT' => $crash ? 'true' : 'false', + ]); + $process->setTimeout(15); + + return $process; + } + + private function timestamp(string $value): float + { + return (float) (new DateTimeImmutable($value, new DateTimeZone('UTC')))->format('U.u'); + } +} From dbb6a40a53dec715f3728475595c1d100132ab35 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 14:06:40 +0000 Subject: [PATCH 10/77] Use stable formatting for recovery attempt projection --- tests/Fixtures/V2/tracing-process.php | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 03f4a5c24..728ea8171 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -414,15 +414,17 @@ protected function defineEnvironment($app): void 'close_deadline_at' => $activity->getRawOriginal('close_deadline_at'), ])->all(), 'attempts' => ActivityAttempt::query()->orderBy('attempt_number')->get()->map( - static fn ($attempt): array => [ - 'id' => $attempt->id, - 'activity_id' => $attempt->activity_execution_id, - 'task_id' => $attempt->workflow_task_id, - 'number' => $attempt->attempt_number, - 'status' => $attempt->status->value, - 'lease_expires_at' => $attempt->getRawOriginal('lease_expires_at'), - 'started_at' => $attempt->getRawOriginal('started_at'), - ] + static function ($attempt): array { + return [ + 'id' => $attempt->id, + 'activity_id' => $attempt->activity_execution_id, + 'task_id' => $attempt->workflow_task_id, + 'number' => $attempt->attempt_number, + 'status' => $attempt->status->value, + 'lease_expires_at' => $attempt->getRawOriginal('lease_expires_at'), + 'started_at' => $attempt->getRawOriginal('started_at'), + ]; + } )->all(), 'history_types' => $history->map(static fn ($event): string => $event->event_type->value) ->all(), From 4d7028472f9e3fbe2659432b23351aff4632a8fc Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 14:17:00 +0000 Subject: [PATCH 11/77] Carry admission tracing context into child and continued runs --- .github/workflows/php.yml | 1 + src/V2/Support/WorkflowExecutor.php | 20 +++ tests/Fixtures/V2/tracing-process.php | 46 ++++++- .../V2/TransitionExecutionTracingTest.php | 129 ++++++++++++++++++ 4 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/V2/TransitionExecutionTracingTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 30224c7dc..7d60384ec 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -379,6 +379,7 @@ jobs: vendor/bin/phpunit tests/Unit/V2/ActivityExecutionTracingTest.php tests/Unit/V2/IncomingHttpTracingTest.php tests/Unit/V2/RecoveryExecutionTracingTest.php + tests/Unit/V2/TransitionExecutionTracingTest.php --fail-on-warning --log-junit build/test-results/pr-activity-tracing.xml - name: Run database-independent unit contracts diff --git a/src/V2/Support/WorkflowExecutor.php b/src/V2/Support/WorkflowExecutor.php index b1ddae1a2..7eb47f608 100644 --- a/src/V2/Support/WorkflowExecutor.php +++ b/src/V2/Support/WorkflowExecutor.php @@ -2756,6 +2756,7 @@ private function scheduleChildWorkflow( $childRun = WorkflowRun::query()->create([ 'workflow_instance_id' => $childInstance->id, 'run_number' => 1, + 'observability_context' => $this->inheritedTraceContext($run), 'workflow_class' => $childWorkflowCall->workflow, 'workflow_type' => $workflowType, 'namespace' => $run->namespace, @@ -3698,6 +3699,7 @@ private function continueAsNew( $continuedRun = WorkflowRun::query()->create([ 'workflow_instance_id' => $run->workflow_instance_id, 'run_number' => $run->run_number + 1, + 'observability_context' => $this->inheritedTraceContext($run), 'workflow_class' => $workflowClass, 'workflow_type' => $run->workflow_type, 'namespace' => $run->namespace, @@ -7327,6 +7329,24 @@ private function logApproachingLimit(?array $warning, WorkflowRun $run, array $e ], $extraContext)); } + /** + * @return array{schema: string, version: int, carrier: array}|null + */ + private function inheritedTraceContext(WorkflowRun $run): ?array + { + if (config('workflows.tracing.enabled', false) !== true) { + return null; + } + + // Preserve the admission parent; never recapture ambient worker context in this transaction. + $stored = array_key_exists('observability_context', $run->getAttributes()) + ? $run->getAttribute('observability_context') + : null; + $allowlist = config('workflows.tracing.baggage_allowlist', []); + + return TraceCarrier::normalize($stored, is_array($allowlist) ? $allowlist : []); + } + private function inheritTypedVisibilityMetadata(WorkflowRun $sourceRun, WorkflowRun $targetRun): void { app(MemoUpsertService::class)->inheritFromParent($sourceRun, $targetRun, 1); diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 728ea8171..23fbfbbd9 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -47,6 +47,7 @@ } $retry = $argv[3] === 'retry'; $recovery = getenv('DW_TEST_TRACING_RECOVERY') === 'true'; +$transition = getenv('DW_TEST_TRACING_TRANSITION'); foreach (array_slice($argv, 4) as $autoload) { require_once $autoload; } @@ -89,6 +90,26 @@ public function handle(): array } } +final class TracingChildParentWorkflow extends Workflow +{ + public function handle(): array + { + return self::child(TracingProcessWorkflow::class, false); + } +} + +final class TracingContinuationWorkflow extends Workflow +{ + public function handle(bool $continued = false): array + { + if (! $continued) { + return self::continueAsNew(true); + } + + return self::activity(TracingProcessActivity::class, false); + } +} + final class TracingProcessDependency { public function __construct() @@ -310,6 +331,13 @@ protected function defineEnvironment($app): void } http_response_code(201); header('Content-Type: application/json'); + } elseif ($transition === 'child' || $transition === 'continue') { + $workflow = $transition === 'child' + ? TracingChildParentWorkflow::class + : TracingContinuationWorkflow::class; + WorkflowStub::make($workflow, 'cold-tracing', '0')->start( + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); } elseif ($recovery) { WorkflowStub::make(TracingRecoveryWorkflow::class, 'cold-tracing', '0')->start( new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), @@ -363,7 +391,9 @@ protected function defineEnvironment($app): void $scope->detach(); $requestSpan?->end(); } - $run = WorkflowRun::query()->where('workflow_instance_id', 'cold-tracing')->sole(); + $runs = WorkflowRun::query()->where('workflow_instance_id', 'cold-tracing'); + $run = $transition === 'continue' ? $runs->orderByDesc('run_number') + ->firstOrFail() : $runs->sole(); $output = [ 'pid' => getmypid(), 'framework' => $app->version(), @@ -394,6 +424,20 @@ protected function defineEnvironment($app): void ->toArray(), ], $exporter->getSpans()), ]; + if ($transition === 'child' || $transition === 'continue') { + $output['runs'] = WorkflowRun::query()->orderBy('id')->get()->map(static fn ($run): array => [ + 'id' => $run->id, + 'instance_id' => $run->workflow_instance_id, + 'status' => $run->status->value, + 'closed_reason' => $run->closed_reason, + 'carrier' => $run->getRawOriginal('observability_context'), + 'execution_deadline' => $run->getRawOriginal('execution_deadline_at'), + 'run_deadline' => $run->getRawOriginal('run_deadline_at'), + ])->all(); + $output['activity_count'] = ActivityExecution::query()->count(); + $output['completion_count'] = WorkflowHistoryEvent::query()->where('event_type', 'ActivityCompleted')->count(); + $output['history_payloads'] = WorkflowHistoryEvent::query()->orderBy('id')->pluck('payload')->all(); + } if ($recovery) { $history = WorkflowHistoryEvent::query()->where('workflow_run_id', $run->id)->orderBy('sequence')->get(); $output['recovery'] = [ diff --git a/tests/Unit/V2/TransitionExecutionTracingTest.php b/tests/Unit/V2/TransitionExecutionTracingTest.php new file mode 100644 index 000000000..339997a3e --- /dev/null +++ b/tests/Unit/V2/TransitionExecutionTracingTest.php @@ -0,0 +1,129 @@ + ['child', true, true], + 'child, tracing disabled' => ['child', false, false], + 'continuation, tracing enabled' => ['continue', true, true], + 'continuation, tracing disabled' => ['continue', false, false], + 'legacy child, enabled later' => ['child', true, false], + 'legacy continuation, enabled later' => ['continue', true, false], + ]; + } + + #[DataProvider('transitions')] + public function testColdSuccessorRetainsTheAdmissionCarrierWithoutChangingDurableExecution( + string $transition, + bool $enabled, + bool $admissionEnabled, + ): void { + $directory = sys_get_temp_dir() . '/dw-tracing-transition-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static function (string $mode) use ($directory, $transition, $enabled, $admissionEnabled): array { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_TRANSITION' => $transition, + 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admissionEnabled : $enabled) ? 'true' : 'false', + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + try { + $admitted = $worker('admit'); + self::assertSame($admissionEnabled, $admitted['carrier'] !== null); + $scheduled = $worker('work'); + self::assertCount(2, $scheduled['runs']); + foreach ($scheduled['runs'] as $run) { + self::assertSame($admitted['carrier'], $run['carrier'], json_encode($run, JSON_THROW_ON_ERROR)); + } + $workers = [$scheduled]; + for ($remaining = 8; $remaining > 0; --$remaining) { + $state = $worker('inspect'); + if ($state['jobs'] === 0) { + break; + } + $workers[] = $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + self::assertSame(1, $finished['activity_count']); + self::assertSame(1, $finished['completion_count']); + self::assertCount(2, $finished['runs']); + foreach ($scheduled['runs'] as $index => $run) { + foreach (['id', 'instance_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { + self::assertSame($run[$key], $finished['runs'][$index][$key]); + } + } + if ($transition === 'continue') { + self::assertNotSame($admitted['run_id'], $finished['run_id']); + self::assertSame($admitted['execution_deadline'], $finished['execution_deadline']); + self::assertSame('completed', $finished['runs'][0]['status']); + self::assertSame('continued', $finished['runs'][0]['closed_reason']); + self::assertSame($finished['runs'][0]['instance_id'], $finished['runs'][1]['instance_id']); + } else { + self::assertSame($admitted['run_id'], $finished['run_id']); + self::assertSame('completed', $finished['runs'][0]['status']); + self::assertNotSame($finished['runs'][0]['instance_id'], $finished['runs'][1]['instance_id']); + } + self::assertSame(count($workers), count(array_unique(array_column($workers, 'pid')))); + $spans = array_merge(...array_column($workers, 'spans')); + if ($enabled) { + $observedRuns = []; + foreach ($spans as $span) { + if ($admissionEnabled) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); + self::assertSame('00f067aa0ba902b7', $span['parent_id']); + } else { + self::assertSame('0000000000000000', $span['parent_id']); + self::assertNotSame('11111111111111111111111111111111', $span['trace_id']); + } + $observedRuns[] = $span['fields']['workflow_run_id']; + } + foreach ($finished['runs'] as $run) { + self::assertContains($run['id'], $observedRuns); + } + } else { + self::assertSame([], $spans); + } + self::assertStringNotContainsString( + 'observability_context', + json_encode($finished['history_payloads'], JSON_THROW_ON_ERROR) + ); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } +} From 4fb83961b36010de5be728aee052bc07ec1d54f3 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 14:33:06 +0000 Subject: [PATCH 12/77] Persist tracing context for signal-with-start admission --- .github/workflows/php.yml | 1 + src/V2/WorkflowStub.php | 8 ++ tests/Fixtures/V2/tracing-process.php | 81 ++++++++++++ .../V2/SignalStartExecutionTracingTest.php | 120 ++++++++++++++++++ 4 files changed, 210 insertions(+) create mode 100644 tests/Unit/V2/SignalStartExecutionTracingTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 7d60384ec..7705adc5c 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -380,6 +380,7 @@ jobs: tests/Unit/V2/IncomingHttpTracingTest.php tests/Unit/V2/RecoveryExecutionTracingTest.php tests/Unit/V2/TransitionExecutionTracingTest.php + tests/Unit/V2/SignalStartExecutionTracingTest.php --fail-on-warning --log-junit build/test-results/pr-activity-tracing.xml - name: Run database-independent unit contracts diff --git a/src/V2/WorkflowStub.php b/src/V2/WorkflowStub.php index 8961eb54b..7abcb9f2e 100644 --- a/src/V2/WorkflowStub.php +++ b/src/V2/WorkflowStub.php @@ -3061,6 +3061,12 @@ private function attemptSignalWithStartInternal( $task = null; $startedRun = null; $intakeGroupId = (string) Str::ulid(); + // Capture before locking. An existing run retains its original admission carrier. + $observabilityContext = null; + if (config('workflows.tracing.enabled', false) === true) { + $allowlist = config('workflows.tracing.baggage_allowlist', []); + $observabilityContext = TraceCarrier::capture(is_array($allowlist) ? $allowlist : []); + } DB::transaction(function () use ( $name, @@ -3068,6 +3074,7 @@ private function attemptSignalWithStartInternal( $startArguments, $startOptions, $intakeGroupId, + $observabilityContext, &$startCommand, &$signalCommand, &$task, @@ -3333,6 +3340,7 @@ private function attemptSignalWithStartInternal( $run = self::runQuery()->create([ 'workflow_instance_id' => $instance->id, 'run_number' => $instance->run_count + 1, + 'observability_context' => $observabilityContext, 'workflow_class' => $workflowClass, 'workflow_type' => $instance->workflow_type, 'namespace' => $instance->namespace, diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 23fbfbbd9..5ac7f6630 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -19,13 +19,18 @@ use OpenTelemetry\API\Trace\Span; use OpenTelemetry\API\Trace\SpanKind; use OpenTelemetry\Context\Context; +use OpenTelemetry\Context\ContextInterface; use OpenTelemetry\Context\Propagation\MultiTextMapPropagator; +use OpenTelemetry\Context\Propagation\PropagationGetterInterface; +use OpenTelemetry\Context\Propagation\PropagationSetterInterface; +use OpenTelemetry\Context\Propagation\TextMapPropagatorInterface; use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor; use OpenTelemetry\SDK\Trace\TracerProvider; use Orchestra\Testbench\Foundation\Application; use Workflow\Providers\WorkflowServiceProvider; use Workflow\V2\Activity; +use Workflow\V2\Attributes\Signal; use Workflow\V2\Models\ActivityAttempt; use Workflow\V2\Models\ActivityExecution; use Workflow\V2\Models\WorkflowHistoryEvent; @@ -48,6 +53,7 @@ $retry = $argv[3] === 'retry'; $recovery = getenv('DW_TEST_TRACING_RECOVERY') === 'true'; $transition = getenv('DW_TEST_TRACING_TRANSITION'); +$signalStart = getenv('DW_TEST_TRACING_SIGNAL_START') === 'true'; foreach (array_slice($argv, 4) as $autoload) { require_once $autoload; } @@ -110,6 +116,20 @@ public function handle(bool $continued = false): array } } +#[Signal('ready')] +final class TracingSignalStartWorkflow extends Workflow +{ + public function handle(): array + { + $signal = self::awaitSignal('ready'); + + return [ + 'signal' => $signal, + 'activity' => self::activity(TracingProcessActivity::class, false), + ]; + } +} + final class TracingProcessDependency { public function __construct() @@ -263,6 +283,40 @@ protected function defineEnvironment($app): void TraceContextPropagator::getInstance(), BaggagePropagator::getInstance(), ]); +$recordedPropagator = null; +if ($signalStart) { + $recordedPropagator = new class($propagator) implements TextMapPropagatorInterface { + public array $transactionLevels = []; + + public function __construct( + private readonly TextMapPropagatorInterface $delegate + ) { + } + + public function fields(): array + { + return $this->delegate->fields(); + } + + public function inject( + mixed &$carrier, + ?PropagationSetterInterface $setter = null, + ?ContextInterface $context = null, + ): void { + $this->transactionLevels[] = DB::transactionLevel(); + $this->delegate->inject($carrier, $setter, $context); + } + + public function extract( + $carrier, + ?PropagationGetterInterface $getter = null, + ?ContextInterface $context = null, + ): ContextInterface { + return $this->delegate->extract($carrier, $getter, $context); + } + }; + $propagator = $recordedPropagator; +} $configuration = Configurator::create()->withTracerProvider($provider) ->withPropagator($propagator) ->activate(); @@ -297,6 +351,7 @@ protected function defineEnvironment($app): void ->activate(); $outsideHttp = null; $repairReport = null; + $signalStartResult = null; try { if ($mode === 'admit') { if (Artisan::call('migrate', [ @@ -331,6 +386,13 @@ protected function defineEnvironment($app): void } http_response_code(201); header('Content-Type: application/json'); + } elseif ($signalStart) { + $signalStartResult = WorkflowStub::make(TracingSignalStartWorkflow::class, 'cold-tracing', '0') + ->signalWithStart( + 'ready', + [[9007199254740993, null, '雪']], + StartOptions::returnExistingActive()->withExecutionTimeout(60)->withRunTimeout(45) + ); } elseif ($transition === 'child' || $transition === 'continue') { $workflow = $transition === 'child' ? TracingChildParentWorkflow::class @@ -348,6 +410,12 @@ protected function defineEnvironment($app): void new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), ); } + } elseif ($mode === 'duplicate' && $signalStart) { + $signalStartResult = WorkflowStub::make(TracingSignalStartWorkflow::class, 'cold-tracing', '0') + ->signalWithStart('ready', [['later']], StartOptions::returnExistingActive()); + if (! $signalStartResult->returnedExistingActive()) { + throw new \RuntimeException('Expected the original signal-with-start admission.'); + } } elseif ($mode === 'duplicate') { $result = WorkflowStub::make(TracingProcessWorkflow::class, 'cold-tracing', '0') ->attemptStart($retry, StartOptions::returnExistingActive()); @@ -424,6 +492,19 @@ protected function defineEnvironment($app): void ->toArray(), ], $exporter->getSpans()), ]; + if ($signalStart) { + $history = WorkflowHistoryEvent::query()->where('workflow_run_id', $run->id)->orderBy('sequence')->get(); + $output['signal_start_outcome'] = $signalStartResult?->startOutcome(); + $output['capture_transaction_levels'] = $recordedPropagator?->transactionLevels ?? []; + $output['run_count'] = WorkflowRun::query()->count(); + $output['signal_count'] = $history->where('event_type.value', 'SignalReceived')->count(); + $output['completion_count'] = $history->where('event_type.value', 'ActivityCompleted')->count(); + $output['history_hashes'] = $history->map(static fn ($event): string => hash( + 'sha256', + json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR), + ))->all(); + $output['history_payloads'] = $history->pluck('payload')->all(); + } if ($transition === 'child' || $transition === 'continue') { $output['runs'] = WorkflowRun::query()->orderBy('id')->get()->map(static fn ($run): array => [ 'id' => $run->id, diff --git a/tests/Unit/V2/SignalStartExecutionTracingTest.php b/tests/Unit/V2/SignalStartExecutionTracingTest.php new file mode 100644 index 000000000..261dcbae6 --- /dev/null +++ b/tests/Unit/V2/SignalStartExecutionTracingTest.php @@ -0,0 +1,120 @@ + [true, true], + 'disabled control' => [false, false], + 'legacy admission, enabled later' => [false, true], + ]; + } + + #[DataProvider('modes')] + public function testSignalWithStartKeepsItsOriginalTraceAcrossAnotherCallerAndColdWorkers( + bool $admissionEnabled, + bool $workerEnabled, + ): void { + $directory = sys_get_temp_dir() . '/dw-tracing-signal-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static function (string $mode) use ($directory, $admissionEnabled, $workerEnabled): array { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_SIGNAL_START' => 'true', + 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admissionEnabled : $workerEnabled) ? 'true' : 'false', + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + try { + $admitted = $worker('admit'); + self::assertSame('started_new', $admitted['signal_start_outcome']); + self::assertSame($admissionEnabled, $admitted['carrier'] !== null); + self::assertSame($admissionEnabled ? [0] : [], $admitted['capture_transaction_levels']); + $existing = $worker('duplicate'); + self::assertSame('returned_existing_active', $existing['signal_start_outcome']); + self::assertLessThanOrEqual($workerEnabled ? 1 : 0, count($existing['capture_transaction_levels'])); + foreach ($existing['capture_transaction_levels'] as $level) { + self::assertSame(0, $level); + } + self::assertSame(2, $existing['signal_count']); + self::assertSame( + $admitted['history_hashes'], + array_slice($existing['history_hashes'], 0, count($admitted['history_hashes'])) + ); + $workers = []; + for ($remaining = 6; $remaining > 0; --$remaining) { + $state = $worker('inspect'); + if ($state['jobs'] === 0) { + break; + } + $workers[] = $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([ + 'signal' => [9007199254740993, null, '雪'], + 'activity' => [9007199254740993, null, '雪'], + ], $finished['output']); + self::assertSame(1, $finished['run_count']); + self::assertSame(1, $finished['completion_count']); + self::assertSame(0, $finished['repairs']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { + self::assertSame($admitted[$key], $existing[$key]); + self::assertSame($admitted[$key], $finished[$key]); + } + self::assertSame( + $existing['history_hashes'], + array_slice($finished['history_hashes'], 0, count($existing['history_hashes'])) + ); + self::assertSame(count($workers), count(array_unique(array_column($workers, 'pid')))); + $spans = array_merge(...array_column($workers, 'spans')); + self::assertCount($workerEnabled ? 3 : 0, $spans); + foreach ($spans as $span) { + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + if ($admissionEnabled) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); + self::assertSame('00f067aa0ba902b7', $span['parent_id']); + } else { + self::assertSame('0000000000000000', $span['parent_id']); + self::assertNotSame('11111111111111111111111111111111', $span['trace_id']); + } + } + self::assertStringNotContainsString( + 'observability_context', + json_encode($finished['history_payloads'], JSON_THROW_ON_ERROR) + ); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } +} From 106dda51a55f7a4a513bc0493e5c6a408f960040 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 15:41:39 +0000 Subject: [PATCH 13/77] Qualify bounded tracing exports through collector outages --- .github/tracing-http/composer.json | 1 + .github/workflows/php.yml | 1 + docs/embedded-tracing.md | 107 ++++++++ tests/Fixtures/V2/TracingExporter.php | 46 ++++ .../Fixtures/V2/tracing-collector-router.php | 37 +++ tests/Fixtures/V2/tracing-process.php | 18 +- .../Unit/V2/ExporterExecutionTracingTest.php | 248 ++++++++++++++++++ 7 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 docs/embedded-tracing.md create mode 100644 tests/Fixtures/V2/TracingExporter.php create mode 100644 tests/Fixtures/V2/tracing-collector-router.php create mode 100644 tests/Unit/V2/ExporterExecutionTracingTest.php diff --git a/.github/tracing-http/composer.json b/.github/tracing-http/composer.json index e2e9fd951..ff1f635df 100644 --- a/.github/tracing-http/composer.json +++ b/.github/tracing-http/composer.json @@ -2,6 +2,7 @@ "require": { "open-telemetry/api": "^1.10", "open-telemetry/sdk": "^1.15", + "open-telemetry/exporter-otlp": "^1.4", "open-telemetry/context": "^1.5", "open-telemetry/opentelemetry-auto-guzzle": "^1.5", "guzzlehttp/guzzle": "^8.2" diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 7705adc5c..4b8826d19 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -342,6 +342,7 @@ jobs: --bootstrap .github/tracing-http/bootstrap.php --do-not-record-test-run-history --fail-on-warning --fail-on-skipped tests/Unit/V2/HttpExecutionTracingTest.php + tests/Unit/V2/ExporterExecutionTracingTest.php pr-unit-contracts: needs: preflight diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md new file mode 100644 index 000000000..30b83ba6a --- /dev/null +++ b/docs/embedded-tracing.md @@ -0,0 +1,107 @@ +# Embedded tracing preparation + +This document describes the optional embedded tracing implementation being +qualified in [#773](https://github.com/durable-workflow/workflow/issues/773). +It is not a published-package support claim. The released OpenTelemetry baggage +parser still fails the encoded-value regression. Full qualification and a +compatible stable release are required before enabling this in production. + +The application owns its OpenTelemetry providers, instrumentation, sampling, +exporters and process shutdown. Durable Workflow uses those providers when +`workflow.tracing.enabled` is true. It does not replace application providers or +flush an exporter after each task. Tracing is disabled by default. + +## Keep collector outages outside business work + +PHP exporters can perform synchronous HTTP requests. A batch processor alone +does not guarantee that ending a span is free of network waits. In the tested +SDK, the default batch processor can export from `onEnd()`. Its export-timeout +constructor argument does not impose a timeout on an arbitrary HTTP client. + +The tested application profile uses the official OTLP exporter and an explicit +PSR HTTP client. The client limits each request to 100 milliseconds, transport +retries are disabled, and the processor buffers at most four completed spans. +It exports only when the application explicitly flushes or shuts down: + +```php +use GuzzleHttp\Client; +use GuzzleHttp\Psr7\HttpFactory; +use OpenTelemetry\API\Common\Time\Clock; +use OpenTelemetry\API\Instrumentation\Configurator; +use OpenTelemetry\Context\Context; +use OpenTelemetry\Contrib\Otlp\ContentTypes; +use OpenTelemetry\Contrib\Otlp\SpanExporter; +use OpenTelemetry\SDK\Common\Export\Http\PsrTransportFactory; +use OpenTelemetry\SDK\Trace\SpanProcessor\BatchSpanProcessor; + +$http = new HttpFactory(); +$transport = (new PsrTransportFactory( + new Client(['timeout' => 0.1, 'connect_timeout' => 0.1]), + $http, + $http, +))->create($collectorEndpoint, ContentTypes::PROTOBUF, maxRetries: 0); + +// The processor captures this context for its own export calls. +$exportScope = Configurator::createNoop()->storeInContext(Context::getRoot())->activate(); +try { + $processor = new BatchSpanProcessor( + new SpanExporter($transport), + Clock::getDefault(), + maxQueueSize: 4, + maxExportBatchSize: 2, + autoFlush: false, + ); +} finally { + $exportScope->detach(); +} +``` + +Add the processor to the application's provider. The optional dependency profile +is in [`.github/tracing-http/composer.json`](../.github/tracing-http/composer.json). +It is separate from the package's required dependencies. +The temporary export context prevents client instrumentation from creating new +spans for collector requests. It restores the application's context immediately +after constructing the processor. + +Arrange bounded worker recycling and call the provider's `shutdown()` from the +application's process teardown after its worker loop returns. Flushing and +shutdown belong outside workflow replay, activity callbacks and database +transactions. Reserve time for every queued export batch in the supervisor's +shutdown grace period. Transport deadlines, batch count, retries and other +application processors all contribute to that budget. + +This small queue intentionally drops excess spans. Failed exports are reported +by OpenTelemetry's diagnostic logger and are not replayed as workflow work. +`forceFlush()` returning true alone is not proof that a collector accepted every +span. Observe exporter errors and collector ingestion separately. A slow or +unbounded custom processor can still block its caller. + +See the [official PHP exporter documentation](https://opentelemetry.io/docs/languages/php/exporters/) +and the tested SDK's [batch processor](https://github.com/opentelemetry-php/sdk/blob/77e1aa73850154abb86937d52a70883edc3b4547/Trace/SpanProcessor/BatchSpanProcessor.php) +and [HTTP transport factory](https://github.com/opentelemetry-php/sdk/blob/77e1aa73850154abb86937d52a70883edc3b4547/Common/Export/Http/PsrTransportFactory.php). + +## What this profile currently verifies + +Real HTTP controls cover a collector returning 503, a collector that accepts +the request but stalls, and a refused connection. Independent database queue +workers still complete the original workflow and activity once with exact +typed output, unchanged admission context and deadlines, and zero repairs. +No export request occurs before process teardown in these controls. + +Separate controls verify the four-span queue limit, overflow loss, failed-export +diagnostics, recovery through the same provider, original application exception +identity, context cleanup and repeated shutdown. The receiver decodes requests +with the official OTLP protobuf classes. These are functional checks, not +overhead, throughput or collector durability measurements. + +## Current boundaries + +Workflow processing and activity attempts receive separate spans. Persisted +admission context survives the qualified cold-worker, child, continuation and +signal-with-start paths. Existing runs keep their original admission context. +An untraced legacy admission does not borrow a worker's ambient parent. + +The run carrier is separate observability metadata. It is not command, history, +payload or lease authority. The proposed nullable run column has not been +adopted by the native Server. Other admission paths, broader trigger links, +runtime coverage and performance qualification remain unfinished. diff --git a/tests/Fixtures/V2/TracingExporter.php b/tests/Fixtures/V2/TracingExporter.php new file mode 100644 index 000000000..25c56a3ce --- /dev/null +++ b/tests/Fixtures/V2/TracingExporter.php @@ -0,0 +1,46 @@ + 0.1, + 'connect_timeout' => 0.1, + ]), + $factory, + $factory, + ))->create($endpoint, ContentTypes::PROTOBUF, maxRetries: 0); + + // The processor captures this context for export. Never trace its own transport. + $exportScope = Configurator::createNoop()->storeInContext(Context::getRoot())->activate(); + try { + return new BatchSpanProcessor( + new SpanExporter($transport), + Clock::getDefault(), + maxQueueSize: 4, + maxExportBatchSize: 2, + autoFlush: false, + ); + } finally { + $exportScope->detach(); + } + } +} diff --git a/tests/Fixtures/V2/tracing-collector-router.php b/tests/Fixtures/V2/tracing-collector-router.php new file mode 100644 index 000000000..0d5a229d5 --- /dev/null +++ b/tests/Fixtures/V2/tracing-collector-router.php @@ -0,0 +1,37 @@ +mergeFromString(file_get_contents('php://input')); +$spans = []; +foreach ($request->getResourceSpans() as $resource) { + foreach ($resource->getScopeSpans() as $scope) { + foreach ($scope->getSpans() as $span) { + $spans[] = [ + 'name' => $span->getName(), + 'trace_id' => bin2hex($span->getTraceId()), + 'span_id' => bin2hex($span->getSpanId()), + ]; + } + } +} +$mode = trim(file_get_contents($directory . '/mode')); +file_put_contents($directory . '/requests.jsonl', json_encode([ + 'mode' => $mode, + 'method' => $_SERVER['REQUEST_METHOD'], + 'path' => $_SERVER['REQUEST_URI'], + 'content_type' => $_SERVER['CONTENT_TYPE'], + 'spans' => $spans, + 'request' => $request->serializeToJsonString(), +], JSON_THROW_ON_ERROR) . PHP_EOL, FILE_APPEND | LOCK_EX); +if ($mode === 'stall') { + usleep(300_000); +} +http_response_code($mode === 'unavailable' ? 503 : 200); +header('Content-Type: application/x-protobuf'); +echo (new Opentelemetry\Proto\Collector\Trace\V1\ExportTraceServiceResponse())->serializeToString(); diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 5ac7f6630..784397181 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -278,7 +278,16 @@ protected function defineEnvironment($app): void ], ); $exporter = new InMemoryExporter(); -$provider = new TracerProvider(new SimpleSpanProcessor($exporter)); +$processors = [new SimpleSpanProcessor($exporter)]; +$exportEndpoint = getenv('DW_TEST_TRACING_EXPORTER_ENDPOINT'); +$exportJournal = getenv('DW_TEST_TRACING_EXPORTER_JOURNAL'); +$exportRequests = static fn (): int => $exportJournal && is_file($exportJournal) + ? count(file($exportJournal, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)) : 0; +$initialExportRequests = $exportRequests(); +if ($exportEndpoint) { + $processors[] = TracingExporter::processor($exportEndpoint); +} +$provider = new TracerProvider($processors); $propagator = new MultiTextMapPropagator([ TraceContextPropagator::getInstance(), BaggagePropagator::getInstance(), @@ -492,6 +501,9 @@ public function extract( ->toArray(), ], $exporter->getSpans()), ]; + if ($exportEndpoint) { + $output['exports_before_shutdown'] = $exportRequests() - $initialExportRequests; + } if ($signalStart) { $history = WorkflowHistoryEvent::query()->where('workflow_run_id', $run->id)->orderBy('sequence')->get(); $output['signal_start_outcome'] = $signalStartResult?->startOutcome(); @@ -569,6 +581,10 @@ static function ($attempt): array { } } finally { $configuration->detach(); + $shutdownStarted = hrtime(true); $provider->shutdown(); } +if ($exportEndpoint) { + $output['export_shutdown_seconds'] = (hrtime(true) - $shutdownStarted) / 1e9; +} echo json_encode($output, JSON_THROW_ON_ERROR), PHP_EOL; diff --git a/tests/Unit/V2/ExporterExecutionTracingTest.php b/tests/Unit/V2/ExporterExecutionTracingTest.php new file mode 100644 index 000000000..448d84df7 --- /dev/null +++ b/tests/Unit/V2/ExporterExecutionTracingTest.php @@ -0,0 +1,248 @@ + ['unavailable'], + 'stalled collector' => ['stall'], + 'connection refused' => ['refused'], + ]; + } + + #[DataProvider('outages')] + public function testCollectorFailureDoesNotChangeColdWorkflowCompletion(string $mode): void + { + $this->requireExporter(); + $directory = $this->directory(); + [$server, $address] = $this->collector($directory, $mode); + if ($mode === 'refused') { + $server->stop(); + } + try { + $admitted = $this->worker($directory, 'admit', $address); + $workers = []; + for ($i = 0; $i < 3; ++$i) { + $workers[] = $this->worker($directory, 'work', $address); + } + $finished = $this->worker($directory, 'inspect', $address); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame(3, $finished['tasks']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + self::assertCount(3, array_unique(array_column($workers, 'pid'))); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { + self::assertSame($admitted[$key], $finished[$key]); + } + $spans = array_merge(...array_column($workers, 'spans')); + self::assertCount(3, $spans); + foreach ($spans as $span) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + } + $requests = $this->requests($directory); + self::assertCount($mode === 'refused' ? 0 : 3, $requests); + foreach ($requests as $request) { + self::assertSame('POST', $request['method']); + self::assertSame('/v1/traces', $request['path']); + self::assertSame('application/x-protobuf', $request['content_type']); + self::assertCount(1, $request['spans']); + } + } finally { + $server->stop(); + (new Filesystem())->deleteDirectory($directory); + } + } + + public function testBoundedQueueDropsOverflowAndRecoversWithoutReplayingBusinessWork(): void + { + $this->requireExporter(); + $directory = $this->directory(); + [$server, $address] = $this->collector($directory, 'unavailable'); + $initial = Context::getCurrent(); + $provider = new TracerProvider(TracingExporter::processor('http://' . $address . '/v1/traces')); + self::assertSame($initial, Context::getCurrent()); + $previousWriter = Logging::logWriter(); + $logs = new TestHandler(); + Logging::setLogWriter(new Psr3LogWriter(new Logger('telemetry-test', [$logs]))); + $configuration = Configurator::create()->withTracerProvider($provider)->activate(); + $calls = 0; + try { + for ($i = 0; $i < 10; ++$i) { + self::assertSame(7, ExecutionTracing::activity([ + 'task_id' => 'task-' . $i, + 'arguments' => 'payload-private-sentinel', + ], null, static function () use (&$calls): int { + ++$calls; + + return 7; + }, enabled: true)); + } + $failure = new RuntimeException('business-private-sentinel'); + try { + ExecutionTracing::activity([], null, static function () use ($failure): void { + throw $failure; + }, enabled: true); + self::fail('The original business exception must escape.'); + } catch (RuntimeException $caught) { + self::assertSame($failure, $caught); + } + self::assertSame(10, $calls); + self::assertSame([], ExecutionTracing::current()); + self::assertSame([], $this->requests($directory)); + $provider->forceFlush(); + $failed = $this->requests($directory); + self::assertCount(2, $failed); + self::assertCount(4, array_merge(...array_column($failed, 'spans'))); + self::assertCount(2, $logs->getRecords()); + foreach ($logs->getRecords() as $record) { + self::assertSame('Export failure', $record->message); + } + file_put_contents($directory . '/mode', 'healthy'); + ExecutionTracing::activity([ + 'task_id' => 'recovered', + ], null, static function () use (&$calls): void { + ++$calls; + }, enabled: true); + self::assertCount(2, $this->requests($directory)); + $provider->forceFlush(); + $requests = $this->requests($directory); + self::assertCount(3, $requests); + self::assertSame('healthy', $requests[2]['mode']); + self::assertCount(1, $requests[2]['spans']); + self::assertStringContainsString('recovered', $requests[2]['request']); + try { + ExecutionTracing::activity([], null, static function () use ($failure): void { + throw $failure; + }, enabled: true); + self::fail('Exporter recovery must preserve the original business exception.'); + } catch (RuntimeException $caught) { + self::assertSame($failure, $caught); + } + $provider->forceFlush(); + $requests = $this->requests($directory); + self::assertCount(4, $requests); + self::assertStringContainsString('error.type', $requests[3]['request']); + self::assertStringContainsString(RuntimeException::class, $requests[3]['request']); + foreach (['payload-private-sentinel', 'business-private-sentinel'] as $private) { + self::assertStringNotContainsString($private, json_encode($requests, JSON_THROW_ON_ERROR)); + } + self::assertSame(11, $calls); + $provider->shutdown(); + $provider->shutdown(); + self::assertCount(4, $this->requests($directory)); + self::assertSame([], ExecutionTracing::current()); + } finally { + $configuration->detach(); + $provider->shutdown(); + Logging::setLogWriter($previousWriter); + $server->stop(); + (new Filesystem())->deleteDirectory($directory); + } + self::assertSame($initial, Context::getCurrent()); + } + + private function requireExporter(): void + { + if (! class_exists(SpanExporter::class)) { + self::markTestSkipped('Requires the optional official OTLP exporter.'); + } + } + + private function directory(): string + { + $directory = sys_get_temp_dir() . '/dw-tracing-exporter-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + + return $directory; + } + + private function collector(string $directory, string $mode): array + { + file_put_contents($directory . '/mode', $mode); + $socket = stream_socket_server('tcp://127.0.0.1:0'); + self::assertIsResource($socket); + $address = stream_socket_get_name($socket, false); + self::assertIsString($address); + fclose($socket); + $server = new Process([ + PHP_BINARY, '-S', $address, __DIR__ . '/../../Fixtures/V2/tracing-collector-router.php', + ], env: [ + 'DW_TEST_COLLECTOR_DIRECTORY' => $directory, + 'DW_TEST_COLLECTOR_AUTOLOADS' => json_encode($this->autoloads(), JSON_THROW_ON_ERROR), + ]); + $server->setTimeout(15); + $server->start(); + self::assertTrue($server->waitUntil(static fn (string $type, string $output): bool => str_contains( + $output, + 'Development Server' + ))); + + return [$server, $address]; + } + + private function autoloads(): array + { + return array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + array_keys(ClassLoader::getRegisteredLoaders()) + ); + } + + private function requests(string $directory): array + { + $journal = $directory . '/requests.jsonl'; + + return is_file($journal) ? array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($journal, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ) : []; + } + + private function worker(string $directory, string $mode, string $address): array + { + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...$this->autoloads(), + ], env: [ + 'DW_TEST_TRACING_EXPORTER_ENDPOINT' => 'http://' . $address . '/v1/traces', + 'DW_TEST_TRACING_EXPORTER_JOURNAL' => $directory . '/requests.jsonl', + ]); + $process->setTimeout(15); + $process->mustRun(); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + self::assertSame(0, $result['exports_before_shutdown']); + self::assertLessThan(2, $result['export_shutdown_seconds']); + if ($mode === 'work') { + self::assertStringContainsString('Export failure', $process->getErrorOutput()); + } + + return $result; + } +} From a316c0af16d6911e1980998dbfa1292fa86e39ef Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 15:43:39 +0000 Subject: [PATCH 14/77] Use descriptive tracing proposal link in preparation guide --- docs/embedded-tracing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index 30b83ba6a..281fd1b36 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -1,7 +1,7 @@ # Embedded tracing preparation This document describes the optional embedded tracing implementation being -qualified in [#773](https://github.com/durable-workflow/workflow/issues/773). +qualified in the [tracing proposal](https://github.com/durable-workflow/workflow/issues/773). It is not a published-package support claim. The released OpenTelemetry baggage parser still fails the encoded-value regression. Full qualification and a compatible stable release are required before enabling this in production. From 50794fc88e076a1e9490a0ad1efbfbd183b80b28 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 15:46:49 +0000 Subject: [PATCH 15/77] Install OTLP fixtures in the normal development toolchain --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 1d8f7d836..ee785d693 100644 --- a/composer.json +++ b/composer.json @@ -68,6 +68,7 @@ "require-dev": { "doctrine/dbal": "^3.7", "open-telemetry/api": "^1.10", + "open-telemetry/exporter-otlp": "^1.4", "open-telemetry/sdk": "^1.0", "orchestra/testbench": "^8.0|^11.0", "phpstan/phpstan": "^2.0", From d9b4f1186d7882c3dec1cb9295120245fe98d2a5 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 15:48:31 +0000 Subject: [PATCH 16/77] Keep exporter log assertions compatible with supported Monolog records --- tests/Unit/V2/ExporterExecutionTracingTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/V2/ExporterExecutionTracingTest.php b/tests/Unit/V2/ExporterExecutionTracingTest.php index 448d84df7..a94044194 100644 --- a/tests/Unit/V2/ExporterExecutionTracingTest.php +++ b/tests/Unit/V2/ExporterExecutionTracingTest.php @@ -120,7 +120,7 @@ public function testBoundedQueueDropsOverflowAndRecoversWithoutReplayingBusiness self::assertCount(4, array_merge(...array_column($failed, 'spans'))); self::assertCount(2, $logs->getRecords()); foreach ($logs->getRecords() as $record) { - self::assertSame('Export failure', $record->message); + self::assertSame('Export failure', $record['message']); } file_put_contents($directory . '/mode', 'healthy'); ExecutionTracing::activity([ From f36c5b951964ed6b75567f1613b7d2a3274440d4 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 16:23:22 +0000 Subject: [PATCH 17/77] Preserve admission traces across child retry runs --- .github/workflows/php.yml | 1 + docs/embedded-tracing.md | 14 +- src/V2/Support/DefaultWorkflowTaskBridge.php | 2 + src/V2/Support/TraceCarrier.php | 22 ++ src/V2/Support/WorkflowExecutor.php | 23 +-- tests/Fixtures/V2/tracing-process.php | 107 ++++++++++ .../V2/ChildRetryExecutionTracingTest.php | 193 ++++++++++++++++++ tests/Unit/V2/TraceCarrierTest.php | 30 +++ .../V2/TransitionExecutionTracingTest.php | 2 + 9 files changed, 373 insertions(+), 21 deletions(-) create mode 100644 tests/Unit/V2/ChildRetryExecutionTracingTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 4b8826d19..9c15dc3b3 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -381,6 +381,7 @@ jobs: tests/Unit/V2/IncomingHttpTracingTest.php tests/Unit/V2/RecoveryExecutionTracingTest.php tests/Unit/V2/TransitionExecutionTracingTest.php + tests/Unit/V2/ChildRetryExecutionTracingTest.php tests/Unit/V2/SignalStartExecutionTracingTest.php --fail-on-warning --log-junit build/test-results/pr-activity-tracing.xml diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index 281fd1b36..a37e1167c 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -8,7 +8,7 @@ compatible stable release are required before enabling this in production. The application owns its OpenTelemetry providers, instrumentation, sampling, exporters and process shutdown. Durable Workflow uses those providers when -`workflow.tracing.enabled` is true. It does not replace application providers or +`workflows.tracing.enabled` is true. It does not replace application providers or flush an exporter after each task. Tracing is disabled by default. ## Keep collector outages outside business work @@ -101,6 +101,18 @@ admission context survives the qualified cold-worker, child, continuation and signal-with-start paths. Existing runs keep their original admission context. An untraced legacy admission does not borrow a worker's ambient parent. +The existing bridge-authored child-retry policy is covered through both queued +child failure and bridge-recorded failure, followed by separate cold retry, +activity and parent workers. Child admission and retries preserve the original +carrier, retry policy and execution deadline. This does not add retry-policy +options to the PHP `Workflow::child` helper. + +Disabling tracing stops new context capture and span emission. These qualified +embedded transitions and child-retry paths still inherit valid stored admission +context while the optional propagation API remains installed. The current +baggage allowlist still applies. Missing or unreadable metadata produces no +inherited parent. + The run carrier is separate observability metadata. It is not command, history, payload or lease authority. The proposed nullable run column has not been adopted by the native Server. Other admission paths, broader trigger links, diff --git a/src/V2/Support/DefaultWorkflowTaskBridge.php b/src/V2/Support/DefaultWorkflowTaskBridge.php index 18479ecf3..1fa731b24 100644 --- a/src/V2/Support/DefaultWorkflowTaskBridge.php +++ b/src/V2/Support/DefaultWorkflowTaskBridge.php @@ -4257,6 +4257,7 @@ private function applyStartChildWorkflow( $childRun = WorkflowRun::query()->create([ 'workflow_instance_id' => $childInstance->id, 'run_number' => 1, + 'observability_context' => TraceCarrier::fromRun($run), 'workflow_class' => $workflowType, 'workflow_type' => $workflowType, 'namespace' => $run->namespace, @@ -5288,6 +5289,7 @@ private function startChildRetryIfAvailable( $retryRun = WorkflowRun::query()->create([ 'workflow_instance_id' => $failedChildRun->workflow_instance_id, 'run_number' => $nextRunNumber, + 'observability_context' => TraceCarrier::fromRun($failedChildRun), 'workflow_class' => $failedChildRun->workflow_class, 'workflow_type' => $failedChildRun->workflow_type, 'namespace' => $failedChildRun->namespace, diff --git a/src/V2/Support/TraceCarrier.php b/src/V2/Support/TraceCarrier.php index bc798aa3e..850ef044c 100644 --- a/src/V2/Support/TraceCarrier.php +++ b/src/V2/Support/TraceCarrier.php @@ -11,6 +11,7 @@ use OpenTelemetry\API\Trace\Span; use OpenTelemetry\Context\Context; use Throwable; +use Workflow\V2\Models\WorkflowRun; /** * Bounded telemetry metadata, kept separate from business payloads and authority. @@ -27,6 +28,27 @@ final class TraceCarrier 'baggage' => 2048, ]; + /** + * @return array{schema: string, version: int, carrier: array}|null + */ + public static function fromRun(WorkflowRun $run): ?array + { + try { + // Emission can be disabled without erasing a previously captured admission parent. + $stored = array_key_exists('observability_context', $run->getAttributes()) + ? $run->getAttribute('observability_context') : null; + if (! is_array($stored)) { + return null; + } + $allowlist = config('workflows.tracing.baggage_allowlist', []); + + return self::normalize($stored, is_array($allowlist) ? $allowlist : []); + } catch (Throwable) { + // Optional telemetry metadata must not interrupt child admission or retries. + return null; + } + } + /** * @param list $baggageAllowlist * @return array{schema: string, version: int, carrier: array}|null diff --git a/src/V2/Support/WorkflowExecutor.php b/src/V2/Support/WorkflowExecutor.php index 7eb47f608..c5febb752 100644 --- a/src/V2/Support/WorkflowExecutor.php +++ b/src/V2/Support/WorkflowExecutor.php @@ -2756,7 +2756,7 @@ private function scheduleChildWorkflow( $childRun = WorkflowRun::query()->create([ 'workflow_instance_id' => $childInstance->id, 'run_number' => 1, - 'observability_context' => $this->inheritedTraceContext($run), + 'observability_context' => TraceCarrier::fromRun($run), 'workflow_class' => $childWorkflowCall->workflow, 'workflow_type' => $workflowType, 'namespace' => $run->namespace, @@ -3699,7 +3699,7 @@ private function continueAsNew( $continuedRun = WorkflowRun::query()->create([ 'workflow_instance_id' => $run->workflow_instance_id, 'run_number' => $run->run_number + 1, - 'observability_context' => $this->inheritedTraceContext($run), + 'observability_context' => TraceCarrier::fromRun($run), 'workflow_class' => $workflowClass, 'workflow_type' => $run->workflow_type, 'namespace' => $run->namespace, @@ -4851,6 +4851,7 @@ private function startChildRetryIfAvailable( $retryRun = WorkflowRun::query()->create([ 'workflow_instance_id' => $failedChildRun->workflow_instance_id, 'run_number' => $nextRunNumber, + 'observability_context' => TraceCarrier::fromRun($failedChildRun), 'workflow_class' => $failedChildRun->workflow_class, 'workflow_type' => $failedChildRun->workflow_type, 'namespace' => $failedChildRun->namespace, @@ -7329,24 +7330,6 @@ private function logApproachingLimit(?array $warning, WorkflowRun $run, array $e ], $extraContext)); } - /** - * @return array{schema: string, version: int, carrier: array}|null - */ - private function inheritedTraceContext(WorkflowRun $run): ?array - { - if (config('workflows.tracing.enabled', false) !== true) { - return null; - } - - // Preserve the admission parent; never recapture ambient worker context in this transaction. - $stored = array_key_exists('observability_context', $run->getAttributes()) - ? $run->getAttribute('observability_context') - : null; - $allowlist = config('workflows.tracing.baggage_allowlist', []); - - return TraceCarrier::normalize($stored, is_array($allowlist) ? $allowlist : []); - } - private function inheritTypedVisibilityMetadata(WorkflowRun $sourceRun, WorkflowRun $targetRun): void { app(MemoUpsertService::class)->inheritFromParent($sourceRun, $targetRun, 1); diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 784397181..0f49c997c 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -29,16 +29,21 @@ use OpenTelemetry\SDK\Trace\TracerProvider; use Orchestra\Testbench\Foundation\Application; use Workflow\Providers\WorkflowServiceProvider; +use Workflow\Serializers\Serializer; use Workflow\V2\Activity; use Workflow\V2\Attributes\Signal; use Workflow\V2\Models\ActivityAttempt; use Workflow\V2\Models\ActivityExecution; +use Workflow\V2\Models\WorkflowChildCall; use Workflow\V2\Models\WorkflowHistoryEvent; +use Workflow\V2\Models\WorkflowLink; use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Models\WorkflowTask; use Workflow\V2\StartOptions; use Workflow\V2\Support\ActivityOptions; +use Workflow\V2\Support\DefaultWorkflowTaskBridge; use Workflow\V2\Support\ExecutionTracing; +use Workflow\V2\Support\TaskDispatcher; use Workflow\V2\Tracing\LogContext; use Workflow\V2\Workflow; use Workflow\V2\WorkflowStub; @@ -54,6 +59,7 @@ $recovery = getenv('DW_TEST_TRACING_RECOVERY') === 'true'; $transition = getenv('DW_TEST_TRACING_TRANSITION'); $signalStart = getenv('DW_TEST_TRACING_SIGNAL_START') === 'true'; +$childRetry = getenv('DW_TEST_TRACING_CHILD_RETRY') === 'true'; foreach (array_slice($argv, 4) as $autoload) { require_once $autoload; } @@ -116,6 +122,26 @@ public function handle(bool $continued = false): array } } +final class TracingRetryParentWorkflow extends Workflow +{ + public function handle(): array + { + return self::child(TracingRetryChildWorkflow::class); + } +} + +final class TracingRetryChildWorkflow extends Workflow +{ + public function handle(): array + { + if (getenv('DW_TEST_TRACING_FAIL_CHILD') === 'true') { + throw new \RuntimeException('deliberate first child failure'); + } + + return self::activity(TracingProcessActivity::class, false); + } +} + #[Signal('ready')] final class TracingSignalStartWorkflow extends Workflow { @@ -395,6 +421,38 @@ public function extract( } http_response_code(201); header('Content-Type: application/json'); + } elseif ($childRetry) { + // A real bridge-authored policy, not an invented PHP child option or history edit. + config() + ->set('workflows.v2.task_dispatch_mode', 'poll'); + WorkflowStub::make(TracingRetryParentWorkflow::class, 'cold-tracing', '0')->start( + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); + $parentRun = WorkflowRun::query()->where('workflow_instance_id', 'cold-tracing')->sole(); + $parentTask = WorkflowTask::query()->where('workflow_run_id', $parentRun->id)->sole(); + $bridge = app(DefaultWorkflowTaskBridge::class); + if (! $bridge->claimStatus($parentTask->id)['claimed']) { + throw new \RuntimeException('Parent claim refused.'); + } + config() + ->set('workflows.v2.task_dispatch_mode', 'queue'); + $completion = $bridge->complete($parentTask->id, [[ + 'type' => 'start_child_workflow', + 'workflow_type' => TracingRetryChildWorkflow::class, + 'arguments' => Serializer::serializeWithCodec('avro', []), + 'retry_policy' => [ + 'max_attempts' => 2, + 'backoff_seconds' => [1], + ], + 'execution_timeout_seconds' => 30, + 'run_timeout_seconds' => 20, + ]]); + if (! $completion['completed']) { + throw new \RuntimeException('Parent child admission refused.'); + } + foreach ($completion['created_task_ids'] as $taskId) { + TaskDispatcher::dispatch(WorkflowTask::query()->findOrFail($taskId)); + } } elseif ($signalStart) { $signalStartResult = WorkflowStub::make(TracingSignalStartWorkflow::class, 'cold-tracing', '0') ->signalWithStart( @@ -431,6 +489,20 @@ public function extract( if (! $result->returnedExistingActive()) { throw new \RuntimeException('Expected the original active admission.'); } + } elseif ($mode === 'bridge-fail' && $childRetry) { + $child = WorkflowRun::query()->where('workflow_class', TracingRetryChildWorkflow::class) + ->where('run_number', 1) + ->sole(); + $childTask = WorkflowTask::query()->where('workflow_run_id', $child->id)->sole(); + $bridge = app(DefaultWorkflowTaskBridge::class); + if (! $bridge->claimStatus($childTask->id)['claimed'] + || ! $bridge->complete($childTask->id, [[ + 'type' => 'fail_workflow', + 'message' => 'deliberate first child failure', + 'exception_class' => \RuntimeException::class, + ]])['completed']) { + throw new \RuntimeException('Child failure admission refused.'); + } } elseif ($mode === 'work') { if (Artisan::call('queue:work', [ 'connection' => 'database', @@ -490,6 +562,8 @@ public function extract( 'outside_http' => $outsideHttp, 'spans' => array_map(static fn ($span): array => [ 'name' => $span->getName(), + 'status_code' => $span->getStatus() + ->getCode(), 'kind' => $span->getKind(), 'trace_id' => $span->getContext() ->getTraceId(), @@ -531,6 +605,39 @@ public function extract( $output['completion_count'] = WorkflowHistoryEvent::query()->where('event_type', 'ActivityCompleted')->count(); $output['history_payloads'] = WorkflowHistoryEvent::query()->orderBy('id')->pluck('payload')->all(); } + if ($childRetry) { + $output['runs'] = WorkflowRun::query()->orderBy('id')->get()->map(static fn ($row): array => [ + 'id' => $row->id, + 'instance_id' => $row->workflow_instance_id, + 'number' => $row->run_number, + 'status' => $row->status->value, + 'carrier' => $row->getRawOriginal('observability_context'), + 'execution_deadline' => $row->getRawOriginal('execution_deadline_at'), + 'run_deadline' => $row->getRawOriginal('run_deadline_at'), + 'started_at' => $row->getRawOriginal('started_at'), + 'history_hashes' => $row->historyEvents() + ->orderBy('sequence') + ->get() + ->map(static function ($event): string { + return hash('sha256', json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR)); + })->all(), + ])->all(); + $output['activity_count'] = ActivityExecution::query()->count(); + $output['completion_count'] = WorkflowHistoryEvent::query()->where('event_type', 'ActivityCompleted')->count(); + $output['policy'] = WorkflowChildCall::query()->sole()->retry_policy; + $output['links'] = WorkflowLink::query()->orderBy('id')->get([ + 'id', 'parent_workflow_run_id', 'child_workflow_instance_id', 'child_workflow_run_id', + ])->toArray(); + $output['task_rows'] = WorkflowTask::query()->orderBy('id')->get()->map(static fn ($row): array => [ + 'id' => $row->id, + 'run_id' => $row->workflow_run_id, + 'status' => $row->status->value, + 'attempt_count' => $row->attempt_count, + 'available_at' => $row->getRawOriginal('available_at'), + 'leased_at' => $row->getRawOriginal('leased_at'), + ])->all(); + $output['history_payloads'] = WorkflowHistoryEvent::query()->orderBy('id')->pluck('payload')->all(); + } if ($recovery) { $history = WorkflowHistoryEvent::query()->where('workflow_run_id', $run->id)->orderBy('sequence')->get(); $output['recovery'] = [ diff --git a/tests/Unit/V2/ChildRetryExecutionTracingTest.php b/tests/Unit/V2/ChildRetryExecutionTracingTest.php new file mode 100644 index 000000000..1438f1f37 --- /dev/null +++ b/tests/Unit/V2/ChildRetryExecutionTracingTest.php @@ -0,0 +1,193 @@ + [$failure, true, true]; + yield $failure . ', disabled' => [$failure, false, false]; + yield $failure . ', legacy admission' => [$failure, false, true]; + yield $failure . ', disabled workers' => [$failure, true, false]; + } + } + + #[DataProvider('modes')] + public function testColdChildRetryKeepsItsAdmissionContextAndOriginalDurablePolicy( + string $failure, + bool $admissionEnabled, + bool $workerEnabled, + ): void { + $directory = sys_get_temp_dir() . '/dw-tracing-child-retry-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static function (string $mode, bool $fail = false) use ( + $directory, + $admissionEnabled, + $workerEnabled, + ): array { + $autoloads = array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + array_keys(ClassLoader::getRegisteredLoaders()) + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...$autoloads, + ], env: [ + 'DW_TEST_TRACING_CHILD_RETRY' => 'true', + 'DW_TEST_TRACING_FAIL_CHILD' => $fail ? 'true' : 'false', + 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admissionEnabled : $workerEnabled) ? 'true' : 'false', + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + try { + $admitted = $worker('admit'); + self::assertSame($admissionEnabled, $admitted['carrier'] !== null); + self::assertCount(2, $admitted['runs']); + $originalChild = $admitted['runs'][1]; + $failed = $worker($failure, true); + self::assertCount(3, $failed['runs']); + $retry = $failed['runs'][2]; + self::assertSame('failed', $failed['runs'][1]['status']); + self::assertSame('pending', $retry['status']); + self::assertSame($originalChild['instance_id'], $retry['instance_id']); + self::assertSame(2, $retry['number']); + self::assertSame($originalChild['execution_deadline'], $retry['execution_deadline']); + $retryTask = array_values(array_filter( + $failed['task_rows'], + static fn (array $row): bool => $row['run_id'] === $retry['id'] + ))[0]; + $zone = new DateTimeZone('UTC'); + $started = new DateTimeImmutable($retry['started_at'], $zone); + $available = new DateTimeImmutable($retryTask['available_at'], $zone); + self::assertSame($started->modify('+1 second')->format('U.u'), $available->format('U.u')); + self::assertSame($started->modify('+20 seconds')->format('U.u'), ( + new DateTimeImmutable($retry['run_deadline'], $zone) + )->format('U.u')); + // Wait for the real queue's second-resolution wakeup, without advancing stored clocks. + $wakeup = ceil((float) $available->format('U.u')); + self::assertLessThan(5, $wakeup - microtime(true)); + while (microtime(true) < $wakeup) { + usleep(10_000); + } + $workers = [$failed]; + for ($remaining = 10; $remaining > 0; --$remaining) { + $state = $worker('inspect'); + if ($state['jobs'] === 0) { + break; + } + $workers[] = $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + self::assertSame(1, $finished['activity_count']); + self::assertSame(1, $finished['completion_count']); + self::assertSame(6, $finished['tasks']); + self::assertCount(3, $finished['runs']); + self::assertSame(['completed', 'failed', 'completed'], array_column($finished['runs'], 'status')); + self::assertCount(2, $finished['links']); + self::assertCount(1, array_filter( + $finished['task_rows'], + static fn (array $task): bool => $task['status'] === 'failed' + )); + self::assertCount(5, array_filter( + $finished['task_rows'], + static fn (array $task): bool => $task['status'] === 'completed' + )); + self::assertSame($admitted['policy'], $failed['policy']); + self::assertSame($admitted['policy'], $finished['policy']); + self::assertSame([ + 'snapshot_version' => 1, + 'max_attempts' => 2, + 'backoff_seconds' => [1], + 'non_retryable_error_types' => [], + ], $finished['policy']); + foreach ($admitted['runs'] as $index => $run) { + foreach (['id', 'instance_id', 'carrier', 'execution_deadline', 'run_deadline', 'started_at'] as $key) { + self::assertSame($run[$key], $failed['runs'][$index][$key]); + } + self::assertSame( + $run['history_hashes'], + array_slice($failed['runs'][$index]['history_hashes'], 0, count($run['history_hashes'])) + ); + } + foreach ($failed['runs'] as $index => $run) { + foreach (['id', 'instance_id', 'execution_deadline', 'run_deadline', 'started_at'] as $key) { + self::assertSame($run[$key], $finished['runs'][$index][$key]); + } + self::assertSame($run['history_hashes'], array_slice( + $finished['runs'][$index]['history_hashes'], + 0, + count($run['history_hashes']) + )); + } + self::assertSame($failed['runs'][1]['history_hashes'], $finished['runs'][1]['history_hashes']); + foreach ($finished['task_rows'] as $task) { + self::assertSame(1, $task['attempt_count']); + self::assertGreaterThanOrEqual( + new DateTimeImmutable($task['available_at'], $zone), + new DateTimeImmutable($task['leased_at'], $zone) + ); + } + self::assertSame(count($workers), count(array_unique(array_column($workers, 'pid')))); + foreach ($finished['runs'] as $run) { + self::assertSame($admitted['carrier'], $run['carrier'], $run['id'] . ' generation ' . $run['number']); + } + $spans = array_merge(...array_column($workers, 'spans')); + if ($workerEnabled) { + $observed = []; + foreach ($spans as $span) { + $observed[] = $span['fields']['workflow_run_id']; + if ($admissionEnabled) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); + self::assertSame('00f067aa0ba902b7', $span['parent_id']); + } else { + self::assertSame('0000000000000000', $span['parent_id']); + self::assertNotSame('11111111111111111111111111111111', $span['trace_id']); + } + } + self::assertContains($retry['id'], $observed); + self::assertContains($admitted['run_id'], $observed); + if ($failure === 'work') { + $failureSpan = array_values(array_filter( + $failed['spans'], + static fn (array $span): bool => $span['fields']['workflow_run_id'] === $originalChild['id'] + )); + self::assertCount(1, $failureSpan); + self::assertSame(StatusCode::STATUS_ERROR, $failureSpan[0]['status_code']); + } + } else { + self::assertSame([], $spans); + } + self::assertStringNotContainsString( + 'observability_context', + json_encode($finished['history_payloads'], JSON_THROW_ON_ERROR) + ); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } +} diff --git a/tests/Unit/V2/TraceCarrierTest.php b/tests/Unit/V2/TraceCarrierTest.php index 710d6a1c5..2ac0d8429 100644 --- a/tests/Unit/V2/TraceCarrierTest.php +++ b/tests/Unit/V2/TraceCarrierTest.php @@ -13,6 +13,8 @@ use OpenTelemetry\Context\Propagation\MultiTextMapPropagator; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use RuntimeException; +use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Support\TraceCarrier; final class TraceCarrierTest extends TestCase @@ -28,6 +30,34 @@ public function testValidCarrierPreservesTraceIdentityAndState(): void $this->assertSame($input, TraceCarrier::normalize($input)); } + public function testAbsentStoredMetadataDoesNotRequireApplicationInstrumentation(): void + { + $run = new WorkflowRun(); + $this->assertNull(TraceCarrier::fromRun($run)); + $run->setRawAttributes([ + 'observability_context' => null, + ]); + $this->assertNull(TraceCarrier::fromRun($run)); + } + + public function testMetadataReadFailureIsDiscardedWithoutInterruptingExecution(): void + { + $run = new class() extends WorkflowRun { + public function getAttributes(): array + { + return [ + 'observability_context' => 'unreadable', + ]; + } + + public function getAttribute($key): mixed + { + throw new RuntimeException('optional telemetry cast failed'); + } + }; + $this->assertNull(TraceCarrier::fromRun($run)); + } + public function testBaggageIsAbsentUnlessExplicitlyAllowlisted(): void { $input = self::envelope([ diff --git a/tests/Unit/V2/TransitionExecutionTracingTest.php b/tests/Unit/V2/TransitionExecutionTracingTest.php index 339997a3e..ab5e9dfc6 100644 --- a/tests/Unit/V2/TransitionExecutionTracingTest.php +++ b/tests/Unit/V2/TransitionExecutionTracingTest.php @@ -21,6 +21,8 @@ public static function transitions(): array 'continuation, tracing disabled' => ['continue', false, false], 'legacy child, enabled later' => ['child', true, false], 'legacy continuation, enabled later' => ['continue', true, false], + 'child, disabled workers' => ['child', false, true], + 'continuation, disabled workers' => ['continue', false, true], ]; } From 23d656b4941285c6172493d87e173d144c051104 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 16:41:06 +0000 Subject: [PATCH 18/77] test: cover trace context through workflow cancellation --- .github/workflows/php.yml | 1 + docs/embedded-tracing.md | 6 + tests/Fixtures/V2/tracing-process.php | 56 ++++++ .../V2/CancellationExecutionTracingTest.php | 163 ++++++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 tests/Unit/V2/CancellationExecutionTracingTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 9c15dc3b3..45ed53d00 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -382,6 +382,7 @@ jobs: tests/Unit/V2/RecoveryExecutionTracingTest.php tests/Unit/V2/TransitionExecutionTracingTest.php tests/Unit/V2/ChildRetryExecutionTracingTest.php + tests/Unit/V2/CancellationExecutionTracingTest.php tests/Unit/V2/SignalStartExecutionTracingTest.php --fail-on-warning --log-junit build/test-results/pr-activity-tracing.xml diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index a37e1167c..42ef03f33 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -107,6 +107,12 @@ activity and parent workers. Child admission and retries preserve the original carrier, retry policy and execution deadline. This does not add retry-policy options to the PHP `Workflow::child` helper. +Independent-process cancellation controls cover a waiting workflow, a cooperative +request followed by its cleanup activity, and immediate cancellation or +termination. Existing carrier metadata and deadlines remain unchanged. Cleanup +uses the original run's parent, and task scopes restore the worker's context. +These controls do not yet add causal links to the cancellation caller. + Disabling tracing stops new context capture and span emission. These qualified embedded transitions and child-retry paths still inherit valid stored admission context while the optional propagation API remains installed. The current diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 0f49c997c..ab3fe5e99 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -35,6 +35,7 @@ use Workflow\V2\Models\ActivityAttempt; use Workflow\V2\Models\ActivityExecution; use Workflow\V2\Models\WorkflowChildCall; +use Workflow\V2\Models\WorkflowCommand; use Workflow\V2\Models\WorkflowHistoryEvent; use Workflow\V2\Models\WorkflowLink; use Workflow\V2\Models\WorkflowRun; @@ -60,6 +61,7 @@ $transition = getenv('DW_TEST_TRACING_TRANSITION'); $signalStart = getenv('DW_TEST_TRACING_SIGNAL_START') === 'true'; $childRetry = getenv('DW_TEST_TRACING_CHILD_RETRY') === 'true'; +$cancellation = getenv('DW_TEST_TRACING_CANCELLATION'); foreach (array_slice($argv, 4) as $autoload) { require_once $autoload; } @@ -142,6 +144,23 @@ public function handle(): array } } +#[Signal('ready')] +final class TracingCancellationWorkflow extends Workflow +{ + public function handle(): void + { + try { + self::awaitSignal('ready'); + } finally { + Log::info('cancellation cleanup'); + $result = self::activity(TracingProcessActivity::class, false); + self::upsertMemo([ + 'cleanup' => $result, + ]); + } + } +} + #[Signal('ready')] final class TracingSignalStartWorkflow extends Workflow { @@ -387,6 +406,7 @@ public function extract( $outsideHttp = null; $repairReport = null; $signalStartResult = null; + $cancellationResult = null; try { if ($mode === 'admit') { if (Artisan::call('migrate', [ @@ -453,6 +473,10 @@ public function extract( foreach ($completion['created_task_ids'] as $taskId) { TaskDispatcher::dispatch(WorkflowTask::query()->findOrFail($taskId)); } + } elseif ($cancellation) { + WorkflowStub::make(TracingCancellationWorkflow::class, 'cold-tracing', '0')->start( + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); } elseif ($signalStart) { $signalStartResult = WorkflowStub::make(TracingSignalStartWorkflow::class, 'cold-tracing', '0') ->signalWithStart( @@ -477,6 +501,18 @@ public function extract( new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), ); } + } elseif ($mode === 'cancel' && $cancellation) { + $stub = WorkflowStub::make(TracingCancellationWorkflow::class, 'cold-tracing', '0'); + $result = match ($cancellation) { + 'request' => $stub->requestCancellation('deliberate cancellation', 20), + 'cancel' => $stub->cancel('deliberate cancellation'), + 'terminate' => $stub->terminate('deliberate termination'), + default => throw new \RuntimeException('Unknown cancellation mode.'), + }; + $cancellationResult = [ + 'accepted' => $result->accepted(), + 'command_id' => $result->commandId(), + ]; } elseif ($mode === 'duplicate' && $signalStart) { $signalStartResult = WorkflowStub::make(TracingSignalStartWorkflow::class, 'cold-tracing', '0') ->signalWithStart('ready', [['later']], StartOptions::returnExistingActive()); @@ -638,6 +674,26 @@ public function extract( ])->all(); $output['history_payloads'] = WorkflowHistoryEvent::query()->orderBy('id')->pluck('payload')->all(); } + if ($cancellation) { + $history = $run->historyEvents() + ->orderBy('sequence') + ->get(); + $output['cancellation'] = [ + 'result' => $cancellationResult, + 'deadline' => $run->getRawOriginal('cancellation_deadline_at'), + 'memo' => WorkflowStub::make(TracingCancellationWorkflow::class, 'cold-tracing', '0')->memo(), + 'commands' => WorkflowCommand::query()->orderBy('id')->get(['id', 'command_type'])->toArray(), + 'history_types' => $history->map(static fn ($event): string => $event->event_type->value) + ->all(), + 'history_hashes' => $history->map(static function ($event): string { + return hash('sha256', json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR)); + })->all(), + 'history_payloads' => $history->pluck('payload') + ->all(), + 'activities' => ActivityExecution::query()->get(['id', 'status', 'attempt_count'])->toArray(), + 'tasks' => WorkflowTask::query()->get(['id', 'status', 'attempt_count'])->toArray(), + ]; + } if ($recovery) { $history = WorkflowHistoryEvent::query()->where('workflow_run_id', $run->id)->orderBy('sequence')->get(); $output['recovery'] = [ diff --git a/tests/Unit/V2/CancellationExecutionTracingTest.php b/tests/Unit/V2/CancellationExecutionTracingTest.php new file mode 100644 index 000000000..5007c7bf9 --- /dev/null +++ b/tests/Unit/V2/CancellationExecutionTracingTest.php @@ -0,0 +1,163 @@ + [$command, true]; + yield $command . ', disabled' => [$command, false]; + } + } + + #[DataProvider('modes')] + public function testCancellationKeepsOriginalContextAndUnwindsColdWorkerScopes(string $command, bool $enabled): void + { + $directory = sys_get_temp_dir() . '/dw-tracing-cancellation-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static function (string $mode) use ($directory, $command, $enabled): array { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_CANCELLATION' => $command, + 'DW_TEST_TRACING_ENABLED' => $enabled ? 'true' : 'false', + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + try { + $admitted = $worker('admit'); + self::assertSame($enabled, $admitted['carrier'] !== null); + $waiting = $worker('work'); + self::assertSame('waiting', $waiting['status']); + self::assertSame(0, $waiting['jobs']); + self::assertSame([], $waiting['cancellation']['activities']); + $requested = $worker('cancel'); + self::assertTrue($requested['cancellation']['result']['accepted']); + if ($command === 'request') { + $duplicate = $worker('cancel'); + self::assertSame($requested['cancellation']['result'], $duplicate['cancellation']['result']); + self::assertSame($requested['cancellation']['deadline'], $duplicate['cancellation']['deadline']); + self::assertSame( + $requested['cancellation']['history_hashes'], + $duplicate['cancellation']['history_hashes'] + ); + } + $workers = [$waiting]; + for ($remaining = 6; $remaining > 0; --$remaining) { + if ($worker('inspect')['jobs'] === 0) { + break; + } + $workers[] = $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame($command === 'terminate' ? 'terminated' : 'cancelled', $finished['status']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { + self::assertSame($admitted[$key], $requested[$key]); + self::assertSame($admitted[$key], $finished[$key]); + } + foreach ([$waiting, $requested] as $prefix) { + self::assertSame($prefix['cancellation']['history_hashes'], array_slice( + $finished['cancellation']['history_hashes'], + 0, + count($prefix['cancellation']['history_hashes']) + )); + } + $originalCommands = array_column($waiting['cancellation']['commands'], null, 'id'); + $commands = array_column($finished['cancellation']['commands'], null, 'id'); + self::assertSame($originalCommands, array_intersect_key($commands, $originalCommands)); + self::assertCount(count($originalCommands) + 1, $commands); + self::assertSame( + $command === 'request' ? 'request_cancellation' : $command, + $commands[$requested['cancellation']['result']['command_id']]['command_type'] + ); + $cooperative = $command === 'request'; + self::assertSame($cooperative ? [ + 'cleanup' => [9007199254740993, null, '雪'], + ] : [], $finished['cancellation']['memo']); + self::assertCount($cooperative ? 1 : 0, $finished['cancellation']['activities']); + if ($cooperative) { + self::assertSame('completed', $finished['cancellation']['activities'][0]['status']); + self::assertSame(1, $finished['cancellation']['activities'][0]['attempt_count']); + foreach ([ + 'CooperativeCancellationRequested', + 'CooperativeCancellationDelivered', + 'ActivityCompleted', + 'MemoUpserted', + ] as $type) { + self::assertSame(1, array_count_values($finished['cancellation']['history_types'])[$type] ?? 0); + } + } + self::assertNotContains('WorkflowCompleted', $finished['cancellation']['history_types']); + foreach ($finished['cancellation']['tasks'] as $task) { + self::assertSame(1, $task['attempt_count']); + } + $spans = array_merge(...array_column($workers, 'spans')); + self::assertCount($enabled ? count($workers) : 0, $spans); + $taskIds = array_column($finished['cancellation']['tasks'], 'id'); + foreach ($spans as $span) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); + self::assertSame('00f067aa0ba902b7', $span['parent_id']); + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + self::assertContains($span['fields']['task_id'], $taskIds); + } + self::assertCount($enabled && $cooperative ? 1 : 0, array_filter( + $spans, + static fn (array $span): bool => $span['name'] === 'durable.activity.attempt' + )); + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); + self::assertCount($cooperative ? 1 : 0, array_filter( + $records, + static fn (array $record): bool => $record['message'] === 'callback' + )); + foreach ($records as $record) { + if ($record['message'] === 'worker outside' || ! $enabled) { + self::assertArrayNotHasKey('workflow_run_id', $record['context']); + self::assertArrayNotHasKey('trace_id', $record['context']); + } elseif (in_array($record['message'], ['cancellation cleanup', 'callback'], true)) { + self::assertSame($admitted['run_id'], $record['context']['workflow_run_id']); + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $record['context']['trace_id']); + self::assertContains($record['context']['task_id'], $taskIds); + } + } + self::assertStringNotContainsString('observability_context', json_encode( + $finished['cancellation']['history_payloads'], + JSON_THROW_ON_ERROR + )); + self::assertSame(count($workers), count(array_unique(array_column($workers, 'pid')))); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } +} From d29d37b48af5706ba7c7c7d9958ffb15e064cee8 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 16:55:01 +0000 Subject: [PATCH 19/77] fix: isolate task execution from unreadable trace metadata --- docs/embedded-tracing.md | 5 +- src/V2/Jobs/RunActivityTask.php | 5 +- src/V2/Jobs/RunWorkflowTask.php | 5 +- .../Unit/V2/ActivityExecutionTracingTest.php | 100 ++++++++++++++++++ 4 files changed, 108 insertions(+), 7 deletions(-) diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index 42ef03f33..f9391480a 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -117,7 +117,10 @@ Disabling tracing stops new context capture and span emission. These qualified embedded transitions and child-retry paths still inherit valid stored admission context while the optional propagation API remains installed. The current baggage allowlist still applies. Missing or unreadable metadata produces no -inherited parent. +inherited parent. Task execution also discards unreadable telemetry metadata +without failing business work or rewriting the stored carrier. Task span +wrappers skip their metadata read when emission is disabled. Transition +inheritance follows the preservation rule above. The run carrier is separate observability metadata. It is not command, history, payload or lease authority. The proposed nullable run column has not been diff --git a/src/V2/Jobs/RunActivityTask.php b/src/V2/Jobs/RunActivityTask.php index b839d3d71..c2b622153 100644 --- a/src/V2/Jobs/RunActivityTask.php +++ b/src/V2/Jobs/RunActivityTask.php @@ -20,6 +20,7 @@ use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\RuntimeObjectFactory; use Workflow\V2\Support\TaskDispatcher; +use Workflow\V2\Support\TraceCarrier; use Workflow\V2\Support\TypeRegistry; use Workflow\V2\Support\WorkerCompatibilityFleet; use Workflow\V2\Testing\ActivityFakeContext; @@ -71,9 +72,7 @@ public function handle(): void } $allowlist = config('workflows.tracing.baggage_allowlist', []); - $storedContext = array_key_exists('observability_context', $claim->run->getAttributes()) - ? $claim->run->getAttribute('observability_context') - : null; + $storedContext = TraceCarrier::fromRun($claim->run); ExecutionTracing::activity( [ 'workflow_instance_id' => $claim->run->workflow_instance_id, diff --git a/src/V2/Jobs/RunWorkflowTask.php b/src/V2/Jobs/RunWorkflowTask.php index 4e36fc9af..5ebad298d 100644 --- a/src/V2/Jobs/RunWorkflowTask.php +++ b/src/V2/Jobs/RunWorkflowTask.php @@ -23,6 +23,7 @@ use Workflow\V2\Support\TaskBackendCapabilities; use Workflow\V2\Support\TaskCompatibility; use Workflow\V2\Support\TaskDispatcher; +use Workflow\V2\Support\TraceCarrier; use Workflow\V2\Support\WorkerCompatibilityFleet; use Workflow\V2\Support\WorkflowExecutor; use Workflow\V2\Support\WorkflowTaskLease; @@ -63,9 +64,7 @@ public function handle(WorkflowExecutor $executor): void /** @var WorkflowRun $run */ $run = $task->getRelation('run'); $allowlist = config('workflows.tracing.baggage_allowlist', []); - $storedContext = array_key_exists('observability_context', $run->getAttributes()) - ? $run->getAttribute('observability_context') - : null; + $storedContext = TraceCarrier::fromRun($run); // Activate and end the segment outside the execution transaction. ExecutionTracing::workflow( [ diff --git a/tests/Unit/V2/ActivityExecutionTracingTest.php b/tests/Unit/V2/ActivityExecutionTracingTest.php index 4342232a7..55a79cf41 100644 --- a/tests/Unit/V2/ActivityExecutionTracingTest.php +++ b/tests/Unit/V2/ActivityExecutionTracingTest.php @@ -5,6 +5,8 @@ namespace Tests\Unit\V2; use Composer\Autoload\ClassLoader; +use Illuminate\Contracts\Database\Eloquent\CastsAttributes; +use Illuminate\Database\Eloquent\Model; use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; @@ -176,6 +178,88 @@ public static function coldModes(): array ]; } + public static function unreadableMetadataModes(): iterable + { + foreach (['workflow', 'activity'] as $phase) { + yield $phase . ', enabled' => [$phase, true]; + yield $phase . ', disabled' => [$phase, false]; + } + } + + #[DataProvider('unreadableMetadataModes')] + public function testUnreadableOptionalRunMetadataCannotInterruptClaimedBusinessWork( + string $phase, + bool $enabled + ): void { + config([ + 'workflows.tracing.enabled' => $enabled, + ]); + $scope = TraceContextPropagator::getInstance()->extract([ + 'traceparent' => '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + ])->activate(); + try { + $stub = WorkflowStub::make(TracedWorkflow::class, 'unreadable-tracing', '0'); + $stub->start(false, new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45)); + } finally { + $scope->detach(); + } + $run = WorkflowRun::query()->findOrFail($stub->runId()); + $preserved = array_flip(['observability_context', 'execution_deadline_at', 'run_deadline_at']); + $original = array_intersect_key($run->getRawOriginal(), $preserved); + $prefix = WorkflowHistoryEvent::query()->where('workflow_run_id', $run->id)->orderBy('sequence') + ->get() + ->map(static fn ($event): array => $event->getRawOriginal()) + ->all(); + if ($phase === 'activity') { + $this->workOnce(); + } + UnreadableTraceCast::$metadataReads = 0; + WorkflowRun::retrieved(static function (WorkflowRun $retrievedRun): void { + if ($retrievedRun->workflow_instance_id === 'unreadable-tracing') { + $retrievedRun->mergeCasts([ + 'observability_context' => UnreadableTraceCast::class, + ]); + } + }); + for ($remaining = 4; $remaining > 0 && DB::table('jobs')->count() > 0; --$remaining) { + $this->workOnce(); + self::assertSame([], ExecutionTracing::current()); + } + $run->refresh(); + self::assertSame('completed', $run->status->value); + self::assertSame([9007199254740993, null, '雪'], $run->workflowOutput()); + self::assertSame($original, array_intersect_key($run->getRawOriginal(), $preserved)); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + self::assertSame(3, WorkflowTask::query()->where('status', 'completed')->where('attempt_count', 1)->count()); + self::assertSame( + 1, + ActivityAttempt::query()->where('status', 'completed')->where('attempt_number', 1)->count() + ); + self::assertSame(1, WorkflowHistoryEvent::query()->where('event_type', 'ActivityCompleted')->count()); + self::assertSame(1, WorkflowHistoryEvent::query()->where('event_type', 'WorkflowCompleted')->count()); + $history = WorkflowHistoryEvent::query()->where('workflow_run_id', $run->id)->orderBy('sequence') + ->get() + ->map(static fn ($event): array => $event->getRawOriginal()) + ->all(); + self::assertSame($prefix, array_slice($history, 0, count($prefix))); + self::assertSame($enabled ? ($phase === 'workflow' ? 3 : 2) : 0, UnreadableTraceCast::$metadataReads); + $spans = $this->exporter->getSpans(); + self::assertCount($enabled ? 3 : 0, $spans); + foreach ($spans as $index => $span) { + self::assertSame($run->id, $span->getAttributes()->get('workflow_run_id')); + self::assertSame($phase === 'activity' && $index === 0, $span->getParentContext()->isValid()); + } + self::assertCount( + 1, + array_filter( + TracedActivity::$observations, + static fn (array $observation): bool => $observation[0] === 'callback' + ) + ); + } + public function testFailedWorkflowExportsItsProcessingSegmentOutsideTheDatabaseTransaction(): void { config([ @@ -473,6 +557,22 @@ private function activitySpans(): array } } +final class UnreadableTraceCast implements CastsAttributes +{ + public static int $metadataReads = 0; + + public function get(Model $model, string $key, mixed $value, array $attributes): mixed + { + ++self::$metadataReads; + throw new RuntimeException('optional application telemetry cast failed'); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): mixed + { + return $value; + } +} + final class TracedWorkflow extends Workflow { public function handle(bool $retry): array From 67be1016cde080084afdde3aab3bbb53f33cc692 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 17:13:24 +0000 Subject: [PATCH 20/77] feat: link embedded signal callers to processing spans --- .github/workflows/php.yml | 1 + docs/embedded-tracing.md | 35 ++++ src/V2/Models/WorkflowCommand.php | 3 + src/V2/Support/ExecutionTracing.php | 44 ++++- src/V2/Support/TraceCarrier.php | 34 ++++ src/V2/Support/TraceLinkState.php | 44 +++++ src/V2/Support/WorkflowExecutor.php | 7 +- src/V2/WorkflowStub.php | 5 + ...rvability_context_to_workflow_commands.php | 28 +++ tests/Fixtures/V2/tracing-process.php | 87 ++++++++- .../Unit/V2/ActivityExecutionTracingTest.php | 30 +++ tests/Unit/V2/ExecutionTracingTest.php | 174 ++++++++++++++++++ .../V2/SignalLinkExecutionTracingTest.php | 172 +++++++++++++++++ .../V2/SignalStartExecutionTracingTest.php | 4 + 14 files changed, 664 insertions(+), 4 deletions(-) create mode 100644 src/V2/Support/TraceLinkState.php create mode 100644 src/migrations/2026_10_15_000200_add_observability_context_to_workflow_commands.php create mode 100644 tests/Unit/V2/SignalLinkExecutionTracingTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 45ed53d00..641667325 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -384,6 +384,7 @@ jobs: tests/Unit/V2/ChildRetryExecutionTracingTest.php tests/Unit/V2/CancellationExecutionTracingTest.php tests/Unit/V2/SignalStartExecutionTracingTest.php + tests/Unit/V2/SignalLinkExecutionTracingTest.php --fail-on-warning --log-junit build/test-results/pr-activity-tracing.xml - name: Run database-independent unit contracts diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index f9391480a..5c8532bc9 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -122,6 +122,41 @@ without failing business work or rewriting the stored carrier. Task span wrappers skip their metadata read when emission is disabled. Transition inheritance follows the preservation rule above. +## Signal caller links + +This draft also proposes a nullable `workflow_commands.observability_context` +column. New accepted embedded signals and signal-with-start commands capture +their own bounded carrier before the admission transaction. Existing command +metadata, including null, stays unchanged. The column is separate from command +context, payloads, history and idempotency. It is hidden from ordinary model JSON. +Each captured run or command carrier is limited to 4 KiB. Links add no durable +history events. + +When the embedded executor applies a signal, its processing span links to that +command's captured caller. This includes signals received before the first task +runs and after a real wait. The run's original parent and baggage stay active. +Legacy admissions without a parent keep independent processing segments with +links to their new signal callers. Replaying an already applied signal adds no +new signal-application link. + +```mermaid +flowchart LR + A[Captured admission span] -->|parent| W[Workflow processing segment] + A -->|parent| X[Activity attempt] + B[Independent signal caller] -.->|link on application| W + X -->|parent| H[Instrumented HTTP client] +``` + +Links are added during processing. They do not change an earlier head-sampling +decision and are not proof that a transaction committed. Up to 32 distinct +caller trace/span contexts are retained per processing segment. Repeated callers +share a link identified by the first applied command. Further additions are +dropped, with the count in `durable.links.dropped`. Application SDK limits may +be lower. The link state ends with its processing scope. + +This does not add service wire propagation, native catalog support, update or +cancellation-caller links, schedule links or complete fan-in tracing. + The run carrier is separate observability metadata. It is not command, history, payload or lease authority. The proposed nullable run column has not been adopted by the native Server. Other admission paths, broader trigger links, diff --git a/src/V2/Models/WorkflowCommand.php b/src/V2/Models/WorkflowCommand.php index a1725af62..7f9079522 100644 --- a/src/V2/Models/WorkflowCommand.php +++ b/src/V2/Models/WorkflowCommand.php @@ -35,6 +35,8 @@ class WorkflowCommand extends Model protected $keyType = 'string'; + protected $hidden = ['observability_context']; + protected $dateFormat = 'Y-m-d H:i:s.u'; protected $casts = [ @@ -44,6 +46,7 @@ class WorkflowCommand extends Model 'command_sequence' => 'integer', 'message_sequence' => 'integer', 'context' => 'array', + 'observability_context' => 'array', 'accepted_at' => 'datetime', 'applied_at' => 'datetime', 'rejected_at' => 'datetime', diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php index 032b012f2..4ab74e04d 100644 --- a/src/V2/Support/ExecutionTracing.php +++ b/src/V2/Support/ExecutionTracing.php @@ -19,6 +19,10 @@ use OpenTelemetry\Context\ScopeInterface; use Throwable; use WeakMap; +use Workflow\V2\Enums\CommandStatus; +use Workflow\V2\Enums\CommandType; +use Workflow\V2\Models\WorkflowCommand; +use Workflow\V2\Models\WorkflowRun; /** @internal */ final class ExecutionTracing @@ -31,6 +35,8 @@ final class ExecutionTracing private static ?ContextKeyInterface $correlationKey = null; + private static ?ContextKeyInterface $linkKey = null; + /** * @var WeakMap|null */ @@ -148,6 +154,35 @@ public static function recordFailure(Throwable $error): void } } + public static function linkSignal(WorkflowRun $run, WorkflowCommand $command): void + { + try { + $fields = self::current(); + if (self::$linkKey === null || ($fields['workflow_run_id'] ?? null) !== $run->id + || ($fields['namespace'] ?? null) !== $run->namespace + || $command->workflow_run_id !== $run->id + || $command->workflow_instance_id !== $run->workflow_instance_id + || $command->command_type !== CommandType::Signal || $command->status !== CommandStatus::Accepted) { + return; + } + $state = Context::getCurrent()->get(self::$linkKey); + if (! $state instanceof TraceLinkState || ! $state->isRecording()) { + return; + } + $carrier = TraceCarrier::fromCommand($command); + if ($carrier === null) { + return; + } + $context = TraceContextPropagator::getInstance()->extract($carrier['carrier'], context: Context::getRoot()); + $state->add(Span::fromContext($context)->getContext(), [ + 'workflow_command_id' => $command->id, + 'command_type' => 'signal', + ]); + } catch (Throwable) { + // Optional causal links cannot replace a signal's durable decision. + } + } + /** * @param array $attributes * @param list $baggageAllowlist @@ -197,8 +232,13 @@ private static function segment( $fields['span_id'] = $span->getContext()->getSpanId(); } self::$correlationKey ??= Context::createKey('durable-workflow.correlation'); - $scope = $span->storeInContext($parent) - ->with(self::$correlationKey, $fields)->activate(); + $active = $span->storeInContext($parent) + ->with(self::$correlationKey, $fields); + if ($name === 'durable.workflow.task') { + self::$linkKey ??= Context::createKey('durable-workflow.trace-links'); + $active = $active->with(self::$linkKey, new TraceLinkState($span)); + } + $scope = $active->activate(); self::enterFiberScope(); } catch (Throwable) { self::close($scope, $span); diff --git a/src/V2/Support/TraceCarrier.php b/src/V2/Support/TraceCarrier.php index 850ef044c..55bbe964d 100644 --- a/src/V2/Support/TraceCarrier.php +++ b/src/V2/Support/TraceCarrier.php @@ -11,6 +11,7 @@ use OpenTelemetry\API\Trace\Span; use OpenTelemetry\Context\Context; use Throwable; +use Workflow\V2\Models\WorkflowCommand; use Workflow\V2\Models\WorkflowRun; /** @@ -28,6 +29,39 @@ final class TraceCarrier 'baggage' => 2048, ]; + /** + * @return array{schema: string, version: int, carrier: array}|null + */ + public static function captureConfigured(): ?array + { + try { + if (config('workflows.tracing.enabled', false) !== true) { + return null; + } + $allowlist = config('workflows.tracing.baggage_allowlist', []); + + return self::capture(is_array($allowlist) ? $allowlist : []); + } catch (Throwable) { + return null; + } + } + + /** + * @return array{schema: string, version: int, carrier: array}|null + */ + public static function fromCommand(WorkflowCommand $command): ?array + { + try { + $stored = array_key_exists('observability_context', $command->getAttributes()) + ? $command->getAttribute('observability_context') : null; + + // A link never imports the triggering caller's baggage into execution. + return self::normalize($stored); + } catch (Throwable) { + return null; + } + } + /** * @return array{schema: string, version: int, carrier: array}|null */ diff --git a/src/V2/Support/TraceLinkState.php b/src/V2/Support/TraceLinkState.php new file mode 100644 index 000000000..d2f6c044a --- /dev/null +++ b/src/V2/Support/TraceLinkState.php @@ -0,0 +1,44 @@ +span->isRecording(); + } + + /** + * @param array $attributes + */ + public function add(SpanContextInterface $context, array $attributes): void + { + $identity = $context->getTraceId() . ':' . $context->getSpanId(); + if (isset($this->seen[$identity])) { + return; + } + if (count($this->seen) >= 32) { + $this->span->setAttribute('durable.links.dropped', ++$this->dropped); + + return; + } + $this->span->addLink($context, $attributes); + $this->seen[$identity] = true; + } +} diff --git a/src/V2/Support/WorkflowExecutor.php b/src/V2/Support/WorkflowExecutor.php index c5febb752..4a989dfac 100644 --- a/src/V2/Support/WorkflowExecutor.php +++ b/src/V2/Support/WorkflowExecutor.php @@ -3229,7 +3229,7 @@ private function applySignal( is_string($run->namespace) ? $run->namespace : null, ); - return WorkflowHistoryEvent::record($run, HistoryEventType::SignalApplied, array_filter([ + $event = WorkflowHistoryEvent::record($run, HistoryEventType::SignalApplied, array_filter([ 'workflow_command_id' => $command->id, 'signal_id' => $signal?->id, 'signal_name' => $signalCall->name, @@ -3238,6 +3238,11 @@ private function applySignal( 'value' => ExternalPayloads::historyValue($storedValue, $run->payload_codec, $run->namespace), ...($parallelMetadata ?? []), ], static fn (mixed $payloadValue): bool => $payloadValue !== null), $task, $command); + if (config('workflows.tracing.enabled', false) === true) { + ExecutionTracing::linkSignal($run, $command); + } + + return $event; } private function recordSignalWait( diff --git a/src/V2/WorkflowStub.php b/src/V2/WorkflowStub.php index 7abcb9f2e..f8d866ae7 100644 --- a/src/V2/WorkflowStub.php +++ b/src/V2/WorkflowStub.php @@ -2747,6 +2747,7 @@ private function attemptSignalInternal( /** @var WorkflowCommand|null $command */ $command = null; $task = null; + $observabilityContext = $runtimeReserved ? null : TraceCarrier::captureConfigured(); SqliteTransaction::run(function () use ( $name, @@ -2754,6 +2755,7 @@ private function attemptSignalInternal( $payloadCodec, $payloadBlob, $runtimeReserved, + $observabilityContext, &$command, &$task ): void { @@ -2944,6 +2946,7 @@ private function attemptSignalInternal( $storedExternally = $payloadBlob !== null && ExternalPayloads::isStoredReference($payloadBlob); $commandAttributes = $this->commandAttributes([ 'command_type' => CommandType::Signal->value, + 'observability_context' => $observabilityContext, 'target_scope' => $this->commandTargetScope(), 'status' => CommandStatus::Accepted->value, 'outcome' => CommandOutcome::SignalReceived->value, @@ -3186,6 +3189,7 @@ private function attemptSignalWithStartInternal( $commandContext, [ 'command_type' => CommandType::Signal->value, + 'observability_context' => $observabilityContext, 'target_scope' => 'instance', 'status' => CommandStatus::Accepted->value, 'outcome' => CommandOutcome::SignalReceived->value, @@ -3447,6 +3451,7 @@ private function attemptSignalWithStartInternal( $commandContext, [ 'command_type' => CommandType::Signal->value, + 'observability_context' => $observabilityContext, 'target_scope' => 'instance', 'status' => CommandStatus::Accepted->value, 'outcome' => CommandOutcome::SignalReceived->value, diff --git a/src/migrations/2026_10_15_000200_add_observability_context_to_workflow_commands.php b/src/migrations/2026_10_15_000200_add_observability_context_to_workflow_commands.php new file mode 100644 index 000000000..7c5886b03 --- /dev/null +++ b/src/migrations/2026_10_15_000200_add_observability_context_to_workflow_commands.php @@ -0,0 +1,28 @@ +json('observability_context') + ->nullable(); + }); + } + } + + public function down(): void + { + if (Schema::hasColumn('workflow_commands', 'observability_context')) { + Schema::table('workflow_commands', static function (Blueprint $table): void { + $table->dropColumn('observability_context'); + }); + } + } +}; diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index ab3fe5e99..b1be77009 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -13,6 +13,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Monolog\Formatter\JsonFormatter; +use OpenTelemetry\API\Baggage\Baggage; use OpenTelemetry\API\Baggage\Propagation\BaggagePropagator; use OpenTelemetry\API\Instrumentation\Configurator; use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator; @@ -60,6 +61,7 @@ $recovery = getenv('DW_TEST_TRACING_RECOVERY') === 'true'; $transition = getenv('DW_TEST_TRACING_TRANSITION'); $signalStart = getenv('DW_TEST_TRACING_SIGNAL_START') === 'true'; +$signalLinks = getenv('DW_TEST_TRACING_SIGNAL_LINKS') === 'true'; $childRetry = getenv('DW_TEST_TRACING_CHILD_RETRY') === 'true'; $cancellation = getenv('DW_TEST_TRACING_CANCELLATION'); foreach (array_slice($argv, 4) as $autoload) { @@ -161,6 +163,30 @@ public function handle(): void } } +#[Signal('ready')] +final class TracingSignalLinksWorkflow extends Workflow +{ + public function handle(): array + { + $first = self::awaitSignal('ready'); + Log::info('signal applied', [ + 'approved_baggage' => ExecutionTracing::current() === [] + ? null : Baggage::fromContext(Context::getCurrent())->getValue('approved'), + ]); + $second = self::awaitSignal('ready'); + Log::info('signal applied', [ + 'approved_baggage' => ExecutionTracing::current() === [] + ? null : Baggage::fromContext(Context::getCurrent())->getValue('approved'), + ]); + + return [ + 'first' => $first, + 'second' => $second, + 'activity' => self::activity(TracingProcessActivity::class, false), + ]; + } +} + #[Signal('ready')] final class TracingSignalStartWorkflow extends Workflow { @@ -338,7 +364,7 @@ protected function defineEnvironment($app): void BaggagePropagator::getInstance(), ]); $recordedPropagator = null; -if ($signalStart) { +if ($signalStart || $signalLinks) { $recordedPropagator = new class($propagator) implements TextMapPropagatorInterface { public array $transactionLevels = []; @@ -382,6 +408,12 @@ public function extract( $headers = [ 'traceparent' => $parent, ]; + if ($signalLinks && $mode === 'signal') { + $headers['traceparent'] = getenv('DW_TEST_TRACING_SIGNAL_CALLER'); + $headers['baggage'] = 'approved=caller'; + } elseif ($signalLinks && $mode === 'admit') { + $headers['baggage'] = 'approved=origin'; + } if ($mode === 'admit' && getenv('DW_TEST_TRACING_HTTP_URL')) { $headers['tracestate'] = 'dwtest=one'; $headers['baggage'] = 'approved=tenant-a,private=must-not-propagate'; @@ -473,6 +505,10 @@ public function extract( foreach ($completion['created_task_ids'] as $taskId) { TaskDispatcher::dispatch(WorkflowTask::query()->findOrFail($taskId)); } + } elseif ($signalLinks) { + WorkflowStub::make(TracingSignalLinksWorkflow::class, 'cold-tracing', '0')->start( + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); } elseif ($cancellation) { WorkflowStub::make(TracingCancellationWorkflow::class, 'cold-tracing', '0')->start( new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), @@ -501,6 +537,15 @@ public function extract( new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), ); } + } elseif ($mode === 'signal' && $signalLinks) { + WorkflowStub::make(TracingSignalLinksWorkflow::class, 'cold-tracing', '0')->signal( + 'ready', + getenv('DW_TEST_TRACING_SIGNAL_STEP') === 'first' ? [9007199254740993, null, '雪'] : [ + false, + null, + 'é', + ] + ); } elseif ($mode === 'cancel' && $cancellation) { $stub = WorkflowStub::make(TracingCancellationWorkflow::class, 'cold-tracing', '0'); $result = match ($cancellation) { @@ -609,6 +654,14 @@ public function extract( ->getSpanId(), 'fields' => $span->getAttributes() ->toArray(), + 'links' => array_map(static fn ($link): array => [ + 'trace_id' => $link->getSpanContext() + ->getTraceId(), + 'span_id' => $link->getSpanContext() + ->getSpanId(), + 'fields' => $link->getAttributes() + ->toArray(), + ], $span->getLinks()), ], $exporter->getSpans()), ]; if ($exportEndpoint) { @@ -626,6 +679,14 @@ public function extract( json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR), ))->all(); $output['history_payloads'] = $history->pluck('payload')->all(); + $output['signal_commands'] = WorkflowCommand::query()->where('command_type', 'signal')->orderBy( + 'command_sequence' + ) + ->get() + ->map(static fn ($command): array => [ + 'id' => $command->id, + 'carrier' => $command->getRawOriginal('observability_context'), + ])->all(); } if ($transition === 'child' || $transition === 'continue') { $output['runs'] = WorkflowRun::query()->orderBy('id')->get()->map(static fn ($run): array => [ @@ -694,6 +755,30 @@ public function extract( 'tasks' => WorkflowTask::query()->get(['id', 'status', 'attempt_count'])->toArray(), ]; } + if ($signalLinks) { + $history = $run->historyEvents() + ->orderBy('sequence') + ->get(); + $output['signals'] = [ + 'capture_transaction_levels' => $recordedPropagator->transactionLevels, + 'commands' => WorkflowCommand::query()->where('command_type', 'signal')->orderBy('command_sequence') + ->get() + ->map(static fn ($command): array => [ + 'id' => $command->id, + 'carrier' => $command->getRawOriginal('observability_context'), + 'immutable' => array_intersect_key($command->getRawOriginal(), array_flip([ + 'id', 'workflow_run_id', 'command_sequence', 'message_sequence', 'payload_codec', 'payload', 'context', + 'observability_context', + ])), + ])->all(), + 'history_hashes' => $history->map(static function ($event): string { + return hash('sha256', json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR)); + })->all(), + 'history_payloads' => $history->pluck('payload') + ->all(), + 'tasks' => WorkflowTask::query()->get(['id', 'status', 'attempt_count'])->toArray(), + ]; + } if ($recovery) { $history = WorkflowHistoryEvent::query()->where('workflow_run_id', $run->id)->orderBy('sequence')->get(); $output['recovery'] = [ diff --git a/tests/Unit/V2/ActivityExecutionTracingTest.php b/tests/Unit/V2/ActivityExecutionTracingTest.php index 55a79cf41..4246b4afc 100644 --- a/tests/Unit/V2/ActivityExecutionTracingTest.php +++ b/tests/Unit/V2/ActivityExecutionTracingTest.php @@ -29,6 +29,7 @@ use Workflow\Serializers\Serializer; use Workflow\V2\Activity; use Workflow\V2\Models\ActivityAttempt; +use Workflow\V2\Models\WorkflowCommand; use Workflow\V2\Models\WorkflowHistoryEvent; use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Models\WorkflowTask; @@ -302,6 +303,35 @@ public function onEnd(ReadableSpanInterface $span): void } } + public function testCommandCarrierMigrationPreservesLegacyRowsAndKeepsMetadataOutOfPublicContext(): void + { + $stub = WorkflowStub::make(TracedWorkflow::class, 'legacy-command', '0'); + $stub->start(false); + $migration = require __DIR__ . '/../../../src/migrations/2026_10_15_000200_add_observability_context_to_workflow_commands.php'; + $migration->down(); + $before = (array) DB::table('workflow_commands')->sole(); + $migration->up(); + $migration->up(); + $after = (array) DB::table('workflow_commands')->sole(); + self::assertArrayHasKey('observability_context', $after); + self::assertNull($after['observability_context']); + unset($after['observability_context']); + self::assertSame($before, $after); + $command = WorkflowCommand::query()->sole(); + $context = $command->commandContext(); + $publicContext = $command->publicContext(); + $command->observability_context = [ + 'schema' => 'durable-workflow.trace-context', + 'version' => 1, + 'carrier' => [ + 'traceparent' => '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', + ], + ]; + self::assertSame($context, $command->commandContext()); + self::assertSame($publicContext, $command->publicContext()); + self::assertArrayNotHasKey('observability_context', $command->toArray()); + } + #[DataProvider('coldModes')] public function testIndependentQueueProcessesUseThePersistedAdmissionParent(bool $retry): void { diff --git a/tests/Unit/V2/ExecutionTracingTest.php b/tests/Unit/V2/ExecutionTracingTest.php index 8bcae85ab..7f88e67a3 100644 --- a/tests/Unit/V2/ExecutionTracingTest.php +++ b/tests/Unit/V2/ExecutionTracingTest.php @@ -22,6 +22,8 @@ use PHPUnit\Framework\TestCase; use RuntimeException; use Symfony\Component\Process\Process; +use Workflow\V2\Models\WorkflowCommand; +use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\WorkflowExecution; use Workflow\V2\Tracing\LogContext; @@ -368,6 +370,178 @@ public function onEnd(ReadableSpanInterface $span): void } } + public function testSignalLinksStayOnTheProcessingSpanAndHaveABoundedPerSegmentLifetime(): void + { + $run = self::signalRun(); + $first = null; + $result = ExecutionTracing::workflow(self::fields(), self::carrier(), function () use ($run, &$first): int { + $nested = $this->provider->getTracer('application') + ->spanBuilder('application child') + ->startSpan(); + $scope = $nested->activate(); + try { + $original = ExecutionTracing::current(); + for ($index = 0; $index < 36; ++$index) { + $carrier = self::carrier(); + $carrier['carrier']['traceparent'] = '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-' + . str_pad(dechex($index + 1), 16, '0', STR_PAD_LEFT) . '-01'; + $command = self::signalCommand($carrier, 'signal-' . $index); + $first ??= $command; + ExecutionTracing::linkSignal($run, $command); + self::assertSame($original, ExecutionTracing::current()); + } + $duplicate = clone $first; + $duplicate->id = 'another-command-from-the-first-caller'; + ExecutionTracing::linkSignal($run, $duplicate); + } finally { + $scope->detach(); + $nested->end(); + } + + return 7; + }, enabled: true); + self::assertSame(7, $result); + $spans = $this->exporter->getSpans(); + self::assertCount(2, $spans); + self::assertSame([], $spans[0]->getLinks()); + self::assertCount(32, $spans[1]->getLinks()); + self::assertSame(4, $spans[1]->getAttributes()->get('durable.links.dropped')); + self::assertSame('signal-0', $spans[1]->getLinks()[0]->getAttributes()->get('workflow_command_id')); + self::assertSame(self::TRACE_ID, $spans[1]->getContext()->getTraceId()); + self::assertSame(self::PARENT_ID, $spans[1]->getParentContext()->getSpanId()); + ExecutionTracing::linkSignal($run, $first); + self::assertCount(32, $spans[1]->getLinks()); + ExecutionTracing::workflow(self::fields(), self::carrier(), static function () use ($run, $first): void { + ExecutionTracing::linkSignal($run, $first); + }, enabled: true); + $next = $this->exporter->getSpans()[2]; + self::assertCount(1, $next->getLinks()); + self::assertNull($next->getAttributes()->get('durable.links.dropped')); + } + + public function testInvalidForeignAndUnboundSignalMetadataCannotLinkOrReplaceTheBusinessResult(): void + { + $run = self::signalRun(); + $valid = self::carrier(); + $valid['carrier']['traceparent'] = '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01'; + $valid['carrier']['baggage'] = 'approved=caller'; + $command = self::signalCommand($valid); + $result = ExecutionTracing::workflow(self::fields(), self::carrier(), static function () use ( + $run, + $command, + $valid + ): int { + $original = ExecutionTracing::current(); + $invalid = [null, 'invalid', [ + 'schema' => 'unknown', + 'version' => 1, + 'carrier' => [], + ], + array_replace($valid, [ + 'version' => 2, + ]), + array_replace($valid, [ + 'carrier' => [ + 'traceparent' => str_repeat('a', 257), + ], + ]), + array_replace($valid, [ + 'carrier' => [ + 'traceparent' => $valid['carrier']['traceparent'], + 'baggage' => str_repeat('a', 2049), + ], + ]), + array_replace($valid, [ + 'carrier' => [ + 'traceparent' => '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-0000000000000000-01', + ], + ]), + ]; + foreach ($invalid as $carrier) { + ExecutionTracing::linkSignal($run, self::signalCommand($carrier)); + } + foreach ([ + 'workflow_run_id' => 'other-run', + 'workflow_instance_id' => 'other-instance', + 'status' => 'rejected', + 'command_type' => 'update', + ] as $key => $value) { + $foreign = clone $command; + $foreign->{$key} = $value; + ExecutionTracing::linkSignal($run, $foreign); + } + $foreignRun = clone $run; + $foreignRun->namespace = 'other'; + ExecutionTracing::linkSignal($foreignRun, $command); + $unreadable = new class() extends WorkflowCommand { + public function getAttribute($key): mixed + { + if ($key === 'observability_context') { + throw new RuntimeException('private-telemetry-cast'); + } + + return parent::getAttribute($key); + } + }; + $unreadable->setRawAttributes($command->getRawOriginal()); + ExecutionTracing::linkSignal($run, $unreadable); + (new Fiber(static function () use ($run, $command): void { + ExecutionTracing::linkSignal($run, $command); + }))->start(); + self::assertSame($original, ExecutionTracing::current()); + ExecutionTracing::linkSignal($run, $command); + self::assertSame($original, ExecutionTracing::current()); + + return 9; + }, enabled: true); + self::assertSame(9, $result); + $span = $this->exporter->getSpans()[0]; + self::assertCount(1, $span->getLinks()); + self::assertSame('signal-1', $span->getLinks()[0]->getAttributes()->get('workflow_command_id')); + self::assertSame(self::TRACE_ID, $span->getContext()->getTraceId()); + self::assertSame(self::PARENT_ID, $span->getParentContext()->getSpanId()); + self::assertStringNotContainsString( + 'private-telemetry-cast', + json_encode($span->getAttributes()->toArray(), JSON_THROW_ON_ERROR) + ); + self::assertSame(11, ExecutionTracing::workflow(self::fields(), self::carrier(), static function () use ( + $run, + $command + ): int { + ExecutionTracing::linkSignal($run, $command); + + return 11; + }, enabled: false)); + self::assertCount(1, $this->exporter->getSpans()); + } + + private static function signalRun(): WorkflowRun + { + $run = new WorkflowRun(); + $run->setRawAttributes([ + 'id' => 'run-1', + 'workflow_instance_id' => 'instance-1', + 'namespace' => '0', + ]); + + return $run; + } + + private static function signalCommand(mixed $carrier, string $id = 'signal-1'): WorkflowCommand + { + $command = new WorkflowCommand(); + $command->setRawAttributes([ + 'id' => $id, + 'workflow_instance_id' => 'instance-1', + 'workflow_run_id' => 'run-1', + 'command_type' => 'signal', + 'status' => 'accepted', + 'observability_context' => json_encode($carrier, JSON_THROW_ON_ERROR), + ]); + + return $command; + } + private static function fields(string $run = 'run-1', string $attempt = 'attempt-1'): array { return [ diff --git a/tests/Unit/V2/SignalLinkExecutionTracingTest.php b/tests/Unit/V2/SignalLinkExecutionTracingTest.php new file mode 100644 index 000000000..bafd27bd4 --- /dev/null +++ b/tests/Unit/V2/SignalLinkExecutionTracingTest.php @@ -0,0 +1,172 @@ + [true, true], + 'disabled control' => [false, false], + 'legacy admission' => [false, true], + ]; + } + + #[DataProvider('modes')] + public function testIndependentSignalCallersLinkOnlyTheirActualApplicationSegments( + bool $admissionEnabled, + bool $workerEnabled, + ): void { + $directory = sys_get_temp_dir() . '/dw-tracing-signal-links-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static function (string $mode, string $step = 'first') use ( + $directory, + $admissionEnabled, + $workerEnabled + ): array { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_SIGNAL_LINKS' => 'true', + 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admissionEnabled : $workerEnabled) ? 'true' : 'false', + 'DW_TEST_TRACING_SIGNAL_STEP' => $step, + 'DW_TEST_TRACING_SIGNAL_CALLER' => $step === 'first' ? self::FIRST_CALLER : self::SECOND_CALLER, + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + try { + $admitted = $worker('admit'); + self::assertSame($admissionEnabled ? [0] : [], $admitted['signals']['capture_transaction_levels']); + $early = $worker('signal'); + self::assertSame($workerEnabled ? [0] : [], $early['signals']['capture_transaction_levels']); + self::assertSame($workerEnabled, $early['signals']['commands'][0]['carrier'] !== null); + $waiting = $worker('work'); + self::assertSame( + 'waiting', + $waiting['status'], + json_encode(array_column($waiting['signals']['history_payloads'], 'message'), JSON_THROW_ON_ERROR) + ); + self::assertSame(0, $waiting['jobs']); + $late = $worker('signal', 'second'); + self::assertSame($workerEnabled ? [0] : [], $late['signals']['capture_transaction_levels']); + $workers = [$waiting]; + for ($remaining = 6; $remaining > 0 && $worker('inspect')['jobs'] > 0; --$remaining) { + $workers[] = $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([ + 'first' => [9007199254740993, null, '雪'], + 'second' => [false, null, 'é'], + 'activity' => [9007199254740993, null, '雪'], + ], $finished['output']); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { + self::assertSame($admitted[$key], $finished[$key]); + } + foreach ([$admitted, $early, $waiting, $late] as $prefix) { + self::assertSame($prefix['signals']['history_hashes'], array_slice( + $finished['signals']['history_hashes'], + 0, + count($prefix['signals']['history_hashes']) + )); + } + self::assertSame($early['signals']['commands'][0], $finished['signals']['commands'][0]); + self::assertSame($late['signals']['commands'], $finished['signals']['commands']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + self::assertCount(4, $finished['signals']['tasks']); + foreach ($finished['signals']['tasks'] as $task) { + self::assertSame('completed', $task['status']); + self::assertSame(1, $task['attempt_count']); + } + $spans = array_merge(...array_column($workers, 'spans')); + self::assertCount($workerEnabled ? 4 : 0, $spans); + foreach ($spans as $span) { + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + if ($admissionEnabled) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); + self::assertSame('00f067aa0ba902b7', $span['parent_id']); + } else { + self::assertSame('0000000000000000', $span['parent_id']); + self::assertNotSame('11111111111111111111111111111111', $span['trace_id']); + } + } + if ($workerEnabled) { + foreach ([self::FIRST_CALLER, self::SECOND_CALLER] as $index => $caller) { + $link = $spans[$index]['links']; + self::assertCount(1, $link); + self::assertSame(explode('-', $caller)[1], $link[0]['trace_id']); + self::assertSame(explode('-', $caller)[2], $link[0]['span_id']); + self::assertSame( + $finished['signals']['commands'][$index]['id'], + $link[0]['fields']['workflow_command_id'] + ); + self::assertSame('signal', $link[0]['fields']['command_type']); + self::assertSame($caller, json_decode( + $finished['signals']['commands'][$index]['carrier'], + true, + flags: JSON_THROW_ON_ERROR + )['carrier']['traceparent']); + } + self::assertSame([], $spans[2]['links']); + self::assertSame([], $spans[3]['links']); + } else { + foreach ($finished['signals']['commands'] as $command) { + self::assertNull($command['carrier']); + } + } + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); + $callbacks = array_filter($records, static fn (array $record): bool => $record['message'] === 'callback'); + self::assertCount(1, $callbacks); + foreach ($records as $record) { + if ($record['message'] === 'signal applied') { + self::assertSame($admissionEnabled ? 'origin' : null, $record['context']['approved_baggage']); + } + if ($record['message'] === 'worker outside' || ! $workerEnabled) { + self::assertArrayNotHasKey('trace_id', $record['context']); + self::assertArrayNotHasKey('workflow_run_id', $record['context']); + } + } + self::assertStringNotContainsString('observability_context', json_encode( + $finished['signals']['history_payloads'], + JSON_THROW_ON_ERROR + )); + self::assertSame(count($workers), count(array_unique(array_column($workers, 'pid')))); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } +} diff --git a/tests/Unit/V2/SignalStartExecutionTracingTest.php b/tests/Unit/V2/SignalStartExecutionTracingTest.php index 261dcbae6..30408d37f 100644 --- a/tests/Unit/V2/SignalStartExecutionTracingTest.php +++ b/tests/Unit/V2/SignalStartExecutionTracingTest.php @@ -57,6 +57,7 @@ public function testSignalWithStartKeepsItsOriginalTraceAcrossAnotherCallerAndCo $admitted = $worker('admit'); self::assertSame('started_new', $admitted['signal_start_outcome']); self::assertSame($admissionEnabled, $admitted['carrier'] !== null); + self::assertSame($admissionEnabled, $admitted['signal_commands'][0]['carrier'] !== null); self::assertSame($admissionEnabled ? [0] : [], $admitted['capture_transaction_levels']); $existing = $worker('duplicate'); self::assertSame('returned_existing_active', $existing['signal_start_outcome']); @@ -65,6 +66,8 @@ public function testSignalWithStartKeepsItsOriginalTraceAcrossAnotherCallerAndCo self::assertSame(0, $level); } self::assertSame(2, $existing['signal_count']); + self::assertSame($admitted['signal_commands'][0], $existing['signal_commands'][0]); + self::assertSame($workerEnabled, $existing['signal_commands'][1]['carrier'] !== null); self::assertSame( $admitted['history_hashes'], array_slice($existing['history_hashes'], 0, count($admitted['history_hashes'])) @@ -88,6 +91,7 @@ public function testSignalWithStartKeepsItsOriginalTraceAcrossAnotherCallerAndCo self::assertSame(0, $finished['repairs']); self::assertSame(0, $finished['jobs']); self::assertSame(0, $finished['failed_jobs']); + self::assertSame($existing['signal_commands'], $finished['signal_commands']); foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { self::assertSame($admitted[$key], $existing[$key]); self::assertSame($admitted[$key], $finished[$key]); From 1da19776c427b7ba5753de1cd8ee72f89a5b8c7c Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 17:23:39 +0000 Subject: [PATCH 21/77] feat: link cooperative cancellation callers at delivery --- docs/embedded-tracing.md | 12 ++- src/V2/Support/ExecutionTracing.php | 27 +++++- src/V2/Support/WorkflowExecutor.php | 3 + src/V2/WorkflowStub.php | 6 +- tests/Fixtures/V2/tracing-process.php | 18 +++- .../V2/CancellationExecutionTracingTest.php | 83 ++++++++++++++++--- tests/Unit/V2/ExecutionTracingTest.php | 36 ++++++++ 7 files changed, 163 insertions(+), 22 deletions(-) diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index 5c8532bc9..87f307005 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -111,7 +111,13 @@ Independent-process cancellation controls cover a waiting workflow, a cooperativ request followed by its cleanup activity, and immediate cancellation or termination. Existing carrier metadata and deadlines remain unchanged. Cleanup uses the original run's parent, and task scopes restore the worker's context. -These controls do not yet add causal links to the cancellation caller. +An accepted direct cooperative cancellation request also captures its caller +in the proposed command column. Its first actual delivery links that caller +to the workflow-processing span. Duplicate requests keep the first carrier. +Cleanup activities and later replay keep their original parent and add no new +delivery link. Caller baggage never replaces the run's baggage. Internal +descendant requests, immediate cancellation and termination have no caller-link +qualification yet. Disabling tracing stops new context capture and span emission. These qualified embedded transitions and child-retry paths still inherit valid stored admission @@ -154,8 +160,8 @@ share a link identified by the first applied command. Further additions are dropped, with the count in `durable.links.dropped`. Application SDK limits may be lower. The link state ends with its processing scope. -This does not add service wire propagation, native catalog support, update or -cancellation-caller links, schedule links or complete fan-in tracing. +This does not add service wire propagation, native catalog support, update +links, schedule links or complete fan-in tracing. The run carrier is separate observability metadata. It is not command, history, payload or lease authority. The proposed nullable run column has not been diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php index 4ab74e04d..c99682c46 100644 --- a/src/V2/Support/ExecutionTracing.php +++ b/src/V2/Support/ExecutionTracing.php @@ -155,6 +155,27 @@ public static function recordFailure(Throwable $error): void } public static function linkSignal(WorkflowRun $run, WorkflowCommand $command): void + { + self::linkCommand($run, $command, CommandType::Signal); + } + + public static function linkCancellation(WorkflowRun $run): void + { + try { + // Execution already owns this loaded snapshot. Telemetry adds no database read. + if (! $run->relationLoaded('commands')) { + return; + } + $command = $run->commands->firstWhere('id', $run->cancellation_request_command_id); + if ($command instanceof WorkflowCommand) { + self::linkCommand($run, $command, CommandType::RequestCancellation); + } + } catch (Throwable) { + // Optional metadata cannot interrupt cancellation delivery. + } + } + + private static function linkCommand(WorkflowRun $run, WorkflowCommand $command, CommandType $type): void { try { $fields = self::current(); @@ -162,7 +183,7 @@ public static function linkSignal(WorkflowRun $run, WorkflowCommand $command): v || ($fields['namespace'] ?? null) !== $run->namespace || $command->workflow_run_id !== $run->id || $command->workflow_instance_id !== $run->workflow_instance_id - || $command->command_type !== CommandType::Signal || $command->status !== CommandStatus::Accepted) { + || $command->command_type !== $type || $command->status !== CommandStatus::Accepted) { return; } $state = Context::getCurrent()->get(self::$linkKey); @@ -176,10 +197,10 @@ public static function linkSignal(WorkflowRun $run, WorkflowCommand $command): v $context = TraceContextPropagator::getInstance()->extract($carrier['carrier'], context: Context::getRoot()); $state->add(Span::fromContext($context)->getContext(), [ 'workflow_command_id' => $command->id, - 'command_type' => 'signal', + 'command_type' => $type->value, ]); } catch (Throwable) { - // Optional causal links cannot replace a signal's durable decision. + // Optional causal links cannot replace the command's durable decision. } } diff --git a/src/V2/Support/WorkflowExecutor.php b/src/V2/Support/WorkflowExecutor.php index 4a989dfac..60b520d02 100644 --- a/src/V2/Support/WorkflowExecutor.php +++ b/src/V2/Support/WorkflowExecutor.php @@ -5746,6 +5746,9 @@ private function deliverCancellationAtCall( $operationSequence, $operationSequenceSpan, ); + if (config('workflows.tracing.enabled', false) === true) { + ExecutionTracing::linkCancellation($run); + } return CancellationDeliveryState::Delivered; } diff --git a/src/V2/WorkflowStub.php b/src/V2/WorkflowStub.php index f8d866ae7..53150ba08 100644 --- a/src/V2/WorkflowStub.php +++ b/src/V2/WorkflowStub.php @@ -1941,6 +1941,8 @@ private function recordCancellationRequest( throw new LogicException('Cancellation cleanup timeout must be between 1 and 3600 seconds.'); } + $incomingContext = $parentWorkflowRunId === null && $scopeTarget === null && $serviceCallId === null + ? TraceCarrier::captureConfigured() : null; /** @var WorkflowCommand|null $command */ $command = null; $task = null; @@ -1952,7 +1954,8 @@ private function recordCancellationRequest( $cleanupTimeoutSeconds, $parentWorkflowRunId, $scopeTarget, - $serviceCallId + $serviceCallId, + $incomingContext ): void { $command = null; $task = null; @@ -2224,6 +2227,7 @@ private function recordCancellationRequest( 'target_scope' => $this->commandTargetScope(), 'status' => CommandStatus::Accepted->value, 'outcome' => CommandOutcome::CancellationRequested->value, + 'observability_context' => $incomingContext, 'payload_codec' => $run->payload_codec ?? CodecRegistry::defaultCodec(), 'payload' => Serializer::serializeWithCodec( $run->payload_codec ?? CodecRegistry::defaultCodec(), diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index b1be77009..71358b288 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -154,7 +154,10 @@ public function handle(): void try { self::awaitSignal('ready'); } finally { - Log::info('cancellation cleanup'); + Log::info('cancellation cleanup', [ + 'approved_baggage' => ExecutionTracing::current() === [] + ? null : Baggage::fromContext(Context::getCurrent())->getValue('approved'), + ]); $result = self::activity(TracingProcessActivity::class, false); self::upsertMemo([ 'cleanup' => $result, @@ -364,7 +367,7 @@ protected function defineEnvironment($app): void BaggagePropagator::getInstance(), ]); $recordedPropagator = null; -if ($signalStart || $signalLinks) { +if ($signalStart || $signalLinks || $cancellation) { $recordedPropagator = new class($propagator) implements TextMapPropagatorInterface { public array $transactionLevels = []; @@ -411,7 +414,12 @@ public function extract( if ($signalLinks && $mode === 'signal') { $headers['traceparent'] = getenv('DW_TEST_TRACING_SIGNAL_CALLER'); $headers['baggage'] = 'approved=caller'; - } elseif ($signalLinks && $mode === 'admit') { + } elseif ($cancellation && $mode === 'cancel') { + $headers['traceparent'] = getenv('DW_TEST_TRACING_CANCEL_DUPLICATE') === 'true' + ? '00-cccccccccccccccccccccccccccccccc-dddddddddddddddd-01' + : '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01'; + $headers['baggage'] = 'approved=caller'; + } elseif (($signalLinks || $cancellation) && $mode === 'admit') { $headers['baggage'] = 'approved=origin'; } if ($mode === 'admit' && getenv('DW_TEST_TRACING_HTTP_URL')) { @@ -744,6 +752,10 @@ public function extract( 'deadline' => $run->getRawOriginal('cancellation_deadline_at'), 'memo' => WorkflowStub::make(TracingCancellationWorkflow::class, 'cold-tracing', '0')->memo(), 'commands' => WorkflowCommand::query()->orderBy('id')->get(['id', 'command_type'])->toArray(), + 'capture_transaction_levels' => $recordedPropagator->transactionLevels, + 'request_carrier' => $run->cancellation_request_command_id === null ? null + : WorkflowCommand::query()->findOrFail($run->cancellation_request_command_id) + ->getRawOriginal('observability_context'), 'history_types' => $history->map(static fn ($event): string => $event->event_type->value) ->all(), 'history_hashes' => $history->map(static function ($event): string { diff --git a/tests/Unit/V2/CancellationExecutionTracingTest.php b/tests/Unit/V2/CancellationExecutionTracingTest.php index 5007c7bf9..03f43cbef 100644 --- a/tests/Unit/V2/CancellationExecutionTracingTest.php +++ b/tests/Unit/V2/CancellationExecutionTracingTest.php @@ -15,17 +15,26 @@ final class CancellationExecutionTracingTest extends TestCase public static function modes(): iterable { foreach (['request', 'cancel', 'terminate'] as $command) { - yield $command . ', traced' => [$command, true]; - yield $command . ', disabled' => [$command, false]; + yield $command . ', traced' => [$command, true, true]; + yield $command . ', disabled' => [$command, false, false]; } + yield 'request, legacy admission' => ['request', true, false]; } #[DataProvider('modes')] - public function testCancellationKeepsOriginalContextAndUnwindsColdWorkerScopes(string $command, bool $enabled): void - { + public function testCancellationKeepsOriginalContextAndUnwindsColdWorkerScopes( + string $command, + bool $enabled, + bool $admissionEnabled, + ): void { $directory = sys_get_temp_dir() . '/dw-tracing-cancellation-' . bin2hex(random_bytes(8)); mkdir($directory, 0700); - $worker = static function (string $mode) use ($directory, $command, $enabled): array { + $worker = static function (string $mode, bool $duplicate = false) use ( + $directory, + $command, + $enabled, + $admissionEnabled, + ): array { $packageVendor = dirname(__DIR__, 3) . '/vendor'; $otherVendors = array_filter( array_keys(ClassLoader::getRegisteredLoaders()), @@ -39,7 +48,8 @@ public function testCancellationKeepsOriginalContextAndUnwindsColdWorkerScopes(s ), ], env: [ 'DW_TEST_TRACING_CANCELLATION' => $command, - 'DW_TEST_TRACING_ENABLED' => $enabled ? 'true' : 'false', + 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admissionEnabled : $enabled) ? 'true' : 'false', + 'DW_TEST_TRACING_CANCEL_DUPLICATE' => $duplicate ? 'true' : 'false', ]); $process->setTimeout(15); $process->mustRun(); @@ -52,7 +62,7 @@ public function testCancellationKeepsOriginalContextAndUnwindsColdWorkerScopes(s }; try { $admitted = $worker('admit'); - self::assertSame($enabled, $admitted['carrier'] !== null); + self::assertSame($admissionEnabled, $admitted['carrier'] !== null); $waiting = $worker('work'); self::assertSame('waiting', $waiting['status']); self::assertSame(0, $waiting['jobs']); @@ -60,9 +70,22 @@ public function testCancellationKeepsOriginalContextAndUnwindsColdWorkerScopes(s $requested = $worker('cancel'); self::assertTrue($requested['cancellation']['result']['accepted']); if ($command === 'request') { - $duplicate = $worker('cancel'); + self::assertSame($enabled ? [0] : [], $requested['cancellation']['capture_transaction_levels']); + self::assertSame($enabled, $requested['cancellation']['request_carrier'] !== null); + if ($enabled) { + self::assertSame('00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', json_decode( + $requested['cancellation']['request_carrier'], + true, + flags: JSON_THROW_ON_ERROR + )['carrier']['traceparent']); + } + $duplicate = $worker('cancel', true); self::assertSame($requested['cancellation']['result'], $duplicate['cancellation']['result']); self::assertSame($requested['cancellation']['deadline'], $duplicate['cancellation']['deadline']); + self::assertSame( + $requested['cancellation']['request_carrier'], + $duplicate['cancellation']['request_carrier'] + ); self::assertSame( $requested['cancellation']['history_hashes'], $duplicate['cancellation']['history_hashes'] @@ -76,7 +99,11 @@ public function testCancellationKeepsOriginalContextAndUnwindsColdWorkerScopes(s $workers[] = $worker('work'); } $finished = $worker('inspect'); - self::assertSame($command === 'terminate' ? 'terminated' : 'cancelled', $finished['status']); + self::assertSame($command === 'terminate' ? 'terminated' : 'cancelled', $finished['status'], json_encode([ + 'history_types' => $finished['cancellation']['history_types'], + 'tasks' => $finished['cancellation']['tasks'], + 'failed_jobs' => $finished['failed_jobs'], + ], JSON_THROW_ON_ERROR)); self::assertSame(0, $finished['jobs']); self::assertSame(0, $finished['failed_jobs']); self::assertSame(0, $finished['repairs']); @@ -100,6 +127,10 @@ public function testCancellationKeepsOriginalContextAndUnwindsColdWorkerScopes(s $commands[$requested['cancellation']['result']['command_id']]['command_type'] ); $cooperative = $command === 'request'; + self::assertSame( + $requested['cancellation']['request_carrier'], + $finished['cancellation']['request_carrier'] + ); self::assertSame($cooperative ? [ 'cleanup' => [9007199254740993, null, '雪'], ] : [], $finished['cancellation']['memo']); @@ -124,11 +155,29 @@ public function testCancellationKeepsOriginalContextAndUnwindsColdWorkerScopes(s self::assertCount($enabled ? count($workers) : 0, $spans); $taskIds = array_column($finished['cancellation']['tasks'], 'id'); foreach ($spans as $span) { - self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); - self::assertSame('00f067aa0ba902b7', $span['parent_id']); + if ($admissionEnabled) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); + self::assertSame('00f067aa0ba902b7', $span['parent_id']); + } else { + self::assertSame('0000000000000000', $span['parent_id']); + self::assertNotSame('11111111111111111111111111111111', $span['trace_id']); + } self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); self::assertContains($span['fields']['task_id'], $taskIds); } + $linked = array_values(array_filter($spans, static fn (array $span): bool => $span['links'] !== [])); + self::assertCount($enabled && $cooperative ? 1 : 0, $linked); + if ($linked !== []) { + self::assertSame('durable.workflow.task', $linked[0]['name']); + self::assertCount(1, $linked[0]['links']); + self::assertSame('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $linked[0]['links'][0]['trace_id']); + self::assertSame('bbbbbbbbbbbbbbbb', $linked[0]['links'][0]['span_id']); + self::assertSame('request_cancellation', $linked[0]['links'][0]['fields']['command_type']); + self::assertSame( + $requested['cancellation']['result']['command_id'], + $linked[0]['links'][0]['fields']['workflow_command_id'] + ); + } self::assertCount($enabled && $cooperative ? 1 : 0, array_filter( $spans, static fn (array $span): bool => $span['name'] === 'durable.activity.attempt' @@ -147,9 +196,19 @@ public function testCancellationKeepsOriginalContextAndUnwindsColdWorkerScopes(s self::assertArrayNotHasKey('trace_id', $record['context']); } elseif (in_array($record['message'], ['cancellation cleanup', 'callback'], true)) { self::assertSame($admitted['run_id'], $record['context']['workflow_run_id']); - self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $record['context']['trace_id']); + if ($admissionEnabled) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $record['context']['trace_id']); + } else { + self::assertNotSame('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $record['context']['trace_id']); + } self::assertContains($record['context']['task_id'], $taskIds); } + if ($record['message'] === 'cancellation cleanup') { + self::assertSame( + $admissionEnabled && $enabled ? 'origin' : null, + $record['context']['approved_baggage'] + ); + } } self::assertStringNotContainsString('observability_context', json_encode( $finished['cancellation']['history_payloads'], diff --git a/tests/Unit/V2/ExecutionTracingTest.php b/tests/Unit/V2/ExecutionTracingTest.php index 7f88e67a3..eea43e993 100644 --- a/tests/Unit/V2/ExecutionTracingTest.php +++ b/tests/Unit/V2/ExecutionTracingTest.php @@ -515,6 +515,42 @@ public function getAttribute($key): mixed self::assertCount(1, $this->exporter->getSpans()); } + public function testCancellationLinksUseOnlyTheOriginalLoadedRequestWithoutDatabaseOrContextFallback(): void + { + $run = self::signalRun(); + $run->cancellation_request_command_id = 'request-1'; + $carrier = self::carrier(); + $carrier['carrier']['traceparent'] = '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01'; + $command = self::signalCommand($carrier, 'request-1'); + $command->command_type = 'request_cancellation'; + $original = $command->getAttributes(); + $result = ExecutionTracing::workflow(self::fields(), self::carrier(), static function () use ( + $run, + $command + ): int { + // An unloaded relation must not query a database or invent a caller. + ExecutionTracing::linkCancellation($run); + $run->setRelation('commands', new \Illuminate\Database\Eloquent\Collection([$command])); + $run->cancellation_request_command_id = 'other-request'; + ExecutionTracing::linkCancellation($run); + $run->cancellation_request_command_id = $command->id; + $command->command_type = 'signal'; + ExecutionTracing::linkCancellation($run); + $command->command_type = 'request_cancellation'; + ExecutionTracing::linkCancellation($run); + ExecutionTracing::linkCancellation($run); + + return 9; + }, enabled: true); + self::assertSame(9, $result); + $span = $this->exporter->getSpans()[0]; + self::assertCount(1, $span->getLinks()); + self::assertSame('request_cancellation', $span->getLinks()[0]->getAttributes()->get('command_type')); + self::assertSame('request-1', $span->getLinks()[0]->getAttributes()->get('workflow_command_id')); + self::assertSame('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $span->getLinks()[0]->getSpanContext()->getTraceId()); + self::assertSame($original, $command->getAttributes()); + } + private static function signalRun(): WorkflowRun { $run = new WorkflowRun(); From c46b88d087478ea097b738ff8a02f802209d4d64 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 17:33:28 +0000 Subject: [PATCH 22/77] feat: link accepted update callers to processing attempts --- .github/workflows/php.yml | 1 + docs/embedded-tracing.md | 24 +- src/V2/Support/ExecutionTracing.php | 5 + src/V2/Support/WorkflowExecutor.php | 3 + src/V2/WorkflowStub.php | 11 +- tests/Fixtures/V2/tracing-process.php | 109 ++++++++- .../V2/UpdateLinkExecutionTracingTest.php | 208 ++++++++++++++++++ 7 files changed, 356 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/V2/UpdateLinkExecutionTracingTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 641667325..b42469993 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -385,6 +385,7 @@ jobs: tests/Unit/V2/CancellationExecutionTracingTest.php tests/Unit/V2/SignalStartExecutionTracingTest.php tests/Unit/V2/SignalLinkExecutionTracingTest.php + tests/Unit/V2/UpdateLinkExecutionTracingTest.php --fail-on-warning --log-junit build/test-results/pr-activity-tracing.xml - name: Run database-independent unit contracts diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index 87f307005..9ec65c294 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -160,8 +160,28 @@ share a link identified by the first applied command. Further additions are dropped, with the count in `durable.links.dropped`. Application SDK limits may be lower. The link state ends with its processing scope. -This does not add service wire propagation, native catalog support, update -links, schedule links or complete fan-in tracing. +This does not add service wire propagation, native catalog support, schedule +links or complete fan-in tracing. + +## Update caller links + +New accepted embedded updates capture their independent caller in the same +proposed command column before the admission transaction. Idempotent duplicate +requests keep the first carrier. Rejected commands do not retain it. + +The processing span links to the captured caller immediately before invoking +the accepted handler. This records the processing attempt even when the handler +fails. It does not prove that a transaction committed or that the update +succeeded. The run's parent and baggage remain active. Later replay of a +recorded update adds no new update-application link, even though it can execute +the handler to reconstruct workflow state. + +Cold-process controls cover successful and failed updates, duplicate admission, +a rejected undeclared update, later signal/activity completion and legacy or +disabled tracing. They preserve canonical update results/failures, command +identity, typed values, original history and deadlines. The existing per-segment +link bounds also apply to updates. Service/native update propagation and wider +concurrent update cases remain unqualified. The run carrier is separate observability metadata. It is not command, history, payload or lease authority. The proposed nullable run column has not been diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php index c99682c46..2571b87cf 100644 --- a/src/V2/Support/ExecutionTracing.php +++ b/src/V2/Support/ExecutionTracing.php @@ -159,6 +159,11 @@ public static function linkSignal(WorkflowRun $run, WorkflowCommand $command): v self::linkCommand($run, $command, CommandType::Signal); } + public static function linkUpdate(WorkflowRun $run, WorkflowCommand $command): void + { + self::linkCommand($run, $command, CommandType::Update); + } + public static function linkCancellation(WorkflowRun $run): void { try { diff --git a/src/V2/Support/WorkflowExecutor.php b/src/V2/Support/WorkflowExecutor.php index 60b520d02..70de47874 100644 --- a/src/V2/Support/WorkflowExecutor.php +++ b/src/V2/Support/WorkflowExecutor.php @@ -6467,6 +6467,9 @@ private function applyPendingUpdates( ]), new ReflectionMethod($workflow, $resolvedTarget['method']), ); + if ($command instanceof WorkflowCommand && config('workflows.tracing.enabled', false) === true) { + ExecutionTracing::linkUpdate($run, $command); + } $result = $workflow->{$resolvedTarget['method']}(...$parameters); $appliedEvent = WorkflowHistoryEvent::record($run, HistoryEventType::UpdateApplied, [ diff --git a/src/V2/WorkflowStub.php b/src/V2/WorkflowStub.php index 53150ba08..9d21b03a3 100644 --- a/src/V2/WorkflowStub.php +++ b/src/V2/WorkflowStub.php @@ -2307,6 +2307,7 @@ private function recordCancellationRequest( private function recordAcceptedUpdateWithArguments(string $method, array $arguments): array { $this->refresh(); + $incomingContext = TraceCarrier::captureConfigured(); /** @var WorkflowCommand|null $command */ $command = null; @@ -2315,7 +2316,14 @@ private function recordAcceptedUpdateWithArguments(string $method, array $argume /** @var WorkflowTask|null $resumeTask */ $resumeTask = null; - DB::transaction(function () use ($method, $arguments, &$command, &$update, &$resumeTask): void { + DB::transaction(function () use ( + $method, + $arguments, + &$command, + &$update, + &$resumeTask, + $incomingContext + ): void { /** @var WorkflowInstance $instance */ $instance = self::instanceQuery() ->lockForUpdate() @@ -2505,6 +2513,7 @@ private function recordAcceptedUpdateWithArguments(string $method, array $argume 'target_scope' => $this->commandTargetScope(), 'status' => CommandStatus::Accepted->value, 'accepted_at' => now(), + 'observability_context' => $incomingContext, ], $updateCommandAttributes))); /** @var WorkflowUpdate $update */ diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 71358b288..25d4283ed 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -31,8 +31,10 @@ use Orchestra\Testbench\Foundation\Application; use Workflow\Providers\WorkflowServiceProvider; use Workflow\Serializers\Serializer; +use Workflow\UpdateMethod; use Workflow\V2\Activity; use Workflow\V2\Attributes\Signal; +use Workflow\V2\CommandContext; use Workflow\V2\Models\ActivityAttempt; use Workflow\V2\Models\ActivityExecution; use Workflow\V2\Models\WorkflowChildCall; @@ -41,6 +43,7 @@ use Workflow\V2\Models\WorkflowLink; use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Models\WorkflowTask; +use Workflow\V2\Models\WorkflowUpdate; use Workflow\V2\StartOptions; use Workflow\V2\Support\ActivityOptions; use Workflow\V2\Support\DefaultWorkflowTaskBridge; @@ -62,6 +65,7 @@ $transition = getenv('DW_TEST_TRACING_TRANSITION'); $signalStart = getenv('DW_TEST_TRACING_SIGNAL_START') === 'true'; $signalLinks = getenv('DW_TEST_TRACING_SIGNAL_LINKS') === 'true'; +$updateLinks = getenv('DW_TEST_TRACING_UPDATE_LINKS') === 'true'; $childRetry = getenv('DW_TEST_TRACING_CHILD_RETRY') === 'true'; $cancellation = getenv('DW_TEST_TRACING_CANCELLATION'); foreach (array_slice($argv, 4) as $autoload) { @@ -166,6 +170,40 @@ public function handle(): void } } +#[Signal('ready')] +final class TracingUpdateLinksWorkflow extends Workflow +{ + private array $values = []; + + public function handle(): array + { + self::awaitSignal('ready'); + + return [ + 'updates' => $this->values, + 'activity' => self::activity(TracingProcessActivity::class, false), + ]; + } + + #[UpdateMethod] + public function accept(array $value): array + { + Log::info('update handler', [ + 'approved_baggage' => ExecutionTracing::current() === [] + ? null : Baggage::fromContext(Context::getCurrent())->getValue('approved'), + ]); + $this->values[] = $value; + + return $value; + } + + #[UpdateMethod] + public function refuse(): never + { + throw new \RuntimeException('deliberate update failure'); + } +} + #[Signal('ready')] final class TracingSignalLinksWorkflow extends Workflow { @@ -367,7 +405,7 @@ protected function defineEnvironment($app): void BaggagePropagator::getInstance(), ]); $recordedPropagator = null; -if ($signalStart || $signalLinks || $cancellation) { +if ($signalStart || $signalLinks || $cancellation || $updateLinks) { $recordedPropagator = new class($propagator) implements TextMapPropagatorInterface { public array $transactionLevels = []; @@ -414,12 +452,17 @@ public function extract( if ($signalLinks && $mode === 'signal') { $headers['traceparent'] = getenv('DW_TEST_TRACING_SIGNAL_CALLER'); $headers['baggage'] = 'approved=caller'; + } elseif ($updateLinks && $mode === 'update') { + $headers['traceparent'] = getenv('DW_TEST_TRACING_UPDATE_CALLER'); + $headers['baggage'] = 'approved=update-caller'; + } elseif ($updateLinks && $mode === 'signal') { + $headers['traceparent'] = '00-77777777777777777777777777777777-8888888888888888-01'; } elseif ($cancellation && $mode === 'cancel') { $headers['traceparent'] = getenv('DW_TEST_TRACING_CANCEL_DUPLICATE') === 'true' ? '00-cccccccccccccccccccccccccccccccc-dddddddddddddddd-01' : '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01'; $headers['baggage'] = 'approved=caller'; - } elseif (($signalLinks || $cancellation) && $mode === 'admit') { + } elseif (($signalLinks || $cancellation || $updateLinks) && $mode === 'admit') { $headers['baggage'] = 'approved=origin'; } if ($mode === 'admit' && getenv('DW_TEST_TRACING_HTTP_URL')) { @@ -447,6 +490,7 @@ public function extract( $repairReport = null; $signalStartResult = null; $cancellationResult = null; + $updateResult = null; try { if ($mode === 'admit') { if (Artisan::call('migrate', [ @@ -513,6 +557,10 @@ public function extract( foreach ($completion['created_task_ids'] as $taskId) { TaskDispatcher::dispatch(WorkflowTask::query()->findOrFail($taskId)); } + } elseif ($updateLinks) { + WorkflowStub::make(TracingUpdateLinksWorkflow::class, 'cold-tracing', '0')->start( + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); } elseif ($signalLinks) { WorkflowStub::make(TracingSignalLinksWorkflow::class, 'cold-tracing', '0')->start( new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), @@ -545,6 +593,27 @@ public function extract( new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), ); } + } elseif ($mode === 'update' && $updateLinks) { + $step = getenv('DW_TEST_TRACING_UPDATE_STEP'); + $stub = WorkflowStub::make(TracingUpdateLinksWorkflow::class, 'cold-tracing', '0') + ->withCommandContext(CommandContext::phpApi()->with([ + 'request' => [ + 'request_id' => 'tracing-update-' . $step, + ], + ])); + $result = match ($step) { + 'first' => $stub->submitUpdate('accept', [9007199254740993, null, '雪']), + 'failure' => $stub->submitUpdate('refuse'), + 'invalid' => $stub->submitUpdate('notDeclared'), + default => throw new \RuntimeException('Unknown update step.'), + }; + $updateResult = [ + 'accepted' => $result->accepted(), + 'command_id' => $result->commandId(), + 'update_id' => $result->updateId(), + ]; + } elseif ($mode === 'signal' && $updateLinks) { + WorkflowStub::make(TracingUpdateLinksWorkflow::class, 'cold-tracing', '0')->signal('ready'); } elseif ($mode === 'signal' && $signalLinks) { WorkflowStub::make(TracingSignalLinksWorkflow::class, 'cold-tracing', '0')->signal( 'ready', @@ -767,6 +836,42 @@ public function extract( 'tasks' => WorkflowTask::query()->get(['id', 'status', 'attempt_count'])->toArray(), ]; } + if ($updateLinks) { + $history = $run->historyEvents() + ->orderBy('sequence') + ->get(); + $output['updates'] = [ + 'submitted' => $updateResult, + 'signal_command_id' => WorkflowCommand::query()->where('command_type', 'signal')->value('id'), + 'capture_transaction_levels' => $recordedPropagator->transactionLevels, + 'commands' => WorkflowCommand::query()->where('command_type', 'update')->orderBy('command_sequence')->get() + ->map(static fn ($command): array => [ + 'id' => $command->id, + 'status' => $command->status->value, + 'outcome' => $command->outcome?->value, + 'carrier' => $command->getRawOriginal('observability_context'), + 'immutable' => array_intersect_key($command->getRawOriginal(), array_flip([ + 'id', 'workflow_run_id', 'command_sequence', 'payload_codec', 'payload', 'context', + 'request_id', 'observability_context', + ])), + ])->all(), + 'rows' => WorkflowUpdate::query()->orderBy('command_sequence')->get()->map(static fn ($update): array => [ + 'id' => $update->id, + 'command_id' => $update->workflow_command_id, + 'status' => $update->status->value, + 'result' => $update->updateResult(), + 'failure_message' => $update->failure_message, + ])->all(), + 'history_hashes' => $history->map(static function ($event): string { + return hash('sha256', json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR)); + })->all(), + 'history_types' => $history->map(static fn ($event): string => $event->event_type->value) + ->all(), + 'history_payloads' => $history->pluck('payload') + ->all(), + 'tasks' => WorkflowTask::query()->get(['id', 'status', 'attempt_count'])->toArray(), + ]; + } if ($signalLinks) { $history = $run->historyEvents() ->orderBy('sequence') diff --git a/tests/Unit/V2/UpdateLinkExecutionTracingTest.php b/tests/Unit/V2/UpdateLinkExecutionTracingTest.php new file mode 100644 index 000000000..91ad945f8 --- /dev/null +++ b/tests/Unit/V2/UpdateLinkExecutionTracingTest.php @@ -0,0 +1,208 @@ + [true, true], + 'disabled' => [false, false], + 'legacy admission' => [false, true], + ]; + } + + #[DataProvider('modes')] + public function testUpdateCallersLinkOnlyAcceptedProcessingAndKeepOriginalDecisions( + bool $admissionEnabled, + bool $workerEnabled, + ): void { + $directory = sys_get_temp_dir() . '/dw-tracing-update-links-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static function (string $mode, string $step = 'first', bool $duplicate = false) use ( + $directory, + $admissionEnabled, + $workerEnabled, + ): array { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_UPDATE_LINKS' => 'true', + 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admissionEnabled : $workerEnabled) ? 'true' : 'false', + 'DW_TEST_TRACING_UPDATE_STEP' => $step, + 'DW_TEST_TRACING_UPDATE_CALLER' => $duplicate ? self::DUPLICATE_CALLER + : ($step === 'first' ? self::FIRST_CALLER : self::FAILED_CALLER), + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + self::assertJson($process->getOutput(), substr($process->getOutput(), -1500)); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + try { + $admitted = $worker('admit'); + $workers = [$worker('work')]; + self::assertSame('waiting', $workers[0]['status']); + self::assertSame(0, $workers[0]['jobs']); + $accepted = $worker('update'); + self::assertTrue($accepted['updates']['submitted']['accepted']); + self::assertSame($workerEnabled ? [0] : [], $accepted['updates']['capture_transaction_levels']); + $duplicate = $worker('update', duplicate: true); + self::assertSame($accepted['updates']['submitted'], $duplicate['updates']['submitted']); + self::assertSame($accepted['updates']['commands'], $duplicate['updates']['commands']); + self::assertSame($accepted['updates']['history_hashes'], $duplicate['updates']['history_hashes']); + $workers[] = $worker('work'); + self::assertSame('completed', $workers[1]['updates']['rows'][0]['status']); + self::assertSame([9007199254740993, null, '雪'], $workers[1]['updates']['rows'][0]['result']); + $failed = $worker('update', 'failure'); + self::assertTrue($failed['updates']['submitted']['accepted']); + $workers[] = $worker('work'); + self::assertSame('failed', $workers[2]['updates']['rows'][1]['status']); + self::assertSame('deliberate update failure', $workers[2]['updates']['rows'][1]['failure_message']); + $workers[] = $worker('work'); + $refused = $worker('update', 'invalid'); + self::assertFalse($refused['updates']['submitted']['accepted']); + self::assertNull($refused['updates']['commands'][2]['carrier']); + $signalled = $worker('signal'); + for ($remaining = 6; $remaining > 0 && $worker('inspect')['jobs'] > 0; --$remaining) { + $workers[] = $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status'], json_encode([ + 'history' => $finished['updates']['history_types'], + 'tasks' => $finished['updates']['tasks'], + 'failed_jobs' => $finished['failed_jobs'], + ], JSON_THROW_ON_ERROR)); + self::assertSame([ + 'updates' => [[9007199254740993, null, '雪']], + 'activity' => [9007199254740993, null, '雪'], + ], $finished['output']); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { + self::assertSame($admitted[$field], $finished[$field]); + } + foreach ([$admitted, $accepted, $failed, $refused, $signalled] as $prefix) { + self::assertSame($prefix['updates']['history_hashes'], array_slice( + $finished['updates']['history_hashes'], + 0, + count($prefix['updates']['history_hashes']) + )); + } + self::assertSame( + $accepted['updates']['commands'][0]['immutable'], + $finished['updates']['commands'][0]['immutable'] + ); + self::assertSame( + $failed['updates']['commands'][1]['immutable'], + $finished['updates']['commands'][1]['immutable'] + ); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + self::assertCount(7, $finished['updates']['tasks']); + foreach ($finished['updates']['tasks'] as $task) { + self::assertSame('completed', $task['status']); + self::assertSame(1, $task['attempt_count']); + } + $spans = array_merge(...array_column($workers, 'spans')); + self::assertCount($workerEnabled ? 7 : 0, $spans); + foreach ($spans as $index => $span) { + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + if ($admissionEnabled) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); + self::assertSame('00f067aa0ba902b7', $span['parent_id']); + } else { + self::assertSame('0000000000000000', $span['parent_id']); + self::assertNotSame('11111111111111111111111111111111', $span['trace_id']); + } + if ($index === 1 || $index === 2) { + self::assertCount(1, $span['links']); + $caller = $index === 1 ? self::FIRST_CALLER : self::FAILED_CALLER; + self::assertSame(explode('-', $caller)[1], $span['links'][0]['trace_id']); + self::assertSame(explode('-', $caller)[2], $span['links'][0]['span_id']); + self::assertSame('update', $span['links'][0]['fields']['command_type']); + self::assertSame( + $finished['updates']['commands'][$index - 1]['id'], + $span['links'][0]['fields']['workflow_command_id'] + ); + } elseif ($index === 4) { + self::assertCount(1, $span['links']); + self::assertSame('signal', $span['links'][0]['fields']['command_type']); + self::assertSame('77777777777777777777777777777777', $span['links'][0]['trace_id']); + self::assertSame('8888888888888888', $span['links'][0]['span_id']); + self::assertSame( + $finished['updates']['signal_command_id'], + $span['links'][0]['fields']['workflow_command_id'] + ); + } else { + self::assertSame([], $span['links']); + } + } + foreach (array_slice($finished['updates']['commands'], 0, 2) as $index => $command) { + if ($workerEnabled) { + self::assertSame($index === 0 ? self::FIRST_CALLER : self::FAILED_CALLER, json_decode( + $command['carrier'], + true, + flags: JSON_THROW_ON_ERROR + )['carrier']['traceparent']); + } else { + self::assertNull($command['carrier']); + } + } + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); + self::assertCount( + 1, + array_filter($records, static fn (array $record): bool => $record['message'] === 'callback') + ); + foreach ($records as $record) { + if ($record['message'] === 'update handler') { + self::assertSame( + $admissionEnabled && $workerEnabled ? 'origin' : null, + $record['context']['approved_baggage'] + ); + } + if ($record['message'] === 'worker outside' || ! $workerEnabled) { + self::assertArrayNotHasKey('trace_id', $record['context']); + self::assertArrayNotHasKey('workflow_run_id', $record['context']); + } + } + self::assertStringNotContainsString( + 'observability_context', + json_encode($finished['updates']['history_payloads'], JSON_THROW_ON_ERROR) + ); + self::assertSame(count($workers), count(array_unique(array_column($workers, 'pid')))); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } +} From a5d5f47ebd9d9f7bd3aded62d32d527a6b67f0c1 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 17:48:09 +0000 Subject: [PATCH 23/77] feat: separate durable wait observations from processing spans --- .github/pr-test-selection.json | 1 + .github/workflows/php.yml | 1 + docs/embedded-tracing.md | 30 +++ src/V2/Jobs/RunActivityTask.php | 2 + src/V2/Jobs/RunTimerTask.php | 40 +++- src/V2/Jobs/RunWorkflowTask.php | 2 + src/V2/Support/ExecutionTracing.php | 36 +++- src/V2/Support/TaskTraceTimings.php | 101 ++++++++++ tests/Fixtures/V2/tracing-process.php | 51 +++++ .../Unit/V2/ActivityExecutionTracingTest.php | 7 +- tests/Unit/V2/ExecutionTracingTest.php | 34 ++++ tests/Unit/V2/TaskTraceTimingsTest.php | 143 ++++++++++++++ tests/Unit/V2/TimingExecutionTracingTest.php | 177 ++++++++++++++++++ 13 files changed, 619 insertions(+), 6 deletions(-) create mode 100644 src/V2/Support/TaskTraceTimings.php create mode 100644 tests/Unit/V2/TaskTraceTimingsTest.php create mode 100644 tests/Unit/V2/TimingExecutionTracingTest.php diff --git a/.github/pr-test-selection.json b/.github/pr-test-selection.json index 419c8e623..c2b0ac79c 100644 --- a/.github/pr-test-selection.json +++ b/.github/pr-test-selection.json @@ -14,6 +14,7 @@ "tests/Unit/V2/ReadmeBranchDestinationsTest.php", "tests/Unit/V2/ScopedCancellationContextTest.php", "tests/Unit/V2/StartOptionsTest.php", + "tests/Unit/V2/TaskTraceTimingsTest.php", "tests/Unit/V2/TraceCarrierTest.php", "tests/Unit/V2/VersionMarkerWireFormatTest.php", "tests/Unit/V2/WorkflowFiberRunnerTest.php", diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index b42469993..f4f17d85b 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -386,6 +386,7 @@ jobs: tests/Unit/V2/SignalStartExecutionTracingTest.php tests/Unit/V2/SignalLinkExecutionTracingTest.php tests/Unit/V2/UpdateLinkExecutionTracingTest.php + tests/Unit/V2/TimingExecutionTracingTest.php --fail-on-warning --log-junit build/test-results/pr-activity-tracing.xml - name: Run database-independent unit contracts diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index 9ec65c294..c8a62f516 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -187,3 +187,33 @@ The run carrier is separate observability metadata. It is not command, history, payload or lease authority. The proposed nullable run column has not been adopted by the native Server. Other admission paths, broader trigger links, runtime coverage and performance qualification remain unfinished. + +## Processing duration and durable waits + +Workflow, activity-attempt and `durable.timer.task` spans cover actual processing. +A timer span starts after its task is claimed and ends after that processing +attempt. No span remains open during the durable wait. Timer scope setup and +teardown happen outside the existing execution transaction. + +These optional span attributes describe separate observations, in seconds: + +| Attribute | Meaning | +| --- | --- | +| `durable.task.queue_wait_seconds` | Claimed task's `leased_at` minus its `available_at`. The scheduled delay before eligibility is excluded. | +| `durable.task.retry_backoff_seconds` | Exact backoff recorded on a queued activity retry. It is requested delay, not measured execution or total elapsed retry time. | +| `durable.timer.requested_wait_seconds` | Delay recorded on the claimed timer. | +| `durable.timer.lateness_seconds` | Timer task claim minus the original timer deadline. | + +The values come from the already-claimed snapshot and add no telemetry query. +Missing, unreadable, malformed or negative timing observations are omitted. +Second and fractional database precision are supported. Stored clock deltas can +reflect clock adjustments and queue/scheduler delays; they are not monotonic +latency or a new authority rule. Existing eligibility, deadlines, retry policy +and history remain authoritative. Timing attributes are not added to log +correlation fields or automatically used as metric labels. + +The current controls cover cold embedded timer workers and a real queued +activity failure/backoff/retry, including disabled emission and a legacy +admission. Broader backend/runtime, local-activity, timeout and exporter-failure +qualification for timer processing remains unfinished. No performance gain or +overhead acceptance is claimed by these functional checks. diff --git a/src/V2/Jobs/RunActivityTask.php b/src/V2/Jobs/RunActivityTask.php index c2b622153..c87bb4e85 100644 --- a/src/V2/Jobs/RunActivityTask.php +++ b/src/V2/Jobs/RunActivityTask.php @@ -20,6 +20,7 @@ use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\RuntimeObjectFactory; use Workflow\V2\Support\TaskDispatcher; +use Workflow\V2\Support\TaskTraceTimings; use Workflow\V2\Support\TraceCarrier; use Workflow\V2\Support\TypeRegistry; use Workflow\V2\Support\WorkerCompatibilityFleet; @@ -84,6 +85,7 @@ public function handle(): void 'activity_type' => $claim->execution->activity_type, 'activity_attempt_id' => $claim->attemptId(), 'activity_attempt_number' => $claim->attemptNumber(), + ...TaskTraceTimings::forTask($claim->task), ], $storedContext, fn () => $this->executeClaim($claim), diff --git a/src/V2/Jobs/RunTimerTask.php b/src/V2/Jobs/RunTimerTask.php index 0e7be66ce..9ab9953ce 100644 --- a/src/V2/Jobs/RunTimerTask.php +++ b/src/V2/Jobs/RunTimerTask.php @@ -19,12 +19,15 @@ use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Models\WorkflowTask; use Workflow\V2\Models\WorkflowTimer; +use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\ParallelChildGroup; use Workflow\V2\Support\SqliteTransaction; use Workflow\V2\Support\TaskBackendCapabilities; use Workflow\V2\Support\TaskCompatibility; use Workflow\V2\Support\TaskDispatcher; +use Workflow\V2\Support\TaskTraceTimings; use Workflow\V2\Support\TimerRecovery; +use Workflow\V2\Support\TraceCarrier; use Workflow\V2\Support\WorkerCompatibilityFleet; use Workflow\V2\Support\WorkflowTaskLease; use Workflow\V2\Support\WorkflowTaskPayload; @@ -79,7 +82,7 @@ public function handle(): void is_string($this->queue ?? null) ? $this->queue : null, ); - [$timerId, $releaseIn] = $this->claimTask(); + [$claimedTask, $releaseIn] = $this->claimTask(); if ($releaseIn !== null) { // Relay capped/early wakeups without spending the queue job's failure budget. @@ -92,10 +95,39 @@ public function handle(): void return; } - if ($timerId === null) { + if ($claimedTask === null) { return; } + /** @var WorkflowTimer $timer */ + $timer = $claimedTask->getRelation('timer'); + if (config('workflows.tracing.enabled', false) !== true) { + $this->executeClaim($timer->id); + + return; + } + /** @var WorkflowRun $run */ + $run = $claimedTask->getRelation('run'); + $allowlist = config('workflows.tracing.baggage_allowlist', []); + ExecutionTracing::timer( + [ + 'workflow_instance_id' => $run->workflow_instance_id, + 'workflow_run_id' => $run->id, + 'workflow_type' => $run->workflow_type, + 'namespace' => $run->namespace, + 'task_id' => $claimedTask->id, + 'timer_id' => $timer->id, + ...TaskTraceTimings::forTask($claimedTask, $timer), + ], + TraceCarrier::fromRun($run), + fn () => $this->executeClaim($timer->id), + is_array($allowlist) ? $allowlist : [], + true + ); + } + + private function executeClaim(string $timerId): void + { $resumeTask = SqliteTransaction::forTask($this->taskId, function () use ($timerId): ?WorkflowTask { /** @var WorkflowTask $task */ $task = WorkflowTask::query() @@ -264,7 +296,7 @@ public function handle(): void } /** - * @return array{0: ?string, 1: ?int} + * @return array{0: WorkflowTask|null, 1: int|null} */ private function claimTask(): array { @@ -334,7 +366,7 @@ private function claimTask(): array $this->projectRun($run, self::PROJECTION_RUN_RELATIONS); - return [$timerId, null]; + return [$task->setRelation('run', $run)->setRelation('timer', $timer), null]; }); } diff --git a/src/V2/Jobs/RunWorkflowTask.php b/src/V2/Jobs/RunWorkflowTask.php index 5ebad298d..4f7f81760 100644 --- a/src/V2/Jobs/RunWorkflowTask.php +++ b/src/V2/Jobs/RunWorkflowTask.php @@ -23,6 +23,7 @@ use Workflow\V2\Support\TaskBackendCapabilities; use Workflow\V2\Support\TaskCompatibility; use Workflow\V2\Support\TaskDispatcher; +use Workflow\V2\Support\TaskTraceTimings; use Workflow\V2\Support\TraceCarrier; use Workflow\V2\Support\WorkerCompatibilityFleet; use Workflow\V2\Support\WorkflowExecutor; @@ -73,6 +74,7 @@ public function handle(WorkflowExecutor $executor): void 'workflow_type' => $run->workflow_type, 'namespace' => $run->namespace, 'task_id' => $task->id, + ...TaskTraceTimings::forTask($task), ], $storedContext, fn () => $this->executeClaim($executor), diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php index 2571b87cf..a3bd7f4f5 100644 --- a/src/V2/Support/ExecutionTracing.php +++ b/src/V2/Support/ExecutionTracing.php @@ -31,6 +31,12 @@ final class ExecutionTracing 'workflow_instance_id', 'workflow_run_id', 'workflow_type', 'namespace', 'task_id', 'activity_id', 'activity_type', 'activity_attempt_id', 'activity_attempt_number', 'request_id', + 'timer_id', + ]; + + private const TIMINGS = [ + 'durable.task.queue_wait_seconds', 'durable.task.retry_backoff_seconds', + 'durable.timer.requested_wait_seconds', 'durable.timer.lateness_seconds', ]; private static ?ContextKeyInterface $correlationKey = null; @@ -111,6 +117,27 @@ public static function workflow( ); } + /** + * @param array $attributes + * @param list $baggageAllowlist + */ + public static function timer( + array $attributes, + mixed $persistedContext, + callable $operation, + array $baggageAllowlist = [], + bool $enabled = false, + ): mixed { + return self::segment( + 'durable.timer.task', + $attributes, + $persistedContext, + $operation, + $baggageAllowlist, + $enabled + ); + } + /** * @return array */ @@ -244,6 +271,13 @@ private static function segment( ->withResponsePropagator(Globals::responsePropagator()) ->storeInContext($parent); $fields = self::fields($attributes); + $spanAttributes = $fields; + foreach (self::TIMINGS as $key) { + $value = $attributes[$key] ?? null; + if ((is_int($value) || is_float($value)) && is_finite((float) $value) && $value >= 0) { + $spanAttributes[$key] = $value; + } + } $version = InstalledVersions::isInstalled('durable-workflow/workflow') ? InstalledVersions::getPrettyVersion('durable-workflow/workflow') : null; @@ -251,7 +285,7 @@ private static function segment( ->spanBuilder($name) ->setSpanKind(SpanKind::KIND_CONSUMER) ->setParent($parent) - ->setAttributes($fields) + ->setAttributes($spanAttributes) ->startSpan(); if ($span->getContext()->isValid()) { $fields['trace_id'] = $span->getContext()->getTraceId(); diff --git a/src/V2/Support/TaskTraceTimings.php b/src/V2/Support/TaskTraceTimings.php new file mode 100644 index 000000000..67037e5c1 --- /dev/null +++ b/src/V2/Support/TaskTraceTimings.php @@ -0,0 +1,101 @@ + + */ + public static function forTask(WorkflowTask $task, ?WorkflowTimer $timer = null): array + { + $attributes = []; + try { + $seconds = self::elapsed(self::date($task, 'available_at'), self::date($task, 'leased_at')); + if ($seconds !== null) { + $attributes['durable.task.queue_wait_seconds'] = $seconds; + } + } catch (Throwable) { + // Optional date casts must not prevent execution. + } + try { + $payload = $task->payload; + $backoff = is_array($payload) ? ($payload['retry_backoff_seconds'] ?? null) : null; + if ($task->task_type === TaskType::Activity && is_int($backoff) && $backoff >= 0 + && is_int($payload['retry_after_attempt'] ?? null) && $payload['retry_after_attempt'] > 0 + && is_string($payload['retry_after_attempt_id'] ?? null) && $payload['retry_after_attempt_id'] !== '') { + $attributes['durable.task.retry_backoff_seconds'] = $backoff; + } + } catch (Throwable) { + // Unreadable retry metadata supplies no inferred backoff. + } + try { + if ($timer === null || $task->task_type !== TaskType::Timer + || $timer->workflow_run_id !== $task->workflow_run_id + || $timer->id !== ($task->payload['timer_id'] ?? null)) { + return $attributes; + } + $delay = $timer->delay_seconds; + if (is_int($delay) && $delay >= 0) { + $attributes['durable.timer.requested_wait_seconds'] = $delay; + } + $seconds = self::elapsed(self::date($timer, 'fire_at'), self::date($task, 'leased_at')); + if ($seconds !== null) { + $attributes['durable.timer.lateness_seconds'] = $seconds; + } + } catch (Throwable) { + // Timer authority and outcomes do not depend on telemetry reads. + } + + return $attributes; + } + + private static function date(WorkflowTask|WorkflowTimer $model, string $field): ?DateTimeInterface + { + $value = $model->getAttribute($field); + if (! $value instanceof DateTimeInterface) { + return null; + } + $raw = $model->getRawOriginal($field); + if ($raw instanceof DateTimeInterface) { + return $value; + } + if (! is_string($raw)) { + return null; + } + $parsed = DateTimeImmutable::createFromFormat( + str_contains($raw, '.') ? '!Y-m-d H:i:s.u' : '!Y-m-d H:i:s', + $raw, + new DateTimeZone('UTC'), + ); + $errors = DateTimeImmutable::getLastErrors(); + if ($parsed === false || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) + || $parsed->format('Y-m-d H:i:s.u') !== $value->format('Y-m-d H:i:s.u')) { + return null; + } + + return $value; + } + + private static function elapsed(?DateTimeInterface $start, ?DateTimeInterface $end): ?float + { + if (! $start instanceof DateTimeInterface || ! $end instanceof DateTimeInterface) { + return null; + } + $seconds = ($end->getTimestamp() - $start->getTimestamp()) + + ((int) $end->format('u') - (int) $start->format('u')) / 1_000_000; + + return is_finite($seconds) && $seconds >= 0 ? $seconds : null; + } +} diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 25d4283ed..edac3c837 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -43,6 +43,7 @@ use Workflow\V2\Models\WorkflowLink; use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Models\WorkflowTask; +use Workflow\V2\Models\WorkflowTimer; use Workflow\V2\Models\WorkflowUpdate; use Workflow\V2\StartOptions; use Workflow\V2\Support\ActivityOptions; @@ -66,6 +67,7 @@ $signalStart = getenv('DW_TEST_TRACING_SIGNAL_START') === 'true'; $signalLinks = getenv('DW_TEST_TRACING_SIGNAL_LINKS') === 'true'; $updateLinks = getenv('DW_TEST_TRACING_UPDATE_LINKS') === 'true'; +$timings = getenv('DW_TEST_TRACING_TIMINGS') === 'true'; $childRetry = getenv('DW_TEST_TRACING_CHILD_RETRY') === 'true'; $cancellation = getenv('DW_TEST_TRACING_CANCELLATION'); foreach (array_slice($argv, 4) as $autoload) { @@ -110,6 +112,21 @@ public function handle(): array } } +final class TracingTimingWorkflow extends Workflow +{ + public function handle(): array + { + self::timer(1); + + return self::activity(TracingProcessActivity::class, new ActivityOptions( + maxAttempts: 2, + backoff: [1], + startToCloseTimeout: 10, + scheduleToCloseTimeout: 30, + ), true); + } +} + final class TracingChildParentWorkflow extends Workflow { public function handle(): array @@ -557,6 +574,10 @@ public function extract( foreach ($completion['created_task_ids'] as $taskId) { TaskDispatcher::dispatch(WorkflowTask::query()->findOrFail($taskId)); } + } elseif ($timings) { + WorkflowStub::make(TracingTimingWorkflow::class, 'cold-tracing', '0')->start( + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); } elseif ($updateLinks) { WorkflowStub::make(TracingUpdateLinksWorkflow::class, 'cold-tracing', '0')->start( new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), @@ -872,6 +893,36 @@ public function extract( 'tasks' => WorkflowTask::query()->get(['id', 'status', 'attempt_count'])->toArray(), ]; } + if ($timings) { + $history = $run->historyEvents() + ->orderBy('sequence') + ->get(); + $output['timings'] = [ + 'history_hashes' => $history->map(static function ($event): string { + return hash('sha256', json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR)); + })->all(), + 'history_types' => $history->map(static fn ($event): string => $event->event_type->value) + ->all(), + 'history_payloads' => $history->pluck('payload') + ->all(), + 'tasks' => WorkflowTask::query()->orderBy('created_at')->get()->map(static fn ($task): array => [ + 'id' => $task->id, + 'type' => $task->task_type->value, + 'status' => $task->status->value, + 'attempt_count' => $task->attempt_count, + 'available_at' => $task->getRawOriginal('available_at'), + 'leased_at' => $task->getRawOriginal('leased_at'), + 'payload' => $task->payload, + ])->all(), + 'timer' => WorkflowTimer::query()->first()?->getRawOriginal(), + 'attempts' => ActivityAttempt::query()->orderBy('attempt_number')->get([ + 'id', 'attempt_number', 'status', + ])->toArray(), + 'policy' => ActivityExecution::query()->first()?->retry_policy, + 'completion_count' => $history->where('event_type.value', 'ActivityCompleted') + ->count(), + ]; + } if ($signalLinks) { $history = $run->historyEvents() ->orderBy('sequence') diff --git a/tests/Unit/V2/ActivityExecutionTracingTest.php b/tests/Unit/V2/ActivityExecutionTracingTest.php index 4246b4afc..221fbcf7f 100644 --- a/tests/Unit/V2/ActivityExecutionTracingTest.php +++ b/tests/Unit/V2/ActivityExecutionTracingTest.php @@ -465,12 +465,17 @@ public function testIndependentQueueProcessesUseThePersistedAdmissionParent(bool $segments = []; foreach ([$workflow, $completion] as $segment) { $span = $segment['spans'][0]; - $segments[$span['fields']['task_id']] = $span['fields'] + [ + self::assertArrayHasKey('durable.task.queue_wait_seconds', $span['fields']); + self::assertGreaterThanOrEqual(0, $span['fields']['durable.task.queue_wait_seconds']); + $correlation = $span['fields']; + unset($correlation['durable.task.queue_wait_seconds']); + $segments[$span['fields']['task_id']] = $correlation + [ 'trace_id' => $span['trace_id'], 'span_id' => $span['span_id'], ]; } foreach ($workflowLogs as $record) { + self::assertArrayNotHasKey('durable.task.queue_wait_seconds', $record['context']); self::assertArrayHasKey($record['context']['task_id'], $segments); self::assertSame($segments[$record['context']['task_id']], $record['context']); } diff --git a/tests/Unit/V2/ExecutionTracingTest.php b/tests/Unit/V2/ExecutionTracingTest.php index eea43e993..370c9437c 100644 --- a/tests/Unit/V2/ExecutionTracingTest.php +++ b/tests/Unit/V2/ExecutionTracingTest.php @@ -551,6 +551,40 @@ public function testCancellationLinksUseOnlyTheOriginalLoadedRequestWithoutDatab self::assertSame($original, $command->getAttributes()); } + public function testTimerSpanKeepsFiniteTimingsOutsideLoggingContextAndPreservesTheOriginalFailure(): void + { + $error = new RuntimeException('original-timer-failure'); + try { + ExecutionTracing::timer([ + ...self::fields(), + 'timer_id' => 'timer-1', + 'durable.task.queue_wait_seconds' => 0.250001, + 'durable.task.retry_backoff_seconds' => 7, + 'durable.timer.requested_wait_seconds' => NAN, + 'durable.timer.lateness_seconds' => -1, + 'private.payload' => 'excluded-body', + ], self::carrier(), static function () use ($error): never { + self::assertArrayNotHasKey('durable.task.queue_wait_seconds', ExecutionTracing::current()); + self::assertSame('timer-1', ExecutionTracing::current()['timer_id']); + throw $error; + }, enabled: true); + self::fail('Expected the original failure.'); + } catch (RuntimeException $caught) { + self::assertSame($error, $caught); + } + $span = $this->exporter->getSpans()[0]; + self::assertSame('durable.timer.task', $span->getName()); + self::assertTrue($span->hasEnded()); + self::assertSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + $attributes = $span->getAttributes() + ->toArray(); + self::assertSame(0.250001, $attributes['durable.task.queue_wait_seconds']); + self::assertSame(7, $attributes['durable.task.retry_backoff_seconds']); + self::assertArrayNotHasKey('durable.timer.requested_wait_seconds', $attributes); + self::assertArrayNotHasKey('durable.timer.lateness_seconds', $attributes); + self::assertArrayNotHasKey('private.payload', $attributes); + } + private static function signalRun(): WorkflowRun { $run = new WorkflowRun(); diff --git a/tests/Unit/V2/TaskTraceTimingsTest.php b/tests/Unit/V2/TaskTraceTimingsTest.php new file mode 100644 index 000000000..ae6af2513 --- /dev/null +++ b/tests/Unit/V2/TaskTraceTimingsTest.php @@ -0,0 +1,143 @@ + '2026-10-15 10:00:10.123450', + 'leased_at' => '2026-10-15 10:00:10.373451', + 'payload' => json_encode([ + 'retry_after_attempt_id' => 'attempt-1', + 'retry_after_attempt' => 1, + 'retry_backoff_seconds' => 7, + ], JSON_THROW_ON_ERROR), + ]); + $before = $task->getRawOriginal(); + self::assertSame([ + 'durable.task.queue_wait_seconds' => 0.250001, + 'durable.task.retry_backoff_seconds' => 7, + ], TaskTraceTimings::forTask($task)); + self::assertSame($before, $task->getRawOriginal()); + self::assertSame([], $task->getRelations()); + + $task->task_type = 'timer'; + $task->payload = [ + 'timer_id' => 'timer-1', + ]; + $timer = new WorkflowTimer(); + $timer->setRawAttributes([ + 'id' => 'timer-1', + 'workflow_run_id' => 'run-1', + 'delay_seconds' => 7, + 'fire_at' => '2026-10-15 10:00:09.373450', + ], true); + self::assertSame([ + 'durable.task.queue_wait_seconds' => 0.250001, + 'durable.timer.requested_wait_seconds' => 7, + 'durable.timer.lateness_seconds' => 1.000001, + ], TaskTraceTimings::forTask($task, $timer)); + $timer->workflow_run_id = 'foreign'; + self::assertSame([ + 'durable.task.queue_wait_seconds' => 0.250001, + ], TaskTraceTimings::forTask($task, $timer)); + } + + public static function invalidDates(): array + { + return [ + 'missing eligibility' => [null, '2026-10-15 10:00:10'], + 'missing claim' => ['2026-10-15 10:00:10', null], + 'invalid' => ['not-a-date', '2026-10-15 10:00:10'], + 'calendar normalization' => ['2026-02-30 10:00:10', '2026-03-03 10:00:10'], + 'time normalization' => ['2026-10-15 25:00:00', '2026-10-16 02:00:00'], + 'negative clock observation' => ['2026-10-15 10:00:11', '2026-10-15 10:00:10'], + ]; + } + + #[DataProvider('invalidDates')] + public function testMissingInvalidAndNegativeTimesProduceNoFabricatedQueueWait( + ?string $available, + ?string $leased + ): void { + self::assertSame([], TaskTraceTimings::forTask(self::task([ + 'available_at' => $available, + 'leased_at' => $leased, + ]))); + } + + public function testStoredSecondAndFractionalPrecisionAndZeroBackoffRemainValid(): void + { + foreach (['2026-10-15 10:00:10', '2026-10-15 10:00:10.1', '2026-10-15 10:00:10.12345'] as $time) { + $task = self::task([ + 'available_at' => $time, + 'leased_at' => $time, + ]); + self::assertSame([ + 'durable.task.queue_wait_seconds' => 0.0, + ], TaskTraceTimings::forTask($task)); + } + $task->payload = [ + 'retry_after_attempt_id' => 'attempt-1', + 'retry_after_attempt' => 1, + 'retry_backoff_seconds' => 0, + ]; + self::assertSame(0, TaskTraceTimings::forTask($task)['durable.task.retry_backoff_seconds']); + foreach ([-1, '7', false, null, 0.5] as $backoff) { + $task->payload = [ + 'retry_after_attempt_id' => 'attempt-1', + 'retry_after_attempt' => 1, + 'retry_backoff_seconds' => $backoff, + ]; + self::assertArrayNotHasKey('durable.task.retry_backoff_seconds', TaskTraceTimings::forTask($task)); + } + } + + public function testThrowingOptionalDateAndPayloadCastsCannotInterruptBusinessMetadata(): void + { + $task = new class() extends WorkflowTask { + public function getAttribute($key): mixed + { + if (in_array($key, ['available_at', 'leased_at', 'payload'], true)) { + throw new RuntimeException('private-optional-cast'); + } + + return parent::getAttribute($key); + } + }; + $task->setRawAttributes([ + 'id' => 'task-1', + 'task_type' => 'activity', + 'workflow_run_id' => 'run-1', + ], true); + $before = $task->getRawOriginal(); + self::assertSame([], TaskTraceTimings::forTask($task)); + self::assertSame($before, $task->getRawOriginal()); + } + + private static function task(array $attributes): WorkflowTask + { + $task = new WorkflowTask(); + $task->setRawAttributes(array_replace([ + 'id' => 'task-1', + 'workflow_run_id' => 'run-1', + 'task_type' => 'activity', + 'available_at' => null, + 'leased_at' => null, + 'payload' => '{}', + ], $attributes), true); + + return $task; + } +} diff --git a/tests/Unit/V2/TimingExecutionTracingTest.php b/tests/Unit/V2/TimingExecutionTracingTest.php new file mode 100644 index 000000000..e9aafee4e --- /dev/null +++ b/tests/Unit/V2/TimingExecutionTracingTest.php @@ -0,0 +1,177 @@ + [true, true], + 'disabled' => [false, false], + 'legacy admission' => [false, true], + ]; + } + + #[DataProvider('modes')] + public function testActualTimerAndRetryWorkersSeparateStoredWaitingFromProcessing( + bool $admissionEnabled, + bool $workerEnabled, + ): void { + $directory = sys_get_temp_dir() . '/dw-tracing-timings-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static function (string $mode) use ($directory, $admissionEnabled, $workerEnabled): array { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_TIMINGS' => 'true', + 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admissionEnabled : $workerEnabled) ? 'true' : 'false', + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + self::assertJson($process->getOutput(), substr($process->getOutput(), -1500)); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + try { + $admitted = $worker('admit'); + $workers = [$worker('work')]; + self::assertSame('waiting', $workers[0]['status']); + $scheduled = $workers[0]['timings']['timer']; + sleep(2); + $workers[] = $worker('work'); + $workers[] = $worker('work'); + $workers[] = $worker('work'); + self::assertSame('failed', $workers[3]['timings']['attempts'][0]['status']); + $retryPolicy = $workers[3]['timings']['policy']; + sleep(2); + $workers[] = $worker('work'); + $workers[] = $worker('work'); + $finished = $worker('inspect'); + self::assertSame( + 'completed', + $finished['status'], + json_encode($finished['timings']['history_types'], JSON_THROW_ON_ERROR) + ); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + self::assertSame(1, $finished['timings']['completion_count']); + self::assertSame($retryPolicy, $finished['timings']['policy']); + self::assertSame([1], $retryPolicy['backoff_seconds']); + self::assertSame(2, $retryPolicy['max_attempts']); + self::assertSame(['failed', 'completed'], array_column($finished['timings']['attempts'], 'status')); + self::assertSame([1, 2], array_column($finished['timings']['attempts'], 'attempt_number')); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { + self::assertSame($admitted[$field], $finished[$field]); + } + foreach (['id', 'fire_at', 'delay_seconds', 'created_at'] as $field) { + self::assertSame($scheduled[$field], $finished['timings']['timer'][$field]); + } + self::assertSame('fired', $finished['timings']['timer']['status']); + foreach ($workers as $prefix) { + self::assertSame($prefix['timings']['history_hashes'], array_slice( + $finished['timings']['history_hashes'], + 0, + count($prefix['timings']['history_hashes']) + )); + } + $tasks = array_column($finished['timings']['tasks'], null, 'id'); + self::assertCount(6, $tasks); + foreach ($tasks as $task) { + self::assertSame('completed', $task['status']); + self::assertSame($task['payload']['retry_after_attempt'] ?? 0, $task['attempt_count'] - 1); + } + $spans = array_merge(...array_column($workers, 'spans')); + self::assertCount($workerEnabled ? 6 : 0, $spans); + foreach ($spans as $span) { + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + if ($admissionEnabled) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); + self::assertSame('00f067aa0ba902b7', $span['parent_id']); + } else { + self::assertSame('0000000000000000', $span['parent_id']); + self::assertNotSame('11111111111111111111111111111111', $span['trace_id']); + } + $task = $tasks[$span['fields']['task_id']]; + $seconds = self::seconds($task['available_at'], $task['leased_at']); + self::assertEqualsWithDelta($seconds, $span['fields']['durable.task.queue_wait_seconds'], 0.000001); + if (isset($task['payload']['retry_backoff_seconds'])) { + self::assertSame(1, $span['fields']['durable.task.retry_backoff_seconds']); + self::assertSame(2, $span['fields']['activity_attempt_number']); + } else { + self::assertArrayNotHasKey('durable.task.retry_backoff_seconds', $span['fields']); + } + if ($task['type'] === 'timer') { + self::assertSame('durable.timer.task', $span['name']); + self::assertSame($scheduled['id'], $span['fields']['timer_id']); + self::assertSame(1, $span['fields']['durable.timer.requested_wait_seconds']); + self::assertEqualsWithDelta( + self::seconds($scheduled['fire_at'], $task['leased_at']), + $span['fields']['durable.timer.lateness_seconds'], + 0.000001 + ); + } else { + self::assertArrayNotHasKey('durable.timer.requested_wait_seconds', $span['fields']); + } + } + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); + self::assertCount( + 2, + array_filter($records, static fn (array $record): bool => $record['message'] === 'callback') + ); + foreach ($records as $record) { + foreach (array_keys($record['context']) as $key) { + self::assertStringNotContainsString('queue_wait_seconds', $key); + self::assertStringNotContainsString('retry_backoff_seconds', $key); + } + if ($record['message'] === 'worker outside' || ! $workerEnabled) { + self::assertArrayNotHasKey('trace_id', $record['context']); + } + } + self::assertStringNotContainsString( + 'durable.task.queue_wait_seconds', + json_encode($finished['timings']['history_payloads'], JSON_THROW_ON_ERROR) + ); + self::assertCount(6, array_unique(array_column($workers, 'pid'))); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } + + private static function seconds(string $start, string $end): float + { + $from = new DateTimeImmutable($start, new DateTimeZone('UTC')); + $to = new DateTimeImmutable($end, new DateTimeZone('UTC')); + + return ($to->getTimestamp() - $from->getTimestamp()) + + ((int) $to->format('u') - (int) $from->format('u')) / 1_000_000; + } +} From 356fd9982c1b83b3a8a6ef014c8cc4c11d013686 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 18:18:42 +0000 Subject: [PATCH 24/77] feat: trace ordinary workflow admission decisions --- .github/workflows/php.yml | 1 + docs/embedded-tracing.md | 16 + src/V2/Support/ExecutionTracing.php | 63 +- src/V2/WorkflowStub.php | 701 +++++++++--------- tests/Fixtures/V2/tracing-process.php | 111 ++- .../Unit/V2/ActivityExecutionTracingTest.php | 32 +- .../Unit/V2/AdmissionExecutionTracingTest.php | 172 +++++ tests/Unit/V2/ExecutionTracingTest.php | 96 +++ .../Unit/V2/ExporterExecutionTracingTest.php | 9 +- tests/Unit/V2/IncomingHttpTracingTest.php | 20 +- 10 files changed, 861 insertions(+), 360 deletions(-) create mode 100644 tests/Unit/V2/AdmissionExecutionTracingTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index f4f17d85b..66430dab8 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -387,6 +387,7 @@ jobs: tests/Unit/V2/SignalLinkExecutionTracingTest.php tests/Unit/V2/UpdateLinkExecutionTracingTest.php tests/Unit/V2/TimingExecutionTracingTest.php + tests/Unit/V2/AdmissionExecutionTracingTest.php --fail-on-warning --log-junit build/test-results/pr-activity-tracing.xml - name: Run database-independent unit contracts diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index c8a62f516..bccef81e1 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -96,6 +96,22 @@ overhead, throughput or collector durability measurements. ## Current boundaries +Ordinary embedded starts emit a short `durable.workflow.admission` span when +tracing is enabled. It is an internal operation under the application's current +context. The span reports the returned start receipt, including a new run, +return of an existing active run or duplicate refusal. Unexpected application +exceptions retain their original identity. Optional telemetry failures do not +replace the admission result. + +The durable carrier is captured before this span starts. Later workers remain +children of that original caller, rather than the admission span. Duplicate +attempts have their own admission spans and keep the original run carrier and +deadlines. Span creation and completion occur outside the admission transaction. +The application owns processors, so a synchronous processor can still delay its +caller. The bounded-export guidance above also applies to admission spans. +Signal-with-start, service admission, schedules and wider runtime coverage have +no admission-span qualification yet. + Workflow processing and activity attempts receive separate spans. Persisted admission context survives the qualified cold-worker, child, continuation and signal-with-start paths. Existing runs keep their original admission context. diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php index a3bd7f4f5..018caaddc 100644 --- a/src/V2/Support/ExecutionTracing.php +++ b/src/V2/Support/ExecutionTracing.php @@ -22,7 +22,9 @@ use Workflow\V2\Enums\CommandStatus; use Workflow\V2\Enums\CommandType; use Workflow\V2\Models\WorkflowCommand; +use Workflow\V2\Models\WorkflowInstance; use Workflow\V2\Models\WorkflowRun; +use Workflow\V2\StartResult; /** @internal */ final class ExecutionTracing @@ -32,6 +34,7 @@ final class ExecutionTracing 'task_id', 'activity_id', 'activity_type', 'activity_attempt_id', 'activity_attempt_number', 'request_id', 'timer_id', + 'workflow_command_id', ]; private const TIMINGS = [ @@ -168,6 +171,25 @@ public static function current(): array return $fields; } + public static function admission(WorkflowInstance $instance, callable $operation, bool $enabled = false): mixed + { + if (! $enabled || ! class_exists(Globals::class)) { + return $operation(); + } + $attributes = []; + try { + $attributes = [ + 'workflow_instance_id' => $instance->getRawOriginal('id'), + 'workflow_type' => $instance->getRawOriginal('workflow_type'), + 'namespace' => $instance->getRawOriginal('namespace'), + ]; + } catch (Throwable) { + // Optional identity reads do not replace admission. + } + + return self::segment('durable.workflow.admission', $attributes, null, $operation, [], true); + } + public static function recordFailure(Throwable $error): void { try { @@ -255,8 +277,8 @@ private static function segment( $span = null; $scope = null; try { - // A queue worker's ambient trace is not the run's causal authority. - $parent = Context::getRoot(); + // Local admission keeps the caller's context. Workers recover only stored authority. + $parent = $name === 'durable.workflow.admission' ? Context::getCurrent() : Context::getRoot(); $carrier = TraceCarrier::normalize($persistedContext, $baggageAllowlist); if ($carrier !== null) { $parent = TraceContextPropagator::getInstance()->extract($carrier['carrier'], context: $parent); @@ -283,7 +305,9 @@ private static function segment( : null; $span = Globals::tracerProvider()->getTracer('durable-workflow/workflow', $version) ->spanBuilder($name) - ->setSpanKind(SpanKind::KIND_CONSUMER) + ->setSpanKind( + $name === 'durable.workflow.admission' ? SpanKind::KIND_INTERNAL : SpanKind::KIND_CONSUMER + ) ->setParent($parent) ->setAttributes($spanAttributes) ->startSpan(); @@ -307,7 +331,16 @@ private static function segment( } try { - return $operation(); + $result = $operation(); + if ($name === 'durable.workflow.admission' && $result instanceof StartResult && $span !== null) { + try { + self::recordAdmissionResult($span, $result); + } catch (Throwable) { + // A telemetry receipt read cannot replace the committed decision. + } + } + + return $result; } catch (Throwable $error) { self::recordFailure($error); throw $error; @@ -335,6 +368,28 @@ private static function fields(array $attributes): array return $fields; } + private static function recordAdmissionResult(SpanInterface $span, StartResult $result): void + { + $attributes = self::fields([ + 'workflow_instance_id' => $result->instanceId(), + 'workflow_run_id' => $result->runId(), + 'workflow_type' => $result->workflowType(), + 'workflow_command_id' => $result->commandId(), + ]); + foreach ([ + 'durable.command.type' => $result->type(), + 'durable.command.status' => $result->status(), + 'durable.command.outcome' => $result->outcome(), + 'durable.command.rejection_reason' => $result->rejectionReason(), + ] as $key => $value) { + if (is_string($value) && $value !== '' && strlen($value) <= 256 + && ! preg_match('/[\x00-\x1f\x7f]/', $value)) { + $attributes[$key] = $value; + } + } + $span->setAttributes($attributes); + } + private static function close(?ScopeInterface $scope, ?SpanInterface $span): void { try { diff --git a/src/V2/WorkflowStub.php b/src/V2/WorkflowStub.php index 9d21b03a3..67ac8aefa 100644 --- a/src/V2/WorkflowStub.php +++ b/src/V2/WorkflowStub.php @@ -57,6 +57,7 @@ use Workflow\V2\Support\ChildRunHistory; use Workflow\V2\Support\ConfiguredV2Models; use Workflow\V2\Support\CurrentRunResolver; +use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\ExternalPayloads; use Workflow\V2\Support\LifecycleEventDispatcher; use Workflow\V2\Support\MemoUpsertService; @@ -940,288 +941,24 @@ public function start(...$arguments): StartResult public function attemptStart(...$arguments): StartResult { - /** @var WorkflowCommand|null $command */ - $command = null; - [$arguments, $startOptions] = $this->extractStartArguments($arguments); $metadata = WorkflowMetadata::fromStartArguments($arguments); // Capture outside the storage transaction and preserve it across its retries. $observabilityContext = null; - if (config('workflows.tracing.enabled', false) === true) { + $tracing = config('workflows.tracing.enabled', false) === true; + if ($tracing) { $allowlist = config('workflows.tracing.baggage_allowlist', []); $observabilityContext = TraceCarrier::capture(is_array($allowlist) ? $allowlist : []); } - $task = null; - $startedRun = null; - - SqliteTransaction::run(function () use ( - $metadata, - $startOptions, - $observabilityContext, - &$task, - &$command, - &$startedRun, - ): void { - $task = null; - $command = null; - $startedRun = null; - - /** @var WorkflowInstance $instance */ - $instance = self::instanceQuery() - ->lockForUpdate() - ->findOrFail($this->instance->id); - - $run = $this->currentRunForInstance($instance, true); - - if ($run instanceof WorkflowRun) { - $canReturnExisting = $startOptions->duplicateStartPolicy === DuplicateStartPolicy::ReturnExistingActive - && in_array($run->status, [RunStatus::Pending, RunStatus::Running, RunStatus::Waiting], true); - - /** @var WorkflowCommand $command */ - $command = WorkflowCommand::record($instance, $run, $this->commandAttributes([ - 'command_type' => CommandType::Start->value, - 'target_scope' => 'instance', - 'status' => $canReturnExisting - ? CommandStatus::Accepted->value - : CommandStatus::Rejected->value, - 'outcome' => $canReturnExisting - ? CommandOutcome::ReturnedExistingActive->value - : CommandOutcome::RejectedDuplicate->value, - 'payload_codec' => $run->payload_codec ?? CodecRegistry::defaultCodec(), - 'payload' => Serializer::serializeWithCodec( - $run->payload_codec ?? CodecRegistry::defaultCodec(), - $metadata->arguments - ), - 'rejection_reason' => $canReturnExisting - ? null - : 'instance_already_started', - 'accepted_at' => $canReturnExisting - ? now() - : null, - 'applied_at' => $canReturnExisting - ? now() - : null, - 'rejected_at' => $canReturnExisting - ? null - : now(), - ])); - - WorkflowHistoryEvent::record($run, $canReturnExisting - ? HistoryEventType::StartAccepted - : HistoryEventType::StartRejected, [ - 'workflow_command_id' => $command->id, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'workflow_class' => $run->workflow_class, - 'workflow_type' => $run->workflow_type, - 'business_key' => $run->business_key, - 'visibility_labels' => $run->visibility_labels, - 'memo' => AvroValueJsonProjection::project($run->typedMemos()), - 'search_attributes' => $run->typedSearchAttributes(), - 'outcome' => $command->outcome?->value, - 'rejection_reason' => $command->rejection_reason, - ], null, $command); - - self::projectRun($run, self::PROJECTION_RUN_RELATIONS); - - return; - } - - $workflowClass = TypeRegistry::resolveWorkflowClass($instance->workflow_class, $instance->workflow_type); - $commandContract = RunCommandContract::snapshot($workflowClass); - $businessKey = $startOptions->businessKey ?? $instance->business_key; - $visibilityLabels = $startOptions->labels !== [] - ? $startOptions->labels - : (is_array($instance->visibility_labels) ? $instance->visibility_labels : null); - $memo = $startOptions->memo !== [] - ? $startOptions->memo - : (is_array($instance->memo) ? $instance->memo : null); - $searchAttributes = $startOptions->searchAttributes !== [] - ? $startOptions->searchAttributes - : null; - $executionTimeoutSeconds = $startOptions->executionTimeoutSeconds; - $runTimeoutSeconds = $startOptions->runTimeoutSeconds; - $connection = RoutingResolver::workflowConnection($workflowClass, $metadata); - $queue = RoutingResolver::workflowQueue($workflowClass, $metadata); - $startBlockedReason = WorkflowStartGate::blockedReason( - WorkerCompatibility::current(), - $connection, - $queue, - ); - - if ($startBlockedReason !== null) { - $command = $this->rejectStartCommand( - $instance, - $metadata->arguments, - $startBlockedReason, - WorkflowStartGate::blockedMessage( - sprintf('Workflow instance [%s] cannot start.', $instance->id), - WorkerCompatibility::current(), - $connection, - $queue, - ) ?? sprintf('Workflow instance [%s] cannot start.', $instance->id), - ); - - return; - } - - if ($instance->workflow_class !== $workflowClass) { - $instance->forceFill([ - 'workflow_class' => $workflowClass, - ])->save(); - } - - $startedAt = now(); - $executionDeadlineAt = $executionTimeoutSeconds !== null - ? $startedAt->copy() - ->addSeconds($executionTimeoutSeconds) - : null; - $runDeadlineAt = $runTimeoutSeconds !== null - ? $startedAt->copy() - ->addSeconds($runTimeoutSeconds) - : null; - $payloadCodec = CodecRegistry::defaultCodec(); - $serializedArguments = ExternalPayloads::externalizeForNamespace( - Serializer::serializeWithCodec($payloadCodec, $metadata->arguments), - $payloadCodec, - is_string($instance->namespace) ? $instance->namespace : null, - ); - - /** @var WorkflowRun $run */ - $run = self::runQuery()->create([ - 'workflow_instance_id' => $instance->id, - 'run_number' => $instance->run_count + 1, - 'observability_context' => $observabilityContext, - 'workflow_class' => $workflowClass, - 'workflow_type' => $instance->workflow_type, - 'namespace' => $instance->namespace, - 'business_key' => $businessKey, - 'visibility_labels' => $visibilityLabels, - 'run_timeout_seconds' => $runTimeoutSeconds, - 'execution_deadline_at' => $executionDeadlineAt, - 'run_deadline_at' => $runDeadlineAt, - 'status' => RunStatus::Pending->value, - 'compatibility' => WorkerCompatibility::current(), - 'payload_codec' => $payloadCodec, - 'arguments' => $serializedArguments, - 'connection' => $connection, - 'queue' => $queue, - 'started_at' => $startedAt, - 'last_progress_at' => $startedAt, - 'last_history_sequence' => 0, - ]); - - $this->seedTypedVisibilityMetadata($run, $memo, $searchAttributes); - - /** @var WorkflowCommand $command */ - $command = WorkflowCommand::record($instance, $run, $this->commandAttributes([ - 'command_type' => CommandType::Start->value, - 'target_scope' => 'instance', - 'status' => CommandStatus::Accepted->value, - 'outcome' => CommandOutcome::StartedNew->value, - 'payload_codec' => $payloadCodec, - 'payload' => $serializedArguments, - 'accepted_at' => now(), - 'applied_at' => now(), - ])); - - $instance->forceFill([ - 'current_run_id' => $run->id, - 'business_key' => $businessKey, - 'visibility_labels' => $visibilityLabels, - 'memo' => $memo, - 'execution_timeout_seconds' => $executionTimeoutSeconds, - 'started_at' => $startedAt, - 'run_count' => $run->run_number, - ])->save(); - - $memoPayload = self::visibilityMetadataPayload($memo); - $searchAttributesPayload = self::visibilityMetadataPayload($searchAttributes); - - WorkflowHistoryEvent::record($run, HistoryEventType::StartAccepted, [ - 'workflow_command_id' => $command->id, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'workflow_class' => $run->workflow_class, - 'workflow_type' => $run->workflow_type, - 'business_key' => $run->business_key, - 'visibility_labels' => $run->visibility_labels, - 'memo' => $memoPayload, - 'search_attributes' => $searchAttributesPayload, - 'outcome' => $command->outcome?->value, - ], null, $command); - - WorkflowHistoryEvent::record($run, HistoryEventType::WorkflowStarted, [ - 'workflow_class' => $run->workflow_class, - 'workflow_type' => $run->workflow_type, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'workflow_command_id' => $command->id, - 'business_key' => $run->business_key, - 'visibility_labels' => $run->visibility_labels, - 'memo' => $memoPayload, - 'search_attributes' => $searchAttributesPayload, - 'execution_timeout_seconds' => $executionTimeoutSeconds, - 'run_timeout_seconds' => $runTimeoutSeconds, - 'execution_deadline_at' => $executionDeadlineAt?->toIso8601String(), - 'run_deadline_at' => $runDeadlineAt?->toIso8601String(), - 'workflow_definition_fingerprint' => WorkflowDefinition::fingerprint($workflowClass), - 'declared_queries' => $commandContract['queries'], - 'declared_query_contracts' => $commandContract['query_contracts'], - 'declared_signals' => $commandContract['signals'], - 'declared_signal_contracts' => $commandContract['signal_contracts'], - 'declared_updates' => $commandContract['updates'], - 'declared_update_contracts' => $commandContract['update_contracts'], - 'declared_entry_method' => $commandContract['entry_method'], - 'declared_entry_mode' => $commandContract['entry_mode'], - 'declared_entry_declaring_class' => $commandContract['entry_declaring_class'], - ], null, $command); - - /** @var WorkflowTask $task */ - $task = self::taskQuery()->create([ - 'workflow_run_id' => $run->id, - 'namespace' => $run->namespace, - 'task_type' => TaskType::Workflow->value, - 'status' => TaskStatus::Ready->value, - 'available_at' => now(), - 'payload' => [], - 'connection' => $run->connection, - 'queue' => $run->queue, - 'compatibility' => $run->compatibility, - ]); - - self::projectRun($run, ['instance', 'tasks', 'activityExecutions', 'failures', 'historyEvents']); - - $startedRun = $run; - }, self::storageTransactionAttempts(), admission: function (): void { - $instance = self::instanceQuery(); - $key = $instance->getModel() - ->getKeyName(); - $instance->whereKey($this->instance->id) - ->toBase() - ->update([ - $key => $this->instance->id, - ]); - }); - - if ($startedRun instanceof WorkflowRun) { - LifecycleEventDispatcher::workflowStarted($startedRun); - } - - $this->refresh(); - - if ($task instanceof WorkflowTask) { - TaskDispatcher::dispatch($task); - } - - if (! $command instanceof WorkflowCommand) { - throw new LogicException(sprintf( - 'Workflow instance [%s] failed to record a start command.', - $this->instance->id - )); + if (! $tracing) { + return $this->admitStart($metadata, $startOptions, $observabilityContext); } - return StartResult::fromCommand($command); + return ExecutionTracing::admission( + $this->instance, + fn () => $this->admitStart($metadata, $startOptions, $observabilityContext), + enabled: true + ); } public function signalWithStart( @@ -1818,113 +1555,393 @@ public function archive(?string $reason = null): CommandResult )); } - return $result; - } + return $result; + } + + public function attemptArchive(?string $reason = null): CommandResult + { + /** @var WorkflowCommand|null $command */ + $command = null; + + DB::transaction(function () use (&$command, $reason): void { + /** @var WorkflowInstance $instance */ + $instance = self::instanceQuery() + ->lockForUpdate() + ->findOrFail($this->instance->id); + + $currentRun = $this->currentRunForInstance($instance, true); + + if (! $currentRun instanceof WorkflowRun) { + $command = $this->rejectCommand( + $instance, + null, + CommandType::Archive, + 'instance_not_started', + $this->commandTargetScope(), + $this->archiveCommandPayloadAttributes($reason), + ); + + return; + } + + if ($this->runTargeted) { + /** @var WorkflowRun $run */ + $run = self::runQuery() + ->lockForUpdate() + ->findOrFail($this->selectedRunId); + } else { + $run = $currentRun; + } + + if (! $run->status->isTerminal()) { + $command = $this->rejectCommand( + $instance, + $run, + CommandType::Archive, + 'run_not_closed', + $this->commandTargetScope(), + $this->archiveCommandPayloadAttributes($reason, [ + 'resolved_workflow_run_id' => $currentRun->id, + ]), + ); + + return; + } + + $alreadyArchived = $run->archived_at !== null; + + /** @var WorkflowCommand $command */ + $command = WorkflowCommand::record($instance, $run, $this->commandAttributes([ + 'command_type' => CommandType::Archive->value, + 'target_scope' => $this->commandTargetScope(), + 'status' => CommandStatus::Accepted->value, + 'outcome' => $alreadyArchived + ? CommandOutcome::ArchiveNotNeeded->value + : CommandOutcome::Archived->value, + ...$this->archiveCommandPayloadAttributes($reason), + 'accepted_at' => now(), + 'applied_at' => now(), + ])); + + WorkflowHistoryEvent::record($run, HistoryEventType::ArchiveRequested, array_filter([ + 'workflow_command_id' => $command->id, + 'workflow_instance_id' => $instance->id, + 'workflow_run_id' => $run->id, + 'command_type' => CommandType::Archive->value, + 'outcome' => $command->outcome?->value, + 'reason' => $reason, + ], static fn (mixed $value): bool => $value !== null), null, $command); + + if (! $alreadyArchived) { + $run->forceFill([ + 'archived_at' => \now('UTC'), + 'archive_command_id' => $command->id, + 'archive_reason' => $reason, + 'last_progress_at' => now(), + ])->save(); + + WorkflowHistoryEvent::record($run, HistoryEventType::WorkflowArchived, array_filter([ + 'workflow_command_id' => $command->id, + 'workflow_instance_id' => $instance->id, + 'workflow_run_id' => $run->id, + 'archive_command_id' => $command->id, + 'reason' => $reason, + ], static fn (mixed $value): bool => $value !== null), null, $command); + } + + self::projectRun($run, self::PROJECTION_RUN_RELATIONS); + }); + + $this->refresh(); + + if (! $command instanceof WorkflowCommand) { + throw new LogicException(sprintf( + 'Workflow instance [%s] failed to record an archive command.', + $this->instance->id, + )); + } + + return new CommandResult($command); + } + + private function admitStart( + WorkflowMetadata $metadata, + StartOptions $startOptions, + ?array $observabilityContext, + ): StartResult { + /** @var WorkflowCommand|null $command */ + $command = null; + $task = null; + $startedRun = null; + + SqliteTransaction::run(function () use ( + $metadata, + $startOptions, + $observabilityContext, + &$task, + &$command, + &$startedRun, + ): void { + $task = null; + $command = null; + $startedRun = null; + + /** @var WorkflowInstance $instance */ + $instance = self::instanceQuery() + ->lockForUpdate() + ->findOrFail($this->instance->id); + + $run = $this->currentRunForInstance($instance, true); + + if ($run instanceof WorkflowRun) { + $canReturnExisting = $startOptions->duplicateStartPolicy === DuplicateStartPolicy::ReturnExistingActive + && in_array($run->status, [RunStatus::Pending, RunStatus::Running, RunStatus::Waiting], true); + + /** @var WorkflowCommand $command */ + $command = WorkflowCommand::record($instance, $run, $this->commandAttributes([ + 'command_type' => CommandType::Start->value, + 'target_scope' => 'instance', + 'status' => $canReturnExisting + ? CommandStatus::Accepted->value + : CommandStatus::Rejected->value, + 'outcome' => $canReturnExisting + ? CommandOutcome::ReturnedExistingActive->value + : CommandOutcome::RejectedDuplicate->value, + 'payload_codec' => $run->payload_codec ?? CodecRegistry::defaultCodec(), + 'payload' => Serializer::serializeWithCodec( + $run->payload_codec ?? CodecRegistry::defaultCodec(), + $metadata->arguments + ), + 'rejection_reason' => $canReturnExisting + ? null + : 'instance_already_started', + 'accepted_at' => $canReturnExisting + ? now() + : null, + 'applied_at' => $canReturnExisting + ? now() + : null, + 'rejected_at' => $canReturnExisting + ? null + : now(), + ])); + + WorkflowHistoryEvent::record($run, $canReturnExisting + ? HistoryEventType::StartAccepted + : HistoryEventType::StartRejected, [ + 'workflow_command_id' => $command->id, + 'workflow_instance_id' => $instance->id, + 'workflow_run_id' => $run->id, + 'workflow_class' => $run->workflow_class, + 'workflow_type' => $run->workflow_type, + 'business_key' => $run->business_key, + 'visibility_labels' => $run->visibility_labels, + 'memo' => AvroValueJsonProjection::project($run->typedMemos()), + 'search_attributes' => $run->typedSearchAttributes(), + 'outcome' => $command->outcome?->value, + 'rejection_reason' => $command->rejection_reason, + ], null, $command); - public function attemptArchive(?string $reason = null): CommandResult - { - /** @var WorkflowCommand|null $command */ - $command = null; + self::projectRun($run, self::PROJECTION_RUN_RELATIONS); - DB::transaction(function () use (&$command, $reason): void { - /** @var WorkflowInstance $instance */ - $instance = self::instanceQuery() - ->lockForUpdate() - ->findOrFail($this->instance->id); + return; + } - $currentRun = $this->currentRunForInstance($instance, true); + $workflowClass = TypeRegistry::resolveWorkflowClass($instance->workflow_class, $instance->workflow_type); + $commandContract = RunCommandContract::snapshot($workflowClass); + $businessKey = $startOptions->businessKey ?? $instance->business_key; + $visibilityLabels = $startOptions->labels !== [] + ? $startOptions->labels + : (is_array($instance->visibility_labels) ? $instance->visibility_labels : null); + $memo = $startOptions->memo !== [] + ? $startOptions->memo + : (is_array($instance->memo) ? $instance->memo : null); + $searchAttributes = $startOptions->searchAttributes !== [] + ? $startOptions->searchAttributes + : null; + $executionTimeoutSeconds = $startOptions->executionTimeoutSeconds; + $runTimeoutSeconds = $startOptions->runTimeoutSeconds; + $connection = RoutingResolver::workflowConnection($workflowClass, $metadata); + $queue = RoutingResolver::workflowQueue($workflowClass, $metadata); + $startBlockedReason = WorkflowStartGate::blockedReason( + WorkerCompatibility::current(), + $connection, + $queue, + ); - if (! $currentRun instanceof WorkflowRun) { - $command = $this->rejectCommand( + if ($startBlockedReason !== null) { + $command = $this->rejectStartCommand( $instance, - null, - CommandType::Archive, - 'instance_not_started', - $this->commandTargetScope(), - $this->archiveCommandPayloadAttributes($reason), + $metadata->arguments, + $startBlockedReason, + WorkflowStartGate::blockedMessage( + sprintf('Workflow instance [%s] cannot start.', $instance->id), + WorkerCompatibility::current(), + $connection, + $queue, + ) ?? sprintf('Workflow instance [%s] cannot start.', $instance->id), ); return; } - if ($this->runTargeted) { - /** @var WorkflowRun $run */ - $run = self::runQuery() - ->lockForUpdate() - ->findOrFail($this->selectedRunId); - } else { - $run = $currentRun; + if ($instance->workflow_class !== $workflowClass) { + $instance->forceFill([ + 'workflow_class' => $workflowClass, + ])->save(); } - if (! $run->status->isTerminal()) { - $command = $this->rejectCommand( - $instance, - $run, - CommandType::Archive, - 'run_not_closed', - $this->commandTargetScope(), - $this->archiveCommandPayloadAttributes($reason, [ - 'resolved_workflow_run_id' => $currentRun->id, - ]), - ); + $startedAt = now(); + $executionDeadlineAt = $executionTimeoutSeconds !== null + ? $startedAt->copy() + ->addSeconds($executionTimeoutSeconds) + : null; + $runDeadlineAt = $runTimeoutSeconds !== null + ? $startedAt->copy() + ->addSeconds($runTimeoutSeconds) + : null; + $payloadCodec = CodecRegistry::defaultCodec(); + $serializedArguments = ExternalPayloads::externalizeForNamespace( + Serializer::serializeWithCodec($payloadCodec, $metadata->arguments), + $payloadCodec, + is_string($instance->namespace) ? $instance->namespace : null, + ); - return; - } + /** @var WorkflowRun $run */ + $run = self::runQuery()->create([ + 'workflow_instance_id' => $instance->id, + 'run_number' => $instance->run_count + 1, + 'observability_context' => $observabilityContext, + 'workflow_class' => $workflowClass, + 'workflow_type' => $instance->workflow_type, + 'namespace' => $instance->namespace, + 'business_key' => $businessKey, + 'visibility_labels' => $visibilityLabels, + 'run_timeout_seconds' => $runTimeoutSeconds, + 'execution_deadline_at' => $executionDeadlineAt, + 'run_deadline_at' => $runDeadlineAt, + 'status' => RunStatus::Pending->value, + 'compatibility' => WorkerCompatibility::current(), + 'payload_codec' => $payloadCodec, + 'arguments' => $serializedArguments, + 'connection' => $connection, + 'queue' => $queue, + 'started_at' => $startedAt, + 'last_progress_at' => $startedAt, + 'last_history_sequence' => 0, + ]); - $alreadyArchived = $run->archived_at !== null; + $this->seedTypedVisibilityMetadata($run, $memo, $searchAttributes); /** @var WorkflowCommand $command */ $command = WorkflowCommand::record($instance, $run, $this->commandAttributes([ - 'command_type' => CommandType::Archive->value, - 'target_scope' => $this->commandTargetScope(), + 'command_type' => CommandType::Start->value, + 'target_scope' => 'instance', 'status' => CommandStatus::Accepted->value, - 'outcome' => $alreadyArchived - ? CommandOutcome::ArchiveNotNeeded->value - : CommandOutcome::Archived->value, - ...$this->archiveCommandPayloadAttributes($reason), + 'outcome' => CommandOutcome::StartedNew->value, + 'payload_codec' => $payloadCodec, + 'payload' => $serializedArguments, 'accepted_at' => now(), 'applied_at' => now(), ])); - WorkflowHistoryEvent::record($run, HistoryEventType::ArchiveRequested, array_filter([ + $instance->forceFill([ + 'current_run_id' => $run->id, + 'business_key' => $businessKey, + 'visibility_labels' => $visibilityLabels, + 'memo' => $memo, + 'execution_timeout_seconds' => $executionTimeoutSeconds, + 'started_at' => $startedAt, + 'run_count' => $run->run_number, + ])->save(); + + $memoPayload = self::visibilityMetadataPayload($memo); + $searchAttributesPayload = self::visibilityMetadataPayload($searchAttributes); + + WorkflowHistoryEvent::record($run, HistoryEventType::StartAccepted, [ 'workflow_command_id' => $command->id, 'workflow_instance_id' => $instance->id, 'workflow_run_id' => $run->id, - 'command_type' => CommandType::Archive->value, + 'workflow_class' => $run->workflow_class, + 'workflow_type' => $run->workflow_type, + 'business_key' => $run->business_key, + 'visibility_labels' => $run->visibility_labels, + 'memo' => $memoPayload, + 'search_attributes' => $searchAttributesPayload, 'outcome' => $command->outcome?->value, - 'reason' => $reason, - ], static fn (mixed $value): bool => $value !== null), null, $command); + ], null, $command); - if (! $alreadyArchived) { - $run->forceFill([ - 'archived_at' => \now('UTC'), - 'archive_command_id' => $command->id, - 'archive_reason' => $reason, - 'last_progress_at' => now(), - ])->save(); + WorkflowHistoryEvent::record($run, HistoryEventType::WorkflowStarted, [ + 'workflow_class' => $run->workflow_class, + 'workflow_type' => $run->workflow_type, + 'workflow_instance_id' => $instance->id, + 'workflow_run_id' => $run->id, + 'workflow_command_id' => $command->id, + 'business_key' => $run->business_key, + 'visibility_labels' => $run->visibility_labels, + 'memo' => $memoPayload, + 'search_attributes' => $searchAttributesPayload, + 'execution_timeout_seconds' => $executionTimeoutSeconds, + 'run_timeout_seconds' => $runTimeoutSeconds, + 'execution_deadline_at' => $executionDeadlineAt?->toIso8601String(), + 'run_deadline_at' => $runDeadlineAt?->toIso8601String(), + 'workflow_definition_fingerprint' => WorkflowDefinition::fingerprint($workflowClass), + 'declared_queries' => $commandContract['queries'], + 'declared_query_contracts' => $commandContract['query_contracts'], + 'declared_signals' => $commandContract['signals'], + 'declared_signal_contracts' => $commandContract['signal_contracts'], + 'declared_updates' => $commandContract['updates'], + 'declared_update_contracts' => $commandContract['update_contracts'], + 'declared_entry_method' => $commandContract['entry_method'], + 'declared_entry_mode' => $commandContract['entry_mode'], + 'declared_entry_declaring_class' => $commandContract['entry_declaring_class'], + ], null, $command); - WorkflowHistoryEvent::record($run, HistoryEventType::WorkflowArchived, array_filter([ - 'workflow_command_id' => $command->id, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'archive_command_id' => $command->id, - 'reason' => $reason, - ], static fn (mixed $value): bool => $value !== null), null, $command); - } + /** @var WorkflowTask $task */ + $task = self::taskQuery()->create([ + 'workflow_run_id' => $run->id, + 'namespace' => $run->namespace, + 'task_type' => TaskType::Workflow->value, + 'status' => TaskStatus::Ready->value, + 'available_at' => now(), + 'payload' => [], + 'connection' => $run->connection, + 'queue' => $run->queue, + 'compatibility' => $run->compatibility, + ]); - self::projectRun($run, self::PROJECTION_RUN_RELATIONS); + self::projectRun($run, ['instance', 'tasks', 'activityExecutions', 'failures', 'historyEvents']); + + $startedRun = $run; + }, self::storageTransactionAttempts(), admission: function (): void { + $instance = self::instanceQuery(); + $key = $instance->getModel() + ->getKeyName(); + $instance->whereKey($this->instance->id) + ->toBase() + ->update([ + $key => $this->instance->id, + ]); }); + if ($startedRun instanceof WorkflowRun) { + LifecycleEventDispatcher::workflowStarted($startedRun); + } + $this->refresh(); + if ($task instanceof WorkflowTask) { + TaskDispatcher::dispatch($task); + } + if (! $command instanceof WorkflowCommand) { throw new LogicException(sprintf( - 'Workflow instance [%s] failed to record an archive command.', - $this->instance->id, + 'Workflow instance [%s] failed to record a start command.', + $this->instance->id )); } - return new CommandResult($command); + return StartResult::fromCommand($command); } /** diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index edac3c837..392772117 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -25,6 +25,8 @@ use OpenTelemetry\Context\Propagation\PropagationGetterInterface; use OpenTelemetry\Context\Propagation\PropagationSetterInterface; use OpenTelemetry\Context\Propagation\TextMapPropagatorInterface; +use OpenTelemetry\SDK\Trace\ReadableSpanInterface; +use OpenTelemetry\SDK\Trace\ReadWriteSpanInterface; use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor; use OpenTelemetry\SDK\Trace\TracerProvider; @@ -35,6 +37,7 @@ use Workflow\V2\Activity; use Workflow\V2\Attributes\Signal; use Workflow\V2\CommandContext; +use Workflow\V2\Events\WorkflowStarted; use Workflow\V2\Models\ActivityAttempt; use Workflow\V2\Models\ActivityExecution; use Workflow\V2\Models\WorkflowChildCall; @@ -68,6 +71,7 @@ $signalLinks = getenv('DW_TEST_TRACING_SIGNAL_LINKS') === 'true'; $updateLinks = getenv('DW_TEST_TRACING_UPDATE_LINKS') === 'true'; $timings = getenv('DW_TEST_TRACING_TIMINGS') === 'true'; +$admissionProbe = getenv('DW_TEST_TRACING_ADMISSION') === 'true'; $childRetry = getenv('DW_TEST_TRACING_CHILD_RETRY') === 'true'; $cancellation = getenv('DW_TEST_TRACING_CANCELLATION'); foreach (array_slice($argv, 4) as $autoload) { @@ -407,7 +411,32 @@ protected function defineEnvironment($app): void ], ); $exporter = new InMemoryExporter(); -$processors = [new SimpleSpanProcessor($exporter)]; +$admissionProcessor = $admissionProbe ? new class($exporter) extends SimpleSpanProcessor { + public array $boundaries = []; + + public function onStart(ReadWriteSpanInterface $span, ContextInterface $parentContext): void + { + if ($span->getName() === 'durable.workflow.admission') { + $this->boundaries[] = [ + 'event' => 'start', + 'transaction_level' => DB::transactionLevel(), + ]; + } + parent::onStart($span, $parentContext); + } + + public function onEnd(ReadableSpanInterface $span): void + { + if ($span->getName() === 'durable.workflow.admission') { + $this->boundaries[] = [ + 'event' => 'end', + 'transaction_level' => DB::transactionLevel(), + ]; + } + parent::onEnd($span); + } +} : null; +$processors = [$admissionProcessor ?? new SimpleSpanProcessor($exporter)]; $exportEndpoint = getenv('DW_TEST_TRACING_EXPORTER_ENDPOINT'); $exportJournal = getenv('DW_TEST_TRACING_EXPORTER_JOURNAL'); $exportRequests = static fn (): int => $exportJournal && is_file($exportJournal) @@ -422,7 +451,7 @@ protected function defineEnvironment($app): void BaggagePropagator::getInstance(), ]); $recordedPropagator = null; -if ($signalStart || $signalLinks || $cancellation || $updateLinks) { +if ($signalStart || $signalLinks || $cancellation || $updateLinks || $admissionProbe) { $recordedPropagator = new class($propagator) implements TextMapPropagatorInterface { public array $transactionLevels = []; @@ -459,6 +488,26 @@ public function extract( ->withPropagator($propagator) ->activate(); $initial = Context::getCurrent(); +$admissionForeignKey = Context::createKey('test.application.admission'); +$admissionCallbacks = []; +if ($admissionProbe) { + $app['events']->listen(WorkflowStarted::class, static function () use ( + &$admissionCallbacks, + $admissionForeignKey + ): void { + $context = Span::getCurrent()->getContext(); + $admissionCallbacks[] = [ + 'transaction_level' => DB::transactionLevel(), + 'foreign' => Context::getCurrent()->get($admissionForeignKey), + 'fields' => ExecutionTracing::current(), + 'trace_id' => $context->getTraceId(), + 'span_id' => $context->getSpanId(), + ]; + Log::info('admission callback', [ + 'request_id' => 'application-request', + ]); + }); +} try { $parent = $mode === 'admit' ? '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' @@ -486,6 +535,15 @@ public function extract( $headers['tracestate'] = 'dwtest=one'; $headers['baggage'] = 'approved=tenant-a,private=must-not-propagate'; } + if ($admissionProbe && in_array($mode, ['admit', 'duplicate', 'reject'], true)) { + $headers['traceparent'] = $mode === 'admit' + ? '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' + : '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01'; + if (getenv('DW_TEST_TRACING_ADMISSION_NO_PARENT') === 'true') { + unset($headers['traceparent']); + } + $headers['baggage'] = 'approved=origin,private=caller-local'; + } $request = $incomingHttp ? Request::capture() : null; if ($request !== null) { $headers = []; @@ -497,6 +555,9 @@ public function extract( } } $parentContext = $propagator->extract($headers, context: $initial); + if ($admissionProbe) { + $parentContext = $parentContext->with($admissionForeignKey, 'caller-scope'); + } // The fixture application owns its HTTP instrumentation, as a real Laravel app would. $requestSpan = $incomingHttp ? $provider->getTracer('test.application') ->spanBuilder('POST /start') @@ -508,6 +569,8 @@ public function extract( $signalStartResult = null; $cancellationResult = null; $updateResult = null; + $admissionResult = null; + $admissionContextPreserved = null; try { if ($mode === 'admit') { if (Artisan::call('migrate', [ @@ -609,10 +672,16 @@ public function extract( new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), ); } else { - WorkflowStub::make(TracingProcessWorkflow::class, 'cold-tracing', '0')->start( + $result = WorkflowStub::make(TracingProcessWorkflow::class, 'cold-tracing', '0')->start( $retry, new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), ); + if ($admissionProbe) { + $admissionResult = [ + 'command_id' => $result->commandId(), + 'outcome' => $result->outcome(), + ]; + } } } elseif ($mode === 'update' && $updateLinks) { $step = getenv('DW_TEST_TRACING_UPDATE_STEP'); @@ -662,12 +731,27 @@ public function extract( if (! $signalStartResult->returnedExistingActive()) { throw new \RuntimeException('Expected the original signal-with-start admission.'); } + } elseif ($mode === 'reject' && $admissionProbe) { + $result = WorkflowStub::make(TracingProcessWorkflow::class, 'cold-tracing', '0')->attemptStart($retry); + if (! $result->rejected()) { + throw new \RuntimeException('Expected an ordinary duplicate refusal.'); + } + $admissionResult = [ + 'command_id' => $result->commandId(), + 'outcome' => $result->outcome(), + ]; } elseif ($mode === 'duplicate') { $result = WorkflowStub::make(TracingProcessWorkflow::class, 'cold-tracing', '0') ->attemptStart($retry, StartOptions::returnExistingActive()); if (! $result->returnedExistingActive()) { throw new \RuntimeException('Expected the original active admission.'); } + if ($admissionProbe) { + $admissionResult = [ + 'command_id' => $result->commandId(), + 'outcome' => $result->outcome(), + ]; + } } elseif ($mode === 'bridge-fail' && $childRetry) { $child = WorkflowRun::query()->where('workflow_class', TracingRetryChildWorkflow::class) ->where('run_number', 1) @@ -715,6 +799,9 @@ public function extract( } elseif ($mode !== 'inspect') { throw new \RuntimeException('Unknown fixture command.'); } + if ($admissionProbe) { + $admissionContextPreserved = Context::getCurrent() === $parentContext; + } } finally { $scope->detach(); $requestSpan?->end(); @@ -923,6 +1010,24 @@ public function extract( ->count(), ]; } + if ($admissionProbe) { + $history = $run->historyEvents() + ->orderBy('sequence') + ->get(); + $output['admission'] = [ + 'result' => $admissionResult, + 'context_preserved' => $admissionContextPreserved, + 'boundaries' => $admissionProcessor->boundaries, + 'callbacks' => $admissionCallbacks, + 'capture_transaction_levels' => $recordedPropagator->transactionLevels, + 'history_hashes' => $history->map(static function ($event): string { + return hash('sha256', json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR)); + })->all(), + 'history_payloads' => $history->pluck('payload') + ->all(), + 'run_count' => WorkflowRun::query()->count(), + ]; + } if ($signalLinks) { $history = $run->historyEvents() ->orderBy('sequence') diff --git a/tests/Unit/V2/ActivityExecutionTracingTest.php b/tests/Unit/V2/ActivityExecutionTracingTest.php index 221fbcf7f..5a04f1885 100644 --- a/tests/Unit/V2/ActivityExecutionTracingTest.php +++ b/tests/Unit/V2/ActivityExecutionTracingTest.php @@ -247,8 +247,22 @@ public function testUnreadableOptionalRunMetadataCannotInterruptClaimedBusinessW self::assertSame($prefix, array_slice($history, 0, count($prefix))); self::assertSame($enabled ? ($phase === 'workflow' ? 3 : 2) : 0, UnreadableTraceCast::$metadataReads); $spans = $this->exporter->getSpans(); - self::assertCount($enabled ? 3 : 0, $spans); - foreach ($spans as $index => $span) { + self::assertCount($enabled ? 4 : 0, $spans); + $admissions = array_values(array_filter( + $spans, + static fn ($span): bool => $span->getName() === 'durable.workflow.admission' + )); + self::assertCount($enabled ? 1 : 0, $admissions); + if ($enabled) { + self::assertSame($run->id, $admissions[0]->getAttributes()->get('workflow_run_id')); + self::assertSame('00f067aa0ba902b7', $admissions[0]->getParentContext()->getSpanId()); + } + $processing = array_values(array_filter( + $spans, + static fn ($span): bool => $span->getName() !== 'durable.workflow.admission' + )); + self::assertCount($enabled ? 3 : 0, $processing); + foreach ($processing as $index => $span) { self::assertSame($run->id, $span->getAttributes()->get('workflow_run_id')); self::assertSame($phase === 'activity' && $index === 0, $span->getParentContext()->isValid()); } @@ -287,14 +301,16 @@ public function onEnd(ReadableSpanInterface $span): void $stub->start(); $this->workOnce(); self::assertSame('failed', WorkflowRun::query()->findOrFail($stub->runId())->status->value); - self::assertSame([0, 0], $processor->transactionLevels); + self::assertSame([0, 0, 0, 0], $processor->transactionLevels); $spans = $this->exporter->getSpans(); - self::assertCount(1, $spans); - self::assertSame('durable.workflow.task', $spans[0]->getName()); - self::assertSame(StatusCode::STATUS_ERROR, $spans[0]->getStatus()->getCode()); - self::assertSame(RuntimeException::class, $spans[0]->getAttributes()->get('error.type')); + self::assertCount(2, $spans); + self::assertSame('durable.workflow.admission', $spans[0]->getName()); + self::assertSame('started_new', $spans[0]->getAttributes()->get('durable.command.outcome')); + self::assertSame('durable.workflow.task', $spans[1]->getName()); + self::assertSame(StatusCode::STATUS_ERROR, $spans[1]->getStatus()->getCode()); + self::assertSame(RuntimeException::class, $spans[1]->getAttributes()->get('error.type')); self::assertSame($stub->runId(), FailingTracedWorkflow::$fields['workflow_run_id']); - self::assertSame($spans[0]->getContext()->getSpanId(), FailingTracedWorkflow::$fields['span_id']); + self::assertSame($spans[1]->getContext()->getSpanId(), FailingTracedWorkflow::$fields['span_id']); self::assertSame([], ExecutionTracing::current()); self::assertSame(0, DB::table('failed_jobs')->count()); } finally { diff --git a/tests/Unit/V2/AdmissionExecutionTracingTest.php b/tests/Unit/V2/AdmissionExecutionTracingTest.php new file mode 100644 index 000000000..6d43df99c --- /dev/null +++ b/tests/Unit/V2/AdmissionExecutionTracingTest.php @@ -0,0 +1,172 @@ + [true, false], + 'disabled control' => [false, false], + 'caller without parent' => [true, true], + ]; + } + + #[DataProvider('modes')] + public function testOrdinaryAdmissionDecisionsPreserveTheCallerAndOriginalDurableParent( + bool $enabled, + bool $withoutParent, + ): void { + $directory = sys_get_temp_dir() . '/dw-tracing-admission-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static function (string $mode) use ($directory, $enabled, $withoutParent): array { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_ADMISSION' => 'true', + 'DW_TEST_TRACING_ADMISSION_NO_PARENT' => $withoutParent ? 'true' : 'false', + 'DW_TEST_TRACING_ENABLED' => $enabled ? 'true' : 'false', + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + self::assertJson($process->getOutput(), substr($process->getOutput(), -1500)); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + self::assertTrue($result['admission']['context_preserved']); + + return $result; + }; + try { + $admitted = $worker('admit'); + $duplicate = $worker('duplicate'); + $rejected = $worker('reject'); + $workers = [$worker('work'), $worker('work'), $worker('work')]; + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame(1, $finished['admission']['run_count']); + self::assertSame(3, $finished['tasks']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + foreach ([$duplicate, $rejected, $finished] as $observation) { + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { + self::assertSame($admitted[$field], $observation[$field]); + } + self::assertSame($admitted['admission']['history_hashes'], array_slice( + $observation['admission']['history_hashes'], + 0, + count($admitted['admission']['history_hashes']) + )); + } + foreach ([$admitted, $duplicate, $rejected] as $index => $attempt) { + self::assertSame($enabled ? [0] : [], $attempt['admission']['capture_transaction_levels']); + self::assertCount($enabled ? 1 : 0, $attempt['spans']); + self::assertSame( + ['started_new', 'returned_existing_active', 'rejected_duplicate'][$index], + $attempt['admission']['result']['outcome'] + ); + if (! $enabled) { + self::assertSame([], $attempt['admission']['boundaries']); + continue; + } + self::assertSame([ + [ + 'event' => 'start', + 'transaction_level' => 0, + ], + [ + 'event' => 'end', + 'transaction_level' => 0, + ], + ], $attempt['admission']['boundaries']); + $span = $attempt['spans'][0]; + self::assertSame('durable.workflow.admission', $span['name']); + self::assertSame(SpanKind::KIND_INTERNAL, $span['kind']); + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + self::assertSame('cold-tracing', $span['fields']['workflow_instance_id']); + self::assertSame('0', $span['fields']['namespace']); + self::assertSame($attempt['admission']['result']['command_id'], $span['fields']['workflow_command_id']); + self::assertSame( + $attempt['admission']['result']['outcome'], + $span['fields']['durable.command.outcome'] + ); + self::assertSame($index === 2 ? 'rejected' : 'accepted', $span['fields']['durable.command.status']); + if ($index === 2) { + self::assertSame('instance_already_started', $span['fields']['durable.command.rejection_reason']); + } + if ($withoutParent) { + self::assertSame('0000000000000000', $span['parent_id']); + } else { + self::assertSame($index === 0 ? '4bf92f3577b34da6a3ce929d0e0e4736' + : 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $span['trace_id']); + self::assertSame($index === 0 ? '00f067aa0ba902b7' : 'bbbbbbbbbbbbbbbb', $span['parent_id']); + } + } + self::assertCount(1, $admitted['admission']['callbacks']); + $callback = $admitted['admission']['callbacks'][0]; + self::assertSame(0, $callback['transaction_level']); + self::assertSame('caller-scope', $callback['foreign']); + if ($enabled) { + self::assertSame($admitted['spans'][0]['span_id'], $callback['span_id']); + self::assertSame('cold-tracing', $callback['fields']['workflow_instance_id']); + } else { + self::assertSame([], $callback['fields']); + } + self::assertSame([], $duplicate['admission']['callbacks']); + self::assertSame([], $rejected['admission']['callbacks']); + if ($enabled && ! $withoutParent) { + $carrier = json_decode($admitted['carrier'], true, flags: JSON_THROW_ON_ERROR)['carrier']; + self::assertSame('00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', $carrier['traceparent']); + self::assertSame('approved=origin', $carrier['baggage']); + } else { + self::assertNull($admitted['carrier']); + } + foreach ($workers as $workerResult) { + self::assertCount($enabled ? 1 : 0, $workerResult['spans']); + if ($enabled) { + $span = $workerResult['spans'][0]; + self::assertSame($withoutParent ? '0000000000000000' : '00f067aa0ba902b7', $span['parent_id']); + self::assertNotSame($admitted['spans'][0]['span_id'], $span['parent_id']); + } + } + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); + $callbacks = array_values( + array_filter($records, static fn (array $record): bool => $record['message'] === 'admission callback') + ); + self::assertCount(1, $callbacks); + self::assertSame('application-request', $callbacks[0]['context']['request_id']); + self::assertStringNotContainsString( + 'durable.command.outcome', + json_encode($finished['admission']['history_payloads'], JSON_THROW_ON_ERROR) + ); + self::assertCount(3, array_unique(array_column($workers, 'pid'))); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } +} diff --git a/tests/Unit/V2/ExecutionTracingTest.php b/tests/Unit/V2/ExecutionTracingTest.php index 370c9437c..786489bb8 100644 --- a/tests/Unit/V2/ExecutionTracingTest.php +++ b/tests/Unit/V2/ExecutionTracingTest.php @@ -10,7 +10,9 @@ use Monolog\Logger; use OpenTelemetry\API\Globals; use OpenTelemetry\API\Instrumentation\Configurator; +use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator; use OpenTelemetry\API\Trace\Span; +use OpenTelemetry\API\Trace\SpanKind; use OpenTelemetry\API\Trace\StatusCode; use OpenTelemetry\Context\Context; use OpenTelemetry\Context\ContextInterface; @@ -23,7 +25,9 @@ use RuntimeException; use Symfony\Component\Process\Process; use Workflow\V2\Models\WorkflowCommand; +use Workflow\V2\Models\WorkflowInstance; use Workflow\V2\Models\WorkflowRun; +use Workflow\V2\StartResult; use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\WorkflowExecution; use Workflow\V2\Tracing\LogContext; @@ -299,6 +303,98 @@ public function testDisabledInstrumentationLeavesTheApplicationContextUntouched( self::assertSame([], $this->exporter->getSpans()); } + public function testAdmissionPreservesTheApplicationParentProviderAndOriginalException(): void + { + $instance = new WorkflowInstance(); + $instance->setRawAttributes([ + 'id' => 'instance-1', + 'workflow_type' => 'test.workflow', + 'namespace' => '0', + ], true); + $foreign = Context::createKey('application-admission-state'); + $parent = TraceContextPropagator::getInstance()->extract( + self::carrier()['carrier'], + context: Context::getCurrent() + )->with($foreign, 'application-value'); + $scope = $parent->activate(); + $error = new RuntimeException('private-original-admission-error'); + $calls = 0; + try { + try { + ExecutionTracing::admission($instance, function () use ($foreign, $error, &$calls): never { + ++$calls; + self::assertSame($this->provider, Globals::tracerProvider()); + self::assertSame('application-value', Context::getCurrent()->get($foreign)); + self::assertSame('instance-1', ExecutionTracing::current()['workflow_instance_id']); + throw $error; + }, enabled: true); + self::fail('Admission must preserve the original exception.'); + } catch (RuntimeException $observed) { + self::assertSame($error, $observed); + } + self::assertSame($parent, Context::getCurrent()); + self::assertSame([], ExecutionTracing::current()); + } finally { + $scope->detach(); + } + self::assertSame(1, $calls); + $spans = $this->exporter->getSpans(); + self::assertCount(1, $spans); + self::assertSame(SpanKind::KIND_INTERNAL, $spans[0]->getKind()); + self::assertSame(self::TRACE_ID, $spans[0]->getContext()->getTraceId()); + self::assertSame(self::PARENT_ID, $spans[0]->getParentContext()->getSpanId()); + self::assertSame(StatusCode::STATUS_ERROR, $spans[0]->getStatus()->getCode()); + self::assertSame(RuntimeException::class, $spans[0]->getAttributes()->get('error.type')); + self::assertSame([], $spans[0]->getEvents()); + self::assertStringNotContainsString('private-original', $spans[0]->getStatus()->getDescription()); + } + + public function testAdmissionOptionalIdentityAndReceiptReadsCannotReplaceTheResult(): void + { + $instance = new class() extends WorkflowInstance { + public int $reads = 0; + + public function getRawOriginal($key = null, $default = null): mixed + { + ++$this->reads; + throw new RuntimeException('private-identity-read'); + } + }; + $command = new class() extends WorkflowCommand { + public int $reads = 0; + + public function getAttribute($key): mixed + { + ++$this->reads; + throw new RuntimeException('private-receipt-read'); + } + }; + $receipt = StartResult::fromCommand($command); + $calls = 0; + $operation = static function () use (&$calls, $receipt): StartResult { + ++$calls; + + return $receipt; + }; + self::assertSame($receipt, ExecutionTracing::admission($instance, $operation)); + self::assertSame(0, $instance->reads); + self::assertSame(0, $command->reads); + self::assertSame([], $this->exporter->getSpans()); + self::assertSame($receipt, ExecutionTracing::admission($instance, $operation, enabled: true)); + self::assertSame(2, $calls); + self::assertSame(1, $instance->reads); + self::assertSame(1, $command->reads); + $spans = $this->exporter->getSpans(); + self::assertCount(1, $spans); + self::assertNull($spans[0]->getAttributes()->get('workflow_instance_id')); + self::assertNull($spans[0]->getAttributes()->get('durable.command.outcome')); + self::assertSame([], $spans[0]->getEvents()); + self::assertStringNotContainsString( + 'private-', + json_encode($spans[0]->getAttributes()->toArray(), JSON_THROW_ON_ERROR) + ); + } + public function testOptionalApiAbsenceStillExecutesTheCallbackExactlyOnce(): void { $script = <<<'PHP' diff --git a/tests/Unit/V2/ExporterExecutionTracingTest.php b/tests/Unit/V2/ExporterExecutionTracingTest.php index a94044194..fa8243275 100644 --- a/tests/Unit/V2/ExporterExecutionTracingTest.php +++ b/tests/Unit/V2/ExporterExecutionTracingTest.php @@ -65,13 +65,20 @@ public function testCollectorFailureDoesNotChangeColdWorkflowCompletion(string $ self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); } $requests = $this->requests($directory); - self::assertCount($mode === 'refused' ? 0 : 3, $requests); + self::assertCount($mode === 'refused' ? 0 : 4, $requests); foreach ($requests as $request) { self::assertSame('POST', $request['method']); self::assertSame('/v1/traces', $request['path']); self::assertSame('application/x-protobuf', $request['content_type']); self::assertCount(1, $request['spans']); } + if ($mode !== 'refused') { + self::assertSame(['durable.workflow.admission', 'durable.workflow.task', + 'durable.activity.attempt', 'durable.workflow.task'], array_map( + static fn (array $request): string => $request['spans'][0]['name'], + $requests + )); + } } finally { $server->stop(); (new Filesystem())->deleteDirectory($directory); diff --git a/tests/Unit/V2/IncomingHttpTracingTest.php b/tests/Unit/V2/IncomingHttpTracingTest.php index e4410ea95..25c4dd4f5 100644 --- a/tests/Unit/V2/IncomingHttpTracingTest.php +++ b/tests/Unit/V2/IncomingHttpTracingTest.php @@ -52,11 +52,27 @@ public function testRequestContextSurvivesServerExitAndTwoIndependentActivityWor self::assertFalse($server->isRunning()); self::assertSame([], $admitted['outside_context']); self::assertTrue($admitted['scope_restored']); - self::assertCount(1, $admitted['spans']); - $requestSpan = $admitted['spans'][0]; + self::assertCount(2, $admitted['spans']); + $requestSpans = array_values(array_filter( + $admitted['spans'], + static fn (array $span): bool => $span['kind'] === SpanKind::KIND_SERVER + )); + $admissionSpans = array_values(array_filter( + $admitted['spans'], + static fn (array $span): bool => $span['name'] === 'durable.workflow.admission' + )); + self::assertCount(1, $requestSpans); + self::assertCount(1, $admissionSpans); + $requestSpan = $requestSpans[0]; + $admissionSpan = $admissionSpans[0]; self::assertSame(SpanKind::KIND_SERVER, $requestSpan['kind']); self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $requestSpan['trace_id']); self::assertSame('00f067aa0ba902b7', $requestSpan['parent_id']); + self::assertSame(SpanKind::KIND_INTERNAL, $admissionSpan['kind']); + self::assertSame($requestSpan['trace_id'], $admissionSpan['trace_id']); + self::assertSame($requestSpan['span_id'], $admissionSpan['parent_id']); + self::assertSame($admitted['run_id'], $admissionSpan['fields']['workflow_run_id']); + self::assertSame('started_new', $admissionSpan['fields']['durable.command.outcome']); $carrier = json_decode($admitted['carrier'], true, flags: JSON_THROW_ON_ERROR)['carrier']; self::assertSame( '00-' . $requestSpan['trace_id'] . '-' . $requestSpan['span_id'] . '-01', From 9e9403aa308b3621a16d7e14843501fa39bd6c98 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 18:33:44 +0000 Subject: [PATCH 25/77] feat: trace signal-with-start admission decisions --- docs/embedded-tracing.md | 15 ++- src/V2/Support/ExecutionTracing.php | 19 +++- src/V2/WorkflowStub.php | 38 +++++-- tests/Fixtures/V2/tracing-process.php | 20 +++- tests/Unit/V2/ExecutionTracingTest.php | 84 ++++++++++++++ .../V2/SignalStartExecutionTracingTest.php | 107 ++++++++++++++++-- 6 files changed, 258 insertions(+), 25 deletions(-) diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index bccef81e1..d9c71c97f 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -109,8 +109,16 @@ attempts have their own admission spans and keep the original run carrier and deadlines. Span creation and completion occur outside the admission transaction. The application owns processors, so a synchronous processor can still delay its caller. The bounded-export guidance above also applies to admission spans. -Signal-with-start, service admission, schedules and wider runtime coverage have -no admission-span qualification yet. + +Embedded signal-with-start uses the same admission span. The primary command +attributes describe its signal receipt. If a start command was actually +recorded, `durable.start.command_id`, `durable.start.status` and +`durable.start.outcome` identify that paired decision. A rejected signal can +have no paired start command; tracing does not invent one. Each signal keeps +its captured caller for later delivery links, while an existing run keeps its +original parent and deadlines. Legacy and no-parent admissions are included in +the cold-process controls. Service admission, schedules and wider runtime +coverage have no admission-span qualification yet. Workflow processing and activity attempts receive separate spans. Persisted admission context survives the qualified cold-worker, child, continuation and @@ -163,7 +171,8 @@ new signal-application link. ```mermaid flowchart LR - A[Captured admission span] -->|parent| W[Workflow processing segment] + A[Captured caller] -->|parent| D[Admission operation] + A -->|parent| W[Workflow processing segment] A -->|parent| X[Activity attempt] B[Independent signal caller] -.->|link on application| W X -->|parent| H[Instrumented HTTP client] diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php index 018caaddc..9214b6b49 100644 --- a/src/V2/Support/ExecutionTracing.php +++ b/src/V2/Support/ExecutionTracing.php @@ -19,11 +19,13 @@ use OpenTelemetry\Context\ScopeInterface; use Throwable; use WeakMap; +use Workflow\V2\CommandResult; use Workflow\V2\Enums\CommandStatus; use Workflow\V2\Enums\CommandType; use Workflow\V2\Models\WorkflowCommand; use Workflow\V2\Models\WorkflowInstance; use Workflow\V2\Models\WorkflowRun; +use Workflow\V2\SignalWithStartResult; use Workflow\V2\StartResult; /** @internal */ @@ -332,7 +334,8 @@ private static function segment( try { $result = $operation(); - if ($name === 'durable.workflow.admission' && $result instanceof StartResult && $span !== null) { + if ($name === 'durable.workflow.admission' + && ($result instanceof StartResult || $result instanceof SignalWithStartResult) && $span !== null) { try { self::recordAdmissionResult($span, $result); } catch (Throwable) { @@ -368,7 +371,7 @@ private static function fields(array $attributes): array return $fields; } - private static function recordAdmissionResult(SpanInterface $span, StartResult $result): void + private static function recordAdmissionResult(SpanInterface $span, CommandResult $result): void { $attributes = self::fields([ 'workflow_instance_id' => $result->instanceId(), @@ -376,12 +379,20 @@ private static function recordAdmissionResult(SpanInterface $span, StartResult $ 'workflow_type' => $result->workflowType(), 'workflow_command_id' => $result->commandId(), ]); - foreach ([ + $decisions = [ 'durable.command.type' => $result->type(), 'durable.command.status' => $result->status(), 'durable.command.outcome' => $result->outcome(), 'durable.command.rejection_reason' => $result->rejectionReason(), - ] as $key => $value) { + ]; + if ($result instanceof SignalWithStartResult) { + $decisions += [ + 'durable.start.command_id' => $result->startCommandId(), + 'durable.start.status' => $result->startStatus(), + 'durable.start.outcome' => $result->startOutcome(), + ]; + } + foreach ($decisions as $key => $value) { if (is_string($value) && $value !== '' && strlen($value) <= 256 && ! preg_match('/[\x00-\x1f\x7f]/', $value)) { $attributes[$key] = $value; diff --git a/src/V2/WorkflowStub.php b/src/V2/WorkflowStub.php index 67ac8aefa..675ed3309 100644 --- a/src/V2/WorkflowStub.php +++ b/src/V2/WorkflowStub.php @@ -3087,19 +3087,43 @@ private function attemptSignalWithStartInternal( $this->refresh(); - /** @var WorkflowCommand|null $startCommand */ - $startCommand = null; - /** @var WorkflowCommand|null $signalCommand */ - $signalCommand = null; - $task = null; - $startedRun = null; $intakeGroupId = (string) Str::ulid(); // Capture before locking. An existing run retains its original admission carrier. $observabilityContext = null; - if (config('workflows.tracing.enabled', false) === true) { + $tracing = config('workflows.tracing.enabled', false) === true; + if ($tracing) { $allowlist = config('workflows.tracing.baggage_allowlist', []); $observabilityContext = TraceCarrier::capture(is_array($allowlist) ? $allowlist : []); } + $operation = fn (): SignalWithStartResult => $this->admitSignalWithStart( + $name, + $signalArguments, + $startArguments, + $startOptions, + $intakeGroupId, + $observabilityContext, + ); + if (! $tracing) { + return $operation(); + } + + return ExecutionTracing::admission($this->instance, $operation, enabled: true); + } + + private function admitSignalWithStart( + string $name, + array $signalArguments, + array $startArguments, + StartOptions $startOptions, + string $intakeGroupId, + ?array $observabilityContext, + ): SignalWithStartResult { + /** @var WorkflowCommand|null $startCommand */ + $startCommand = null; + /** @var WorkflowCommand|null $signalCommand */ + $signalCommand = null; + $task = null; + $startedRun = null; DB::transaction(function () use ( $name, diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 392772117..5faf476b2 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -71,7 +71,8 @@ $signalLinks = getenv('DW_TEST_TRACING_SIGNAL_LINKS') === 'true'; $updateLinks = getenv('DW_TEST_TRACING_UPDATE_LINKS') === 'true'; $timings = getenv('DW_TEST_TRACING_TIMINGS') === 'true'; -$admissionProbe = getenv('DW_TEST_TRACING_ADMISSION') === 'true'; +$admissionProbe = getenv('DW_TEST_TRACING_ADMISSION') === 'true' + || getenv('DW_TEST_TRACING_SIGNAL_START_ADMISSION') === 'true'; $childRetry = getenv('DW_TEST_TRACING_CHILD_RETRY') === 'true'; $cancellation = getenv('DW_TEST_TRACING_CANCELLATION'); foreach (array_slice($argv, 4) as $autoload) { @@ -535,7 +536,7 @@ public function extract( $headers['tracestate'] = 'dwtest=one'; $headers['baggage'] = 'approved=tenant-a,private=must-not-propagate'; } - if ($admissionProbe && in_array($mode, ['admit', 'duplicate', 'reject'], true)) { + if ($admissionProbe && in_array($mode, ['admit', 'duplicate', 'reject', 'reject-signal-start'], true)) { $headers['traceparent'] = $mode === 'admit' ? '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' : '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01'; @@ -731,6 +732,12 @@ public function extract( if (! $signalStartResult->returnedExistingActive()) { throw new \RuntimeException('Expected the original signal-with-start admission.'); } + } elseif ($mode === 'reject-signal-start' && $signalStart) { + $signalStartResult = WorkflowStub::make(TracingSignalStartWorkflow::class, 'cold-tracing', '0') + ->attemptSignalWithStart('notDeclared', ['refused-admission-private']); + if (! $signalStartResult->rejected()) { + throw new \RuntimeException('Expected an undeclared signal-with-start refusal.'); + } } elseif ($mode === 'reject' && $admissionProbe) { $result = WorkflowStub::make(TracingProcessWorkflow::class, 'cold-tracing', '0')->attemptStart($retry); if (! $result->rejected()) { @@ -855,6 +862,15 @@ public function extract( if ($signalStart) { $history = WorkflowHistoryEvent::query()->where('workflow_run_id', $run->id)->orderBy('sequence')->get(); $output['signal_start_outcome'] = $signalStartResult?->startOutcome(); + $output['signal_start_result'] = $signalStartResult === null ? null : [ + 'command_id' => $signalStartResult->commandId(), + 'status' => $signalStartResult->status(), + 'outcome' => $signalStartResult->outcome(), + 'rejection_reason' => $signalStartResult->rejectionReason(), + 'start_command_id' => $signalStartResult->startCommandId(), + 'start_status' => $signalStartResult->startStatus(), + 'start_outcome' => $signalStartResult->startOutcome(), + ]; $output['capture_transaction_levels'] = $recordedPropagator?->transactionLevels ?? []; $output['run_count'] = WorkflowRun::query()->count(); $output['signal_count'] = $history->where('event_type.value', 'SignalReceived')->count(); diff --git a/tests/Unit/V2/ExecutionTracingTest.php b/tests/Unit/V2/ExecutionTracingTest.php index 786489bb8..a12699cce 100644 --- a/tests/Unit/V2/ExecutionTracingTest.php +++ b/tests/Unit/V2/ExecutionTracingTest.php @@ -27,6 +27,7 @@ use Workflow\V2\Models\WorkflowCommand; use Workflow\V2\Models\WorkflowInstance; use Workflow\V2\Models\WorkflowRun; +use Workflow\V2\SignalWithStartResult; use Workflow\V2\StartResult; use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\WorkflowExecution; @@ -395,6 +396,89 @@ public function getAttribute($key): mixed ); } + public function testSignalWithStartAdmissionReadsOnlyTheRecordedReceipts(): void + { + $instance = new WorkflowInstance(); + $signal = new class() extends WorkflowCommand { + public function getAttribute($key): mixed + { + if (in_array($key, ['payload', 'observability_context'], true)) { + throw new RuntimeException('Business data is not admission telemetry.'); + } + + return parent::getAttribute($key); + } + }; + $signal->setRawAttributes([ + 'id' => 'signal-1', + 'workflow_instance_id' => 'instance-1', + 'workflow_run_id' => 'run-1', + 'workflow_type' => 'test.workflow', + 'command_type' => 'signal', + 'status' => 'accepted', + 'outcome' => 'signal_received', + 'payload' => 'private-command-body', + 'observability_context' => 'private-command-carrier', + ], true); + $start = clone $signal; + $start->setRawAttributes(array_replace($signal->getRawOriginal(), [ + 'id' => 'start-1', + 'command_type' => 'start', + 'outcome' => 'started_new', + ]), true); + $original = [$signal->getRawOriginal(), $start->getRawOriginal()]; + $result = SignalWithStartResult::fromCommands($signal, $start); + self::assertSame($result, ExecutionTracing::admission($instance, static fn () => $result, enabled: true)); + $span = $this->exporter->getSpans()[0]; + foreach ([ + 'workflow_command_id' => 'signal-1', + 'durable.command.type' => 'signal', + 'durable.command.status' => 'accepted', + 'durable.command.outcome' => 'signal_received', + 'durable.start.command_id' => 'start-1', + 'durable.start.status' => 'accepted', + 'durable.start.outcome' => 'started_new', + ] as $key => $value) { + self::assertSame($value, $span->getAttributes()->get($key)); + } + self::assertSame($original, [$signal->getRawOriginal(), $start->getRawOriginal()]); + self::assertStringNotContainsString( + 'private-command', + json_encode($span->getAttributes()->toArray(), JSON_THROW_ON_ERROR) + ); + } + + public function testOptionalPairedStartReceiptFailureCannotReplaceSignalAdmission(): void + { + $signal = self::signalCommand(null); + $signal->outcome = 'signal_received'; + $start = new class() extends WorkflowCommand { + public function getAttribute($key): mixed + { + throw new RuntimeException('private-start-receipt-error'); + } + }; + $result = SignalWithStartResult::fromCommands($signal, $start); + $calls = 0; + self::assertSame($result, ExecutionTracing::admission(new WorkflowInstance(), static function () use ( + &$calls, + $result + ) { + ++$calls; + + return $result; + }, enabled: true)); + self::assertSame(1, $calls); + $spans = $this->exporter->getSpans(); + self::assertCount(1, $spans); + self::assertSame([], $spans[0]->getEvents()); + self::assertNotSame(StatusCode::STATUS_ERROR, $spans[0]->getStatus()->getCode()); + self::assertStringNotContainsString( + 'private-start-receipt', + json_encode($spans[0]->getAttributes()->toArray(), JSON_THROW_ON_ERROR) + ); + } + public function testOptionalApiAbsenceStillExecutesTheCallbackExactlyOnce(): void { $script = <<<'PHP' diff --git a/tests/Unit/V2/SignalStartExecutionTracingTest.php b/tests/Unit/V2/SignalStartExecutionTracingTest.php index 30408d37f..c883b0a8b 100644 --- a/tests/Unit/V2/SignalStartExecutionTracingTest.php +++ b/tests/Unit/V2/SignalStartExecutionTracingTest.php @@ -6,6 +6,7 @@ use Composer\Autoload\ClassLoader; use Illuminate\Filesystem\Filesystem; +use OpenTelemetry\API\Trace\SpanKind; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\Process\Process; @@ -15,9 +16,10 @@ final class SignalStartExecutionTracingTest extends TestCase public static function modes(): array { return [ - 'traced admission' => [true, true], - 'disabled control' => [false, false], - 'legacy admission, enabled later' => [false, true], + 'traced admission' => [true, true, false], + 'disabled control' => [false, false, false], + 'legacy admission, enabled later' => [false, true, false], + 'no application parent' => [true, true, true], ]; } @@ -25,10 +27,16 @@ public static function modes(): array public function testSignalWithStartKeepsItsOriginalTraceAcrossAnotherCallerAndColdWorkers( bool $admissionEnabled, bool $workerEnabled, + bool $withoutParent, ): void { $directory = sys_get_temp_dir() . '/dw-tracing-signal-' . bin2hex(random_bytes(8)); mkdir($directory, 0700); - $worker = static function (string $mode) use ($directory, $admissionEnabled, $workerEnabled): array { + $worker = static function (string $mode) use ( + $directory, + $admissionEnabled, + $workerEnabled, + $withoutParent + ): array { $packageVendor = dirname(__DIR__, 3) . '/vendor'; $otherVendors = array_filter( array_keys(ClassLoader::getRegisteredLoaders()), @@ -42,6 +50,8 @@ public function testSignalWithStartKeepsItsOriginalTraceAcrossAnotherCallerAndCo ), ], env: [ 'DW_TEST_TRACING_SIGNAL_START' => 'true', + 'DW_TEST_TRACING_SIGNAL_START_ADMISSION' => 'true', + 'DW_TEST_TRACING_ADMISSION_NO_PARENT' => $withoutParent ? 'true' : 'false', 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admissionEnabled : $workerEnabled) ? 'true' : 'false', ]); $process->setTimeout(15); @@ -50,14 +60,16 @@ public function testSignalWithStartKeepsItsOriginalTraceAcrossAnotherCallerAndCo $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); self::assertSame([], $result['outside_context']); self::assertTrue($result['scope_restored']); + self::assertTrue($result['admission']['context_preserved']); return $result; }; try { $admitted = $worker('admit'); self::assertSame('started_new', $admitted['signal_start_outcome']); - self::assertSame($admissionEnabled, $admitted['carrier'] !== null); - self::assertSame($admissionEnabled, $admitted['signal_commands'][0]['carrier'] !== null); + $originalHasParent = $admissionEnabled && ! $withoutParent; + self::assertSame($originalHasParent, $admitted['carrier'] !== null); + self::assertSame($originalHasParent, $admitted['signal_commands'][0]['carrier'] !== null); self::assertSame($admissionEnabled ? [0] : [], $admitted['capture_transaction_levels']); $existing = $worker('duplicate'); self::assertSame('returned_existing_active', $existing['signal_start_outcome']); @@ -67,11 +79,24 @@ public function testSignalWithStartKeepsItsOriginalTraceAcrossAnotherCallerAndCo } self::assertSame(2, $existing['signal_count']); self::assertSame($admitted['signal_commands'][0], $existing['signal_commands'][0]); - self::assertSame($workerEnabled, $existing['signal_commands'][1]['carrier'] !== null); + self::assertSame($workerEnabled && ! $withoutParent, $existing['signal_commands'][1]['carrier'] !== null); self::assertSame( $admitted['history_hashes'], array_slice($existing['history_hashes'], 0, count($admitted['history_hashes'])) ); + $rejected = $worker('reject-signal-start'); + self::assertSame('rejected', $rejected['signal_start_result']['status']); + self::assertSame('unknown_signal', $rejected['signal_start_result']['rejection_reason']); + self::assertNull($rejected['signal_start_result']['start_command_id']); + self::assertSame(2, $rejected['signal_count']); + self::assertSame($existing['signal_commands'], array_slice($rejected['signal_commands'], 0, 2)); + self::assertCount(3, $rejected['signal_commands']); + self::assertNull($rejected['signal_commands'][2]['carrier']); + self::assertSame($existing['history_hashes'], array_slice( + $rejected['history_hashes'], + 0, + count($existing['history_hashes']) + )); $workers = []; for ($remaining = 6; $remaining > 0; --$remaining) { $state = $worker('inspect'); @@ -91,9 +116,10 @@ public function testSignalWithStartKeepsItsOriginalTraceAcrossAnotherCallerAndCo self::assertSame(0, $finished['repairs']); self::assertSame(0, $finished['jobs']); self::assertSame(0, $finished['failed_jobs']); - self::assertSame($existing['signal_commands'], $finished['signal_commands']); + self::assertSame($rejected['signal_commands'], $finished['signal_commands']); foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { self::assertSame($admitted[$key], $existing[$key]); + self::assertSame($admitted[$key], $rejected[$key]); self::assertSame($admitted[$key], $finished[$key]); } self::assertSame( @@ -105,7 +131,7 @@ public function testSignalWithStartKeepsItsOriginalTraceAcrossAnotherCallerAndCo self::assertCount($workerEnabled ? 3 : 0, $spans); foreach ($spans as $span) { self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); - if ($admissionEnabled) { + if ($originalHasParent) { self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); self::assertSame('00f067aa0ba902b7', $span['parent_id']); } else { @@ -117,6 +143,69 @@ public function testSignalWithStartKeepsItsOriginalTraceAcrossAnotherCallerAndCo 'observability_context', json_encode($finished['history_payloads'], JSON_THROW_ON_ERROR) ); + foreach ([$admitted, $existing, $rejected] as $index => $attempt) { + $enabled = $index === 0 ? $admissionEnabled : $workerEnabled; + self::assertSame($enabled ? [0] : [], $attempt['capture_transaction_levels']); + self::assertCount($enabled ? 1 : 0, $attempt['spans']); + if (! $enabled) { + self::assertSame([], $attempt['admission']['boundaries']); + continue; + } + self::assertSame([ + [ + 'event' => 'start', + 'transaction_level' => 0, + ], + [ + 'event' => 'end', + 'transaction_level' => 0, + ], + ], $attempt['admission']['boundaries']); + $span = $attempt['spans'][0]; + $receipt = $attempt['signal_start_result']; + self::assertSame('durable.workflow.admission', $span['name']); + self::assertSame(SpanKind::KIND_INTERNAL, $span['kind']); + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + self::assertSame('0', $span['fields']['namespace']); + self::assertSame($receipt['command_id'], $span['fields']['workflow_command_id']); + self::assertSame('signal', $span['fields']['durable.command.type']); + self::assertSame($receipt['status'], $span['fields']['durable.command.status']); + self::assertSame($receipt['outcome'], $span['fields']['durable.command.outcome']); + if ($index === 2) { + self::assertSame('unknown_signal', $span['fields']['durable.command.rejection_reason']); + foreach (['command_id', 'status', 'outcome'] as $field) { + self::assertArrayNotHasKey('durable.start.' . $field, $span['fields']); + } + } else { + foreach (['command_id', 'status', 'outcome'] as $field) { + self::assertSame($receipt['start_' . $field], $span['fields']['durable.start.' . $field]); + } + } + self::assertSame($withoutParent ? '0000000000000000' + : ($index === 0 ? '00f067aa0ba902b7' : 'bbbbbbbbbbbbbbbb'), $span['parent_id']); + if (! $withoutParent) { + self::assertSame($index === 0 ? '4bf92f3577b34da6a3ce929d0e0e4736' + : 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $span['trace_id']); + } + self::assertStringNotContainsString( + 'refused-admission-private', + json_encode($span, JSON_THROW_ON_ERROR) + ); + foreach ($spans as $processing) { + self::assertNotSame($span['span_id'], $processing['parent_id']); + foreach ($processing['links'] as $link) { + self::assertNotSame($span['span_id'], $link['span_id']); + } + } + } + foreach ([$admitted['signal_commands'][0], $existing['signal_commands'][1]] as $index => $command) { + if ($command['carrier'] !== null) { + $carrier = json_decode($command['carrier'], true, flags: JSON_THROW_ON_ERROR)['carrier']; + self::assertSame($index === 0 + ? '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' + : '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', $carrier['traceparent']); + } + } } finally { (new Filesystem())->deleteDirectory($directory); } From 32e4bd517fe30742b5a20a5f8349b1ced9ab1afa Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 21:42:56 +0000 Subject: [PATCH 26/77] feat: link embedded queue dispatch to cold processing --- .github/pr-test-selection.json | 1 + .github/workflows/php.yml | 1 + docs/embedded-tracing.md | 13 ++ src/V2/Jobs/RunActivityTask.php | 4 + src/V2/Jobs/RunTimerTask.php | 6 +- src/V2/Jobs/RunWorkflowTask.php | 4 + src/V2/Support/ExecutionTracing.php | 101 +++++++++- src/V2/Support/TaskDispatcher.php | 19 +- src/V2/Support/TaskTraceCarrier.php | 68 +++++++ .../V2/tracing-legacy-queue-jobs.json | 25 +++ tests/Fixtures/V2/tracing-process.php | 73 +++++++- .../Unit/V2/ActivityExecutionTracingTest.php | 175 ++++++++++++++---- .../Unit/V2/AdmissionExecutionTracingTest.php | 17 +- .../V2/CancellationExecutionTracingTest.php | 11 +- .../V2/ChildRetryExecutionTracingTest.php | 4 +- .../Unit/V2/DispatchExecutionTracingTest.php | 175 ++++++++++++++++++ tests/Unit/V2/ExecutionTracingTest.php | 161 ++++++++++++++++ tests/Unit/V2/IncomingHttpTracingTest.php | 10 +- .../Unit/V2/RecoveryExecutionTracingTest.php | 2 +- .../V2/SignalLinkExecutionTracingTest.php | 21 ++- .../V2/SignalStartExecutionTracingTest.php | 25 ++- tests/Unit/V2/TaskTraceCarrierTest.php | 168 +++++++++++++++++ tests/Unit/V2/TimingExecutionTracingTest.php | 2 +- .../V2/TransitionExecutionTracingTest.php | 2 +- .../V2/UpdateLinkExecutionTracingTest.php | 11 +- 25 files changed, 1019 insertions(+), 80 deletions(-) create mode 100644 src/V2/Support/TaskTraceCarrier.php create mode 100644 tests/Fixtures/V2/tracing-legacy-queue-jobs.json create mode 100644 tests/Unit/V2/DispatchExecutionTracingTest.php create mode 100644 tests/Unit/V2/TaskTraceCarrierTest.php diff --git a/.github/pr-test-selection.json b/.github/pr-test-selection.json index c2b0ac79c..9eb6c0f2c 100644 --- a/.github/pr-test-selection.json +++ b/.github/pr-test-selection.json @@ -15,6 +15,7 @@ "tests/Unit/V2/ScopedCancellationContextTest.php", "tests/Unit/V2/StartOptionsTest.php", "tests/Unit/V2/TaskTraceTimingsTest.php", + "tests/Unit/V2/TaskTraceCarrierTest.php", "tests/Unit/V2/TraceCarrierTest.php", "tests/Unit/V2/VersionMarkerWireFormatTest.php", "tests/Unit/V2/WorkflowFiberRunnerTest.php", diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 66430dab8..da9c0654b 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -388,6 +388,7 @@ jobs: tests/Unit/V2/UpdateLinkExecutionTracingTest.php tests/Unit/V2/TimingExecutionTracingTest.php tests/Unit/V2/AdmissionExecutionTracingTest.php + tests/Unit/V2/DispatchExecutionTracingTest.php --fail-on-warning --log-junit build/test-results/pr-activity-tracing.xml - name: Run database-independent unit contracts diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index d9c71c97f..11388121e 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -125,6 +125,19 @@ admission context survives the qualified cold-worker, child, continuation and signal-with-start paths. Existing runs keep their original admission context. An untraced legacy admission does not borrow a worker's ambient parent. +Queue dispatch emits a short `durable.task.dispatch` producer span outside the +database transaction. It covers the queue dispatcher call. A returned call is +not proof that a worker claimed or completed the task. Poll mode and fake +execution do not emit queue producer spans. + +The queued job can carry a bounded trace context tied to its exact task ID. +Cold workflow, activity and timer consumers link the producer as +`durable.link.type=queue_dispatch`; their parent remains the original persisted +run context. Queue metadata carries no baggage, payload, lease or deadline +authority. Missing, malformed or mismatched metadata supplies no dispatch link. +Legacy serialized jobs remain readable, and disabled tracing leaves newly +created jobs' serialized form unchanged. The service protocol is unchanged. + The existing bridge-authored child-retry policy is covered through both queued child failure and bridge-recorded failure, followed by separate cold retry, activity and parent workers. Child admission and retries preserve the original diff --git a/src/V2/Jobs/RunActivityTask.php b/src/V2/Jobs/RunActivityTask.php index c87bb4e85..02687ef8d 100644 --- a/src/V2/Jobs/RunActivityTask.php +++ b/src/V2/Jobs/RunActivityTask.php @@ -36,6 +36,9 @@ final class RunActivityTask implements ShouldQueue public int $tries = 1; + // Mixed so malformed telemetry cannot prevent an older job from loading. + public mixed $dispatchObservabilityContext = null; + public function __construct( public readonly string $taskId, ) { @@ -91,6 +94,7 @@ public function handle(): void fn () => $this->executeClaim($claim), is_array($allowlist) ? $allowlist : [], true, + $this->dispatchObservabilityContext, ); } diff --git a/src/V2/Jobs/RunTimerTask.php b/src/V2/Jobs/RunTimerTask.php index 9ab9953ce..bea79b7f4 100644 --- a/src/V2/Jobs/RunTimerTask.php +++ b/src/V2/Jobs/RunTimerTask.php @@ -69,6 +69,9 @@ final class RunTimerTask implements ShouldQueue public int $tries = 5; + // Mixed so malformed telemetry cannot prevent an older job from loading. + public mixed $dispatchObservabilityContext = null; + public function __construct( public readonly string $taskId, ) { @@ -122,7 +125,8 @@ public function handle(): void TraceCarrier::fromRun($run), fn () => $this->executeClaim($timer->id), is_array($allowlist) ? $allowlist : [], - true + true, + $this->dispatchObservabilityContext, ); } diff --git a/src/V2/Jobs/RunWorkflowTask.php b/src/V2/Jobs/RunWorkflowTask.php index 4f7f81760..8f5b53555 100644 --- a/src/V2/Jobs/RunWorkflowTask.php +++ b/src/V2/Jobs/RunWorkflowTask.php @@ -38,6 +38,9 @@ final class RunWorkflowTask implements ShouldQueue public int $tries = 1; + // Mixed so malformed telemetry cannot prevent an older job from loading. + public mixed $dispatchObservabilityContext = null; + public function __construct( public readonly string $taskId, ) { @@ -80,6 +83,7 @@ public function handle(WorkflowExecutor $executor): void fn () => $this->executeClaim($executor), is_array($allowlist) ? $allowlist : [], true, + $this->dispatchObservabilityContext, ); } diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php index 9214b6b49..11c6b130d 100644 --- a/src/V2/Support/ExecutionTracing.php +++ b/src/V2/Support/ExecutionTracing.php @@ -22,9 +22,13 @@ use Workflow\V2\CommandResult; use Workflow\V2\Enums\CommandStatus; use Workflow\V2\Enums\CommandType; +use Workflow\V2\Jobs\RunActivityTask; +use Workflow\V2\Jobs\RunTimerTask; +use Workflow\V2\Jobs\RunWorkflowTask; use Workflow\V2\Models\WorkflowCommand; use Workflow\V2\Models\WorkflowInstance; use Workflow\V2\Models\WorkflowRun; +use Workflow\V2\Models\WorkflowTask; use Workflow\V2\SignalWithStartResult; use Workflow\V2\StartResult; @@ -90,6 +94,7 @@ public static function activity( callable $operation, array $baggageAllowlist = [], bool $enabled = false, + mixed $dispatchContext = null, ): mixed { return self::segment( 'durable.activity.attempt', @@ -97,7 +102,8 @@ public static function activity( $persistedContext, $operation, $baggageAllowlist, - $enabled + $enabled, + $dispatchContext, ); } @@ -111,6 +117,7 @@ public static function workflow( callable $operation, array $baggageAllowlist = [], bool $enabled = false, + mixed $dispatchContext = null, ): mixed { return self::segment( 'durable.workflow.task', @@ -118,7 +125,8 @@ public static function workflow( $persistedContext, $operation, $baggageAllowlist, - $enabled + $enabled, + $dispatchContext, ); } @@ -132,6 +140,7 @@ public static function timer( callable $operation, array $baggageAllowlist = [], bool $enabled = false, + mixed $dispatchContext = null, ): mixed { return self::segment( 'durable.timer.task', @@ -139,7 +148,43 @@ public static function timer( $persistedContext, $operation, $baggageAllowlist, - $enabled + $enabled, + $dispatchContext, + ); + } + + public static function dispatch( + WorkflowTask $task, + RunWorkflowTask|RunActivityTask|RunTimerTask $job, + callable $operation, + bool $enabled = false, + ): mixed { + if (! $enabled || ! class_exists(Globals::class)) { + return $operation(); + } + $attributes = [ + 'task_id' => $job->taskId, + ]; + try { + $attributes += [ + 'workflow_run_id' => $task->getRawOriginal('workflow_run_id'), + 'namespace' => $task->getRawOriginal('namespace'), + 'durable.task.type' => $task->getRawOriginal('task_type'), + ]; + } catch (Throwable) { + // Optional loaded identity cannot replace the queue operation. + } + + return self::segment( + 'durable.task.dispatch', + $attributes, + null, + $operation, + [], + true, + onActivated: static function () use ($job): void { + $job->dispatchObservabilityContext = TaskTraceCarrier::capture($job->taskId); + } ); } @@ -271,6 +316,8 @@ private static function segment( callable $operation, array $baggageAllowlist, bool $enabled, + mixed $dispatchContext = null, + ?callable $onActivated = null, ): mixed { if (! $enabled || ! class_exists(Globals::class)) { return $operation(); @@ -280,7 +327,9 @@ private static function segment( $scope = null; try { // Local admission keeps the caller's context. Workers recover only stored authority. - $parent = $name === 'durable.workflow.admission' ? Context::getCurrent() : Context::getRoot(); + $localParent = $name === 'durable.workflow.admission' + || ($name === 'durable.task.dispatch' && self::current() !== []); + $parent = $localParent ? Context::getCurrent() : Context::getRoot(); $carrier = TraceCarrier::normalize($persistedContext, $baggageAllowlist); if ($carrier !== null) { $parent = TraceContextPropagator::getInstance()->extract($carrier['carrier'], context: $parent); @@ -305,14 +354,36 @@ private static function segment( $version = InstalledVersions::isInstalled('durable-workflow/workflow') ? InstalledVersions::getPrettyVersion('durable-workflow/workflow') : null; - $span = Globals::tracerProvider()->getTracer('durable-workflow/workflow', $version) + if ($name === 'durable.task.dispatch' && in_array( + $attributes['durable.task.type'] ?? null, + ['workflow', 'activity', 'timer'], + true + )) { + $spanAttributes['durable.task.type'] = $attributes['durable.task.type']; + } + $builder = Globals::tracerProvider()->getTracer('durable-workflow/workflow', $version) ->spanBuilder($name) - ->setSpanKind( - $name === 'durable.workflow.admission' ? SpanKind::KIND_INTERNAL : SpanKind::KIND_CONSUMER - ) + ->setSpanKind(match ($name) { + 'durable.workflow.admission' => SpanKind::KIND_INTERNAL, + 'durable.task.dispatch' => SpanKind::KIND_PRODUCER, + default => SpanKind::KIND_CONSUMER, + }) ->setParent($parent) - ->setAttributes($spanAttributes) - ->startSpan(); + ->setAttributes($spanAttributes); + if ($dispatchContext !== null) { + $dispatch = TaskTraceCarrier::fromJob($dispatchContext, $fields['task_id'] ?? ''); + if ($dispatch !== null) { + $linked = TraceContextPropagator::getInstance()->extract( + $dispatch['carrier'], + context: Context::getRoot() + ); + $builder->addLink(Span::fromContext($linked)->getContext(), [ + 'durable.link.type' => 'queue_dispatch', + 'task_id' => $fields['task_id'], + ]); + } + } + $span = $builder->startSpan(); if ($span->getContext()->isValid()) { $fields['trace_id'] = $span->getContext()->getTraceId(); $fields['span_id'] = $span->getContext()->getSpanId(); @@ -326,6 +397,9 @@ private static function segment( } $scope = $active->activate(); self::enterFiberScope(); + if ($onActivated !== null) { + $onActivated(); + } } catch (Throwable) { self::close($scope, $span); @@ -334,6 +408,13 @@ private static function segment( try { $result = $operation(); + if ($name === 'durable.task.dispatch' && $span !== null) { + try { + $span->setAttribute('durable.dispatch.outcome', 'returned'); + } catch (Throwable) { + // A queue return is not completion or telemetry-delivery authority. + } + } if ($name === 'durable.workflow.admission' && ($result instanceof StartResult || $result instanceof SignalWithStartResult) && $span !== null) { try { diff --git a/src/V2/Support/TaskDispatcher.php b/src/V2/Support/TaskDispatcher.php index 15197c252..6d847c5f8 100644 --- a/src/V2/Support/TaskDispatcher.php +++ b/src/V2/Support/TaskDispatcher.php @@ -80,7 +80,22 @@ private static function publish( return; } - app(BusDispatcher::class)->dispatch($job); + $tracing = false; + try { + $tracing = config('workflows.tracing.enabled', false) === true; + } catch (Throwable) { + // Optional tracing configuration cannot block queue dispatch. + } + if ($tracing) { + ExecutionTracing::dispatch( + $task, + $job, + static fn () => app(BusDispatcher::class)->dispatch($job), + true + ); + } else { + app(BusDispatcher::class)->dispatch($job); + } self::markDispatched($task, $attemptedAt, $boundedWorkflowTaskProjection); } catch (Throwable $throwable) { self::markDispatchFailure($task, $attemptedAt, $throwable->getMessage(), $boundedWorkflowTaskProjection); @@ -136,7 +151,7 @@ private static function fleetBlockReason(WorkflowTask $task): ?string : 'Dispatch blocked under fail validation mode. ' . $reason; } - private static function makeJob(WorkflowTask $task): object + private static function makeJob(WorkflowTask $task): RunWorkflowTask|RunActivityTask|RunTimerTask { $job = match ($task->task_type) { TaskType::Workflow => new RunWorkflowTask($task->id), diff --git a/src/V2/Support/TaskTraceCarrier.php b/src/V2/Support/TaskTraceCarrier.php new file mode 100644 index 000000000..5907d1ba2 --- /dev/null +++ b/src/V2/Support/TaskTraceCarrier.php @@ -0,0 +1,68 @@ +|null + */ + public static function capture(string $taskId): ?array + { + if (! self::validTaskId($taskId)) { + return null; + } + $context = TraceCarrier::capture(); + if ($context === null) { + return null; + } + + $envelope = [ + 'schema' => self::SCHEMA, + 'version' => 1, + 'task_id' => $taskId, + 'context' => $context, + ]; + try { + return strlen(json_encode($envelope, JSON_THROW_ON_ERROR)) <= 4096 ? $envelope : null; + } catch (Throwable) { + return null; + } + } + + /** + * @return array{schema: string, version: int, carrier: array}|null + */ + public static function fromJob(mixed $value, string $taskId): ?array + { + if (! self::validTaskId($taskId) || ! is_array($value) || ($value['schema'] ?? null) !== self::SCHEMA + || ($value['version'] ?? null) !== 1 || ($value['task_id'] ?? null) !== $taskId + || array_diff(array_keys($value), ['schema', 'version', 'task_id', 'context']) !== [] + || ! is_array($value['context'] ?? null) || ! is_array($value['context']['carrier'] ?? null) + || array_diff(array_keys($value['context']['carrier']), ['traceparent', 'tracestate']) !== []) { + return null; + } + $context = TraceCarrier::normalize($value['context']); + if ($context === null) { + return null; + } + + try { + return strlen(json_encode($value, JSON_THROW_ON_ERROR)) <= 4096 ? $context : null; + } catch (Throwable) { + return null; + } + } + + private static function validTaskId(string $taskId): bool + { + return $taskId !== '' && strlen($taskId) <= 512 && ! preg_match('/[\x00-\x1f\x7f]/', $taskId); + } +} diff --git a/tests/Fixtures/V2/tracing-legacy-queue-jobs.json b/tests/Fixtures/V2/tracing-legacy-queue-jobs.json new file mode 100644 index 000000000..65bd891f1 --- /dev/null +++ b/tests/Fixtures/V2/tracing-legacy-queue-jobs.json @@ -0,0 +1,25 @@ +{ + "workflow": "2.5.22", + "tag_source": "908e2db9b4337ceae52ebe5fc5e64f3de8e5338b", + "workflow_reference": "1d6acb2db83aa8c0a033e1a5f647d9fda0ce84c7", + "image": "ghcr.io/durable-workflow/server@sha256:f6a6507a5a424dfad03e636ec690b0f1e813d385be9940691c59116446dfd1cc", + "laravel": "v13.34.0", + "php": "8.3.35", + "jobs": [ + { + "class": "Workflow\\V2\\Jobs\\RunWorkflowTask", + "source_sha256": "a139d5807e15d3c62da7289471fd0a4e02f7caab3ffacf5d86b8a147ce2c4168", + "serialized": "O:32:\"Workflow\\V2\\Jobs\\RunWorkflowTask\":2:{s:6:\"taskId\";s:13:\"legacy-task-0\";s:11:\"afterCommit\";b:1;}" + }, + { + "class": "Workflow\\V2\\Jobs\\RunActivityTask", + "source_sha256": "47f88ab431d81090ecd097fc667f06a6848164c4fe5bc20d44ca013d8730f09c", + "serialized": "O:32:\"Workflow\\V2\\Jobs\\RunActivityTask\":2:{s:6:\"taskId\";s:13:\"legacy-task-1\";s:11:\"afterCommit\";b:1;}" + }, + { + "class": "Workflow\\V2\\Jobs\\RunTimerTask", + "source_sha256": "95b033891bd766049c1d5b4a56417ec80176a85b1bce0130c1e1d9d75da656a4", + "serialized": "O:29:\"Workflow\\V2\\Jobs\\RunTimerTask\":2:{s:6:\"taskId\";s:13:\"legacy-task-2\";s:11:\"afterCommit\";b:1;}" + } + ] +} diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 5faf476b2..8bcde0fa5 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -38,6 +38,9 @@ use Workflow\V2\Attributes\Signal; use Workflow\V2\CommandContext; use Workflow\V2\Events\WorkflowStarted; +use Workflow\V2\Jobs\RunActivityTask; +use Workflow\V2\Jobs\RunTimerTask; +use Workflow\V2\Jobs\RunWorkflowTask; use Workflow\V2\Models\ActivityAttempt; use Workflow\V2\Models\ActivityExecution; use Workflow\V2\Models\WorkflowChildCall; @@ -71,6 +74,7 @@ $signalLinks = getenv('DW_TEST_TRACING_SIGNAL_LINKS') === 'true'; $updateLinks = getenv('DW_TEST_TRACING_UPDATE_LINKS') === 'true'; $timings = getenv('DW_TEST_TRACING_TIMINGS') === 'true'; +$dispatchProbe = getenv('DW_TEST_TRACING_DISPATCH') === 'true'; $admissionProbe = getenv('DW_TEST_TRACING_ADMISSION') === 'true' || getenv('DW_TEST_TRACING_SIGNAL_START_ADMISSION') === 'true'; $childRetry = getenv('DW_TEST_TRACING_CHILD_RETRY') === 'true'; @@ -412,12 +416,22 @@ protected function defineEnvironment($app): void ], ); $exporter = new InMemoryExporter(); -$admissionProcessor = $admissionProbe ? new class($exporter) extends SimpleSpanProcessor { +$admissionProcessor = ($admissionProbe || $dispatchProbe) ? new class( + $exporter, + $dispatchProbe ? 'durable.task.dispatch' : 'durable.workflow.admission' +) extends SimpleSpanProcessor { public array $boundaries = []; + public function __construct( + InMemoryExporter $exporter, + private readonly string $observedName + ) { + parent::__construct($exporter); + } + public function onStart(ReadWriteSpanInterface $span, ContextInterface $parentContext): void { - if ($span->getName() === 'durable.workflow.admission') { + if ($span->getName() === $this->observedName) { $this->boundaries[] = [ 'event' => 'start', 'transaction_level' => DB::transactionLevel(), @@ -428,7 +442,7 @@ public function onStart(ReadWriteSpanInterface $span, ContextInterface $parentCo public function onEnd(ReadableSpanInterface $span): void { - if ($span->getName() === 'durable.workflow.admission') { + if ($span->getName() === $this->observedName) { $this->boundaries[] = [ 'event' => 'end', 'transaction_level' => DB::transactionLevel(), @@ -490,6 +504,25 @@ public function extract( ->activate(); $initial = Context::getCurrent(); $admissionForeignKey = Context::createKey('test.application.admission'); +$dispatchDelivered = []; +$dispatchQueue = static function (): array { + return DB::table('jobs')->orderBy('id')->get(['id', 'payload'])->map(static function ($row): array { + $payload = json_decode($row->payload, true, flags: JSON_THROW_ON_ERROR); + $job = unserialize($payload['data']['command'], [ + 'allowed_classes' => [RunWorkflowTask::class, RunActivityTask::class, RunTimerTask::class], + ]); + if (! $job instanceof RunWorkflowTask && ! $job instanceof RunActivityTask && ! $job instanceof RunTimerTask) { + throw new \RuntimeException('Unexpected queued tracing fixture job.'); + } + + return [ + 'id' => $row->id, + 'class' => $job::class, + 'task_id' => $job->taskId, + 'trace' => $job->dispatchObservabilityContext ?? null, + ]; + })->all(); +}; $admissionCallbacks = []; if ($admissionProbe) { $app['events']->listen(WorkflowStarted::class, static function () use ( @@ -774,6 +807,9 @@ public function extract( throw new \RuntimeException('Child failure admission refused.'); } } elseif ($mode === 'work') { + if ($dispatchProbe) { + $dispatchDelivered = $dispatchQueue(); + } if (Artisan::call('queue:work', [ 'connection' => 'database', '--queue' => 'default', @@ -856,6 +892,19 @@ public function extract( ], $span->getLinks()), ], $exporter->getSpans()), ]; + // Preserve the complete export for dispatch controls. Execution checks separately + // inspect their admission/consumer spans and application command links. + $output['execution_spans'] = array_values(array_filter( + $output['spans'], + static fn (array $span): bool => $span['name'] !== 'durable.task.dispatch' + )); + foreach ($output['execution_spans'] as &$executionSpan) { + $executionSpan['links'] = array_values(array_filter( + $executionSpan['links'], + static fn (array $link): bool => ($link['fields']['durable.link.type'] ?? null) !== 'queue_dispatch' + )); + } + unset($executionSpan); if ($exportEndpoint) { $output['exports_before_shutdown'] = $exportRequests() - $initialExportRequests; } @@ -1026,6 +1075,24 @@ public function extract( ->count(), ]; } + if ($dispatchProbe) { + $history = $run->historyEvents() + ->orderBy('sequence') + ->get(); + $output['dispatch'] = [ + 'queued' => $dispatchQueue(), + 'delivered' => $dispatchDelivered, + 'boundaries' => $admissionProcessor->boundaries, + 'history_hashes' => $history->map(static fn ($event): string => hash( + 'sha256', + json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR) + ))->all(), + 'history_payloads' => $history->pluck('payload') + ->all(), + 'activity_completions' => $history->where('event_type.value', 'ActivityCompleted') + ->count(), + ]; + } if ($admissionProbe) { $history = $run->historyEvents() ->orderBy('sequence') diff --git a/tests/Unit/V2/ActivityExecutionTracingTest.php b/tests/Unit/V2/ActivityExecutionTracingTest.php index 5a04f1885..c47db80c9 100644 --- a/tests/Unit/V2/ActivityExecutionTracingTest.php +++ b/tests/Unit/V2/ActivityExecutionTracingTest.php @@ -7,11 +7,13 @@ use Composer\Autoload\ClassLoader; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\QueryException; use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use OpenTelemetry\API\Instrumentation\Configurator; use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator; +use OpenTelemetry\API\Trace\SpanKind; use OpenTelemetry\API\Trace\StatusCode; use OpenTelemetry\Context\ContextInterface; use OpenTelemetry\Context\ScopeInterface; @@ -82,6 +84,96 @@ public static function modes(): array ]; } + public static function nonQueueModes(): array + { + return [ + 'poll bookkeeping' => ['poll'], + 'fake execution' => ['fake'], + ]; + } + + #[DataProvider('nonQueueModes')] + public function testNonQueuePathsDoNotClaimQueueProducerSpans(string $mode): void + { + config([ + 'workflows.tracing.enabled' => true, + ]); + if ($mode === 'poll') { + config([ + 'workflows.v2.task_dispatch_mode' => 'poll', + ]); + } else { + WorkflowStub::fake(); + } + $stub = WorkflowStub::make(TracedWorkflow::class, 'non-queue-' . $mode, '0'); + $stub->start(false); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame([], array_values(array_filter( + $this->exporter->getSpans(), + static fn ($span): bool => $span->getName() === 'durable.task.dispatch' + ))); + $run = WorkflowRun::query()->findOrFail($stub->runId()); + self::assertSame($mode === 'poll' ? 'pending' : 'completed', $run->status->value); + if ($mode === 'fake') { + self::assertSame([9007199254740993, null, '雪'], $stub->refresh()->output()); + } + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + } + + public static function dispatchFailures(): array + { + return [ + 'disabled' => [false], + 'enabled' => [true], + ]; + } + + #[DataProvider('dispatchFailures')] + public function testRealQueueStorageFailureRetainsTheOriginalAdmissionAndFailure(bool $enabled): void + { + config([ + 'workflows.tracing.enabled' => $enabled, + 'queue.connections.database.table' => 'missing_trace_jobs', + ]); + $stub = WorkflowStub::make(TracedWorkflow::class, 'queue-storage-failure', '0'); + try { + $stub->start(false, new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45)); + self::fail('The real queue INSERT must fail.'); + } catch (QueryException $failure) { + self::assertStringContainsString('missing_trace_jobs', $failure->getMessage()); + } + $run = WorkflowRun::query()->where('workflow_instance_id', 'queue-storage-failure')->sole(); + self::assertSame('pending', $run->status->value); + self::assertSame([false], Serializer::unserializeWithCodec($run->payload_codec, $run->arguments)); + self::assertTrue($run->started_at->copy()->addSeconds(60)->equalTo($run->execution_deadline_at)); + self::assertSame(45, $run->run_timeout_seconds); + self::assertTrue($run->started_at->copy()->addSeconds(45)->equalTo($run->run_deadline_at)); + $task = WorkflowTask::query()->where('workflow_run_id', $run->id)->sole(); + self::assertSame('ready', $task->status->value); + self::assertNull($task->last_dispatched_at); + self::assertStringContainsString('missing_trace_jobs', $task->last_dispatch_error); + self::assertSame(0, (int) $task->repair_count); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + self::assertSame(0, ActivityAttempt::query()->count()); + self::assertSame('started_new', WorkflowCommand::query()->sole()->outcome->value); + $spans = array_values(array_filter( + $this->exporter->getSpans(), + static fn ($span): bool => $span->getName() === 'durable.task.dispatch' + )); + self::assertCount($enabled ? 1 : 0, $spans); + foreach ($spans as $span) { + self::assertSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + self::assertSame(QueryException::class, $span->getAttributes()->get('error.type')); + self::assertNull($span->getAttributes()->get('durable.dispatch.outcome')); + self::assertSame([], $span->getEvents()); + self::assertStringNotContainsString( + 'missing_trace_jobs', + json_encode($span->getAttributes()->toArray(), JSON_THROW_ON_ERROR) + ); + } + } + public function testAdmissionPersistsItsOriginalParentAndDuplicateCannotReplaceIt(): void { config([ @@ -247,7 +339,11 @@ public function testUnreadableOptionalRunMetadataCannotInterruptClaimedBusinessW self::assertSame($prefix, array_slice($history, 0, count($prefix))); self::assertSame($enabled ? ($phase === 'workflow' ? 3 : 2) : 0, UnreadableTraceCast::$metadataReads); $spans = $this->exporter->getSpans(); - self::assertCount($enabled ? 4 : 0, $spans); + self::assertCount($enabled ? 7 : 0, $spans); + self::assertCount($enabled ? 3 : 0, array_filter( + $spans, + static fn ($span): bool => $span->getKind() === SpanKind::KIND_PRODUCER + )); $admissions = array_values(array_filter( $spans, static fn ($span): bool => $span->getName() === 'durable.workflow.admission' @@ -259,7 +355,7 @@ public function testUnreadableOptionalRunMetadataCannotInterruptClaimedBusinessW } $processing = array_values(array_filter( $spans, - static fn ($span): bool => $span->getName() !== 'durable.workflow.admission' + static fn ($span): bool => $span->getKind() === SpanKind::KIND_CONSUMER )); self::assertCount($enabled ? 3 : 0, $processing); foreach ($processing as $index => $span) { @@ -301,16 +397,17 @@ public function onEnd(ReadableSpanInterface $span): void $stub->start(); $this->workOnce(); self::assertSame('failed', WorkflowRun::query()->findOrFail($stub->runId())->status->value); - self::assertSame([0, 0, 0, 0], $processor->transactionLevels); + self::assertSame([0, 0, 0, 0, 0, 0], $processor->transactionLevels); $spans = $this->exporter->getSpans(); - self::assertCount(2, $spans); - self::assertSame('durable.workflow.admission', $spans[0]->getName()); - self::assertSame('started_new', $spans[0]->getAttributes()->get('durable.command.outcome')); - self::assertSame('durable.workflow.task', $spans[1]->getName()); - self::assertSame(StatusCode::STATUS_ERROR, $spans[1]->getStatus()->getCode()); - self::assertSame(RuntimeException::class, $spans[1]->getAttributes()->get('error.type')); + self::assertSame( + ['durable.task.dispatch', 'durable.workflow.admission', 'durable.workflow.task'], + array_map(static fn ($span): string => $span->getName(), $spans) + ); + self::assertSame('started_new', $spans[1]->getAttributes()->get('durable.command.outcome')); + self::assertSame(StatusCode::STATUS_ERROR, $spans[2]->getStatus()->getCode()); + self::assertSame(RuntimeException::class, $spans[2]->getAttributes()->get('error.type')); self::assertSame($stub->runId(), FailingTracedWorkflow::$fields['workflow_run_id']); - self::assertSame($spans[1]->getContext()->getSpanId(), FailingTracedWorkflow::$fields['span_id']); + self::assertSame($spans[2]->getContext()->getSpanId(), FailingTracedWorkflow::$fields['span_id']); self::assertSame([], ExecutionTracing::current()); self::assertSame(0, DB::table('failed_jobs')->count()); } finally { @@ -389,36 +486,48 @@ public function testIndependentQueueProcessesUseThePersistedAdmissionParent(bool $workerPids = [$workflow['pid'], $activity['pid'], $completion['pid']]; if ($retried !== null) { $workerPids[] = $retried['pid']; - self::assertCount(1, $retried['spans']); + self::assertCount(1, $retried['execution_spans']); self::assertSame( - $activity['spans'][0]['fields']['activity_id'], - $retried['spans'][0]['fields']['activity_id'] + $activity['execution_spans'][0]['fields']['activity_id'], + $retried['execution_spans'][0]['fields']['activity_id'] + ); + self::assertNotSame( + $activity['execution_spans'][0]['fields']['activity_attempt_id'], + $retried['execution_spans'][0]['fields']['activity_attempt_id'] ); self::assertNotSame( - $activity['spans'][0]['fields']['activity_attempt_id'], - $retried['spans'][0]['fields']['activity_attempt_id'] + $activity['execution_spans'][0]['span_id'], + $retried['execution_spans'][0]['span_id'] + ); + self::assertSame( + $activity['execution_spans'][0]['trace_id'], + $retried['execution_spans'][0]['trace_id'] + ); + self::assertSame( + $activity['execution_spans'][0]['parent_id'], + $retried['execution_spans'][0]['parent_id'] ); - self::assertNotSame($activity['spans'][0]['span_id'], $retried['spans'][0]['span_id']); - self::assertSame($activity['spans'][0]['trace_id'], $retried['spans'][0]['trace_id']); - self::assertSame($activity['spans'][0]['parent_id'], $retried['spans'][0]['parent_id']); } self::assertCount($retry ? 4 : 3, array_unique($workerPids)); foreach ([$workflow, $completion] as $segment) { - self::assertCount(1, $segment['spans']); - self::assertSame('durable.workflow.task', $segment['spans'][0]['name']); - self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $segment['spans'][0]['trace_id']); - self::assertSame('00f067aa0ba902b7', $segment['spans'][0]['parent_id']); - self::assertSame($admitted['run_id'], $segment['spans'][0]['fields']['workflow_run_id']); - self::assertArrayNotHasKey('activity_attempt_id', $segment['spans'][0]['fields']); + self::assertCount(1, $segment['execution_spans']); + self::assertSame('durable.workflow.task', $segment['execution_spans'][0]['name']); + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $segment['execution_spans'][0]['trace_id']); + self::assertSame('00f067aa0ba902b7', $segment['execution_spans'][0]['parent_id']); + self::assertSame($admitted['run_id'], $segment['execution_spans'][0]['fields']['workflow_run_id']); + self::assertArrayNotHasKey('activity_attempt_id', $segment['execution_spans'][0]['fields']); } - self::assertNotSame($workflow['spans'][0]['span_id'], $completion['spans'][0]['span_id']); self::assertNotSame( - $workflow['spans'][0]['fields']['task_id'], - $completion['spans'][0]['fields']['task_id'] + $workflow['execution_spans'][0]['span_id'], + $completion['execution_spans'][0]['span_id'] + ); + self::assertNotSame( + $workflow['execution_spans'][0]['fields']['task_id'], + $completion['execution_spans'][0]['fields']['task_id'] ); - self::assertCount(1, $activity['spans']); - self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $activity['spans'][0]['trace_id']); - self::assertSame('00f067aa0ba902b7', $activity['spans'][0]['parent_id']); + self::assertCount(1, $activity['execution_spans']); + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $activity['execution_spans'][0]['trace_id']); + self::assertSame('00f067aa0ba902b7', $activity['execution_spans'][0]['parent_id']); $finished = $run('inspect'); self::assertSame('completed', $finished['status']); self::assertSame([9007199254740993, null, '雪'], $finished['output']); @@ -439,7 +548,7 @@ public function testIndependentQueueProcessesUseThePersistedAdmissionParent(bool self::assertCount($retry ? 2 : 1, $callbacks); foreach ($callbacks as $callback) { self::assertSame($admitted['run_id'], $callback['context']['workflow_run_id']); - self::assertSame($activity['spans'][0]['trace_id'], $callback['context']['trace_id']); + self::assertSame($activity['execution_spans'][0]['trace_id'], $callback['context']['trace_id']); self::assertArrayHasKey('activity_attempt_id', $callback['context']); self::assertSame('0', $callback['context']['namespace']); $constructed = array_values( @@ -470,7 +579,7 @@ public function testIndependentQueueProcessesUseThePersistedAdmissionParent(bool self::assertArrayNotHasKey('workflow_run_id', $record['context']); } elseif (isset($record['context']['activity_attempt_id'])) { self::assertSame($admitted['run_id'], $record['context']['workflow_run_id']); - self::assertSame($activity['spans'][0]['trace_id'], $record['context']['trace_id']); + self::assertSame($activity['execution_spans'][0]['trace_id'], $record['context']['trace_id']); } } $workflowLogs = array_values(array_filter( @@ -480,7 +589,7 @@ public function testIndependentQueueProcessesUseThePersistedAdmissionParent(bool self::assertCount(9, $workflowLogs); $segments = []; foreach ([$workflow, $completion] as $segment) { - $span = $segment['spans'][0]; + $span = $segment['execution_spans'][0]; self::assertArrayHasKey('durable.task.queue_wait_seconds', $span['fields']); self::assertGreaterThanOrEqual(0, $span['fields']['durable.task.queue_wait_seconds']); $correlation = $span['fields']; diff --git a/tests/Unit/V2/AdmissionExecutionTracingTest.php b/tests/Unit/V2/AdmissionExecutionTracingTest.php index 6d43df99c..ce39e16a1 100644 --- a/tests/Unit/V2/AdmissionExecutionTracingTest.php +++ b/tests/Unit/V2/AdmissionExecutionTracingTest.php @@ -81,8 +81,11 @@ public function testOrdinaryAdmissionDecisionsPreserveTheCallerAndOriginalDurabl )); } foreach ([$admitted, $duplicate, $rejected] as $index => $attempt) { - self::assertSame($enabled ? [0] : [], $attempt['admission']['capture_transaction_levels']); - self::assertCount($enabled ? 1 : 0, $attempt['spans']); + self::assertSame( + $enabled ? array_fill(0, 1 + count($attempt['spans']) - count($attempt['execution_spans']), 0) : [], + $attempt['admission']['capture_transaction_levels'] + ); + self::assertCount($enabled ? 1 : 0, $attempt['execution_spans']); self::assertSame( ['started_new', 'returned_existing_active', 'rejected_duplicate'][$index], $attempt['admission']['result']['outcome'] @@ -101,7 +104,7 @@ public function testOrdinaryAdmissionDecisionsPreserveTheCallerAndOriginalDurabl 'transaction_level' => 0, ], ], $attempt['admission']['boundaries']); - $span = $attempt['spans'][0]; + $span = $attempt['execution_spans'][0]; self::assertSame('durable.workflow.admission', $span['name']); self::assertSame(SpanKind::KIND_INTERNAL, $span['kind']); self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); @@ -129,7 +132,7 @@ public function testOrdinaryAdmissionDecisionsPreserveTheCallerAndOriginalDurabl self::assertSame(0, $callback['transaction_level']); self::assertSame('caller-scope', $callback['foreign']); if ($enabled) { - self::assertSame($admitted['spans'][0]['span_id'], $callback['span_id']); + self::assertSame($admitted['execution_spans'][0]['span_id'], $callback['span_id']); self::assertSame('cold-tracing', $callback['fields']['workflow_instance_id']); } else { self::assertSame([], $callback['fields']); @@ -144,11 +147,11 @@ public function testOrdinaryAdmissionDecisionsPreserveTheCallerAndOriginalDurabl self::assertNull($admitted['carrier']); } foreach ($workers as $workerResult) { - self::assertCount($enabled ? 1 : 0, $workerResult['spans']); + self::assertCount($enabled ? 1 : 0, $workerResult['execution_spans']); if ($enabled) { - $span = $workerResult['spans'][0]; + $span = $workerResult['execution_spans'][0]; self::assertSame($withoutParent ? '0000000000000000' : '00f067aa0ba902b7', $span['parent_id']); - self::assertNotSame($admitted['spans'][0]['span_id'], $span['parent_id']); + self::assertNotSame($admitted['execution_spans'][0]['span_id'], $span['parent_id']); } } $records = array_map( diff --git a/tests/Unit/V2/CancellationExecutionTracingTest.php b/tests/Unit/V2/CancellationExecutionTracingTest.php index 03f43cbef..c2be9ba03 100644 --- a/tests/Unit/V2/CancellationExecutionTracingTest.php +++ b/tests/Unit/V2/CancellationExecutionTracingTest.php @@ -70,7 +70,14 @@ public function testCancellationKeepsOriginalContextAndUnwindsColdWorkerScopes( $requested = $worker('cancel'); self::assertTrue($requested['cancellation']['result']['accepted']); if ($command === 'request') { - self::assertSame($enabled ? [0] : [], $requested['cancellation']['capture_transaction_levels']); + self::assertSame( + $enabled ? array_fill( + 0, + 1 + count($requested['spans']) - count($requested['execution_spans']), + 0 + ) : [], + $requested['cancellation']['capture_transaction_levels'] + ); self::assertSame($enabled, $requested['cancellation']['request_carrier'] !== null); if ($enabled) { self::assertSame('00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', json_decode( @@ -151,7 +158,7 @@ public function testCancellationKeepsOriginalContextAndUnwindsColdWorkerScopes( foreach ($finished['cancellation']['tasks'] as $task) { self::assertSame(1, $task['attempt_count']); } - $spans = array_merge(...array_column($workers, 'spans')); + $spans = array_merge(...array_column($workers, 'execution_spans')); self::assertCount($enabled ? count($workers) : 0, $spans); $taskIds = array_column($finished['cancellation']['tasks'], 'id'); foreach ($spans as $span) { diff --git a/tests/Unit/V2/ChildRetryExecutionTracingTest.php b/tests/Unit/V2/ChildRetryExecutionTracingTest.php index 1438f1f37..97960b219 100644 --- a/tests/Unit/V2/ChildRetryExecutionTracingTest.php +++ b/tests/Unit/V2/ChildRetryExecutionTracingTest.php @@ -156,7 +156,7 @@ public function testColdChildRetryKeepsItsAdmissionContextAndOriginalDurablePoli foreach ($finished['runs'] as $run) { self::assertSame($admitted['carrier'], $run['carrier'], $run['id'] . ' generation ' . $run['number']); } - $spans = array_merge(...array_column($workers, 'spans')); + $spans = array_merge(...array_column($workers, 'execution_spans')); if ($workerEnabled) { $observed = []; foreach ($spans as $span) { @@ -173,7 +173,7 @@ public function testColdChildRetryKeepsItsAdmissionContextAndOriginalDurablePoli self::assertContains($admitted['run_id'], $observed); if ($failure === 'work') { $failureSpan = array_values(array_filter( - $failed['spans'], + $failed['execution_spans'], static fn (array $span): bool => $span['fields']['workflow_run_id'] === $originalChild['id'] )); self::assertCount(1, $failureSpan); diff --git a/tests/Unit/V2/DispatchExecutionTracingTest.php b/tests/Unit/V2/DispatchExecutionTracingTest.php new file mode 100644 index 000000000..5fee75ecc --- /dev/null +++ b/tests/Unit/V2/DispatchExecutionTracingTest.php @@ -0,0 +1,175 @@ + ['ordinary', true, true], + 'retry' => ['retry', true, true], + 'timer and backoff' => ['timer', true, true], + 'disabled' => ['ordinary', false, false], + 'legacy admission' => ['ordinary', false, true], + ]; + } + + #[DataProvider('modes')] + public function testActualQueuedDispatchLinksColdProcessingWithoutChangingTheDurableParent( + string $scenario, + bool $admissionEnabled, + bool $workerEnabled, + ): void { + $directory = sys_get_temp_dir() . '/dw-tracing-dispatch-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static function (string $mode) use ($directory, $scenario, $admissionEnabled, $workerEnabled): array { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, + $scenario === 'retry' ? 'retry' : 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_DISPATCH' => 'true', + 'DW_TEST_TRACING_TIMINGS' => $scenario === 'timer' ? 'true' : 'false', + 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admissionEnabled : $workerEnabled) ? 'true' : 'false', + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + self::assertJson($process->getOutput(), substr($process->getOutput(), -1500)); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + try { + $admitted = $worker('admit'); + $count = $scenario === 'timer' ? 6 : ($scenario === 'retry' ? 4 : 3); + $workers = []; + for ($index = 0; $index < $count; ++$index) { + if ($scenario === 'timer' && ($index === 1 || $index === 4)) { + sleep(2); + } + $workers[] = $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame($count, $finished['tasks']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + self::assertSame(1, $finished['dispatch']['activity_completions']); + self::assertCount($count, array_unique(array_column($workers, 'pid'))); + foreach ([$admitted, ...$workers] as $phase) { + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { + self::assertSame($admitted[$field], $phase[$field]); + self::assertSame($admitted[$field], $finished[$field]); + } + self::assertSame($phase['dispatch']['history_hashes'], array_slice( + $finished['dispatch']['history_hashes'], + 0, + count($phase['dispatch']['history_hashes']) + )); + } + $allSpans = array_merge(...array_column([$admitted, ...$workers], 'spans')); + $dispatches = array_values(array_filter( + $allSpans, + static fn (array $span): bool => $span['name'] === 'durable.task.dispatch' + )); + self::assertCount(($admissionEnabled ? 1 : 0) + ($workerEnabled ? $count - 1 : 0), $dispatches); + $dispatchByTask = []; + foreach ($dispatches as $span) { + self::assertSame(SpanKind::KIND_PRODUCER, $span['kind']); + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + self::assertSame('0', $span['fields']['namespace']); + self::assertSame('returned', $span['fields']['durable.dispatch.outcome']); + $dispatchByTask[$span['fields']['task_id']] = $span; + } + foreach ([$admitted, ...$workers] as $index => $phase) { + $enabled = $index === 0 ? $admissionEnabled : $workerEnabled; + $producers = array_values(array_filter( + $phase['spans'], + static fn (array $span): bool => $span['kind'] === SpanKind::KIND_PRODUCER + )); + self::assertCount(2 * count($producers), $phase['dispatch']['boundaries']); + foreach ($phase['dispatch']['boundaries'] as $boundary) { + self::assertSame(0, $boundary['transaction_level']); + } + foreach ($phase['dispatch']['queued'] as $queued) { + if (! $enabled) { + self::assertNull($queued['trace']); + continue; + } + $carrier = $queued['trace']; + self::assertSame('durable-workflow.queue-trace', $carrier['schema']); + self::assertSame(1, $carrier['version']); + self::assertSame($queued['task_id'], $carrier['task_id']); + self::assertLessThanOrEqual(4096, strlen(json_encode($carrier, JSON_THROW_ON_ERROR))); + self::assertArrayNotHasKey('baggage', $carrier['context']['carrier']); + $producer = $dispatchByTask[$queued['task_id']]; + self::assertSame( + '00-' . $producer['trace_id'] . '-' . $producer['span_id'] . '-01', + $carrier['context']['carrier']['traceparent'] + ); + } + if ($producers !== []) { + $parent = array_values(array_filter($phase['spans'], static fn (array $span): bool => + $span['kind'] === SpanKind::KIND_CONSUMER || $span['name'] === 'durable.workflow.admission')); + self::assertCount(1, $parent); + foreach ($producers as $producer) { + self::assertSame($parent[0]['span_id'], $producer['parent_id']); + } + } + } + foreach ($workers as $index => $phase) { + $consumers = array_values(array_filter( + $phase['spans'], + static fn (array $span): bool => $span['kind'] === SpanKind::KIND_CONSUMER + )); + self::assertCount($workerEnabled ? 1 : 0, $consumers); + if (! $workerEnabled) { + continue; + } + $span = $consumers[0]; + self::assertSame($admissionEnabled ? '00f067aa0ba902b7' : '0000000000000000', $span['parent_id']); + self::assertNotSame('11111111111111111111111111111111', $span['trace_id']); + $delivered = $phase['dispatch']['delivered']; + self::assertCount(1, $delivered); + self::assertSame($span['fields']['task_id'], $delivered[0]['task_id']); + $tracedDispatch = $index === 0 ? $admissionEnabled : $workerEnabled; + self::assertCount($tracedDispatch ? 1 : 0, $span['links']); + if ($tracedDispatch) { + $producer = $dispatchByTask[$span['fields']['task_id']]; + self::assertSame($producer['trace_id'], $span['links'][0]['trace_id']); + self::assertSame($producer['span_id'], $span['links'][0]['span_id']); + self::assertSame($span['fields']['task_id'], $span['links'][0]['fields']['task_id']); + } + } + $history = json_encode($finished['dispatch']['history_payloads'], JSON_THROW_ON_ERROR); + self::assertStringNotContainsString('dispatchObservabilityContext', $history); + self::assertStringNotContainsString('durable-workflow.queue-trace', $history); + self::assertStringNotContainsString('must-not-propagate', json_encode($allSpans, JSON_THROW_ON_ERROR)); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } +} diff --git a/tests/Unit/V2/ExecutionTracingTest.php b/tests/Unit/V2/ExecutionTracingTest.php index a12699cce..5ad34a3ee 100644 --- a/tests/Unit/V2/ExecutionTracingTest.php +++ b/tests/Unit/V2/ExecutionTracingTest.php @@ -17,16 +17,22 @@ use OpenTelemetry\Context\Context; use OpenTelemetry\Context\ContextInterface; use OpenTelemetry\Context\ScopeInterface; +use OpenTelemetry\SDK\Common\Attribute\AttributesInterface; use OpenTelemetry\SDK\Trace\ReadableSpanInterface; +use OpenTelemetry\SDK\Trace\ReadWriteSpanInterface; +use OpenTelemetry\SDK\Trace\Sampler\AlwaysOnSampler; +use OpenTelemetry\SDK\Trace\SamplingResult; use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor; use OpenTelemetry\SDK\Trace\TracerProvider; use PHPUnit\Framework\TestCase; use RuntimeException; use Symfony\Component\Process\Process; +use Workflow\V2\Jobs\RunWorkflowTask; use Workflow\V2\Models\WorkflowCommand; use Workflow\V2\Models\WorkflowInstance; use Workflow\V2\Models\WorkflowRun; +use Workflow\V2\Models\WorkflowTask; use Workflow\V2\SignalWithStartResult; use Workflow\V2\StartResult; use Workflow\V2\Support\ExecutionTracing; @@ -479,6 +485,161 @@ public function getAttribute($key): mixed ); } + public function testQueueDispatchUsesOnlyAnExplicitDurableParentAndPreservesTheOriginalFailure(): void + { + $task = new WorkflowTask(); + $task->setRawAttributes([ + 'workflow_run_id' => 'run-1', + 'namespace' => '0', + 'task_type' => 'workflow', + ], true); + $job = new RunWorkflowTask('next-task'); + $foreign = Context::createKey('unrelated-dispatch-state'); + $ambient = Context::getCurrent()->with($foreign, 'private')->activate(); + try { + self::assertSame(7, ExecutionTracing::dispatch($task, $job, static function () use ($foreign): int { + self::assertNull(Context::getCurrent()->get($foreign)); + self::assertSame('next-task', ExecutionTracing::current()['task_id']); + + return 7; + }, enabled: true)); + self::assertSame('private', Context::getCurrent()->get($foreign)); + self::assertFalse($this->exporter->getSpans()[0]->getParentContext()->isValid()); + $failure = new RuntimeException('private-dispatch-failure'); + ExecutionTracing::workflow(self::fields(), self::carrier(), static function () use ( + $task, + $job, + $failure + ): void { + $context = ExecutionTracing::current(); + try { + ExecutionTracing::dispatch($task, $job, static fn () => throw $failure, enabled: true); + self::fail('The original dispatch exception must survive.'); + } catch (RuntimeException $observed) { + self::assertSame($failure, $observed); + } + self::assertSame($context, ExecutionTracing::current()); + }, enabled: true); + $spans = $this->exporter->getSpans(); + self::assertCount(3, $spans); + self::assertSame($spans[2]->getContext()->getSpanId(), $spans[1]->getParentContext()->getSpanId()); + self::assertSame(StatusCode::STATUS_ERROR, $spans[1]->getStatus()->getCode()); + self::assertSame(RuntimeException::class, $spans[1]->getAttributes()->get('error.type')); + self::assertSame([], $spans[1]->getEvents()); + self::assertNull($spans[1]->getAttributes()->get('durable.dispatch.outcome')); + self::assertStringNotContainsString('private-dispatch', $spans[1]->getStatus()->getDescription()); + } finally { + $ambient->detach(); + } + } + + public function testDispatchSetupFailureAndDisabledInstrumentationCannotEnrichOrReplaceTheOperation(): void + { + $processor = new class(new InMemoryExporter()) extends SimpleSpanProcessor { + public function onStart(ReadWriteSpanInterface $span, ContextInterface $parentContext): void + { + throw new RuntimeException('optional-start-failure'); + } + }; + $provider = new TracerProvider($processor); + $configuration = Configurator::create()->withTracerProvider($provider)->activate(); + $task = new class() extends WorkflowTask { + public int $reads = 0; + + public function getRawOriginal($key = null, $default = null): mixed + { + ++$this->reads; + throw new RuntimeException('optional-task-metadata'); + } + }; + $job = new RunWorkflowTask('next-task'); + $calls = 0; + $operation = static function () use (&$calls): array { + ++$calls; + + return [7, null, '雪']; + }; + try { + self::assertSame([7, null, '雪'], ExecutionTracing::dispatch($task, $job, $operation)); + self::assertSame(0, $task->reads); + self::assertSame([7, null, '雪'], ExecutionTracing::dispatch($task, $job, $operation, enabled: true)); + self::assertSame(1, $task->reads); + self::assertSame(2, $calls); + self::assertNull($job->dispatchObservabilityContext); + self::assertSame([], ExecutionTracing::current()); + } finally { + $configuration->detach(); + $provider->shutdown(); + } + } + + public function testAllConsumerKindsExposeTheDispatchLinkToTheSamplerWithoutReplacingTheirRunParent(): void + { + $sampler = new class() extends AlwaysOnSampler { + public array $links = []; + + public function shouldSample( + ContextInterface $parentContext, + string $traceId, + string $spanName, + int $spanKind, + AttributesInterface $attributes, + array $links + ): SamplingResult { + if ($spanKind === SpanKind::KIND_CONSUMER) { + $this->links[] = [$spanName, $links]; + } + + return parent::shouldSample($parentContext, $traceId, $spanName, $spanKind, $attributes, $links); + } + }; + $provider = new TracerProvider(new SimpleSpanProcessor($this->exporter), sampler: $sampler); + $configuration = Configurator::create()->withTracerProvider($provider) + ->withPropagator(TraceContextPropagator::getInstance())->activate(); + try { + $task = new WorkflowTask(); + $task->setRawAttributes([ + 'workflow_run_id' => 'run-1', + 'namespace' => '0', + 'task_type' => 'workflow', + ], true); + $job = new RunWorkflowTask('next-task'); + self::assertSame(7, ExecutionTracing::dispatch($task, $job, static fn () => 7, enabled: true)); + self::assertNotNull($job->dispatchObservabilityContext); + $producer = $this->exporter->getSpans()[0]; + self::assertNotSame(self::TRACE_ID, $producer->getContext()->getTraceId()); + foreach (['workflow', 'activity', 'timer'] as $method) { + self::assertSame(9, ExecutionTracing::{$method}( + array_replace(self::fields(), [ + 'task_id' => 'next-task', + ]), + self::carrier(), + static fn () => 9, + enabled: true, + dispatchContext: $job->dispatchObservabilityContext + )); + } + self::assertCount(3, $sampler->links); + foreach ($sampler->links as [$name, $links]) { + self::assertContains( + $name, + ['durable.workflow.task', 'durable.activity.attempt', 'durable.timer.task'] + ); + self::assertCount(1, $links); + self::assertSame($producer->getContext()->getSpanId(), $links[0]->getSpanContext()->getSpanId()); + self::assertSame('next-task', $links[0]->getAttributes()->get('task_id')); + } + self::assertCount(4, $this->exporter->getSpans()); + foreach (array_slice($this->exporter->getSpans(), 1) as $consumer) { + self::assertSame(self::TRACE_ID, $consumer->getContext()->getTraceId()); + self::assertSame(self::PARENT_ID, $consumer->getParentContext()->getSpanId()); + } + } finally { + $configuration->detach(); + $provider->shutdown(); + } + } + public function testOptionalApiAbsenceStillExecutesTheCallbackExactlyOnce(): void { $script = <<<'PHP' diff --git a/tests/Unit/V2/IncomingHttpTracingTest.php b/tests/Unit/V2/IncomingHttpTracingTest.php index 25c4dd4f5..af4fab2a1 100644 --- a/tests/Unit/V2/IncomingHttpTracingTest.php +++ b/tests/Unit/V2/IncomingHttpTracingTest.php @@ -52,13 +52,13 @@ public function testRequestContextSurvivesServerExitAndTwoIndependentActivityWor self::assertFalse($server->isRunning()); self::assertSame([], $admitted['outside_context']); self::assertTrue($admitted['scope_restored']); - self::assertCount(2, $admitted['spans']); + self::assertCount(2, $admitted['execution_spans']); $requestSpans = array_values(array_filter( - $admitted['spans'], + $admitted['execution_spans'], static fn (array $span): bool => $span['kind'] === SpanKind::KIND_SERVER )); $admissionSpans = array_values(array_filter( - $admitted['spans'], + $admitted['execution_spans'], static fn (array $span): bool => $span['name'] === 'durable.workflow.admission' )); self::assertCount(1, $requestSpans); @@ -90,8 +90,8 @@ public function testRequestContextSurvivesServerExitAndTwoIndependentActivityWor foreach ($workers as $worker) { self::assertSame([], $worker['outside_context']); self::assertTrue($worker['scope_restored']); - self::assertCount(1, $worker['spans']); - $span = $worker['spans'][0]; + self::assertCount(1, $worker['execution_spans']); + $span = $worker['execution_spans'][0]; self::assertSame($requestSpan['trace_id'], $span['trace_id']); self::assertSame($requestSpan['span_id'], $span['parent_id']); self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); diff --git a/tests/Unit/V2/RecoveryExecutionTracingTest.php b/tests/Unit/V2/RecoveryExecutionTracingTest.php index 456111284..ebea59178 100644 --- a/tests/Unit/V2/RecoveryExecutionTracingTest.php +++ b/tests/Unit/V2/RecoveryExecutionTracingTest.php @@ -126,7 +126,7 @@ public function testWorkerCrashThenAuthoredTimeoutRecoversContextInAColdRetry(bo foreach ($workers as $worker) { self::assertSame([], $worker['outside_context']); self::assertTrue($worker['scope_restored']); - foreach ($worker['spans'] as $span) { + foreach ($worker['execution_spans'] as $span) { if ($span['name'] === 'durable.activity.attempt') { $spans[] = $span; } diff --git a/tests/Unit/V2/SignalLinkExecutionTracingTest.php b/tests/Unit/V2/SignalLinkExecutionTracingTest.php index bafd27bd4..5ebcfffa1 100644 --- a/tests/Unit/V2/SignalLinkExecutionTracingTest.php +++ b/tests/Unit/V2/SignalLinkExecutionTracingTest.php @@ -65,9 +65,19 @@ public function testIndependentSignalCallersLinkOnlyTheirActualApplicationSegmen }; try { $admitted = $worker('admit'); - self::assertSame($admissionEnabled ? [0] : [], $admitted['signals']['capture_transaction_levels']); + self::assertSame( + $admissionEnabled ? array_fill( + 0, + 1 + count($admitted['spans']) - count($admitted['execution_spans']), + 0 + ) : [], + $admitted['signals']['capture_transaction_levels'] + ); $early = $worker('signal'); - self::assertSame($workerEnabled ? [0] : [], $early['signals']['capture_transaction_levels']); + self::assertSame( + $workerEnabled ? array_fill(0, 1 + count($early['spans']) - count($early['execution_spans']), 0) : [], + $early['signals']['capture_transaction_levels'] + ); self::assertSame($workerEnabled, $early['signals']['commands'][0]['carrier'] !== null); $waiting = $worker('work'); self::assertSame( @@ -77,7 +87,10 @@ public function testIndependentSignalCallersLinkOnlyTheirActualApplicationSegmen ); self::assertSame(0, $waiting['jobs']); $late = $worker('signal', 'second'); - self::assertSame($workerEnabled ? [0] : [], $late['signals']['capture_transaction_levels']); + self::assertSame( + $workerEnabled ? array_fill(0, 1 + count($late['spans']) - count($late['execution_spans']), 0) : [], + $late['signals']['capture_transaction_levels'] + ); $workers = [$waiting]; for ($remaining = 6; $remaining > 0 && $worker('inspect')['jobs'] > 0; --$remaining) { $workers[] = $worker('work'); @@ -109,7 +122,7 @@ public function testIndependentSignalCallersLinkOnlyTheirActualApplicationSegmen self::assertSame('completed', $task['status']); self::assertSame(1, $task['attempt_count']); } - $spans = array_merge(...array_column($workers, 'spans')); + $spans = array_merge(...array_column($workers, 'execution_spans')); self::assertCount($workerEnabled ? 4 : 0, $spans); foreach ($spans as $span) { self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); diff --git a/tests/Unit/V2/SignalStartExecutionTracingTest.php b/tests/Unit/V2/SignalStartExecutionTracingTest.php index c883b0a8b..cb6f00656 100644 --- a/tests/Unit/V2/SignalStartExecutionTracingTest.php +++ b/tests/Unit/V2/SignalStartExecutionTracingTest.php @@ -70,10 +70,20 @@ public function testSignalWithStartKeepsItsOriginalTraceAcrossAnotherCallerAndCo $originalHasParent = $admissionEnabled && ! $withoutParent; self::assertSame($originalHasParent, $admitted['carrier'] !== null); self::assertSame($originalHasParent, $admitted['signal_commands'][0]['carrier'] !== null); - self::assertSame($admissionEnabled ? [0] : [], $admitted['capture_transaction_levels']); + self::assertSame( + $admissionEnabled ? array_fill( + 0, + 1 + count($admitted['spans']) - count($admitted['execution_spans']), + 0 + ) : [], + $admitted['capture_transaction_levels'] + ); $existing = $worker('duplicate'); self::assertSame('returned_existing_active', $existing['signal_start_outcome']); - self::assertLessThanOrEqual($workerEnabled ? 1 : 0, count($existing['capture_transaction_levels'])); + self::assertLessThanOrEqual( + $workerEnabled ? 1 + count($existing['spans']) - count($existing['execution_spans']) : 0, + count($existing['capture_transaction_levels']) + ); foreach ($existing['capture_transaction_levels'] as $level) { self::assertSame(0, $level); } @@ -127,7 +137,7 @@ public function testSignalWithStartKeepsItsOriginalTraceAcrossAnotherCallerAndCo array_slice($finished['history_hashes'], 0, count($existing['history_hashes'])) ); self::assertSame(count($workers), count(array_unique(array_column($workers, 'pid')))); - $spans = array_merge(...array_column($workers, 'spans')); + $spans = array_merge(...array_column($workers, 'execution_spans')); self::assertCount($workerEnabled ? 3 : 0, $spans); foreach ($spans as $span) { self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); @@ -145,8 +155,11 @@ public function testSignalWithStartKeepsItsOriginalTraceAcrossAnotherCallerAndCo ); foreach ([$admitted, $existing, $rejected] as $index => $attempt) { $enabled = $index === 0 ? $admissionEnabled : $workerEnabled; - self::assertSame($enabled ? [0] : [], $attempt['capture_transaction_levels']); - self::assertCount($enabled ? 1 : 0, $attempt['spans']); + self::assertSame( + $enabled ? array_fill(0, 1 + count($attempt['spans']) - count($attempt['execution_spans']), 0) : [], + $attempt['capture_transaction_levels'] + ); + self::assertCount($enabled ? 1 : 0, $attempt['execution_spans']); if (! $enabled) { self::assertSame([], $attempt['admission']['boundaries']); continue; @@ -161,7 +174,7 @@ public function testSignalWithStartKeepsItsOriginalTraceAcrossAnotherCallerAndCo 'transaction_level' => 0, ], ], $attempt['admission']['boundaries']); - $span = $attempt['spans'][0]; + $span = $attempt['execution_spans'][0]; $receipt = $attempt['signal_start_result']; self::assertSame('durable.workflow.admission', $span['name']); self::assertSame(SpanKind::KIND_INTERNAL, $span['kind']); diff --git a/tests/Unit/V2/TaskTraceCarrierTest.php b/tests/Unit/V2/TaskTraceCarrierTest.php new file mode 100644 index 000000000..43b7f6042 --- /dev/null +++ b/tests/Unit/V2/TaskTraceCarrierTest.php @@ -0,0 +1,168 @@ +withPropagator(new MultiTextMapPropagator([ + TraceContextPropagator::getInstance(), BaggagePropagator::getInstance(), + ]))->activate(); + $parent = TraceContextPropagator::getInstance()->extract([ + 'traceparent' => self::PARENT, + 'tracestate' => 'vendor=opaque', + ], context: Context::getCurrent()); + $scope = Baggage::getBuilder()->set('private', 'must-not-queue')->build()->storeInContext($parent)->activate(); + try { + $carrier = TaskTraceCarrier::capture('task-1'); + self::assertSame(self::envelope(), $carrier); + self::assertSame(self::envelope()['context'], TaskTraceCarrier::fromJob($carrier, 'task-1')); + self::assertLessThanOrEqual(4096, strlen(json_encode($carrier, JSON_THROW_ON_ERROR))); + self::assertSame('must-not-queue', Baggage::getCurrent()->getValue('private')); + } finally { + $scope->detach(); + $configuration->detach(); + } + self::assertSame($initial, Context::getCurrent()); + } + + public static function invalidCarriers(): iterable + { + yield 'missing' => [null, 'task-1']; + yield 'scalar' => ['invalid', 'task-1']; + yield 'empty' => [[], 'task-1']; + foreach ([ + 'schema' => 'other', + 'version' => '1', + 'task_id' => 'other-task', + 'context' => null, + ] as $key => $value) { + yield $key => [array_replace(self::envelope(), [ + $key => $value, + ]), 'task-1']; + } + yield 'extra field' => [self::envelope() + [ + 'extra' => 'private', + ], 'task-1']; + yield 'wrong task type' => [array_replace(self::envelope(), [ + 'task_id' => 1, + ]), 'task-1']; + yield 'invalid expected task' => [self::envelope(), '']; + yield 'oversized task' => [array_replace(self::envelope(), [ + 'task_id' => str_repeat('x', 4097), + ]), str_repeat('x', 4097)]; + foreach ([ + 'invalid parent' => [ + 'traceparent' => 'invalid', + ], + 'oversized parent' => [ + 'traceparent' => str_repeat('x', 257), + ], + 'oversized state' => [ + 'traceparent' => self::PARENT, + 'tracestate' => str_repeat('x', 513), + ], + 'baggage' => [ + 'traceparent' => self::PARENT, + 'baggage' => 'private=discard', + ], + ] as $name => $headers) { + $envelope = self::envelope(); + $envelope['context']['carrier'] = $headers; + yield $name => [$envelope, 'task-1']; + } + } + + #[DataProvider('invalidCarriers')] + public function testInvalidMetadataSuppliesNoContext(mixed $value, string $taskId): void + { + self::assertNull(TaskTraceCarrier::fromJob($value, $taskId)); + } + + public function testMetadataInspectionDoesNotExecuteUserObjectSerialization(): void + { + $object = new class() implements JsonSerializable { + public int $calls = 0; + + public function jsonSerialize(): mixed + { + ++$this->calls; + + return 'must-not-execute'; + } + }; + self::assertNull(TaskTraceCarrier::fromJob($object, 'task-1')); + $envelope = self::envelope(); + $envelope['context']['carrier']['traceparent'] = $object; + self::assertNull(TaskTraceCarrier::fromJob($envelope, 'task-1')); + self::assertSame(0, $object->calls); + } + + public function testPublishedSerializedJobsLoadAndDefaultNullAddsNoWireBytes(): void + { + // Constructor-only captures from the released image; these are not hand-authored objects. + $fixture = json_decode( + file_get_contents(__DIR__ . '/../../Fixtures/V2/tracing-legacy-queue-jobs.json'), + true, + flags: JSON_THROW_ON_ERROR + ); + $allowed = [RunWorkflowTask::class, RunActivityTask::class, RunTimerTask::class]; + foreach ($fixture['jobs'] as $index => $original) { + $job = unserialize($original['serialized'], [ + 'allowed_classes' => $allowed, + ]); + self::assertInstanceOf($original['class'], $job); + self::assertSame('legacy-task-' . $index, $job->taskId); + self::assertTrue($job->afterCommit); + self::assertTrue(property_exists($job, 'dispatchObservabilityContext')); + self::assertNull($job->dispatchObservabilityContext); + self::assertSame($original['serialized'], serialize($job)); + self::assertSame($original['serialized'], serialize(new ($original['class'])($job->taskId))); + $job->dispatchObservabilityContext = 'malformed'; + $restored = unserialize(serialize($job), [ + 'allowed_classes' => $allowed, + ]); + self::assertSame($job->taskId, $restored->taskId); + self::assertTrue($restored->afterCommit); + self::assertSame('malformed', $restored->dispatchObservabilityContext); + self::assertNull(TaskTraceCarrier::fromJob($restored->dispatchObservabilityContext, $restored->taskId)); + } + } + + private static function envelope(): array + { + return [ + 'schema' => 'durable-workflow.queue-trace', + 'version' => 1, + 'task_id' => 'task-1', + 'context' => [ + 'schema' => 'durable-workflow.trace-context', + 'version' => 1, + 'carrier' => [ + 'traceparent' => self::PARENT, + 'tracestate' => 'vendor=opaque', + ], + ], + ]; + } +} diff --git a/tests/Unit/V2/TimingExecutionTracingTest.php b/tests/Unit/V2/TimingExecutionTracingTest.php index e9aafee4e..6591c69ff 100644 --- a/tests/Unit/V2/TimingExecutionTracingTest.php +++ b/tests/Unit/V2/TimingExecutionTracingTest.php @@ -106,7 +106,7 @@ public function testActualTimerAndRetryWorkersSeparateStoredWaitingFromProcessin self::assertSame('completed', $task['status']); self::assertSame($task['payload']['retry_after_attempt'] ?? 0, $task['attempt_count'] - 1); } - $spans = array_merge(...array_column($workers, 'spans')); + $spans = array_merge(...array_column($workers, 'execution_spans')); self::assertCount($workerEnabled ? 6 : 0, $spans); foreach ($spans as $span) { self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); diff --git a/tests/Unit/V2/TransitionExecutionTracingTest.php b/tests/Unit/V2/TransitionExecutionTracingTest.php index ab5e9dfc6..aa8d39371 100644 --- a/tests/Unit/V2/TransitionExecutionTracingTest.php +++ b/tests/Unit/V2/TransitionExecutionTracingTest.php @@ -101,7 +101,7 @@ public function testColdSuccessorRetainsTheAdmissionCarrierWithoutChangingDurabl self::assertNotSame($finished['runs'][0]['instance_id'], $finished['runs'][1]['instance_id']); } self::assertSame(count($workers), count(array_unique(array_column($workers, 'pid')))); - $spans = array_merge(...array_column($workers, 'spans')); + $spans = array_merge(...array_column($workers, 'execution_spans')); if ($enabled) { $observedRuns = []; foreach ($spans as $span) { diff --git a/tests/Unit/V2/UpdateLinkExecutionTracingTest.php b/tests/Unit/V2/UpdateLinkExecutionTracingTest.php index 91ad945f8..17925c1d1 100644 --- a/tests/Unit/V2/UpdateLinkExecutionTracingTest.php +++ b/tests/Unit/V2/UpdateLinkExecutionTracingTest.php @@ -74,7 +74,14 @@ public function testUpdateCallersLinkOnlyAcceptedProcessingAndKeepOriginalDecisi self::assertSame(0, $workers[0]['jobs']); $accepted = $worker('update'); self::assertTrue($accepted['updates']['submitted']['accepted']); - self::assertSame($workerEnabled ? [0] : [], $accepted['updates']['capture_transaction_levels']); + self::assertSame( + $workerEnabled ? array_fill( + 0, + 1 + count($accepted['spans']) - count($accepted['execution_spans']), + 0 + ) : [], + $accepted['updates']['capture_transaction_levels'] + ); $duplicate = $worker('update', duplicate: true); self::assertSame($accepted['updates']['submitted'], $duplicate['updates']['submitted']); self::assertSame($accepted['updates']['commands'], $duplicate['updates']['commands']); @@ -131,7 +138,7 @@ public function testUpdateCallersLinkOnlyAcceptedProcessingAndKeepOriginalDecisi self::assertSame('completed', $task['status']); self::assertSame(1, $task['attempt_count']); } - $spans = array_merge(...array_column($workers, 'spans')); + $spans = array_merge(...array_column($workers, 'execution_spans')); self::assertCount($workerEnabled ? 7 : 0, $spans); foreach ($spans as $index => $span) { self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); From 9faf19b57e3055258586935c4345fef0745185f0 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 21:56:39 +0000 Subject: [PATCH 27/77] refactor: clarify tracing cleanup and exporter assertions --- src/V2/Support/ExecutionTracing.php | 18 +++++++++++------- tests/Unit/V2/ExporterExecutionTracingTest.php | 16 ++++++++-------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php index 11c6b130d..3d6d91432 100644 --- a/src/V2/Support/ExecutionTracing.php +++ b/src/V2/Support/ExecutionTracing.php @@ -35,6 +35,8 @@ /** @internal */ final class ExecutionTracing { + private const PACKAGE_NAME = 'durable-workflow/workflow'; + private const FIELDS = [ 'workflow_instance_id', 'workflow_run_id', 'workflow_type', 'namespace', 'task_id', 'activity_id', 'activity_type', 'activity_attempt_id', @@ -75,6 +77,7 @@ public static function bind(callable $callback): callable $scope = $context->activate(); self::enterFiberScope(); } catch (Throwable) { + // Optional tracing setup must leave the original Fiber callback usable. } try { return $callback(...$arguments); @@ -351,8 +354,8 @@ private static function segment( $spanAttributes[$key] = $value; } } - $version = InstalledVersions::isInstalled('durable-workflow/workflow') - ? InstalledVersions::getPrettyVersion('durable-workflow/workflow') + $version = InstalledVersions::isInstalled(self::PACKAGE_NAME) + ? InstalledVersions::getPrettyVersion(self::PACKAGE_NAME) : null; if ($name === 'durable.task.dispatch' && in_array( $attributes['durable.task.type'] ?? null, @@ -361,7 +364,7 @@ private static function segment( )) { $spanAttributes['durable.task.type'] = $attributes['durable.task.type']; } - $builder = Globals::tracerProvider()->getTracer('durable-workflow/workflow', $version) + $builder = Globals::tracerProvider()->getTracer(self::PACKAGE_NAME, $version) ->spanBuilder($name) ->setSpanKind(match ($name) { 'durable.workflow.admission' => SpanKind::KIND_INTERNAL, @@ -487,17 +490,18 @@ private static function close(?ScopeInterface $scope, ?SpanInterface $span): voi try { $scope?->detach(); } catch (Throwable) { + // A telemetry scope failure cannot replace the business result or exception. } $fiber = Fiber::getCurrent(); - if ($scope !== null && $fiber !== null && isset(self::$activeFibers[$fiber])) { - if (--self::$activeFibers[$fiber] === 0) { - unset(self::$activeFibers[$fiber]); - } + if ($scope !== null && $fiber !== null && isset(self::$activeFibers[$fiber]) + && --self::$activeFibers[$fiber] === 0) { + unset(self::$activeFibers[$fiber]); } try { // The application owns buffering and provider shutdown. Never flush per task. $span?->end(); } catch (Throwable) { + // Exporter errors cannot replace an already determined business outcome. } } diff --git a/tests/Unit/V2/ExporterExecutionTracingTest.php b/tests/Unit/V2/ExporterExecutionTracingTest.php index fa8243275..efb8901e4 100644 --- a/tests/Unit/V2/ExporterExecutionTracingTest.php +++ b/tests/Unit/V2/ExporterExecutionTracingTest.php @@ -58,7 +58,7 @@ public function testCollectorFailureDoesNotChangeColdWorkflowCompletion(string $ foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { self::assertSame($admitted[$key], $finished[$key]); } - $spans = array_merge(...array_column($workers, 'spans')); + $spans = array_merge(...array_column($workers, 'execution_spans')); self::assertCount(3, $spans); foreach ($spans as $span) { self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); @@ -66,18 +66,18 @@ public function testCollectorFailureDoesNotChangeColdWorkflowCompletion(string $ } $requests = $this->requests($directory); self::assertCount($mode === 'refused' ? 0 : 4, $requests); - foreach ($requests as $request) { + foreach ($requests as $index => $request) { self::assertSame('POST', $request['method']); self::assertSame('/v1/traces', $request['path']); self::assertSame('application/x-protobuf', $request['content_type']); - self::assertCount(1, $request['spans']); + self::assertCount($index < 3 ? 2 : 1, $request['spans']); } if ($mode !== 'refused') { - self::assertSame(['durable.workflow.admission', 'durable.workflow.task', - 'durable.activity.attempt', 'durable.workflow.task'], array_map( - static fn (array $request): string => $request['spans'][0]['name'], - $requests - )); + self::assertSame([ + 'durable.task.dispatch', 'durable.workflow.admission', + 'durable.task.dispatch', 'durable.workflow.task', + 'durable.task.dispatch', 'durable.activity.attempt', 'durable.workflow.task', + ], array_column(array_merge(...array_column($requests, 'spans')), 'name')); } } finally { $server->stop(); From 751ec62cf322c645a0f5bb0186cae9a6a6628487 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 22:17:22 +0000 Subject: [PATCH 28/77] refactor: separate trace context and span metadata processing --- src/V2/Support/ExecutionTracing.php | 121 +++++++++++++++++----------- src/V2/Support/TraceCarrier.php | 54 ++++++++----- 2 files changed, 111 insertions(+), 64 deletions(-) diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php index 3d6d91432..a6bc287a6 100644 --- a/src/V2/Support/ExecutionTracing.php +++ b/src/V2/Support/ExecutionTracing.php @@ -15,6 +15,7 @@ use OpenTelemetry\API\Trace\SpanKind; use OpenTelemetry\API\Trace\StatusCode; use OpenTelemetry\Context\Context; +use OpenTelemetry\Context\ContextInterface; use OpenTelemetry\Context\ContextKeyInterface; use OpenTelemetry\Context\ScopeInterface; use Throwable; @@ -329,41 +330,12 @@ private static function segment( $span = null; $scope = null; try { - // Local admission keeps the caller's context. Workers recover only stored authority. - $localParent = $name === 'durable.workflow.admission' - || ($name === 'durable.task.dispatch' && self::current() !== []); - $parent = $localParent ? Context::getCurrent() : Context::getRoot(); - $carrier = TraceCarrier::normalize($persistedContext, $baggageAllowlist); - if ($carrier !== null) { - $parent = TraceContextPropagator::getInstance()->extract($carrier['carrier'], context: $parent); - $parent = BaggagePropagator::getInstance()->extract($carrier['carrier'], context: $parent); - } - // Preserve application providers without inheriting unrelated worker context. - $parent = Configurator::create() - ->withTracerProvider(Globals::tracerProvider()) - ->withMeterProvider(Globals::meterProvider()) - ->withLoggerProvider(Globals::loggerProvider()) - ->withPropagator(Globals::propagator()) - ->withResponsePropagator(Globals::responsePropagator()) - ->storeInContext($parent); + $parent = self::parentContext($name, $persistedContext, $baggageAllowlist); $fields = self::fields($attributes); - $spanAttributes = $fields; - foreach (self::TIMINGS as $key) { - $value = $attributes[$key] ?? null; - if ((is_int($value) || is_float($value)) && is_finite((float) $value) && $value >= 0) { - $spanAttributes[$key] = $value; - } - } + $spanAttributes = self::spanAttributes($name, $attributes, $fields); $version = InstalledVersions::isInstalled(self::PACKAGE_NAME) ? InstalledVersions::getPrettyVersion(self::PACKAGE_NAME) : null; - if ($name === 'durable.task.dispatch' && in_array( - $attributes['durable.task.type'] ?? null, - ['workflow', 'activity', 'timer'], - true - )) { - $spanAttributes['durable.task.type'] = $attributes['durable.task.type']; - } $builder = Globals::tracerProvider()->getTracer(self::PACKAGE_NAME, $version) ->spanBuilder($name) ->setSpanKind(match ($name) { @@ -411,21 +383,7 @@ private static function segment( try { $result = $operation(); - if ($name === 'durable.task.dispatch' && $span !== null) { - try { - $span->setAttribute('durable.dispatch.outcome', 'returned'); - } catch (Throwable) { - // A queue return is not completion or telemetry-delivery authority. - } - } - if ($name === 'durable.workflow.admission' - && ($result instanceof StartResult || $result instanceof SignalWithStartResult) && $span !== null) { - try { - self::recordAdmissionResult($span, $result); - } catch (Throwable) { - // A telemetry receipt read cannot replace the committed decision. - } - } + self::recordResult($span, $name, $result); return $result; } catch (Throwable $error) { @@ -436,6 +394,77 @@ private static function segment( } } + /** + * @param list $baggageAllowlist + */ + private static function parentContext( + string $name, + mixed $persistedContext, + array $baggageAllowlist + ): ContextInterface { + // Local admission keeps the caller's context. Workers recover only stored authority. + $localParent = $name === 'durable.workflow.admission' + || ($name === 'durable.task.dispatch' && self::current() !== []); + $parent = $localParent ? Context::getCurrent() : Context::getRoot(); + $carrier = TraceCarrier::normalize($persistedContext, $baggageAllowlist); + if ($carrier !== null) { + $parent = TraceContextPropagator::getInstance()->extract($carrier['carrier'], context: $parent); + $parent = BaggagePropagator::getInstance()->extract($carrier['carrier'], context: $parent); + } + + // Preserve application providers without inheriting unrelated worker context. + return Configurator::create() + ->withTracerProvider(Globals::tracerProvider()) + ->withMeterProvider(Globals::meterProvider()) + ->withLoggerProvider(Globals::loggerProvider()) + ->withPropagator(Globals::propagator()) + ->withResponsePropagator(Globals::responsePropagator()) + ->storeInContext($parent); + } + + /** + * @param array $attributes + * @param array $fields + * @return array + */ + private static function spanAttributes(string $name, array $attributes, array $fields): array + { + $spanAttributes = $fields; + foreach (self::TIMINGS as $key) { + $value = $attributes[$key] ?? null; + if ((is_int($value) || is_float($value)) && is_finite((float) $value) && $value >= 0) { + $spanAttributes[$key] = $value; + } + } + if ($name === 'durable.task.dispatch' && in_array( + $attributes['durable.task.type'] ?? null, + ['workflow', 'activity', 'timer'], + true + )) { + $spanAttributes['durable.task.type'] = $attributes['durable.task.type']; + } + + return $spanAttributes; + } + + private static function recordResult(?SpanInterface $span, string $name, mixed $result): void + { + if ($span === null) { + return; + } + try { + if ($name === 'durable.task.dispatch') { + $span->setAttribute('durable.dispatch.outcome', 'returned'); + } + if ($name === 'durable.workflow.admission' + && ($result instanceof StartResult || $result instanceof SignalWithStartResult)) { + self::recordAdmissionResult($span, $result); + } + } catch (Throwable) { + // Optional receipt metadata cannot replace a returned business value. + } + } + /** * @param array $attributes * @return array diff --git a/src/V2/Support/TraceCarrier.php b/src/V2/Support/TraceCarrier.php index 55bbe964d..06bef4976 100644 --- a/src/V2/Support/TraceCarrier.php +++ b/src/V2/Support/TraceCarrier.php @@ -146,25 +146,11 @@ public static function normalize(mixed $value, array $baggageAllowlist = []): ?a $normalized = []; TraceContextPropagator::getInstance()->inject($normalized, context: $context); - if ($baggageAllowlist !== [] && isset($carrier['baggage'])) { - $baggageContext = BaggagePropagator::getInstance()->extract($carrier, context: Context::getRoot()); - $builder = Baggage::getBuilder(); - $count = 0; - foreach (Baggage::fromContext($baggageContext)->getAll() as $key => $entry) { - if (! in_array($key, $baggageAllowlist, true)) { - continue; - } - if (++$count > 32) { - return null; - } - $builder->set($key, $entry->getValue(), $entry->getMetadata()); - } - BaggagePropagator::getInstance()->inject( - $normalized, - context: $builder->build() - ->storeInContext(Context::getRoot()), - ); + $baggage = self::normalizedBaggage($carrier, $baggageAllowlist); + if ($baggage === null) { + return null; } + $normalized = array_replace($normalized, $baggage); if (isset($normalized['baggage']) && strlen($normalized['baggage']) > self::LIMITS['baggage']) { return null; @@ -180,4 +166,36 @@ public static function normalize(mixed $value, array $baggageAllowlist = []): ?a return null; } } + + /** + * @param array $carrier + * @param list $allowlist + * @return array|null + */ + private static function normalizedBaggage(array $carrier, array $allowlist): ?array + { + if ($allowlist === [] || ! isset($carrier['baggage'])) { + return []; + } + $context = BaggagePropagator::getInstance()->extract($carrier, context: Context::getRoot()); + $builder = Baggage::getBuilder(); + $count = 0; + foreach (Baggage::fromContext($context)->getAll() as $key => $entry) { + if (! in_array($key, $allowlist, true)) { + continue; + } + if (++$count > 32) { + return null; + } + $builder->set($key, $entry->getValue(), $entry->getMetadata()); + } + $normalized = []; + BaggagePropagator::getInstance()->inject( + $normalized, + context: $builder->build() + ->storeInContext(Context::getRoot()), + ); + + return $normalized; + } } From 4a5a15a4daf1740ffb70d89bd52d93e1d00e1bd7 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 22:30:36 +0000 Subject: [PATCH 29/77] fix: keep optional tracing configuration outside business failures --- docs/embedded-tracing.md | 5 + src/V2/Jobs/RunActivityTask.php | 8 +- src/V2/Jobs/RunTimerTask.php | 6 +- src/V2/Jobs/RunWorkflowTask.php | 6 +- src/V2/Support/ExecutionTracing.php | 27 ++++ src/V2/Support/TaskDispatcher.php | 8 +- src/V2/Support/TraceCarrier.php | 10 +- src/V2/Support/WorkflowExecutor.php | 8 +- src/V2/WorkflowStub.php | 10 +- .../Unit/V2/ActivityExecutionTracingTest.php | 128 ++++++++++++++++++ 10 files changed, 182 insertions(+), 34 deletions(-) diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index 11388121e..cc40a7f92 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -11,6 +11,11 @@ exporters and process shutdown. Durable Workflow uses those providers when `workflows.tracing.enabled` is true. It does not replace application providers or flush an exporter after each task. Tracing is disabled by default. +An unavailable tracing-enabled setting disables instrumentation for that +operation. An unavailable or non-array baggage allowlist permits no baggage; +only string entries are used. Neither optional setting can prevent admission, +timer or activity execution, or replace the workflow's own failure. + ## Keep collector outages outside business work PHP exporters can perform synchronous HTTP requests. A batch processor alone diff --git a/src/V2/Jobs/RunActivityTask.php b/src/V2/Jobs/RunActivityTask.php index 02687ef8d..2042b1e65 100644 --- a/src/V2/Jobs/RunActivityTask.php +++ b/src/V2/Jobs/RunActivityTask.php @@ -69,13 +69,13 @@ public function handle(): void return; } - if (config('workflows.tracing.enabled', false) !== true) { + if (! ExecutionTracing::enabled()) { $this->executeClaim($claim); return; } - $allowlist = config('workflows.tracing.baggage_allowlist', []); + $allowlist = ExecutionTracing::baggageAllowlist(); $storedContext = TraceCarrier::fromRun($claim->run); ExecutionTracing::activity( [ @@ -92,7 +92,7 @@ public function handle(): void ], $storedContext, fn () => $this->executeClaim($claim), - is_array($allowlist) ? $allowlist : [], + $allowlist, true, $this->dispatchObservabilityContext, ); @@ -144,7 +144,7 @@ private function executeClaim(ActivityTaskClaim $claim): void } $maxAttempts = ActivityRetryPolicy::maxAttempts($execution, $activity); - if ($throwable !== null && config('workflows.tracing.enabled', false) === true) { + if ($throwable !== null && ExecutionTracing::enabled()) { ExecutionTracing::recordFailure($throwable); } $backoffSeconds = ActivityRetryPolicy::backoffSeconds($execution, $activity, $attemptCount); diff --git a/src/V2/Jobs/RunTimerTask.php b/src/V2/Jobs/RunTimerTask.php index bea79b7f4..10a7535f8 100644 --- a/src/V2/Jobs/RunTimerTask.php +++ b/src/V2/Jobs/RunTimerTask.php @@ -104,14 +104,14 @@ public function handle(): void /** @var WorkflowTimer $timer */ $timer = $claimedTask->getRelation('timer'); - if (config('workflows.tracing.enabled', false) !== true) { + if (! ExecutionTracing::enabled()) { $this->executeClaim($timer->id); return; } /** @var WorkflowRun $run */ $run = $claimedTask->getRelation('run'); - $allowlist = config('workflows.tracing.baggage_allowlist', []); + $allowlist = ExecutionTracing::baggageAllowlist(); ExecutionTracing::timer( [ 'workflow_instance_id' => $run->workflow_instance_id, @@ -124,7 +124,7 @@ public function handle(): void ], TraceCarrier::fromRun($run), fn () => $this->executeClaim($timer->id), - is_array($allowlist) ? $allowlist : [], + $allowlist, true, $this->dispatchObservabilityContext, ); diff --git a/src/V2/Jobs/RunWorkflowTask.php b/src/V2/Jobs/RunWorkflowTask.php index 8f5b53555..4bcc41d88 100644 --- a/src/V2/Jobs/RunWorkflowTask.php +++ b/src/V2/Jobs/RunWorkflowTask.php @@ -59,7 +59,7 @@ public function handle(WorkflowExecutor $executor): void return; } - if (config('workflows.tracing.enabled', false) !== true) { + if (! ExecutionTracing::enabled()) { $this->executeClaim($executor); return; @@ -67,7 +67,7 @@ public function handle(WorkflowExecutor $executor): void /** @var WorkflowRun $run */ $run = $task->getRelation('run'); - $allowlist = config('workflows.tracing.baggage_allowlist', []); + $allowlist = ExecutionTracing::baggageAllowlist(); $storedContext = TraceCarrier::fromRun($run); // Activate and end the segment outside the execution transaction. ExecutionTracing::workflow( @@ -81,7 +81,7 @@ public function handle(WorkflowExecutor $executor): void ], $storedContext, fn () => $this->executeClaim($executor), - is_array($allowlist) ? $allowlist : [], + $allowlist, true, $this->dispatchObservabilityContext, ); diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php index a6bc287a6..6a0fff9b7 100644 --- a/src/V2/Support/ExecutionTracing.php +++ b/src/V2/Support/ExecutionTracing.php @@ -60,6 +60,33 @@ final class ExecutionTracing */ private static ?WeakMap $activeFibers = null; + public static function enabled(): bool + { + try { + return config('workflows.tracing.enabled', false) === true; + } catch (Throwable) { + // Optional configuration must not interrupt admission or execution. + return false; + } + } + + /** + * @return list + */ + public static function baggageAllowlist(): array + { + try { + $allowlist = config('workflows.tracing.baggage_allowlist', []); + + return is_array($allowlist) + ? array_values(array_filter($allowlist, static fn (mixed $value): bool => is_string($value))) + : []; + } catch (Throwable) { + // Without a readable allowlist, propagate no baggage. + return []; + } + } + public static function bind(callable $callback): callable { try { diff --git a/src/V2/Support/TaskDispatcher.php b/src/V2/Support/TaskDispatcher.php index 6d847c5f8..b6dd84d16 100644 --- a/src/V2/Support/TaskDispatcher.php +++ b/src/V2/Support/TaskDispatcher.php @@ -80,13 +80,7 @@ private static function publish( return; } - $tracing = false; - try { - $tracing = config('workflows.tracing.enabled', false) === true; - } catch (Throwable) { - // Optional tracing configuration cannot block queue dispatch. - } - if ($tracing) { + if (ExecutionTracing::enabled()) { ExecutionTracing::dispatch( $task, $job, diff --git a/src/V2/Support/TraceCarrier.php b/src/V2/Support/TraceCarrier.php index 06bef4976..2fbeb7bdb 100644 --- a/src/V2/Support/TraceCarrier.php +++ b/src/V2/Support/TraceCarrier.php @@ -35,12 +35,10 @@ final class TraceCarrier public static function captureConfigured(): ?array { try { - if (config('workflows.tracing.enabled', false) !== true) { + if (! ExecutionTracing::enabled()) { return null; } - $allowlist = config('workflows.tracing.baggage_allowlist', []); - - return self::capture(is_array($allowlist) ? $allowlist : []); + return self::capture(ExecutionTracing::baggageAllowlist()); } catch (Throwable) { return null; } @@ -74,9 +72,7 @@ public static function fromRun(WorkflowRun $run): ?array if (! is_array($stored)) { return null; } - $allowlist = config('workflows.tracing.baggage_allowlist', []); - - return self::normalize($stored, is_array($allowlist) ? $allowlist : []); + return self::normalize($stored, ExecutionTracing::baggageAllowlist()); } catch (Throwable) { // Optional telemetry metadata must not interrupt child admission or retries. return null; diff --git a/src/V2/Support/WorkflowExecutor.php b/src/V2/Support/WorkflowExecutor.php index 70de47874..9b401e094 100644 --- a/src/V2/Support/WorkflowExecutor.php +++ b/src/V2/Support/WorkflowExecutor.php @@ -3238,7 +3238,7 @@ private function applySignal( 'value' => ExternalPayloads::historyValue($storedValue, $run->payload_codec, $run->namespace), ...($parallelMetadata ?? []), ], static fn (mixed $payloadValue): bool => $payloadValue !== null), $task, $command); - if (config('workflows.tracing.enabled', false) === true) { + if (ExecutionTracing::enabled()) { ExecutionTracing::linkSignal($run, $command); } @@ -4362,7 +4362,7 @@ private function failRun( throw $throwable; } - if (config('workflows.tracing.enabled', false) === true) { + if (ExecutionTracing::enabled()) { ExecutionTracing::recordFailure($throwable); } @@ -5746,7 +5746,7 @@ private function deliverCancellationAtCall( $operationSequence, $operationSequenceSpan, ); - if (config('workflows.tracing.enabled', false) === true) { + if (ExecutionTracing::enabled()) { ExecutionTracing::linkCancellation($run); } @@ -6467,7 +6467,7 @@ private function applyPendingUpdates( ]), new ReflectionMethod($workflow, $resolvedTarget['method']), ); - if ($command instanceof WorkflowCommand && config('workflows.tracing.enabled', false) === true) { + if ($command instanceof WorkflowCommand && ExecutionTracing::enabled()) { ExecutionTracing::linkUpdate($run, $command); } $result = $workflow->{$resolvedTarget['method']}(...$parameters); diff --git a/src/V2/WorkflowStub.php b/src/V2/WorkflowStub.php index 675ed3309..9f6b5db24 100644 --- a/src/V2/WorkflowStub.php +++ b/src/V2/WorkflowStub.php @@ -945,10 +945,9 @@ public function attemptStart(...$arguments): StartResult $metadata = WorkflowMetadata::fromStartArguments($arguments); // Capture outside the storage transaction and preserve it across its retries. $observabilityContext = null; - $tracing = config('workflows.tracing.enabled', false) === true; + $tracing = ExecutionTracing::enabled(); if ($tracing) { - $allowlist = config('workflows.tracing.baggage_allowlist', []); - $observabilityContext = TraceCarrier::capture(is_array($allowlist) ? $allowlist : []); + $observabilityContext = TraceCarrier::capture(ExecutionTracing::baggageAllowlist()); } if (! $tracing) { return $this->admitStart($metadata, $startOptions, $observabilityContext); @@ -3090,10 +3089,9 @@ private function attemptSignalWithStartInternal( $intakeGroupId = (string) Str::ulid(); // Capture before locking. An existing run retains its original admission carrier. $observabilityContext = null; - $tracing = config('workflows.tracing.enabled', false) === true; + $tracing = ExecutionTracing::enabled(); if ($tracing) { - $allowlist = config('workflows.tracing.baggage_allowlist', []); - $observabilityContext = TraceCarrier::capture(is_array($allowlist) ? $allowlist : []); + $observabilityContext = TraceCarrier::capture(ExecutionTracing::baggageAllowlist()); } $operation = fn (): SignalWithStartResult => $this->admitSignalWithStart( $name, diff --git a/tests/Unit/V2/ActivityExecutionTracingTest.php b/tests/Unit/V2/ActivityExecutionTracingTest.php index c47db80c9..b07d09df8 100644 --- a/tests/Unit/V2/ActivityExecutionTracingTest.php +++ b/tests/Unit/V2/ActivityExecutionTracingTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\V2; use Composer\Autoload\ClassLoader; +use Illuminate\Config\Repository; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\QueryException; @@ -371,6 +372,102 @@ public function testUnreadableOptionalRunMetadataCannotInterruptClaimedBusinessW ); } + public static function unavailableTracingConfiguration(): array + { + return [ + 'disabled control' => [null, false], + 'unavailable tracing key' => ['workflows.tracing.enabled', false], + 'unavailable baggage allowlist' => ['workflows.tracing.baggage_allowlist', true], + ]; + } + + #[DataProvider('unavailableTracingConfiguration')] + public function testOptionalTracingConfigurationCannotReplaceTheBusinessFailure( + ?string $unavailable, + bool $enabled + ): void { + config([ + 'workflows.tracing.enabled' => $enabled, + ]); + $stub = WorkflowStub::make(FailingTracedWorkflow::class, 'unavailable-tracing-config', '0'); + $stub->start(new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45)); + $run = WorkflowRun::query()->findOrFail($stub->runId()); + $preserved = array_flip(['observability_context', 'execution_deadline_at', 'run_deadline_at']); + $original = array_intersect_key($run->getRawOriginal(), $preserved); + $prefix = $run->historyEvents() + ->orderBy('sequence') + ->get() + ->map(static fn ($event): array => $event->getRawOriginal()) + ->all(); + $configuration = $this->app['config']; + $this->app->instance('config', $this->unavailableConfiguration($unavailable)); + try { + $this->workOnce(); + } finally { + $this->app->instance('config', $configuration); + } + $run->refresh(); + self::assertSame('failed', $run->status->value); + self::assertSame('application detail must not be exported', $run->failures()->sole()->message); + self::assertSame($original, array_intersect_key($run->getRawOriginal(), $preserved)); + self::assertSame(1, $run->historyEvents()->where('event_type', 'WorkflowFailed')->count()); + self::assertSame($prefix, array_slice($run->historyEvents()->orderBy('sequence')->get() + ->map(static fn ($event): array => $event->getRawOriginal()) + ->all(), 0, count($prefix))); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + self::assertCount($enabled ? 3 : 0, $this->exporter->getSpans()); + } + + #[DataProvider('unavailableTracingConfiguration')] + public function testOptionalConfigurationCannotBlockAdmissionTimerOrActivity( + ?string $unavailable, + bool $enabled + ): void { + config([ + 'workflows.tracing.enabled' => $enabled, + ]); + $configuration = $this->app['config']; + $this->app->instance('config', $this->unavailableConfiguration($unavailable)); + try { + $stub = WorkflowStub::make(TimerTracedWorkflow::class, 'unavailable-config-completion', '0'); + $stub->start(new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45)); + $run = WorkflowRun::query()->findOrFail($stub->runId()); + $deadlines = [$run->getRawOriginal('execution_deadline_at'), $run->getRawOriginal('run_deadline_at')]; + $prefix = $run->historyEvents() + ->orderBy('sequence') + ->get() + ->map(static fn ($event): array => $event->getRawOriginal()) + ->all(); + $this->workOnce(); + sleep(2); + for ($remaining = 4; $remaining > 0; --$remaining) { + $this->workOnce(); + } + $run->refresh(); + self::assertSame('completed', $run->status->value); + self::assertSame([9007199254740993, null, '雪'], $stub->refresh()->output()); + self::assertSame( + $deadlines, + [$run->getRawOriginal('execution_deadline_at'), $run->getRawOriginal('run_deadline_at')] + ); + self::assertSame($prefix, array_slice($run->historyEvents()->orderBy('sequence')->get() + ->map(static fn ($event): array => $event->getRawOriginal()) + ->all(), 0, count($prefix))); + self::assertSame(1, $run->historyEvents()->where('event_type', 'WorkflowCompleted')->count()); + self::assertSame(1, $run->historyEvents()->where('event_type', 'ActivityCompleted')->count()); + self::assertSame(1, $run->historyEvents()->where('event_type', 'TimerFired')->count()); + self::assertSame(5, WorkflowTask::query()->where('status', 'completed')->count()); + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + self::assertCount($enabled ? 11 : 0, $this->exporter->getSpans()); + } finally { + $this->app->instance('config', $configuration); + } + } + public function testFailedWorkflowExportsItsProcessingSegmentOutsideTheDatabaseTransaction(): void { config([ @@ -697,6 +794,27 @@ protected function defineEnvironment($app): void $app['config']->set('logging.default', 'stderr'); } + private function unavailableConfiguration(?string $unavailable): Repository + { + return new class($this->app['config']->all(), $unavailable) extends Repository { + public function __construct( + array $items, + private readonly ?string $unavailable + ) { + parent::__construct($items); + } + + public function get($key, $default = null) + { + if ($key === $this->unavailable) { + throw new RuntimeException('Optional tracing setting is unavailable'); + } + + return parent::get($key, $default); + } + }; + } + private function workOnce(): void { self::assertSame(0, Artisan::call('queue:work', [ @@ -741,6 +859,16 @@ public function handle(bool $retry): array } } +final class TimerTracedWorkflow extends Workflow +{ + public function handle(): array + { + self::timer(1); + + return self::activity(TracedActivity::class, false); + } +} + final class FailingTracedWorkflow extends Workflow { public static array $fields = []; From 880190924f3306ad99408b9979dd738be39e4489 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 23:05:25 +0000 Subject: [PATCH 30/77] fix: trace schedule admission outside runtime transactions --- .github/workflows/php.yml | 1 + docs/embedded-tracing.md | 36 ++- src/V2/Support/ExecutionTracing.php | 96 +++++- src/V2/Support/ScheduleManager.php | 280 ++++++++++-------- src/V2/Support/TraceCarrier.php | 78 +++++ src/V2/WorkflowStub.php | 4 +- tests/Fixtures/V2/tracing-process.php | 68 ++++- .../Unit/V2/ActivityExecutionTracingTest.php | 274 +++++++++++++++++ .../Unit/V2/ScheduleExecutionTracingTest.php | 119 ++++++++ tests/Unit/V2/TraceCarrierTest.php | 59 ++++ 10 files changed, 883 insertions(+), 132 deletions(-) create mode 100644 tests/Unit/V2/ScheduleExecutionTracingTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index da9c0654b..9bfbe9927 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -389,6 +389,7 @@ jobs: tests/Unit/V2/TimingExecutionTracingTest.php tests/Unit/V2/AdmissionExecutionTracingTest.php tests/Unit/V2/DispatchExecutionTracingTest.php + tests/Unit/V2/ScheduleExecutionTracingTest.php --fail-on-warning --log-junit build/test-results/pr-activity-tracing.xml - name: Run database-independent unit contracts diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index cc40a7f92..cfa500541 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -122,8 +122,8 @@ recorded, `durable.start.command_id`, `durable.start.status` and have no paired start command; tracing does not invent one. Each signal keeps its captured caller for later delivery links, while an existing run keeps its original parent and deadlines. Legacy and no-parent admissions are included in -the cold-process controls. Service admission, schedules and wider runtime -coverage have no admission-span qualification yet. +the cold-process controls. Embedded schedule controls are described below; +service admission and wider runtime coverage remain unqualified. Workflow processing and activity attempts receive separate spans. Persisted admission context survives the qualified cold-worker, child, continuation and @@ -170,6 +170,34 @@ without failing business work or rewriting the stored carrier. Task span wrappers skip their metadata read when emission is disabled. Transition inheritance follows the preservation rule above. +## Schedule triggers + +Embedded PHP schedules emit a short `durable.schedule.trigger` internal span +around the trigger operation. Its processor starts and ends outside the +runtime-owned schedule transaction. The schedule's occurrence, overlap, action +budget and atomic admission decisions are unchanged. An application that wraps +the call in its own transaction still owns that outer boundary. +Synchronous start listeners retain the workflow instance and type in their +correlation fields without starting another span under the schedule lock. + +The actual triggering caller is captured before the trigger span. An admitted +run persists that caller for later workers; its cold processing spans do not +become children of the short trigger span. Creating a schedule does not persist +the creator's trace for future runs. Manual and periodic triggers have separate +cold-worker controls, including disabled tracing and an unrelated worker parent. + +Span attributes include `schedule_id`, namespace and +`durable.schedule.trigger_kind`: `manual`, `scheduled`, `buffered` or `backfill`. +Returned identities, `durable.schedule.outcome` and `durable.schedule.reason` +are attached when available. Backfill's instance-only return does not invent a +run ID. A buffered receipt is not an admitted run or a completed workflow. + +Buffered draining and backfill have focused queue/state and transaction-boundary +controls. The later drain currently captures its actual scheduler context. +Preserving a buffered request's independent caller as a later link, wider cold +backfill/drain coverage and full schedule tracing remain unqualified. The +component adds no schedule metadata column or service wire field. + ## Signal caller links This draft also proposes a nullable `workflow_commands.observability_context` @@ -203,8 +231,8 @@ share a link identified by the first applied command. Further additions are dropped, with the count in `durable.links.dropped`. Application SDK limits may be lower. The link state ends with its processing scope. -This does not add service wire propagation, native catalog support, schedule -links or complete fan-in tracing. +This does not add service wire propagation, native catalog support, buffered +schedule caller links or complete fan-in tracing. ## Update caller links diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php index 6a0fff9b7..93243f49f 100644 --- a/src/V2/Support/ExecutionTracing.php +++ b/src/V2/Support/ExecutionTracing.php @@ -29,6 +29,7 @@ use Workflow\V2\Models\WorkflowCommand; use Workflow\V2\Models\WorkflowInstance; use Workflow\V2\Models\WorkflowRun; +use Workflow\V2\Models\WorkflowSchedule; use Workflow\V2\Models\WorkflowTask; use Workflow\V2\SignalWithStartResult; use Workflow\V2\StartResult; @@ -44,6 +45,7 @@ final class ExecutionTracing 'activity_attempt_number', 'request_id', 'timer_id', 'workflow_command_id', + 'schedule_id', ]; private const TIMINGS = [ @@ -264,10 +266,34 @@ public static function admission(WorkflowInstance $instance, callable $operation } catch (Throwable) { // Optional identity reads do not replace admission. } + if (TraceCarrier::hasPreparedAdmission()) { + return self::withAdmissionFields($attributes, $operation); + } return self::segment('durable.workflow.admission', $attributes, null, $operation, [], true); } + public static function scheduleAdmission(WorkflowSchedule $schedule, callable $operation, string $kind): mixed + { + if (! self::enabled() || ! class_exists(Globals::class) || TraceCarrier::hasPreparedAdmission()) { + return $operation(); + } + $attributes = []; + try { + $attributes = [ + 'schedule_id' => $schedule->getRawOriginal('schedule_id'), + 'namespace' => $schedule->getRawOriginal('namespace'), + 'durable.schedule.trigger_kind' => $kind, + ]; + } catch (Throwable) { + // Optional schedule identity does not replace its locked admission decision. + } + + return TraceCarrier::withPreparedAdmission( + static fn (): mixed => self::segment('durable.schedule.trigger', $attributes, null, $operation, [], true) + ); + } + public static function recordFailure(Throwable $error): void { try { @@ -336,6 +362,30 @@ private static function linkCommand(WorkflowRun $run, WorkflowCommand $command, } } + /** + * @param array $attributes + */ + private static function withAdmissionFields(array $attributes, callable $operation): mixed + { + $scope = null; + try { + self::$correlationKey ??= Context::createKey('durable-workflow.correlation'); + $fields = array_replace(self::current(), self::fields($attributes)); + $scope = Context::getCurrent()->with(self::$correlationKey, $fields)->activate(); + self::enterFiberScope(); + } catch (Throwable) { + self::close($scope, null); + + return $operation(); + } + + try { + return $operation(); + } finally { + self::close($scope, null); + } + } + /** * @param array $attributes * @param list $baggageAllowlist @@ -366,7 +416,7 @@ private static function segment( $builder = Globals::tracerProvider()->getTracer(self::PACKAGE_NAME, $version) ->spanBuilder($name) ->setSpanKind(match ($name) { - 'durable.workflow.admission' => SpanKind::KIND_INTERNAL, + 'durable.workflow.admission', 'durable.schedule.trigger' => SpanKind::KIND_INTERNAL, 'durable.task.dispatch' => SpanKind::KIND_PRODUCER, default => SpanKind::KIND_CONSUMER, }) @@ -430,7 +480,7 @@ private static function parentContext( array $baggageAllowlist ): ContextInterface { // Local admission keeps the caller's context. Workers recover only stored authority. - $localParent = $name === 'durable.workflow.admission' + $localParent = in_array($name, ['durable.workflow.admission', 'durable.schedule.trigger'], true) || ($name === 'durable.task.dispatch' && self::current() !== []); $parent = $localParent ? Context::getCurrent() : Context::getRoot(); $carrier = TraceCarrier::normalize($persistedContext, $baggageAllowlist); @@ -470,6 +520,13 @@ private static function spanAttributes(string $name, array $attributes, array $f )) { $spanAttributes['durable.task.type'] = $attributes['durable.task.type']; } + if ($name === 'durable.schedule.trigger' && in_array( + $attributes['durable.schedule.trigger_kind'] ?? null, + ['manual', 'scheduled', 'buffered', 'backfill'], + true + )) { + $spanAttributes['durable.schedule.trigger_kind'] = $attributes['durable.schedule.trigger_kind']; + } return $spanAttributes; } @@ -487,11 +544,46 @@ private static function recordResult(?SpanInterface $span, string $name, mixed $ && ($result instanceof StartResult || $result instanceof SignalWithStartResult)) { self::recordAdmissionResult($span, $result); } + if ($name === 'durable.schedule.trigger') { + self::recordScheduleResult($span, $result); + } } catch (Throwable) { // Optional receipt metadata cannot replace a returned business value. } } + private static function recordScheduleResult(SpanInterface $span, mixed $result): void + { + $receipt = match (true) { + $result instanceof ScheduleTriggerResult => [ + 'instance_id' => $result->instanceId, + 'run_id' => $result->runId, + 'outcome' => $result->outcome, + 'reason' => $result->reason, + ], + is_array($result) => $result, + is_string($result) => [ + 'instance_id' => $result, + 'outcome' => 'triggered', + ], + default => [ + 'outcome' => 'no_run', + ], + }; + $attributes = self::fields([ + 'workflow_instance_id' => $receipt['instance_id'] ?? null, + 'workflow_run_id' => $receipt['run_id'] ?? null, + ]); + foreach (['outcome', 'reason'] as $key) { + $value = $receipt[$key] ?? null; + if (is_string($value) && $value !== '' && strlen($value) <= 256 + && ! preg_match('/[\x00-\x1f\x7f]/', $value)) { + $attributes['durable.schedule.' . $key] = $value; + } + } + $span->setAttributes($attributes); + } + /** * @param array $attributes * @return array diff --git a/src/V2/Support/ScheduleManager.php b/src/V2/Support/ScheduleManager.php index 90561090e..ce2ccc5c1 100644 --- a/src/V2/Support/ScheduleManager.php +++ b/src/V2/Support/ScheduleManager.php @@ -360,100 +360,16 @@ public static function triggerDetailed( ?CommandContext $context = null, ?DateTimeInterface $occurrenceTime = null, ): ScheduleTriggerResult { - return DB::transaction(static function () use ( + return ExecutionTracing::scheduleAdmission( $schedule, - $overlapPolicyOverride, - $context, - $occurrenceTime - ): ScheduleTriggerResult { - /** @var WorkflowSchedule $schedule */ - $schedule = WorkflowSchedule::query() - ->lockForUpdate() - ->findOrFail($schedule->id); - - // A tick may have selected this occurrence before another trigger - // or update changed the schedule. Decide using the locked row. - if ($occurrenceTime !== null && ( - $schedule->next_fire_at === null - || UtcScheduleTimestamp::databaseValue($schedule->next_fire_at) - !== UtcScheduleTimestamp::databaseValue($occurrenceTime) - )) { - self::recordSkip($schedule, 'stale_occurrence', $context); - - return new ScheduleTriggerResult('skipped', null, null, 'stale_occurrence'); - } - - if (! $schedule->status->allowsTrigger()) { - self::recordSkip($schedule, 'status_not_triggerable', $context); - - return new ScheduleTriggerResult('skipped', null, null, 'status_not_triggerable'); - } - - if ($schedule->remaining_actions !== null && $schedule->remaining_actions <= 0) { - self::recordSkip($schedule, 'remaining_actions_exhausted', $context); - - return new ScheduleTriggerResult('skipped', null, null, 'remaining_actions_exhausted'); - } - - if ($occurrenceTime !== null && self::hasFiredOccurrence($schedule, $occurrenceTime)) { - self::skipFiredOccurrence($schedule, $occurrenceTime, $context); - - return new ScheduleTriggerResult('skipped', null, null, 'already_fired_occurrence'); - } - - $overlapPolicy = $overlapPolicyOverride - ?? ScheduleOverlapPolicy::tryFrom($schedule->overlap_policy ?? '') - ?? ScheduleOverlapPolicy::Skip; - - if ($overlapPolicy->isBuffer() && self::runningRunExists($schedule)) { - if ($schedule->isAtBufferCapacity($overlapPolicy->value)) { - self::recordSkip($schedule, 'buffer_full', $context); - $schedule->forceFill([ - 'next_fire_at' => $schedule->computeNextFireAtWithJitter(), - ])->save(); - - return new ScheduleTriggerResult('buffer_full', null, null, 'buffer_full'); - } - - $schedule->bufferAction($occurrenceTime); - $schedule->next_fire_at = $schedule->computeNextFireAtWithJitter(); - $schedule->save(); - - return new ScheduleTriggerResult('buffered', null, null, null); - } - - if (! self::overlapAllowed($schedule, $overlapPolicy)) { - $reason = 'overlap_policy_' . $overlapPolicy->value; - self::recordSkip($schedule, $reason, $context); - $schedule->forceFill([ - 'next_fire_at' => $schedule->computeNextFireAtWithJitter(), - ])->save(); - - return new ScheduleTriggerResult('skipped', null, null, $reason); - } - - if ($overlapPolicy === ScheduleOverlapPolicy::CancelOther || $overlapPolicy === ScheduleOverlapPolicy::TerminateOther) { - self::closeExistingRun($schedule, $overlapPolicy); - } - - try { - $startResult = self::startRun( - $schedule, - occurrenceTime: $occurrenceTime, - effectiveOverlapPolicy: $overlapPolicy->value, - context: $context, - ); - } catch (WorkflowExecutionUnavailableException $exception) { - self::recordSkip($schedule, $exception->blockedReason(), $context); - $schedule->forceFill([ - 'next_fire_at' => $schedule->computeNextFireAtWithJitter(), - ])->save(); - - return new ScheduleTriggerResult('skipped', null, null, $exception->blockedReason()); - } - - return new ScheduleTriggerResult('triggered', $startResult->instanceId, $startResult->runId, null); - }); + static fn (): ScheduleTriggerResult => self::triggerAtomically( + $schedule, + $overlapPolicyOverride, + $context, + $occurrenceTime + ), + $occurrenceTime === null ? 'manual' : 'scheduled' + ); } /** @@ -474,40 +390,44 @@ public static function tick(int $limit = 100): array } try { - $drained = DB::transaction(static function () use ($schedule): ?array { - /** @var WorkflowSchedule $schedule */ - $schedule = WorkflowSchedule::query()->lockForUpdate()->findOrFail($schedule->id); + $drained = ExecutionTracing::scheduleAdmission( + $schedule, + static fn (): ?array => DB::transaction(static function () use ($schedule): ?array { + /** @var WorkflowSchedule $schedule */ + $schedule = WorkflowSchedule::query()->lockForUpdate()->findOrFail($schedule->id); - $bufferedAction = $schedule->drainBuffer(); + $bufferedAction = $schedule->drainBuffer(); - if ($bufferedAction === null) { - return null; - } + if ($bufferedAction === null) { + return null; + } - $schedule->save(); + $schedule->save(); - $occurrenceTime = self::dateTimeFromPayload($bufferedAction['occurrence_time'] ?? null); - if ($occurrenceTime !== null && self::hasFiredOccurrence($schedule, $occurrenceTime)) { - self::skipFiredOccurrence($schedule, $occurrenceTime); + $occurrenceTime = self::dateTimeFromPayload($bufferedAction['occurrence_time'] ?? null); + if ($occurrenceTime !== null && self::hasFiredOccurrence($schedule, $occurrenceTime)) { + self::skipFiredOccurrence($schedule, $occurrenceTime); - return [ - 'instance_id' => null, - 'run_id' => null, - 'outcome' => 'skipped', - 'reason' => 'already_fired_occurrence', - 'occurrence_time' => $occurrenceTime->format('Y-m-d\TH:i:s.uP'), - ]; - } + return [ + 'instance_id' => null, + 'run_id' => null, + 'outcome' => 'skipped', + 'reason' => 'already_fired_occurrence', + 'occurrence_time' => $occurrenceTime->format('Y-m-d\TH:i:s.uP'), + ]; + } - $startResult = self::startRun($schedule, occurrenceTime: $occurrenceTime, outcome: 'drained'); + $startResult = self::startRun($schedule, occurrenceTime: $occurrenceTime, outcome: 'drained'); - return [ - 'instance_id' => $startResult->instanceId, - 'run_id' => $startResult->runId, - 'outcome' => 'drained', - 'occurrence_time' => $occurrenceTime?->format('Y-m-d\TH:i:s.uP'), - ]; - }); + return [ + 'instance_id' => $startResult->instanceId, + 'run_id' => $startResult->runId, + 'outcome' => 'drained', + 'occurrence_time' => $occurrenceTime?->format('Y-m-d\TH:i:s.uP'), + ]; + }), + 'buffered' + ); if ($drained !== null) { $schedule->refresh(); @@ -671,6 +591,108 @@ public static function backfill( return $results; } + private static function triggerAtomically( + WorkflowSchedule $schedule, + ?ScheduleOverlapPolicy $overlapPolicyOverride, + ?CommandContext $context, + ?DateTimeInterface $occurrenceTime, + ): ScheduleTriggerResult { + return DB::transaction(static function () use ( + $schedule, + $overlapPolicyOverride, + $context, + $occurrenceTime + ): ScheduleTriggerResult { + /** @var WorkflowSchedule $schedule */ + $schedule = WorkflowSchedule::query() + ->lockForUpdate() + ->findOrFail($schedule->id); + + // A tick may have selected this occurrence before another trigger + // or update changed the schedule. Decide using the locked row. + if ($occurrenceTime !== null && ( + $schedule->next_fire_at === null + || UtcScheduleTimestamp::databaseValue($schedule->next_fire_at) + !== UtcScheduleTimestamp::databaseValue($occurrenceTime) + )) { + self::recordSkip($schedule, 'stale_occurrence', $context); + + return new ScheduleTriggerResult('skipped', null, null, 'stale_occurrence'); + } + + if (! $schedule->status->allowsTrigger()) { + self::recordSkip($schedule, 'status_not_triggerable', $context); + + return new ScheduleTriggerResult('skipped', null, null, 'status_not_triggerable'); + } + + if ($schedule->remaining_actions !== null && $schedule->remaining_actions <= 0) { + self::recordSkip($schedule, 'remaining_actions_exhausted', $context); + + return new ScheduleTriggerResult('skipped', null, null, 'remaining_actions_exhausted'); + } + + if ($occurrenceTime !== null && self::hasFiredOccurrence($schedule, $occurrenceTime)) { + self::skipFiredOccurrence($schedule, $occurrenceTime, $context); + + return new ScheduleTriggerResult('skipped', null, null, 'already_fired_occurrence'); + } + + $overlapPolicy = $overlapPolicyOverride + ?? ScheduleOverlapPolicy::tryFrom($schedule->overlap_policy ?? '') + ?? ScheduleOverlapPolicy::Skip; + + if ($overlapPolicy->isBuffer() && self::runningRunExists($schedule)) { + if ($schedule->isAtBufferCapacity($overlapPolicy->value)) { + self::recordSkip($schedule, 'buffer_full', $context); + $schedule->forceFill([ + 'next_fire_at' => $schedule->computeNextFireAtWithJitter(), + ])->save(); + + return new ScheduleTriggerResult('buffer_full', null, null, 'buffer_full'); + } + + $schedule->bufferAction($occurrenceTime); + $schedule->next_fire_at = $schedule->computeNextFireAtWithJitter(); + $schedule->save(); + + return new ScheduleTriggerResult('buffered', null, null, null); + } + + if (! self::overlapAllowed($schedule, $overlapPolicy)) { + $reason = 'overlap_policy_' . $overlapPolicy->value; + self::recordSkip($schedule, $reason, $context); + $schedule->forceFill([ + 'next_fire_at' => $schedule->computeNextFireAtWithJitter(), + ])->save(); + + return new ScheduleTriggerResult('skipped', null, null, $reason); + } + + if ($overlapPolicy === ScheduleOverlapPolicy::CancelOther || $overlapPolicy === ScheduleOverlapPolicy::TerminateOther) { + self::closeExistingRun($schedule, $overlapPolicy); + } + + try { + $startResult = self::startRun( + $schedule, + occurrenceTime: $occurrenceTime, + effectiveOverlapPolicy: $overlapPolicy->value, + context: $context, + ); + } catch (WorkflowExecutionUnavailableException $exception) { + self::recordSkip($schedule, $exception->blockedReason(), $context); + $schedule->forceFill([ + 'next_fire_at' => $schedule->computeNextFireAtWithJitter(), + ])->save(); + + return new ScheduleTriggerResult('skipped', null, null, $exception->blockedReason()); + } + + return new ScheduleTriggerResult('triggered', $startResult->instanceId, $startResult->runId, null); + }); + } + /** * Select a bounded oldest-first batch without allowing one namespace to * occupy every slot. Namespace order is based on each namespace's oldest @@ -783,6 +805,24 @@ private static function triggerForBackfill( ScheduleOverlapPolicy $overlapPolicy, DateTimeInterface $occurrenceTime, ?CommandContext $context = null, + ): ?string { + return ExecutionTracing::scheduleAdmission( + $schedule, + static fn (): ?string => self::triggerBackfillAtomically( + $schedule, + $overlapPolicy, + $occurrenceTime, + $context + ), + 'backfill' + ); + } + + private static function triggerBackfillAtomically( + WorkflowSchedule $schedule, + ScheduleOverlapPolicy $overlapPolicy, + DateTimeInterface $occurrenceTime, + ?CommandContext $context, ): ?string { $effectivePolicy = $overlapPolicy->isBuffer() ? ScheduleOverlapPolicy::AllowAll diff --git a/src/V2/Support/TraceCarrier.php b/src/V2/Support/TraceCarrier.php index 2fbeb7bdb..181e344c9 100644 --- a/src/V2/Support/TraceCarrier.php +++ b/src/V2/Support/TraceCarrier.php @@ -4,13 +4,16 @@ namespace Workflow\V2\Support; +use Fiber; use OpenTelemetry\API\Baggage\Baggage; use OpenTelemetry\API\Baggage\Propagation\BaggagePropagator; use OpenTelemetry\API\Globals; use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator; use OpenTelemetry\API\Trace\Span; use OpenTelemetry\Context\Context; +use OpenTelemetry\Context\ContextKeyInterface; use Throwable; +use WeakMap; use Workflow\V2\Models\WorkflowCommand; use Workflow\V2\Models\WorkflowRun; @@ -29,6 +32,65 @@ final class TraceCarrier 'baggage' => 2048, ]; + private static ?ContextKeyInterface $admissionKey = null; + + /** + * @var WeakMap|null + */ + private static ?WeakMap $admissionFibers = null; + + public static function withPreparedAdmission(callable $operation): mixed + { + $fiber = Fiber::getCurrent(); + try { + self::$admissionKey ??= Context::createKey('durable-workflow.schedule-admission'); + $scope = Context::getCurrent()->with(self::$admissionKey, [ + 'carrier' => self::captureConfigured(), + 'fiber' => $fiber, + ])->activate(); + if ($fiber !== null) { + self::$admissionFibers ??= new WeakMap(); + self::$admissionFibers[$fiber] = (self::$admissionFibers[$fiber] ?? 0) + 1; + } + } catch (Throwable) { + return $operation(); + } + + try { + return $operation(); + } finally { + try { + $scope->detach(); + } catch (Throwable) { + // Optional carrier scope cleanup cannot replace the business result. + } finally { + if ($fiber !== null && isset(self::$admissionFibers[$fiber])) { + $depth = self::$admissionFibers[$fiber] - 1; + if ($depth === 0) { + unset(self::$admissionFibers[$fiber]); + } else { + self::$admissionFibers[$fiber] = $depth; + } + } + } + } + } + + public static function hasPreparedAdmission(): bool + { + return self::preparedAdmission() !== null; + } + + /** + * @return array{schema: string, version: int, carrier: array}|null + */ + public static function admissionParent(): ?array + { + $prepared = self::preparedAdmission(); + + return $prepared !== null ? $prepared['carrier'] : self::captureConfigured(); + } + /** * @return array{schema: string, version: int, carrier: array}|null */ @@ -163,6 +225,22 @@ public static function normalize(mixed $value, array $baggageAllowlist = []): ?a } } + private static function preparedAdmission(): ?array + { + $fiber = Fiber::getCurrent(); + if ($fiber !== null && ! isset(self::$admissionFibers[$fiber])) { + return null; + } + try { + $prepared = self::$admissionKey === null ? null : Context::getCurrent()->get(self::$admissionKey); + + return is_array($prepared) && array_key_exists('carrier', $prepared) + && ($prepared['fiber'] ?? null) === $fiber ? $prepared : null; + } catch (Throwable) { + return null; + } + } + /** * @param array $carrier * @param list $allowlist diff --git a/src/V2/WorkflowStub.php b/src/V2/WorkflowStub.php index 9f6b5db24..97655262c 100644 --- a/src/V2/WorkflowStub.php +++ b/src/V2/WorkflowStub.php @@ -947,7 +947,7 @@ public function attemptStart(...$arguments): StartResult $observabilityContext = null; $tracing = ExecutionTracing::enabled(); if ($tracing) { - $observabilityContext = TraceCarrier::capture(ExecutionTracing::baggageAllowlist()); + $observabilityContext = TraceCarrier::admissionParent(); } if (! $tracing) { return $this->admitStart($metadata, $startOptions, $observabilityContext); @@ -3091,7 +3091,7 @@ private function attemptSignalWithStartInternal( $observabilityContext = null; $tracing = ExecutionTracing::enabled(); if ($tracing) { - $observabilityContext = TraceCarrier::capture(ExecutionTracing::baggageAllowlist()); + $observabilityContext = TraceCarrier::admissionParent(); } $operation = fn (): SignalWithStartResult => $this->admitSignalWithStart( $name, diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 8bcde0fa5..84572f811 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -48,6 +48,7 @@ use Workflow\V2\Models\WorkflowHistoryEvent; use Workflow\V2\Models\WorkflowLink; use Workflow\V2\Models\WorkflowRun; +use Workflow\V2\Models\WorkflowSchedule; use Workflow\V2\Models\WorkflowTask; use Workflow\V2\Models\WorkflowTimer; use Workflow\V2\Models\WorkflowUpdate; @@ -55,6 +56,7 @@ use Workflow\V2\Support\ActivityOptions; use Workflow\V2\Support\DefaultWorkflowTaskBridge; use Workflow\V2\Support\ExecutionTracing; +use Workflow\V2\Support\ScheduleManager; use Workflow\V2\Support\TaskDispatcher; use Workflow\V2\Tracing\LogContext; use Workflow\V2\Workflow; @@ -75,6 +77,7 @@ $updateLinks = getenv('DW_TEST_TRACING_UPDATE_LINKS') === 'true'; $timings = getenv('DW_TEST_TRACING_TIMINGS') === 'true'; $dispatchProbe = getenv('DW_TEST_TRACING_DISPATCH') === 'true'; +$scheduleProbe = getenv('DW_TEST_TRACING_SCHEDULE'); $admissionProbe = getenv('DW_TEST_TRACING_ADMISSION') === 'true' || getenv('DW_TEST_TRACING_SIGNAL_START_ADMISSION') === 'true'; $childRetry = getenv('DW_TEST_TRACING_CHILD_RETRY') === 'true'; @@ -416,9 +419,9 @@ protected function defineEnvironment($app): void ], ); $exporter = new InMemoryExporter(); -$admissionProcessor = ($admissionProbe || $dispatchProbe) ? new class( +$admissionProcessor = ($admissionProbe || $dispatchProbe || $scheduleProbe) ? new class( $exporter, - $dispatchProbe ? 'durable.task.dispatch' : 'durable.workflow.admission' + $scheduleProbe ? 'durable.schedule.trigger' : ($dispatchProbe ? 'durable.task.dispatch' : 'durable.workflow.admission') ) extends SimpleSpanProcessor { public array $boundaries = []; @@ -466,7 +469,7 @@ public function onEnd(ReadableSpanInterface $span): void BaggagePropagator::getInstance(), ]); $recordedPropagator = null; -if ($signalStart || $signalLinks || $cancellation || $updateLinks || $admissionProbe) { +if ($signalStart || $signalLinks || $cancellation || $updateLinks || $admissionProbe || $scheduleProbe) { $recordedPropagator = new class($propagator) implements TextMapPropagatorInterface { public array $transactionLevels = []; @@ -639,6 +642,37 @@ public function extract( } http_response_code(201); header('Content-Type: application/json'); + } elseif ($scheduleProbe) { + $creator = $propagator->extract([ + 'traceparent' => '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', + ], context: $initial)->activate(); + try { + $schedule = ScheduleManager::create( + scheduleId: 'cold-schedule', + workflowClass: TracingProcessWorkflow::class, + cronExpression: '* * * * *', + arguments: [false], + namespace: '0', + executionTimeoutSeconds: 60, + runTimeoutSeconds: 45 + ); + } finally { + $creator->detach(); + } + if ($scheduleProbe === 'scheduled') { + $schedule->forceFill([ + 'next_fire_at' => now() + ->subSecond(), + ])->save(); + $triggered = ScheduleManager::tick(1); + $instanceId = $triggered[0]['instance_id'] ?? null; + } else { + $instanceId = ScheduleManager::trigger($schedule); + } + if (! is_string($instanceId) || $instanceId === '') { + throw new \RuntimeException('The actual schedule did not admit a workflow.'); + } + file_put_contents($directory . '/schedule-instance', $instanceId); } elseif ($childRetry) { // A real bridge-authored policy, not an invented PHP child option or history edit. config() @@ -849,7 +883,9 @@ public function extract( $scope->detach(); $requestSpan?->end(); } - $runs = WorkflowRun::query()->where('workflow_instance_id', 'cold-tracing'); + $selectedInstance = $scheduleProbe + ? trim(file_get_contents($directory . '/schedule-instance')) : 'cold-tracing'; + $runs = WorkflowRun::query()->where('workflow_instance_id', $selectedInstance); $run = $transition === 'continue' ? $runs->orderByDesc('run_number') ->firstOrFail() : $runs->sole(); $output = [ @@ -905,6 +941,30 @@ public function extract( )); } unset($executionSpan); + if ($scheduleProbe) { + $schedule = WorkflowSchedule::query()->where('schedule_id', 'cold-schedule')->sole(); + $history = $run->historyEvents() + ->orderBy('sequence') + ->get(); + $output['schedule'] = [ + 'id' => $schedule->schedule_id, + 'namespace' => $schedule->namespace, + 'fires_count' => (int) $schedule->fires_count, + 'latest_instance_id' => $schedule->latest_workflow_instance_id, + 'instance_id' => $run->workflow_instance_id, + 'boundaries' => $admissionProcessor->boundaries, + 'capture_transaction_levels' => $recordedPropagator->transactionLevels, + 'history_hashes' => $history->map(static fn ($event): string => hash( + 'sha256', + json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR) + ))->all(), + 'history_types' => $history->map(static fn ($event): string => $event->event_type->value) + ->all(), + 'tasks' => WorkflowTask::query()->orderBy('created_at')->get([ + 'id', 'status', 'attempt_count', 'repair_count', + ])->toArray(), + ]; + } if ($exportEndpoint) { $output['exports_before_shutdown'] = $exportRequests() - $initialExportRequests; } diff --git a/tests/Unit/V2/ActivityExecutionTracingTest.php b/tests/Unit/V2/ActivityExecutionTracingTest.php index b07d09df8..9704ea387 100644 --- a/tests/Unit/V2/ActivityExecutionTracingTest.php +++ b/tests/Unit/V2/ActivityExecutionTracingTest.php @@ -31,6 +31,8 @@ use Workflow\Providers\WorkflowServiceProvider; use Workflow\Serializers\Serializer; use Workflow\V2\Activity; +use Workflow\V2\Enums\ScheduleOverlapPolicy; +use Workflow\V2\Events\WorkflowStarted; use Workflow\V2\Models\ActivityAttempt; use Workflow\V2\Models\WorkflowCommand; use Workflow\V2\Models\WorkflowHistoryEvent; @@ -38,6 +40,7 @@ use Workflow\V2\Models\WorkflowTask; use Workflow\V2\StartOptions; use Workflow\V2\Support\ExecutionTracing; +use Workflow\V2\Support\ScheduleManager; use Workflow\V2\Workflow; use Workflow\V2\WorkflowStub; @@ -468,6 +471,260 @@ public function testOptionalConfigurationCannotBlockAdmissionTimerOrActivity( } } + #[DataProvider('dispatchFailures')] + public function testScheduledAdmissionKeepsProcessorHooksOutsideTheScheduleTransaction(bool $enabled): void + { + config([ + 'workflows.tracing.enabled' => $enabled, + ]); + $admissionContexts = []; + $this->app['events']->listen(WorkflowStarted::class, static function () use (&$admissionContexts): void { + $admissionContexts[] = ExecutionTracing::current(); + }); + $processor = new ScheduleTracingProcessor($this->exporter); + $provider = new TracerProvider($processor); + $instrumentation = Configurator::create()->withTracerProvider($provider)->activate(); + try { + $creator = TraceContextPropagator::getInstance()->extract([ + 'traceparent' => '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', + ])->activate(); + try { + $schedule = ScheduleManager::create( + scheduleId: 'traced-manual-schedule', + workflowClass: TracedWorkflow::class, + cronExpression: '* * * * *', + arguments: [false], + connection: 'database', + queue: 'default', + namespace: '0', + executionTimeoutSeconds: 60, + runTimeoutSeconds: 45, + ); + } finally { + $creator->detach(); + } + $parent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'; + $caller = TraceContextPropagator::getInstance()->extract([ + 'traceparent' => $parent, + ])->activate(); + try { + $instanceId = ScheduleManager::trigger($schedule); + } finally { + $caller->detach(); + } + self::assertSame(0, DB::transactionLevel()); + self::assertNotNull($instanceId); + $run = WorkflowRun::query()->where('workflow_instance_id', $instanceId)->sole(); + self::assertCount(1, $admissionContexts); + if ($enabled) { + self::assertSame($instanceId, $admissionContexts[0]['workflow_instance_id'] ?? null); + self::assertSame($run->workflow_type, $admissionContexts[0]['workflow_type'] ?? null); + self::assertSame('0', $admissionContexts[0]['namespace'] ?? null); + self::assertSame('traced-manual-schedule', $admissionContexts[0]['schedule_id'] ?? null); + } else { + self::assertSame([], $admissionContexts[0]); + } + $preserved = array_intersect_key($run->getRawOriginal(), array_flip([ + 'observability_context', 'execution_deadline_at', 'run_deadline_at', + ])); + self::assertSame($enabled ? $parent : null, $run->observability_context['carrier']['traceparent'] ?? null); + $prefix = $run->historyEvents() + ->orderBy('sequence') + ->get() + ->map(static fn ($event): array => $event->getRawOriginal()) + ->all(); + for ($remaining = 3; $remaining > 0; --$remaining) { + $this->workOnce(); + } + $run->refresh(); + $schedule->refresh(); + self::assertSame('completed', $run->status->value); + self::assertSame([9007199254740993, null, '雪'], Serializer::unserializeWithCodec( + $run->payload_codec, + $run->output + )); + self::assertSame($preserved, array_intersect_key($run->getRawOriginal(), $preserved)); + self::assertSame($prefix, array_slice($run->historyEvents()->orderBy('sequence')->get() + ->map(static fn ($event): array => $event->getRawOriginal()) + ->all(), 0, count($prefix))); + self::assertSame(1, (int) $schedule->fires_count); + self::assertSame($instanceId, $schedule->latest_workflow_instance_id); + self::assertSame(1, $run->historyEvents()->where('event_type', 'ScheduleTriggered')->count()); + self::assertSame(1, $run->historyEvents()->where('event_type', 'WorkflowCompleted')->count()); + self::assertSame(1, $run->historyEvents()->where('event_type', 'ActivityCompleted')->count()); + self::assertSame(3, WorkflowTask::query()->where('status', 'completed')->count()); + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + self::assertCount($enabled ? 7 : 0, $this->exporter->getSpans()); + $triggers = array_values(array_filter( + $this->exporter->getSpans(), + static fn ($span): bool => $span->getName() === 'durable.schedule.trigger' + )); + self::assertCount($enabled ? 1 : 0, $triggers); + if ($enabled) { + self::assertSame('00f067aa0ba902b7', $triggers[0]->getParentContext()->getSpanId()); + self::assertSame($run->id, $triggers[0]->getAttributes()->get('workflow_run_id')); + self::assertSame('traced-manual-schedule', $triggers[0]->getAttributes()->get('schedule_id')); + self::assertSame('triggered', $triggers[0]->getAttributes()->get('durable.schedule.outcome')); + self::assertSame('manual', $triggers[0]->getAttributes()->get('durable.schedule.trigger_kind')); + } + self::assertSame(array_fill(0, count($processor->transactionLevels), 0), $processor->transactionLevels); + } finally { + $instrumentation->detach(); + $provider->shutdown(); + } + } + + #[DataProvider('dispatchFailures')] + public function testScheduledQueueStorageFailurePreservesItsCommittedAdmission(bool $enabled): void + { + config([ + 'workflows.tracing.enabled' => $enabled, + 'queue.connections.database.table' => 'missing_trace_jobs', + ]); + $schedule = ScheduleManager::create( + scheduleId: 'schedule-queue-failure', + workflowClass: TracedWorkflow::class, + cronExpression: '* * * * *', + arguments: [false], + namespace: '0', + executionTimeoutSeconds: 60, + runTimeoutSeconds: 45 + ); + try { + ScheduleManager::trigger($schedule); + self::fail('The actual post-commit queue INSERT must fail.'); + } catch (QueryException $failure) { + self::assertStringContainsString('missing_trace_jobs', $failure->getMessage()); + } + $schedule->refresh(); + $run = WorkflowRun::query()->sole(); + $task = WorkflowTask::query()->sole(); + self::assertSame(1, (int) $schedule->fires_count); + self::assertSame($run->workflow_instance_id, $schedule->latest_workflow_instance_id); + self::assertSame('pending', $run->status->value); + self::assertSame('ready', $task->status->value); + self::assertNull($task->last_dispatched_at); + self::assertStringContainsString('missing_trace_jobs', $task->last_dispatch_error); + self::assertSame(0, (int) $task->repair_count); + self::assertTrue($run->started_at->copy()->addSeconds(60)->equalTo($run->execution_deadline_at)); + self::assertTrue($run->started_at->copy()->addSeconds(45)->equalTo($run->run_deadline_at)); + self::assertSame(0, ActivityAttempt::query()->count()); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + $triggers = array_values(array_filter( + $this->exporter->getSpans(), + static fn ($span): bool => $span->getName() === 'durable.schedule.trigger' + )); + self::assertCount($enabled ? 1 : 0, $triggers); + if ($enabled) { + self::assertSame(StatusCode::STATUS_ERROR, $triggers[0]->getStatus()->getCode()); + self::assertSame(QueryException::class, $triggers[0]->getAttributes()->get('error.type')); + self::assertNull($triggers[0]->getAttributes()->get('durable.schedule.outcome')); + } + } + + public static function queuedScheduleKinds(): array + { + return [ + 'buffered traced' => ['buffered', true], + 'buffered disabled' => ['buffered', false], + 'backfill traced' => ['backfill', true], + 'backfill disabled' => ['backfill', false], + ]; + } + + #[DataProvider('queuedScheduleKinds')] + public function testBufferedAndBackfilledRunsKeepTheirAtomicScheduleState(string $kind, bool $enabled): void + { + config([ + 'workflows.tracing.enabled' => $enabled, + ]); + $processor = new ScheduleTracingProcessor($this->exporter); + $provider = new TracerProvider($processor); + $scope = Configurator::create()->withTracerProvider($provider)->activate(); + try { + $schedule = ScheduleManager::create( + scheduleId: 'queued-' . $kind, + workflowClass: TracedWorkflow::class, + cronExpression: '* * * * *', + arguments: [false], + overlapPolicy: $kind === 'buffered' ? ScheduleOverlapPolicy::BufferOne : ScheduleOverlapPolicy::AllowAll, + namespace: '0', + executionTimeoutSeconds: 60, + runTimeoutSeconds: 45 + ); + if ($kind === 'buffered') { + self::assertNotNull(ScheduleManager::trigger($schedule)); + self::assertNull(ScheduleManager::trigger($schedule)); + } else { + $from = now() + ->startOfMinute() + ->subMinutes(2); + $receipts = ScheduleManager::backfill($schedule, $from, $from->copy()->addMinute()); + self::assertCount(1, $receipts); + self::assertNotNull($receipts[0]['instance_id']); + } + $first = WorkflowRun::query()->sole(); + $original = $first->getRawOriginal(); + $prefix = $first->historyEvents() + ->orderBy('sequence') + ->get() + ->map(static fn ($event): array => $event->getRawOriginal()) + ->all(); + for ($remaining = 3; $remaining > 0; --$remaining) { + $this->workOnce(); + } + if ($kind === 'buffered') { + $receipts = ScheduleManager::tick(1); + self::assertSame('drained', $receipts[0]['outcome']); + for ($remaining = 3; $remaining > 0; --$remaining) { + $this->workOnce(); + } + } + $schedule->refresh(); + $first->refresh(); + foreach (['observability_context', 'execution_deadline_at', 'run_deadline_at'] as $field) { + self::assertSame($original[$field], $first->getRawOriginal($field)); + } + self::assertSame($prefix, array_slice($first->historyEvents()->orderBy('sequence')->get() + ->map(static fn ($event): array => $event->getRawOriginal()) + ->all(), 0, count($prefix))); + $runCount = $kind === 'buffered' ? 2 : 1; + self::assertSame($runCount, (int) $schedule->fires_count); + self::assertSame($runCount, WorkflowRun::query()->where('status', 'completed')->count()); + foreach (WorkflowRun::query()->get() as $run) { + self::assertSame([9007199254740993, null, '雪'], $run->workflowOutput()); + self::assertTrue($run->started_at->copy()->addSeconds(60)->equalTo($run->execution_deadline_at)); + self::assertTrue($run->started_at->copy()->addSeconds(45)->equalTo($run->run_deadline_at)); + } + self::assertSame($runCount, ActivityAttempt::query()->where('status', 'completed')->count()); + self::assertSame($runCount * 3, WorkflowTask::query()->where('status', 'completed')->count()); + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + $triggers = array_values(array_filter( + $this->exporter->getSpans(), + static fn ($span): bool => $span->getName() === 'durable.schedule.trigger' + )); + self::assertCount($enabled ? ($kind === 'buffered' ? 3 : 1) : 0, $triggers); + if ($enabled) { + $last = $triggers[count($triggers) - 1]; + self::assertSame($kind, $last->getAttributes()->get('durable.schedule.trigger_kind')); + self::assertSame( + $kind === 'buffered' ? 'drained' : 'triggered', + $last->getAttributes() + ->get('durable.schedule.outcome') + ); + } + self::assertSame(array_fill(0, count($processor->transactionLevels), 0), $processor->transactionLevels); + } finally { + $scope->detach(); + $provider->shutdown(); + } + } + public function testFailedWorkflowExportsItsProcessingSegmentOutsideTheDatabaseTransaction(): void { config([ @@ -835,6 +1092,23 @@ private function activitySpans(): array } } +final class ScheduleTracingProcessor extends SimpleSpanProcessor +{ + public array $transactionLevels = []; + + public function onStart(ReadWriteSpanInterface $span, ContextInterface $parentContext): void + { + $this->transactionLevels[] = DB::transactionLevel(); + parent::onStart($span, $parentContext); + } + + public function onEnd(ReadableSpanInterface $span): void + { + $this->transactionLevels[] = DB::transactionLevel(); + parent::onEnd($span); + } +} + final class UnreadableTraceCast implements CastsAttributes { public static int $metadataReads = 0; diff --git a/tests/Unit/V2/ScheduleExecutionTracingTest.php b/tests/Unit/V2/ScheduleExecutionTracingTest.php new file mode 100644 index 000000000..f737d8152 --- /dev/null +++ b/tests/Unit/V2/ScheduleExecutionTracingTest.php @@ -0,0 +1,119 @@ + ['manual', true], + 'manual disabled' => ['manual', false], + 'periodic traced' => ['scheduled', true], + 'periodic disabled' => ['scheduled', false], + ]; + } + + #[DataProvider('modes')] + public function testActualScheduleAdmissionAndColdWorkersKeepTheirOriginalCaller(string $kind, bool $enabled): void + { + $directory = sys_get_temp_dir() . '/dw-tracing-schedule-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static function (string $mode) use ($directory, $kind, $enabled): array { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_SCHEDULE' => $kind, + 'DW_TEST_TRACING_ENABLED' => $enabled ? 'true' : 'false', + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + self::assertJson($process->getOutput(), substr($process->getOutput(), -1500)); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + try { + $admitted = $worker('admit'); + $workers = [$worker('work'), $worker('work'), $worker('work')]; + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { + self::assertSame($admitted[$field], $finished[$field]); + } + self::assertSame(1, $finished['schedule']['fires_count']); + self::assertSame($finished['schedule']['instance_id'], $finished['schedule']['latest_instance_id']); + self::assertSame(1, array_count_values($finished['schedule']['history_types'])['ScheduleTriggered']); + self::assertSame(1, array_count_values($finished['schedule']['history_types'])['ActivityCompleted']); + self::assertSame(1, array_count_values($finished['schedule']['history_types'])['WorkflowCompleted']); + self::assertCount(3, $finished['schedule']['tasks']); + foreach ($finished['schedule']['tasks'] as $task) { + self::assertSame('completed', $task['status']); + self::assertSame(1, $task['attempt_count']); + self::assertSame(0, $task['repair_count']); + } + foreach ([$admitted, ...$workers] as $checkpoint) { + self::assertSame($checkpoint['schedule']['history_hashes'], array_slice( + $finished['schedule']['history_hashes'], + 0, + count($checkpoint['schedule']['history_hashes']) + )); + foreach ($checkpoint['schedule']['capture_transaction_levels'] as $level) { + self::assertSame(0, $level); + } + foreach ($checkpoint['schedule']['boundaries'] as $boundary) { + self::assertSame(0, $boundary['transaction_level']); + } + } + $triggers = array_values(array_filter( + $admitted['spans'], + static fn (array $span): bool => $span['name'] === 'durable.schedule.trigger' + )); + self::assertCount($enabled ? 1 : 0, $triggers); + if ($enabled) { + self::assertSame('00f067aa0ba902b7', $triggers[0]['parent_id']); + self::assertSame($kind, $triggers[0]['fields']['durable.schedule.trigger_kind']); + self::assertSame('triggered', $triggers[0]['fields']['durable.schedule.outcome']); + self::assertSame($admitted['run_id'], $triggers[0]['fields']['workflow_run_id']); + } + $processing = array_values(array_filter( + array_merge(...array_column($workers, 'spans')), + static fn (array $span): bool => $span['kind'] === SpanKind::KIND_CONSUMER + )); + self::assertCount($enabled ? 3 : 0, $processing); + foreach ($processing as $span) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); + self::assertSame('00f067aa0ba902b7', $span['parent_id']); + self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); + } + self::assertCount(3, array_unique(array_column($workers, 'pid'))); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } +} diff --git a/tests/Unit/V2/TraceCarrierTest.php b/tests/Unit/V2/TraceCarrierTest.php index 2ac0d8429..97bb3f162 100644 --- a/tests/Unit/V2/TraceCarrierTest.php +++ b/tests/Unit/V2/TraceCarrierTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\V2; +use Fiber; use OpenTelemetry\API\Baggage\Baggage; use OpenTelemetry\API\Baggage\Propagation\BaggagePropagator; use OpenTelemetry\API\Instrumentation\Configurator; @@ -21,6 +22,64 @@ final class TraceCarrierTest extends TestCase { private const PARENT = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'; + public function testPreparedAdmissionCannotLeakIntoAnUnboundFiber(): void + { + $initial = Context::getCurrent(); + TraceCarrier::withPreparedAdmission(static function (): void { + self::assertTrue(TraceCarrier::hasPreparedAdmission()); + $fiber = new Fiber(static fn (): bool => TraceCarrier::hasPreparedAdmission()); + $fiber->start(); + self::assertFalse($fiber->getReturn()); + self::assertTrue(TraceCarrier::hasPreparedAdmission()); + }); + self::assertFalse(TraceCarrier::hasPreparedAdmission()); + self::assertSame($initial, Context::getCurrent()); + } + + public function testPreparedAdmissionCanNestWithinAnExplicitlyBoundFiber(): void + { + $initial = Context::getCurrent(); + $fiber = new Fiber(static function () use ($initial): void { + $bound = $initial->activate(); + try { + TraceCarrier::withPreparedAdmission(static function (): void { + $outer = Context::getCurrent(); + self::assertTrue(TraceCarrier::hasPreparedAdmission()); + TraceCarrier::withPreparedAdmission(static function (): void { + self::assertTrue(TraceCarrier::hasPreparedAdmission()); + }); + self::assertTrue(TraceCarrier::hasPreparedAdmission()); + self::assertSame($outer, Context::getCurrent()); + }); + self::assertFalse(TraceCarrier::hasPreparedAdmission()); + } finally { + $bound->detach(); + } + }); + $fiber->start(); + self::assertTrue($fiber->isTerminated()); + self::assertSame($initial, Context::getCurrent()); + } + + public function testPreparedAdmissionRestoresItsScopeAfterTheOriginalFailure(): void + { + $initial = Context::getCurrent(); + $failure = new RuntimeException('original admission failure'); + $calls = 0; + try { + TraceCarrier::withPreparedAdmission(static function () use ($failure, &$calls): never { + ++$calls; + throw $failure; + }); + self::fail('The original business failure must be preserved.'); + } catch (RuntimeException $caught) { + self::assertSame($failure, $caught); + } + self::assertSame(1, $calls); + self::assertFalse(TraceCarrier::hasPreparedAdmission()); + self::assertSame($initial, Context::getCurrent()); + } + public function testValidCarrierPreservesTraceIdentityAndState(): void { $input = self::envelope([ From 48dc5376f4c9a98e6404835b559d07fab6b62ede Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 23:11:34 +0000 Subject: [PATCH 31/77] refactor: share tracing control-character validation --- src/V2/Support/ExecutionTracing.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php index 93243f49f..92fc8e156 100644 --- a/src/V2/Support/ExecutionTracing.php +++ b/src/V2/Support/ExecutionTracing.php @@ -39,6 +39,8 @@ final class ExecutionTracing { private const PACKAGE_NAME = 'durable-workflow/workflow'; + private const CONTROL_CHARACTERS = '/[\x00-\x1f\x7f]/'; + private const FIELDS = [ 'workflow_instance_id', 'workflow_run_id', 'workflow_type', 'namespace', 'task_id', 'activity_id', 'activity_type', 'activity_attempt_id', @@ -577,7 +579,7 @@ private static function recordScheduleResult(SpanInterface $span, mixed $result) foreach (['outcome', 'reason'] as $key) { $value = $receipt[$key] ?? null; if (is_string($value) && $value !== '' && strlen($value) <= 256 - && ! preg_match('/[\x00-\x1f\x7f]/', $value)) { + && ! preg_match(self::CONTROL_CHARACTERS, $value)) { $attributes['durable.schedule.' . $key] = $value; } } @@ -595,7 +597,7 @@ private static function fields(array $attributes): array $value = $attributes[$key] ?? null; if (($key === 'activity_attempt_number' && is_int($value) && $value > 0) || ($key !== 'activity_attempt_number' && is_string($value) && $value !== '' && strlen($value) <= 512 - && ! preg_match('/[\x00-\x1f\x7f]/', $value))) { + && ! preg_match(self::CONTROL_CHARACTERS, $value))) { $fields[$key] = $value; } } @@ -626,7 +628,7 @@ private static function recordAdmissionResult(SpanInterface $span, CommandResult } foreach ($decisions as $key => $value) { if (is_string($value) && $value !== '' && strlen($value) <= 256 - && ! preg_match('/[\x00-\x1f\x7f]/', $value)) { + && ! preg_match(self::CONTROL_CHARACTERS, $value)) { $attributes[$key] = $value; } } From e792630d4c1071db5218f559bac671a9448803fb Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 00:20:14 +0000 Subject: [PATCH 32/77] Link buffered schedule callers after committed admission --- docs/embedded-tracing.md | 29 +- src/V2/Models/WorkflowSchedule.php | 21 +- src/V2/Support/ExecutionTracing.php | 41 ++- src/V2/Support/ScheduleManager.php | 81 +++-- tests/Fixtures/V2/tracing-process.php | 47 ++- .../Unit/V2/ScheduleExecutionTracingTest.php | 307 ++++++++++++++++++ 6 files changed, 482 insertions(+), 44 deletions(-) diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index cfa500541..bf690790f 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -192,11 +192,26 @@ Returned identities, `durable.schedule.outcome` and `durable.schedule.reason` are attached when available. Backfill's instance-only return does not invent a run ID. A buffered receipt is not an admitted run or a completed workflow. -Buffered draining and backfill have focused queue/state and transaction-boundary -controls. The later drain currently captures its actual scheduler context. -Preserving a buffered request's independent caller as a later link, wider cold -backfill/drain coverage and full schedule tracing remain unqualified. The -component adds no schedule metadata column or service wire field. +An accepted buffered trigger keeps its bounded caller carrier alongside that +specific buffered action in the internal `observability_context` field. A full +buffer keeps the first entry and its caller. The public schedule detail omits +this optional field, and tracing disabled at admission stores no carrier. + +The later drain keeps its actual scheduler parent and adds a +`buffered_schedule` link to the buffered caller after the runtime-owned admission +transaction commits. A rolled-back admission retains the original entry and +adds no success link. Legacy entries and malformed metadata produce no link; +they retain the normal scheduling behavior. The caller's baggage is not imported +into the drain. Separate cold processes qualify traced, disabled, full-buffer, +legacy/malformed and rollback-then-success paths with unchanged run deadlines, +typed results and first-attempt tasks. +Both `buffer_one` and ordered `buffer_all` draining have cold-process controls; +successive entries link their own callers rather than reusing the first caller. + +Backfill retains its focused queue/state and transaction-boundary controls; +wider cold backfill coverage and full schedule tracing remain unfinished. This +component adds no schedule column or service wire field. The internal buffered +metadata has no stable or native adoption claim. ## Signal caller links @@ -231,8 +246,8 @@ share a link identified by the first applied command. Further additions are dropped, with the count in `durable.links.dropped`. Application SDK limits may be lower. The link state ends with its processing scope. -This does not add service wire propagation, native catalog support, buffered -schedule caller links or complete fan-in tracing. +This does not add service wire propagation, native catalog support or complete +fan-in tracing. ## Update caller links diff --git a/src/V2/Models/WorkflowSchedule.php b/src/V2/Models/WorkflowSchedule.php index 26695a690..dee260241 100644 --- a/src/V2/Models/WorkflowSchedule.php +++ b/src/V2/Models/WorkflowSchedule.php @@ -319,7 +319,10 @@ public static function normalizeActionTimeouts(array $action): array // ── Buffer queue ────────────────────────────────────────────────── - public function bufferAction(?DateTimeInterface $occurrenceTime = null): void + /** + * @param array{schema: string, version: int, carrier: array}|null $observabilityContext + */ + public function bufferAction(?DateTimeInterface $occurrenceTime = null, ?array $observabilityContext = null): void { $buffer = $this->buffered_actions ?? []; @@ -331,6 +334,9 @@ public function bufferAction(?DateTimeInterface $occurrenceTime = null): void if ($occurrenceTime !== null) { $action['occurrence_time'] = $occurrenceTime->format('Y-m-d\TH:i:s.uP'); } + if ($observabilityContext !== null) { + $action['observability_context'] = $observabilityContext; + } $buffer[] = $action; $this->buffered_actions = array_values($buffer); @@ -450,6 +456,17 @@ public function toListItem(): array */ public function toDetail(): array { + $bufferedActions = $this->buffered_actions ?? []; + if (is_array($bufferedActions)) { + $bufferedActions = array_map( + static fn (mixed $action): mixed => is_array($action) + ? array_diff_key($action, [ + 'observability_context' => true, + ]) : $action, + $bufferedActions + ); + } + return [ 'schedule_id' => $this->schedule_id, 'spec' => $this->spec, @@ -466,7 +483,7 @@ public function toDetail(): array 'fires_count' => (int) $this->fires_count, 'failures_count' => (int) $this->failures_count, 'recent_actions' => $this->recent_actions ?? [], - 'buffered_actions' => $this->buffered_actions ?? [], + 'buffered_actions' => $bufferedActions, ], 'memo' => $this->memo, 'search_attributes' => $this->search_attributes, diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php index 92fc8e156..2cfef0e70 100644 --- a/src/V2/Support/ExecutionTracing.php +++ b/src/V2/Support/ExecutionTracing.php @@ -319,6 +319,32 @@ public static function linkUpdate(WorkflowRun $run, WorkflowCommand $command): v self::linkCommand($run, $command, CommandType::Update); } + public static function linkBufferedSchedule(WorkflowSchedule $schedule, mixed $stored): void + { + if (! self::enabled()) { + return; + } + try { + $fields = self::current(); + if (self::$linkKey === null || ($fields['schedule_id'] ?? null) !== $schedule->getRawOriginal('schedule_id') + || ($fields['namespace'] ?? null) !== $schedule->getRawOriginal('namespace')) { + return; + } + $state = Context::getCurrent()->get(self::$linkKey); + if (! $state instanceof TraceLinkState || ! $state->isRecording()) { + return; + } + $carrier = TraceCarrier::normalize($stored); + if ($carrier !== null) { + self::linkCarrier($state, $carrier, [ + 'durable.link.type' => 'buffered_schedule', + ]); + } + } catch (Throwable) { + // Optional causal metadata cannot replace a committed schedule decision. + } + } + public static function linkCancellation(WorkflowRun $run): void { try { @@ -354,8 +380,7 @@ private static function linkCommand(WorkflowRun $run, WorkflowCommand $command, if ($carrier === null) { return; } - $context = TraceContextPropagator::getInstance()->extract($carrier['carrier'], context: Context::getRoot()); - $state->add(Span::fromContext($context)->getContext(), [ + self::linkCarrier($state, $carrier, [ 'workflow_command_id' => $command->id, 'command_type' => $type->value, ]); @@ -364,6 +389,16 @@ private static function linkCommand(WorkflowRun $run, WorkflowCommand $command, } } + /** + * @param array{schema: string, version: int, carrier: array} $carrier + * @param array $attributes + */ + private static function linkCarrier(TraceLinkState $state, array $carrier, array $attributes): void + { + $context = TraceContextPropagator::getInstance()->extract($carrier['carrier'], context: Context::getRoot()); + $state->add(Span::fromContext($context)->getContext(), $attributes); + } + /** * @param array $attributes */ @@ -445,7 +480,7 @@ private static function segment( self::$correlationKey ??= Context::createKey('durable-workflow.correlation'); $active = $span->storeInContext($parent) ->with(self::$correlationKey, $fields); - if ($name === 'durable.workflow.task') { + if (in_array($name, ['durable.workflow.task', 'durable.schedule.trigger'], true)) { self::$linkKey ??= Context::createKey('durable-workflow.trace-links'); $active = $active->with(self::$linkKey, new TraceLinkState($span)); } diff --git a/src/V2/Support/ScheduleManager.php b/src/V2/Support/ScheduleManager.php index ce2ccc5c1..00f5a3638 100644 --- a/src/V2/Support/ScheduleManager.php +++ b/src/V2/Support/ScheduleManager.php @@ -392,40 +392,56 @@ public static function tick(int $limit = 100): array try { $drained = ExecutionTracing::scheduleAdmission( $schedule, - static fn (): ?array => DB::transaction(static function () use ($schedule): ?array { - /** @var WorkflowSchedule $schedule */ - $schedule = WorkflowSchedule::query()->lockForUpdate()->findOrFail($schedule->id); - - $bufferedAction = $schedule->drainBuffer(); - - if ($bufferedAction === null) { - return null; - } - - $schedule->save(); - - $occurrenceTime = self::dateTimeFromPayload($bufferedAction['occurrence_time'] ?? null); - if ($occurrenceTime !== null && self::hasFiredOccurrence($schedule, $occurrenceTime)) { - self::skipFiredOccurrence($schedule, $occurrenceTime); + static function () use ($schedule): ?array { + $drained = DB::transaction(static function () use ($schedule): ?array { + /** @var WorkflowSchedule $schedule */ + $schedule = WorkflowSchedule::query()->lockForUpdate()->findOrFail($schedule->id); + + $bufferedAction = $schedule->drainBuffer(); + + if ($bufferedAction === null) { + return null; + } + + $schedule->save(); + + $occurrenceTime = self::dateTimeFromPayload($bufferedAction['occurrence_time'] ?? null); + if ($occurrenceTime !== null && self::hasFiredOccurrence($schedule, $occurrenceTime)) { + self::skipFiredOccurrence($schedule, $occurrenceTime); + + return [ + 'instance_id' => null, + 'run_id' => null, + 'outcome' => 'skipped', + 'reason' => 'already_fired_occurrence', + 'occurrence_time' => $occurrenceTime->format('Y-m-d\TH:i:s.uP'), + ]; + } + + $startResult = self::startRun( + $schedule, + occurrenceTime: $occurrenceTime, + outcome: 'drained' + ); return [ - 'instance_id' => null, - 'run_id' => null, - 'outcome' => 'skipped', - 'reason' => 'already_fired_occurrence', - 'occurrence_time' => $occurrenceTime->format('Y-m-d\TH:i:s.uP'), + 'instance_id' => $startResult->instanceId, + 'run_id' => $startResult->runId, + 'outcome' => 'drained', + 'occurrence_time' => $occurrenceTime?->format('Y-m-d\TH:i:s.uP'), + 'observability_context' => $bufferedAction['observability_context'] ?? null, ]; + }); + if ($drained !== null) { + ExecutionTracing::linkBufferedSchedule( + $schedule, + $drained['observability_context'] ?? null + ); + unset($drained['observability_context']); } - $startResult = self::startRun($schedule, occurrenceTime: $occurrenceTime, outcome: 'drained'); - - return [ - 'instance_id' => $startResult->instanceId, - 'run_id' => $startResult->runId, - 'outcome' => 'drained', - 'occurrence_time' => $occurrenceTime?->format('Y-m-d\TH:i:s.uP'), - ]; - }), + return $drained; + }, 'buffered' ); @@ -597,11 +613,14 @@ private static function triggerAtomically( ?CommandContext $context, ?DateTimeInterface $occurrenceTime, ): ScheduleTriggerResult { + $observabilityContext = ExecutionTracing::enabled() ? TraceCarrier::admissionParent() : null; + return DB::transaction(static function () use ( $schedule, $overlapPolicyOverride, $context, - $occurrenceTime + $occurrenceTime, + $observabilityContext ): ScheduleTriggerResult { /** @var WorkflowSchedule $schedule */ $schedule = WorkflowSchedule::query() @@ -652,7 +671,7 @@ private static function triggerAtomically( return new ScheduleTriggerResult('buffer_full', null, null, 'buffer_full'); } - $schedule->bufferAction($occurrenceTime); + $schedule->bufferAction($occurrenceTime, $observabilityContext); $schedule->next_fire_at = $schedule->computeNextFireAtWithJitter(); $schedule->save(); diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 84572f811..c7e3d59b5 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -37,6 +37,7 @@ use Workflow\V2\Activity; use Workflow\V2\Attributes\Signal; use Workflow\V2\CommandContext; +use Workflow\V2\Enums\ScheduleOverlapPolicy; use Workflow\V2\Events\WorkflowStarted; use Workflow\V2\Jobs\RunActivityTask; use Workflow\V2\Jobs\RunTimerTask; @@ -555,6 +556,11 @@ public function extract( if ($signalLinks && $mode === 'signal') { $headers['traceparent'] = getenv('DW_TEST_TRACING_SIGNAL_CALLER'); $headers['baggage'] = 'approved=caller'; + } elseif ($scheduleProbe === 'buffered' && in_array($mode, ['buffer', 'buffer-full', 'buffer-next'], true)) { + $headers['traceparent'] = $mode === 'buffer' + ? '00-cccccccccccccccccccccccccccccccc-dddddddddddddddd-01' + : '00-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-ffffffffffffffff-01'; + $headers['baggage'] = 'approved=buffer-only'; } elseif ($updateLinks && $mode === 'update') { $headers['traceparent'] = getenv('DW_TEST_TRACING_UPDATE_CALLER'); $headers['baggage'] = 'approved=update-caller'; @@ -607,6 +613,8 @@ public function extract( $cancellationResult = null; $updateResult = null; $admissionResult = null; + $scheduleBufferResult = null; + $scheduleDrainResults = null; $admissionContextPreserved = null; try { if ($mode === 'admit') { @@ -650,8 +658,11 @@ public function extract( $schedule = ScheduleManager::create( scheduleId: 'cold-schedule', workflowClass: TracingProcessWorkflow::class, - cronExpression: '* * * * *', + cronExpression: $scheduleProbe === 'buffered' ? '0 0 1 1 *' : '* * * * *', arguments: [false], + overlapPolicy: $scheduleProbe === 'buffered' + ? ScheduleOverlapPolicy::from(getenv('DW_TEST_TRACING_BUFFER_POLICY') ?: 'buffer_one') + : ScheduleOverlapPolicy::Skip, namespace: '0', executionTimeoutSeconds: 60, runTimeoutSeconds: 45 @@ -751,6 +762,34 @@ public function extract( ]; } } + } elseif ($scheduleProbe === 'buffered' && in_array($mode, ['buffer', 'buffer-full', 'buffer-next'], true)) { + $schedule = WorkflowSchedule::query()->where('schedule_id', 'cold-schedule')->sole(); + $scheduleBufferResult = ScheduleManager::triggerDetailed($schedule)->outcome; + } elseif ($scheduleProbe === 'buffered' && $mode === 'buffer-metadata') { + $schedule = WorkflowSchedule::query()->where('schedule_id', 'cold-schedule')->sole(); + $buffer = $schedule->buffered_actions; + if (getenv('DW_TEST_TRACING_BUFFER_METADATA') === 'legacy') { + unset($buffer[0]['observability_context']); + } else { + $buffer[0]['observability_context'] = 'malformed optional context'; + } + $schedule->buffered_actions = $buffer; + $schedule->save(); + } elseif ($scheduleProbe === 'buffered' && in_array($mode, ['drain', 'drain-fail'], true)) { + if ($mode === 'drain-fail') { + $app['events']->listen(WorkflowStarted::class, static function (): never { + throw new \RuntimeException('deliberate buffered admission rollback'); + }); + } + $triggered = ScheduleManager::tick(1); + $scheduleDrainResults = $triggered; + $instanceId = $triggered[0]['instance_id'] ?? null; + if ($mode === 'drain' && (! is_string($instanceId) || $instanceId === '')) { + throw new \RuntimeException('The actual buffer did not admit its workflow.'); + } + if ($mode === 'drain') { + file_put_contents($directory . '/schedule-instance', $instanceId); + } } elseif ($mode === 'update' && $updateLinks) { $step = getenv('DW_TEST_TRACING_UPDATE_STEP'); $stub = WorkflowStub::make(TracingUpdateLinksWorkflow::class, 'cold-tracing', '0') @@ -950,7 +989,13 @@ public function extract( 'id' => $schedule->schedule_id, 'namespace' => $schedule->namespace, 'fires_count' => (int) $schedule->fires_count, + 'failures_count' => (int) $schedule->failures_count, + 'run_count' => WorkflowRun::query()->count(), 'latest_instance_id' => $schedule->latest_workflow_instance_id, + 'buffer_result' => $scheduleBufferResult, + 'drain_results' => $scheduleDrainResults, + 'buffered_actions' => $schedule->buffered_actions, + 'public_buffered_actions' => $schedule->toDetail()['info']['buffered_actions'], 'instance_id' => $run->workflow_instance_id, 'boundaries' => $admissionProcessor->boundaries, 'capture_transaction_levels' => $recordedPropagator->transactionLevels, diff --git a/tests/Unit/V2/ScheduleExecutionTracingTest.php b/tests/Unit/V2/ScheduleExecutionTracingTest.php index f737d8152..a884d5f27 100644 --- a/tests/Unit/V2/ScheduleExecutionTracingTest.php +++ b/tests/Unit/V2/ScheduleExecutionTracingTest.php @@ -7,12 +7,219 @@ use Composer\Autoload\ClassLoader; use Illuminate\Filesystem\Filesystem; use OpenTelemetry\API\Trace\SpanKind; +use OpenTelemetry\API\Trace\StatusCode; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\Process\Process; final class ScheduleExecutionTracingTest extends TestCase { + public static function bufferModes(): array + { + return [ + 'buffered caller' => [true], + 'disabled buffer control' => [false], + ]; + } + + #[DataProvider('bufferModes')] + public function testBufferedCallerLinksOnlyItsCommittedColdDrain(bool $enabled): void + { + $directory = sys_get_temp_dir() . '/dw-tracing-buffer-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static fn (string $mode): array => self::bufferWorker($directory, $mode, $enabled); + try { + $original = $worker('admit'); + $buffered = $worker('buffer'); + self::assertSame('buffered', $buffered['schedule']['buffer_result']); + self::assertCount(1, $buffered['schedule']['buffered_actions']); + $entry = $buffered['schedule']['buffered_actions'][0]; + if ($enabled) { + self::assertSame( + '00-cccccccccccccccccccccccccccccccc-dddddddddddddddd-01', + $entry['observability_context']['carrier']['traceparent'] ?? null + ); + self::assertSame('approved=buffer-only', $entry['observability_context']['carrier']['baggage']); + } else { + self::assertArrayNotHasKey('observability_context', $entry); + } + unset($entry['observability_context']); + self::assertSame([$entry], $buffered['schedule']['public_buffered_actions']); + + $full = $worker('buffer-full'); + self::assertSame('buffer_full', $full['schedule']['buffer_result']); + self::assertSame($buffered['schedule']['buffered_actions'], $full['schedule']['buffered_actions']); + for ($workerIndex = 0; $workerIndex < 3; ++$workerIndex) { + $worker('work'); + } + $completed = $worker('inspect'); + self::assertSame('completed', $completed['status']); + self::assertSame($original['run_id'], $completed['run_id']); + self::assertSame($original['execution_deadline'], $completed['execution_deadline']); + self::assertSame($original['run_deadline'], $completed['run_deadline']); + + $drained = $worker('drain'); + self::assertNotSame($original['run_id'], $drained['run_id']); + self::assertNull($drained['schedule']['buffered_actions']); + self::assertArrayNotHasKey('observability_context', $drained['schedule']['drain_results'][0]); + if ($enabled) { + $drainedCarrier = json_decode($drained['carrier'], true, flags: JSON_THROW_ON_ERROR); + self::assertArrayNotHasKey('baggage', $drainedCarrier['carrier']); + } + $triggers = array_values(array_filter( + $drained['spans'], + static fn (array $span): bool => $span['name'] === 'durable.schedule.trigger' + )); + self::assertCount($enabled ? 1 : 0, $triggers); + if ($enabled) { + self::assertSame('11111111111111111111111111111111', $triggers[0]['trace_id']); + self::assertSame('2222222222222222', $triggers[0]['parent_id']); + self::assertSame([[ + 'trace_id' => 'cccccccccccccccccccccccccccccccc', + 'span_id' => 'dddddddddddddddd', + 'fields' => [ + 'durable.link.type' => 'buffered_schedule', + ], + ]], $triggers[0]['links']); + } + for ($workerIndex = 0; $workerIndex < 3; ++$workerIndex) { + $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame($drained['carrier'], $finished['carrier']); + self::assertSame($drained['execution_deadline'], $finished['execution_deadline']); + self::assertSame($drained['run_deadline'], $finished['run_deadline']); + self::assertSame(2, $finished['schedule']['fires_count']); + self::assertCount(6, $finished['schedule']['tasks']); + foreach ($finished['schedule']['tasks'] as $task) { + self::assertSame('completed', $task['status']); + self::assertSame(1, $task['attempt_count']); + self::assertSame(0, $task['repair_count']); + } + foreach ([$original, $buffered, $full, $completed, $drained, $finished] as $checkpoint) { + foreach ($checkpoint['schedule']['capture_transaction_levels'] as $level) { + self::assertSame(0, $level); + } + foreach ($checkpoint['schedule']['boundaries'] as $boundary) { + self::assertSame(0, $boundary['transaction_level']); + } + } + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } + + public static function missingBufferMetadata(): array + { + return [ + 'legacy buffer' => ['legacy'], + 'malformed optional carrier' => ['malformed'], + ]; + } + + #[DataProvider('missingBufferMetadata')] + public function testLegacyOrMalformedBufferKeepsBusinessStateWithoutInventingACaller(string $metadata): void + { + $directory = sys_get_temp_dir() . '/dw-tracing-buffer-metadata-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static fn (string $mode): array => self::bufferWorker($directory, $mode, true, $metadata); + try { + $worker('admit'); + $buffered = $worker('buffer'); + $changed = $worker('buffer-metadata'); + self::assertSame( + $buffered['schedule']['public_buffered_actions'], + $changed['schedule']['public_buffered_actions'] + ); + self::assertSame($buffered['schedule']['history_hashes'], $changed['schedule']['history_hashes']); + for ($workerIndex = 0; $workerIndex < 3; ++$workerIndex) { + $worker('work'); + } + $drained = $worker('drain'); + $triggers = array_values(array_filter( + $drained['spans'], + static fn (array $span): bool => $span['name'] === 'durable.schedule.trigger' + )); + self::assertCount(1, $triggers); + self::assertSame('2222222222222222', $triggers[0]['parent_id']); + self::assertSame([], $triggers[0]['links']); + self::assertNull($drained['schedule']['buffered_actions']); + for ($workerIndex = 0; $workerIndex < 3; ++$workerIndex) { + $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame($drained['execution_deadline'], $finished['execution_deadline']); + self::assertSame($drained['run_deadline'], $finished['run_deadline']); + self::assertSame(0, $finished['repairs']); + self::assertSame(0, $finished['failed_jobs']); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } + + public function testRolledBackDrainRetainsItsOriginalBufferedCallerUntilActualAdmission(): void + { + $directory = sys_get_temp_dir() . '/dw-tracing-buffer-rollback-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static fn (string $mode): array => self::bufferWorker($directory, $mode, true); + try { + $worker('admit'); + $buffered = $worker('buffer'); + for ($workerIndex = 0; $workerIndex < 3; ++$workerIndex) { + $worker('work'); + } + $before = $worker('inspect'); + $failed = $worker('drain-fail'); + self::assertSame([[ + 'schedule_id' => 'cold-schedule', + 'instance_id' => null, + 'error' => 'deliberate buffered admission rollback', + ]], $failed['schedule']['drain_results']); + self::assertSame(1, $failed['schedule']['failures_count']); + self::assertSame(1, $failed['schedule']['fires_count']); + self::assertSame(1, $failed['schedule']['run_count']); + self::assertSame($buffered['schedule']['buffered_actions'], $failed['schedule']['buffered_actions']); + foreach (['run_id', 'execution_deadline', 'run_deadline', 'carrier'] as $field) { + self::assertSame($before[$field], $failed[$field]); + } + self::assertSame($before['schedule']['history_hashes'], $failed['schedule']['history_hashes']); + self::assertSame($before['schedule']['tasks'], $failed['schedule']['tasks']); + $triggers = array_values(array_filter( + $failed['spans'], + static fn (array $span): bool => $span['name'] === 'durable.schedule.trigger' + )); + self::assertCount(1, $triggers); + self::assertSame([], $triggers[0]['links']); + self::assertSame(StatusCode::STATUS_ERROR, $triggers[0]['status_code']); + + $drained = $worker('drain'); + $triggers = array_values(array_filter( + $drained['spans'], + static fn (array $span): bool => $span['name'] === 'durable.schedule.trigger' + )); + self::assertSame('cccccccccccccccccccccccccccccccc', $triggers[0]['links'][0]['trace_id']); + self::assertNull($drained['schedule']['buffered_actions']); + self::assertSame(2, $drained['schedule']['run_count']); + for ($workerIndex = 0; $workerIndex < 3; ++$workerIndex) { + $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame(0, $finished['repairs']); + self::assertSame(0, $finished['failed_jobs']); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } + public static function modes(): array { return [ @@ -116,4 +323,104 @@ public function testActualScheduleAdmissionAndColdWorkersKeepTheirOriginalCaller (new Filesystem())->deleteDirectory($directory); } } + + public function testAllBufferedCallersRemainPairedWithTheirOwnColdDrains(): void + { + $directory = sys_get_temp_dir() . '/dw-tracing-buffer-all-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static fn (string $mode): array => self::bufferWorker($directory, $mode, true, 'valid', 'buffer_all'); + try { + $original = $worker('admit'); + $first = $worker('buffer'); + $second = $worker('buffer-next'); + self::assertSame('buffered', $first['schedule']['buffer_result']); + self::assertSame('buffered', $second['schedule']['buffer_result']); + self::assertCount(2, $second['schedule']['buffered_actions']); + self::assertSame($first['schedule']['buffered_actions'][0], $second['schedule']['buffered_actions'][0]); + $runIds = [$original['run_id']]; + foreach (['cccccccccccccccccccccccccccccccc', 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'] as $caller) { + for ($workerIndex = 0; $workerIndex < 3; ++$workerIndex) { + $worker('work'); + } + $before = $worker('inspect'); + self::assertSame('completed', $before['status']); + $drained = $worker('drain'); + $runIds[] = $drained['run_id']; + $triggers = array_values(array_filter( + $drained['spans'], + static fn (array $span): bool => $span['name'] === 'durable.schedule.trigger' + )); + self::assertCount(1, $triggers); + self::assertCount(1, $triggers[0]['links']); + self::assertSame($caller, $triggers[0]['links'][0]['trace_id']); + self::assertSame('2222222222222222', $triggers[0]['parent_id']); + if ($caller === 'cccccccccccccccccccccccccccccccc') { + self::assertSame([$second['schedule']['buffered_actions'][1]], + $drained['schedule']['buffered_actions'] + ); + } else { + self::assertNull($drained['schedule']['buffered_actions']); + } + foreach ($drained['schedule']['boundaries'] as $boundary) { + self::assertSame(0, $boundary['transaction_level']); + } + } + for ($workerIndex = 0; $workerIndex < 3; ++$workerIndex) { + $worker('work'); + } + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame($drained['execution_deadline'], $finished['execution_deadline']); + self::assertSame($drained['run_deadline'], $finished['run_deadline']); + self::assertSame(3, count(array_unique($runIds))); + self::assertSame(3, $finished['schedule']['fires_count']); + self::assertSame(3, $finished['schedule']['run_count']); + self::assertCount(9, $finished['schedule']['tasks']); + foreach ($finished['schedule']['tasks'] as $task) { + self::assertSame('completed', $task['status']); + self::assertSame(1, $task['attempt_count']); + self::assertSame(0, $task['repair_count']); + } + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } + + private static function bufferWorker( + string $directory, + string $mode, + bool $enabled, + string $metadata = 'valid', + string $policy = 'buffer_one' + ): array { + $packageVendor = dirname(__DIR__, 3) . '/vendor'; + $otherVendors = array_filter( + array_keys(ClassLoader::getRegisteredLoaders()), + static fn (string $vendor): bool => $vendor !== $packageVendor + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + [...$otherVendors, $packageVendor] + ), + ], env: [ + 'DW_TEST_TRACING_SCHEDULE' => 'buffered', + 'DW_TEST_TRACING_ENABLED' => $enabled ? 'true' : 'false', + 'DW_TEST_TRACING_BUFFER_METADATA' => $metadata, + 'DW_TEST_TRACING_BUFFER_POLICY' => $policy, + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + self::assertJson($process->getOutput(), substr($process->getOutput(), -1500)); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + } } From f81a9d1da3ce65a464e204cf3c1a6a51af335f3e Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 00:34:56 +0000 Subject: [PATCH 33/77] test: qualify cold backfill trace identity --- docs/embedded-tracing.md | 16 +- tests/Fixtures/V2/tracing-process.php | 44 ++++- .../Unit/V2/ScheduleExecutionTracingTest.php | 187 +++++++++++++++--- 3 files changed, 210 insertions(+), 37 deletions(-) diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index bf690790f..4b166ecff 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -208,10 +208,18 @@ typed results and first-attempt tasks. Both `buffer_one` and ordered `buffer_all` draining have cold-process controls; successive entries link their own callers rather than reusing the first caller. -Backfill retains its focused queue/state and transaction-boundary controls; -wider cold backfill coverage and full schedule tracing remain unfinished. This -component adds no schedule column or service wire field. The internal buffered -metadata has no stable or native adoption claim. +Cold backfill controls cover traced and disabled admission, an already-fired +occurrence, rollback and a later successful admission of that same occurrence. +Independent queue workers retain the actual backfill caller as their parent. +The failed admission reports an error without a successful instance or run +identity. Duplicate and failed admissions preserve the original run, carrier, +history and deadlines. A later occurrence gets its own caller without changing +the earlier completed run. Disabled controls keep the same business outcomes +without capturing a carrier or emitting spans. + +These schedule controls add no schedule column or service wire field. The +internal buffered metadata has no stable or native adoption claim. Full tracing +acceptance, broader gates and overhead measurements remain unfinished. ## Signal caller links diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index c7e3d59b5..c726d38e5 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -615,6 +615,7 @@ public function extract( $admissionResult = null; $scheduleBufferResult = null; $scheduleDrainResults = null; + $scheduleBackfillResults = null; $admissionContextPreserved = null; try { if ($mode === 'admit') { @@ -677,6 +678,13 @@ public function extract( ])->save(); $triggered = ScheduleManager::tick(1); $instanceId = $triggered[0]['instance_id'] ?? null; + } elseif ($scheduleProbe === 'backfill') { + $from = now() + ->startOfMinute() + ->subMinutes(2); + file_put_contents($directory . '/backfill-from', $from->toIso8601String()); + $scheduleBackfillResults = ScheduleManager::backfill($schedule, $from, $from->copy()->addMinute()); + $instanceId = $scheduleBackfillResults[0]['instance_id'] ?? null; } else { $instanceId = ScheduleManager::trigger($schedule); } @@ -684,6 +692,9 @@ public function extract( throw new \RuntimeException('The actual schedule did not admit a workflow.'); } file_put_contents($directory . '/schedule-instance', $instanceId); + if ($scheduleProbe === 'backfill') { + file_put_contents($directory . '/backfill-first-instance', $instanceId); + } } elseif ($childRetry) { // A real bridge-authored policy, not an invented PHP child option or history edit. config() @@ -762,6 +773,29 @@ public function extract( ]; } } + } elseif ($scheduleProbe === 'backfill' && in_array( + $mode, + ['backfill-duplicate', 'backfill-fail', 'backfill-next'], + true + )) { + $schedule = WorkflowSchedule::query()->where('schedule_id', 'cold-schedule')->sole(); + $from = \Carbon\CarbonImmutable::parse(trim(file_get_contents($directory . '/backfill-from'))); + if ($mode !== 'backfill-duplicate') { + $from = $from->addMinute(); + } + if ($mode === 'backfill-fail') { + $app['events']->listen(WorkflowStarted::class, static function (): never { + throw new \RuntimeException('deliberate backfill admission rollback'); + }); + } + $scheduleBackfillResults = ScheduleManager::backfill($schedule, $from, $from->addMinute()); + if ($mode === 'backfill-next') { + $instanceId = $scheduleBackfillResults[0]['instance_id'] ?? null; + if (! is_string($instanceId) || $instanceId === '') { + throw new \RuntimeException('The next backfill occurrence did not admit a workflow.'); + } + file_put_contents($directory . '/schedule-instance', $instanceId); + } } elseif ($scheduleProbe === 'buffered' && in_array($mode, ['buffer', 'buffer-full', 'buffer-next'], true)) { $schedule = WorkflowSchedule::query()->where('schedule_id', 'cold-schedule')->sole(); $scheduleBufferResult = ScheduleManager::triggerDetailed($schedule)->outcome; @@ -912,7 +946,7 @@ public function extract( throw new \RuntimeException(Artisan::output()); } $repairReport = json_decode(Artisan::output(), true, flags: JSON_THROW_ON_ERROR); - } elseif ($mode !== 'inspect') { + } elseif (! in_array($mode, ['inspect', 'inspect-first'], true)) { throw new \RuntimeException('Unknown fixture command.'); } if ($admissionProbe) { @@ -923,7 +957,12 @@ public function extract( $requestSpan?->end(); } $selectedInstance = $scheduleProbe - ? trim(file_get_contents($directory . '/schedule-instance')) : 'cold-tracing'; + ? trim( + file_get_contents( + $directory . ($mode === 'inspect-first' ? '/backfill-first-instance' : '/schedule-instance') + ) + ) + : 'cold-tracing'; $runs = WorkflowRun::query()->where('workflow_instance_id', $selectedInstance); $run = $transition === 'continue' ? $runs->orderByDesc('run_number') ->firstOrFail() : $runs->sole(); @@ -994,6 +1033,7 @@ public function extract( 'latest_instance_id' => $schedule->latest_workflow_instance_id, 'buffer_result' => $scheduleBufferResult, 'drain_results' => $scheduleDrainResults, + 'backfill_results' => $scheduleBackfillResults, 'buffered_actions' => $schedule->buffered_actions, 'public_buffered_actions' => $schedule->toDetail()['info']['buffered_actions'], 'instance_id' => $run->workflow_instance_id, diff --git a/tests/Unit/V2/ScheduleExecutionTracingTest.php b/tests/Unit/V2/ScheduleExecutionTracingTest.php index a884d5f27..61b267d98 100644 --- a/tests/Unit/V2/ScheduleExecutionTracingTest.php +++ b/tests/Unit/V2/ScheduleExecutionTracingTest.php @@ -227,6 +227,8 @@ public static function modes(): array 'manual disabled' => ['manual', false], 'periodic traced' => ['scheduled', true], 'periodic disabled' => ['scheduled', false], + 'backfill traced' => ['backfill', true], + 'backfill disabled' => ['backfill', false], ]; } @@ -235,32 +237,7 @@ public function testActualScheduleAdmissionAndColdWorkersKeepTheirOriginalCaller { $directory = sys_get_temp_dir() . '/dw-tracing-schedule-' . bin2hex(random_bytes(8)); mkdir($directory, 0700); - $worker = static function (string $mode) use ($directory, $kind, $enabled): array { - $packageVendor = dirname(__DIR__, 3) . '/vendor'; - $otherVendors = array_filter( - array_keys(ClassLoader::getRegisteredLoaders()), - static fn (string $vendor): bool => $vendor !== $packageVendor - ); - $process = new Process([ - PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', - ...array_map( - static fn (string $vendor): string => $vendor . '/autoload.php', - [...$otherVendors, $packageVendor] - ), - ], env: [ - 'DW_TEST_TRACING_SCHEDULE' => $kind, - 'DW_TEST_TRACING_ENABLED' => $enabled ? 'true' : 'false', - ]); - $process->setTimeout(15); - $process->mustRun(); - self::assertSame('', $process->getErrorOutput()); - self::assertJson($process->getOutput(), substr($process->getOutput(), -1500)); - $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); - self::assertSame([], $result['outside_context']); - self::assertTrue($result['scope_restored']); - - return $result; - }; + $worker = static fn (string $mode): array => self::scheduleWorker($directory, $mode, $kind, $enabled); try { $admitted = $worker('admit'); $workers = [$worker('work'), $worker('work'), $worker('work')]; @@ -306,7 +283,20 @@ public function testActualScheduleAdmissionAndColdWorkersKeepTheirOriginalCaller self::assertSame('00f067aa0ba902b7', $triggers[0]['parent_id']); self::assertSame($kind, $triggers[0]['fields']['durable.schedule.trigger_kind']); self::assertSame('triggered', $triggers[0]['fields']['durable.schedule.outcome']); - self::assertSame($admitted['run_id'], $triggers[0]['fields']['workflow_run_id']); + if ($kind === 'backfill') { + self::assertSame( + $admitted['schedule']['instance_id'], + $triggers[0]['fields']['workflow_instance_id'] + ); + self::assertArrayNotHasKey('workflow_run_id', $triggers[0]['fields']); + self::assertCount(1, $admitted['schedule']['backfill_results']); + self::assertSame( + $admitted['schedule']['instance_id'], + $admitted['schedule']['backfill_results'][0]['instance_id'] + ); + } else { + self::assertSame($admitted['run_id'], $triggers[0]['fields']['workflow_run_id']); + } } $processing = array_values(array_filter( array_merge(...array_column($workers, 'spans')), @@ -324,6 +314,128 @@ public function testActualScheduleAdmissionAndColdWorkersKeepTheirOriginalCaller } } + #[DataProvider('bufferModes')] + public function testBackfillDuplicateAndRollbackKeepTheOriginalOccurrenceAndCaller(bool $enabled): void + { + $directory = sys_get_temp_dir() . '/dw-tracing-backfill-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static fn (string $mode): array => self::scheduleWorker($directory, $mode, 'backfill', $enabled); + try { + $admitted = $worker('admit'); + if (! $enabled) { + self::assertNull($admitted['carrier']); + } + $duplicate = $worker('backfill-duplicate'); + self::assertNull($duplicate['schedule']['backfill_results'][0]['instance_id']); + self::assertSame( + $admitted['schedule']['backfill_results'][0]['cron_time'], + $duplicate['schedule']['backfill_results'][0]['cron_time'] + ); + self::assertSame(1, $duplicate['schedule']['run_count']); + self::assertSame(1, $duplicate['schedule']['fires_count']); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { + self::assertSame($admitted[$field], $duplicate[$field]); + } + self::assertSame($admitted['schedule']['history_hashes'], $duplicate['schedule']['history_hashes']); + self::assertSame($admitted['schedule']['tasks'], $duplicate['schedule']['tasks']); + $duplicateSpans = array_values(array_filter( + $duplicate['spans'], + static fn (array $span): bool => $span['name'] === 'durable.schedule.trigger' + )); + self::assertCount($enabled ? 1 : 0, $duplicateSpans); + if ($enabled) { + self::assertSame('no_run', $duplicateSpans[0]['fields']['durable.schedule.outcome']); + self::assertArrayNotHasKey('workflow_instance_id', $duplicateSpans[0]['fields']); + self::assertArrayNotHasKey('workflow_run_id', $duplicateSpans[0]['fields']); + } + for ($index = 0; $index < 3; ++$index) { + $worker('work'); + } + $original = $worker('inspect'); + self::assertSame('completed', $original['status']); + $failed = $worker('backfill-fail'); + self::assertSame( + 'deliberate backfill admission rollback', + $failed['schedule']['backfill_results'][0]['error'] + ); + self::assertNull($failed['schedule']['backfill_results'][0]['instance_id']); + self::assertSame(1, $failed['schedule']['run_count']); + self::assertSame(1, $failed['schedule']['fires_count']); + self::assertSame(1, $failed['schedule']['failures_count']); + self::assertSame(0, $failed['jobs']); + self::assertSame($original['schedule']['tasks'], $failed['schedule']['tasks']); + self::assertSame($original['schedule']['history_hashes'], $failed['schedule']['history_hashes']); + $failedSpans = array_values(array_filter( + $failed['spans'], + static fn (array $span): bool => $span['name'] === 'durable.schedule.trigger' + )); + self::assertCount($enabled ? 1 : 0, $failedSpans); + if ($enabled) { + self::assertSame(StatusCode::STATUS_ERROR, $failedSpans[0]['status_code']); + self::assertSame(\RuntimeException::class, $failedSpans[0]['fields']['error.type']); + self::assertArrayNotHasKey('workflow_instance_id', $failedSpans[0]['fields']); + self::assertArrayNotHasKey('workflow_run_id', $failedSpans[0]['fields']); + } + $next = $worker('backfill-next'); + self::assertNotSame($original['run_id'], $next['run_id']); + self::assertSame( + $failed['schedule']['backfill_results'][0]['cron_time'], + $next['schedule']['backfill_results'][0]['cron_time'] + ); + self::assertSame(2, $next['schedule']['fires_count']); + self::assertSame(2, $next['schedule']['run_count']); + $workers = [$worker('work'), $worker('work'), $worker('work')]; + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status']); + self::assertSame([9007199254740993, null, '雪'], $finished['output']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { + self::assertSame($next[$field], $finished[$field]); + } + self::assertSame($next['schedule']['history_hashes'], array_slice( + $finished['schedule']['history_hashes'], + 0, + count($next['schedule']['history_hashes']) + )); + foreach (['ScheduleTriggered', 'ActivityCompleted', 'WorkflowCompleted'] as $eventType) { + self::assertSame(1, array_count_values($finished['schedule']['history_types'])[$eventType]); + } + foreach ($finished['schedule']['tasks'] as $task) { + self::assertSame('completed', $task['status']); + self::assertSame(1, $task['attempt_count']); + self::assertSame(0, $task['repair_count']); + } + self::assertCount(6, $finished['schedule']['tasks']); + $processing = array_values(array_filter( + array_merge(...array_column($workers, 'spans')), + static fn (array $span): bool => $span['kind'] === SpanKind::KIND_CONSUMER + )); + self::assertCount($enabled ? 3 : 0, $processing); + foreach ($processing as $span) { + self::assertSame('11111111111111111111111111111111', $span['trace_id']); + self::assertSame('2222222222222222', $span['parent_id']); + self::assertSame($next['run_id'], $span['fields']['workflow_run_id']); + } + $retained = $worker('inspect-first'); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline', 'status', 'output'] as $field) { + self::assertSame($original[$field], $retained[$field]); + } + self::assertSame($original['schedule']['history_hashes'], $retained['schedule']['history_hashes']); + foreach ([$duplicate, $failed, $next, ...$workers] as $checkpoint) { + foreach ($checkpoint['schedule']['capture_transaction_levels'] as $level) { + self::assertSame(0, $level); + } + foreach ($checkpoint['schedule']['boundaries'] as $boundary) { + self::assertSame(0, $boundary['transaction_level']); + } + } + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } + public function testAllBufferedCallersRemainPairedWithTheirOwnColdDrains(): void { $directory = sys_get_temp_dir() . '/dw-tracing-buffer-all-' . bin2hex(random_bytes(8)); @@ -355,7 +467,8 @@ public function testAllBufferedCallersRemainPairedWithTheirOwnColdDrains(): void self::assertSame($caller, $triggers[0]['links'][0]['trace_id']); self::assertSame('2222222222222222', $triggers[0]['parent_id']); if ($caller === 'cccccccccccccccccccccccccccccccc') { - self::assertSame([$second['schedule']['buffered_actions'][1]], + self::assertSame( + [$second['schedule']['buffered_actions'][1]], $drained['schedule']['buffered_actions'] ); } else { @@ -395,6 +508,19 @@ private static function bufferWorker( bool $enabled, string $metadata = 'valid', string $policy = 'buffer_one' + ): array { + return self::scheduleWorker($directory, $mode, 'buffered', $enabled, [ + 'DW_TEST_TRACING_BUFFER_METADATA' => $metadata, + 'DW_TEST_TRACING_BUFFER_POLICY' => $policy, + ]); + } + + private static function scheduleWorker( + string $directory, + string $mode, + string $kind, + bool $enabled, + array $environment = [] ): array { $packageVendor = dirname(__DIR__, 3) . '/vendor'; $otherVendors = array_filter( @@ -408,10 +534,9 @@ private static function bufferWorker( [...$otherVendors, $packageVendor] ), ], env: [ - 'DW_TEST_TRACING_SCHEDULE' => 'buffered', + 'DW_TEST_TRACING_SCHEDULE' => $kind, 'DW_TEST_TRACING_ENABLED' => $enabled ? 'true' : 'false', - 'DW_TEST_TRACING_BUFFER_METADATA' => $metadata, - 'DW_TEST_TRACING_BUFFER_POLICY' => $policy, + ...$environment, ]); $process->setTimeout(15); $process->mustRun(); From b91794f4d5f4a772ca59eaa934bda84ee8d9b79b Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 02:31:54 +0000 Subject: [PATCH 34/77] Trace embedded local activity callbacks in their hosting task --- docs/embedded-tracing.md | 9 + src/V2/Support/ExecutionTracing.php | 52 ++++- src/V2/Support/LocalActivityExecutor.php | 100 ++++++--- .../Unit/V2/ActivityExecutionTracingTest.php | 208 ++++++++++++++++++ 4 files changed, 332 insertions(+), 37 deletions(-) diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index 4b166ecff..ffe322920 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -130,6 +130,15 @@ admission context survives the qualified cold-worker, child, continuation and signal-with-start paths. Existing runs keep their original admission context. An untraced legacy admission does not borrow a worker's ambient parent. +An embedded local callback uses an internal `durable.local_activity.attempt` +span for its object construction, injected dependencies and handler. It inherits +the active hosting workflow task only when instance, run, namespace and task +all match. Otherwise it uses the stored admission parent, or a root for a legacy +run. Each retry has its own attempt span. History replay, refused attempts and +mocked results do not emit callback spans. Local attempts have no activity-queue +wait measurement. The span ends before outcome recording, and the existing +execution transaction and retry dispatch rules remain unchanged. + Queue dispatch emits a short `durable.task.dispatch` producer span outside the database transaction. It covers the queue dispatcher call. A returned call is not proof that a worker claimed or completed the task. Poll mode and fake diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php index 2cfef0e70..541120aff 100644 --- a/src/V2/Support/ExecutionTracing.php +++ b/src/V2/Support/ExecutionTracing.php @@ -142,6 +142,27 @@ public static function activity( ); } + /** + * @param array $attributes + * @param list $baggageAllowlist + */ + public static function localActivity( + array $attributes, + mixed $persistedContext, + callable $operation, + array $baggageAllowlist = [], + bool $enabled = false, + ): mixed { + return self::segment( + 'durable.local_activity.attempt', + $attributes, + $persistedContext, + $operation, + $baggageAllowlist, + $enabled, + ); + } + /** * @param array $attributes * @param list $baggageAllowlist @@ -444,8 +465,8 @@ private static function segment( $span = null; $scope = null; try { - $parent = self::parentContext($name, $persistedContext, $baggageAllowlist); $fields = self::fields($attributes); + $parent = self::parentContext($name, $persistedContext, $baggageAllowlist, $fields); $spanAttributes = self::spanAttributes($name, $attributes, $fields); $version = InstalledVersions::isInstalled(self::PACKAGE_NAME) ? InstalledVersions::getPrettyVersion(self::PACKAGE_NAME) @@ -453,7 +474,8 @@ private static function segment( $builder = Globals::tracerProvider()->getTracer(self::PACKAGE_NAME, $version) ->spanBuilder($name) ->setSpanKind(match ($name) { - 'durable.workflow.admission', 'durable.schedule.trigger' => SpanKind::KIND_INTERNAL, + 'durable.workflow.admission', 'durable.schedule.trigger', + 'durable.local_activity.attempt' => SpanKind::KIND_INTERNAL, 'durable.task.dispatch' => SpanKind::KIND_PRODUCER, default => SpanKind::KIND_CONSUMER, }) @@ -510,17 +532,20 @@ private static function segment( /** * @param list $baggageAllowlist + * @param array $fields */ private static function parentContext( string $name, mixed $persistedContext, - array $baggageAllowlist + array $baggageAllowlist, + array $fields, ): ContextInterface { - // Local admission keeps the caller's context. Workers recover only stored authority. + // Local callbacks may share their hosting task. Other worker segments recover the stored parent. $localParent = in_array($name, ['durable.workflow.admission', 'durable.schedule.trigger'], true) || ($name === 'durable.task.dispatch' && self::current() !== []); - $parent = $localParent ? Context::getCurrent() : Context::getRoot(); - $carrier = TraceCarrier::normalize($persistedContext, $baggageAllowlist); + $hostingTask = $name === 'durable.local_activity.attempt' && self::matchesHostingTask($fields); + $parent = $localParent || $hostingTask ? Context::getCurrent() : Context::getRoot(); + $carrier = $hostingTask ? null : TraceCarrier::normalize($persistedContext, $baggageAllowlist); if ($carrier !== null) { $parent = TraceContextPropagator::getInstance()->extract($carrier['carrier'], context: $parent); $parent = BaggagePropagator::getInstance()->extract($carrier['carrier'], context: $parent); @@ -536,6 +561,21 @@ private static function parentContext( ->storeInContext($parent); } + /** + * @param array $fields + */ + private static function matchesHostingTask(array $fields): bool + { + $current = self::current(); + foreach (['workflow_instance_id', 'workflow_run_id', 'namespace', 'task_id'] as $key) { + if (! isset($fields[$key]) || ($current[$key] ?? null) !== $fields[$key]) { + return false; + } + } + + return true; + } + /** * @param array $attributes * @param array $fields diff --git a/src/V2/Support/LocalActivityExecutor.php b/src/V2/Support/LocalActivityExecutor.php index 0824664a3..18b91c85a 100644 --- a/src/V2/Support/LocalActivityExecutor.php +++ b/src/V2/Support/LocalActivityExecutor.php @@ -270,40 +270,34 @@ public function execute( /** @var ActivityAttempt $attempt */ $attempt = $started['attempt']; - $activityClass = TypeRegistry::resolveActivityClass($execution->activity_class, $execution->activity_type); - $activity = RuntimeObjectFactory::activity($activityClass, $execution, $run, $task->id); - $entryMethod = EntryMethod::forActivity($activity); - $activityArguments = $execution->activityArguments(); - $arguments = $activity->resolveMethodDependencies($activityArguments, $entryMethod); - - $result = null; - $throwable = null; - - if (WorkflowStub::faked()) { - WorkflowStub::recordDispatched($execution->activity_class, $activityArguments); - } + $operation = fn (): array => $this->invokeCallback($run, $task, $execution); + if (ExecutionTracing::enabled() && ! WorkflowStub::hasMock($execution->activity_class)) { + [$result, $throwable] = ExecutionTracing::localActivity( + [ + 'workflow_instance_id' => $run->workflow_instance_id, + 'workflow_run_id' => $run->id, + 'workflow_type' => $run->workflow_type, + 'namespace' => $run->namespace, + 'task_id' => $task->id, + 'activity_id' => $execution->id, + 'activity_type' => $execution->activity_type, + 'activity_attempt_id' => $attempt->id, + 'activity_attempt_number' => $attempt->attempt_number, + ], + TraceCarrier::fromRun($run), + static function () use ($operation): array { + [$result, $throwable] = $operation(); + if ($throwable !== null) { + ExecutionTracing::recordFailure($throwable); + } - if (WorkflowStub::hasMock($execution->activity_class)) { - $mocked = WorkflowStub::mockedResult( - $execution->activity_class, - new ActivityFakeContext( - run: $run, - execution: $execution, - taskId: $task->id, - sequence: (int) $execution->sequence, - activity: $execution->activity_class, - ), - $activityArguments, + return [$result, $throwable]; + }, + ExecutionTracing::baggageAllowlist(), + true, ); - - $result = $mocked['result']; - $throwable = $mocked['throwable']; } else { - try { - $result = $activity->{$entryMethod->getName()}(...$arguments); - } catch (Throwable $error) { - $throwable = $error; - } + [$result, $throwable] = $operation(); } return $this->recordAttemptOutcome($task, $execution, $attempt, $result, $throwable); @@ -384,6 +378,50 @@ public function recoverPortableAttempt( ]; } + /** + * @return array{mixed, Throwable|null} + */ + private function invokeCallback(WorkflowRun $run, WorkflowTask $task, ActivityExecution $execution): array + { + $activityClass = TypeRegistry::resolveActivityClass($execution->activity_class, $execution->activity_type); + $activity = RuntimeObjectFactory::activity($activityClass, $execution, $run, $task->id); + $entryMethod = EntryMethod::forActivity($activity); + $activityArguments = $execution->activityArguments(); + $arguments = $activity->resolveMethodDependencies($activityArguments, $entryMethod); + + $result = null; + $throwable = null; + + if (WorkflowStub::faked()) { + WorkflowStub::recordDispatched($execution->activity_class, $activityArguments); + } + + if (WorkflowStub::hasMock($execution->activity_class)) { + $mocked = WorkflowStub::mockedResult( + $execution->activity_class, + new ActivityFakeContext( + run: $run, + execution: $execution, + taskId: $task->id, + sequence: (int) $execution->sequence, + activity: $execution->activity_class, + ), + $activityArguments, + ); + + $result = $mocked['result']; + $throwable = $mocked['throwable']; + } else { + try { + $result = $activity->{$entryMethod->getName()}(...$arguments); + } catch (Throwable $error) { + $throwable = $error; + } + } + + return [$result, $throwable]; + } + /** @param array $report * @return array */ diff --git a/tests/Unit/V2/ActivityExecutionTracingTest.php b/tests/Unit/V2/ActivityExecutionTracingTest.php index 9704ea387..76d00be8c 100644 --- a/tests/Unit/V2/ActivityExecutionTracingTest.php +++ b/tests/Unit/V2/ActivityExecutionTracingTest.php @@ -34,6 +34,7 @@ use Workflow\V2\Enums\ScheduleOverlapPolicy; use Workflow\V2\Events\WorkflowStarted; use Workflow\V2\Models\ActivityAttempt; +use Workflow\V2\Models\ActivityExecution; use Workflow\V2\Models\WorkflowCommand; use Workflow\V2\Models\WorkflowHistoryEvent; use Workflow\V2\Models\WorkflowRun; @@ -69,6 +70,7 @@ protected function setUp(): void $this->configuration = Configurator::create()->withTracerProvider($this->provider) ->withPropagator(TraceContextPropagator::getInstance())->activate(); TracedActivity::$observations = []; + LocalTracedWorkflow::$scopes = []; } protected function tearDown(): void @@ -1017,6 +1019,192 @@ public function testDatabaseQueueScopesDependenciesAndPreservesOriginalTaskRetri } } + public static function localModes(): array + { + return [ + 'disabled' => [false, false, false], + 'enabled' => [true, false, false], + 'legacy' => [true, false, true], + 'retry' => [true, true, false], + ]; + } + + #[DataProvider('localModes')] + public function testLocalAttemptsKeepTheHostingTaskParentAcrossRetryAndHistoryReplay( + bool $enabled, + bool $retry, + bool $legacy, + ): void { + config([ + 'workflows.tracing.enabled' => $enabled && ! $legacy, + ]); + $stub = WorkflowStub::make(LocalTracedWorkflow::class, 'local-tracing-original', '0'); + $stub->start($retry, new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45)); + $run = WorkflowRun::query()->findOrFail($stub->runId()); + $deadlines = [$run->getRawOriginal('execution_deadline_at'), $run->getRawOriginal('run_deadline_at')]; + if ($legacy) { + self::assertNull($run->observability_context); + } + config([ + 'workflows.tracing.enabled' => $enabled, + ]); + for ($remaining = 10; $remaining > 0 && DB::table('jobs')->count() > 0; --$remaining) { + $this->workOnce(); + self::assertSame([], ExecutionTracing::current()); + } + self::assertSame('completed', $run->fresh()->status->value); + self::assertSame([9007199254740993, null, '雪'], $stub->refresh()->output()); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + self::assertSame(0, WorkflowTask::query()->where('task_type', 'activity')->count()); + $run->refresh(); + self::assertSame($deadlines, [ + $run->getRawOriginal('execution_deadline_at'), $run->getRawOriginal('run_deadline_at'), + ]); + $attempts = ActivityAttempt::query()->get()->keyBy('id'); + self::assertCount($retry ? 2 : 1, $attempts); + $execution = ActivityExecution::query()->sole(); + $spans = array_values(array_filter( + $this->exporter->getSpans(), + static fn ($span): bool => $span->getName() === 'durable.local_activity.attempt' + )); + self::assertCount($enabled ? ($retry ? 2 : 1) : 0, $spans); + $parents = []; + foreach ($this->exporter->getSpans() as $span) { + if ($span->getName() === 'durable.workflow.task') { + $parents[$span->getContext()->getSpanId()] = $span; + } + } + foreach ($spans as $span) { + self::assertSame(SpanKind::KIND_INTERNAL, $span->getKind()); + $fields = $span->getAttributes(); + $attempt = $attempts[$fields->get('activity_attempt_id')]; + self::assertSame($execution->id, $fields->get('activity_id')); + self::assertSame($attempt->attempt_number, $fields->get('activity_attempt_number')); + self::assertSame($attempt->workflow_task_id, $fields->get('task_id')); + self::assertNull($fields->get('durable.task.queue_wait_seconds')); + $parent = $parents[$span->getParentContext()->getSpanId()]; + self::assertSame($span->getContext()->getTraceId(), $parent->getContext()->getTraceId()); + self::assertSame($fields->get('task_id'), $parent->getAttributes()->get('task_id')); + } + $callbacks = array_values(array_filter( + TracedActivity::$observations, + static fn (array $observation): bool => $observation[0] === 'callback' + )); + self::assertCount($retry ? 2 : 1, $callbacks); + foreach ($callbacks as [, $fields]) { + if ($enabled) { + self::assertSame($execution->id, $fields['activity_id']); + self::assertArrayHasKey($fields['activity_attempt_id'], $attempts->all()); + } else { + self::assertSame([], $fields); + } + } + foreach (LocalTracedWorkflow::$scopes as [$before, $after]) { + self::assertSame($before, $after); + self::assertArrayNotHasKey('activity_attempt_id', $after); + } + if ($enabled) { + foreach ($attempts as $attempt) { + $observed = array_column(array_filter( + TracedActivity::$observations, + static fn (array $entry): bool => ($entry[1]['activity_attempt_id'] ?? null) === $attempt->id, + ), 0); + self::assertContains('constructor', $observed); + self::assertContains('dependency', $observed); + self::assertContains('callback', $observed); + } + } + if ($enabled && $retry) { + self::assertSame(StatusCode::STATUS_ERROR, $spans[0]->getStatus()->getCode()); + self::assertSame(RuntimeException::class, $spans[0]->getAttributes()->get('error.type')); + self::assertNotSame($spans[0]->getContext()->getSpanId(), $spans[1]->getContext()->getSpanId()); + } + self::assertStringNotContainsString('observability_context', json_encode( + WorkflowHistoryEvent::query()->pluck('payload')->all(), + JSON_THROW_ON_ERROR, + )); + } + + public static function unrelatedLocalParents(): array + { + return [ + 'other instance' => ['workflow_instance_id', true], + 'other run' => ['workflow_run_id', true], + 'other namespace' => ['namespace', true], + 'other task' => ['task_id', true], + 'legacy without stored parent' => ['task_id', false], + ]; + } + + #[DataProvider('unrelatedLocalParents')] + public function testLocalAttemptNeverInheritsAnUnrelatedHostingTask(string $mismatch, bool $stored): void + { + $fields = [ + 'workflow_instance_id' => 'local-instance', + 'workflow_run_id' => 'local-run', + 'namespace' => '0', + 'task_id' => 'local-task', + ]; + $other = array_replace($fields, [ + $mismatch => 'unrelated', + ]); + $carrier = $stored ? [ + 'schema' => 'durable-workflow.trace-context', + 'version' => 1, + 'carrier' => [ + 'traceparent' => '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + ], + ] : null; + ExecutionTracing::workflow($other, null, static function () use ($fields, $carrier): void { + $before = ExecutionTracing::current(); + self::assertSame('original result', ExecutionTracing::localActivity( + $fields, + $carrier, + static function () use ($fields): string { + self::assertSame($fields['task_id'], ExecutionTracing::current()['task_id']); + + return 'original result'; + }, + enabled: true, + )); + self::assertSame($before, ExecutionTracing::current()); + }, enabled: true); + $spans = $this->exporter->getSpans(); + self::assertCount(2, $spans); + $local = $spans[0]; + $ambient = $spans[1]; + self::assertSame('durable.local_activity.attempt', $local->getName()); + self::assertNotSame($ambient->getContext()->getSpanId(), $local->getParentContext()->getSpanId()); + if ($stored) { + self::assertSame('00f067aa0ba902b7', $local->getParentContext()->getSpanId()); + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $local->getContext()->getTraceId()); + } else { + self::assertFalse($local->getParentContext()->isValid()); + } + } + + public function testMockedLocalActivityDoesNotClaimACallbackSpan(): void + { + config([ + 'workflows.tracing.enabled' => true, + ]); + $expected = [9007199254740993, null, '雪']; + WorkflowStub::mock(TracedActivity::class, $expected); + $stub = WorkflowStub::make(LocalTracedWorkflow::class, 'mocked-local-tracing', '0'); + $stub->start(false, false); + self::assertSame($expected, $stub->refresh()->output()); + self::assertSame([], array_values(array_filter( + TracedActivity::$observations, + static fn (array $entry): bool => $entry[0] === 'callback', + ))); + self::assertSame([], array_values(array_filter( + $this->exporter->getSpans(), + static fn ($span): bool => $span->getName() === 'durable.local_activity.attempt', + ))); + } + protected function getPackageProviders($app): array { return [TestDatabaseServiceProvider::class, WorkflowServiceProvider::class]; @@ -1143,6 +1331,26 @@ public function handle(): array } } +final class LocalTracedWorkflow extends Workflow +{ + public static array $scopes = []; + + public function handle(bool $retry, bool $wait = true): array + { + $before = ExecutionTracing::current(); + try { + $result = self::localActivity(TracedActivity::class, $retry); + } finally { + self::$scopes[] = [$before, ExecutionTracing::current()]; + } + if ($wait) { + self::timer(1); + } + + return $result; + } +} + final class FailingTracedWorkflow extends Workflow { public static array $fields = []; From 8f45925cd507d088a8b86f333d21d35f4231fae0 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 04:08:21 +0000 Subject: [PATCH 35/77] Qualify cold parallel tracing scopes and retry identity --- .github/workflows/php.yml | 1 + docs/embedded-tracing.md | 14 ++ tests/Fixtures/V2/TracingFanOutWorkflow.php | 33 +++ tests/Fixtures/V2/tracing-process.php | 30 +++ tests/Unit/V2/FanOutExecutionTracingTest.php | 224 +++++++++++++++++++ 5 files changed, 302 insertions(+) create mode 100644 tests/Fixtures/V2/TracingFanOutWorkflow.php create mode 100644 tests/Unit/V2/FanOutExecutionTracingTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index fe4c2b1fa..744333657 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -381,6 +381,7 @@ jobs: tests/Unit/V2/IncomingHttpTracingTest.php tests/Unit/V2/RecoveryExecutionTracingTest.php tests/Unit/V2/TransitionExecutionTracingTest.php + tests/Unit/V2/FanOutExecutionTracingTest.php tests/Unit/V2/ChildRetryExecutionTracingTest.php tests/Unit/V2/CancellationExecutionTracingTest.php tests/Unit/V2/SignalStartExecutionTracingTest.php diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index ffe322920..9c6a45a61 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -139,6 +139,20 @@ mocked results do not emit callback spans. Local attempts have no activity-queue wait measurement. The span ends before outcome recording, and the existing execution transaction and retry dispatch rules remain unchanged. +Cold parallel-workflow controls use a local callback followed by a group of +two ordinary activities and a timer, including a genuine activity retry. +Independent queue processes retain the original admission parent and restore +their previous context. Each callback attempt has its own identity and span; +replaying the completed local callback adds no attempt span. The same recipe +covers tracing disabled, a legacy admission, and workers disabled after a +traced admission. It preserves the complete retry snapshots, original +deadlines and history prefixes, with zero repairs. + +The local callback precedes the parallel group. The existing `all()` API does +not admit local-activity descriptors as group members. Completion-specific +fan-in links, simultaneous-worker load and large-fan-out overhead remain +separate qualification gates. + Queue dispatch emits a short `durable.task.dispatch` producer span outside the database transaction. It covers the queue dispatcher call. A returned call is not proof that a worker claimed or completed the task. Poll mode and fake diff --git a/tests/Fixtures/V2/TracingFanOutWorkflow.php b/tests/Fixtures/V2/TracingFanOutWorkflow.php new file mode 100644 index 000000000..50264fd1a --- /dev/null +++ b/tests/Fixtures/V2/TracingFanOutWorkflow.php @@ -0,0 +1,33 @@ + self::activity(TracingProcessActivity::class, false), + static fn (): mixed => self::activity(TracingProcessActivity::class, new ActivityOptions( + maxAttempts: 2, + backoff: [1], + startToCloseTimeout: 10, + scheduleToCloseTimeout: 30, + ), true), + static fn (): mixed => self::timer(1), + ]); + + return [ + 'ordinary' => $parallel[0], + 'retry' => $parallel[1], + 'local' => $local, + 'timer' => $parallel[2], + ]; + } +} diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index c726d38e5..1a61ef8ca 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -82,6 +82,7 @@ $admissionProbe = getenv('DW_TEST_TRACING_ADMISSION') === 'true' || getenv('DW_TEST_TRACING_SIGNAL_START_ADMISSION') === 'true'; $childRetry = getenv('DW_TEST_TRACING_CHILD_RETRY') === 'true'; +$fanOut = getenv('DW_TEST_TRACING_FAN_OUT') === 'true'; $cancellation = getenv('DW_TEST_TRACING_CANCELLATION'); foreach (array_slice($argv, 4) as $autoload) { require_once $autoload; @@ -750,6 +751,10 @@ public function extract( [[9007199254740993, null, '雪']], StartOptions::returnExistingActive()->withExecutionTimeout(60)->withRunTimeout(45) ); + } elseif ($fanOut) { + WorkflowStub::make(TracingFanOutWorkflow::class, 'cold-tracing', '0')->start( + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); } elseif ($transition === 'child' || $transition === 'continue') { $workflow = $transition === 'child' ? TracingChildParentWorkflow::class @@ -1280,6 +1285,31 @@ public function extract( 'tasks' => WorkflowTask::query()->get(['id', 'status', 'attempt_count'])->toArray(), ]; } + if ($fanOut) { + $history = $run->historyEvents() + ->orderBy('sequence') + ->get(); + $output['fan_out'] = [ + 'ready_jobs' => DB::table('jobs')->whereNull('reserved_at')->where('available_at', '<=', time())->count(), + 'history_hashes' => $history->map(static fn ($event): string => hash( + 'sha256', + json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR), + ))->all(), + 'history_types' => $history->map(static fn ($event): string => $event->event_type->value) + ->all(), + 'history_payloads' => $history->pluck('payload') + ->all(), + 'activities' => ActivityExecution::query()->orderBy('id')->get([ + 'id', 'status', 'attempt_count', 'current_attempt_id', 'retry_policy', + ])->toArray(), + 'attempts' => ActivityAttempt::query()->orderBy('id')->get([ + 'id', 'activity_execution_id', 'workflow_task_id', 'attempt_number', 'status', + ])->toArray(), + 'tasks' => WorkflowTask::query()->orderBy('id')->get([ + 'id', 'status', 'task_type', 'attempt_count', 'repair_count', + ])->toArray(), + ]; + } if ($recovery) { $history = WorkflowHistoryEvent::query()->where('workflow_run_id', $run->id)->orderBy('sequence')->get(); $output['recovery'] = [ diff --git a/tests/Unit/V2/FanOutExecutionTracingTest.php b/tests/Unit/V2/FanOutExecutionTracingTest.php new file mode 100644 index 000000000..14bce71a4 --- /dev/null +++ b/tests/Unit/V2/FanOutExecutionTracingTest.php @@ -0,0 +1,224 @@ + [true, true], + 'disabled' => [false, false], + 'legacy admission' => [false, true], + 'disabled workers' => [true, false], + ]; + } + + #[DataProvider('modes')] + public function testColdParallelMembersPreserveTheirOwnAttemptScopesAndRecordedResults( + bool $admissionEnabled, + bool $workerEnabled, + ): void { + $directory = sys_get_temp_dir() . '/dw-tracing-fan-out-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $worker = static function (string $mode) use ($directory, $admissionEnabled, $workerEnabled): array { + $autoloads = array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + array_keys(ClassLoader::getRegisteredLoaders()), + ); + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...$autoloads, + ], env: [ + 'DW_TEST_TRACING_FAN_OUT' => 'true', + 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admissionEnabled : $workerEnabled) ? 'true' : 'false', + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + try { + $admitted = $worker('admit'); + self::assertSame($admissionEnabled, $admitted['carrier'] !== null); + $states = [$admitted]; + $workers = []; + $deadline = microtime(true) + 30; + do { + $state = $worker('inspect'); + if (in_array($state['status'], ['completed', 'failed', 'cancelled', 'terminated'], true)) { + break; + } + if ($state['fan_out']['ready_jobs'] === 0) { + usleep(100_000); + continue; + } + $states[] = $workers[] = $worker('work'); + } while (microtime(true) < $deadline); + $finished = $worker('inspect'); + self::assertDurableResult($admitted, $states, $workers, $finished); + self::assertAttemptScopes( + $admitted, + $workers, + $finished['fan_out']['attempts'], + $admissionEnabled, + $workerEnabled + ); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } + + private static function assertDurableResult(array $admitted, array $states, array $workers, array $finished): void + { + self::assertSame('completed', $finished['status'], json_encode([ + 'run_id' => $finished['run_id'], + 'jobs' => $finished['jobs'], + 'history_types' => $finished['fan_out']['history_types'], + 'last_event' => end($finished['fan_out']['history_payloads']), + ], JSON_THROW_ON_ERROR)); + self::assertSame(array_fill_keys(['ordinary', 'retry', 'local'], [9007199254740993, null, '雪']) + [ + 'timer' => true, + ], $finished['output']); + self::assertSame(0, $finished['jobs']); + self::assertSame(0, $finished['failed_jobs']); + self::assertSame(0, $finished['repairs']); + $fanOut = $finished['fan_out']; + self::assertCount(3, $fanOut['activities']); + self::assertCount(4, $fanOut['attempts']); + $attemptCounts = array_column($fanOut['activities'], 'attempt_count'); + sort($attemptCounts); + self::assertSame([1, 1, 2], $attemptCounts); + self::assertSame(3, count(array_filter( + $fanOut['history_types'], + static fn (string $type): bool => $type === 'ActivityCompleted', + ))); + self::assertSame(1, count(array_filter( + $fanOut['history_types'], + static fn (string $type): bool => $type === 'TimerFired', + ))); + self::assertSame(1, count(array_filter( + $fanOut['history_types'], + static fn (string $type): bool => $type === 'WorkflowCompleted', + ))); + $activitiesById = array_column($fanOut['activities'], null, 'id'); + foreach ($states as $state) { + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { + self::assertSame($admitted[$field], $state[$field]); + self::assertSame($admitted[$field], $finished[$field]); + } + self::assertSame($state['fan_out']['history_hashes'], array_slice( + $fanOut['history_hashes'], + 0, + count($state['fan_out']['history_hashes']), + )); + foreach ($state['fan_out']['activities'] as $activity) { + self::assertArrayHasKey($activity['id'], $activitiesById); + self::assertSame($activity['retry_policy'], $activitiesById[$activity['id']]['retry_policy']); + } + } + $retryActivity = array_values(array_filter( + $fanOut['activities'], + static fn (array $activity): bool => $activity['attempt_count'] === 2, + ))[0]; + $retryAttempts = array_values(array_filter( + $fanOut['attempts'], + static fn (array $attempt): bool => $attempt['activity_execution_id'] === $retryActivity['id'], + )); + self::assertSame([1, 2], array_column($retryAttempts, 'attempt_number')); + $tasksById = array_column($fanOut['tasks'], null, 'id'); + foreach ($retryAttempts as $attempt) { + self::assertArrayHasKey($attempt['workflow_task_id'], $tasksById); + } + foreach ($fanOut['tasks'] as $task) { + self::assertGreaterThanOrEqual(1, $task['attempt_count']); + self::assertSame(0, $task['repair_count']); + } + self::assertSame(count($workers), count(array_unique(array_column($workers, 'pid')))); + self::assertGreaterThan(2, count($workers)); + self::assertStringNotContainsString( + 'observability_context', + json_encode($fanOut['history_payloads'], JSON_THROW_ON_ERROR), + ); + } + + private static function assertAttemptScopes( + array $admitted, + array $workers, + array $recordedAttempts, + bool $admissionEnabled, + bool $workerEnabled, + ): void { + $spans = array_merge(...array_column($workers, 'spans')); + if (! $workerEnabled) { + self::assertSame([], $spans); + + return; + } + $attempts = array_values(array_filter($spans, static fn (array $span): bool => in_array( + $span['name'], + ['durable.activity.attempt', 'durable.local_activity.attempt'], + true, + ))); + self::assertCount(4, $attempts); + self::assertCount(1, array_filter( + $attempts, + static fn (array $span): bool => $span['status_code'] === StatusCode::STATUS_ERROR, + )); + self::assertCount(4, array_unique(array_column($attempts, 'span_id'))); + self::assertCount(4, array_unique(array_map( + static fn (array $span): string => $span['fields']['activity_attempt_id'], + $attempts, + ))); + $durableAttempts = array_column($recordedAttempts, null, 'id'); + foreach ($workers as $state) { + $workflowSpans = array_values(array_filter( + $state['spans'], + static fn (array $span): bool => $span['name'] === 'durable.workflow.task', + )); + $stateAttempts = array_filter($state['spans'], static fn (array $span): bool => in_array( + $span['name'], + ['durable.activity.attempt', 'durable.local_activity.attempt'], + true, + )); + foreach ($stateAttempts as $span) { + $fields = $span['fields']; + self::assertSame($admitted['run_id'], $fields['workflow_run_id']); + self::assertSame('cold-tracing', $fields['workflow_instance_id']); + self::assertSame('0', $fields['namespace']); + $recorded = $durableAttempts[$fields['activity_attempt_id']]; + self::assertSame($recorded['activity_execution_id'], $fields['activity_id']); + self::assertSame($recorded['attempt_number'], $fields['activity_attempt_number']); + if ($span['name'] === 'durable.local_activity.attempt') { + self::assertCount(1, $workflowSpans); + self::assertSame($workflowSpans[0]['span_id'], $span['parent_id']); + self::assertSame($workflowSpans[0]['fields']['task_id'], $fields['task_id']); + } elseif ($admissionEnabled) { + self::assertSame($recorded['workflow_task_id'], $fields['task_id']); + self::assertSame('00f067aa0ba902b7', $span['parent_id']); + } else { + self::assertSame($recorded['workflow_task_id'], $fields['task_id']); + self::assertSame('0000000000000000', $span['parent_id']); + } + if ($admissionEnabled) { + self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); + } else { + self::assertNotSame('11111111111111111111111111111111', $span['trace_id']); + } + } + } + } +} From e0c5fb6a138b059682aea2aa8efac8d87a5cfe8d Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 04:48:44 +0000 Subject: [PATCH 36/77] Link consumed embedded outcomes to their actual task spans --- .github/workflows/php.yml | 1 + docs/embedded-tracing.md | 38 ++- src/Providers/WorkflowServiceProvider.php | 5 + src/V2/Jobs/RunWorkflowTask.php | 2 + src/V2/Models/WorkflowHistoryEvent.php | 2 + src/V2/Models/WorkflowTask.php | 3 + src/V2/Observers/WorkflowTaskObserver.php | 6 + src/V2/Support/ExecutionTracing.php | 118 ++++++++- src/V2/Support/TaskOutcomeCarrier.php | 228 ++++++++++++++++++ src/V2/Support/TaskOutcomeTraceState.php | 117 +++++++++ src/V2/Support/TraceLinkState.php | 12 + src/V2/Support/WorkflowExecutor.php | 5 + ...bservability_context_to_workflow_tasks.php | 28 +++ tests/Fixtures/V2/tracing-process.php | 34 ++- .../Unit/V2/ActivityExecutionTracingTest.php | 95 ++++++++ .../Unit/V2/DispatchExecutionTracingTest.php | 18 +- tests/Unit/V2/FanOutExecutionTracingTest.php | 87 +++++++ tests/Unit/V2/TaskOutcomeCarrierTest.php | 185 ++++++++++++++ 18 files changed, 975 insertions(+), 9 deletions(-) create mode 100644 src/V2/Support/TaskOutcomeCarrier.php create mode 100644 src/V2/Support/TaskOutcomeTraceState.php create mode 100644 src/migrations/2026_10_16_000100_add_observability_context_to_workflow_tasks.php create mode 100644 tests/Unit/V2/TaskOutcomeCarrierTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 744333657..8b96e4724 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -382,6 +382,7 @@ jobs: tests/Unit/V2/RecoveryExecutionTracingTest.php tests/Unit/V2/TransitionExecutionTracingTest.php tests/Unit/V2/FanOutExecutionTracingTest.php + tests/Unit/V2/TaskOutcomeCarrierTest.php tests/Unit/V2/ChildRetryExecutionTracingTest.php tests/Unit/V2/CancellationExecutionTracingTest.php tests/Unit/V2/SignalStartExecutionTracingTest.php diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index 9c6a45a61..22b51ab9f 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -149,9 +149,41 @@ traced admission. It preserves the complete retry snapshots, original deadlines and history prefixes, with zero repairs. The local callback precedes the parallel group. The existing `all()` API does -not admit local-activity descriptors as group members. Completion-specific -fan-in links, simultaneous-worker load and large-fan-out overhead remain -separate qualification gates. +not admit local-activity descriptors as group members. Simultaneous-worker +load and large-fan-out overhead remain separate qualification gates. + +### Outcome links (draft) + +The draft adds a nullable, internal `workflow_tasks.observability_context` +column. A recording task can retain its actual outcome span context alongside +its existing task update. Entries bind the physical history event ID, sequence +and type to the original task, run, namespace and activity attempt or timer. +They carry no baggage or business result, are hidden from task JSON, and grant +no completion, retry or lease authority. The business history is unchanged. + +Local callbacks retain their actual span context until the hosting task records +the outcome. Ordinary activity and timer scopes capture their context before +outcome SQL. This uses the official W3C propagator directly, without invoking +application propagation hooks inside an execution transaction. Rollback removes +pending entries; duplicate completion cannot replace the first stored context. +Missing legacy columns and invalid optional metadata supply no outcome links. + +Before an enabled workflow task executes, it can read the latest 32 eligible +physical outcomes and their scoped task metadata outside the execution +transaction. Consuming a matching activity result or fired timer adds a +`durable.link.type=task_outcome` link to that actual span. The workflow span's +original admission parent remains unchanged. Each task carrier is capped at +4 KiB and 32 complete entries; the existing processing-segment link cap also +applies. Older outcomes outside this window can have no link. Disabled workers +perform no additional outcome-metadata reads or writes. + +Cold SQLite controls check the actual contributing local activity, ordinary +activity and timer span IDs, including a genuine retry, with disabled and +legacy modes. Transaction rollback, duplicate decisions and legacy migration +controls are separate. This is not complete failure/timeout/child causality, +an unlimited fan-in graph, multi-backend acceptance or overhead qualification. +The new column remains unreleased and is not adopted by the service wire or +native catalogs. Queue dispatch emits a short `durable.task.dispatch` producer span outside the database transaction. It covers the queue dispatcher call. A returned call is diff --git a/src/Providers/WorkflowServiceProvider.php b/src/Providers/WorkflowServiceProvider.php index 4611cae45..c888cbdfd 100644 --- a/src/Providers/WorkflowServiceProvider.php +++ b/src/Providers/WorkflowServiceProvider.php @@ -4,6 +4,7 @@ namespace Workflow\Providers; +use Illuminate\Database\Events\TransactionRolledBack; use Illuminate\Queue\Events\Looping; use Illuminate\Queue\QueueManager; use Illuminate\Support\Facades\Event; @@ -60,6 +61,7 @@ use Workflow\V2\Support\DefaultServiceControlPlane; use Workflow\V2\Support\DefaultWorkflowControlPlane; use Workflow\V2\Support\DefaultWorkflowTaskBridge; +use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\HistoryProjectionMaintenanceFallback; use Workflow\V2\Support\InMemoryTaskFairnessState; use Workflow\V2\Support\LongPollCacheValidator; @@ -198,6 +200,9 @@ public function boot(): void // Register long-poll wake signal observers $this->registerLongPollObservers(); + Event::listen(TransactionRolledBack::class, static function (TransactionRolledBack $event): void { + ExecutionTracing::discardTaskOutcomes($event->connectionName); + }); // Validate cache backend for multi-node deployments $this->validateCacheBackend(); diff --git a/src/V2/Jobs/RunWorkflowTask.php b/src/V2/Jobs/RunWorkflowTask.php index 4bcc41d88..5756c4091 100644 --- a/src/V2/Jobs/RunWorkflowTask.php +++ b/src/V2/Jobs/RunWorkflowTask.php @@ -23,6 +23,7 @@ use Workflow\V2\Support\TaskBackendCapabilities; use Workflow\V2\Support\TaskCompatibility; use Workflow\V2\Support\TaskDispatcher; +use Workflow\V2\Support\TaskOutcomeCarrier; use Workflow\V2\Support\TaskTraceTimings; use Workflow\V2\Support\TraceCarrier; use Workflow\V2\Support\WorkerCompatibilityFleet; @@ -84,6 +85,7 @@ public function handle(WorkflowExecutor $executor): void $allowlist, true, $this->dispatchObservabilityContext, + static fn (): array => TaskOutcomeCarrier::forRun($run, $task), ); } diff --git a/src/V2/Models/WorkflowHistoryEvent.php b/src/V2/Models/WorkflowHistoryEvent.php index 03368583f..68a0bebc8 100644 --- a/src/V2/Models/WorkflowHistoryEvent.php +++ b/src/V2/Models/WorkflowHistoryEvent.php @@ -11,6 +11,7 @@ use Workflow\Traits\ResolvesStorageConnection; use Workflow\V2\Enums\HistoryEventType; use Workflow\V2\Support\ConfiguredV2Models; +use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\ExternalPayloads; use Workflow\V2\Support\HistoryEventPayloadContract; use Workflow\V2\Support\HistoryRecordedAt; @@ -114,6 +115,7 @@ public static function record( 'workflow_command_id' => $commandId, 'recorded_at' => $now, ]); + ExecutionTracing::observeOutcome($lockedRun, $taskModel, $event); $lockedRun->forceFill([ 'last_history_sequence' => $sequence, diff --git a/src/V2/Models/WorkflowTask.php b/src/V2/Models/WorkflowTask.php index a4fca40d6..cff76a13e 100644 --- a/src/V2/Models/WorkflowTask.php +++ b/src/V2/Models/WorkflowTask.php @@ -24,11 +24,14 @@ class WorkflowTask extends Model protected $guarded = []; + protected $hidden = ['observability_context']; + protected $keyType = 'string'; protected $dateFormat = 'Y-m-d H:i:s.u'; protected $casts = [ + 'observability_context' => 'array', 'task_type' => TaskType::class, 'status' => TaskStatus::class, 'payload' => 'array', diff --git a/src/V2/Observers/WorkflowTaskObserver.php b/src/V2/Observers/WorkflowTaskObserver.php index 81abed250..85d90a14f 100644 --- a/src/V2/Observers/WorkflowTaskObserver.php +++ b/src/V2/Observers/WorkflowTaskObserver.php @@ -11,6 +11,7 @@ use Workflow\V2\Models\WorkflowTask; use Workflow\V2\Support\CacheLongPollWakeStore; use Workflow\V2\Support\ConfiguredV2Models; +use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\StickyExecution; /** @@ -27,6 +28,11 @@ public function __construct( ) { } + public function updating(WorkflowTask $task): void + { + ExecutionTracing::persistTaskOutcomes($task); + } + public function creating(WorkflowTask $task): void { if ($task->task_type !== TaskType::Workflow && $task->task_type !== TaskType::Workflow->value) { diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php index 541120aff..83bef3c57 100644 --- a/src/V2/Support/ExecutionTracing.php +++ b/src/V2/Support/ExecutionTracing.php @@ -27,6 +27,7 @@ use Workflow\V2\Jobs\RunTimerTask; use Workflow\V2\Jobs\RunWorkflowTask; use Workflow\V2\Models\WorkflowCommand; +use Workflow\V2\Models\WorkflowHistoryEvent; use Workflow\V2\Models\WorkflowInstance; use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Models\WorkflowSchedule; @@ -59,6 +60,8 @@ final class ExecutionTracing private static ?ContextKeyInterface $linkKey = null; + private static ?ContextKeyInterface $outcomeKey = null; + /** * @var WeakMap|null */ @@ -174,6 +177,7 @@ public static function workflow( array $baggageAllowlist = [], bool $enabled = false, mixed $dispatchContext = null, + ?callable $loadOutcomes = null, ): mixed { return self::segment( 'durable.workflow.task', @@ -183,9 +187,81 @@ public static function workflow( $baggageAllowlist, $enabled, $dispatchContext, + onActivated: static function () use ($loadOutcomes): void { + try { + $state = self::$linkKey === null ? null : Context::getCurrent()->get(self::$linkKey); + if ($loadOutcomes !== null && $state instanceof TraceLinkState && $state->isRecording()) { + $state->setOutcomes($loadOutcomes()); + } + } catch (Throwable) { + // Optional observation reads do not replace the original task. + } + }, ); } + public static function observeOutcome(WorkflowRun $run, ?WorkflowTask $task, WorkflowHistoryEvent $event): void + { + try { + if ($task !== null) { + self::outcomeState()?->observe($run, $task, $event); + } + } catch (Throwable) { + // The recorded business event remains authoritative without telemetry. + } + } + + public static function persistTaskOutcomes(WorkflowTask $task): void + { + try { + self::outcomeState()?->apply($task); + } catch (Throwable) { + // Optional task metadata cannot replace the existing fenced write. + } + } + + public static function discardTaskOutcomes(mixed $connection): void + { + try { + if (is_string($connection)) { + self::outcomeState()?->discard($connection); + } + } catch (Throwable) { + // Rollback still belongs to the database transaction manager. + } + } + + public static function linkOutcome(?WorkflowRun $run, ?WorkflowHistoryEvent $event): void + { + try { + if ($run === null || $event === null || self::$linkKey === null) { + return; + } + $fields = self::current(); + if (($fields['workflow_run_id'] ?? null) !== $run->id || ($fields['namespace'] ?? null) !== $run->namespace + || $event->workflow_run_id !== $run->id) { + return; + } + $state = Context::getCurrent()->get(self::$linkKey); + if (! $state instanceof TraceLinkState || ! $state->isRecording()) { + return; + } + $entry = $state->outcome($event->id); + if ($entry === null || $entry['task_id'] !== $event->workflow_task_id + || ! TaskOutcomeCarrier::matchesEvent($entry, $event)) { + return; + } + self::linkCarrier($state, $entry['context'], [ + 'durable.link.type' => 'task_outcome', + 'history_event_id' => $event->id, + 'event_type' => $event->event_type->value, + 'task_id' => $event->workflow_task_id, + ]); + } catch (Throwable) { + // A missing or malformed causal observation does not change replay. + } + } + /** * @param array $attributes * @param list $baggageAllowlist @@ -382,6 +458,16 @@ public static function linkCancellation(WorkflowRun $run): void } } + private static function outcomeState(): ?TaskOutcomeTraceState + { + if (self::$outcomeKey === null || self::current() === []) { + return null; + } + $state = Context::getCurrent()->get(self::$outcomeKey); + + return $state instanceof TaskOutcomeTraceState ? $state : null; + } + private static function linkCommand(WorkflowRun $run, WorkflowCommand $command, CommandType $type): void { try { @@ -502,12 +588,14 @@ private static function segment( self::$correlationKey ??= Context::createKey('durable-workflow.correlation'); $active = $span->storeInContext($parent) ->with(self::$correlationKey, $fields); + $active = self::withOutcomeState($active, $name, $fields, $span); if (in_array($name, ['durable.workflow.task', 'durable.schedule.trigger'], true)) { self::$linkKey ??= Context::createKey('durable-workflow.trace-links'); $active = $active->with(self::$linkKey, new TraceLinkState($span)); } $scope = $active->activate(); self::enterFiberScope(); + self::rememberLocalOutcome($name, $fields, $span); if ($onActivated !== null) { $onActivated(); } @@ -531,9 +619,37 @@ private static function segment( } /** - * @param list $baggageAllowlist * @param array $fields */ + private static function withOutcomeState( + ContextInterface $active, + string $name, + array $fields, + SpanInterface $span + ): ContextInterface { + if (! in_array($name, ['durable.workflow.task', 'durable.activity.attempt', 'durable.timer.task'], true)) { + return $active; + } + $context = TaskOutcomeCarrier::capture($span); + if ($context === null) { + return $active; + } + self::$outcomeKey ??= Context::createKey('durable-workflow.task-outcomes'); + + return $active->with(self::$outcomeKey, new TaskOutcomeTraceState($name, $fields, $context)); + } + + private static function rememberLocalOutcome(string $name, array $fields, SpanInterface $span): void + { + if ($name !== 'durable.local_activity.attempt') { + return; + } + $context = TaskOutcomeCarrier::capture($span); + if ($context !== null) { + self::outcomeState()?->rememberLocal($fields, $context); + } + } + private static function parentContext( string $name, mixed $persistedContext, diff --git a/src/V2/Support/TaskOutcomeCarrier.php b/src/V2/Support/TaskOutcomeCarrier.php new file mode 100644 index 000000000..dc89dffc2 --- /dev/null +++ b/src/V2/Support/TaskOutcomeCarrier.php @@ -0,0 +1,228 @@ +isRecording()) { + return null; + } + $headers = []; + // The official propagator reads the SDK span directly; application hooks are not invoked. + TraceContextPropagator::getInstance()->inject($headers, context: $span->storeInContext(Context::getRoot())); + + return TraceCarrier::normalize([ + 'schema' => 'durable-workflow.trace-context', + 'version' => 1, + 'carrier' => $headers, + ]); + } + + public static function identity(WorkflowHistoryEvent $event): array + { + return match ($event->event_type->value) { + 'ActivityCompleted', 'ActivityFailed', 'ActivityTimedOut' => [ + 'activity_id' => $event->payload['activity_execution_id'] ?? null, + 'activity_attempt_id' => $event->payload['activity_attempt_id'] ?? null, + ], + 'TimerFired' => [ + 'timer_id' => $event->payload['timer_id'] ?? null, + ], + default => [], + }; + } + + public static function fromTask(WorkflowTask $task): ?array + { + $attributes = $task->getAttributes(); + $value = $attributes['observability_context'] ?? null; + if (is_string($value)) { + if (strlen($value) > 4096) { + return null; + } + try { + $value = json_decode($value, true, flags: JSON_THROW_ON_ERROR); + } catch (Throwable) { + return null; + } + } + if (! is_array($value) || ($value['schema'] ?? null) !== self::SCHEMA || ($value['version'] ?? null) !== 1 + || ($value['task_id'] ?? null) !== ($attributes['id'] ?? null) + || ($value['run_id'] ?? null) !== ($attributes['workflow_run_id'] ?? null) + || ($value['namespace'] ?? null) !== ($attributes['namespace'] ?? null) + || ! self::identifier($value['task_id'] ?? null) || ! self::identifier($value['run_id'] ?? null) + || ! self::identifier($value['namespace'] ?? null) + || array_diff( + array_keys($value), + ['schema', 'version', 'task_id', 'run_id', 'namespace', 'outcomes'] + ) !== [] + || ! is_array($value['outcomes'] ?? null) || ! array_is_list($value['outcomes']) + || count($value['outcomes']) > 32) { + return null; + } + foreach ($value['outcomes'] as $index => $entry) { + $entry = self::entry($entry); + if ($entry === null) { + return null; + } + $value['outcomes'][$index] = $entry; + } + + return strlen(json_encode($value, JSON_THROW_ON_ERROR)) <= 4096 ? $value : null; + } + + public static function append(WorkflowTask $task, array $entries): void + { + $attributes = $task->getAttributes(); + if ($entries === [] || ! array_key_exists('observability_context', $attributes)) { + return; + } + $stored = $attributes['observability_context'] === null ? [ + 'schema' => self::SCHEMA, + 'version' => 1, + 'task_id' => $attributes['id'], + 'run_id' => $attributes['workflow_run_id'], + 'namespace' => $attributes['namespace'], + 'outcomes' => [], + ] : self::fromTask($task); + if ($stored === null) { + return; + } + $seen = array_fill_keys(array_column($stored['outcomes'], 'event_id'), true); + $changed = false; + foreach ($entries as $entry) { + $entry = self::entry($entry); + if ($entry === null || isset($seen[$entry['event_id']]) || count($stored['outcomes']) >= 32) { + continue; + } + $next = $stored; + $next['outcomes'][] = $entry; + if (strlen(json_encode($next, JSON_THROW_ON_ERROR)) <= 4096) { + $stored = $next; + $seen[$entry['event_id']] = true; + $changed = true; + } + } + if ($changed) { + // Preserve normal task casts and business attributes without invoking a telemetry setter. + $attributes['observability_context'] = json_encode($stored, JSON_THROW_ON_ERROR); + $task->setRawAttributes($attributes); + } + } + + public static function entry(mixed $value): ?array + { + if (! is_array($value) || ! self::identifier($value['event_id'] ?? null) + || ! is_int($value['sequence'] ?? null) || $value['sequence'] < 1 + || ! in_array($value['event_type'] ?? null, self::EVENTS, true) + || array_diff(array_keys($value), ['event_id', 'sequence', 'event_type', 'activity_id', + 'activity_attempt_id', 'timer_id', 'context']) !== [] + || ! is_array($value['context'] ?? null) + || array_diff(array_keys($value['context']), ['schema', 'version', 'carrier']) !== [] + || ! is_array($value['context']['carrier'] ?? null) + || array_diff(array_keys($value['context']['carrier']), ['traceparent', 'tracestate']) !== []) { + return null; + } + $identityKeys = match ($value['event_type']) { + 'ActivityCompleted', 'ActivityFailed', 'ActivityTimedOut' => ['activity_id', 'activity_attempt_id'], + 'TimerFired' => ['timer_id'], + default => [], + }; + foreach (['activity_id', 'activity_attempt_id', 'timer_id'] as $key) { + if (in_array($key, $identityKeys, true) ? ! self::identifier($value[$key] ?? null) : array_key_exists( + $key, + $value + )) { + return null; + } + } + $context = TraceCarrier::normalize($value['context']); + + return $context === null ? null : array_replace($value, [ + 'context' => $context, + ]); + } + + public static function matchesEvent(array $entry, WorkflowHistoryEvent $event): bool + { + if (($entry['event_id'] ?? null) !== $event->id || ($entry['sequence'] ?? null) !== (int) $event->sequence + || ($entry['event_type'] ?? null) !== $event->event_type->value) { + return false; + } + foreach (self::identity($event) as $key => $value) { + if (($entry[$key] ?? null) !== $value) { + return false; + } + } + + return true; + } + + public static function forRun(WorkflowRun $run, WorkflowTask $claim): array + { + try { + if (! array_key_exists('observability_context', $claim->getRawOriginal())) { + return []; + } + $events = ConfiguredV2Models::query('history_event_model', WorkflowHistoryEvent::class) + ->where('workflow_run_id', $run->id) + ->whereIn('event_type', self::EVENTS) + ->whereNotNull('workflow_task_id') + ->orderByDesc('sequence') + ->limit(32) + ->get(['id', 'workflow_run_id', 'workflow_task_id', 'sequence', 'event_type']); + if ($events->isEmpty()) { + return []; + } + $tasks = ConfiguredV2Models::query('task_model', WorkflowTask::class) + ->whereKey($events->pluck('workflow_task_id')->unique()) + ->where('workflow_run_id', $run->id) + ->where('namespace', $run->namespace) + ->get(['id', 'workflow_run_id', 'namespace', 'observability_context'])->keyBy('id'); + $result = []; + foreach ($events as $event) { + $task = $tasks->get($event->workflow_task_id); + if (! $task instanceof WorkflowTask) { + continue; + } + $envelope = self::fromTask($task); + foreach ($envelope['outcomes'] ?? [] as $entry) { + if ($entry['event_id'] === $event->id && $entry['sequence'] === (int) $event->sequence + && $entry['event_type'] === $event->event_type->value) { + $result[$event->id] = $entry + [ + 'task_id' => $task->id, + ]; + } + } + } + + return $result; + } catch (Throwable) { + // A missing legacy column or unavailable observation cannot replace workflow execution. + return []; + } + } + + private static function identifier(mixed $value): bool + { + return is_string($value) && $value !== '' && strlen($value) <= 512 && ! preg_match('/[\x00-\x1f\x7f]/', $value); + } +} diff --git a/src/V2/Support/TaskOutcomeTraceState.php b/src/V2/Support/TaskOutcomeTraceState.php new file mode 100644 index 000000000..14402c259 --- /dev/null +++ b/src/V2/Support/TaskOutcomeTraceState.php @@ -0,0 +1,117 @@ +fields[$key] ?? null)) { + return; + } + } + $id = $fields['activity_attempt_id'] ?? null; + if (is_string($id) && count($this->local) < 32) { + $this->local[$id] ??= [ + 'fields' => $fields, + 'context' => $context, + ]; + } + } + + public function observe(WorkflowRun $run, WorkflowTask $task, WorkflowHistoryEvent $event): void + { + if (! $this->owns($task) || $event->workflow_task_id !== $task->id || $event->workflow_run_id !== $run->id + || $run->id !== $task->workflow_run_id || $run->namespace !== $task->namespace + || ! in_array($event->event_type->value, TaskOutcomeCarrier::EVENTS, true)) { + return; + } + $identity = TaskOutcomeCarrier::identity($event); + $source = $this->source($event, $identity); + if ($source === null) { + return; + } + foreach ($identity as $key => $value) { + if ($value === null || ($source['fields'][$key] ?? null) !== $value) { + return; + } + } + $entry = TaskOutcomeCarrier::entry([ + 'event_id' => $event->id, + 'sequence' => (int) $event->sequence, + 'event_type' => $event->event_type->value, + ...$identity, + 'context' => $source['context'], + ]); + if ($entry !== null && count($this->entries) < 32) { + $this->connection = $task->getConnection() + ->getName(); + $this->entries[$event->id] ??= $entry; + } + } + + public function apply(WorkflowTask $task): void + { + if ($this->owns($task)) { + TaskOutcomeCarrier::append($task, $this->entries); + } + } + + public function discard(string $connection): void + { + if ($this->connection === $connection) { + $this->entries = []; + } + } + + private function owns(WorkflowTask $task): bool + { + return ($this->fields['task_id'] ?? null) === $task->id + && ($this->fields['workflow_run_id'] ?? null) === $task->workflow_run_id + && ($this->fields['namespace'] ?? null) === $task->namespace; + } + + private function source(WorkflowHistoryEvent $event, array $identity): ?array + { + if (isset($identity['activity_attempt_id'])) { + if ($this->kind === 'durable.activity.attempt') { + return [ + 'fields' => $this->fields, + 'context' => $this->context, + ]; + } + return $this->local[$identity['activity_attempt_id']] ?? null; + } + if (($event->event_type->value === 'TimerFired' && $this->kind === 'durable.timer.task') + || (in_array($event->event_type->value, ['WorkflowCompleted', 'WorkflowFailed'], true) + && $this->kind === 'durable.workflow.task')) { + return [ + 'fields' => $this->fields, + 'context' => $this->context, + ]; + } + + return null; + } +} diff --git a/src/V2/Support/TraceLinkState.php b/src/V2/Support/TraceLinkState.php index d2f6c044a..00c0d6797 100644 --- a/src/V2/Support/TraceLinkState.php +++ b/src/V2/Support/TraceLinkState.php @@ -14,6 +14,8 @@ final class TraceLinkState private int $dropped = 0; + private array $outcomes = []; + public function __construct( private readonly SpanInterface $span ) { @@ -24,6 +26,16 @@ public function isRecording(): bool return $this->span->isRecording(); } + public function setOutcomes(array $outcomes): void + { + $this->outcomes = $outcomes; + } + + public function outcome(string $id): ?array + { + return $this->outcomes[$id] ?? null; + } + /** * @param array $attributes */ diff --git a/src/V2/Support/WorkflowExecutor.php b/src/V2/Support/WorkflowExecutor.php index b7addcc16..97c79f285 100644 --- a/src/V2/Support/WorkflowExecutor.php +++ b/src/V2/Support/WorkflowExecutor.php @@ -5263,6 +5263,7 @@ private function recordClosedParallelChildResolutions( private function activityResult(WorkflowHistoryEvent $event, ?WorkflowRun $run = null): mixed { + ExecutionTracing::linkOutcome($run, $event); $serialized = ExternalPayloads::payloadBlob( $event->payload['result'] ?? null, $this->stringValue($event->payload['payload_codec'] ?? null) ?? $this->stringValue( @@ -5336,6 +5337,8 @@ private function activityCompletionEvent(WorkflowRun $run, int $sequence): ?Work ) && ($event->payload['sequence'] ?? null) === $sequence ); + ExecutionTracing::linkOutcome($run, $event); + return $event; } @@ -5836,6 +5839,8 @@ private function timerFiredEvent(WorkflowRun $run, int $sequence): ?WorkflowHist && ($event->payload['sequence'] ?? null) === $sequence ); + ExecutionTracing::linkOutcome($run, $event); + return $event; } diff --git a/src/migrations/2026_10_16_000100_add_observability_context_to_workflow_tasks.php b/src/migrations/2026_10_16_000100_add_observability_context_to_workflow_tasks.php new file mode 100644 index 000000000..c3e513a32 --- /dev/null +++ b/src/migrations/2026_10_16_000100_add_observability_context_to_workflow_tasks.php @@ -0,0 +1,28 @@ +json('observability_context') + ->nullable(); + }); + } + } + + public function down(): void + { + if (Schema::hasColumn('workflow_tasks', 'observability_context')) { + Schema::table('workflow_tasks', static function (Blueprint $table): void { + $table->dropColumn('observability_context'); + }); + } + } +}; diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 1a61ef8ca..a4abdbbc3 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -507,6 +507,14 @@ public function extract( $configuration = Configurator::create()->withTracerProvider($provider) ->withPropagator($propagator) ->activate(); +$taskOutcomeQueries = 0; +if ($fanOut) { + DB::listen(static function ($query) use (&$taskOutcomeQueries): void { + if (str_contains($query->sql, 'workflow_tasks') && str_contains($query->sql, 'observability_context')) { + ++$taskOutcomeQueries; + } + }); +} $initial = Context::getCurrent(); $admissionForeignKey = Context::createKey('test.application.admission'); $dispatchDelivered = []; @@ -1020,7 +1028,11 @@ public function extract( foreach ($output['execution_spans'] as &$executionSpan) { $executionSpan['links'] = array_values(array_filter( $executionSpan['links'], - static fn (array $link): bool => ($link['fields']['durable.link.type'] ?? null) !== 'queue_dispatch' + static fn (array $link): bool => ! in_array( + $link['fields']['durable.link.type'] ?? null, + ['queue_dispatch', 'task_outcome'], + true, + ) )); } unset($executionSpan); @@ -1290,6 +1302,7 @@ public function extract( ->orderBy('sequence') ->get(); $output['fan_out'] = [ + 'task_outcome_queries' => $taskOutcomeQueries, 'ready_jobs' => DB::table('jobs')->whereNull('reserved_at')->where('available_at', '<=', time())->count(), 'history_hashes' => $history->map(static fn ($event): string => hash( 'sha256', @@ -1308,6 +1321,25 @@ public function extract( 'tasks' => WorkflowTask::query()->orderBy('id')->get([ 'id', 'status', 'task_type', 'attempt_count', 'repair_count', ])->toArray(), + 'outcome_contexts' => WorkflowTask::query()->orderBy('id')->get()->map(static fn ($row): array => [ + 'task_id' => $row->id, + 'run_id' => $row->workflow_run_id, + 'namespace' => $row->namespace, + 'context' => $row->getRawOriginal('observability_context'), + ])->filter(static fn (array $row): bool => $row['context'] !== null)->values()->all(), + 'outcome_events' => $history->filter(static fn ($event): bool => in_array( + $event->event_type->value, + ['ActivityCompleted', 'TimerFired', 'WorkflowCompleted'], + true, + ))->map(static fn ($event): array => [ + 'id' => $event->id, + 'sequence' => $event->sequence, + 'type' => $event->event_type->value, + 'task_id' => $event->workflow_task_id, + 'payload' => $event->payload, + ])->values() + ->all(), + 'public_tasks' => WorkflowTask::query()->get()->toArray(), ]; } if ($recovery) { diff --git a/tests/Unit/V2/ActivityExecutionTracingTest.php b/tests/Unit/V2/ActivityExecutionTracingTest.php index 76d00be8c..975106c7f 100644 --- a/tests/Unit/V2/ActivityExecutionTracingTest.php +++ b/tests/Unit/V2/ActivityExecutionTracingTest.php @@ -40,8 +40,11 @@ use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Models\WorkflowTask; use Workflow\V2\StartOptions; +use Workflow\V2\Support\ActivityOutcomeRecorder; +use Workflow\V2\Support\ActivityTaskClaimer; use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\ScheduleManager; +use Workflow\V2\Support\TaskOutcomeCarrier; use Workflow\V2\Workflow; use Workflow\V2\WorkflowStub; @@ -234,6 +237,98 @@ public function testAdmissionPersistsItsOriginalParentAndDuplicateCannotReplaceI self::assertSame(0, DB::table('failed_jobs')->count()); } + public function testOutcomeMetadataRollsBackWithItsEventAndDuplicateCannotReplaceIt(): void + { + config([ + 'workflows.tracing.enabled' => true, + ]); + $stub = WorkflowStub::make(TracedWorkflow::class, 'outcome-rollback', '0'); + $stub->start(false); + $this->workOnce(); + $task = WorkflowTask::query()->where('task_type', 'activity')->sole(); + [$claim] = ActivityTaskClaimer::claim($task->id, $task->id); + self::assertNotNull($claim); + $prefix = WorkflowHistoryEvent::query()->orderBy('sequence')->get()->map( + static fn ($event): array => $event->getRawOriginal(), + )->all(); + $failure = new RuntimeException('deliberate outer outcome rollback'); + $record = static fn (): array => ActivityOutcomeRecorder::record( + $claim->task->id, + $claim->attemptId(), + $claim->attemptNumber(), + [9007199254740993, null, '雪'], + null, + 2, + 0, + ); + ExecutionTracing::activity([ + 'workflow_instance_id' => $claim->run->workflow_instance_id, + 'workflow_run_id' => $claim->run->id, + 'workflow_type' => $claim->run->workflow_type, + 'namespace' => $claim->run->namespace, + 'task_id' => $claim->task->id, + 'activity_id' => $claim->execution->id, + 'activity_attempt_id' => $claim->attemptId(), + 'activity_attempt_number' => $claim->attemptNumber(), + ], null, static function () use ($claim, $record, $failure, $prefix): void { + try { + $claim->task->getConnection() + ->transaction(static function () use ($record, $failure): void { + self::assertTrue($record()['recorded']); + throw $failure; + }); + self::fail('Expected the constructed outer rollback.'); + } catch (RuntimeException $caught) { + self::assertSame($failure, $caught); + } + self::assertSame($prefix, WorkflowHistoryEvent::query()->orderBy('sequence')->get()->map( + static fn ($event): array => $event->getRawOriginal(), + )->all()); + $fresh = $claim->task->fresh(); + self::assertNull($fresh->getRawOriginal('observability_context')); + // A later ordinary update must not publish a candidate from the rolled-back event. + $fresh->touch(); + self::assertNull($fresh->fresh()->getRawOriginal('observability_context')); + self::assertTrue($record()['recorded']); + }, enabled: true); + $storedTask = $claim->task->fresh(); + $contexts = TaskOutcomeCarrier::fromTask($storedTask); + self::assertCount(1, $contexts['outcomes']); + $event = WorkflowHistoryEvent::query()->where('event_type', 'ActivityCompleted')->sole(); + self::assertSame($event->id, $contexts['outcomes'][0]['event_id']); + $stored = $storedTask->getRawOriginal('observability_context'); + $history = WorkflowHistoryEvent::query()->orderBy('sequence')->get()->map( + static fn ($row): array => $row->getRawOriginal(), + )->all(); + self::assertSame([ + 'recorded' => false, + 'reason' => 'stale_attempt', + 'next_task' => null, + ], $record()); + self::assertSame($stored, $storedTask->fresh()->getRawOriginal('observability_context')); + self::assertSame($history, WorkflowHistoryEvent::query()->orderBy('sequence')->get()->map( + static fn ($row): array => $row->getRawOriginal(), + )->all()); + } + + public function testTaskCarrierMigrationKeepsLegacyRowsAndHasNoImplicitAuthority(): void + { + $stub = WorkflowStub::make(TracedWorkflow::class, 'legacy-outcome-task', '0'); + $stub->start(false); + $task = WorkflowTask::query()->sole(); + $migration = require __DIR__ . '/../../../src/migrations/2026_10_16_000100_add_observability_context_to_workflow_tasks.php'; + $migration->down(); + $before = (array) DB::table('workflow_tasks')->where('id', $task->id)->sole(); + $migration->up(); + $migration->up(); + $after = (array) DB::table('workflow_tasks')->where('id', $task->id)->sole(); + self::assertArrayHasKey('observability_context', $after); + self::assertNull($after['observability_context']); + unset($after['observability_context']); + self::assertSame($before, $after); + self::assertArrayNotHasKey('observability_context', $task->fresh()->toArray()); + } + public function testExistingRunMigrationLeavesItsDataUntouchedAndAllowsExecutionWithoutAParent(): void { $scope = TraceContextPropagator::getInstance()->extract([ diff --git a/tests/Unit/V2/DispatchExecutionTracingTest.php b/tests/Unit/V2/DispatchExecutionTracingTest.php index 5fee75ecc..a6a4f5846 100644 --- a/tests/Unit/V2/DispatchExecutionTracingTest.php +++ b/tests/Unit/V2/DispatchExecutionTracingTest.php @@ -156,12 +156,22 @@ public function testActualQueuedDispatchLinksColdProcessingWithoutChangingTheDur self::assertCount(1, $delivered); self::assertSame($span['fields']['task_id'], $delivered[0]['task_id']); $tracedDispatch = $index === 0 ? $admissionEnabled : $workerEnabled; - self::assertCount($tracedDispatch ? 1 : 0, $span['links']); + $queueLinks = array_values(array_filter( + $span['links'], + static fn (array $link): bool => ($link['fields']['durable.link.type'] ?? null) === 'queue_dispatch' + )); + foreach ($span['links'] as $link) { + self::assertContains( + $link['fields']['durable.link.type'] ?? null, + ['queue_dispatch', 'task_outcome'] + ); + } + self::assertCount($tracedDispatch ? 1 : 0, $queueLinks); if ($tracedDispatch) { $producer = $dispatchByTask[$span['fields']['task_id']]; - self::assertSame($producer['trace_id'], $span['links'][0]['trace_id']); - self::assertSame($producer['span_id'], $span['links'][0]['span_id']); - self::assertSame($span['fields']['task_id'], $span['links'][0]['fields']['task_id']); + self::assertSame($producer['trace_id'], $queueLinks[0]['trace_id']); + self::assertSame($producer['span_id'], $queueLinks[0]['span_id']); + self::assertSame($span['fields']['task_id'], $queueLinks[0]['fields']['task_id']); } } $history = json_encode($finished['dispatch']['history_payloads'], JSON_THROW_ON_ERROR); diff --git a/tests/Unit/V2/FanOutExecutionTracingTest.php b/tests/Unit/V2/FanOutExecutionTracingTest.php index 14bce71a4..1caa3c3cd 100644 --- a/tests/Unit/V2/FanOutExecutionTracingTest.php +++ b/tests/Unit/V2/FanOutExecutionTracingTest.php @@ -6,7 +6,10 @@ use Composer\Autoload\ClassLoader; use Illuminate\Filesystem\Filesystem; +use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator; +use OpenTelemetry\API\Trace\Span; use OpenTelemetry\API\Trace\StatusCode; +use OpenTelemetry\Context\Context; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\Process\Process; @@ -77,6 +80,7 @@ public function testColdParallelMembersPreserveTheirOwnAttemptScopesAndRecordedR $admissionEnabled, $workerEnabled ); + self::assertOutcomeLinks($finished, $workers, $workerEnabled); } finally { (new Filesystem())->deleteDirectory($directory); } @@ -221,4 +225,87 @@ private static function assertAttemptScopes( } } } + + private static function assertOutcomeLinks(array $finished, array $workers, bool $enabled): void + { + $rows = $finished['fan_out']['outcome_contexts']; + self::assertStringNotContainsString( + 'observability_context', + json_encode($finished['fan_out']['public_tasks'], JSON_THROW_ON_ERROR), + ); + if (! $enabled) { + self::assertSame([], $rows); + self::assertSame(0, array_sum(array_map( + static fn (array $state): int => $state['fan_out']['task_outcome_queries'], + $workers, + ))); + return; + } + self::assertGreaterThan(0, array_sum(array_map( + static fn (array $state): int => $state['fan_out']['task_outcome_queries'], + $workers, + ))); + $contexts = []; + foreach ($rows as $row) { + $envelope = json_decode($row['context'], true, flags: JSON_THROW_ON_ERROR); + self::assertSame('durable-workflow.task-outcomes', $envelope['schema']); + self::assertSame(1, $envelope['version']); + foreach (['task_id', 'run_id', 'namespace'] as $field) { + self::assertSame($row[$field], $envelope[$field]); + } + self::assertLessThanOrEqual(4096, strlen($row['context'])); + foreach ($envelope['outcomes'] as $entry) { + $contexts[$entry['event_id']] = $entry + [ + 'task_id' => $row['task_id'], + ]; + } + } + self::assertCount(5, $contexts); + $spans = array_column(array_merge(...array_column($workers, 'spans')), null, 'span_id'); + $contributions = []; + $final = null; + foreach ($finished['fan_out']['outcome_events'] as $event) { + self::assertArrayHasKey($event['id'], $contexts); + $entry = $contexts[$event['id']]; + self::assertSame($event['sequence'], $entry['sequence']); + self::assertSame($event['type'], $entry['event_type']); + self::assertSame($event['task_id'], $entry['task_id']); + $context = Span::fromContext(TraceContextPropagator::getInstance()->extract( + $entry['context']['carrier'], + context: Context::getRoot(), + ))->getContext(); + self::assertArrayHasKey($context->getSpanId(), $spans); + $span = $spans[$context->getSpanId()]; + self::assertSame($span['trace_id'], $context->getTraceId()); + self::assertSame($event['task_id'], $span['fields']['task_id']); + if ($event['type'] === 'WorkflowCompleted') { + self::assertSame('durable.workflow.task', $span['name']); + $final = $span; + } else { + $contributions[$event['id']] = [ + 'trace_id' => $span['trace_id'], + 'span_id' => $span['span_id'], + ]; + if ($event['type'] === 'ActivityCompleted') { + self::assertSame($event['payload']['activity_attempt_id'], $span['fields']['activity_attempt_id']); + } else { + self::assertSame('durable.timer.task', $span['name']); + self::assertSame($event['payload']['timer_id'], $span['fields']['timer_id']); + } + } + } + self::assertNotNull($final); + $links = []; + foreach ($final['links'] as $link) { + if (($link['fields']['durable.link.type'] ?? null) === 'task_outcome') { + $links[$link['fields']['history_event_id']] = [ + 'trace_id' => $link['trace_id'], + 'span_id' => $link['span_id'], + ]; + } + } + ksort($links); + ksort($contributions); + self::assertSame($contributions, $links); + } } diff --git a/tests/Unit/V2/TaskOutcomeCarrierTest.php b/tests/Unit/V2/TaskOutcomeCarrierTest.php new file mode 100644 index 000000000..264df5a4b --- /dev/null +++ b/tests/Unit/V2/TaskOutcomeCarrierTest.php @@ -0,0 +1,185 @@ +setRawAttributes(array_replace($task->getAttributes(), [ + 'observability_context' => $stored, + ]), true); + $other = self::entry(); + $other['context']['carrier']['traceparent'] = '00-11111111111111111111111111111111-2222222222222222-01'; + TaskOutcomeCarrier::append($task, [$other]); + self::assertSame($stored, $task->getAttributes()['observability_context']); + self::assertSame(self::entry(), TaskOutcomeCarrier::fromTask($task)['outcomes'][0]); + self::assertArrayNotHasKey('observability_context', $task->toArray()); + } + + public function testBoundedAppendRetainsCompleteEarlyEntries(): void + { + $task = self::task(); + $entries = []; + for ($sequence = 1; $sequence <= 60; ++$sequence) { + $entries[] = array_replace(self::entry(), [ + 'event_id' => 'event-' . $sequence, + 'sequence' => $sequence, + ]); + } + TaskOutcomeCarrier::append($task, $entries); + $stored = TaskOutcomeCarrier::fromTask($task); + self::assertNotEmpty($stored['outcomes']); + self::assertLessThanOrEqual(32, count($stored['outcomes'])); + self::assertLessThanOrEqual(4096, strlen($task->getAttributes()['observability_context'])); + self::assertSame(array_slice($entries, 0, count($stored['outcomes'])), $stored['outcomes']); + } + + public function testLegacyAndMalformedStorageRemainUnchanged(): void + { + $legacy = self::task(); + $attributes = $legacy->getAttributes(); + unset($attributes['observability_context']); + $legacy->setRawAttributes($attributes, true); + TaskOutcomeCarrier::append($legacy, [self::entry()]); + self::assertSame($attributes, $legacy->getAttributes()); + $bad = self::task('broken{'); + self::assertNull(TaskOutcomeCarrier::fromTask($bad)); + TaskOutcomeCarrier::append($bad, [self::entry()]); + self::assertSame('broken{', $bad->getAttributes()['observability_context']); + } + + public static function invalidEntries(): iterable + { + yield 'missing' => [null]; + foreach ([ + 'event_id' => '', + 'sequence' => '1', + 'event_type' => 'Other', + 'activity_attempt_id' => null, + ] as $key => $value) { + yield $key => [ + array_replace(self::entry(), [ + $key => $value, + ])]; + } + yield 'payload' => [ + self::entry() + [ + 'payload' => 'must-not-store', + ]]; + $baggage = self::entry(); + $baggage['context']['carrier']['baggage'] = 'private=discard'; + yield 'baggage' => [$baggage]; + $invalid = self::entry(); + $invalid['context']['carrier']['traceparent'] = 'invalid'; + yield 'traceparent' => [$invalid]; + yield 'timer identity on activity' => [ + self::entry() + [ + 'timer_id' => 'other', + ]]; + } + + #[DataProvider('invalidEntries')] + public function testInvalidEntriesNeverBecomeStoredOutcomes(mixed $entry): void + { + self::assertNull(TaskOutcomeCarrier::entry($entry)); + $task = self::task(); + TaskOutcomeCarrier::append($task, [$entry]); + self::assertNull($task->getAttributes()['observability_context']); + } + + public function testNamespaceRunAndTaskBindingsCannotBeReinterpreted(): void + { + $task = self::task(); + TaskOutcomeCarrier::append($task, [self::entry()]); + $original = TaskOutcomeCarrier::fromTask($task); + foreach (['namespace', 'run_id', 'task_id', 'schema', 'version'] as $field) { + $wrong = array_replace($original, [ + $field => 'other', + ]); + $task->setRawAttributes(array_replace($task->getAttributes(), [ + 'observability_context' => json_encode($wrong, JSON_THROW_ON_ERROR), + ]), true); + self::assertNull(TaskOutcomeCarrier::fromTask($task)); + } + $event = new WorkflowHistoryEvent(); + $event->setRawAttributes([ + 'id' => 'event-1', + 'sequence' => 1, + 'event_type' => 'ActivityCompleted', + 'payload' => json_encode([ + 'activity_execution_id' => 'activity-1', + 'activity_attempt_id' => 'attempt-1', + ]), + ], true); + self::assertTrue(TaskOutcomeCarrier::matchesEvent(self::entry(), $event)); + self::assertFalse(TaskOutcomeCarrier::matchesEvent(array_replace(self::entry(), [ + 'sequence' => 2, + ]), $event)); + self::assertFalse(TaskOutcomeCarrier::matchesEvent(array_replace(self::entry(), [ + 'activity_attempt_id' => 'other', + ]), $event)); + } + + public function testInspectionNeverExecutesObjectSerialization(): void + { + $object = new class() implements JsonSerializable { + public int $calls = 0; + + public function jsonSerialize(): mixed + { + ++$this->calls; + return 'must-not-execute'; + } + }; + $entry = self::entry(); + $entry['context']['carrier']['traceparent'] = $object; + self::assertNull(TaskOutcomeCarrier::entry($entry)); + self::assertNull(TaskOutcomeCarrier::entry(self::entry() + [ + 'payload' => $object, + ])); + self::assertSame(0, $object->calls); + } + + private static function task(mixed $context = null): WorkflowTask + { + $task = new WorkflowTask(); + $task->setRawAttributes([ + 'id' => 'task-1', + 'workflow_run_id' => 'run-1', + 'namespace' => '0', + 'observability_context' => $context, + ], true); + return $task; + } + + private static function entry(): array + { + return [ + 'event_id' => 'event-1', + 'sequence' => 1, + 'event_type' => 'ActivityCompleted', + 'activity_id' => 'activity-1', + 'activity_attempt_id' => 'attempt-1', + 'context' => [ + 'schema' => 'durable-workflow.trace-context', + 'version' => 1, + 'carrier' => [ + 'traceparent' => '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + ], + ], + ]; + } +} From e0473bb6a6d45b0fff84a0d687095394043a5eaa Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 05:13:16 +0000 Subject: [PATCH 37/77] Link parent processing to recorded child outcome spans --- .github/workflows/php.yml | 1 + docs/embedded-tracing.md | 19 +- src/V2/Support/ChildRunHistory.php | 2 + src/V2/Support/ExecutionTracing.php | 61 +++++++ src/V2/Support/TaskOutcomeCarrier.php | 91 ++++++++-- .../V2/TracingFailingChildWorkflow.php | 16 ++ .../V2/TracingFailureOutcomeWorkflow.php | 40 +++++ .../V2/TracingHandledChildFailureWorkflow.php | 24 +++ .../V2/TracingTerminalFailureActivity.php | 20 +++ tests/Fixtures/V2/tracing-process.php | 69 +++++++- .../V2/FailureOutcomeExecutionTracingTest.php | 163 ++++++++++++++++++ tests/Unit/V2/TaskOutcomeCarrierTest.php | 109 ++++++++++++ .../V2/TransitionExecutionTracingTest.php | 84 ++++++++- 13 files changed, 670 insertions(+), 29 deletions(-) create mode 100644 tests/Fixtures/V2/TracingFailingChildWorkflow.php create mode 100644 tests/Fixtures/V2/TracingFailureOutcomeWorkflow.php create mode 100644 tests/Fixtures/V2/TracingHandledChildFailureWorkflow.php create mode 100644 tests/Fixtures/V2/TracingTerminalFailureActivity.php create mode 100644 tests/Unit/V2/FailureOutcomeExecutionTracingTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 8b96e4724..729723b01 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -381,6 +381,7 @@ jobs: tests/Unit/V2/IncomingHttpTracingTest.php tests/Unit/V2/RecoveryExecutionTracingTest.php tests/Unit/V2/TransitionExecutionTracingTest.php + tests/Unit/V2/FailureOutcomeExecutionTracingTest.php tests/Unit/V2/FanOutExecutionTracingTest.php tests/Unit/V2/TaskOutcomeCarrierTest.php tests/Unit/V2/ChildRetryExecutionTracingTest.php diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index 22b51ab9f..4f33b63a8 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -177,11 +177,22 @@ original admission parent remains unchanged. Each task carrier is capped at applies. Older outcomes outside this window can have no link. Disabled workers perform no additional outcome-metadata reads or writes. +Parent processing also prefetches at most 32 original child links and 32 +physical completed/failed child outcomes. Consuming a child resolution can add +`durable.link.type=child_outcome` only when the original parent link, authored +sequence, namespace/run/task bindings and physical terminal event all match. +The child history must already be loaded by execution; adding the link performs +no observation SQL. A missing or mismatched optional observation does not change +child resolution. Root and child observations share the existing 32-link +processing-segment cap; either bounded read window can omit older outcomes. + Cold SQLite controls check the actual contributing local activity, ordinary -activity and timer span IDs, including a genuine retry, with disabled and -legacy modes. Transaction rollback, duplicate decisions and legacy migration -controls are separate. This is not complete failure/timeout/child causality, -an unlimited fan-in graph, multi-backend acceptance or overhead qualification. +activity and timer span IDs, including genuine retries and handled terminal +activity failures. Parent links cover completed and failed children, with +disabled/legacy modes and wrong-task/namespace metadata controls. Transaction +rollback, duplicate decisions and legacy migration are exercised separately. +Timeout/cancellation causality, concurrent/large fan-out, multi-backend +acceptance and overhead remain separate qualification gates. The new column remains unreleased and is not adopted by the service wire or native catalogs. diff --git a/src/V2/Support/ChildRunHistory.php b/src/V2/Support/ChildRunHistory.php index dc509a1bf..953840520 100644 --- a/src/V2/Support/ChildRunHistory.php +++ b/src/V2/Support/ChildRunHistory.php @@ -582,6 +582,7 @@ public static function outputForResolution( ?WorkflowRun $childRun = null, ): mixed { $childRun ??= self::loadRun(self::stringValue($resolutionEvent->payload['child_workflow_run_id'] ?? null)); + ExecutionTracing::linkChildOutcome($resolutionEvent, $childRun); $payload = $resolutionEvent->payload['output'] ?? null; if ($payload === null && $childRun !== null) { @@ -610,6 +611,7 @@ public static function exceptionForResolution( WorkflowHistoryEvent $resolutionEvent, ?WorkflowRun $childRun = null, ): Throwable { + ExecutionTracing::linkChildOutcome($resolutionEvent, $childRun); $payload = self::exceptionPayload( is_array($resolutionEvent->payload['exception'] ?? null) ? $resolutionEvent->payload['exception'] : null, $childRun, diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php index 83bef3c57..3a3ce3a53 100644 --- a/src/V2/Support/ExecutionTracing.php +++ b/src/V2/Support/ExecutionTracing.php @@ -262,6 +262,39 @@ public static function linkOutcome(?WorkflowRun $run, ?WorkflowHistoryEvent $eve } } + public static function linkChildOutcome(WorkflowHistoryEvent $resolution, ?WorkflowRun $child): void + { + try { + if ($child === null || self::$linkKey === null || ! $child->relationLoaded('historyEvents')) { + return; + } + $state = Context::getCurrent()->get(self::$linkKey); + $entry = $state instanceof TraceLinkState && $state->isRecording() + ? $state->outcome('child:' . $child->id) : null; + $terminal = $child->getRelation('historyEvents') + ->filter( + static fn (WorkflowHistoryEvent $event): bool => in_array($event->event_type->value, [ + 'WorkflowCompleted', 'WorkflowFailed', 'WorkflowCancelled', 'WorkflowTerminated', + ], true), + )->sortByDesc('sequence') + ->first(); + if ($entry === null || ! $terminal instanceof WorkflowHistoryEvent + || ! self::childOutcomeMatches($entry, $resolution, $child, $terminal)) { + return; + } + self::linkCarrier($state, $entry['context'], [ + 'durable.link.type' => 'child_outcome', + 'history_event_id' => $terminal->id, + 'event_type' => $terminal->event_type->value, + 'task_id' => $terminal->workflow_task_id, + 'child_workflow_run_id' => $child->id, + 'parent_resolution_event_id' => $resolution->id, + ]); + } catch (Throwable) { + // Child resolution and its original authority remain independent of tracing. + } + } + /** * @param array $attributes * @param list $baggageAllowlist @@ -458,6 +491,34 @@ public static function linkCancellation(WorkflowRun $run): void } } + private static function childOutcomeMatches( + array $entry, + WorkflowHistoryEvent $resolution, + WorkflowRun $child, + WorkflowHistoryEvent $terminal, + ): bool { + $expected = match ($resolution->event_type->value) { + 'ChildRunCompleted' => 'WorkflowCompleted', + 'ChildRunFailed' => 'WorkflowFailed', + default => null, + }; + $fields = self::current(); + + return $expected !== null && $terminal->event_type->value === $expected + && ($fields['workflow_run_id'] ?? null) === $resolution->workflow_run_id + && ($fields['workflow_run_id'] ?? null) === $entry['parent_run_id'] + && ($fields['namespace'] ?? null) === $entry['parent_namespace'] + && ($resolution->payload['workflow_link_id'] ?? null) === $entry['link_id'] + && ($resolution->payload['sequence'] ?? null) === $entry['parent_sequence'] + && ($resolution->payload['child_workflow_run_id'] ?? null) === $child->id + && ($resolution->payload['child_workflow_instance_id'] ?? null) === $child->workflow_instance_id + && $child->id === $entry['child_run_id'] && $terminal->workflow_run_id === $child->id + && $child->namespace === $entry['child_namespace'] + && $child->workflow_instance_id === $entry['child_instance_id'] + && $entry['task_id'] === $terminal->workflow_task_id + && TaskOutcomeCarrier::matchesEvent($entry, $terminal); + } + private static function outcomeState(): ?TaskOutcomeTraceState { if (self::$outcomeKey === null || self::current() === []) { diff --git a/src/V2/Support/TaskOutcomeCarrier.php b/src/V2/Support/TaskOutcomeCarrier.php index dc89dffc2..43f2b4d41 100644 --- a/src/V2/Support/TaskOutcomeCarrier.php +++ b/src/V2/Support/TaskOutcomeCarrier.php @@ -9,6 +9,7 @@ use OpenTelemetry\Context\Context; use Throwable; use Workflow\V2\Models\WorkflowHistoryEvent; +use Workflow\V2\Models\WorkflowLink; use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Models\WorkflowTask; @@ -182,33 +183,97 @@ public static function forRun(WorkflowRun $run, WorkflowTask $claim): array if (! array_key_exists('observability_context', $claim->getRawOriginal())) { return []; } + return self::ownRunOutcomes($run) + self::childRunOutcomes($run); + } catch (Throwable) { + // A missing legacy column or unavailable observation cannot replace workflow execution. + return []; + } + } + + private static function ownRunOutcomes(WorkflowRun $run): array + { + $events = ConfiguredV2Models::query('history_event_model', WorkflowHistoryEvent::class) + ->where('workflow_run_id', $run->id) + ->whereIn('event_type', self::EVENTS) + ->whereNotNull('workflow_task_id') + ->orderByDesc('sequence') + ->limit(32) + ->get(['id', 'workflow_run_id', 'workflow_task_id', 'sequence', 'event_type']); + if ($events->isEmpty()) { + return []; + } + $tasks = ConfiguredV2Models::query('task_model', WorkflowTask::class) + ->whereKey($events->pluck('workflow_task_id')->unique()) + ->where('workflow_run_id', $run->id) + ->where('namespace', $run->namespace) + ->get(['id', 'workflow_run_id', 'namespace', 'observability_context'])->keyBy('id'); + $result = []; + foreach ($events as $event) { + $task = $tasks->get($event->workflow_task_id); + if (! $task instanceof WorkflowTask) { + continue; + } + $envelope = self::fromTask($task); + foreach ($envelope['outcomes'] ?? [] as $entry) { + if ($entry['event_id'] === $event->id && $entry['sequence'] === (int) $event->sequence + && $entry['event_type'] === $event->event_type->value) { + $result[$event->id] = $entry + [ + 'task_id' => $task->id, + ]; + } + } + } + + return $result; + } + + private static function childRunOutcomes(WorkflowRun $run): array + { + try { + $links = ConfiguredV2Models::query('link_model', WorkflowLink::class) + ->where('parent_workflow_run_id', $run->id) + ->where('parent_workflow_instance_id', $run->workflow_instance_id) + ->where('link_type', 'child_workflow') + ->orderByDesc('created_at') + ->orderByDesc('id') + ->limit(32) + ->get(['id', 'sequence', 'child_workflow_run_id', 'child_workflow_instance_id']) + ->keyBy('child_workflow_run_id'); + if ($links->isEmpty()) { + return []; + } $events = ConfiguredV2Models::query('history_event_model', WorkflowHistoryEvent::class) - ->where('workflow_run_id', $run->id) - ->whereIn('event_type', self::EVENTS) + ->whereIn('workflow_run_id', $links->keys()) + ->whereIn('event_type', ['WorkflowCompleted', 'WorkflowFailed']) ->whereNotNull('workflow_task_id') - ->orderByDesc('sequence') + ->orderByDesc('recorded_at') + ->orderByDesc('id') ->limit(32) ->get(['id', 'workflow_run_id', 'workflow_task_id', 'sequence', 'event_type']); - if ($events->isEmpty()) { - return []; - } $tasks = ConfiguredV2Models::query('task_model', WorkflowTask::class) ->whereKey($events->pluck('workflow_task_id')->unique()) - ->where('workflow_run_id', $run->id) - ->where('namespace', $run->namespace) + ->whereIn('workflow_run_id', $links->keys()) ->get(['id', 'workflow_run_id', 'namespace', 'observability_context'])->keyBy('id'); $result = []; foreach ($events as $event) { $task = $tasks->get($event->workflow_task_id); - if (! $task instanceof WorkflowTask) { + $link = $links->get($event->workflow_run_id); + if (! $task instanceof WorkflowTask || ! $link instanceof WorkflowLink + || $task->workflow_run_id !== $event->workflow_run_id) { continue; } - $envelope = self::fromTask($task); - foreach ($envelope['outcomes'] ?? [] as $entry) { + foreach (self::fromTask($task)['outcomes'] ?? [] as $entry) { if ($entry['event_id'] === $event->id && $entry['sequence'] === (int) $event->sequence && $entry['event_type'] === $event->event_type->value) { - $result[$event->id] = $entry + [ + $result['child:' . $event->workflow_run_id] ??= $entry + [ 'task_id' => $task->id, + 'child_run_id' => $task->workflow_run_id, + 'child_instance_id' => $link->child_workflow_instance_id, + 'child_namespace' => $task->namespace, + 'parent_run_id' => $run->id, + 'parent_namespace' => $run->namespace, + 'parent_sequence' => $link->sequence, + 'link_id' => $link->id, ]; } } @@ -216,7 +281,7 @@ public static function forRun(WorkflowRun $run, WorkflowTask $claim): array return $result; } catch (Throwable) { - // A missing legacy column or unavailable observation cannot replace workflow execution. + // Missing child observation rows cannot invalidate the run's own observations. return []; } } diff --git a/tests/Fixtures/V2/TracingFailingChildWorkflow.php b/tests/Fixtures/V2/TracingFailingChildWorkflow.php new file mode 100644 index 000000000..363372fec --- /dev/null +++ b/tests/Fixtures/V2/TracingFailingChildWorkflow.php @@ -0,0 +1,16 @@ +getMessage(); + } + self::timer(1); + + return [ + 'caught' => $caught, + 'value' => [9007199254740993, null, '雪'], + ]; + } +} diff --git a/tests/Fixtures/V2/TracingHandledChildFailureWorkflow.php b/tests/Fixtures/V2/TracingHandledChildFailureWorkflow.php new file mode 100644 index 000000000..8ff5fe118 --- /dev/null +++ b/tests/Fixtures/V2/TracingHandledChildFailureWorkflow.php @@ -0,0 +1,24 @@ +getMessage() !== 'deliberate terminal child failure') { + throw $error; + } + } + + return [9007199254740993, null, '雪']; + } +} diff --git a/tests/Fixtures/V2/TracingTerminalFailureActivity.php b/tests/Fixtures/V2/TracingTerminalFailureActivity.php new file mode 100644 index 000000000..9fab50971 --- /dev/null +++ b/tests/Fixtures/V2/TracingTerminalFailureActivity.php @@ -0,0 +1,20 @@ +withPropagator($propagator) ->activate(); $taskOutcomeQueries = 0; -if ($fanOut) { +if ($fanOut || $failureOutcomes) { DB::listen(static function ($query) use (&$taskOutcomeQueries): void { if (str_contains($query->sql, 'workflow_tasks') && str_contains($query->sql, 'observability_context')) { ++$taskOutcomeQueries; @@ -626,6 +627,7 @@ public function extract( $scheduleDrainResults = null; $scheduleBackfillResults = null; $admissionContextPreserved = null; + $childOutcomeControl = null; try { if ($mode === 'admit') { if (Artisan::call('migrate', [ @@ -759,14 +761,21 @@ public function extract( [[9007199254740993, null, '雪']], StartOptions::returnExistingActive()->withExecutionTimeout(60)->withRunTimeout(45) ); + } elseif ($failureOutcomes) { + WorkflowStub::make(TracingFailureOutcomeWorkflow::class, 'cold-tracing', '0')->start( + $failureOutcomes === 'local', + new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), + ); } elseif ($fanOut) { WorkflowStub::make(TracingFanOutWorkflow::class, 'cold-tracing', '0')->start( new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), ); - } elseif ($transition === 'child' || $transition === 'continue') { - $workflow = $transition === 'child' - ? TracingChildParentWorkflow::class - : TracingContinuationWorkflow::class; + } elseif (in_array($transition, ['child', 'child-failure', 'continue'], true)) { + $workflow = match ($transition) { + 'child' => TracingChildParentWorkflow::class, + 'child-failure' => TracingHandledChildFailureWorkflow::class, + default => TracingContinuationWorkflow::class, + }; WorkflowStub::make($workflow, 'cold-tracing', '0')->start( new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), ); @@ -926,6 +935,26 @@ public function extract( ]])['completed']) { throw new \RuntimeException('Child failure admission refused.'); } + } elseif ($mode === 'invalidate-child-outcome' && $transition === 'child') { + $parent = WorkflowRun::query()->where('workflow_instance_id', 'cold-tracing')->sole(); + $terminal = WorkflowHistoryEvent::query()->where('event_type', 'WorkflowCompleted') + ->where('workflow_run_id', '!=', $parent->id) + ->sole(); + $task = WorkflowTask::query()->findOrFail($terminal->workflow_task_id); + $carrier = json_decode($task->getRawOriginal('observability_context'), true, flags: JSON_THROW_ON_ERROR); + $field = match (getenv('DW_TEST_TRACING_CHILD_OUTCOME_CONTROL')) { + 'wrong namespace' => 'namespace', + 'wrong task' => 'task_id', + default => throw new \RuntimeException('Unknown optional-carrier control.'), + }; + $carrier[$field] = 'unrelated'; + DB::table('workflow_tasks')->where('id', $task->id)->update([ + 'observability_context' => json_encode($carrier, JSON_THROW_ON_ERROR), + ]); + $childOutcomeControl = [ + 'field' => $field, + 'task_id' => $task->id, + ]; } elseif ($mode === 'work') { if ($dispatchProbe) { $dispatchDelivered = $dispatchQueue(); @@ -1100,7 +1129,7 @@ public function extract( 'carrier' => $command->getRawOriginal('observability_context'), ])->all(); } - if ($transition === 'child' || $transition === 'continue') { + if (in_array($transition, ['child', 'child-failure', 'continue'], true)) { $output['runs'] = WorkflowRun::query()->orderBy('id')->get()->map(static fn ($run): array => [ 'id' => $run->id, 'instance_id' => $run->workflow_instance_id, @@ -1109,10 +1138,32 @@ public function extract( 'carrier' => $run->getRawOriginal('observability_context'), 'execution_deadline' => $run->getRawOriginal('execution_deadline_at'), 'run_deadline' => $run->getRawOriginal('run_deadline_at'), + 'history_hashes' => $run->historyEvents() + ->orderBy('sequence') + ->get() + ->map( + static fn ($event): string => hash( + 'sha256', + json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR) + ), + )->all(), ])->all(); $output['activity_count'] = ActivityExecution::query()->count(); $output['completion_count'] = WorkflowHistoryEvent::query()->where('event_type', 'ActivityCompleted')->count(); $output['history_payloads'] = WorkflowHistoryEvent::query()->orderBy('id')->pluck('payload')->all(); + $output['child_outcome_control'] = $childOutcomeControl; + $output['transition_events'] = WorkflowHistoryEvent::query() + ->whereIn('event_type', ['WorkflowCompleted', 'WorkflowFailed', 'ChildRunCompleted', 'ChildRunFailed']) + ->orderBy('id') + ->get() + ->map(static fn ($event): array => [ + 'id' => $event->id, + 'run_id' => $event->workflow_run_id, + 'task_id' => $event->workflow_task_id, + 'type' => $event->event_type->value, + 'sequence' => $event->sequence, + 'payload' => $event->payload, + ])->all(); } if ($childRetry) { $output['runs'] = WorkflowRun::query()->orderBy('id')->get()->map(static fn ($row): array => [ @@ -1297,11 +1348,11 @@ public function extract( 'tasks' => WorkflowTask::query()->get(['id', 'status', 'attempt_count'])->toArray(), ]; } - if ($fanOut) { + if ($fanOut || $failureOutcomes) { $history = $run->historyEvents() ->orderBy('sequence') ->get(); - $output['fan_out'] = [ + $output[$fanOut ? 'fan_out' : 'failure_outcomes'] = [ 'task_outcome_queries' => $taskOutcomeQueries, 'ready_jobs' => DB::table('jobs')->whereNull('reserved_at')->where('available_at', '<=', time())->count(), 'history_hashes' => $history->map(static fn ($event): string => hash( @@ -1329,7 +1380,7 @@ public function extract( ])->filter(static fn (array $row): bool => $row['context'] !== null)->values()->all(), 'outcome_events' => $history->filter(static fn ($event): bool => in_array( $event->event_type->value, - ['ActivityCompleted', 'TimerFired', 'WorkflowCompleted'], + ['ActivityCompleted', 'ActivityFailed', 'TimerFired', 'WorkflowCompleted'], true, ))->map(static fn ($event): array => [ 'id' => $event->id, diff --git a/tests/Unit/V2/FailureOutcomeExecutionTracingTest.php b/tests/Unit/V2/FailureOutcomeExecutionTracingTest.php new file mode 100644 index 000000000..27c001fca --- /dev/null +++ b/tests/Unit/V2/FailureOutcomeExecutionTracingTest.php @@ -0,0 +1,163 @@ + [$kind, true, true]; + yield $kind . ' disabled' => [$kind, false, false]; + yield $kind . ' legacy' => [$kind, false, true]; + } + } + + #[DataProvider('modes')] + public function testColdReplayLinksTheOriginalHandledFailureWithoutAnotherCallback( + string $kind, + bool $admissionEnabled, + bool $workerEnabled, + ): void { + $directory = sys_get_temp_dir() . '/dw-tracing-failure-outcomes-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + try { + $worker = self::worker($directory, $kind, $admissionEnabled, $workerEnabled); + $admitted = $worker('admit'); + $states = [$admitted]; + $workers = []; + $deadline = microtime(true) + 30; + do { + $state = $worker('inspect'); + if (in_array($state['status'], ['completed', 'failed', 'terminated'], true)) { + break; + } + if ($state['failure_outcomes']['ready_jobs'] === 0) { + usleep(100_000); + continue; + } + $states[] = $workers[] = $worker('work'); + } while (microtime(true) < $deadline); + $finished = $worker('inspect'); + self::assertSame('completed', $finished['status'], json_encode($finished, JSON_THROW_ON_ERROR)); + self::assertSame([ + 'caught' => 'deliberate terminal activity failure', + 'value' => [9007199254740993, null, '雪'], + ], $finished['output']); + foreach (['jobs', 'failed_jobs', 'repairs'] as $field) { + self::assertSame(0, $finished[$field]); + } + $observed = $finished['failure_outcomes']; + self::assertCount(1, $observed['attempts']); + self::assertSame('failed', $observed['attempts'][0]['status']); + self::assertSame(1, $observed['attempts'][0]['attempt_number']); + self::assertSame(1, $observed['activities'][0]['attempt_count']); + self::assertSame( + [1, 2, 5, 10, 15, 30, 60, 120], + $observed['activities'][0]['retry_policy']['backoff_seconds'] + ); + $counts = array_count_values($observed['history_types']); + foreach (['ActivityFailed', 'FailureHandled', 'TimerFired', 'WorkflowCompleted'] as $type) { + self::assertSame(1, $counts[$type]); + } + self::assertCount(count($workers), array_unique(array_column($workers, 'pid'))); + foreach ($states as $state) { + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { + self::assertSame($admitted[$field], $state[$field]); + self::assertSame($admitted[$field], $finished[$field]); + } + self::assertSame($state['failure_outcomes']['history_hashes'], array_slice( + $observed['history_hashes'], + 0, + count($state['failure_outcomes']['history_hashes']), + )); + foreach ($state['failure_outcomes']['activities'] as $activity) { + self::assertSame($activity['retry_policy'], $observed['activities'][0]['retry_policy']); + } + } + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES), + ); + self::assertCount(1, array_filter($records, static fn (array $record): bool => + $record['message'] === 'terminal failure callback')); + self::assertFailureLinks($workers, $observed, $kind, $workerEnabled); + } finally { + (new Filesystem())->deleteDirectory($directory); + } + } + + private static function worker(string $directory, string $kind, bool $admission, bool $enabled): callable + { + return static function (string $mode) use ($directory, $kind, $admission, $enabled): array { + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + array_keys(ClassLoader::getRegisteredLoaders()) + ), + ], env: [ + 'DW_TEST_TRACING_FAILURE_OUTCOMES' => $kind, + 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admission : $enabled) ? 'true' : 'false', + ]); + $process->setTimeout(15); + $process->mustRun(); + self::assertSame('', $process->getErrorOutput()); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + + return $result; + }; + } + + private static function assertFailureLinks(array $workers, array $observed, string $kind, bool $enabled): void + { + $spans = array_merge(...array_column($workers, 'spans')); + if (! $enabled) { + self::assertSame([], $spans); + self::assertSame([], $observed['outcome_contexts']); + self::assertSame(0, array_sum(array_map(static fn (array $state): int => + $state['failure_outcomes']['task_outcome_queries'], $workers))); + return; + } + $callbacks = array_values(array_filter($spans, static fn (array $span): bool => + $span['name'] === ($kind === 'local' ? 'durable.local_activity.attempt' : 'durable.activity.attempt'))); + self::assertCount(1, $callbacks); + self::assertSame(StatusCode::STATUS_ERROR, $callbacks[0]['status_code']); + $events = array_column($observed['outcome_events'], null, 'type'); + self::assertSame($observed['attempts'][0]['id'], $events['ActivityFailed']['payload']['activity_attempt_id']); + $failure = $events['ActivityFailed']; + self::assertSame($failure['payload']['activity_attempt_id'], $callbacks[0]['fields']['activity_attempt_id']); + self::assertSame($failure['task_id'], $callbacks[0]['fields']['task_id']); + $final = array_values(array_filter($spans, static fn (array $span): bool => + $span['name'] === 'durable.workflow.task' + && $span['fields']['task_id'] === $events['WorkflowCompleted']['task_id'])); + self::assertCount(1, $final); + $links = []; + foreach ($final[0]['links'] as $link) { + if (($link['fields']['durable.link.type'] ?? null) === 'task_outcome') { + $links[$link['fields']['history_event_id']] = $link; + } + } + self::assertCount(2, $links); + self::assertArrayHasKey($failure['id'], $links); + self::assertSame($callbacks[0]['trace_id'], $links[$failure['id']]['trace_id']); + self::assertSame($callbacks[0]['span_id'], $links[$failure['id']]['span_id']); + self::assertSame($failure['task_id'], $links[$failure['id']]['fields']['task_id']); + self::assertArrayHasKey($events['TimerFired']['id'], $links); + self::assertStringNotContainsString( + 'deliberate terminal activity failure', + json_encode($spans, JSON_THROW_ON_ERROR) + ); + } +} diff --git a/tests/Unit/V2/TaskOutcomeCarrierTest.php b/tests/Unit/V2/TaskOutcomeCarrierTest.php index 264df5a4b..158577a97 100644 --- a/tests/Unit/V2/TaskOutcomeCarrierTest.php +++ b/tests/Unit/V2/TaskOutcomeCarrierTest.php @@ -4,11 +4,18 @@ namespace Tests\Unit\V2; +use Illuminate\Database\Eloquent\Collection; use JsonSerializable; +use OpenTelemetry\API\Instrumentation\Configurator; +use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; +use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor; +use OpenTelemetry\SDK\Trace\TracerProvider; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Workflow\V2\Models\WorkflowHistoryEvent; +use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Models\WorkflowTask; +use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\TaskOutcomeCarrier; final class TaskOutcomeCarrierTest extends TestCase @@ -153,6 +160,108 @@ public function jsonSerialize(): mixed self::assertSame(0, $object->calls); } + public static function childProofs(): iterable + { + yield 'canonical proof' => ['valid']; + yield 'different parent run' => ['parent']; + yield 'different original link' => ['link']; + yield 'different physical task' => ['task']; + yield 'different child namespace' => ['namespace']; + } + + #[DataProvider('childProofs')] + public function testChildLinkRequiresTheOriginalProofWithoutQueryingOrChangingTheOperation(string $proof): void + { + $exporter = new InMemoryExporter(); + $provider = new TracerProvider(new SimpleSpanProcessor($exporter)); + $configuration = Configurator::create()->withTracerProvider($provider)->activate(); + try { + $source = $provider->getTracer('fixture') + ->spanBuilder('child completion') + ->startSpan(); + $context = TaskOutcomeCarrier::capture($source); + $source->end(); + $terminal = new WorkflowHistoryEvent(); + $terminal->setRawAttributes([ + 'id' => 'terminal', + 'workflow_run_id' => 'child', + 'workflow_task_id' => 'child-task', + 'event_type' => 'WorkflowCompleted', + 'sequence' => 3, + 'payload' => '{}', + ], true); + $child = new WorkflowRun(); + $child->setConnection('unavailable-observation-source'); + $child->setRawAttributes([ + 'id' => 'child', + 'workflow_instance_id' => 'child-instance', + 'namespace' => '0', + ], true); + $child->setRelation('historyEvents', new Collection([$terminal])); + $resolution = new WorkflowHistoryEvent(); + $resolution->setRawAttributes([ + 'id' => 'resolution', + 'workflow_run_id' => 'parent', + 'event_type' => 'ChildRunCompleted', + 'payload' => json_encode([ + 'workflow_link_id' => $proof === 'link' ? 'other' : 'link', + 'sequence' => 1, + 'child_workflow_run_id' => 'child', + 'child_workflow_instance_id' => 'child-instance', + ], JSON_THROW_ON_ERROR), + ], true); + $entry = [ + 'event_id' => 'terminal', + 'sequence' => 3, + 'event_type' => 'WorkflowCompleted', + 'context' => $context, + 'task_id' => 'child-task', + 'child_run_id' => 'child', + 'child_instance_id' => 'child-instance', + 'child_namespace' => '0', + 'parent_run_id' => 'parent', + 'parent_namespace' => '0', + 'parent_sequence' => 1, + 'link_id' => 'link', + ]; + if ($proof === 'parent') { + $resolution->workflow_run_id = 'other'; + } elseif ($proof === 'task') { + $terminal->workflow_task_id = 'other'; + } elseif ($proof === 'namespace') { + $child->namespace = 'other'; + } + $calls = 0; + $result = ExecutionTracing::workflow([ + 'workflow_run_id' => 'parent', + 'workflow_instance_id' => 'parent-instance', + 'namespace' => '0', + 'task_id' => 'parent-task', + ], null, static function () use ($resolution, $child, &$calls): array { + ++$calls; + ExecutionTracing::linkChildOutcome($resolution, $child); + return [9007199254740993, null, '雪']; + }, enabled: true, loadOutcomes: static fn (): array => [ + 'child:child' => $entry, + ]); + self::assertSame([9007199254740993, null, '雪'], $result); + self::assertSame(1, $calls); + self::assertSame([], ExecutionTracing::current()); + $spans = array_values(array_filter($exporter->getSpans(), static fn ($span): bool => + $span->getName() === 'durable.workflow.task')); + self::assertCount(1, $spans); + $links = $spans[0]->getLinks(); + self::assertCount($proof === 'valid' ? 1 : 0, $links); + if ($proof === 'valid') { + self::assertSame($source->getContext()->getTraceId(), $links[0]->getSpanContext()->getTraceId()); + self::assertSame($source->getContext()->getSpanId(), $links[0]->getSpanContext()->getSpanId()); + } + } finally { + $configuration->detach(); + $provider->shutdown(); + } + } + private static function task(mixed $context = null): WorkflowTask { $task = new WorkflowTask(); diff --git a/tests/Unit/V2/TransitionExecutionTracingTest.php b/tests/Unit/V2/TransitionExecutionTracingTest.php index aa8d39371..1c03398a8 100644 --- a/tests/Unit/V2/TransitionExecutionTracingTest.php +++ b/tests/Unit/V2/TransitionExecutionTracingTest.php @@ -23,6 +23,12 @@ public static function transitions(): array 'legacy continuation, enabled later' => ['continue', true, false], 'child, disabled workers' => ['child', false, true], 'continuation, disabled workers' => ['continue', false, true], + 'child, wrong task carrier' => ['child', true, true, 'wrong task'], + 'child, wrong namespace carrier' => ['child', true, true, 'wrong namespace'], + 'child failure, tracing enabled' => ['child-failure', true, true], + 'child failure, tracing disabled' => ['child-failure', false, false], + 'legacy child failure, enabled later' => ['child-failure', true, false], + 'child failure, disabled workers' => ['child-failure', false, true], ]; } @@ -31,10 +37,17 @@ public function testColdSuccessorRetainsTheAdmissionCarrierWithoutChangingDurabl string $transition, bool $enabled, bool $admissionEnabled, + string $control = 'valid', ): void { $directory = sys_get_temp_dir() . '/dw-tracing-transition-' . bin2hex(random_bytes(8)); mkdir($directory, 0700); - $worker = static function (string $mode) use ($directory, $transition, $enabled, $admissionEnabled): array { + $worker = static function (string $mode) use ( + $directory, + $transition, + $enabled, + $admissionEnabled, + $control + ): array { $packageVendor = dirname(__DIR__, 3) . '/vendor'; $otherVendors = array_filter( array_keys(ClassLoader::getRegisteredLoaders()), @@ -48,6 +61,7 @@ public function testColdSuccessorRetainsTheAdmissionCarrierWithoutChangingDurabl ), ], env: [ 'DW_TEST_TRACING_TRANSITION' => $transition, + 'DW_TEST_TRACING_CHILD_OUTCOME_CONTROL' => $control, 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admissionEnabled : $enabled) ? 'true' : 'false', ]); $process->setTimeout(15); @@ -68,26 +82,42 @@ public function testColdSuccessorRetainsTheAdmissionCarrierWithoutChangingDurabl self::assertSame($admitted['carrier'], $run['carrier'], json_encode($run, JSON_THROW_ON_ERROR)); } $workers = [$scheduled]; + $altered = false; for ($remaining = 8; $remaining > 0; --$remaining) { $state = $worker('inspect'); if ($state['jobs'] === 0) { break; } + $childClosed = array_filter($state['transition_events'], static fn (array $event): bool => + $event['type'] === 'WorkflowCompleted' && $event['run_id'] !== $admitted['run_id']); + if ($control !== 'valid' && ! $altered && $childClosed !== []) { + $changed = $worker('invalidate-child-outcome'); + self::assertSame($state['transition_events'], $changed['transition_events']); + self::assertSame($state['runs'], $changed['runs']); + self::assertNotNull($changed['child_outcome_control']); + $altered = true; + } $workers[] = $worker('work'); } $finished = $worker('inspect'); + self::assertSame($control !== 'valid', $altered); self::assertSame('completed', $finished['status']); self::assertSame([9007199254740993, null, '雪'], $finished['output']); self::assertSame(0, $finished['jobs']); self::assertSame(0, $finished['failed_jobs']); self::assertSame(0, $finished['repairs']); - self::assertSame(1, $finished['activity_count']); - self::assertSame(1, $finished['completion_count']); + self::assertSame($transition === 'child-failure' ? 0 : 1, $finished['activity_count']); + self::assertSame($transition === 'child-failure' ? 0 : 1, $finished['completion_count']); self::assertCount(2, $finished['runs']); foreach ($scheduled['runs'] as $index => $run) { foreach (['id', 'instance_id', 'carrier', 'execution_deadline', 'run_deadline'] as $key) { self::assertSame($run[$key], $finished['runs'][$index][$key]); } + self::assertSame($run['history_hashes'], array_slice( + $finished['runs'][$index]['history_hashes'], + 0, + count($run['history_hashes']), + )); } if ($transition === 'continue') { self::assertNotSame($admitted['run_id'], $finished['run_id']); @@ -124,8 +154,56 @@ public function testColdSuccessorRetainsTheAdmissionCarrierWithoutChangingDurabl 'observability_context', json_encode($finished['history_payloads'], JSON_THROW_ON_ERROR) ); + if ($transition !== 'continue') { + self::assertChildOutcomeLinks( + $workers, + $finished, + $enabled, + $control === 'valid', + $transition === 'child-failure' + ); + } } finally { (new Filesystem())->deleteDirectory($directory); } } + + private static function assertChildOutcomeLinks( + array $workers, + array $finished, + bool $enabled, + bool $expected, + bool $failed, + ): void { + $spans = array_merge(...array_column($workers, 'spans')); + if (! $enabled) { + self::assertSame([], $spans); + return; + } + $events = $finished['transition_events']; + $childType = $failed ? 'WorkflowFailed' : 'WorkflowCompleted'; + $child = array_values(array_filter($events, static fn (array $event): bool => + $event['type'] === $childType && $event['run_id'] !== $finished['run_id']))[0]; + $parent = array_values(array_filter($events, static fn (array $event): bool => + $event['type'] === 'WorkflowCompleted' && $event['run_id'] === $finished['run_id']))[0]; + $resolved = array_values(array_filter($events, static fn (array $event): bool => + $event['type'] === ($failed ? 'ChildRunFailed' : 'ChildRunCompleted')))[0]; + self::assertSame($child['run_id'], $resolved['payload']['child_workflow_run_id']); + $childSpan = array_values(array_filter($spans, static fn (array $span): bool => + $span['name'] === 'durable.workflow.task' && $span['fields']['task_id'] === $child['task_id']))[0]; + $parentSpan = array_values(array_filter($spans, static fn (array $span): bool => + $span['name'] === 'durable.workflow.task' && $span['fields']['task_id'] === $parent['task_id']))[0]; + $links = array_values(array_filter($parentSpan['links'], static fn (array $link): bool => + ($link['fields']['durable.link.type'] ?? null) === 'child_outcome')); + self::assertCount($expected ? 1 : 0, $links); + if (! $expected) { + return; + } + self::assertSame($childSpan['trace_id'], $links[0]['trace_id']); + self::assertSame($childSpan['span_id'], $links[0]['span_id']); + self::assertSame($child['id'], $links[0]['fields']['history_event_id']); + self::assertSame($child['run_id'], $links[0]['fields']['child_workflow_run_id']); + self::assertSame($child['task_id'], $links[0]['fields']['task_id']); + self::assertSame($resolved['id'], $links[0]['fields']['parent_resolution_event_id']); + } } From 31108f6402b4ade7b85b8b59e2b8904738be9b8c Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 05:36:14 +0000 Subject: [PATCH 38/77] Specify unavailable trace context and cold fan-out controls --- .../V2/TracingLargeFanOutActivity.php | 20 ++ .../V2/TracingLargeFanOutWorkflow.php | 20 ++ tests/Fixtures/V2/tracing-process.php | 16 +- .../V2/LargeFanOutExecutionTracingTest.php | 172 ++++++++++++++++++ tests/Unit/V2/TaskOutcomeCarrierTest.php | 47 ++++- 5 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 tests/Fixtures/V2/TracingLargeFanOutActivity.php create mode 100644 tests/Fixtures/V2/TracingLargeFanOutWorkflow.php create mode 100644 tests/Unit/V2/LargeFanOutExecutionTracingTest.php diff --git a/tests/Fixtures/V2/TracingLargeFanOutActivity.php b/tests/Fixtures/V2/TracingLargeFanOutActivity.php new file mode 100644 index 000000000..6d64b3bd5 --- /dev/null +++ b/tests/Fixtures/V2/TracingLargeFanOutActivity.php @@ -0,0 +1,20 @@ + $member]); + + return ['member' => $member, 'value' => [9007199254740993, null, '雪']]; + } +} diff --git a/tests/Fixtures/V2/TracingLargeFanOutWorkflow.php b/tests/Fixtures/V2/TracingLargeFanOutWorkflow.php new file mode 100644 index 000000000..e47f97b91 --- /dev/null +++ b/tests/Fixtures/V2/TracingLargeFanOutWorkflow.php @@ -0,0 +1,20 @@ + self::activity(TracingLargeFanOutActivity::class, $member); + } + + return self::all($members); + } +} diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 9be25405c..7e457f421 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -83,6 +83,7 @@ || getenv('DW_TEST_TRACING_SIGNAL_START_ADMISSION') === 'true'; $childRetry = getenv('DW_TEST_TRACING_CHILD_RETRY') === 'true'; $fanOut = getenv('DW_TEST_TRACING_FAN_OUT') === 'true'; +$largeFanOut = getenv('DW_TEST_TRACING_LARGE_FAN_OUT') === 'true'; $failureOutcomes = getenv('DW_TEST_TRACING_FAILURE_OUTCOMES'); $cancellation = getenv('DW_TEST_TRACING_CANCELLATION'); foreach (array_slice($argv, 4) as $autoload) { @@ -509,7 +510,7 @@ public function extract( ->withPropagator($propagator) ->activate(); $taskOutcomeQueries = 0; -if ($fanOut || $failureOutcomes) { +if ($fanOut || $failureOutcomes || $largeFanOut) { DB::listen(static function ($query) use (&$taskOutcomeQueries): void { if (str_contains($query->sql, 'workflow_tasks') && str_contains($query->sql, 'observability_context')) { ++$taskOutcomeQueries; @@ -766,6 +767,10 @@ public function extract( $failureOutcomes === 'local', new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), ); + } elseif ($largeFanOut) { + WorkflowStub::make(TracingLargeFanOutWorkflow::class, 'cold-tracing', '0')->start( + new StartOptions(executionTimeoutSeconds: 120, runTimeoutSeconds: 100), + ); } elseif ($fanOut) { WorkflowStub::make(TracingFanOutWorkflow::class, 'cold-tracing', '0')->start( new StartOptions(executionTimeoutSeconds: 60, runTimeoutSeconds: 45), @@ -1348,13 +1353,18 @@ public function extract( 'tasks' => WorkflowTask::query()->get(['id', 'status', 'attempt_count'])->toArray(), ]; } - if ($fanOut || $failureOutcomes) { + if ($fanOut || $failureOutcomes || $largeFanOut) { $history = $run->historyEvents() ->orderBy('sequence') ->get(); - $output[$fanOut ? 'fan_out' : 'failure_outcomes'] = [ + $output[$largeFanOut ? 'large_fan_out' : ($fanOut ? 'fan_out' : 'failure_outcomes')] = [ 'task_outcome_queries' => $taskOutcomeQueries, 'ready_jobs' => DB::table('jobs')->whereNull('reserved_at')->where('available_at', '<=', time())->count(), + 'reserved_jobs' => DB::table('jobs')->whereNotNull('reserved_at')->count(), + 'delayed_jobs' => DB::table('jobs')->whereNull('reserved_at')->where('available_at', '>', time())->count(), + 'queue_attempts' => (int) DB::table('jobs')->sum('attempts'), + 'delivery_errors' => DB::table('failed_jobs')->orderBy('id')->limit(4)->get(['exception']) + ->map(static fn ($row): string => substr(strtok($row->exception, "\n"), 0, 512))->all(), 'history_hashes' => $history->map(static fn ($event): string => hash( 'sha256', json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR), diff --git a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php new file mode 100644 index 000000000..225fc0a6e --- /dev/null +++ b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php @@ -0,0 +1,172 @@ + [true], 'disabled' => [false]]; + } + + #[DataProvider('modes')] + public function testConcurrentWorkersCompleteTheOriginalFanOutAndExposeMissingOutcomeContexts(bool $enabled): void + { + $directory = sys_get_temp_dir() . '/dw-tracing-large-fan-out-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + $batch = []; + try { + $admitted = self::read(self::process($directory, 'admit', $enabled)); + $deadline = microtime(true) + 90; + $spans = []; + $prefixes = [$admitted['large_fan_out']['history_hashes']]; + $pids = []; + $queries = 0; + do { + $state = self::read(self::process($directory, 'inspect', $enabled)); + if (in_array($state['status'], ['completed', 'failed', 'terminated'], true)) { + break; + } + $batch = []; + $count = min(4, $state['large_fan_out']['ready_jobs']); + for ($index = 0; $index < $count; ++$index) { + $batch[] = $process = self::process($directory, 'work', $enabled); + $process->start(); + } + foreach ($batch as $process) { + $result = self::read($process); + $spans = array_merge($spans, $result['spans']); + $prefixes[] = $result['large_fan_out']['history_hashes']; + $pids[] = $result['pid']; + $queries += $result['large_fan_out']['task_outcome_queries']; + } + if ($count === 0) { + usleep(100_000); + } + } while (microtime(true) < $deadline); + $finished = self::read(self::process($directory, 'inspect', $enabled)); + $lastEvent = end($finished['large_fan_out']['history_payloads']); + $diagnostic = json_encode([ + 'run_id' => $finished['run_id'], 'jobs' => $finished['jobs'], + 'status' => $finished['status'], 'failed_jobs' => $finished['failed_jobs'], + 'ready_jobs' => $finished['large_fan_out']['ready_jobs'], + 'reserved_jobs' => $finished['large_fan_out']['reserved_jobs'], + 'delayed_jobs' => $finished['large_fan_out']['delayed_jobs'], + 'queue_attempts' => $finished['large_fan_out']['queue_attempts'], + 'delivery_errors' => $finished['large_fan_out']['delivery_errors'], + 'task_states' => array_count_values(array_column($finished['large_fan_out']['tasks'], 'status')), + 'activity_states' => array_count_values(array_column($finished['large_fan_out']['activities'], 'status')), + 'history_types' => $finished['large_fan_out']['history_types'], + 'last_event' => is_array($lastEvent) ? array_intersect_key($lastEvent, + array_fill_keys(['exception_class', 'message', 'reason'], true)) : $lastEvent, + ], JSON_THROW_ON_ERROR); + self::assertLessThan($deadline, microtime(true), 'Original 90-second observer elapsed. ' . $diagnostic); + self::assertSame('completed', $finished['status'], $diagnostic); + self::assertSame(array_map(static fn (int $member): array => [ + 'member' => $member, 'value' => [9007199254740993, null, '雪'], + ], range(0, 63)), $finished['output']); + foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { + self::assertSame($admitted[$field], $finished[$field]); + } + foreach (['jobs', 'failed_jobs', 'repairs'] as $field) { + self::assertSame(0, $finished[$field]); + } + $observed = $finished['large_fan_out']; + self::assertCount(64, $observed['activities']); + self::assertCount(64, $observed['attempts']); + self::assertSame([1], array_values(array_unique(array_column($observed['attempts'], 'attempt_number')))); + self::assertSame([0], array_values(array_unique(array_column($observed['tasks'], 'repair_count')))); + self::assertCount(count($pids), array_unique($pids)); + foreach ($prefixes as $prefix) { + self::assertSame($prefix, array_slice($observed['history_hashes'], 0, count($prefix))); + } + $records = array_map(static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)); + $callbacks = array_values(array_filter($records, static fn (array $record): bool => + $record['message'] === 'large callback')); + self::assertCount(64, $callbacks); + $members = array_map(static fn (array $record): int => $record['context']['member'], $callbacks); + sort($members); + self::assertSame(range(0, 63), $members); + self::assertTraceWindow($spans, $observed, $enabled, $queries); + } finally { + foreach ($batch as $process) { + $process->stop(1, 9); + } + (new Filesystem())->deleteDirectory($directory); + } + } + + private static function process(string $directory, string $mode, bool $enabled): Process + { + $process = new Process([ + PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', + ...array_map(static fn (string $vendor): string => $vendor . '/autoload.php', + array_keys(ClassLoader::getRegisteredLoaders())), + ], env: [ + 'DW_TEST_TRACING_LARGE_FAN_OUT' => 'true', + 'DW_TEST_TRACING_ENABLED' => $enabled ? 'true' : 'false', + ]); + $process->setTimeout(20); + return $process; + } + + private static function read(Process $process): array + { + if (! $process->isStarted()) { + $process->mustRun(); + } else { + $process->wait(); + self::assertTrue($process->isSuccessful(), substr($process->getErrorOutput(), -4096)); + } + self::assertSame('', $process->getErrorOutput()); + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame([], $result['outside_context']); + self::assertTrue($result['scope_restored']); + return $result; + } + + private static function assertTraceWindow(array $spans, array $observed, bool $enabled, int $queries): void + { + if (! $enabled) { + self::assertSame([], $spans); + self::assertSame([], $observed['outcome_contexts']); + self::assertSame(0, $queries); + return; + } + $callbacks = array_values(array_filter($spans, static fn (array $span): bool => + $span['name'] === 'durable.activity.attempt')); + self::assertCount(64, $callbacks); + $attempts = array_column($observed['attempts'], null, 'id'); + foreach ($callbacks as $span) { + self::assertArrayHasKey($span['fields']['activity_attempt_id'], $attempts); + self::assertSame($attempts[$span['fields']['activity_attempt_id']]['workflow_task_id'], $span['fields']['task_id']); + } + $events = array_column($observed['outcome_events'], null, 'id'); + $completion = array_values(array_filter($events, static fn (array $event): bool => + $event['type'] === 'WorkflowCompleted'))[0]; + $parent = array_values(array_filter($spans, static fn (array $span): bool => + $span['name'] === 'durable.workflow.task' && $span['fields']['task_id'] === $completion['task_id']))[0]; + $links = array_values(array_filter($parent['links'], static fn (array $link): bool => + ($link['fields']['durable.link.type'] ?? null) === 'task_outcome')); + self::assertCount(32, $links); + foreach ($links as $link) { + $event = $events[$link['fields']['history_event_id']]; + self::assertSame('ActivityCompleted', $event['type']); + $callback = array_values(array_filter($callbacks, static fn (array $span): bool => + $span['fields']['task_id'] === $event['task_id']))[0]; + self::assertSame($callback['trace_id'], $link['trace_id']); + self::assertSame($callback['span_id'], $link['span_id']); + } + self::assertArrayHasKey('durable.links.outcome_context_unavailable', $parent['fields']); + self::assertTrue($parent['fields']['durable.links.outcome_context_unavailable']); + } +} diff --git a/tests/Unit/V2/TaskOutcomeCarrierTest.php b/tests/Unit/V2/TaskOutcomeCarrierTest.php index 158577a97..a34782c63 100644 --- a/tests/Unit/V2/TaskOutcomeCarrierTest.php +++ b/tests/Unit/V2/TaskOutcomeCarrierTest.php @@ -167,6 +167,7 @@ public static function childProofs(): iterable yield 'different original link' => ['link']; yield 'different physical task' => ['task']; yield 'different child namespace' => ['namespace']; + yield 'missing child context' => ['missing']; } #[DataProvider('childProofs')] @@ -241,7 +242,7 @@ public function testChildLinkRequiresTheOriginalProofWithoutQueryingOrChangingTh ++$calls; ExecutionTracing::linkChildOutcome($resolution, $child); return [9007199254740993, null, '雪']; - }, enabled: true, loadOutcomes: static fn (): array => [ + }, enabled: true, loadOutcomes: static fn (): array => $proof === 'missing' ? [] : [ 'child:child' => $entry, ]); self::assertSame([9007199254740993, null, '雪'], $result); @@ -252,6 +253,8 @@ public function testChildLinkRequiresTheOriginalProofWithoutQueryingOrChangingTh self::assertCount(1, $spans); $links = $spans[0]->getLinks(); self::assertCount($proof === 'valid' ? 1 : 0, $links); + self::assertSame(! in_array($proof, ['valid', 'parent'], true), + $spans[0]->getAttributes()->toArray()['durable.links.outcome_context_unavailable'] ?? false); if ($proof === 'valid') { self::assertSame($source->getContext()->getTraceId(), $links[0]->getSpanContext()->getTraceId()); self::assertSame($source->getContext()->getSpanId(), $links[0]->getSpanContext()->getSpanId()); @@ -262,6 +265,48 @@ public function testChildLinkRequiresTheOriginalProofWithoutQueryingOrChangingTh } } + public static function outcomeAvailability(): array + { + return ['prior outcome' => ['prior'], 'current task' => ['current'], 'other run' => ['foreign']]; + } + + #[DataProvider('outcomeAvailability')] + public function testMissingPriorContextMarksOnlyItsOwnRecordingRun(string $kind): void + { + $exporter = new InMemoryExporter(); + $provider = new TracerProvider(new SimpleSpanProcessor($exporter)); + $configuration = Configurator::create()->withTracerProvider($provider)->activate(); + try { + $run = new WorkflowRun(); + $run->setRawAttributes(['id' => 'parent', 'namespace' => '0'], true); + $event = new WorkflowHistoryEvent(); + $event->setRawAttributes([ + 'id' => 'outcome', 'workflow_run_id' => $kind === 'foreign' ? 'other' : 'parent', + 'workflow_task_id' => $kind === 'current' ? 'processing' : 'prior', + 'event_type' => 'ActivityCompleted', 'sequence' => 3, 'payload' => '{}', + ], true); + $calls = 0; + $result = ExecutionTracing::workflow([ + 'workflow_run_id' => 'parent', 'namespace' => '0', 'task_id' => 'processing', + ], null, static function () use ($run, $event, &$calls): array { + ++$calls; + ExecutionTracing::linkOutcome($run, $event); + return [9007199254740993, null, '雪']; + }, enabled: true); + self::assertSame([9007199254740993, null, '雪'], $result); + self::assertSame(1, $calls); + self::assertSame([], ExecutionTracing::current()); + $spans = $exporter->getSpans(); + self::assertCount(1, $spans); + self::assertSame($kind === 'prior', + $spans[0]->getAttributes()->toArray()['durable.links.outcome_context_unavailable'] ?? false); + self::assertSame([], $spans[0]->getLinks()); + } finally { + $configuration->detach(); + $provider->shutdown(); + } + } + private static function task(mixed $context = null): WorkflowTask { $task = new WorkflowTask(); From 39b64e6e2a73200f351f4536885830476a2bc8e7 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 05:50:42 +0000 Subject: [PATCH 39/77] Exercise four-worker fan-out and retain bounded delivery diagnostics --- .github/workflows/php.yml | 1 + .../V2/TracingLargeFanOutActivity.php | 10 +- tests/Fixtures/V2/tracing-process.php | 49 ++++++++-- .../V2/LargeFanOutExecutionTracingTest.php | 95 +++++++++++-------- 4 files changed, 107 insertions(+), 48 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 729723b01..2e575e766 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -383,6 +383,7 @@ jobs: tests/Unit/V2/TransitionExecutionTracingTest.php tests/Unit/V2/FailureOutcomeExecutionTracingTest.php tests/Unit/V2/FanOutExecutionTracingTest.php + tests/Unit/V2/LargeFanOutExecutionTracingTest.php tests/Unit/V2/TaskOutcomeCarrierTest.php tests/Unit/V2/ChildRetryExecutionTracingTest.php tests/Unit/V2/CancellationExecutionTracingTest.php diff --git a/tests/Fixtures/V2/TracingLargeFanOutActivity.php b/tests/Fixtures/V2/TracingLargeFanOutActivity.php index 6d64b3bd5..3af287550 100644 --- a/tests/Fixtures/V2/TracingLargeFanOutActivity.php +++ b/tests/Fixtures/V2/TracingLargeFanOutActivity.php @@ -13,8 +13,14 @@ final class TracingLargeFanOutActivity extends Activity public function handle(int $member): array { - Log::info('large callback', ['member' => $member]); + Log::info('large callback', [ + 'member' => $member, + 'actor_pid' => getmypid(), + ]); - return ['member' => $member, 'value' => [9007199254740993, null, '雪']]; + return [ + 'member' => $member, + 'value' => [9007199254740993, null, '雪'], + ]; } } diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 7e457f421..da3cae3d4 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -373,6 +373,8 @@ protected function defineEnvironment($app): void 'workflows.v1.enabled' => false, 'workflows.v2.compatibility.namespace' => '0', 'workflows.v2.task_dispatch_mode' => 'queue', + 'workflows.v2.matching_role.queue_wake_enabled' => getenv('DW_TEST_TRACING_LARGE_FAN_OUT') !== 'true' + || getenv('DW_TEST_TRACING_LARGE_FAN_OUT_QUEUE_WAKE') !== 'false', 'workflows.tracing.enabled' => getenv('DW_TEST_TRACING_ENABLED') !== 'false', 'workflows.tracing.baggage_allowlist' => ['approved'], 'queue.default' => 'database', @@ -960,17 +962,35 @@ public function extract( 'field' => $field, 'task_id' => $task->id, ]; - } elseif ($mode === 'work') { + } elseif ($mode === 'work' || ($mode === 'work-drain' && $largeFanOut)) { if ($dispatchProbe) { $dispatchDelivered = $dispatchQueue(); } - if (Artisan::call('queue:work', [ + if ($mode === 'work-drain') { + file_put_contents($directory . '/ready-worker-' . getmypid(), 'ready'); + $readyDeadline = hrtime(true) + 10_000_000_000; + while (count(glob($directory . '/ready-worker-*') ?: []) < 4 && hrtime(true) < $readyDeadline) { + usleep(10_000); + } + if (count(glob($directory . '/ready-worker-*') ?: []) !== 4) { + throw new \RuntimeException('Original four-worker ready barrier elapsed.'); + } + } + $workerOptions = [ 'connection' => 'database', '--queue' => 'default', - '--once' => true, + '--once' => $mode !== 'work-drain', '--tries' => 1, '--timeout' => $recovery ? 1 : 30, - ]) !== 0) { + ]; + if ($mode === 'work-drain') { + $workerOptions += [ + '--max-jobs' => 128, + '--max-time' => 45, + '--stop-when-empty' => true, + ]; + } + if (Artisan::call('queue:work', $workerOptions) !== 0) { throw new \RuntimeException(Artisan::output()); } Log::info('worker outside'); @@ -1363,8 +1383,25 @@ public function extract( 'reserved_jobs' => DB::table('jobs')->whereNotNull('reserved_at')->count(), 'delayed_jobs' => DB::table('jobs')->whereNull('reserved_at')->where('available_at', '>', time())->count(), 'queue_attempts' => (int) DB::table('jobs')->sum('attempts'), - 'delivery_errors' => DB::table('failed_jobs')->orderBy('id')->limit(4)->get(['exception']) - ->map(static fn ($row): string => substr(strtok($row->exception, "\n"), 0, 512))->all(), + 'delivery_errors' => DB::table('failed_jobs')->orderBy('id')->limit(4)->get(['exception', 'payload']) + ->map(static function ($row): array { + $lines = explode("\n", $row->exception); + $payload = json_decode($row->payload, true); + $frames = array_filter($lines, static fn (string $line): bool => + str_contains($line, '/src/Queue/') || str_contains($line, '/src/V2/') + || str_contains($line, '/Queue/DatabaseQueue.php') + || str_contains($line, '/Queue/Jobs/') + || str_contains($line, 'Next Illuminate\\Database\\QueryException:')); + return [ + 'summary' => substr($lines[0], 0, 256), + 'job_type' => $payload['displayName'] ?? null, + 'delivery_id' => $payload['uuid'] ?? null, + 'stage_frames' => array_map( + static fn (string $line): string => substr($line, 0, 320), + array_slice(array_values($frames), 0, 8) + ), + ]; + })->all(), 'history_hashes' => $history->map(static fn ($event): string => hash( 'sha256', json_encode($event->getRawOriginal(), JSON_THROW_ON_ERROR), diff --git a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php index 225fc0a6e..c7e81ea1e 100644 --- a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php +++ b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php @@ -14,11 +14,14 @@ final class LargeFanOutExecutionTracingTest extends TestCase { public static function modes(): array { - return ['traced' => [true], 'disabled' => [false]]; + return [ + 'queue-only traced' => [true], + 'queue-only disabled' => [false], + ]; } #[DataProvider('modes')] - public function testConcurrentWorkersCompleteTheOriginalFanOutAndExposeMissingOutcomeContexts(bool $enabled): void + public function testConcurrentQueueOnlyWorkersCompleteTheFanOutAndExposeMissingOutcomeContexts(bool $enabled): void { $directory = sys_get_temp_dir() . '/dw-tracing-large-fan-out-' . bin2hex(random_bytes(8)); mkdir($directory, 0700); @@ -26,52 +29,52 @@ public function testConcurrentWorkersCompleteTheOriginalFanOutAndExposeMissingOu try { $admitted = self::read(self::process($directory, 'admit', $enabled)); $deadline = microtime(true) + 90; - $spans = []; - $prefixes = [$admitted['large_fan_out']['history_hashes']]; + $initial = self::read(self::process($directory, 'work', $enabled)); + self::assertCount(64, $initial['large_fan_out']['activities']); + self::assertSame(64, $initial['large_fan_out']['ready_jobs']); + $spans = $initial['spans']; + $prefixes = [$admitted['large_fan_out']['history_hashes'], $initial['large_fan_out']['history_hashes']]; $pids = []; - $queries = 0; - do { - $state = self::read(self::process($directory, 'inspect', $enabled)); - if (in_array($state['status'], ['completed', 'failed', 'terminated'], true)) { - break; - } - $batch = []; - $count = min(4, $state['large_fan_out']['ready_jobs']); - for ($index = 0; $index < $count; ++$index) { - $batch[] = $process = self::process($directory, 'work', $enabled); - $process->start(); - } - foreach ($batch as $process) { - $result = self::read($process); - $spans = array_merge($spans, $result['spans']); - $prefixes[] = $result['large_fan_out']['history_hashes']; - $pids[] = $result['pid']; - $queries += $result['large_fan_out']['task_outcome_queries']; - } - if ($count === 0) { - usleep(100_000); - } - } while (microtime(true) < $deadline); + $queries = $initial['large_fan_out']['task_outcome_queries']; + for ($index = 0; $index < 4; ++$index) { + $batch[] = $process = self::process($directory, 'work-drain', $enabled); + $process->start(); + } + self::assertCount(4, array_filter($batch, static fn (Process $process): bool => $process->isRunning())); + foreach ($batch as $process) { + $result = self::read($process); + $spans = array_merge($spans, $result['spans']); + $prefixes[] = $result['large_fan_out']['history_hashes']; + $pids[] = $result['pid']; + $queries += $result['large_fan_out']['task_outcome_queries']; + } $finished = self::read(self::process($directory, 'inspect', $enabled)); $lastEvent = end($finished['large_fan_out']['history_payloads']); $diagnostic = json_encode([ - 'run_id' => $finished['run_id'], 'jobs' => $finished['jobs'], - 'status' => $finished['status'], 'failed_jobs' => $finished['failed_jobs'], + 'run_id' => $finished['run_id'], + 'jobs' => $finished['jobs'], + 'status' => $finished['status'], + 'failed_jobs' => $finished['failed_jobs'], 'ready_jobs' => $finished['large_fan_out']['ready_jobs'], 'reserved_jobs' => $finished['large_fan_out']['reserved_jobs'], 'delayed_jobs' => $finished['large_fan_out']['delayed_jobs'], 'queue_attempts' => $finished['large_fan_out']['queue_attempts'], 'delivery_errors' => $finished['large_fan_out']['delivery_errors'], 'task_states' => array_count_values(array_column($finished['large_fan_out']['tasks'], 'status')), - 'activity_states' => array_count_values(array_column($finished['large_fan_out']['activities'], 'status')), - 'history_types' => $finished['large_fan_out']['history_types'], - 'last_event' => is_array($lastEvent) ? array_intersect_key($lastEvent, - array_fill_keys(['exception_class', 'message', 'reason'], true)) : $lastEvent, + 'activity_states' => array_count_values( + array_column($finished['large_fan_out']['activities'], 'status') + ), + 'history_counts' => array_count_values($finished['large_fan_out']['history_types']), + 'last_event' => is_array($lastEvent) ? array_intersect_key( + $lastEvent, + array_fill_keys(['exception_class', 'message', 'reason'], true) + ) : $lastEvent, ], JSON_THROW_ON_ERROR); self::assertLessThan($deadline, microtime(true), 'Original 90-second observer elapsed. ' . $diagnostic); self::assertSame('completed', $finished['status'], $diagnostic); self::assertSame(array_map(static fn (int $member): array => [ - 'member' => $member, 'value' => [9007199254740993, null, '雪'], + 'member' => $member, + 'value' => [9007199254740993, null, '雪'], ], range(0, 63)), $finished['output']); foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { self::assertSame($admitted[$field], $finished[$field]); @@ -85,14 +88,20 @@ public function testConcurrentWorkersCompleteTheOriginalFanOutAndExposeMissingOu self::assertSame([1], array_values(array_unique(array_column($observed['attempts'], 'attempt_number')))); self::assertSame([0], array_values(array_unique(array_column($observed['tasks'], 'repair_count')))); self::assertCount(count($pids), array_unique($pids)); + self::assertCount(4, glob($directory . '/ready-worker-*')); foreach ($prefixes as $prefix) { self::assertSame($prefix, array_slice($observed['history_hashes'], 0, count($prefix))); } - $records = array_map(static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), - file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)); + $records = array_map( + static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($directory . '/activity.log', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); $callbacks = array_values(array_filter($records, static fn (array $record): bool => $record['message'] === 'large callback')); self::assertCount(64, $callbacks); + foreach ($callbacks as $callback) { + self::assertContains($callback['context']['actor_pid'], $pids); + } $members = array_map(static fn (array $record): int => $record['context']['member'], $callbacks); sort($members); self::assertSame(range(0, 63), $members); @@ -109,13 +118,16 @@ private static function process(string $directory, string $mode, bool $enabled): { $process = new Process([ PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', - ...array_map(static fn (string $vendor): string => $vendor . '/autoload.php', - array_keys(ClassLoader::getRegisteredLoaders())), + ...array_map( + static fn (string $vendor): string => $vendor . '/autoload.php', + array_keys(ClassLoader::getRegisteredLoaders()) + ), ], env: [ 'DW_TEST_TRACING_LARGE_FAN_OUT' => 'true', + 'DW_TEST_TRACING_LARGE_FAN_OUT_QUEUE_WAKE' => 'false', 'DW_TEST_TRACING_ENABLED' => $enabled ? 'true' : 'false', ]); - $process->setTimeout(20); + $process->setTimeout($mode === 'work-drain' ? 60 : 20); return $process; } @@ -148,7 +160,10 @@ private static function assertTraceWindow(array $spans, array $observed, bool $e $attempts = array_column($observed['attempts'], null, 'id'); foreach ($callbacks as $span) { self::assertArrayHasKey($span['fields']['activity_attempt_id'], $attempts); - self::assertSame($attempts[$span['fields']['activity_attempt_id']]['workflow_task_id'], $span['fields']['task_id']); + self::assertSame( + $attempts[$span['fields']['activity_attempt_id']]['workflow_task_id'], + $span['fields']['task_id'] + ); } $events = array_column($observed['outcome_events'], null, 'id'); $completion = array_values(array_filter($events, static fn (array $event): bool => From 578865d48d73c59946472a2ac09c4b73ca6db7bc Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 05:52:05 +0000 Subject: [PATCH 40/77] Report unavailable outcome context on recording workflow segments --- docs/embedded-tracing.md | 8 ++++++ src/V2/Support/ExecutionTracing.php | 25 ++++++++++++++--- src/V2/Support/TraceLinkState.php | 5 ++++ tests/Unit/V2/TaskOutcomeCarrierTest.php | 34 +++++++++++++++++------- 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index 4f33b63a8..508d6fd65 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -186,6 +186,14 @@ no observation SQL. A missing or mismatched optional observation does not change child resolution. Root and child observations share the existing 32-link processing-segment cap; either bounded read window can omit older outcomes. +When a recording processing segment consumes a prior physical outcome without +usable matching context, it sets +`durable.links.outcome_context_unavailable=true`. This boolean does not count +missing spans or identify why context is unavailable. It does not apply to a +foreign run or to outcomes represented by the current task's active scope. +Its absence does not prove that a trace graph is complete. The separate +`durable.links.dropped` attribute still reports the processing-segment link cap. + Cold SQLite controls check the actual contributing local activity, ordinary activity and timer span IDs, including genuine retries and handled terminal activity failures. Parent links cover completed and failed children, with diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php index 3a3ce3a53..3b7b8ccba 100644 --- a/src/V2/Support/ExecutionTracing.php +++ b/src/V2/Support/ExecutionTracing.php @@ -249,6 +249,9 @@ public static function linkOutcome(?WorkflowRun $run, ?WorkflowHistoryEvent $eve $entry = $state->outcome($event->id); if ($entry === null || $entry['task_id'] !== $event->workflow_task_id || ! TaskOutcomeCarrier::matchesEvent($entry, $event)) { + if ($event->workflow_task_id !== ($fields['task_id'] ?? null)) { + $state->markOutcomeUnavailable(); + } return; } self::linkCarrier($state, $entry['context'], [ @@ -265,12 +268,15 @@ public static function linkOutcome(?WorkflowRun $run, ?WorkflowHistoryEvent $eve public static function linkChildOutcome(WorkflowHistoryEvent $resolution, ?WorkflowRun $child): void { try { - if ($child === null || self::$linkKey === null || ! $child->relationLoaded('historyEvents')) { + $state = self::recordingLinksForRun($resolution->workflow_run_id); + if ($state === null) { return; } - $state = Context::getCurrent()->get(self::$linkKey); - $entry = $state instanceof TraceLinkState && $state->isRecording() - ? $state->outcome('child:' . $child->id) : null; + if ($child === null || ! $child->relationLoaded('historyEvents')) { + $state->markOutcomeUnavailable(); + return; + } + $entry = $state->outcome('child:' . $child->id); $terminal = $child->getRelation('historyEvents') ->filter( static fn (WorkflowHistoryEvent $event): bool => in_array($event->event_type->value, [ @@ -280,6 +286,7 @@ public static function linkChildOutcome(WorkflowHistoryEvent $resolution, ?Workf ->first(); if ($entry === null || ! $terminal instanceof WorkflowHistoryEvent || ! self::childOutcomeMatches($entry, $resolution, $child, $terminal)) { + $state->markOutcomeUnavailable(); return; } self::linkCarrier($state, $entry['context'], [ @@ -491,6 +498,16 @@ public static function linkCancellation(WorkflowRun $run): void } } + private static function recordingLinksForRun(string $runId): ?TraceLinkState + { + if (self::$linkKey === null || (self::current()['workflow_run_id'] ?? null) !== $runId) { + return null; + } + $state = Context::getCurrent()->get(self::$linkKey); + + return $state instanceof TraceLinkState && $state->isRecording() ? $state : null; + } + private static function childOutcomeMatches( array $entry, WorkflowHistoryEvent $resolution, diff --git a/src/V2/Support/TraceLinkState.php b/src/V2/Support/TraceLinkState.php index 00c0d6797..9f8120482 100644 --- a/src/V2/Support/TraceLinkState.php +++ b/src/V2/Support/TraceLinkState.php @@ -36,6 +36,11 @@ public function outcome(string $id): ?array return $this->outcomes[$id] ?? null; } + public function markOutcomeUnavailable(): void + { + $this->span->setAttribute('durable.links.outcome_context_unavailable', true); + } + /** * @param array $attributes */ diff --git a/tests/Unit/V2/TaskOutcomeCarrierTest.php b/tests/Unit/V2/TaskOutcomeCarrierTest.php index a34782c63..c947eac12 100644 --- a/tests/Unit/V2/TaskOutcomeCarrierTest.php +++ b/tests/Unit/V2/TaskOutcomeCarrierTest.php @@ -253,8 +253,10 @@ public function testChildLinkRequiresTheOriginalProofWithoutQueryingOrChangingTh self::assertCount(1, $spans); $links = $spans[0]->getLinks(); self::assertCount($proof === 'valid' ? 1 : 0, $links); - self::assertSame(! in_array($proof, ['valid', 'parent'], true), - $spans[0]->getAttributes()->toArray()['durable.links.outcome_context_unavailable'] ?? false); + self::assertSame( + ! in_array($proof, ['valid', 'parent'], true), + $spans[0]->getAttributes()->toArray()['durable.links.outcome_context_unavailable'] ?? false + ); if ($proof === 'valid') { self::assertSame($source->getContext()->getTraceId(), $links[0]->getSpanContext()->getTraceId()); self::assertSame($source->getContext()->getSpanId(), $links[0]->getSpanContext()->getSpanId()); @@ -267,7 +269,11 @@ public function testChildLinkRequiresTheOriginalProofWithoutQueryingOrChangingTh public static function outcomeAvailability(): array { - return ['prior outcome' => ['prior'], 'current task' => ['current'], 'other run' => ['foreign']]; + return [ + 'prior outcome' => ['prior'], + 'current task' => ['current'], + 'other run' => ['foreign'], + ]; } #[DataProvider('outcomeAvailability')] @@ -278,16 +284,24 @@ public function testMissingPriorContextMarksOnlyItsOwnRecordingRun(string $kind) $configuration = Configurator::create()->withTracerProvider($provider)->activate(); try { $run = new WorkflowRun(); - $run->setRawAttributes(['id' => 'parent', 'namespace' => '0'], true); + $run->setRawAttributes([ + 'id' => 'parent', + 'namespace' => '0', + ], true); $event = new WorkflowHistoryEvent(); $event->setRawAttributes([ - 'id' => 'outcome', 'workflow_run_id' => $kind === 'foreign' ? 'other' : 'parent', + 'id' => 'outcome', + 'workflow_run_id' => $kind === 'foreign' ? 'other' : 'parent', 'workflow_task_id' => $kind === 'current' ? 'processing' : 'prior', - 'event_type' => 'ActivityCompleted', 'sequence' => 3, 'payload' => '{}', + 'event_type' => 'ActivityCompleted', + 'sequence' => 3, + 'payload' => '{}', ], true); $calls = 0; $result = ExecutionTracing::workflow([ - 'workflow_run_id' => 'parent', 'namespace' => '0', 'task_id' => 'processing', + 'workflow_run_id' => 'parent', + 'namespace' => '0', + 'task_id' => 'processing', ], null, static function () use ($run, $event, &$calls): array { ++$calls; ExecutionTracing::linkOutcome($run, $event); @@ -298,8 +312,10 @@ public function testMissingPriorContextMarksOnlyItsOwnRecordingRun(string $kind) self::assertSame([], ExecutionTracing::current()); $spans = $exporter->getSpans(); self::assertCount(1, $spans); - self::assertSame($kind === 'prior', - $spans[0]->getAttributes()->toArray()['durable.links.outcome_context_unavailable'] ?? false); + self::assertSame( + $kind === 'prior', + $spans[0]->getAttributes()->toArray()['durable.links.outcome_context_unavailable'] ?? false + ); self::assertSame([], $spans[0]->getLinks()); } finally { $configuration->detach(); From 86181fb98775a89dd7166dd536dbc2084cdbe432 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 12:58:27 +0000 Subject: [PATCH 41/77] Qualify tracing fan-out with the documented embedded matching role --- tests/Fixtures/V2/tracing-process.php | 3 ++- .../Unit/V2/LargeFanOutExecutionTracingTest.php | 16 ++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index da3cae3d4..51556b002 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -987,7 +987,7 @@ public function extract( $workerOptions += [ '--max-jobs' => 128, '--max-time' => 45, - '--stop-when-empty' => true, + '--stop-when-empty' => getenv('DW_TEST_TRACING_CONTINUOUS_WORKERS') !== 'true', ]; } if (Artisan::call('queue:work', $workerOptions) !== 0) { @@ -1378,6 +1378,7 @@ public function extract( ->orderBy('sequence') ->get(); $output[$largeFanOut ? 'large_fan_out' : ($fanOut ? 'fan_out' : 'failure_outcomes')] = [ + 'matching_role' => \Workflow\V2\Support\MatchingRoleSnapshot::current(), 'task_outcome_queries' => $taskOutcomeQueries, 'ready_jobs' => DB::table('jobs')->whereNull('reserved_at')->where('available_at', '<=', time())->count(), 'reserved_jobs' => DB::table('jobs')->whereNotNull('reserved_at')->count(), diff --git a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php index c7e81ea1e..08ead769e 100644 --- a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php +++ b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php @@ -15,19 +15,21 @@ final class LargeFanOutExecutionTracingTest extends TestCase public static function modes(): array { return [ - 'queue-only traced' => [true], - 'queue-only disabled' => [false], + 'embedded traced' => [true], + 'embedded disabled' => [false], ]; } #[DataProvider('modes')] - public function testConcurrentQueueOnlyWorkersCompleteTheFanOutAndExposeMissingOutcomeContexts(bool $enabled): void + public function testConcurrentWorkersCompleteTheFanOutAndExposeMissingOutcomeContexts(bool $enabled): void { $directory = sys_get_temp_dir() . '/dw-tracing-large-fan-out-' . bin2hex(random_bytes(8)); mkdir($directory, 0700); $batch = []; try { $admitted = self::read(self::process($directory, 'admit', $enabled)); + self::assertTrue($admitted['large_fan_out']['matching_role']['queue_wake_enabled']); + self::assertSame('in_worker', $admitted['large_fan_out']['matching_role']['shape']); $deadline = microtime(true) + 90; $initial = self::read(self::process($directory, 'work', $enabled)); self::assertCount(64, $initial['large_fan_out']['activities']); @@ -60,6 +62,7 @@ public function testConcurrentQueueOnlyWorkersCompleteTheFanOutAndExposeMissingO 'delayed_jobs' => $finished['large_fan_out']['delayed_jobs'], 'queue_attempts' => $finished['large_fan_out']['queue_attempts'], 'delivery_errors' => $finished['large_fan_out']['delivery_errors'], + 'repairs' => $finished['repairs'], 'task_states' => array_count_values(array_column($finished['large_fan_out']['tasks'], 'status')), 'activity_states' => array_count_values( array_column($finished['large_fan_out']['activities'], 'status') @@ -79,14 +82,13 @@ public function testConcurrentQueueOnlyWorkersCompleteTheFanOutAndExposeMissingO foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { self::assertSame($admitted[$field], $finished[$field]); } - foreach (['jobs', 'failed_jobs', 'repairs'] as $field) { + foreach (['jobs', 'failed_jobs'] as $field) { self::assertSame(0, $finished[$field]); } $observed = $finished['large_fan_out']; self::assertCount(64, $observed['activities']); self::assertCount(64, $observed['attempts']); self::assertSame([1], array_values(array_unique(array_column($observed['attempts'], 'attempt_number')))); - self::assertSame([0], array_values(array_unique(array_column($observed['tasks'], 'repair_count')))); self::assertCount(count($pids), array_unique($pids)); self::assertCount(4, glob($directory . '/ready-worker-*')); foreach ($prefixes as $prefix) { @@ -124,7 +126,9 @@ private static function process(string $directory, string $mode, bool $enabled): ), ], env: [ 'DW_TEST_TRACING_LARGE_FAN_OUT' => 'true', - 'DW_TEST_TRACING_LARGE_FAN_OUT_QUEUE_WAKE' => 'false', + // Disabling this role requires a separate matching process. + 'DW_TEST_TRACING_LARGE_FAN_OUT_QUEUE_WAKE' => 'true', + 'DW_TEST_TRACING_CONTINUOUS_WORKERS' => 'true', 'DW_TEST_TRACING_ENABLED' => $enabled ? 'true' : 'false', ]); $process->setTimeout($mode === 'work-drain' ? 60 : 20); From 7b4e9b10511151c9431cd5170ee2584c7fe65b7b Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 13:12:30 +0000 Subject: [PATCH 42/77] Include queue state when traced fan-out assertions fail --- tests/Unit/V2/LargeFanOutExecutionTracingTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php index 08ead769e..4239eb36d 100644 --- a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php +++ b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php @@ -83,7 +83,7 @@ public function testConcurrentWorkersCompleteTheFanOutAndExposeMissingOutcomeCon self::assertSame($admitted[$field], $finished[$field]); } foreach (['jobs', 'failed_jobs'] as $field) { - self::assertSame(0, $finished[$field]); + self::assertSame(0, $finished[$field], $field . ': ' . $diagnostic); } $observed = $finished['large_fan_out']; self::assertCount(64, $observed['activities']); From ae579027765e4ff95ec1500fcdb693d54bfb396d Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 13:50:09 +0000 Subject: [PATCH 43/77] Identify remaining queue deliveries after complete tracing assertions --- tests/Fixtures/V2/tracing-process.php | 46 +++++++++++++++++++ .../V2/LargeFanOutExecutionTracingTest.php | 10 ++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 51556b002..9685f81e1 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -424,6 +424,37 @@ protected function defineEnvironment($app): void ], ], ); +$queueErrors = []; +if ($largeFanOut) { + $app['events']->listen(\Illuminate\Queue\Events\JobExceptionOccurred::class, static function ($event) use ( + &$queueErrors + ): void { + if (count($queueErrors) >= 4) { + return; + } + $error = $event->exception; + $native = $error; + while ($native->getPrevious() !== null) { + $native = $native->getPrevious(); + } + $connection = DB::connection('tracing'); + $pdo = $connection->getRawPdo(); + $payload = json_decode($event->job->getRawBody(), true); + $queueErrors[] = [ + 'job_id' => (string) $event->job->getJobId(), + 'job_type' => $payload['displayName'] ?? null, + 'delivery_id' => $payload['uuid'] ?? null, + 'deleted' => $event->job->isDeleted(), + 'failed' => $event->job->hasFailed(), + 'error_class' => $error::class, + 'native_code' => $native instanceof \PDOException ? ($native->errorInfo[1] ?? null) : null, + 'operation' => $error instanceof \Illuminate\Database\QueryException + ? strtolower(explode(' ', trim($error->getSql()), 2)[0]) : null, + 'transaction_level' => $connection->transactionLevel(), + 'pdo_in_transaction' => $pdo instanceof \PDO ? $pdo->inTransaction() : null, + ]; + }); +} $exporter = new InMemoryExporter(); $admissionProcessor = ($admissionProbe || $dispatchProbe || $scheduleProbe) ? new class( $exporter, @@ -1384,6 +1415,21 @@ public function extract( 'reserved_jobs' => DB::table('jobs')->whereNotNull('reserved_at')->count(), 'delayed_jobs' => DB::table('jobs')->whereNull('reserved_at')->where('available_at', '>', time())->count(), 'queue_attempts' => (int) DB::table('jobs')->sum('attempts'), + 'queue_errors' => $queueErrors, + 'remaining_deliveries' => DB::table('jobs')->orderBy('id')->limit(8)->get([ + 'id', 'queue', 'attempts', 'reserved_at', 'available_at', 'payload', + ])->map(static function ($row): array { + $payload = json_decode($row->payload, true); + return [ + 'id' => (string) $row->id, + 'queue' => $row->queue, + 'attempts' => (int) $row->attempts, + 'reserved_at' => $row->reserved_at, + 'available_at' => $row->available_at, + 'job_type' => $payload['displayName'] ?? null, + 'delivery_id' => $payload['uuid'] ?? null, + ]; + })->all(), 'delivery_errors' => DB::table('failed_jobs')->orderBy('id')->limit(4)->get(['exception', 'payload']) ->map(static function ($row): array { $lines = explode("\n", $row->exception); diff --git a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php index 4239eb36d..df648c9e5 100644 --- a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php +++ b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php @@ -35,6 +35,7 @@ public function testConcurrentWorkersCompleteTheFanOutAndExposeMissingOutcomeCon self::assertCount(64, $initial['large_fan_out']['activities']); self::assertSame(64, $initial['large_fan_out']['ready_jobs']); $spans = $initial['spans']; + $queueErrors = $initial['large_fan_out']['queue_errors']; $prefixes = [$admitted['large_fan_out']['history_hashes'], $initial['large_fan_out']['history_hashes']]; $pids = []; $queries = $initial['large_fan_out']['task_outcome_queries']; @@ -46,6 +47,7 @@ public function testConcurrentWorkersCompleteTheFanOutAndExposeMissingOutcomeCon foreach ($batch as $process) { $result = self::read($process); $spans = array_merge($spans, $result['spans']); + $queueErrors = array_merge($queueErrors, $result['large_fan_out']['queue_errors']); $prefixes[] = $result['large_fan_out']['history_hashes']; $pids[] = $result['pid']; $queries += $result['large_fan_out']['task_outcome_queries']; @@ -61,6 +63,8 @@ public function testConcurrentWorkersCompleteTheFanOutAndExposeMissingOutcomeCon 'reserved_jobs' => $finished['large_fan_out']['reserved_jobs'], 'delayed_jobs' => $finished['large_fan_out']['delayed_jobs'], 'queue_attempts' => $finished['large_fan_out']['queue_attempts'], + 'queue_errors' => $queueErrors, + 'remaining_deliveries' => $finished['large_fan_out']['remaining_deliveries'], 'delivery_errors' => $finished['large_fan_out']['delivery_errors'], 'repairs' => $finished['repairs'], 'task_states' => array_count_values(array_column($finished['large_fan_out']['tasks'], 'status')), @@ -82,9 +86,6 @@ public function testConcurrentWorkersCompleteTheFanOutAndExposeMissingOutcomeCon foreach (['run_id', 'carrier', 'execution_deadline', 'run_deadline'] as $field) { self::assertSame($admitted[$field], $finished[$field]); } - foreach (['jobs', 'failed_jobs'] as $field) { - self::assertSame(0, $finished[$field], $field . ': ' . $diagnostic); - } $observed = $finished['large_fan_out']; self::assertCount(64, $observed['activities']); self::assertCount(64, $observed['attempts']); @@ -108,6 +109,9 @@ public function testConcurrentWorkersCompleteTheFanOutAndExposeMissingOutcomeCon sort($members); self::assertSame(range(0, 63), $members); self::assertTraceWindow($spans, $observed, $enabled, $queries); + foreach (['jobs', 'failed_jobs'] as $field) { + self::assertSame(0, $finished[$field], $field . ': ' . $diagnostic); + } } finally { foreach ($batch as $process) { $process->stop(1, 9); From 04627eaed9c3741e32820d2824f0c55d854c24f7 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 13:58:03 +0000 Subject: [PATCH 44/77] Capture reported acknowledgement errors and their original task fences --- tests/Fixtures/V2/tracing-process.php | 35 +++++++++++++++++-- .../V2/LargeFanOutExecutionTracingTest.php | 6 ++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 9685f81e1..3791177c0 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -425,11 +425,12 @@ protected function defineEnvironment($app): void ], ); $queueErrors = []; +$reportedQueueErrors = []; if ($largeFanOut) { - $app['events']->listen(\Illuminate\Queue\Events\JobExceptionOccurred::class, static function ($event) use ( + $app['events']->listen(\Illuminate\Queue\Events\JobAttempted::class, static function ($event) use ( &$queueErrors ): void { - if (count($queueErrors) >= 4) { + if ($event->exception === null || count($queueErrors) >= 4) { return; } $error = $event->exception; @@ -454,6 +455,31 @@ protected function defineEnvironment($app): void 'pdo_in_transaction' => $pdo instanceof \PDO ? $pdo->inTransaction() : null, ]; }); + $handler = $app->make(\Illuminate\Contracts\Debug\ExceptionHandler::class); + if (! method_exists($handler, 'reportable')) { + throw new \RuntimeException('The tracing fixture needs Laravel exception reporting callbacks.'); + } + $handler->reportable(static function (\Throwable $error) use (&$reportedQueueErrors): void { + if (count($reportedQueueErrors) >= 4) { + return; + } + $native = $error; + while ($native->getPrevious() !== null) { + $native = $native->getPrevious(); + } + $connection = DB::connection('tracing'); + $pdo = $connection->getRawPdo(); + $sql = $error instanceof \Illuminate\Database\QueryException ? $error->getSql() : ''; + $reportedQueueErrors[] = [ + 'error_class' => $error::class, + 'native_code' => $native instanceof \PDOException ? ($native->errorInfo[1] ?? null) : null, + 'operation' => $sql === '' ? null : strtolower(explode(' ', trim($sql), 2)[0]), + 'ack_fence' => $sql === 'delete from "jobs" where "id" = ? and "queue" = ? and "reserved_at" = ? and "attempts" = ?' + ? $error->getBindings() : null, + 'transaction_level' => $connection->transactionLevel(), + 'pdo_in_transaction' => $pdo instanceof \PDO ? $pdo->inTransaction() : null, + ]; + }); } $exporter = new InMemoryExporter(); $admissionProcessor = ($admissionProbe || $dispatchProbe || $scheduleProbe) ? new class( @@ -1408,6 +1434,7 @@ public function extract( $history = $run->historyEvents() ->orderBy('sequence') ->get(); + $remainingTaskIds = $largeFanOut ? array_column($dispatchQueue(), 'task_id', 'id') : []; $output[$largeFanOut ? 'large_fan_out' : ($fanOut ? 'fan_out' : 'failure_outcomes')] = [ 'matching_role' => \Workflow\V2\Support\MatchingRoleSnapshot::current(), 'task_outcome_queries' => $taskOutcomeQueries, @@ -1416,9 +1443,10 @@ public function extract( 'delayed_jobs' => DB::table('jobs')->whereNull('reserved_at')->where('available_at', '>', time())->count(), 'queue_attempts' => (int) DB::table('jobs')->sum('attempts'), 'queue_errors' => $queueErrors, + 'reported_queue_errors' => $reportedQueueErrors, 'remaining_deliveries' => DB::table('jobs')->orderBy('id')->limit(8)->get([ 'id', 'queue', 'attempts', 'reserved_at', 'available_at', 'payload', - ])->map(static function ($row): array { + ])->map(static function ($row) use ($remainingTaskIds): array { $payload = json_decode($row->payload, true); return [ 'id' => (string) $row->id, @@ -1428,6 +1456,7 @@ public function extract( 'available_at' => $row->available_at, 'job_type' => $payload['displayName'] ?? null, 'delivery_id' => $payload['uuid'] ?? null, + 'task_id' => $remainingTaskIds[$row->id] ?? null, ]; })->all(), 'delivery_errors' => DB::table('failed_jobs')->orderBy('id')->limit(4)->get(['exception', 'payload']) diff --git a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php index df648c9e5..90cbc2011 100644 --- a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php +++ b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php @@ -36,6 +36,7 @@ public function testConcurrentWorkersCompleteTheFanOutAndExposeMissingOutcomeCon self::assertSame(64, $initial['large_fan_out']['ready_jobs']); $spans = $initial['spans']; $queueErrors = $initial['large_fan_out']['queue_errors']; + $reportedQueueErrors = $initial['large_fan_out']['reported_queue_errors']; $prefixes = [$admitted['large_fan_out']['history_hashes'], $initial['large_fan_out']['history_hashes']]; $pids = []; $queries = $initial['large_fan_out']['task_outcome_queries']; @@ -48,6 +49,10 @@ public function testConcurrentWorkersCompleteTheFanOutAndExposeMissingOutcomeCon $result = self::read($process); $spans = array_merge($spans, $result['spans']); $queueErrors = array_merge($queueErrors, $result['large_fan_out']['queue_errors']); + $reportedQueueErrors = array_merge( + $reportedQueueErrors, + $result['large_fan_out']['reported_queue_errors'] + ); $prefixes[] = $result['large_fan_out']['history_hashes']; $pids[] = $result['pid']; $queries += $result['large_fan_out']['task_outcome_queries']; @@ -64,6 +69,7 @@ public function testConcurrentWorkersCompleteTheFanOutAndExposeMissingOutcomeCon 'delayed_jobs' => $finished['large_fan_out']['delayed_jobs'], 'queue_attempts' => $finished['large_fan_out']['queue_attempts'], 'queue_errors' => $queueErrors, + 'reported_queue_errors' => $reportedQueueErrors, 'remaining_deliveries' => $finished['large_fan_out']['remaining_deliveries'], 'delivery_errors' => $finished['large_fan_out']['delivery_errors'], 'repairs' => $finished['repairs'], From af85209d962e5699c36ed529dae2e4c3d6a14b89 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 14:09:22 +0000 Subject: [PATCH 45/77] Qualify tracing through bounded and identified queue recovery --- tests/Fixtures/V2/tracing-process.php | 31 +++++++--- .../V2/LargeFanOutExecutionTracingTest.php | 57 ++++++++++++++++++- 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 3791177c0..bfd889c18 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -426,11 +426,17 @@ protected function defineEnvironment($app): void ); $queueErrors = []; $reportedQueueErrors = []; +$queueDiagnosticsTruncated = false; if ($largeFanOut) { $app['events']->listen(\Illuminate\Queue\Events\JobAttempted::class, static function ($event) use ( - &$queueErrors + &$queueErrors, + &$queueDiagnosticsTruncated ): void { - if ($event->exception === null || count($queueErrors) >= 4) { + if ($event->exception === null) { + return; + } + if (count($queueErrors) >= 64) { + $queueDiagnosticsTruncated = true; return; } $error = $event->exception; @@ -459,8 +465,12 @@ protected function defineEnvironment($app): void if (! method_exists($handler, 'reportable')) { throw new \RuntimeException('The tracing fixture needs Laravel exception reporting callbacks.'); } - $handler->reportable(static function (\Throwable $error) use (&$reportedQueueErrors): void { - if (count($reportedQueueErrors) >= 4) { + $handler->reportable(static function (\Throwable $error) use ( + &$reportedQueueErrors, + &$queueDiagnosticsTruncated + ): void { + if (count($reportedQueueErrors) >= 64) { + $queueDiagnosticsTruncated = true; return; } $native = $error; @@ -1444,7 +1454,9 @@ public function extract( 'queue_attempts' => (int) DB::table('jobs')->sum('attempts'), 'queue_errors' => $queueErrors, 'reported_queue_errors' => $reportedQueueErrors, - 'remaining_deliveries' => DB::table('jobs')->orderBy('id')->limit(8)->get([ + 'queue_diagnostics_truncated' => $queueDiagnosticsTruncated, + 'retry_after_seconds' => config('queue.connections.database.retry_after'), + 'remaining_deliveries' => DB::table('jobs')->orderBy('id')->limit(64)->get([ 'id', 'queue', 'attempts', 'reserved_at', 'available_at', 'payload', ])->map(static function ($row) use ($remainingTaskIds): array { $payload = json_decode($row->payload, true); @@ -1459,10 +1471,13 @@ public function extract( 'task_id' => $remainingTaskIds[$row->id] ?? null, ]; })->all(), - 'delivery_errors' => DB::table('failed_jobs')->orderBy('id')->limit(4)->get(['exception', 'payload']) + 'delivery_errors' => DB::table('failed_jobs')->orderBy('id')->limit(64)->get(['exception', 'payload']) ->map(static function ($row): array { $lines = explode("\n", $row->exception); $payload = json_decode($row->payload, true); + $job = unserialize($payload['data']['command'], [ + 'allowed_classes' => [RunWorkflowTask::class, RunActivityTask::class, RunTimerTask::class], + ]); $frames = array_filter($lines, static fn (string $line): bool => str_contains($line, '/src/Queue/') || str_contains($line, '/src/V2/') || str_contains($line, '/Queue/DatabaseQueue.php') @@ -1472,6 +1487,8 @@ public function extract( 'summary' => substr($lines[0], 0, 256), 'job_type' => $payload['displayName'] ?? null, 'delivery_id' => $payload['uuid'] ?? null, + 'task_id' => $job instanceof RunWorkflowTask || $job instanceof RunActivityTask + ? $job->taskId : null, 'stage_frames' => array_map( static fn (string $line): string => substr($line, 0, 320), array_slice(array_values($frames), 0, 8) @@ -1493,7 +1510,7 @@ public function extract( 'id', 'activity_execution_id', 'workflow_task_id', 'attempt_number', 'status', ])->toArray(), 'tasks' => WorkflowTask::query()->orderBy('id')->get([ - 'id', 'status', 'task_type', 'attempt_count', 'repair_count', + 'id', 'workflow_run_id', 'namespace', 'queue', 'status', 'task_type', 'attempt_count', 'repair_count', ])->toArray(), 'outcome_contexts' => WorkflowTask::query()->orderBy('id')->get()->map(static fn ($row): array => [ 'task_id' => $row->id, diff --git a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php index 90cbc2011..33ec54db2 100644 --- a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php +++ b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php @@ -37,6 +37,7 @@ public function testConcurrentWorkersCompleteTheFanOutAndExposeMissingOutcomeCon $spans = $initial['spans']; $queueErrors = $initial['large_fan_out']['queue_errors']; $reportedQueueErrors = $initial['large_fan_out']['reported_queue_errors']; + $queueDiagnosticsTruncated = $initial['large_fan_out']['queue_diagnostics_truncated']; $prefixes = [$admitted['large_fan_out']['history_hashes'], $initial['large_fan_out']['history_hashes']]; $pids = []; $queries = $initial['large_fan_out']['task_outcome_queries']; @@ -53,6 +54,8 @@ public function testConcurrentWorkersCompleteTheFanOutAndExposeMissingOutcomeCon $reportedQueueErrors, $result['large_fan_out']['reported_queue_errors'] ); + $queueDiagnosticsTruncated = $queueDiagnosticsTruncated + || $result['large_fan_out']['queue_diagnostics_truncated']; $prefixes[] = $result['large_fan_out']['history_hashes']; $pids[] = $result['pid']; $queries += $result['large_fan_out']['task_outcome_queries']; @@ -115,9 +118,8 @@ public function testConcurrentWorkersCompleteTheFanOutAndExposeMissingOutcomeCon sort($members); self::assertSame(range(0, 63), $members); self::assertTraceWindow($spans, $observed, $enabled, $queries); - foreach (['jobs', 'failed_jobs'] as $field) { - self::assertSame(0, $finished[$field], $field . ': ' . $diagnostic); - } + self::assertFalse($queueDiagnosticsTruncated, $diagnostic); + self::assertSupportedQueueRecovery($finished, $observed, $queueErrors, $reportedQueueErrors, $diagnostic); } finally { foreach ($batch as $process) { $process->stop(1, 9); @@ -126,6 +128,55 @@ public function testConcurrentWorkersCompleteTheFanOutAndExposeMissingOutcomeCon } } + private static function assertSupportedQueueRecovery( + array $finished, + array $observed, + array $queueErrors, + array $reportedErrors, + string $diagnostic, + ): void { + foreach (array_merge($queueErrors, $reportedErrors) as $error) { + self::assertIsInt($error['native_code'], $diagnostic); + self::assertContains($error['native_code'] & 255, [5, 6], $diagnostic); + self::assertContains($error['operation'], ['update', 'delete'], $diagnostic); + self::assertSame(0, $error['transaction_level'], $diagnostic); + self::assertFalse($error['pdo_in_transaction'], $diagnostic); + } + self::assertCount($finished['failed_jobs'], $observed['delivery_errors'], $diagnostic); + self::assertCount($finished['jobs'], $observed['remaining_deliveries'], $diagnostic); + $tasks = array_column($observed['tasks'], null, 'id'); + $errors = array_column($queueErrors, null, 'delivery_id'); + foreach ($observed['delivery_errors'] as $failed) { + self::assertRecoveredTask($failed, $tasks, $finished['run_id'], $diagnostic); + self::assertArrayHasKey($failed['delivery_id'], $errors, $diagnostic); + self::assertSame('update', $errors[$failed['delivery_id']]['operation'], $diagnostic); + self::assertStringContainsString('SqliteTransaction::forTask', implode("\n", $failed['stage_frames'])); + } + foreach ($observed['remaining_deliveries'] as $remaining) { + self::assertRecoveredTask($remaining, $tasks, $finished['run_id'], $diagnostic); + $fence = [ + (int) $remaining['id'], $remaining['queue'], + (int) $remaining['reserved_at'], $remaining['attempts'], + ]; + $refusals = array_filter($reportedErrors, static fn (array $error): bool => + $error['operation'] === 'delete' && $error['ack_fence'] === $fence); + self::assertNotEmpty($refusals, $diagnostic); + self::assertGreaterThan(time(), $remaining['reserved_at'] + $observed['retry_after_seconds'], $diagnostic); + } + } + + private static function assertRecoveredTask(array $delivery, array $tasks, string $runId, string $diagnostic): void + { + self::assertIsString($delivery['task_id'], $diagnostic); + self::assertArrayHasKey($delivery['task_id'], $tasks, $diagnostic); + $task = $tasks[$delivery['task_id']]; + self::assertSame($runId, $task['workflow_run_id'], $diagnostic); + self::assertSame('0', $task['namespace'], $diagnostic); + self::assertSame('default', $task['queue'], $diagnostic); + self::assertSame('completed', $task['status'], $diagnostic); + self::assertSame(1, $task['attempt_count'], $diagnostic); + } + private static function process(string $directory, string $mode, bool $enabled): Process { $process = new Process([ From 741332633a8f6f9d8d85f315211780e1955ba74c Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 14:39:56 +0000 Subject: [PATCH 46/77] Keep fan-out workers serving through the original observer --- tests/Fixtures/V2/tracing-process.php | 11 ++++++- .../V2/LargeFanOutExecutionTracingTest.php | 29 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index bfd889c18..c912ab19c 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -1034,6 +1034,15 @@ public function extract( $dispatchDelivered = $dispatchQueue(); } if ($mode === 'work-drain') { + $app['events']->listen(\Illuminate\Queue\Events\Looping::class, static function () use ( + $app, + $directory + ): void { + if (is_file($directory . '/stop-large-workers')) { + $worker = $app->make('queue.worker'); + $worker->shouldQuit = true; + } + }); file_put_contents($directory . '/ready-worker-' . getmypid(), 'ready'); $readyDeadline = hrtime(true) + 10_000_000_000; while (count(glob($directory . '/ready-worker-*') ?: []) < 4 && hrtime(true) < $readyDeadline) { @@ -1053,7 +1062,7 @@ public function extract( if ($mode === 'work-drain') { $workerOptions += [ '--max-jobs' => 128, - '--max-time' => 45, + '--max-time' => getenv('DW_TEST_TRACING_CONTINUOUS_WORKERS') === 'true' ? 0 : 45, '--stop-when-empty' => getenv('DW_TEST_TRACING_CONTINUOUS_WORKERS') !== 'true', ]; } diff --git a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php index 33ec54db2..d9d19696e 100644 --- a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php +++ b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php @@ -6,6 +6,8 @@ use Composer\Autoload\ClassLoader; use Illuminate\Filesystem\Filesystem; +use PDO; +use PDOException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\Process\Process; @@ -46,6 +48,8 @@ public function testConcurrentWorkersCompleteTheFanOutAndExposeMissingOutcomeCon $process->start(); } self::assertCount(4, array_filter($batch, static fn (Process $process): bool => $process->isRunning())); + self::observeCompletion($directory, $admitted['run_id'], $deadline); + file_put_contents($directory . '/stop-large-workers', 'stop'); foreach ($batch as $process) { $result = self::read($process); $spans = array_merge($spans, $result['spans']); @@ -192,10 +196,33 @@ private static function process(string $directory, string $mode, bool $enabled): 'DW_TEST_TRACING_CONTINUOUS_WORKERS' => 'true', 'DW_TEST_TRACING_ENABLED' => $enabled ? 'true' : 'false', ]); - $process->setTimeout($mode === 'work-drain' ? 60 : 20); + $process->setTimeout($mode === 'work-drain' ? 90 : 20); return $process; } + private static function observeCompletion(string $directory, string $runId, float $deadline): void + { + $database = new PDO('sqlite:' . $directory . '/database.sqlite'); + $database->exec('PRAGMA busy_timeout=50'); + $query = $database->prepare('SELECT status FROM workflow_runs WHERE id = ?'); + do { + try { + $query->execute([$runId]); + $status = $query->fetchColumn(); + $query->closeCursor(); + if ($status === 'completed') { + return; + } + } catch (PDOException $exception) { + $code = $exception->errorInfo[1] ?? null; + if (! is_int($code) || ! in_array($code & 255, [5, 6], true)) { + throw $exception; + } + } + usleep(100_000); + } while (microtime(true) < $deadline); + } + private static function read(Process $process): array { if (! $process->isStarted()) { From 8fb2e3b410095991afca4ed2176c8af7a49776be Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 14:59:45 +0000 Subject: [PATCH 47/77] Share the original fan-out observer deadline across phases --- tests/Unit/V2/LargeFanOutExecutionTracingTest.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php index d9d19696e..ddf16d6cc 100644 --- a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php +++ b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php @@ -33,7 +33,7 @@ public function testConcurrentWorkersCompleteTheFanOutAndExposeMissingOutcomeCon self::assertTrue($admitted['large_fan_out']['matching_role']['queue_wake_enabled']); self::assertSame('in_worker', $admitted['large_fan_out']['matching_role']['shape']); $deadline = microtime(true) + 90; - $initial = self::read(self::process($directory, 'work', $enabled)); + $initial = self::read(self::process($directory, 'work', $enabled, $deadline)); self::assertCount(64, $initial['large_fan_out']['activities']); self::assertSame(64, $initial['large_fan_out']['ready_jobs']); $spans = $initial['spans']; @@ -44,7 +44,7 @@ public function testConcurrentWorkersCompleteTheFanOutAndExposeMissingOutcomeCon $pids = []; $queries = $initial['large_fan_out']['task_outcome_queries']; for ($index = 0; $index < 4; ++$index) { - $batch[] = $process = self::process($directory, 'work-drain', $enabled); + $batch[] = $process = self::process($directory, 'work-drain', $enabled, $deadline); $process->start(); } self::assertCount(4, array_filter($batch, static fn (Process $process): bool => $process->isRunning())); @@ -64,7 +64,7 @@ public function testConcurrentWorkersCompleteTheFanOutAndExposeMissingOutcomeCon $pids[] = $result['pid']; $queries += $result['large_fan_out']['task_outcome_queries']; } - $finished = self::read(self::process($directory, 'inspect', $enabled)); + $finished = self::read(self::process($directory, 'inspect', $enabled, $deadline)); $lastEvent = end($finished['large_fan_out']['history_payloads']); $diagnostic = json_encode([ 'run_id' => $finished['run_id'], @@ -181,7 +181,7 @@ private static function assertRecoveredTask(array $delivery, array $tasks, strin self::assertSame(1, $task['attempt_count'], $diagnostic); } - private static function process(string $directory, string $mode, bool $enabled): Process + private static function process(string $directory, string $mode, bool $enabled, ?float $deadline = null): Process { $process = new Process([ PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', @@ -196,7 +196,7 @@ private static function process(string $directory, string $mode, bool $enabled): 'DW_TEST_TRACING_CONTINUOUS_WORKERS' => 'true', 'DW_TEST_TRACING_ENABLED' => $enabled ? 'true' : 'false', ]); - $process->setTimeout($mode === 'work-drain' ? 90 : 20); + $process->setTimeout($deadline === null ? 20 : max(0.001, $deadline - microtime(true))); return $process; } From f14114225e5413fc42ab27303cfd9f74f3ae033e Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 15:34:31 +0000 Subject: [PATCH 48/77] Fix contended tracing observer reads and retain bounded failure state --- .../V2/LargeFanOutExecutionTracingTest.php | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php index ddf16d6cc..dc38441be 100644 --- a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php +++ b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php @@ -10,7 +10,9 @@ use PDOException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use RuntimeException; use Symfony\Component\Process\Process; +use Throwable; final class LargeFanOutExecutionTracingTest extends TestCase { @@ -124,6 +126,11 @@ public function testConcurrentWorkersCompleteTheFanOutAndExposeMissingOutcomeCon self::assertTraceWindow($spans, $observed, $enabled, $queries); self::assertFalse($queueDiagnosticsTruncated, $diagnostic); self::assertSupportedQueueRecovery($finished, $observed, $queueErrors, $reportedQueueErrors, $diagnostic); + } catch (Throwable $exception) { + throw new RuntimeException( + $exception->getMessage() . ' Read-only failure snapshot: ' . self::failureSnapshot($directory), + previous: $exception, + ); } finally { foreach ($batch as $process) { $process->stop(1, 9); @@ -204,9 +211,11 @@ private static function observeCompletion(string $directory, string $runId, floa { $database = new PDO('sqlite:' . $directory . '/database.sqlite'); $database->exec('PRAGMA busy_timeout=50'); - $query = $database->prepare('SELECT status FROM workflow_runs WHERE id = ?'); do { + $query = null; try { + // A statement whose execution returned SQLITE_BUSY cannot be reused safely by PDO SQLite. + $query = $database->prepare('SELECT status FROM workflow_runs WHERE id = ?'); $query->execute([$runId]); $status = $query->fetchColumn(); $query->closeCursor(); @@ -218,11 +227,35 @@ private static function observeCompletion(string $directory, string $runId, floa if (! is_int($code) || ! in_array($code & 255, [5, 6], true)) { throw $exception; } + } finally { + $query = null; } usleep(100_000); } while (microtime(true) < $deadline); } + private static function failureSnapshot(string $directory): string + { + try { + $database = new PDO('sqlite:file:' . $directory . '/database.sqlite?mode=ro'); + $database->exec('PRAGMA busy_timeout=25'); + $database->exec('PRAGMA query_only=ON'); + $snapshot = []; + foreach (['workflow_runs', 'workflow_tasks', 'activity_executions'] as $table) { + $snapshot[$table] = $database->query('SELECT status, COUNT(*) AS count FROM ' . $table + . ' GROUP BY status')->fetchAll(PDO::FETCH_ASSOC); + } + $snapshot['jobs'] = (int) $database->query('SELECT COUNT(*) FROM jobs')->fetchColumn(); + $snapshot['failed_jobs'] = (int) $database->query('SELECT COUNT(*) FROM failed_jobs')->fetchColumn(); + + return json_encode($snapshot, JSON_THROW_ON_ERROR); + } catch (Throwable $exception) { + return json_encode([ + 'unavailable' => $exception->getMessage(), + ], JSON_THROW_ON_ERROR); + } + } + private static function read(Process $process): array { if (! $process->isStarted()) { From e171f48e878491b13106c5f270aba53b6dae4998 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 22:29:37 +0000 Subject: [PATCH 49/77] Separate timer resolution and retry-policy rejection paths --- src/V2/Jobs/RunTimerTask.php | 169 ++++++++++++++++------------ src/V2/Support/WorkflowExecutor.php | 36 +++--- 2 files changed, 120 insertions(+), 85 deletions(-) diff --git a/src/V2/Jobs/RunTimerTask.php b/src/V2/Jobs/RunTimerTask.php index 10a7535f8..a14aa7180 100644 --- a/src/V2/Jobs/RunTimerTask.php +++ b/src/V2/Jobs/RunTimerTask.php @@ -160,45 +160,7 @@ private function executeClaim(string $timerId): void return null; } - $cancelledInHistory = $run->historyEvents->contains(static fn (WorkflowHistoryEvent $event): bool => - $event->event_type === HistoryEventType::TimerCancelled - && ($event->payload['timer_id'] ?? null) === $timerId); - $firedInHistory = $run->historyEvents->contains(static fn (WorkflowHistoryEvent $event): bool => - $event->event_type === HistoryEventType::TimerFired - && ($event->payload['timer_id'] ?? null) === $timerId); - if ( - in_array($run->status, [RunStatus::Cancelled, RunStatus::Terminated], true) - || $timer->status === TimerStatus::Cancelled - || $cancelledInHistory - ) { - $task->forceFill([ - 'status' => $task->status === TaskStatus::Cancelled || $cancelledInHistory - ? TaskStatus::Cancelled : TaskStatus::Completed, - 'lease_owner' => null, - 'lease_expires_at' => null, - ])->save(); - - if ($timer->status !== TimerStatus::Cancelled) { - $timer->forceFill([ - 'status' => TimerStatus::Cancelled, - ])->save(); - } - - $this->projectRun($run, self::PROJECTION_RUN_RELATIONS); - - return null; - } - - if ($firedInHistory) { - $task->forceFill([ - 'status' => TaskStatus::Completed, - 'lease_owner' => null, - 'lease_expires_at' => null, - ])->save(); - $timer->forceFill([ - 'status' => TimerStatus::Fired, - ])->save(); - $this->projectRun($run, self::PROJECTION_RUN_RELATIONS); + if ($this->finishClosedTimer($run, $task, $timer, $timerId)) { return null; } @@ -206,29 +168,7 @@ private function executeClaim(string $timerId): void 'status' => TimerStatus::Fired, 'fired_at' => now(), ])->save(); - $conditionWaitId = is_string($task->payload['condition_wait_id'] ?? null) - ? $task->payload['condition_wait_id'] - : null; - $conditionWaitOccurrenceId = is_string($task->payload['condition_wait_occurrence_id'] ?? null) - ? $task->payload['condition_wait_occurrence_id'] - : null; - $conditionKey = is_string($task->payload['condition_key'] ?? null) - ? $task->payload['condition_key'] - : null; - $conditionDefinitionFingerprint = is_string($task->payload['condition_definition_fingerprint'] ?? null) - ? $task->payload['condition_definition_fingerprint'] - : null; - $signalWaitId = is_string($task->payload['signal_wait_id'] ?? null) - ? $task->payload['signal_wait_id'] - : null; - $signalName = is_string($task->payload['signal_name'] ?? null) - ? $task->payload['signal_name'] - : null; - $timerKind = match (true) { - $conditionWaitId !== null => 'condition_timeout', - $signalWaitId !== null => 'signal_timeout', - default => null, - }; + $waitMetadata = self::timerWaitMetadata($task); $parallelMetadataPath = ParallelChildGroup::metadataPathFromPayload($task->payload); $parallelMetadata = ParallelChildGroup::payloadForPath($parallelMetadataPath); @@ -238,13 +178,7 @@ private function executeClaim(string $timerId): void 'delay_seconds' => $timer->delay_seconds, 'fire_at' => $timer->fire_at?->toJSON(), 'fired_at' => $timer->fired_at?->toJSON(), - 'timer_kind' => $timerKind, - 'condition_wait_id' => $conditionWaitId, - 'condition_wait_occurrence_id' => $conditionWaitOccurrenceId, - 'condition_key' => $conditionKey, - 'condition_definition_fingerprint' => $conditionDefinitionFingerprint, - 'signal_wait_id' => $signalWaitId, - 'signal_name' => $signalName, + ...$waitMetadata, ...$parallelMetadata, ], static fn (mixed $value): bool => $value !== null), $task); @@ -254,8 +188,8 @@ private function executeClaim(string $timerId): void ])->save(); $operationKind = match (true) { - $conditionWaitId !== null => 'condition', - $signalWaitId !== null => 'signal', + $waitMetadata['condition_wait_id'] !== null => 'condition', + $waitMetadata['signal_wait_id'] !== null => 'signal', default => 'timer', }; if ($parallelMetadataPath !== []) { @@ -299,6 +233,99 @@ private function executeClaim(string $timerId): void } } + private function finishClosedTimer( + WorkflowRun $run, + WorkflowTask $task, + WorkflowTimer $timer, + string $timerId, + ): bool { + $cancelledInHistory = $run->historyEvents->contains(static fn (WorkflowHistoryEvent $event): bool => + $event->event_type === HistoryEventType::TimerCancelled + && ($event->payload['timer_id'] ?? null) === $timerId); + $firedInHistory = $run->historyEvents->contains(static fn (WorkflowHistoryEvent $event): bool => + $event->event_type === HistoryEventType::TimerFired + && ($event->payload['timer_id'] ?? null) === $timerId); + if ( + in_array($run->status, [RunStatus::Cancelled, RunStatus::Terminated], true) + || $timer->status === TimerStatus::Cancelled + || $cancelledInHistory + ) { + $task->forceFill([ + 'status' => $task->status === TaskStatus::Cancelled || $cancelledInHistory + ? TaskStatus::Cancelled : TaskStatus::Completed, + 'lease_owner' => null, + 'lease_expires_at' => null, + ])->save(); + + if ($timer->status !== TimerStatus::Cancelled) { + $timer->forceFill([ + 'status' => TimerStatus::Cancelled, + ])->save(); + } + + $this->projectRun($run, self::PROJECTION_RUN_RELATIONS); + + return true; + } + + if ($firedInHistory) { + $task->forceFill([ + 'status' => TaskStatus::Completed, + 'lease_owner' => null, + 'lease_expires_at' => null, + ])->save(); + $timer->forceFill([ + 'status' => TimerStatus::Fired, + ])->save(); + $this->projectRun($run, self::PROJECTION_RUN_RELATIONS); + + return true; + } + + return false; + } + + /** + * @return array{timer_kind: string|null, condition_wait_id: string|null, + * condition_wait_occurrence_id: string|null, condition_key: string|null, + * condition_definition_fingerprint: string|null, signal_wait_id: string|null, signal_name: string|null} + */ + private static function timerWaitMetadata(WorkflowTask $task): array + { + $conditionWaitId = is_string($task->payload['condition_wait_id'] ?? null) + ? $task->payload['condition_wait_id'] + : null; + $conditionWaitOccurrenceId = is_string($task->payload['condition_wait_occurrence_id'] ?? null) + ? $task->payload['condition_wait_occurrence_id'] + : null; + $conditionKey = is_string($task->payload['condition_key'] ?? null) + ? $task->payload['condition_key'] + : null; + $conditionDefinitionFingerprint = is_string($task->payload['condition_definition_fingerprint'] ?? null) + ? $task->payload['condition_definition_fingerprint'] + : null; + $signalWaitId = is_string($task->payload['signal_wait_id'] ?? null) + ? $task->payload['signal_wait_id'] + : null; + $signalName = is_string($task->payload['signal_name'] ?? null) + ? $task->payload['signal_name'] + : null; + + return [ + 'timer_kind' => match (true) { + $conditionWaitId !== null => 'condition_timeout', + $signalWaitId !== null => 'signal_timeout', + default => null, + }, + 'condition_wait_id' => $conditionWaitId, + 'condition_wait_occurrence_id' => $conditionWaitOccurrenceId, + 'condition_key' => $conditionKey, + 'condition_definition_fingerprint' => $conditionDefinitionFingerprint, + 'signal_wait_id' => $signalWaitId, + 'signal_name' => $signalName, + ]; + } + /** * @return array{0: WorkflowTask|null, 1: int|null} */ diff --git a/src/V2/Support/WorkflowExecutor.php b/src/V2/Support/WorkflowExecutor.php index 97c79f285..d918d5fbe 100644 --- a/src/V2/Support/WorkflowExecutor.php +++ b/src/V2/Support/WorkflowExecutor.php @@ -4375,20 +4375,7 @@ private function failRun( } if ($throwable instanceof ActivityRetryPolicyMismatchException) { - $payload = is_array($task->payload) ? $task->payload : []; - $payload['replay_blocked'] = true; - $payload['replay_blocked_reason'] = 'activity_retry_policy_mismatch'; - $payload['replay_blocked_workflow_sequence'] = $throwable->workflowSequence; - $payload['replay_blocked_policy_field'] = $throwable->policyField; - $task->forceFill([ - 'status' => TaskStatus::Failed, - 'payload' => $payload, - 'last_error' => $throwable->getMessage(), - 'lease_expires_at' => null, - ])->save(); - $this->projectRun( - $run->fresh(['instance', 'tasks', 'activityExecutions', 'timers', 'failures', 'historyEvents']) - ); + $this->blockReplayUntilCompatibleActivityRetryPolicy($run, $task, $throwable); return; } @@ -4548,6 +4535,27 @@ private function ensureTypedStepHistoryRecorded( } } + private function blockReplayUntilCompatibleActivityRetryPolicy( + WorkflowRun $run, + WorkflowTask $task, + ActivityRetryPolicyMismatchException $throwable, + ): void { + $payload = is_array($task->payload) ? $task->payload : []; + $payload['replay_blocked'] = true; + $payload['replay_blocked_reason'] = 'activity_retry_policy_mismatch'; + $payload['replay_blocked_workflow_sequence'] = $throwable->workflowSequence; + $payload['replay_blocked_policy_field'] = $throwable->policyField; + $task->forceFill([ + 'status' => TaskStatus::Failed, + 'payload' => $payload, + 'last_error' => $throwable->getMessage(), + 'lease_expires_at' => null, + ])->save(); + $this->projectRun( + $run->fresh(['instance', 'tasks', 'activityExecutions', 'timers', 'failures', 'historyEvents']) + ); + } + private function blockReplayUntilCompatibleVersionRange( WorkflowRun $run, WorkflowTask $task, From 53dfba610d0297b766b61be738dad1b61214e4cc Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 22:49:28 +0000 Subject: [PATCH 50/77] Prepare the HTTP tracing schema before request admission --- tests/Fixtures/V2/tracing-process.php | 38 +++++++++++++++++------ tests/Unit/V2/IncomingHttpTracingTest.php | 10 +++++- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index c912ab19c..9efcd34fc 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -405,6 +405,21 @@ protected function defineEnvironment($app): void } } +function prepareTracingSchema(): void +{ + if (Artisan::call('migrate', [ + '--force' => true, + ]) !== 0) { + throw new \RuntimeException(Artisan::output()); + } + DB::statement('CREATE TABLE jobs (id INTEGER PRIMARY KEY AUTOINCREMENT, queue TEXT NOT NULL, + payload TEXT NOT NULL, attempts INTEGER NOT NULL, reserved_at INTEGER, + available_at INTEGER NOT NULL, created_at INTEGER NOT NULL)'); + DB::statement('CREATE TABLE failed_jobs (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, + connection TEXT NOT NULL, queue TEXT NOT NULL, payload TEXT NOT NULL, exception TEXT NOT NULL, + failed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)'); +} + $directory = $argv[1]; $mode = $argv[2]; foreach (['bootstrap/cache', 'storage/logs', 'storage/framework/views', 'config'] as $path) { @@ -424,6 +439,17 @@ protected function defineEnvironment($app): void ], ], ); +if ($mode === 'prepare-http') { + prepareTracingSchema(); + echo json_encode([ + 'workflow_runs' => WorkflowRun::query()->count(), + 'commands' => WorkflowCommand::query()->count(), + 'history_events' => WorkflowHistoryEvent::query()->count(), + 'tasks' => WorkflowTask::query()->count(), + 'jobs' => DB::table('jobs')->count(), + ], JSON_THROW_ON_ERROR), PHP_EOL; + exit(0); +} $queueErrors = []; $reportedQueueErrors = []; $queueDiagnosticsTruncated = false; @@ -700,17 +726,9 @@ public function extract( $childOutcomeControl = null; try { if ($mode === 'admit') { - if (Artisan::call('migrate', [ - '--force' => true, - ]) !== 0) { - throw new \RuntimeException(Artisan::output()); + if (! $incomingHttp) { + prepareTracingSchema(); } - DB::statement('CREATE TABLE jobs (id INTEGER PRIMARY KEY AUTOINCREMENT, queue TEXT NOT NULL, - payload TEXT NOT NULL, attempts INTEGER NOT NULL, reserved_at INTEGER, - available_at INTEGER NOT NULL, created_at INTEGER NOT NULL)'); - DB::statement('CREATE TABLE failed_jobs (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, - connection TEXT NOT NULL, queue TEXT NOT NULL, payload TEXT NOT NULL, exception TEXT NOT NULL, - failed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)'); if ($request !== null) { $app['router']->post('/start', static function () use ($retry): JsonResponse { $stub = WorkflowStub::make(TracingProcessWorkflow::class, 'cold-tracing', '0'); diff --git a/tests/Unit/V2/IncomingHttpTracingTest.php b/tests/Unit/V2/IncomingHttpTracingTest.php index af4fab2a1..4b6414317 100644 --- a/tests/Unit/V2/IncomingHttpTracingTest.php +++ b/tests/Unit/V2/IncomingHttpTracingTest.php @@ -30,8 +30,16 @@ public function testRequestContextSurvivesServerExitAndTwoIndependentActivityWor 'DW_TEST_TRACING_AUTOLOADS' => json_encode($autoloads, JSON_THROW_ON_ERROR), ]); $server->setTimeout(10); - $server->start(); try { + $prepared = $this->worker($directory, 'prepare-http', $autoloads); + self::assertSame([ + 'workflow_runs' => 0, + 'commands' => 0, + 'history_events' => 0, + 'tasks' => 0, + 'jobs' => 0, + ], $prepared); + $server->start(); self::assertTrue($server->waitUntil(static fn (string $type, string $output): bool => str_contains( $output, 'Development Server' From 05fb0cf87a0b719b05fa279b757f427604beccef Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 23:09:08 +0000 Subject: [PATCH 51/77] Extract duplicate start receipts and initial run setup --- src/V2/WorkflowStub.php | 196 +++++++++++++++++++++------------------- 1 file changed, 103 insertions(+), 93 deletions(-) diff --git a/src/V2/WorkflowStub.php b/src/V2/WorkflowStub.php index 759c119d3..d585dba3c 100644 --- a/src/V2/WorkflowStub.php +++ b/src/V2/WorkflowStub.php @@ -1693,71 +1693,17 @@ private function admitStart( $run = $this->currentRunForInstance($instance, true); if ($run instanceof WorkflowRun) { - $canReturnExisting = $startOptions->duplicateStartPolicy === DuplicateStartPolicy::ReturnExistingActive - && in_array($run->status, [RunStatus::Pending, RunStatus::Running, RunStatus::Waiting], true); - - /** @var WorkflowCommand $command */ - $command = WorkflowCommand::record($instance, $run, $this->commandAttributes([ - 'command_type' => CommandType::Start->value, - 'target_scope' => 'instance', - 'status' => $canReturnExisting - ? CommandStatus::Accepted->value - : CommandStatus::Rejected->value, - 'outcome' => $canReturnExisting - ? CommandOutcome::ReturnedExistingActive->value - : CommandOutcome::RejectedDuplicate->value, - 'payload_codec' => $run->payload_codec ?? CodecRegistry::defaultCodec(), - 'payload' => Serializer::serializeWithCodec( - $run->payload_codec ?? CodecRegistry::defaultCodec(), - $metadata->arguments - ), - 'rejection_reason' => $canReturnExisting - ? null - : 'instance_already_started', - 'accepted_at' => $canReturnExisting - ? now() - : null, - 'applied_at' => $canReturnExisting - ? now() - : null, - 'rejected_at' => $canReturnExisting - ? null - : now(), - ])); - - WorkflowHistoryEvent::record($run, $canReturnExisting - ? HistoryEventType::StartAccepted - : HistoryEventType::StartRejected, [ - 'workflow_command_id' => $command->id, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'workflow_class' => $run->workflow_class, - 'workflow_type' => $run->workflow_type, - 'business_key' => $run->business_key, - 'visibility_labels' => $run->visibility_labels, - 'memo' => AvroValueJsonProjection::project($run->typedMemos()), - 'search_attributes' => $run->typedSearchAttributes(), - 'outcome' => $command->outcome?->value, - 'rejection_reason' => $command->rejection_reason, - ], null, $command); - - self::projectRun($run, self::PROJECTION_RUN_RELATIONS); + $command = $this->recordExistingStart($instance, $run, $metadata, $startOptions); return; } $workflowClass = TypeRegistry::resolveWorkflowClass($instance->workflow_class, $instance->workflow_type); $commandContract = RunCommandContract::snapshot($workflowClass); - $businessKey = $startOptions->businessKey ?? $instance->business_key; - $visibilityLabels = $startOptions->labels !== [] - ? $startOptions->labels - : (is_array($instance->visibility_labels) ? $instance->visibility_labels : null); - $memo = $startOptions->memo !== [] - ? $startOptions->memo - : (is_array($instance->memo) ? $instance->memo : null); - $searchAttributes = $startOptions->searchAttributes !== [] - ? $startOptions->searchAttributes - : null; + [$businessKey, $visibilityLabels, $memo, $searchAttributes] = $this->initialVisibilityMetadata( + $instance, + $startOptions, + ); $executionTimeoutSeconds = $startOptions->executionTimeoutSeconds; $runTimeoutSeconds = $startOptions->runTimeoutSeconds; $connection = RoutingResolver::workflowConnection($workflowClass, $metadata); @@ -1896,18 +1842,7 @@ private function admitStart( 'declared_entry_declaring_class' => $commandContract['entry_declaring_class'], ], null, $command); - /** @var WorkflowTask $task */ - $task = self::taskQuery()->create([ - 'workflow_run_id' => $run->id, - 'namespace' => $run->namespace, - 'task_type' => TaskType::Workflow->value, - 'status' => TaskStatus::Ready->value, - 'available_at' => now(), - 'payload' => [], - 'connection' => $run->connection, - 'queue' => $run->queue, - 'compatibility' => $run->compatibility, - ]); + $task = $this->createInitialWorkflowTask($run); self::projectRun($run, ['instance', 'tasks', 'activityExecutions', 'failures', 'historyEvents']); @@ -1943,6 +1878,98 @@ private function admitStart( return StartResult::fromCommand($command); } + private function recordExistingStart( + WorkflowInstance $instance, + WorkflowRun $run, + WorkflowMetadata $metadata, + StartOptions $startOptions, + ): WorkflowCommand { + $canReturnExisting = $startOptions->duplicateStartPolicy === DuplicateStartPolicy::ReturnExistingActive + && in_array($run->status, [RunStatus::Pending, RunStatus::Running, RunStatus::Waiting], true); + + $command = WorkflowCommand::record($instance, $run, $this->commandAttributes([ + 'command_type' => CommandType::Start->value, + 'target_scope' => 'instance', + 'status' => $canReturnExisting + ? CommandStatus::Accepted->value + : CommandStatus::Rejected->value, + 'outcome' => $canReturnExisting + ? CommandOutcome::ReturnedExistingActive->value + : CommandOutcome::RejectedDuplicate->value, + 'payload_codec' => $run->payload_codec ?? CodecRegistry::defaultCodec(), + 'payload' => Serializer::serializeWithCodec( + $run->payload_codec ?? CodecRegistry::defaultCodec(), + $metadata->arguments + ), + 'rejection_reason' => $canReturnExisting + ? null + : 'instance_already_started', + 'accepted_at' => $canReturnExisting + ? now() + : null, + 'applied_at' => $canReturnExisting + ? now() + : null, + 'rejected_at' => $canReturnExisting + ? null + : now(), + ])); + + WorkflowHistoryEvent::record($run, $canReturnExisting + ? HistoryEventType::StartAccepted + : HistoryEventType::StartRejected, [ + 'workflow_command_id' => $command->id, + 'workflow_instance_id' => $instance->id, + 'workflow_run_id' => $run->id, + 'workflow_class' => $run->workflow_class, + 'workflow_type' => $run->workflow_type, + 'business_key' => $run->business_key, + 'visibility_labels' => $run->visibility_labels, + 'memo' => AvroValueJsonProjection::project($run->typedMemos()), + 'search_attributes' => $run->typedSearchAttributes(), + 'outcome' => $command->outcome?->value, + 'rejection_reason' => $command->rejection_reason, + ], null, $command); + + self::projectRun($run, self::PROJECTION_RUN_RELATIONS); + + return $command; + } + + /** + * @return array{mixed, array|null, array|null, array|null} + */ + private function initialVisibilityMetadata(WorkflowInstance $instance, StartOptions $startOptions): array + { + $businessKey = $startOptions->businessKey ?? $instance->business_key; + $visibilityLabels = $startOptions->labels !== [] + ? $startOptions->labels + : (is_array($instance->visibility_labels) ? $instance->visibility_labels : null); + $memo = $startOptions->memo !== [] + ? $startOptions->memo + : (is_array($instance->memo) ? $instance->memo : null); + $searchAttributes = $startOptions->searchAttributes !== [] + ? $startOptions->searchAttributes + : null; + + return [$businessKey, $visibilityLabels, $memo, $searchAttributes]; + } + + private function createInitialWorkflowTask(WorkflowRun $run): WorkflowTask + { + return self::taskQuery()->create([ + 'workflow_run_id' => $run->id, + 'namespace' => $run->namespace, + 'task_type' => TaskType::Workflow->value, + 'status' => TaskStatus::Ready->value, + 'available_at' => now(), + 'payload' => [], + 'connection' => $run->connection, + 'queue' => $run->queue, + 'compatibility' => $run->compatibility, + ]); + } + /** * @param array{scope_id: string, preparation_history_event_id: string, child_call_id: string}|null $scopeTarget */ @@ -3322,16 +3349,10 @@ private function admitSignalWithStart( $signalArguments = $validatedSignalArguments['arguments']; $metadata = WorkflowMetadata::fromStartArguments($startArguments); $commandContract = RunCommandContract::snapshot($workflowClass); - $businessKey = $startOptions->businessKey ?? $instance->business_key; - $visibilityLabels = $startOptions->labels !== [] - ? $startOptions->labels - : (is_array($instance->visibility_labels) ? $instance->visibility_labels : null); - $memo = $startOptions->memo !== [] - ? $startOptions->memo - : (is_array($instance->memo) ? $instance->memo : null); - $searchAttributes = $startOptions->searchAttributes !== [] - ? $startOptions->searchAttributes - : null; + [$businessKey, $visibilityLabels, $memo, $searchAttributes] = $this->initialVisibilityMetadata( + $instance, + $startOptions, + ); $executionTimeoutSeconds = $startOptions->executionTimeoutSeconds; $runTimeoutSeconds = $startOptions->runTimeoutSeconds; $connection = RoutingResolver::workflowConnection($workflowClass, $metadata); @@ -3485,18 +3506,7 @@ private function admitSignalWithStart( 'declared_entry_declaring_class' => $commandContract['entry_declaring_class'], ], null, $startCommand); - /** @var WorkflowTask $task */ - $task = self::taskQuery()->create([ - 'workflow_run_id' => $run->id, - 'namespace' => $run->namespace, - 'task_type' => TaskType::Workflow->value, - 'status' => TaskStatus::Ready->value, - 'available_at' => now(), - 'payload' => [], - 'connection' => $run->connection, - 'queue' => $run->queue, - 'compatibility' => $run->compatibility, - ]); + $task = $this->createInitialWorkflowTask($run); /** @var WorkflowCommand $signalCommand */ $signalCommand = WorkflowCommand::record($instance, $run, $this->commandAttributesForContext( From 4456b38adb2ef5e3185860ee7f8299e05c94b1a2 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 23:31:52 +0000 Subject: [PATCH 52/77] Separate atomic start and signal admission steps --- src/V2/WorkflowStub.php | 1282 +++++++++++++++++++++------------------ 1 file changed, 693 insertions(+), 589 deletions(-) diff --git a/src/V2/WorkflowStub.php b/src/V2/WorkflowStub.php index d585dba3c..ad02aebc6 100644 --- a/src/V2/WorkflowStub.php +++ b/src/V2/WorkflowStub.php @@ -6,6 +6,7 @@ use BadMethodCallException; use Carbon\CarbonImmutable; +use Carbon\CarbonInterface; use Illuminate\Support\Collection; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\DB; @@ -1708,25 +1709,8 @@ private function admitStart( $runTimeoutSeconds = $startOptions->runTimeoutSeconds; $connection = RoutingResolver::workflowConnection($workflowClass, $metadata); $queue = RoutingResolver::workflowQueue($workflowClass, $metadata); - $startBlockedReason = WorkflowStartGate::blockedReason( - WorkerCompatibility::current(), - $connection, - $queue, - ); - - if ($startBlockedReason !== null) { - $command = $this->rejectStartCommand( - $instance, - $metadata->arguments, - $startBlockedReason, - WorkflowStartGate::blockedMessage( - sprintf('Workflow instance [%s] cannot start.', $instance->id), - WorkerCompatibility::current(), - $connection, - $queue, - ) ?? sprintf('Workflow instance [%s] cannot start.', $instance->id), - ); - + $command = $this->rejectIncompatibleStart($instance, $metadata->arguments, $connection, $queue); + if ($command instanceof WorkflowCommand) { return; } @@ -1778,17 +1762,7 @@ private function admitStart( $this->seedTypedVisibilityMetadata($run, $memo, $searchAttributes); - /** @var WorkflowCommand $command */ - $command = WorkflowCommand::record($instance, $run, $this->commandAttributes([ - 'command_type' => CommandType::Start->value, - 'target_scope' => 'instance', - 'status' => CommandStatus::Accepted->value, - 'outcome' => CommandOutcome::StartedNew->value, - 'payload_codec' => $payloadCodec, - 'payload' => $serializedArguments, - 'accepted_at' => now(), - 'applied_at' => now(), - ])); + $command = $this->recordNewStartCommand($instance, $run, $payloadCodec, $serializedArguments); $instance->forceFill([ 'current_run_id' => $run->id, @@ -1803,44 +1777,15 @@ private function admitStart( $memoPayload = self::visibilityMetadataPayload($memo); $searchAttributesPayload = self::visibilityMetadataPayload($searchAttributes); - WorkflowHistoryEvent::record($run, HistoryEventType::StartAccepted, [ - 'workflow_command_id' => $command->id, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'workflow_class' => $run->workflow_class, - 'workflow_type' => $run->workflow_type, - 'business_key' => $run->business_key, - 'visibility_labels' => $run->visibility_labels, - 'memo' => $memoPayload, - 'search_attributes' => $searchAttributesPayload, - 'outcome' => $command->outcome?->value, - ], null, $command); - - WorkflowHistoryEvent::record($run, HistoryEventType::WorkflowStarted, [ - 'workflow_class' => $run->workflow_class, - 'workflow_type' => $run->workflow_type, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'workflow_command_id' => $command->id, - 'business_key' => $run->business_key, - 'visibility_labels' => $run->visibility_labels, + $this->recordInitialStartAccepted($instance, $run, $command, $memoPayload, $searchAttributesPayload); + $this->recordWorkflowStartedHistory($instance, $run, $command, $workflowClass, $commandContract, [ 'memo' => $memoPayload, 'search_attributes' => $searchAttributesPayload, 'execution_timeout_seconds' => $executionTimeoutSeconds, 'run_timeout_seconds' => $runTimeoutSeconds, - 'execution_deadline_at' => $executionDeadlineAt?->toIso8601String(), - 'run_deadline_at' => $runDeadlineAt?->toIso8601String(), - 'workflow_definition_fingerprint' => WorkflowDefinition::fingerprint($workflowClass), - 'declared_queries' => $commandContract['queries'], - 'declared_query_contracts' => $commandContract['query_contracts'], - 'declared_signals' => $commandContract['signals'], - 'declared_signal_contracts' => $commandContract['signal_contracts'], - 'declared_updates' => $commandContract['updates'], - 'declared_update_contracts' => $commandContract['update_contracts'], - 'declared_entry_method' => $commandContract['entry_method'], - 'declared_entry_mode' => $commandContract['entry_mode'], - 'declared_entry_declaring_class' => $commandContract['entry_declaring_class'], - ], null, $command); + 'execution_deadline_at' => $executionDeadlineAt, + 'run_deadline_at' => $runDeadlineAt, + ]); $task = $this->createInitialWorkflowTask($run); @@ -1848,14 +1793,7 @@ private function admitStart( $startedRun = $run; }, self::storageTransactionAttempts(), admission: function (): void { - $instance = self::instanceQuery(); - $key = $instance->getModel() - ->getKeyName(); - $instance->whereKey($this->instance->id) - ->toBase() - ->update([ - $key => $this->instance->id, - ]); + $this->acquireInstanceWriter(); }); if ($startedRun instanceof WorkflowRun) { @@ -1970,6 +1908,132 @@ private function createInitialWorkflowTask(WorkflowRun $run): WorkflowTask ]); } + private function acquireInstanceWriter(): void + { + $instance = self::instanceQuery(); + $key = $instance->getModel() + ->getKeyName(); + $instance->whereKey($this->instance->id) + ->toBase() + ->update([ + $key => $this->instance->id, + ]); + } + + private function rejectIncompatibleStart( + WorkflowInstance $instance, + array $arguments, + ?string $connection, + ?string $queue, + ): ?WorkflowCommand { + $reason = WorkflowStartGate::blockedReason(WorkerCompatibility::current(), $connection, $queue); + if ($reason === null) { + return null; + } + + return $this->rejectStartCommand( + $instance, + $arguments, + $reason, + WorkflowStartGate::blockedMessage( + sprintf('Workflow instance [%s] cannot start.', $instance->id), + WorkerCompatibility::current(), + $connection, + $queue, + ) ?? sprintf('Workflow instance [%s] cannot start.', $instance->id), + ); + } + + private function recordInitialStartAccepted( + WorkflowInstance $instance, + WorkflowRun $run, + WorkflowCommand $command, + ?array $memo, + ?array $searchAttributes, + ): void { + WorkflowHistoryEvent::record($run, HistoryEventType::StartAccepted, [ + 'workflow_command_id' => $command->id, + 'workflow_instance_id' => $instance->id, + 'workflow_run_id' => $run->id, + 'workflow_class' => $run->workflow_class, + 'workflow_type' => $run->workflow_type, + 'business_key' => $run->business_key, + 'visibility_labels' => $run->visibility_labels, + 'memo' => $memo, + 'search_attributes' => $searchAttributes, + 'outcome' => $command->outcome?->value, + ], null, $command); + } + + private function recordNewStartCommand( + WorkflowInstance $instance, + WorkflowRun $run, + string $payloadCodec, + string $arguments, + ?CommandContext $commandContext = null, + ): WorkflowCommand { + $attributes = [ + 'command_type' => CommandType::Start->value, + 'target_scope' => 'instance', + 'status' => CommandStatus::Accepted->value, + 'outcome' => CommandOutcome::StartedNew->value, + 'payload_codec' => $payloadCodec, + 'payload' => $arguments, + 'accepted_at' => now(), + 'applied_at' => now(), + ]; + + return WorkflowCommand::record($instance, $run, $commandContext === null + ? $this->commandAttributes($attributes) + : $this->commandAttributesForContext($commandContext, $attributes)); + } + + /** + * @param array $commandContract + * @param array{ + * memo: array|null, + * search_attributes: array|null, + * execution_timeout_seconds: int|null, + * run_timeout_seconds: int|null, + * execution_deadline_at: CarbonInterface|null, + * run_deadline_at: CarbonInterface|null + * } $startMetadata + */ + private function recordWorkflowStartedHistory( + WorkflowInstance $instance, + WorkflowRun $run, + WorkflowCommand $command, + string $workflowClass, + array $commandContract, + array $startMetadata, + ): void { + WorkflowHistoryEvent::record($run, HistoryEventType::WorkflowStarted, [ + 'workflow_class' => $run->workflow_class, + 'workflow_type' => $run->workflow_type, + 'workflow_instance_id' => $instance->id, + 'workflow_run_id' => $run->id, + 'workflow_command_id' => $command->id, + 'business_key' => $run->business_key, + 'visibility_labels' => $run->visibility_labels, + 'memo' => $startMetadata['memo'], + 'search_attributes' => $startMetadata['search_attributes'], + 'execution_timeout_seconds' => $startMetadata['execution_timeout_seconds'], + 'run_timeout_seconds' => $startMetadata['run_timeout_seconds'], + 'execution_deadline_at' => $startMetadata['execution_deadline_at']?->toIso8601String(), + 'run_deadline_at' => $startMetadata['run_deadline_at']?->toIso8601String(), + 'workflow_definition_fingerprint' => WorkflowDefinition::fingerprint($workflowClass), + 'declared_queries' => $commandContract['queries'], + 'declared_query_contracts' => $commandContract['query_contracts'], + 'declared_signals' => $commandContract['signals'], + 'declared_signal_contracts' => $commandContract['signal_contracts'], + 'declared_updates' => $commandContract['updates'], + 'declared_update_contracts' => $commandContract['update_contracts'], + 'declared_entry_method' => $commandContract['entry_method'], + 'declared_entry_mode' => $commandContract['entry_mode'], + 'declared_entry_declaring_class' => $commandContract['entry_declaring_class'], + ], null, $command); + } + /** * @param array{scope_id: string, preparation_history_event_id: string, child_call_id: string}|null $scopeTarget */ @@ -2823,65 +2887,12 @@ private function attemptSignalInternal( ->lockForUpdate() ->findOrFail($this->instance->id); - $currentRun = $this->currentRunForInstance($instance, true); - - if (! $currentRun instanceof WorkflowRun) { - $command = $this->rejectCommand( - $instance, - null, - CommandType::Signal, - 'instance_not_started', - $this->commandTargetScope(), - $this->signalCommandPayloadAttributes($name, $arguments, [], $payloadCodec), - ); - $this->recordRejectedSignal($command, $name, $arguments); - - return; - } - - if ($this->runTargeted) { - /** @var WorkflowRun $run */ - $run = self::runQuery() - ->lockForUpdate() - ->findOrFail($this->selectedRunId); - - if ($run->id !== $currentRun->id) { - $command = $this->rejectCommand( - $instance, - $run, - CommandType::Signal, - 'selected_run_not_current', - $this->commandTargetScope(), - array_merge($this->signalCommandPayloadAttributes($name, $arguments, [], $payloadCodec), [ - 'resolved_workflow_run_id' => $currentRun->id, - ]), - ); - $this->recordRejectedSignal($command, $name, $arguments); - - return; - } - } else { - $run = $currentRun; - } - - if (in_array($run->status, [ - RunStatus::Completed, - RunStatus::Failed, - RunStatus::Cancelled, - RunStatus::Terminated, - ], true)) { - $command = $this->rejectCommand( - $instance, - $run, - CommandType::Signal, - 'run_not_active', - $this->commandTargetScope(), - $this->signalCommandPayloadAttributes($name, $arguments, [], $payloadCodec), - ); - $this->recordRejectedSignal($command, $name, $arguments); - + $target = $this->signalRunOrRejection($instance, $name, $arguments, $payloadCodec); + if ($target instanceof WorkflowCommand) { + $command = $target; return; } + $run = $target; $projectionRole = self::historyProjectionRole(); $bufferedSignalSummary = null; @@ -2971,57 +2982,20 @@ private function attemptSignalInternal( ); $serializedSignalArguments = $payloadBlob ?? Serializer::serializeWithCodec($signalCodec, $arguments); - StructuralLimits::logWarning( - StructuralLimits::warnApproachingPayloadSize($serializedSignalArguments), - [ - 'workflow_run_id' => $run->id, - 'workflow_type' => $run->workflow_type, - 'payload_site' => 'signal_input', - 'signal_name' => $name, - ], - ); - - try { - StructuralLimits::guardPayloadSize($serializedSignalArguments); - } catch (StructuralLimitExceededException $e) { - $command = $this->rejectCommand( - $instance, - $run, - CommandType::Signal, - 'structural_limit_exceeded', - $this->commandTargetScope(), - $this->payloadLimitExceededCommandPayloadAttributes($name, $signalCodec, $e), - ); - $this->recordRejectedSignal($command, $name, [], [ - 'arguments' => [$e->getMessage()], - ]); - + $command = $this->rejectOversizedSignal($instance, $run, $name, $signalCodec, $serializedSignalArguments); + if ($command instanceof WorkflowCommand) { return; } - $storedExternally = $payloadBlob !== null && ExternalPayloads::isStoredReference($payloadBlob); - $commandAttributes = $this->commandAttributes([ - 'command_type' => CommandType::Signal->value, - 'observability_context' => $observabilityContext, - 'target_scope' => $this->commandTargetScope(), - 'status' => CommandStatus::Accepted->value, - 'outcome' => CommandOutcome::SignalReceived->value, - ...($storedExternally - ? [ - 'payload_codec' => $signalCodec, - 'payload' => $payloadBlob, - ] - : $this->signalCommandPayloadAttributes($name, $arguments, [], $signalCodec)), - 'accepted_at' => now(), - ]); - if ($storedExternally) { - $commandAttributes['context'] = array_merge($commandAttributes['context'], [ - 'signal_name' => $name, - ]); - } - - /** @var WorkflowCommand $command */ - $command = WorkflowCommand::record($instance, $run, $commandAttributes); + $command = $this->recordAcceptedSignalCommand( + $instance, + $run, + $name, + $arguments, + $signalCodec, + $payloadBlob, + $observabilityContext, + ); $signalWaitId = $bufferedSignalSummary instanceof WorkflowRunSummary ? SignalWaits::bufferedWaitIdForCommandId($command->id) @@ -3037,20 +3011,7 @@ private function attemptSignalInternal( $serializedSignalArguments, ); - $event = WorkflowHistoryEvent::record($run, HistoryEventType::SignalReceived, array_filter([ - 'workflow_command_id' => $command->id, - 'signal_id' => $signal->id, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'signal_name' => $name, - 'signal_wait_id' => $signalWaitId, - 'arguments' => ExternalPayloads::historyValue( - is_string($signal->arguments) ? $signal->arguments : null, - is_string($signal->payload_codec) ? $signal->payload_codec : null, - is_string($run->namespace) ? $run->namespace : null, - ), - 'payload_codec' => $signal->payload_codec, - ], static fn (mixed $value): bool => $value !== null), null, $command); + $event = $this->recordSignalReceivedHistory($instance, $run, $command, $signal, $name, $signalWaitId); if (! $bufferedSignalSummary instanceof WorkflowRunSummary && ! $this->hasOpenWorkflowTask($run->id)) { $task = PendingMessageTask::createForRun($run); @@ -3065,14 +3026,7 @@ private function attemptSignalInternal( self::projectRun($run, self::PROJECTION_RUN_RELATIONS); } }, self::mysqlCommandTransactionAttempts(), admission: function (): void { - $instance = self::instanceQuery(); - $key = $instance->getModel() - ->getKeyName(); - $instance->whereKey($this->instance->id) - ->toBase() - ->update([ - $key => $this->instance->id, - ]); + $this->acquireInstanceWriter(); }); $this->refresh(); @@ -3097,53 +3051,213 @@ private function attemptSignalInternal( return $result; } - /** - * @param array $signalArguments - * @param list $startArguments - */ - private function attemptSignalWithStartInternal( + private function signalRunOrRejection( + WorkflowInstance $instance, string $name, - array $signalArguments, - array $startArguments, - StartOptions $startOptions, - ): SignalWithStartResult { - if ($name === '') { - throw new LogicException('Signal name cannot be empty.'); + array $arguments, + ?string $payloadCodec, + ): WorkflowRun|WorkflowCommand { + $currentRun = $this->currentRunForInstance($instance, true); + if (! $currentRun instanceof WorkflowRun) { + $command = $this->rejectCommand( + $instance, + null, + CommandType::Signal, + 'instance_not_started', + $this->commandTargetScope(), + $this->signalCommandPayloadAttributes($name, $arguments, [], $payloadCodec), + ); + $this->recordRejectedSignal($command, $name, $arguments); + + return $command; } - $this->refresh(); + if ($this->runTargeted) { + /** @var WorkflowRun $run */ + $run = self::runQuery() + ->lockForUpdate() + ->findOrFail($this->selectedRunId); - $intakeGroupId = (string) Str::ulid(); - // Capture before locking. An existing run retains its original admission carrier. - $observabilityContext = null; - $tracing = ExecutionTracing::enabled(); - if ($tracing) { - $observabilityContext = TraceCarrier::admissionParent(); + if ($run->id !== $currentRun->id) { + $command = $this->rejectCommand( + $instance, + $run, + CommandType::Signal, + 'selected_run_not_current', + $this->commandTargetScope(), + array_merge($this->signalCommandPayloadAttributes($name, $arguments, [], $payloadCodec), [ + 'resolved_workflow_run_id' => $currentRun->id, + ]), + ); + $this->recordRejectedSignal($command, $name, $arguments); + + return $command; + } + } else { + $run = $currentRun; } - $operation = fn (): SignalWithStartResult => $this->admitSignalWithStart( - $name, - $signalArguments, - $startArguments, - $startOptions, - $intakeGroupId, - $observabilityContext, - ); - if (! $tracing) { - return $operation(); + + if (in_array($run->status, [ + RunStatus::Completed, + RunStatus::Failed, + RunStatus::Cancelled, + RunStatus::Terminated, + ], true)) { + $command = $this->rejectCommand( + $instance, + $run, + CommandType::Signal, + 'run_not_active', + $this->commandTargetScope(), + $this->signalCommandPayloadAttributes($name, $arguments, [], $payloadCodec), + ); + $this->recordRejectedSignal($command, $name, $arguments); + + return $command; } - return ExecutionTracing::admission($this->instance, $operation, enabled: true); + return $run; } - private function admitSignalWithStart( + private function recordSignalReceivedHistory( + WorkflowInstance $instance, + WorkflowRun $run, + WorkflowCommand $command, + WorkflowSignal $signal, string $name, - array $signalArguments, - array $startArguments, - StartOptions $startOptions, - string $intakeGroupId, - ?array $observabilityContext, - ): SignalWithStartResult { - /** @var WorkflowCommand|null $startCommand */ + ?string $signalWaitId, + ): WorkflowHistoryEvent { + return WorkflowHistoryEvent::record($run, HistoryEventType::SignalReceived, array_filter([ + 'workflow_command_id' => $command->id, + 'signal_id' => $signal->id, + 'workflow_instance_id' => $instance->id, + 'workflow_run_id' => $run->id, + 'signal_name' => $name, + 'signal_wait_id' => $signalWaitId, + 'arguments' => ExternalPayloads::historyValue( + is_string($signal->arguments) ? $signal->arguments : null, + is_string($signal->payload_codec) ? $signal->payload_codec : null, + is_string($run->namespace) ? $run->namespace : null, + ), + 'payload_codec' => $signal->payload_codec, + ], static fn (mixed $value): bool => $value !== null), null, $command); + } + + private function recordAcceptedSignalCommand( + WorkflowInstance $instance, + WorkflowRun $run, + string $name, + array $arguments, + string $signalCodec, + ?string $payloadBlob, + ?array $observabilityContext, + ): WorkflowCommand { + $storedExternally = $payloadBlob !== null && ExternalPayloads::isStoredReference($payloadBlob); + $attributes = $this->commandAttributes([ + 'command_type' => CommandType::Signal->value, + 'observability_context' => $observabilityContext, + 'target_scope' => $this->commandTargetScope(), + 'status' => CommandStatus::Accepted->value, + 'outcome' => CommandOutcome::SignalReceived->value, + ...($storedExternally + ? [ + 'payload_codec' => $signalCodec, + 'payload' => $payloadBlob, + ] + : $this->signalCommandPayloadAttributes($name, $arguments, [], $signalCodec)), + 'accepted_at' => now(), + ]); + if ($storedExternally) { + $attributes['context'] = array_merge($attributes['context'], [ + 'signal_name' => $name, + ]); + } + + return WorkflowCommand::record($instance, $run, $attributes); + } + + private function rejectOversizedSignal( + WorkflowInstance $instance, + WorkflowRun $run, + string $name, + string $signalCodec, + string $arguments, + ): ?WorkflowCommand { + StructuralLimits::logWarning(StructuralLimits::warnApproachingPayloadSize($arguments), [ + 'workflow_run_id' => $run->id, + 'workflow_type' => $run->workflow_type, + 'payload_site' => 'signal_input', + 'signal_name' => $name, + ]); + + try { + StructuralLimits::guardPayloadSize($arguments); + } catch (StructuralLimitExceededException $e) { + $command = $this->rejectCommand( + $instance, + $run, + CommandType::Signal, + 'structural_limit_exceeded', + $this->commandTargetScope(), + $this->payloadLimitExceededCommandPayloadAttributes($name, $signalCodec, $e), + ); + $this->recordRejectedSignal($command, $name, [], [ + 'arguments' => [$e->getMessage()], + ]); + + return $command; + } + + return null; + } + + /** + * @param array $signalArguments + * @param list $startArguments + */ + private function attemptSignalWithStartInternal( + string $name, + array $signalArguments, + array $startArguments, + StartOptions $startOptions, + ): SignalWithStartResult { + if ($name === '') { + throw new LogicException('Signal name cannot be empty.'); + } + + $this->refresh(); + + $intakeGroupId = (string) Str::ulid(); + // Capture before locking. An existing run retains its original admission carrier. + $observabilityContext = null; + $tracing = ExecutionTracing::enabled(); + if ($tracing) { + $observabilityContext = TraceCarrier::admissionParent(); + } + $operation = fn (): SignalWithStartResult => $this->admitSignalWithStart( + $name, + $signalArguments, + $startArguments, + $startOptions, + $intakeGroupId, + $observabilityContext, + ); + if (! $tracing) { + return $operation(); + } + + return ExecutionTracing::admission($this->instance, $operation, enabled: true); + } + + private function admitSignalWithStart( + string $name, + array $signalArguments, + array $startArguments, + StartOptions $startOptions, + string $intakeGroupId, + ?array $observabilityContext, + ): SignalWithStartResult { + /** @var WorkflowCommand|null $startCommand */ $startCommand = null; /** @var WorkflowCommand|null $signalCommand */ $signalCommand = null; @@ -3176,406 +3290,396 @@ private function admitSignalWithStart( $currentRun = $this->currentRunForInstance($instance, true); if ($currentRun instanceof WorkflowRun && $this->runIsActive($currentRun)) { - $run = $currentRun; - - $this->loadLockedRunRelations($run, $instance); - - $signalAdmission = $this->signalAdmissionForRun($run, $name); - - if (($signalAdmission['allowed'] ?? false) !== true) { - $signalCommand = $this->rejectSignalCommandForContext( - $commandContext, - $instance, - $run, - $name, - $signalArguments, - 'unknown_signal', - [], - $signalAdmission['message'] ?? null, - $signalAdmission['payload'] ?? [], - ); - - return; - } - - $validatedSignalArguments = $this->validatedSignalArgumentsForRun($run, $name, $signalArguments); - - if ($validatedSignalArguments['validation_errors'] !== []) { - $signalCommand = $this->rejectSignalCommandForContext( - $commandContext, - $instance, - $run, - $name, - $signalArguments, - 'invalid_signal_arguments', - $validatedSignalArguments['validation_errors'], - ); - - return; - } - - $signalArguments = $validatedSignalArguments['arguments']; - - try { - StructuralLimits::guardPendingSignals($run); - } catch (StructuralLimitExceededException $e) { - $signalCommand = $this->rejectSignalCommandForContext( - $commandContext, - $instance, - $run, - $name, - $signalArguments, - 'structural_limit_exceeded', - ); - - return; - } - - $metadata = WorkflowMetadata::fromStartArguments($startArguments); - - /** @var WorkflowCommand $startCommand */ - $startCommand = WorkflowCommand::record($instance, $run, $this->commandAttributesForContext( - $commandContext, - [ - 'command_type' => CommandType::Start->value, - 'target_scope' => 'instance', - 'status' => CommandStatus::Accepted->value, - 'outcome' => CommandOutcome::ReturnedExistingActive->value, - 'payload_codec' => $run->payload_codec ?? CodecRegistry::defaultCodec(), - 'payload' => Serializer::serializeWithCodec( - $run->payload_codec ?? CodecRegistry::defaultCodec(), - $metadata->arguments - ), - 'accepted_at' => now(), - 'applied_at' => now(), - ], - )); - - WorkflowHistoryEvent::record($run, HistoryEventType::StartAccepted, [ - 'workflow_command_id' => $startCommand->id, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'workflow_class' => $run->workflow_class, - 'workflow_type' => $run->workflow_type, - 'business_key' => $run->business_key, - 'visibility_labels' => $run->visibility_labels, - 'memo' => AvroValueJsonProjection::project($run->typedMemos()), - 'outcome' => $startCommand->outcome?->value, - ], null, $startCommand); - - /** @var WorkflowCommand $signalCommand */ - $signalCommand = WorkflowCommand::record($instance, $run, $this->commandAttributesForContext( - $commandContext, - [ - 'command_type' => CommandType::Signal->value, - 'observability_context' => $observabilityContext, - 'target_scope' => 'instance', - 'status' => CommandStatus::Accepted->value, - 'outcome' => CommandOutcome::SignalReceived->value, - ...$this->signalCommandPayloadAttributes($name, $signalArguments), - 'accepted_at' => now(), - ], - )); - - $signalWaitId = $this->signalWaitIdForAcceptedCommand($run, $name, $signalCommand->id); - $signal = $this->recordAcceptedSignal( + [$startCommand, $signalCommand, $task] = $this->admitSignalWithExistingStart( $instance, - $run, - $signalCommand, - $name, - $signalArguments, - $signalWaitId, - ); - - WorkflowHistoryEvent::record($run, HistoryEventType::SignalReceived, array_filter([ - 'workflow_command_id' => $signalCommand->id, - 'signal_id' => $signal->id, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'signal_name' => $name, - 'signal_wait_id' => $signalWaitId, - 'arguments' => ExternalPayloads::historyValue( - is_string($signal->arguments) ? $signal->arguments : null, - is_string($signal->payload_codec) ? $signal->payload_codec : null, - is_string($run->namespace) ? $run->namespace : null, - ), - 'payload_codec' => $signal->payload_codec, - ], static fn (mixed $value): bool => $value !== null), null, $signalCommand); - - if (! $this->hasOpenWorkflowTask($run->id)) { - $task = PendingMessageTask::createForRun($run); - } - - self::projectRun($run, self::PROJECTION_RUN_RELATIONS); - - return; - } - - $workflowClass = TypeRegistry::resolveWorkflowClass($instance->workflow_class, $instance->workflow_type); - - if (! WorkflowDefinition::hasSignal($workflowClass, $name)) { - $signalCommand = $this->rejectSignalCommandForContext( + $currentRun, $commandContext, - $instance, - null, $name, $signalArguments, - 'unknown_signal', + $startArguments, + $observabilityContext, ); return; } - $validatedSignalArguments = $this->validatedSignalArgumentsForWorkflow( - $workflowClass, + [$startCommand, $signalCommand, $task, $startedRun] = $this->admitSignalWithNewRun( + $instance, + $commandContext, $name, - $signalArguments + $signalArguments, + $startArguments, + $startOptions, + $observabilityContext, ); + }, self::mysqlCommandTransactionAttempts() ?? 1); - if ($validatedSignalArguments['validation_errors'] !== []) { - $signalCommand = $this->rejectSignalCommandForContext( - $commandContext, - $instance, - null, - $name, - $signalArguments, - 'invalid_signal_arguments', - $validatedSignalArguments['validation_errors'], - ); - - return; - } + if ($startedRun instanceof WorkflowRun) { + LifecycleEventDispatcher::workflowStarted($startedRun); + } - $signalArguments = $validatedSignalArguments['arguments']; - $metadata = WorkflowMetadata::fromStartArguments($startArguments); - $commandContract = RunCommandContract::snapshot($workflowClass); - [$businessKey, $visibilityLabels, $memo, $searchAttributes] = $this->initialVisibilityMetadata( - $instance, - $startOptions, - ); - $executionTimeoutSeconds = $startOptions->executionTimeoutSeconds; - $runTimeoutSeconds = $startOptions->runTimeoutSeconds; - $connection = RoutingResolver::workflowConnection($workflowClass, $metadata); - $queue = RoutingResolver::workflowQueue($workflowClass, $metadata); - $startBlockedReason = WorkflowStartGate::blockedReason( - WorkerCompatibility::current(), - $connection, - $queue, - ); + $this->refresh(); - if ($startBlockedReason !== null) { - $signalCommand = $this->rejectSignalCommandForContext( - $commandContext, - $instance, - null, - $name, - $signalArguments, - $startBlockedReason, - [], - WorkflowStartGate::blockedMessage( - sprintf( - 'Workflow instance [%s] cannot start via signal-with-start [%s].', - $instance->id, - $name, - ), - WorkerCompatibility::current(), - $connection, - $queue, - ) ?? sprintf( - 'Workflow instance [%s] cannot start via signal-with-start [%s].', - $instance->id, - $name, - ), - ); + if ($task instanceof WorkflowTask) { + TaskDispatcher::dispatch($task); + } - return; - } + if (! $signalCommand instanceof WorkflowCommand) { + throw new LogicException(sprintf( + 'Workflow instance [%s] failed to record a signal-with-start command.', + $this->instance->id, + )); + } - if ($instance->workflow_class !== $workflowClass) { - $instance->forceFill([ - 'workflow_class' => $workflowClass, - ])->save(); - } + $signalWithStartResult = SignalWithStartResult::fromCommands($signalCommand, $startCommand, $intakeGroupId); - $startedAt = now(); - $executionDeadlineAt = $executionTimeoutSeconds !== null - ? $startedAt->copy() - ->addSeconds($executionTimeoutSeconds) - : null; - $runDeadlineAt = $runTimeoutSeconds !== null - ? $startedAt->copy() - ->addSeconds($runTimeoutSeconds) - : null; - $payloadCodec = CodecRegistry::defaultCodec(); - $serializedArguments = ExternalPayloads::externalizeForNamespace( - Serializer::serializeWithCodec($payloadCodec, $metadata->arguments), - $payloadCodec, - is_string($instance->namespace) ? $instance->namespace : null, - ); + if ($signalWithStartResult->accepted()) { + self::recordSignalSent($this->instance->id, $name, $signalArguments); + } - /** @var WorkflowRun $run */ - $run = self::runQuery()->create([ - 'workflow_instance_id' => $instance->id, - 'run_number' => $instance->run_count + 1, - 'observability_context' => $observabilityContext, - 'workflow_class' => $workflowClass, - 'workflow_type' => $instance->workflow_type, - 'namespace' => $instance->namespace, - 'business_key' => $businessKey, - 'visibility_labels' => $visibilityLabels, - 'run_timeout_seconds' => $runTimeoutSeconds, - 'execution_deadline_at' => $executionDeadlineAt, - 'run_deadline_at' => $runDeadlineAt, - 'status' => RunStatus::Pending->value, - 'compatibility' => WorkerCompatibility::current(), - 'payload_codec' => $payloadCodec, - 'arguments' => $serializedArguments, - 'connection' => $connection, - 'queue' => $queue, - 'started_at' => $startedAt, - 'last_progress_at' => $startedAt, - 'last_history_sequence' => 0, - ]); + return $signalWithStartResult; + } - $this->seedTypedVisibilityMetadata($run, $memo, $searchAttributes); + /** + * @return array{WorkflowCommand|null, WorkflowCommand, WorkflowTask|null, WorkflowRun|null} + */ + private function admitSignalWithNewRun( + WorkflowInstance $instance, + CommandContext $commandContext, + string $name, + array $signalArguments, + array $startArguments, + StartOptions $startOptions, + ?array $observabilityContext, + ): array { + $workflowClass = TypeRegistry::resolveWorkflowClass($instance->workflow_class, $instance->workflow_type); + $signalAdmission = $this->validateNewRunSignal( + $instance, + $workflowClass, + $commandContext, + $name, + $signalArguments, + ); + if ($signalAdmission instanceof WorkflowCommand) { + return [null, $signalAdmission, null, null]; + } - /** @var WorkflowCommand $startCommand */ - $startCommand = WorkflowCommand::record($instance, $run, $this->commandAttributesForContext( - $commandContext, - [ - 'command_type' => CommandType::Start->value, - 'target_scope' => 'instance', - 'status' => CommandStatus::Accepted->value, - 'outcome' => CommandOutcome::StartedNew->value, - 'payload_codec' => $payloadCodec, - 'payload' => $serializedArguments, - 'accepted_at' => now(), - 'applied_at' => now(), - ], - )); + $signalArguments = $signalAdmission; + $metadata = WorkflowMetadata::fromStartArguments($startArguments); + $commandContract = RunCommandContract::snapshot($workflowClass); + [$businessKey, $visibilityLabels, $memo, $searchAttributes] = $this->initialVisibilityMetadata( + $instance, + $startOptions, + ); + $executionTimeoutSeconds = $startOptions->executionTimeoutSeconds; + $runTimeoutSeconds = $startOptions->runTimeoutSeconds; + $connection = RoutingResolver::workflowConnection($workflowClass, $metadata); + $queue = RoutingResolver::workflowQueue($workflowClass, $metadata); + $signalCommand = $this->rejectIncompatibleSignalWithStart( + $instance, + $commandContext, + $name, + $signalArguments, + $connection, + $queue, + ); + if ($signalCommand instanceof WorkflowCommand) { + return [null, $signalCommand, null, null]; + } + if ($instance->workflow_class !== $workflowClass) { $instance->forceFill([ - 'current_run_id' => $run->id, - 'business_key' => $businessKey, - 'visibility_labels' => $visibilityLabels, - 'memo' => $memo, - 'execution_timeout_seconds' => $executionTimeoutSeconds, - 'started_at' => $startedAt, - 'run_count' => $run->run_number, + 'workflow_class' => $workflowClass, ])->save(); + } - $memoPayload = self::visibilityMetadataPayload($memo); - $searchAttributesPayload = self::visibilityMetadataPayload($searchAttributes); + $startedAt = now(); + $executionDeadlineAt = $executionTimeoutSeconds !== null + ? $startedAt->copy() + ->addSeconds($executionTimeoutSeconds) + : null; + $runDeadlineAt = $runTimeoutSeconds !== null + ? $startedAt->copy() + ->addSeconds($runTimeoutSeconds) + : null; + $payloadCodec = CodecRegistry::defaultCodec(); + $serializedArguments = ExternalPayloads::externalizeForNamespace( + Serializer::serializeWithCodec($payloadCodec, $metadata->arguments), + $payloadCodec, + is_string($instance->namespace) ? $instance->namespace : null, + ); - WorkflowHistoryEvent::record($run, HistoryEventType::StartAccepted, [ - 'workflow_command_id' => $startCommand->id, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'workflow_class' => $run->workflow_class, - 'workflow_type' => $run->workflow_type, - 'business_key' => $run->business_key, - 'visibility_labels' => $run->visibility_labels, - 'memo' => $memoPayload, - 'search_attributes' => $searchAttributesPayload, - 'outcome' => $startCommand->outcome?->value, - ], null, $startCommand); + /** @var WorkflowRun $run */ + $run = self::runQuery()->create([ + 'workflow_instance_id' => $instance->id, + 'run_number' => $instance->run_count + 1, + 'observability_context' => $observabilityContext, + 'workflow_class' => $workflowClass, + 'workflow_type' => $instance->workflow_type, + 'namespace' => $instance->namespace, + 'business_key' => $businessKey, + 'visibility_labels' => $visibilityLabels, + 'run_timeout_seconds' => $runTimeoutSeconds, + 'execution_deadline_at' => $executionDeadlineAt, + 'run_deadline_at' => $runDeadlineAt, + 'status' => RunStatus::Pending->value, + 'compatibility' => WorkerCompatibility::current(), + 'payload_codec' => $payloadCodec, + 'arguments' => $serializedArguments, + 'connection' => $connection, + 'queue' => $queue, + 'started_at' => $startedAt, + 'last_progress_at' => $startedAt, + 'last_history_sequence' => 0, + ]); - WorkflowHistoryEvent::record($run, HistoryEventType::WorkflowStarted, [ - 'workflow_class' => $run->workflow_class, - 'workflow_type' => $run->workflow_type, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'workflow_command_id' => $startCommand->id, - 'business_key' => $run->business_key, - 'visibility_labels' => $run->visibility_labels, - 'memo' => $memoPayload, - 'search_attributes' => $searchAttributesPayload, - 'execution_timeout_seconds' => $executionTimeoutSeconds, - 'run_timeout_seconds' => $runTimeoutSeconds, - 'execution_deadline_at' => $executionDeadlineAt?->toIso8601String(), - 'run_deadline_at' => $runDeadlineAt?->toIso8601String(), - 'workflow_definition_fingerprint' => WorkflowDefinition::fingerprint($workflowClass), - 'declared_queries' => $commandContract['queries'], - 'declared_query_contracts' => $commandContract['query_contracts'], - 'declared_signals' => $commandContract['signals'], - 'declared_signal_contracts' => $commandContract['signal_contracts'], - 'declared_updates' => $commandContract['updates'], - 'declared_update_contracts' => $commandContract['update_contracts'], - 'declared_entry_method' => $commandContract['entry_method'], - 'declared_entry_mode' => $commandContract['entry_mode'], - 'declared_entry_declaring_class' => $commandContract['entry_declaring_class'], - ], null, $startCommand); + $this->seedTypedVisibilityMetadata($run, $memo, $searchAttributes); - $task = $this->createInitialWorkflowTask($run); + $startCommand = $this->recordNewStartCommand( + $instance, + $run, + $payloadCodec, + $serializedArguments, + $commandContext, + ); - /** @var WorkflowCommand $signalCommand */ - $signalCommand = WorkflowCommand::record($instance, $run, $this->commandAttributesForContext( - $commandContext, - [ - 'command_type' => CommandType::Signal->value, - 'observability_context' => $observabilityContext, - 'target_scope' => 'instance', - 'status' => CommandStatus::Accepted->value, - 'outcome' => CommandOutcome::SignalReceived->value, - ...$this->signalCommandPayloadAttributes($name, $signalArguments), - 'accepted_at' => now(), - ], - )); + $instance->forceFill([ + 'current_run_id' => $run->id, + 'business_key' => $businessKey, + 'visibility_labels' => $visibilityLabels, + 'memo' => $memo, + 'execution_timeout_seconds' => $executionTimeoutSeconds, + 'started_at' => $startedAt, + 'run_count' => $run->run_number, + ])->save(); - $signalWaitId = $this->signalWaitIdForAcceptedCommand($run, $name, $signalCommand->id); - $signal = $this->recordAcceptedSignal( + $memoPayload = self::visibilityMetadataPayload($memo); + $searchAttributesPayload = self::visibilityMetadataPayload($searchAttributes); + + $this->recordInitialStartAccepted($instance, $run, $startCommand, $memoPayload, $searchAttributesPayload); + $this->recordWorkflowStartedHistory($instance, $run, $startCommand, $workflowClass, $commandContract, [ + 'memo' => $memoPayload, + 'search_attributes' => $searchAttributesPayload, + 'execution_timeout_seconds' => $executionTimeoutSeconds, + 'run_timeout_seconds' => $runTimeoutSeconds, + 'execution_deadline_at' => $executionDeadlineAt, + 'run_deadline_at' => $runDeadlineAt, + ]); + + $task = $this->createInitialWorkflowTask($run); + + /** @var WorkflowCommand $signalCommand */ + $signalCommand = WorkflowCommand::record($instance, $run, $this->commandAttributesForContext( + $commandContext, + [ + 'command_type' => CommandType::Signal->value, + 'observability_context' => $observabilityContext, + 'target_scope' => 'instance', + 'status' => CommandStatus::Accepted->value, + 'outcome' => CommandOutcome::SignalReceived->value, + ...$this->signalCommandPayloadAttributes($name, $signalArguments), + 'accepted_at' => now(), + ], + )); + + $signalWaitId = $this->signalWaitIdForAcceptedCommand($run, $name, $signalCommand->id); + $signal = $this->recordAcceptedSignal( + $instance, + $run, + $signalCommand, + $name, + $signalArguments, + $signalWaitId, + ); + + $this->recordSignalReceivedHistory($instance, $run, $signalCommand, $signal, $name, $signalWaitId); + + self::projectRun($run, self::PROJECTION_RUN_RELATIONS); + + return [$startCommand, $signalCommand, $task, $run]; + } + + /** + * @return array{WorkflowCommand|null, WorkflowCommand, WorkflowTask|null} + */ + private function admitSignalWithExistingStart( + WorkflowInstance $instance, + WorkflowRun $run, + CommandContext $commandContext, + string $name, + array $signalArguments, + array $startArguments, + ?array $observabilityContext, + ): array { + $this->loadLockedRunRelations($run, $instance); + $signalAdmission = $this->signalAdmissionForRun($run, $name); + if (($signalAdmission['allowed'] ?? false) !== true) { + return [null, $this->rejectSignalCommandForContext( + $commandContext, $instance, $run, - $signalCommand, $name, $signalArguments, - $signalWaitId, - ); + 'unknown_signal', + [], + $signalAdmission['message'] ?? null, + $signalAdmission['payload'] ?? [], + ), null]; + } - WorkflowHistoryEvent::record($run, HistoryEventType::SignalReceived, array_filter([ - 'workflow_command_id' => $signalCommand->id, - 'signal_id' => $signal->id, - 'workflow_instance_id' => $instance->id, - 'workflow_run_id' => $run->id, - 'signal_name' => $name, - 'signal_wait_id' => $signalWaitId, - 'arguments' => ExternalPayloads::historyValue( - is_string($signal->arguments) ? $signal->arguments : null, - is_string($signal->payload_codec) ? $signal->payload_codec : null, - is_string($run->namespace) ? $run->namespace : null, - ), - 'payload_codec' => $signal->payload_codec, - ], static fn (mixed $value): bool => $value !== null), null, $signalCommand); + $validatedSignalArguments = $this->validatedSignalArgumentsForRun($run, $name, $signalArguments); + if ($validatedSignalArguments['validation_errors'] !== []) { + return [null, $this->rejectSignalCommandForContext( + $commandContext, + $instance, + $run, + $name, + $signalArguments, + 'invalid_signal_arguments', + $validatedSignalArguments['validation_errors'], + ), null]; + } + $signalArguments = $validatedSignalArguments['arguments']; - self::projectRun($run, self::PROJECTION_RUN_RELATIONS); + try { + StructuralLimits::guardPendingSignals($run); + } catch (StructuralLimitExceededException $e) { + return [null, $this->rejectSignalCommandForContext( + $commandContext, + $instance, + $run, + $name, + $signalArguments, + 'structural_limit_exceeded', + ), null]; + } - $startedRun = $run; - }, self::mysqlCommandTransactionAttempts() ?? 1); + $metadata = WorkflowMetadata::fromStartArguments($startArguments); + $startCommand = WorkflowCommand::record($instance, $run, $this->commandAttributesForContext( + $commandContext, + [ + 'command_type' => CommandType::Start->value, + 'target_scope' => 'instance', + 'status' => CommandStatus::Accepted->value, + 'outcome' => CommandOutcome::ReturnedExistingActive->value, + 'payload_codec' => $run->payload_codec ?? CodecRegistry::defaultCodec(), + 'payload' => Serializer::serializeWithCodec( + $run->payload_codec ?? CodecRegistry::defaultCodec(), + $metadata->arguments + ), + 'accepted_at' => now(), + 'applied_at' => now(), + ], + )); + WorkflowHistoryEvent::record($run, HistoryEventType::StartAccepted, [ + 'workflow_command_id' => $startCommand->id, + 'workflow_instance_id' => $instance->id, + 'workflow_run_id' => $run->id, + 'workflow_class' => $run->workflow_class, + 'workflow_type' => $run->workflow_type, + 'business_key' => $run->business_key, + 'visibility_labels' => $run->visibility_labels, + 'memo' => AvroValueJsonProjection::project($run->typedMemos()), + 'outcome' => $startCommand->outcome?->value, + ], null, $startCommand); + + $signalCommand = WorkflowCommand::record($instance, $run, $this->commandAttributesForContext( + $commandContext, + [ + 'command_type' => CommandType::Signal->value, + 'observability_context' => $observabilityContext, + 'target_scope' => 'instance', + 'status' => CommandStatus::Accepted->value, + 'outcome' => CommandOutcome::SignalReceived->value, + ...$this->signalCommandPayloadAttributes($name, $signalArguments), + 'accepted_at' => now(), + ], + )); + $signalWaitId = $this->signalWaitIdForAcceptedCommand($run, $name, $signalCommand->id); + $signal = $this->recordAcceptedSignal( + $instance, + $run, + $signalCommand, + $name, + $signalArguments, + $signalWaitId, + ); + $this->recordSignalReceivedHistory($instance, $run, $signalCommand, $signal, $name, $signalWaitId); - if ($startedRun instanceof WorkflowRun) { - LifecycleEventDispatcher::workflowStarted($startedRun); + $task = null; + if (! $this->hasOpenWorkflowTask($run->id)) { + $task = PendingMessageTask::createForRun($run); } + self::projectRun($run, self::PROJECTION_RUN_RELATIONS); - $this->refresh(); + return [$startCommand, $signalCommand, $task]; + } - if ($task instanceof WorkflowTask) { - TaskDispatcher::dispatch($task); + /** + * @return array|WorkflowCommand + */ + private function validateNewRunSignal( + WorkflowInstance $instance, + string $workflowClass, + CommandContext $commandContext, + string $name, + array $signalArguments, + ): array|WorkflowCommand { + if (! WorkflowDefinition::hasSignal($workflowClass, $name)) { + return $this->rejectSignalCommandForContext( + $commandContext, + $instance, + null, + $name, + $signalArguments, + 'unknown_signal', + ); } - if (! $signalCommand instanceof WorkflowCommand) { - throw new LogicException(sprintf( - 'Workflow instance [%s] failed to record a signal-with-start command.', - $this->instance->id, - )); + $validated = $this->validatedSignalArgumentsForWorkflow($workflowClass, $name, $signalArguments); + if ($validated['validation_errors'] !== []) { + return $this->rejectSignalCommandForContext( + $commandContext, + $instance, + null, + $name, + $signalArguments, + 'invalid_signal_arguments', + $validated['validation_errors'], + ); } - $signalWithStartResult = SignalWithStartResult::fromCommands($signalCommand, $startCommand, $intakeGroupId); + return $validated['arguments']; + } - if ($signalWithStartResult->accepted()) { - self::recordSignalSent($this->instance->id, $name, $signalArguments); + private function rejectIncompatibleSignalWithStart( + WorkflowInstance $instance, + CommandContext $commandContext, + string $name, + array $signalArguments, + ?string $connection, + ?string $queue, + ): ?WorkflowCommand { + $reason = WorkflowStartGate::blockedReason(WorkerCompatibility::current(), $connection, $queue); + if ($reason === null) { + return null; } - return $signalWithStartResult; + return $this->rejectSignalCommandForContext( + $commandContext, + $instance, + null, + $name, + $signalArguments, + $reason, + [], + WorkflowStartGate::blockedMessage( + sprintf('Workflow instance [%s] cannot start via signal-with-start [%s].', $instance->id, $name), + WorkerCompatibility::current(), + $connection, + $queue, + ) ?? sprintf('Workflow instance [%s] cannot start via signal-with-start [%s].', $instance->id, $name), + ); } /** From 7fa819894406883fdec3a0b2f72737ad7eaabf82 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 23:41:15 +0000 Subject: [PATCH 53/77] Capture bounded queue failure stages without bindings --- tests/Fixtures/V2/tracing-process.php | 3 + .../Support/TracingQueueErrorDiagnostics.php | 26 +++++++ .../V2/TracingQueueErrorDiagnosticsTest.php | 71 +++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 tests/Support/TracingQueueErrorDiagnostics.php create mode 100644 tests/Unit/V2/TracingQueueErrorDiagnosticsTest.php diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index 9efcd34fc..a45eb30ee 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -31,6 +31,7 @@ use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor; use OpenTelemetry\SDK\Trace\TracerProvider; use Orchestra\Testbench\Foundation\Application; +use Tests\Support\TracingQueueErrorDiagnostics; use Workflow\Providers\WorkflowServiceProvider; use Workflow\Serializers\Serializer; use Workflow\UpdateMethod; @@ -485,6 +486,7 @@ function prepareTracingSchema(): void ? strtolower(explode(' ', trim($error->getSql()), 2)[0]) : null, 'transaction_level' => $connection->transactionLevel(), 'pdo_in_transaction' => $pdo instanceof \PDO ? $pdo->inTransaction() : null, + ...TracingQueueErrorDiagnostics::context($error), ]; }); $handler = $app->make(\Illuminate\Contracts\Debug\ExceptionHandler::class); @@ -514,6 +516,7 @@ function prepareTracingSchema(): void ? $error->getBindings() : null, 'transaction_level' => $connection->transactionLevel(), 'pdo_in_transaction' => $pdo instanceof \PDO ? $pdo->inTransaction() : null, + ...TracingQueueErrorDiagnostics::context($error), ]; }); } diff --git a/tests/Support/TracingQueueErrorDiagnostics.php b/tests/Support/TracingQueueErrorDiagnostics.php new file mode 100644 index 000000000..e8d65e97c --- /dev/null +++ b/tests/Support/TracingQueueErrorDiagnostics.php @@ -0,0 +1,26 @@ +getSql() : null; + + return [ + 'statement' => $statement === null ? null : mb_strcut($statement, 0, 1024, 'UTF-8'), + 'statement_truncated' => $statement !== null && strlen($statement) > 1024, + 'stage_frames' => array_map(static fn (array $frame): array => [ + 'file' => isset($frame['file']) ? basename($frame['file']) : null, + 'line' => $frame['line'] ?? null, + 'function' => isset($frame['function']) ? substr($frame['function'], 0, 128) : null, + ], array_slice($error->getTrace(), 0, 8)), + ]; + } +} diff --git a/tests/Unit/V2/TracingQueueErrorDiagnosticsTest.php b/tests/Unit/V2/TracingQueueErrorDiagnosticsTest.php new file mode 100644 index 000000000..e87e5b0a5 --- /dev/null +++ b/tests/Unit/V2/TracingQueueErrorDiagnosticsTest.php @@ -0,0 +1,71 @@ +getTrace()[0]); + $result = TracingQueueErrorDiagnostics::context($error); + self::assertCount(8, $result['stage_frames']); + foreach ($result['stage_frames'] as $frame) { + self::assertSame(['file', 'line', 'function'], array_keys($frame)); + self::assertSame('TracingQueueErrorDiagnosticsTest.php', $frame['file']); + } + self::assertStringNotContainsString('private-argument-sentinel', json_encode($result, JSON_THROW_ON_ERROR)); + self::assertNull($result['statement']); + self::assertFalse($result['statement_truncated']); + } + + private static function nestedError(int $depth, string $argument): Throwable + { + return $depth === 0 ? new RuntimeException('private-message-sentinel') : self::nestedError( + $depth - 1, + $argument + ); + } +} From 6786ca6ff52e45b29c37d47e76b1ceb8a9722861 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 16 Oct 2026 23:55:12 +0000 Subject: [PATCH 54/77] Classify proven failed-job logging contention in tracing controls --- .github/pr-test-selection.json | 1 + .../Support/TracingQueueErrorDiagnostics.php | 29 ++++++++ .../V2/LargeFanOutExecutionTracingTest.php | 22 ++++-- .../V2/TracingQueueErrorDiagnosticsTest.php | 74 +++++++++++++++++++ 4 files changed, 120 insertions(+), 6 deletions(-) diff --git a/.github/pr-test-selection.json b/.github/pr-test-selection.json index 3e98bb8e0..935c24b56 100644 --- a/.github/pr-test-selection.json +++ b/.github/pr-test-selection.json @@ -20,6 +20,7 @@ "tests/Unit/V2/TaskTraceTimingsTest.php", "tests/Unit/V2/TaskTraceCarrierTest.php", "tests/Unit/V2/TraceCarrierTest.php", + "tests/Unit/V2/TracingQueueErrorDiagnosticsTest.php", "tests/Unit/V2/VersionMarkerWireFormatTest.php", "tests/Unit/V2/WorkflowFiberRunnerTest.php", "tests/Unit/V2/WorkflowInstanceIdTest.php", diff --git a/tests/Support/TracingQueueErrorDiagnostics.php b/tests/Support/TracingQueueErrorDiagnostics.php index e8d65e97c..8260b2228 100644 --- a/tests/Support/TracingQueueErrorDiagnostics.php +++ b/tests/Support/TracingQueueErrorDiagnostics.php @@ -23,4 +23,33 @@ public static function context(Throwable $error): array ], array_slice($error->getTrace(), 0, 8)), ]; } + + public static function isFailedJobLogContention(array $error): bool + { + $code = $error['native_code'] ?? null; + if ( + ($error['error_class'] ?? null) !== QueryException::class + || ! is_int($code) || ! in_array($code & 255, [5, 6], true) + || ($error['operation'] ?? null) !== 'insert' + || ($error['transaction_level'] ?? null) !== 0 + || ($error['pdo_in_transaction'] ?? null) !== false + || ($error['statement_truncated'] ?? null) !== false + || ($error['statement'] ?? null) !== 'insert into "failed_jobs" ' + . '("uuid", "connection", "queue", "payload", "exception", "failed_at") values (?, ?, ?, ?, ?, ?)' + ) { + return false; + } + + $frames = $error['stage_frames'] ?? []; + if (! is_array($frames)) { + return false; + } + + return array_filter($frames, static fn (mixed $frame): bool => is_array($frame) + && ($frame['file'] ?? null) === 'DatabaseUuidFailedJobProvider.php' + && ($frame['function'] ?? null) === 'insert') !== [] + && array_filter($frames, static fn (mixed $frame): bool => is_array($frame) + && ($frame['file'] ?? null) === 'WorkCommand.php' + && ($frame['function'] ?? null) === 'logFailedJob') !== []; + } } diff --git a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php index dc38441be..23bec5c28 100644 --- a/tests/Unit/V2/LargeFanOutExecutionTracingTest.php +++ b/tests/Unit/V2/LargeFanOutExecutionTracingTest.php @@ -12,6 +12,7 @@ use PHPUnit\Framework\TestCase; use RuntimeException; use Symfony\Component\Process\Process; +use Tests\Support\TracingQueueErrorDiagnostics; use Throwable; final class LargeFanOutExecutionTracingTest extends TestCase @@ -146,12 +147,11 @@ private static function assertSupportedQueueRecovery( array $reportedErrors, string $diagnostic, ): void { - foreach (array_merge($queueErrors, $reportedErrors) as $error) { - self::assertIsInt($error['native_code'], $diagnostic); - self::assertContains($error['native_code'] & 255, [5, 6], $diagnostic); - self::assertContains($error['operation'], ['update', 'delete'], $diagnostic); - self::assertSame(0, $error['transaction_level'], $diagnostic); - self::assertFalse($error['pdo_in_transaction'], $diagnostic); + foreach ($queueErrors as $error) { + self::assertQueuePressureError($error, false, $diagnostic); + } + foreach ($reportedErrors as $error) { + self::assertQueuePressureError($error, true, $diagnostic); } self::assertCount($finished['failed_jobs'], $observed['delivery_errors'], $diagnostic); self::assertCount($finished['jobs'], $observed['remaining_deliveries'], $diagnostic); @@ -176,6 +176,16 @@ private static function assertSupportedQueueRecovery( } } + private static function assertQueuePressureError(array $error, bool $reported, string $diagnostic): void + { + self::assertIsInt($error['native_code'], $diagnostic); + self::assertContains($error['native_code'] & 255, [5, 6], $diagnostic); + self::assertTrue(in_array($error['operation'], ['update', 'delete'], true) + || ($reported && TracingQueueErrorDiagnostics::isFailedJobLogContention($error)), $diagnostic); + self::assertSame(0, $error['transaction_level'], $diagnostic); + self::assertFalse($error['pdo_in_transaction'], $diagnostic); + } + private static function assertRecoveredTask(array $delivery, array $tasks, string $runId, string $diagnostic): void { self::assertIsString($delivery['task_id'], $diagnostic); diff --git a/tests/Unit/V2/TracingQueueErrorDiagnosticsTest.php b/tests/Unit/V2/TracingQueueErrorDiagnosticsTest.php index e87e5b0a5..9d1b829cd 100644 --- a/tests/Unit/V2/TracingQueueErrorDiagnosticsTest.php +++ b/tests/Unit/V2/TracingQueueErrorDiagnosticsTest.php @@ -6,6 +6,7 @@ use Illuminate\Database\QueryException; use PDOException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use RuntimeException; use Tests\Support\TracingQueueErrorDiagnostics; @@ -61,6 +62,79 @@ public function testDeepFramesExcludeArgumentsAndAbsolutePaths(): void self::assertFalse($result['statement_truncated']); } + public function testOnlyKnownFailedJobLoggerContentionIsClassified(): void + { + $error = self::failedJobLogRefusal(); + self::assertTrue(TracingQueueErrorDiagnostics::isFailedJobLogContention($error)); + $error['native_code'] = 6; + self::assertTrue(TracingQueueErrorDiagnostics::isFailedJobLogContention($error)); + } + + #[DataProvider('unsafeRefusals')] + public function testOtherInsertErrorsRemainRefusals(array $changes): void + { + self::assertFalse(TracingQueueErrorDiagnostics::isFailedJobLogContention( + array_replace(self::failedJobLogRefusal(), $changes), + )); + } + + public static function unsafeRefusals(): array + { + return [ + 'workflow write' => [[ + 'statement' => 'insert into "workflow_history_events" ("payload") values (?)', + ]], + 'uncertain statement' => [[ + 'statement_truncated' => true, + ]], + 'other database failure' => [[ + 'native_code' => 19, + ]], + 'active root' => [[ + 'transaction_level' => 1, + ]], + 'pdo still active' => [[ + 'pdo_in_transaction' => true, + ]], + 'unknown pdo' => [[ + 'pdo_in_transaction' => null, + ]], + 'unknown caller' => [[ + 'stage_frames' => [], + ]], + 'application insert' => [[ + 'stage_frames' => [[ + 'file' => 'Application.php', + 'function' => 'insert', + ]], + ]], + 'malformed frames' => [[ + 'stage_frames' => 'not-frames', + ]], + ]; + } + + private static function failedJobLogRefusal(): array + { + return [ + 'error_class' => QueryException::class, + 'native_code' => 5, + 'operation' => 'insert', + 'transaction_level' => 0, + 'pdo_in_transaction' => false, + 'statement_truncated' => false, + 'statement' => 'insert into "failed_jobs" ' + . '("uuid", "connection", "queue", "payload", "exception", "failed_at") values (?, ?, ?, ?, ?, ?)', + 'stage_frames' => [[ + 'file' => 'DatabaseUuidFailedJobProvider.php', + 'function' => 'insert', + ], [ + 'file' => 'WorkCommand.php', + 'function' => 'logFailedJob', + ]], + ]; + } + private static function nestedError(int $depth, string $argument): Throwable { return $depth === 0 ? new RuntimeException('private-message-sentinel') : self::nestedError( From 42f774059c7a590e4f799a1a70bfa0db8b175d67 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sat, 17 Oct 2026 01:11:42 +0000 Subject: [PATCH 55/77] Add reproducible embedded tracing overhead workload --- .github/tracing-performance/.gitignore | 4 + .github/tracing-performance/Dockerfile | 9 + .github/tracing-performance/README.md | 159 + .github/tracing-performance/app.php | 199 + .github/tracing-performance/collector.php | 18 + .github/tracing-performance/compose.yaml | 117 + .github/tracing-performance/composer.json | 21 + .github/tracing-performance/composer.lock | 9703 +++++++++++++++++ .github/tracing-performance/probe.mjs | 132 + .github/tracing-performance/probe.test.mjs | 53 + .../src/BenchmarkActivity.php | 24 + .../src/BenchmarkApplication.php | 64 + .../src/BenchmarkWorkflow.php | 24 + .../src/CountingProcessor.php | 58 + .github/workflows/php.yml | 9 + 15 files changed, 10594 insertions(+) create mode 100644 .github/tracing-performance/.gitignore create mode 100644 .github/tracing-performance/Dockerfile create mode 100644 .github/tracing-performance/README.md create mode 100644 .github/tracing-performance/app.php create mode 100644 .github/tracing-performance/collector.php create mode 100644 .github/tracing-performance/compose.yaml create mode 100644 .github/tracing-performance/composer.json create mode 100644 .github/tracing-performance/composer.lock create mode 100644 .github/tracing-performance/probe.mjs create mode 100644 .github/tracing-performance/probe.test.mjs create mode 100644 .github/tracing-performance/src/BenchmarkActivity.php create mode 100644 .github/tracing-performance/src/BenchmarkApplication.php create mode 100644 .github/tracing-performance/src/BenchmarkWorkflow.php create mode 100644 .github/tracing-performance/src/CountingProcessor.php diff --git a/.github/tracing-performance/.gitignore b/.github/tracing-performance/.gitignore new file mode 100644 index 000000000..44eccbfdb --- /dev/null +++ b/.github/tracing-performance/.gitignore @@ -0,0 +1,4 @@ +/vendor/ +/state/ +/.env +!/composer.lock diff --git a/.github/tracing-performance/Dockerfile b/.github/tracing-performance/Dockerfile new file mode 100644 index 000000000..72e22c699 --- /dev/null +++ b/.github/tracing-performance/Dockerfile @@ -0,0 +1,9 @@ +FROM php:8.3.35-cli-bookworm +RUN apt-get update && apt-get install -y --no-install-recommends \ + git unzip libonig-dev libxml2-dev \ + && docker-php-ext-install pdo_mysql mbstring dom pcntl \ + && pecl install redis-6.1.0 && docker-php-ext-enable redis \ + && rm -rf /var/lib/apt/lists/* +COPY --from=composer:2.9 /usr/bin/composer /usr/bin/composer +USER 1000:1000 +WORKDIR /app diff --git a/.github/tracing-performance/README.md b/.github/tracing-performance/README.md new file mode 100644 index 000000000..d1c121ea6 --- /dev/null +++ b/.github/tracing-performance/README.md @@ -0,0 +1,159 @@ +# Embedded tracing overhead workload + +This is a prospective qualification recipe for draft Workflow #788. It has no +measured result yet. The encoded-baggage regression and the other acceptance +requirements of [#773](https://github.com/durable-workflow/workflow/issues/773) +remain separate release gates. + +## Fixed comparison + +The primary profile uses Laravel's database queue on MySQL and shared Redis +cache. This follows the reported embedded database-queue path. The earlier +Redis-transport prototypes remain unsuccessful observations, with no overhead +acceptance transferred from their diagnostic controls. A separate fresh stack +can select `BENCH_QUEUE_DRIVER=redis`; the probe verifies the actual driver +before admission, and its results must remain labeled separately. + +Use three cells with the same application, PHP/dependency lock, database, +collector, logging call, worker count and resource limits: + +1. The published Workflow version in `composer.lock`, with tracing disabled. +2. The exact candidate source, with tracing disabled. +3. That same candidate, with tracing enabled and an accepting local collector. + +Record the published package reference, candidate commit/tree, loaded source +hashes, complete dependency references and resolved image digests before any +measurement. Only the Workflow package may differ between installations. +This prospective baseline includes the released MySQL admission correction; +it does not supply retrospective measurements of earlier implementations. +Its exact dependency constraints are deliberate experiment inputs. CI validates +the manifest and lock with Composer's strict validation, excluding generic +warnings about overly strict version constraints and package publication. + +The previously declared acceptance targets remain at most **1% additional CPU +per completed workflow when disabled** and **5% when enabled**. Measure at least +three independent matched repetitions and repeat the published baseline to +check drift. If noise cannot resolve a target, its acceptance remains unproven. +Do not infer overhead from functional test duration or compare old measurements +with a changed machine or workload. + +Run on a separate uncontended host with at least eight logical CPUs and 16 GiB +RAM, swap disabled. Record actual CPU model, kernel, virtualization/steal time, +Docker version and host activity. Builds and dependency installation finish +before measurement. + +## Workloads and bounds + +Run these independently with fresh databases and application directories: + +The offered rates below are prospective inputs. Passing one-workflow controls +does not establish that either runtime sustains these rates. Establish the +published baseline's capacity before admitting matched measurement windows; +record any revised offered rate before starting new windows. + +`PROFILE_NAME` labels results. `WORKLOAD_NAME` supplies the business identifier +prefix and trace-parent seed, so keep it identical across matched cells. Its +defaults are `ordinary` and `fanout`. Give warmup a separate `warmup-*` workload +name to keep its original runs distinct from the measured offers. + +| Workload | Members per workflow | Offered workflows/second | Offer window | +| --- | ---: | ---: | ---: | +| Ordinary activity | 1 | 5 | 120 seconds | +| Large fan-out | 64 | 0.25 | 120 seconds | + +Each member performs one durable database insertion, one Laravel log call and +returns the exact int64/null/Unicode result. A unique activity ID and run/member +pair refuse duplicate callbacks. Every original keeps its initial 90-second +observer, 100-second run deadline and 120-second execution deadline. Offer +scheduling, transport refusals and unfinished work count as failures. A slow +offer generator is recorded, never relabeled as offered work. +Offers more than 100 ms late are missed offers; the probe does not catch up by +submitting them after their declared offer time. + +First complete functional one-workflow controls for both member counts in each +cell. Then warm each fresh measured cell for 30 seconds without counting that +warmup as measured work. Complete and audit the warmup before releasing measured +offers. Keep sampling through the complete observation drain, +worker shutdown and pending export completion. Do not add a repair pass to make +a failed cell complete. + +`compose.yaml` allocates HTTP 0.5 CPU/512 MiB, four independent workers at +1 CPU/384 MiB each, MySQL 1 CPU/2 GiB, Redis 0.1 CPU/128 MiB, collector +0.2 CPU/256 MiB and probe 0.2 CPU/256 MiB. Every role has no swap. These are +qualification inputs, not production sizing advice. + +All three cells install the same application-owned OpenTelemetry provider. +Workers share Redis cache on a separate logical database from their queue. +The runtime's repair throttle therefore coordinates across all four workers; +a process-local array cache is not this multi-process profile. +Automatic queue wake stays enabled in every standard cell. A separately named +`diagnostic-*` control can set `BENCH_QUEUE_WAKE=false` together with +`DIAGNOSTIC_ONLY=true` to isolate background matching. Its response records the +actual setting and diagnostic status. Diagnostic results remain outside +overhead acceptance and do not qualify timer or recovery behavior. The normal +probe refuses a deployment with automatic wake disabled before admitting work. +The enabled cell uses the official OTLP exporter, a 100 ms HTTP timeout, no +transport retries and a 256-span/64-span-batch queue with automatic flush +disabled. Workers flush at most once per second between queue iterations and +shut down the provider after their worker loop. HTTP requests shut down their +own provider during request teardown. No flush is inserted into an activity or +workflow transaction. The collector decodes official OTLP protobuf messages; +it counts received spans without saving business data. Compare that count with +the independent ended-span count. A healthy comparison with drops is not a +complete-telemetry overhead pass. Collector-outage resilience has its own +functional controls and is not established by this accepting profile. + +## Commands + +Build the Dockerfile and install `composer.json` normally as UID 1000. Retain +the generated baseline lock for the experiment. For a separate candidate copy, +add a Composer path repository for the exact source with an explicit benchmark +version alias, then update only `durable-workflow/workflow`. Compare every +other locked and loaded package before starting. The alias does not publish +or qualify a package version. Keep `BENCH_APP_DIR` on that cell's complete +installation, including its own `vendor` and the recipe files. + +Set `BENCH_PHP_IMAGE`, the resolved MySQL/Redis/Node images, an absolute +`BENCH_STATE_DIR`, a unique Compose project and a unique `PROFILE_NAME`. Create +the `runtime`, `collector` and `results` state subdirectories as UID 1000. +Keep all services on the disposable internal network; no public ports or +production credentials are needed. + +```sh +docker compose up -d mysql redis +docker compose run --rm prepare +docker compose up -d collector app --scale worker=4 worker +docker compose run --rm probe +docker compose stop worker app +docker compose run --rm prepare php app.php audit +docker compose stop collector +docker compose down --volumes --remove-orphans +``` + +Select `MEMBERS=64` and `OFFERED_RATE=0.25` for fan-out. Short functional +controls use one offered workflow; their durations and results are not a +measured comparison. Use a fresh project/database for the next cell. +Keep the collector running until all workers and HTTP requests have stopped +and their provider shutdown exports have arrived. Compare ended and received +span counts before stopping it. + +Sample every role with the Docker Engine's cumulative CPU and memory counters, +including MySQL, Redis, the collector and the probe. Bound the sampling interval +and report its resolution. Record component and total CPU per completed +workflow, throughput, p50/p95/p99 latency, memory, original deadlines, failed +jobs, retries/repairs, provider errors, ended/received spans and metadata bytes. +Use `app.php audit` only after workers finish; it refuses unfinished work, +extra attempts, duplicate/missing callbacks and queue residue. Automatic +ready-task redispatch counts remain visible and their processing cost is +included. A redispatch of an unclaimed task is distinct from another activity +invocation; the callback and first-attempt checks remain strict. + +CPU sampling must include the final provider drain. If the last sample loses +shutdown work or noise masks the 1% target, report that limitation and leave the +target unqualified. Do not turn rounded `docker stats` percentages into precise +CPU totals. The runtime's `ended.jsonl` usage fields are cumulative per process; +use the last value for each container/PID pair rather than summing repeated +HTTP snapshots. Separate containers can use the same PID. +Record ordinary worker returns, signal termination, forced stops and OOM +separately. Preserve failures in the readable result, then remove disposable +databases, provider bytes, images, dependencies and raw samples after review. diff --git a/.github/tracing-performance/app.php b/.github/tracing-performance/app.php new file mode 100644 index 000000000..378105761 --- /dev/null +++ b/.github/tracing-performance/app.php @@ -0,0 +1,199 @@ + ['providers' => [WorkflowServiceProvider::class], 'dont-discover' => ['*']], +]); +$mode = PHP_SAPI === 'cli-server' ? 'http' : ($argv[1] ?? ''); +if ($mode === 'prepare') { + if (Schema::hasTable('migrations')) { + throw new RuntimeException('Prepare requires a fresh disposable database.'); + } + if (Artisan::call('migrate', ['--force' => true]) !== 0) { + throw new RuntimeException(Artisan::output()); + } + if (config('queue.default') === 'database') { + Schema::create('jobs', static function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + } + Schema::create('failed_jobs', static function (Blueprint $table): void { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + Schema::create('benchmark_callbacks', static function (Blueprint $table): void { + $table->string('activity_id')->primary(); + $table->string('run_id'); + $table->unsignedInteger('member'); + $table->unique(['run_id', 'member']); + }); + echo json_encode(['prepared' => true, 'framework' => $app->version()], JSON_THROW_ON_ERROR), PHP_EOL; + exit(0); +} +if ($mode === 'audit') { + $tables = ['workflow_runs', 'workflow_tasks', 'activity_executions']; + $metadata = []; + foreach ($tables as $table) { + $metadata[$table] = Schema::hasColumn($table, 'observability_context') + ? (int) DB::table($table)->selectRaw('COALESCE(SUM(OCTET_LENGTH(observability_context)), 0) AS bytes') + ->value('bytes') : 0; + } + $result = [ + 'runs' => WorkflowRun::query()->count(), + 'unfinished_runs' => WorkflowRun::query()->where('status', '!=', 'completed')->count(), + 'activities' => ActivityExecution::query()->count(), + 'unfinished_activities' => ActivityExecution::query()->where('status', '!=', 'completed')->count(), + 'tasks' => WorkflowTask::query()->count(), + 'unfinished_tasks' => WorkflowTask::query()->where('status', '!=', 'completed')->count(), + 'nonfirst_tasks' => WorkflowTask::query()->where('attempt_count', '!=', 1)->count(), + 'repairs' => (int) WorkflowTask::query()->sum('repair_count'), + 'unfinished_attempts' => ActivityAttempt::query()->where('status', '!=', 'completed')->count(), + 'nonfirst_attempts' => ActivityAttempt::query()->where('attempt_number', '!=', 1)->count(), + 'callbacks' => DB::table('benchmark_callbacks')->count(), + 'queue' => $app['queue']->connection()->size(), + 'failed_jobs' => DB::table('failed_jobs')->count(), + 'history_types' => WorkflowHistoryEvent::query()->selectRaw('event_type, COUNT(*) AS count') + ->groupBy('event_type')->pluck('count', 'event_type')->all(), + 'metadata_bytes' => $metadata, + ]; + echo json_encode($result, JSON_THROW_ON_ERROR), PHP_EOL; + exit((array_sum(array_intersect_key($result, array_flip([ + 'unfinished_runs', 'unfinished_activities', 'unfinished_tasks', 'nonfirst_tasks', + 'unfinished_attempts', + 'nonfirst_attempts', 'queue', 'failed_jobs', + ]))) === 0 && $result['callbacks'] === $result['activities']) ? 0 : 1); +} +if (! in_array($mode, ['work', 'http'], true)) { + throw new RuntimeException('Expected prepare, work, audit or the HTTP router.'); +} +$http = new HttpFactory(); +$exportScope = Configurator::createNoop()->storeInContext(Context::getRoot())->activate(); +try { + $transport = (new PsrTransportFactory( + new Client(['timeout' => 0.1, 'connect_timeout' => 0.1]), $http, $http, + ))->create(getenv('BENCH_COLLECTOR') ?: 'http://collector:4318/v1/traces', ContentTypes::PROTOBUF, maxRetries: 0); + $processor = new CountingProcessor(new BatchSpanProcessor( + new SpanExporter($transport), Clock::getDefault(), + maxQueueSize: 256, maxExportBatchSize: 64, autoFlush: false, + )); +} finally { + $exportScope->detach(); +} +$provider = new TracerProvider([$processor]); +$configuration = Configurator::create()->withTracerProvider($provider) + ->withPropagator(TraceContextPropagator::getInstance())->activate(); +$exitCode = 0; +try { + if ($mode === 'work') { + $nextFlush = hrtime(true) + 1_000_000_000; + $app['events']->listen(\Illuminate\Queue\Events\Looping::class, static function () use ( + $provider, + &$nextFlush + ): void { + if (hrtime(true) >= $nextFlush) { + $provider->forceFlush(); + $nextFlush = hrtime(true) + 1_000_000_000; + } + }); + $exitCode = Artisan::call('queue:work', [ + 'connection' => config('queue.default'), '--queue' => 'default', '--sleep' => 1, + '--tries' => 1, '--timeout' => 125, '--max-time' => 900, '--memory' => 256, + ]); + } else { + respond(); + } +} finally { + $configuration->detach(); + $provider->shutdown(); +} +exit($exitCode); + +function respond(): void +{ + header('Content-Type: application/json'); + $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); + if ($_SERVER['REQUEST_METHOD'] === 'GET' && $path === '/health') { + echo json_encode([ + 'ready' => true, + 'queue_wake_enabled' => (bool) config('workflows.v2.matching_role.queue_wake_enabled'), + 'queue_driver' => config('queue.default'), + ], JSON_THROW_ON_ERROR); + return; + } + $id = $path === '/workflows' ? null : substr($path, strlen('/workflows/')); + if ($_SERVER['REQUEST_METHOD'] === 'POST' && $path === '/workflows') { + $input = json_decode(file_get_contents('php://input'), true, flags: JSON_THROW_ON_ERROR); + if (! is_string($input['id'] ?? null) || ! preg_match('/^[a-z0-9-]{1,80}$/D', $input['id']) + || ! in_array($input['members'] ?? null, [1, 64], true)) { + throw new RuntimeException('Expected a synthetic id and 1 or 64 members.'); + } + $scope = TraceContextPropagator::getInstance()->extract(array_change_key_case(getallheaders()))->activate(); + try { + WorkflowStub::make(BenchmarkWorkflow::class, $input['id'], 'default')->start( + new StartOptions(executionTimeoutSeconds: 120, runTimeoutSeconds: 100), $input['members'], + ); + } finally { + $scope->detach(); + } + $id = $input['id']; + } elseif ($_SERVER['REQUEST_METHOD'] !== 'GET' || ! str_starts_with($path, '/workflows/')) { + http_response_code(404); + echo '{}'; + return; + } + $run = WorkflowRun::query()->where('workflow_instance_id', $id)->where('namespace', 'default')->sole(); + echo json_encode([ + 'id' => $id, 'run_id' => $run->id, 'status' => $run->status->value, + 'execution_deadline' => $run->getRawOriginal('execution_deadline_at'), + 'run_deadline' => $run->getRawOriginal('run_deadline_at'), 'output' => $run->workflowOutput(), + ], JSON_THROW_ON_ERROR); +} diff --git a/.github/tracing-performance/collector.php b/.github/tracing-performance/collector.php new file mode 100644 index 000000000..37874e42d --- /dev/null +++ b/.github/tracing-performance/collector.php @@ -0,0 +1,18 @@ +mergeFromString(file_get_contents('php://input')); +$count = 0; +foreach ($request->getResourceSpans() as $resource) { + foreach ($resource->getScopeSpans() as $scope) { + $count += count($scope->getSpans()); + } +} +file_put_contents(getenv('BENCH_STATE') . '/received.jsonl', json_encode([ + 'spans' => $count, 'bytes' => strlen($request->serializeToString()), +], JSON_THROW_ON_ERROR) . PHP_EOL, FILE_APPEND | LOCK_EX); +header('Content-Type: application/x-protobuf'); +echo (new Opentelemetry\Proto\Collector\Trace\V1\ExportTraceServiceResponse())->serializeToString(); diff --git a/.github/tracing-performance/compose.yaml b/.github/tracing-performance/compose.yaml new file mode 100644 index 000000000..d585eeeb5 --- /dev/null +++ b/.github/tracing-performance/compose.yaml @@ -0,0 +1,117 @@ +x-runtime: &runtime + image: ${BENCH_PHP_IMAGE:?Set the qualified PHP image} + user: "1000:1000" + entrypoint: [] + init: true + stop_signal: SIGTERM + healthcheck: {disable: true} + working_dir: /app + volumes: + - ${BENCH_APP_DIR:-.}:/app:ro + - ${BENCH_STATE_DIR:-./state}/runtime:/state + environment: + DB_HOST: mysql + DB_PORT: "3306" + DB_DATABASE: benchmark + DB_USERNAME: benchmark + DB_PASSWORD: disposable-benchmark + REDIS_HOST: redis + BENCH_STATE: /state + BENCH_TRACING: ${BENCH_TRACING:-false} + BENCH_QUEUE_WAKE: ${BENCH_QUEUE_WAKE:-true} + BENCH_QUEUE_DRIVER: ${BENCH_QUEUE_DRIVER:-database} + BENCH_COLLECTOR: http://collector:4318/v1/traces + depends_on: + mysql: {condition: service_healthy} + redis: {condition: service_healthy} + +services: + mysql: + image: ${BENCH_MYSQL_IMAGE:-mysql:8.4} + cpus: 1 + mem_limit: 2g + memswap_limit: 2g + command: ["--innodb-buffer-pool-size=536870912", "--skip-log-bin"] + environment: + MYSQL_ROOT_PASSWORD: disposable-root-benchmark + MYSQL_DATABASE: benchmark + MYSQL_USER: benchmark + MYSQL_PASSWORD: disposable-benchmark + volumes: ["database:/var/lib/mysql"] + healthcheck: + test: ["CMD-SHELL", "MYSQL_PWD=$$MYSQL_PASSWORD mysql --user=$$MYSQL_USER --database=$$MYSQL_DATABASE --host=127.0.0.1 -Nse 'SELECT 1'"] + interval: 2s + timeout: 2s + retries: 60 + redis: + image: ${BENCH_REDIS_IMAGE:-redis:7.4} + user: "1000:1000" + cpus: 0.1 + mem_limit: 128m + memswap_limit: 128m + command: ["redis-server", "--save", "", "--appendonly", "no"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 2s + retries: 20 + prepare: + <<: *runtime + profiles: ["tools"] + cpus: 0.5 + mem_limit: 512m + memswap_limit: 512m + command: ["php", "app.php", "prepare"] + app: + <<: *runtime + cpus: 0.5 + mem_limit: 512m + memswap_limit: 512m + command: ["php", "-S", "0.0.0.0:8080", "app.php"] + worker: + <<: *runtime + cpus: 1 + mem_limit: 384m + memswap_limit: 384m + stop_grace_period: 10s + command: ["php", "app.php", "work"] + collector: + image: ${BENCH_PHP_IMAGE:?Set the qualified PHP image} + user: "1000:1000" + entrypoint: [] + init: true + stop_signal: SIGTERM + healthcheck: {disable: true} + working_dir: /app + cpus: 0.2 + mem_limit: 256m + memswap_limit: 256m + volumes: + - ${BENCH_APP_DIR:-.}:/app:ro + - ${BENCH_STATE_DIR:-./state}/collector:/state + environment: {BENCH_STATE: /state} + command: ["php", "-S", "0.0.0.0:4318", "collector.php"] + probe: + image: ${BENCH_NODE_IMAGE:-node:22-alpine} + user: "1000:1000" + working_dir: /app + profiles: ["tools"] + cpus: 0.2 + mem_limit: 256m + memswap_limit: 256m + volumes: + - ${BENCH_APP_DIR:-.}:/app:ro + - ${BENCH_STATE_DIR:-./state}/results:/results + environment: + BASE_URL: http://app:8080 + PROFILE_NAME: ${PROFILE_NAME:?Set a unique synthetic profile} + WORKLOAD_NAME: ${WORKLOAD_NAME:-} + MEMBERS: ${MEMBERS:-1} + OFFERED_RATE: ${OFFERED_RATE:-5} + DURATION_SECONDS: ${DURATION_SECONDS:-120} + DIAGNOSTIC_ONLY: ${DIAGNOSTIC_ONLY:-false} + BENCH_QUEUE_DRIVER: ${BENCH_QUEUE_DRIVER:-database} + command: ["node", "probe.mjs"] + +volumes: + database: diff --git a/.github/tracing-performance/composer.json b/.github/tracing-performance/composer.json new file mode 100644 index 000000000..b9b7735ad --- /dev/null +++ b/.github/tracing-performance/composer.json @@ -0,0 +1,21 @@ +{ + "name": "durable-workflow/tracing-performance", + "description": "Disposable embedded tracing overhead workload", + "type": "project", + "license": "MIT", + "require": { + "php": "^8.3", + "ext-pdo_mysql": "*", + "ext-redis": "*", + "durable-workflow/workflow": "2.5.28", + "guzzlehttp/guzzle": "8.2.0", + "laravel/framework": "13.37.0", + "open-telemetry/api": "1.10.0", + "open-telemetry/context": "1.5.0", + "open-telemetry/exporter-otlp": "^1.4", + "open-telemetry/sdk": "1.15.0", + "orchestra/testbench": "11.3.0" + }, + "autoload": {"psr-4": {"TracingBenchmark\\": "src/"}}, + "config": {"allow-plugins": false, "sort-packages": true} +} diff --git a/.github/tracing-performance/composer.lock b/.github/tracing-performance/composer.lock new file mode 100644 index 000000000..9edbaacfa --- /dev/null +++ b/.github/tracing-performance/composer.lock @@ -0,0 +1,9703 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "78035009973f2315ac4380270d94bcfd", + "packages": [ + { + "name": "apache/avro", + "version": "1.12.2", + "source": { + "type": "git", + "url": "https://github.com/apache/avro.git", + "reference": "8fa2067f70e3012cb3fd9a8839cd97e8c7cc1772" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/apache/avro/zipball/8fa2067f70e3012cb3fd9a8839cd97e8c7cc1772", + "reference": "8fa2067f70e3012cb3fd9a8839cd97e8c7cc1772", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-curl": "*", + "ext-json": "*", + "ext-pcntl": "*", + "ext-xml": "*", + "php-mock/php-mock-phpunit": "^2.10", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "squizlabs/php_codesniffer": "3.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Apache\\Avro\\": "lang/php/lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Apache Avro Developers", + "email": "dev@avro.apache.org", + "homepage": "http://avro.apache.org" + } + ], + "description": "Apache Avro™ is a data serialization system.", + "homepage": "http://avro.apache.org", + "keywords": [ + "avro", + "data", + "serialization" + ], + "support": { + "email": "dev@avro.apache.org", + "issues": "https://issues.apache.org/jira/browse/AVRO", + "source": "https://github.com/apache/avro/tree/release-1.12.2" + }, + "time": "2026-08-07T12:56:37+00:00" + }, + { + "name": "brick/math", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "2effe05d2177c451b86c6a073196a4034c02f211" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/2effe05d2177c451b86c6a073196a4034c02f211", + "reference": "2effe05d2177c451b86c6a073196a4034c02f211", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "phpstan/phpstan": "2.2.13", + "phpstan/phpstan-phpunit": "2.0.18", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-09-12T10:28:18+00:00" + }, + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2026-09-06T14:11:38+00:00" + }, + { + "name": "composer/semver", + "version": "3.5.1", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "528948210cc46f967a4b98e83b19e76f2b504993" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/528948210cc46f967a4b98e83b19e76f2b504993", + "reference": "528948210cc46f967a4b98e83b19e76f2b504993", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Version comparison library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.5.1" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-10-05T13:12:12+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "71d305eaa6480d3d3460c7d053d1da7749fa9cca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/71d305eaa6480d3d3460c7d053d1da7749fa9cca", + "reference": "71d305eaa6480d3d3460c7d053d1da7749fa9cca", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10.5.58 || ^12.5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.3" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2026-10-01T06:59:29+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, + { + "name": "durable-workflow/workflow", + "version": "2.5.28", + "source": { + "type": "git", + "url": "https://github.com/durable-workflow/workflow.git", + "reference": "592dee305f46ac707baadf55324407c43aeb2dc5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/592dee305f46ac707baadf55324407c43aeb2dc5", + "reference": "592dee305f46ac707baadf55324407c43aeb2dc5", + "shasum": "" + }, + "require": { + "apache/avro": "^1.12", + "laravel/framework": "^9.52.17|^10.48.29|^11.44.1|^12.69.0|^13.30.0", + "php": "^8.1", + "react/promise": "^2.9|^3.0", + "symfony/yaml": "^6.0|^7.0|^8.0" + }, + "replace": { + "laravel-workflow/laravel-workflow": "self.version" + }, + "require-dev": { + "doctrine/dbal": "^3.7", + "orchestra/testbench": "^8.0|^11.0", + "phpstan/phpstan": "^2.0", + "symplify/easy-coding-standard": "^11.0" + }, + "suggest": { + "durable-workflow/sdk": "Framework-neutral client and remote-worker SDK for the standalone Durable Workflow server." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Workflow\\Providers\\WorkflowServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "2.0.x-dev" + }, + "durable-workflow": { + "product-train": "2.5.28", + "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", + "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" + } + }, + "autoload": { + "files": [ + "src/functions.php", + "src/V2/functions.php" + ], + "psr-4": { + "Workflow\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Durable Workflow", + "email": "support@durable-workflow.com" + } + ], + "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.5.28" + }, + "time": "2026-10-16T21:07:01+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.6", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "7d0a2cde892089bb09372f4366e9b960eb3001a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/7d0a2cde892089bb09372f4366e9b960eb3001a9", + "reference": "7d0a2cde892089bb09372f4366e9b960eb3001a9", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3 || ^10.5.58", + "symfony/var-dumper": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.6" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2026-10-03T17:03:57+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "google/protobuf", + "version": "v5.36.2", + "source": { + "type": "git", + "url": "https://github.com/protocolbuffers/protobuf-php.git", + "reference": "020bc8e831611869ac387ddd38fbbadd78d15195" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/020bc8e831611869ac387ddd38fbbadd78d15195", + "reference": "020bc8e831611869ac387ddd38fbbadd78d15195", + "shasum": "" + }, + "require": { + "php": ">=8.2.0" + }, + "require-dev": { + "phpunit/phpunit": ">=11.5.50 <12.0.0" + }, + "suggest": { + "ext-bcmath": "Need to support JSON deserialization" + }, + "type": "library", + "autoload": { + "psr-4": { + "Google\\Protobuf\\": "src/Google/Protobuf", + "GPBMetadata\\Google\\Protobuf\\": "src/GPBMetadata/Google/Protobuf" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "proto library for PHP", + "homepage": "https://developers.google.com/protocol-buffers/", + "keywords": [ + "proto" + ], + "support": { + "source": "https://github.com/protocolbuffers/protobuf-php/tree/v5.36.2" + }, + "time": "2026-09-17T15:56:42+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/adccca3324eece92ca35463648c12b9e6293c05b", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.10" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.52 || ^9.6.34 || ^10.5.63 || ^11.5.55 || ^12.5.14" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:06:52+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "8.2.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "93939470950a9b11e2e84204166ef5e048c55fe4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/93939470950a9b11e2e84204166ef5e048c55fe4", + "reference": "93939470950a9b11e2e84204166ef5e048c55fe4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^3.0.2", + "guzzlehttp/psr7": "^3.1", + "php": "^7.4 || ^8.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "4.0.1", + "guzzlehttp/test-server": "^1.0.1", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/8.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-09-06T13:55:09+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "42118e66a53c492effaf92bc357e931985d5c6f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/42118e66a53c492effaf92bc357e931985d5c6f9", + "reference": "42118e66a53c492effaf92bc357e931985d5c6f9", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-08-24T10:00:26+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/a3059ba1a84c9139c4ae03cf0f45bea276c97c74", + "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" + }, + "provide": { + "psr/http-factory-implementation": "1.1", + "psr/http-message-implementation": "2.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "php-http/psr7-integration-tests": "^1.5.1", + "phpunit/phpunit": "^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/3.1.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-08-24T11:02:13+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "7a466ad606491eb6528c717482f7cca77f1851f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/7a466ad606491eb6528c717482f7cca77f1851f3", + "reference": "7a466ad606491eb6528c717482f7cca77f1851f3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "symfony/polyfill-php80": "^1.25" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^9.6.34", + "uri-template/tests": "1.0.2" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v2.0.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2026-08-24T17:13:02+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-dom": "*", + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v3.0.0" + }, + "time": "2026-03-17T11:56:53+00:00" + }, + { + "name": "laravel/framework", + "version": "v13.37.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "685f8d6c2ab4547287500bc80ccdf2ca12635109" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/685f8d6c2ab4547287500bc80ccdf2ca12635109", + "reference": "685f8d6c2ab4547287500bc80ccdf2ca12635109", + "shasum": "" + }, + "require": { + "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.9 || ^3.0", + "guzzlehttp/uri-template": "^1.0 || ^2.0", + "laravel/prompts": "^0.3.25", + "laravel/serializable-closure": "^2.0.10", + "league/commonmark": "^2.8.1", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.10", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "psr/http-message": "^1.0 || ^2.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.4.0 || ^8.0.0", + "symfony/error-handler": "^7.4.0 || ^8.0.0", + "symfony/finder": "^7.4.0 || ^8.0.0", + "symfony/http-foundation": "^7.4.0 || ^8.0.0", + "symfony/http-kernel": "^7.4.0 || ^8.0.0", + "symfony/mailer": "^7.4.0 || ^8.0.0", + "symfony/mime": "^7.4.0 || ^8.0.0", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", + "symfony/polyfill-php86": "^1.36", + "symfony/process": "^7.4.5 || ^8.0.5", + "symfony/routing": "^7.4.0 || ^8.0.0", + "symfony/uid": "^7.4.0 || ^8.0.0", + "symfony/var-dumper": "^7.4.0 || ^8.0.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1 || 2.0", + "psr/log-implementation": "1.0 || 2.0 || 3.0", + "psr/simple-cache-implementation": "1.0 || 2.0 || 3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/image": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "intervention/image": "^4.2", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^11.6.0", + "pda/pheanstalk": "^7.0.0 || ^8.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "2.3.1", + "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", + "predis/predis": "^2.3 || ^3.0", + "rector/rector": "2.6.3", + "resend/resend-php": "^1.0", + "symfony/cache": "^7.4.0 || ^8.0.0", + "symfony/http-client": "^7.4.0 || ^8.0.0", + "symfony/mercure": "^0.8.0", + "symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0", + "symfony/translation": "^7.4.0 || ^8.0.0", + "web-token/jwt-library": "^4.1" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0 || ^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0 || ^5.0 || ^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "intervention/image": "Required to use the image processing features (^4.2).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^7.0 || ^8.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^11.5.50 || ^12.5.8 || ^13.0.3).", + "predis/predis": "Required to use the predis connector (^2.3 || ^3.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0 || ^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0 || ^1.0).", + "spatie/fork": "Required to use the 'fork' concurrency driver (^1.2).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.4 || ^8.0).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).", + "symfony/mercure": "Required to use the Mercure broadcast driver (^0.8).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0).", + "web-token/jwt-library": "Required to sign Mercure JWTs and use end-to-end encrypted channels (^4.1)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-10-16T04:59:27+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2026-05-20T22:24:57+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.26", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "2d1e9bf6bab8d3f383c254db7f2b4ea8816d55e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/2d1e9bf6bab8d3f383c254db7f2b4ea8816d55e0", + "reference": "2d1e9bf6bab8d3f383c254db7f2b4ea8816d55e0", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "source": "https://github.com/laravel/prompts/tree/v0.3.26" + }, + "time": "2026-10-09T14:45:13+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-09-22T14:32:34+00:00" + }, + { + "name": "laravel/tinker", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "4faba77764bd33411735936acdf30446d058c78b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/4faba77764bd33411735936acdf30446d058c78b", + "reference": "4faba77764bd33411735936acdf30446d058c78b", + "shasum": "" + }, + "require": { + "illuminate/console": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "psy/psysh": "^0.12.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5|^11.5" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^8.0|^9.0|^10.0|^11.0|^12.0|^13.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v3.0.2" + }, + "time": "2026-03-17T14:54:13+00:00" + }, + { + "name": "league/commonmark", + "version": "2.10.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f", + "reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.11-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-09-21T13:07:34+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.36.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.36.0" + }, + "time": "2026-09-02T08:00:27+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.35.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.35.3" + }, + "time": "2026-08-12T13:29:21+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.17.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2026-07-09T11:49:27+00:00" + }, + { + "name": "league/uri", + "version": "7.9.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "fcc872a3d872aa13f935e0cd1887b9abb06da07f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/fcc872a3d872aa13f935e0cd1887b9abb06da07f", + "reference": "fcc872a3d872aa13f935e0cd1887b9abb06da07f", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/uri-interfaces": "^7.9.0", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.9.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-10-16T15:19:33+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.9.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "eb5f6d24e6a4fe0a8e9c78ded4eb1dfff1404f58" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/eb5f6d24e6a4fe0a8e9c78ded4eb1dfff1404f58", + "reference": "eb5f6d24e6a4fe0a8e9c78ded4eb1dfff1404f58", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.9.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-10-16T15:19:33+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.15", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/967a801bd188989a5669bd280f252d51c0fdc9ee", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0 || ^3.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6.36", + "symplify/easy-coding-standard": "^13.2.17" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2026-08-19T19:37:52+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.12.1", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/d7059e4c691d05ea1222b5124bed0f961be46e15", + "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "psr/clock": "^1.0", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "psr/clock": "Required to pass a clock to the Logger and control the timestamp of log records", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.12.1" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-09-29T19:21:12+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.14.2", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "94cfc34887f07ab5c0efa07914c8f527544e3224" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/94cfc34887f07ab5c0efa07914c8f527544e3224", + "reference": "94cfc34887f07ab5c0efa07914c8f527544e3224", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^3.0@dev", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-10-02T07:26:52+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.6", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "c54350438cd6914616f790a49cb424605f421562" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562", + "reference": "c54350438cd6914616f790a49cb424605f421562", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.6" + }, + "time": "2026-08-16T21:58:41+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.5", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.5" + }, + "time": "2026-07-17T23:02:45+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.9.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" + }, + "time": "2026-09-13T18:51:52+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.9.5", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/fb53eacd509a1d303858e2d20cfebf2d630254ec", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.14 || ^8.1.1" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.10.0", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.20.0", + "laravel/pint": "^1.29.3", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.3.5", + "pestphp/pest": "^3.8.5 || ^4.7.5 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.1.2 || ^9.3.2" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-07-15T19:09:14+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "nyholm/psr7-server", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7-server.git", + "reference": "4335801d851f554ca43fa6e7d2602141538854dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7-server/zipball/4335801d851f554ca43fa6e7d2602141538854dc", + "reference": "4335801d851f554ca43fa6e7d2602141538854dc", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "nyholm/nsa": "^1.1", + "nyholm/psr7": "^1.3", + "phpunit/phpunit": "^7.0 || ^8.5 || ^9.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Nyholm\\Psr7Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "Helper classes to handle PSR-7 server requests", + "homepage": "http://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7-server/issues", + "source": "https://github.com/Nyholm/psr7-server/tree/1.1.0" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2023-11-08T09:30:43+00:00" + }, + { + "name": "open-telemetry/api", + "version": "1.10.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/api.git", + "reference": "7c029c4a6fd457094a20569bf98f93d95e9a7559" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/7c029c4a6fd457094a20569bf98f93d95e9a7559", + "reference": "7c029c4a6fd457094a20569bf98f93d95e9a7559", + "shasum": "" + }, + "require": { + "open-telemetry/context": "^1.4", + "php": "^8.1", + "psr/log": "^1.1|^2.0|^3.0", + "symfony/polyfill-php82": "^1.26" + }, + "conflict": { + "open-telemetry/sdk": "<=1.11" + }, + "type": "library", + "extra": { + "spi": { + "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\HookManagerInterface": [ + "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\ExtensionHookManager" + ] + }, + "branch-alias": { + "dev-main": "1.8.x-dev" + } + }, + "autoload": { + "files": [ + "Trace/functions.php" + ], + "psr-4": { + "OpenTelemetry\\API\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "API for OpenTelemetry PHP.", + "keywords": [ + "Metrics", + "api", + "apm", + "logging", + "opentelemetry", + "otel", + "tracing" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2026-07-06T12:28:04+00:00" + }, + { + "name": "open-telemetry/context", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/context.git", + "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/3c414b246e0dabb7d6145404e6a5e4536ca18d07", + "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/polyfill-php82": "^1.26" + }, + "suggest": { + "ext-ffi": "To allow context switching in Fibers" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0.x-dev" + } + }, + "autoload": { + "files": [ + "fiber/initialize_fiber_handler.php" + ], + "psr-4": { + "OpenTelemetry\\Context\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "Context implementation for OpenTelemetry PHP.", + "keywords": [ + "Context", + "opentelemetry", + "otel" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2025-10-19T06:44:33+00:00" + }, + { + "name": "open-telemetry/exporter-otlp", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/exporter-otlp.git", + "reference": "283a0d66522f2adc6d8d7debfd7686be91c282be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/283a0d66522f2adc6d8d7debfd7686be91c282be", + "reference": "283a0d66522f2adc6d8d7debfd7686be91c282be", + "shasum": "" + }, + "require": { + "open-telemetry/api": "^1.0", + "open-telemetry/gen-otlp-protobuf": "^1.1", + "open-telemetry/sdk": "^1.0", + "php": "^8.1", + "php-http/discovery": "^1.14" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0.x-dev" + } + }, + "autoload": { + "files": [ + "_register.php" + ], + "psr-4": { + "OpenTelemetry\\Contrib\\Otlp\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "OTLP exporter for OpenTelemetry.", + "keywords": [ + "Metrics", + "exporter", + "gRPC", + "http", + "opentelemetry", + "otel", + "otlp", + "tracing" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2026-02-05T09:44:52+00:00" + }, + { + "name": "open-telemetry/gen-otlp-protobuf", + "version": "1.10.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/gen-otlp-protobuf.git", + "reference": "66f04d0e448ad333033bfc7baae1aa56330be088" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/66f04d0e448ad333033bfc7baae1aa56330be088", + "reference": "66f04d0e448ad333033bfc7baae1aa56330be088", + "shasum": "" + }, + "require": { + "google/protobuf": "^3.22 || ^4.0 || ^5.0", + "php": "^8.0" + }, + "suggest": { + "ext-protobuf": "For better performance, when dealing with the protobuf format" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opentelemetry\\Proto\\": "Opentelemetry/Proto/", + "GPBMetadata\\Opentelemetry\\": "GPBMetadata/Opentelemetry/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "PHP protobuf files for communication with OpenTelemetry OTLP collectors/servers.", + "keywords": [ + "Metrics", + "apm", + "gRPC", + "logging", + "opentelemetry", + "otel", + "otlp", + "protobuf", + "tracing" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2026-06-17T12:06:32+00:00" + }, + { + "name": "open-telemetry/sdk", + "version": "1.15.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/sdk.git", + "reference": "77e1aa73850154abb86937d52a70883edc3b4547" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/77e1aa73850154abb86937d52a70883edc3b4547", + "reference": "77e1aa73850154abb86937d52a70883edc3b4547", + "shasum": "" + }, + "require": { + "ext-json": "*", + "nyholm/psr7-server": "^1.1", + "open-telemetry/api": "^1.8", + "open-telemetry/context": "^1.4", + "open-telemetry/sem-conv": "^1.38.0", + "php": "^8.1", + "php-http/discovery": "^1.14", + "psr/http-client": "^1.0", + "psr/http-client-implementation": "^1.0", + "psr/http-factory-implementation": "^1.0", + "psr/http-message": "^1.0.1|^2.0", + "psr/log": "^1.1|^2.0|^3.0", + "ramsey/uuid": "^3.0 || ^4.0", + "symfony/polyfill-mbstring": "^1.23", + "symfony/polyfill-php82": "^1.26", + "tbachert/spi": "^1.0.5" + }, + "suggest": { + "ext-gmp": "To support unlimited number of synchronous metric readers", + "ext-mbstring": "To increase performance of string operations", + "open-telemetry/sdk-configuration": "File-based OpenTelemetry SDK configuration" + }, + "type": "library", + "extra": { + "spi": { + "OpenTelemetry\\API\\Configuration\\ConfigEnv\\EnvComponentLoader": [ + "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderHttpConfig", + "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderPeerConfig", + "OpenTelemetry\\SDK\\ConfigEnv\\Trace\\SpanSuppressionStrategySemConv", + "OpenTelemetry\\SDK\\ConfigEnv\\Trace\\SpanSuppressionStrategySpanKind", + "OpenTelemetry\\SDK\\ConfigEnv\\Distribution\\DistributionConfigurationSdk" + ], + "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\ResolverInterface": [ + "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\SdkConfigurationResolver" + ], + "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\HookManagerInterface": [ + "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\ExtensionHookManager" + ] + }, + "branch-alias": { + "dev-main": "1.14.x-dev" + } + }, + "autoload": { + "files": [ + "Common/Util/functions.php", + "Logs/Exporter/_register.php", + "Metrics/MetricExporter/_register.php", + "Propagation/_register.php", + "Trace/SpanExporter/_register.php", + "Common/Dev/Compatibility/_load.php", + "_autoload.php" + ], + "psr-4": { + "OpenTelemetry\\SDK\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "SDK for OpenTelemetry PHP.", + "keywords": [ + "Metrics", + "apm", + "logging", + "opentelemetry", + "otel", + "sdk", + "tracing" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2026-07-14T13:09:54+00:00" + }, + { + "name": "open-telemetry/sem-conv", + "version": "1.44.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/sem-conv.git", + "reference": "c94bc7ebacef039cda4f4c9270807dcc58b4e3af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/c94bc7ebacef039cda4f4c9270807dcc58b4e3af", + "reference": "c94bc7ebacef039cda4f4c9270807dcc58b4e3af", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "OpenTelemetry\\SemConv\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "Semantic conventions for OpenTelemetry PHP.", + "keywords": [ + "Metrics", + "apm", + "logging", + "opentelemetry", + "otel", + "semantic conventions", + "semconv", + "tracing" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2026-09-09T22:06:17+00:00" + }, + { + "name": "orchestra/canvas", + "version": "v11.1.0", + "source": { + "type": "git", + "url": "https://github.com/orchestral/canvas.git", + "reference": "d13e6ec66cb1a285fcc7f092c804abaaf2e24ede" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/orchestral/canvas/zipball/d13e6ec66cb1a285fcc7f092c804abaaf2e24ede", + "reference": "d13e6ec66cb1a285fcc7f092c804abaaf2e24ede", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "composer/semver": "^3.0", + "illuminate/console": "^13.23.0", + "illuminate/database": "^13.23.0", + "illuminate/filesystem": "^13.23.0", + "illuminate/support": "^13.23.0", + "orchestra/canvas-core": "^11.0.0", + "orchestra/sidekick": "~1.1.23|~1.2.20", + "orchestra/testbench-core": "^11.0.0", + "php": "^8.3", + "symfony/yaml": "^7.4.0|^8.0.0" + }, + "require-dev": { + "laravel/doctor": "^0.1.0", + "laravel/framework": "^13.23.0", + "laravel/pint": "^1.24", + "mockery/mockery": "^1.6.10", + "phpstan/phpstan": "^2.1.14", + "phpunit/phpunit": "^11.5.50|^12.5.8|^13.0.0" + }, + "bin": [ + "canvas" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Orchestra\\Canvas\\LaravelServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Orchestra\\Canvas\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Mior Muhammad Zaki", + "email": "crynobone@gmail.com" + } + ], + "description": "Code Generators for Laravel Applications and Packages", + "support": { + "issues": "https://github.com/orchestral/canvas/issues", + "source": "https://github.com/orchestral/canvas/tree/v11.1.0" + }, + "time": "2026-08-04T15:06:43+00:00" + }, + { + "name": "orchestra/canvas-core", + "version": "v11.0.0", + "source": { + "type": "git", + "url": "https://github.com/orchestral/canvas-core.git", + "reference": "88d091ff989748e2ca447bca0cd06ab14671ba82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/orchestral/canvas-core/zipball/88d091ff989748e2ca447bca0cd06ab14671ba82", + "reference": "88d091ff989748e2ca447bca0cd06ab14671ba82", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "composer/semver": "^3.0", + "illuminate/console": "^13.0", + "illuminate/support": "^13.0", + "orchestra/sidekick": "~1.1.23|~1.2.20", + "php": "^8.3" + }, + "require-dev": { + "laravel/framework": "^13.0", + "laravel/pint": "^1.24", + "mockery/mockery": "^1.6.10", + "orchestra/testbench-core": "^11.0", + "phpstan/phpstan": "^2.1.17", + "phpunit/phpunit": "^11.5.50|^12.5.8|^13.0.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Orchestra\\Canvas\\Core\\LaravelServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Orchestra\\Canvas\\Core\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Mior Muhammad Zaki", + "email": "crynobone@gmail.com" + } + ], + "description": "Code Generators Builder for Laravel Applications and Packages", + "support": { + "issues": "https://github.com/orchestral/canvas/issues", + "source": "https://github.com/orchestral/canvas-core/tree/v11.0.0" + }, + "time": "2026-03-16T15:10:50+00:00" + }, + { + "name": "orchestra/sidekick", + "version": "v1.2.23", + "source": { + "type": "git", + "url": "https://github.com/orchestral/sidekick.git", + "reference": "cbdfe583afc069be2ca6790489e9dda007d14d00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/orchestral/sidekick/zipball/cbdfe583afc069be2ca6790489e9dda007d14d00", + "reference": "cbdfe583afc069be2ca6790489e9dda007d14d00", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "composer/semver": "^3.0", + "php": "^8.1", + "symfony/polyfill-php83": "^1.32" + }, + "require-dev": { + "fakerphp/faker": "^1.21", + "laravel/framework": "^10.48.29|^11.44.7|^12.1.1|^13.0", + "laravel/pint": "^1.4", + "mockery/mockery": "^1.5.1", + "orchestra/testbench-core": "^8.37.0|^9.14.0|^10.2.0|^11.0", + "phpstan/phpstan": "2.2.14", + "phpunit/phpunit": "^10.0|^11.0|^12.0|^13.0", + "symfony/process": "^6.0|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Eloquent/functions.php", + "src/Filesystem/functions.php", + "src/Http/functions.php", + "src/functions.php" + ], + "psr-4": { + "Orchestra\\Sidekick\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mior Muhammad Zaki", + "email": "crynobone@gmail.com" + } + ], + "description": "Packages Toolkit Utilities and Helpers for Laravel", + "support": { + "issues": "https://github.com/orchestral/sidekick/issues", + "source": "https://github.com/orchestral/sidekick/tree/v1.2.23" + }, + "time": "2026-09-29T13:27:26+00:00" + }, + { + "name": "orchestra/testbench", + "version": "v11.3.0", + "source": { + "type": "git", + "url": "https://github.com/orchestral/testbench.git", + "reference": "7081895e5bbbdf2fd333fdf34e8ddec6cd4db713" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/orchestral/testbench/zipball/7081895e5bbbdf2fd333fdf34e8ddec6cd4db713", + "reference": "7081895e5bbbdf2fd333fdf34e8ddec6cd4db713", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "fakerphp/faker": "^1.23", + "laravel/framework": "^13.32.0", + "mockery/mockery": "^1.6.10", + "orchestra/testbench-core": "^11.5.0", + "orchestra/workbench": "^11.3.0", + "php": "^8.3", + "phpunit/phpunit": "^11.5.50|^12.5.8|^13.0.0", + "symfony/process": "^7.4.5|^8.0.5", + "symfony/yaml": "^7.4|^8.0", + "vlucas/phpdotenv": "^5.6.1" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mior Muhammad Zaki", + "email": "crynobone@gmail.com", + "homepage": "https://github.com/crynobone" + } + ], + "description": "Laravel Testing Helper for Packages Development", + "homepage": "https://packages.tools/testbench/", + "keywords": [ + "BDD", + "TDD", + "dev", + "laravel", + "laravel-packages", + "testing" + ], + "support": { + "issues": "https://github.com/orchestral/testbench/issues", + "source": "https://github.com/orchestral/testbench/tree/v11.3.0" + }, + "time": "2026-09-24T07:19:57+00:00" + }, + { + "name": "orchestra/testbench-core", + "version": "v11.6.0", + "source": { + "type": "git", + "url": "https://github.com/orchestral/testbench-core.git", + "reference": "f139a4ee74638dc24e0b6f657b47226c8ba5ab05" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/orchestral/testbench-core/zipball/f139a4ee74638dc24e0b6f657b47226c8ba5ab05", + "reference": "f139a4ee74638dc24e0b6f657b47226c8ba5ab05", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "orchestra/sidekick": "~1.1.25|~1.2.22", + "php": "^8.3", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-php84": "^1.34.0" + }, + "conflict": { + "brianium/paratest": "<7.3.0|>=8.0.0", + "laravel/framework": "<13.35.0|>=14.0.0", + "laravel/serializable-closure": ">=2.0.0 <2.0.10|>=3.0.0", + "nunomaduro/collision": "<8.9.0|>=9.0.0", + "phpunit/phpunit": "<11.5.50|>=12.0.0 <12.5.8|>=13.5.0" + }, + "require-dev": { + "fakerphp/faker": "^1.24", + "laravel/framework": "^13.35.0", + "laravel/pint": "^1.24", + "laravel/serializable-closure": "^2.0.10", + "mockery/mockery": "^1.6.10", + "phpstan/phpstan": "2.2.16", + "phpunit/phpunit": "^11.5.50|^12.5.8|^13.0.0", + "spatie/laravel-ray": "^1.43.6", + "symfony/process": "^7.4.5|^8.0.5", + "symfony/yaml": "^7.4.0|^8.0.0", + "vlucas/phpdotenv": "^5.6.1" + }, + "suggest": { + "brianium/paratest": "Allow using parallel testing (^7.3).", + "ext-pcntl": "Required to use all features of the console signal trapping.", + "fakerphp/faker": "Allow using Faker for testing (^1.23).", + "laravel/framework": "Required for testing (^13.35.0).", + "mockery/mockery": "Allow using Mockery for testing (^1.6).", + "nunomaduro/collision": "Allow using Laravel style tests output and parallel testing (^8.9).", + "orchestra/testbench-dusk": "Allow using Laravel Dusk for testing (^11.0).", + "phpunit/phpunit": "Allow using PHPUnit for testing (^11.5.50|^12.5.8|^13.0.0).", + "symfony/process": "Required to use Orchestra\\Testbench\\remote function (^7.4|^8.0).", + "symfony/yaml": "Required for Testbench CLI (^7.4|^8.0).", + "vlucas/phpdotenv": "Required for Testbench CLI (^5.6.1)." + }, + "bin": [ + "testbench" + ], + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Orchestra\\Testbench\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mior Muhammad Zaki", + "email": "crynobone@gmail.com", + "homepage": "https://github.com/crynobone" + } + ], + "description": "Testing Helper for Laravel Development", + "homepage": "https://packages.tools/testbench", + "keywords": [ + "BDD", + "TDD", + "dev", + "laravel", + "laravel-packages", + "testing" + ], + "support": { + "issues": "https://github.com/orchestral/testbench/issues", + "source": "https://github.com/orchestral/testbench-core" + }, + "time": "2026-10-07T23:31:28+00:00" + }, + { + "name": "orchestra/workbench", + "version": "v11.3.0", + "source": { + "type": "git", + "url": "https://github.com/orchestral/workbench.git", + "reference": "660046639af4538d6c56373c9222ca4c93055c42" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/orchestral/workbench/zipball/660046639af4538d6c56373c9222ca4c93055c42", + "reference": "660046639af4538d6c56373c9222ca4c93055c42", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "fakerphp/faker": "^1.23", + "laravel/framework": "^13.0.0", + "laravel/pail": "^1.2.5", + "laravel/tinker": "^3.0.0", + "nunomaduro/collision": "^8.9", + "orchestra/canvas": "^11.0.1", + "orchestra/sidekick": "~1.1.23|~1.2.20", + "orchestra/testbench-core": "^11.5.0", + "php": "^8.3", + "symfony/process": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "require-dev": { + "laravel/pint": "^1.22.0", + "mockery/mockery": "^1.6.12", + "phpstan/phpstan": "^2.1.33", + "phpunit/phpunit": "^11.5.50|^12.5.8|^13.0.0", + "spatie/laravel-ray": "^1.43.6" + }, + "suggest": { + "ext-pcntl": "Required to use all features of the console signal trapping." + }, + "type": "library", + "autoload": { + "psr-4": { + "Orchestra\\Workbench\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mior Muhammad Zaki", + "email": "crynobone@gmail.com" + } + ], + "description": "Workbench Companion for Laravel Packages Development", + "keywords": [ + "dev", + "laravel", + "laravel-packages", + "testing" + ], + "support": { + "issues": "https://github.com/orchestral/workbench/issues", + "source": "https://github.com/orchestral/workbench/tree/v11.3.0" + }, + "time": "2026-09-23T23:52:50+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.10.0", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2026-08-24T00:54:40+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "12.5.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7440368d94738d54f2c1de24dbd3871793bbb0dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7440368d94738d54f2c1de24dbd3871793bbb0dd", + "reference": "7440368d94738d54f2c1de24dbd3871793bbb0dd", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.9.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0.1", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.37" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-10-03T14:03:50+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-08-25T14:40:53+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "babab73b7ef9fae406f2eb3ee11323a61caa63d5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/babab73b7ef9fae406f2eb3ee11323a61caa63d5", + "reference": "babab73b7ef9fae406f2eb3ee11323a61caa63d5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.37" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" + } + ], + "time": "2026-10-03T04:51:29+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "12.5.40", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "76bd3198f16aeb375a4d5f3626471445aed0966e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/76bd3198f16aeb375a4d5f3626471445aed0966e", + "reference": "76bd3198f16aeb375a4d5f3626471445aed0966e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.8", + "phpunit/php-file-iterator": "^6.0.2", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.1", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.1", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.1", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.40" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-10-16T05:55:07+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.24", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" + }, + "time": "2026-06-29T15:41:09+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.4", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.4" + }, + "time": "2026-09-16T11:39:30+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-05-17T05:29:34+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "7c65c1e79836812819705b473a90c12399542485" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:45:25+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33", + "symfony/process": "^7.4.17" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-08-25T15:35:54+00:00" + }, + { + "name": "sebastian/environment", + "version": "8.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.26" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:40:20+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-20T04:37:17+00:00" + }, + { + "name": "sebastian/global-state", + "version": "8.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^12.5.28" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:10:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:22:07+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:17+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:44:59+00:00" + }, + { + "name": "sebastian/type", + "version": "6.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:45:45+00:00" + }, + { + "name": "sebastian/version", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "9e935a16187e14f39b0e79910dabd614972cdb26" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/9e935a16187e14f39b0e79910dabd614972cdb26", + "reference": "9e935a16187e14f39b0e79910dabd614972cdb26", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", + "type": "tidelift" + } + ], + "time": "2026-10-04T05:50:08+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "07437f612dca89c1894c1db04491002b1e798f03" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/07437f612dca89c1894c1db04491002b1e798f03", + "reference": "07437f612dca89c1894c1db04491002b1e798f03", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-12T14:31:36+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "21f1bc2296f7ff6043241a8dcbc09559b3732820" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/21f1bc2296f7ff6043241a8dcbc09559b3732820", + "reference": "21f1bc2296f7ff6043241a8dcbc09559b3732820", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-12T18:45:38+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "fecf40067fc8d8880ea87b8ac227600b9aadd1d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/fecf40067fc8d8880ea87b8ac227600b9aadd1d0", + "reference": "fecf40067fc8d8880ea87b8ac227600b9aadd1d0", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-23T10:03:40+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.20", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0", + "reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.20" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-22T18:56:40+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d269974ee93c61d03620ffee358355bfdb471d66", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.20", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", + "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.20" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-26T06:31:10+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "58c89144c7a36dd89c3d01917e9fa3a72d8aaf31" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/58c89144c7a36dd89c3d01917e9fa3a72d8aaf31", + "reference": "58c89144c7a36dd89c3d01917e9fa3a72d8aaf31", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-08T16:56:12+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "4779fce7dedacb8801b2b61c703ef99159502331" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/4779fce7dedacb8801b2b61c703ef99159502331", + "reference": "4779fce7dedacb8801b2b61c703ef99159502331", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-14T06:44:08+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.4.19", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "6f6f2441bef07a42d2617b9b3aadf04a321aa514" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/6f6f2441bef07a42d2617b9b3aadf04a321aa514", + "reference": "6f6f2441bef07a42d2617b9b3aadf04a321aa514", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.4.19" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-15T06:01:15+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.4.19", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693", + "reference": "f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.44|^7.4.17|^8.1.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.4.19" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-04T10:45:44+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "533d68cf99c8f8ee2e8893c59ed59192e92cfd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/533d68cf99c8f8ee2e8893c59ed59192e92cfd74", + "reference": "533d68cf99c8f8ee2e8893c59ed59192e92cfd74", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T14:40:04+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T22:02:09+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-24T15:19:04+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, + { + "name": "symfony/polyfill-php82", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php82.git", + "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/843bbe8b2a7934307b749751eede0f111f5d1cfc", + "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php82\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php82/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/22c6548499cbe5c5bb072fc0c42312dff062a015", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T22:01:09+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/86a42cbcc07adc331481a5ab310a4024bfc227f6", + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00" + }, + { + "name": "symfony/polyfill-php86", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "8840df45b24f33a49dc419fa18dc0057231ae1d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/8840df45b24f33a49dc419fa18dc0057231ae1d8", + "reference": "8840df45b24f33a49dc419fa18dc0057231ae1d8", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php86\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php86/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "b23ff8245cc5c4479ea2be3d0132d2e6dd433993" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/b23ff8245cc5c4479ea2be3d0132d2e6dd433993", + "reference": "b23ff8245cc5c4479ea2be3d0132d2e6dd433993", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-24T05:20:52+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "e5247f4a5e0797d232ad8e8e6803aeb341843c83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/e5247f4a5e0797d232ad8e8e6803aeb341843c83", + "reference": "e5247f4a5e0797d232ad8e8e6803aeb341843c83", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-08T16:56:12+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "7a344cf871532d081875bfdd18f3dff8dca36c5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/7a344cf871532d081875bfdd18f3dff8dca36c5b", + "reference": "7a344cf871532d081875bfdd18f3dff8dca36c5b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-11T18:40:59+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "9bc35d074ad16a58c21cd52b18c5cacc4f4c3981" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/9bc35d074ad16a58c21cd52b18c5cacc4f4c3981", + "reference": "9bc35d074ad16a58c21cd52b18c5cacc4f4c3981", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-03T15:15:30+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/2ee1e4a3b32a528a642babe041ff7c440b213b4b", + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5.3|^3.3" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "3e706a245b5343ea93be9cafe84525bfb54e60fc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/3e706a245b5343ea93be9cafe84525bfb54e60fc", + "reference": "3e706a245b5343ea93be9cafe84525bfb54e60fc", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-12T14:31:36+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "55d06b02f49eca9bff61828548988e6f8763e8a6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/55d06b02f49eca9bff61828548988e6f8763e8a6", + "reference": "55d06b02f49eca9bff61828548988e6f8763e8a6", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-13T05:48:33+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "044907929c7999814378be22da34722c7a86bad4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/044907929c7999814378be22da34722c7a86bad4", + "reference": "044907929c7999814378be22da34722c7a86bad4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-12T12:34:30+00:00" + }, + { + "name": "tbachert/spi", + "version": "v1.0.5", + "source": { + "type": "git", + "url": "https://github.com/Nevay/spi.git", + "reference": "e7078767866d0a9e0f91d3f9d42a832df5e39002" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nevay/spi/zipball/e7078767866d0a9e0f91d3f9d42a832df5e39002", + "reference": "e7078767866d0a9e0f91d3f9d42a832df5e39002", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "composer/semver": "^1.0 || ^2.0 || ^3.0", + "php": "^8.1" + }, + "require-dev": { + "composer/composer": "^2.0", + "infection/infection": "^0.27.9", + "phpunit/phpunit": "^10.5", + "psalm/phar": "^5.18" + }, + "type": "composer-plugin", + "extra": { + "class": "Nevay\\SPI\\Composer\\Plugin", + "branch-alias": { + "dev-main": "1.0.x-dev" + }, + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Nevay\\SPI\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Service provider loading facility", + "keywords": [ + "service provider" + ], + "support": { + "issues": "https://github.com/Nevay/spi/issues", + "source": "https://github.com/Nevay/spi/tree/v1.0.5" + }, + "time": "2025-06-29T15:42:06+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/phpdotenv/phpdotenv.git", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpdotenv/phpdotenv/zipball/301c07936b16d88628b126b01d082ba153cf4c40", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.2", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.10", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `$_ENV` and `$_SERVER` automagically, and optionally to `getenv()`.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.7.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2026-08-24T18:07:49+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^8.3", + "ext-pdo_mysql": "*", + "ext-redis": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/.github/tracing-performance/probe.mjs b/.github/tracing-performance/probe.mjs new file mode 100644 index 000000000..70b47c480 --- /dev/null +++ b/.github/tracing-performance/probe.mjs @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {writeFile} from 'node:fs/promises'; +import {performance} from 'node:perf_hooks'; + +const base = process.env.BASE_URL ?? 'http://app:8080'; +const profile = process.env.PROFILE_NAME; +const members = Number(process.env.MEMBERS ?? 1); +const workload = process.env.WORKLOAD_NAME || (members === 1 ? 'ordinary' : 'fanout'); +const rate = Number(process.env.OFFERED_RATE ?? (members === 1 ? 5 : 0.25)); +const duration = Number(process.env.DURATION_SECONDS ?? 120); +const observation = 90_000; +const diagnostic = process.env.DIAGNOSTIC_ONLY === 'true'; +const allowedLagMs = 100; +assert.match(profile ?? '', /^[a-z0-9-]{1,55}$/); +assert.match(workload, /^[a-z0-9-]{1,55}$/); +assert.ok([1, 64].includes(members) && rate > 0 && duration > 0); +const health = await fetch(base + '/health', {signal: AbortSignal.timeout(10_000)}); +assert.equal(health.status, 200); +const deployment = await health.json(); +assert.equal(deployment.ready, true); +assert.equal(deployment.queue_driver, process.env.BENCH_QUEUE_DRIVER ?? 'database', 'Expected queue driver.'); +if (diagnostic) { + assert.ok(profile.startsWith('diagnostic-')); +} else { + assert.equal(deployment.queue_wake_enabled, true, 'Standard profile requires automatic queue wake.'); +} +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +const began = performance.now(); +const beganAt = Date.now(); +const outcomes = []; +const requestTimes = []; +let maximumLagMs = 0; +let missedOffers = 0; + +async function request(path, deadline, input, traceparent) { + const begin = performance.now(); + const remaining = deadline - begin; + assert.ok(remaining > 0, 'Original workflow observer expired before request.'); + const response = await fetch(base + path, { + method: input ? 'POST' : 'GET', + headers: {'Content-Type': 'application/json', traceparent}, + body: input ? JSON.stringify(input) : undefined, + signal: AbortSignal.timeout(Math.max(1, Math.floor(Math.min(10_000, remaining)))), + }); + const text = await response.text(); + requestTimes.push({ms: performance.now() - begin, status: response.status}); + assert.equal(response.status, 200, `HTTP ${response.status}: ${text.slice(0, 300)}`); + return {data: JSON.parse(text), text}; +} + +async function original(index) { + const id = `${workload}-${index}`; + const start = performance.now(); + const deadline = start + observation; + const hash = createHash('sha256').update(id).digest('hex'); + const traceparent = `00-${hash.slice(0, 32)}-${hash.slice(32, 48)}-01`; + const outcome = {id, completed: false}; + outcomes.push(outcome); + try { + const {data: admitted} = await request('/workflows', deadline, {id, members}, traceparent); + assert.equal(admitted.id, id); + assert.ok(admitted.run_id && admitted.execution_deadline && admitted.run_deadline); + outcome.run_id = admitted.run_id; + while (performance.now() < deadline) { + const {data: current, text} = await request(`/workflows/${id}`, deadline, undefined, traceparent); + for (const field of ['id', 'run_id', 'execution_deadline', 'run_deadline']) { + assert.equal(current[field], admitted[field]); + } + if (current.status === 'completed') { + assert.equal(current.output.length, members); + for (let member = 0; member < members; member++) { + assert.deepEqual(Object.keys(current.output[member]), ['member', 'value']); + assert.equal(current.output[member].member, member); + assert.equal(current.output[member].value.length, 3); + assert.equal(typeof current.output[member].value[0], 'number'); + assert.equal(current.output[member].value[1], null); + assert.equal(current.output[member].value[2], '雪'); + } + assert.equal(text.match(/9007199254740993/g)?.length, members); + outcome.completed = true; + outcome.latency_ms = performance.now() - start; + return; + } + assert.ok(['pending', 'running', 'waiting'].includes(current.status), `Unexpected ${current.status}`); + await sleep(250); + } + throw new Error('Original 90-second workflow observer expired.'); + } catch (error) { + outcome.error = String(error).slice(0, 500); + } +} + +const pending = []; +const offeredTarget = Math.floor(rate * duration); +assert.ok(offeredTarget > 0); +for (let index = 0; index < offeredTarget; index++) { + const due = began + index * 1000 / rate; + await sleep(Math.max(0, due - performance.now())); + maximumLagMs = Math.max(maximumLagMs, performance.now() - due); + if (performance.now() - due > allowedLagMs || performance.now() >= began + duration * 1000) { + ++missedOffers; + continue; + } + pending.push(original(index)); +} +await sleep(Math.max(0, began + duration * 1000 - performance.now())); +const completedAtOfferEnd = outcomes.filter(item => item.completed).length; +await Promise.all(pending); +const completed = outcomes.filter(item => item.completed); +const sorted = completed.map(item => item.latency_ms).sort((a, b) => a - b); +const percentile = p => sorted[Math.max(0, Math.ceil(sorted.length * p) - 1)] ?? null; +const result = { + profile, workload, members, rate, offered_target: offeredTarget, offered: pending.length, missed_offers: missedOffers, + diagnostic_only: diagnostic, queue_wake_enabled: deployment.queue_wake_enabled, + queue_driver: deployment.queue_driver, + accepted: outcomes.filter(item => item.run_id).length, + completed: completed.length, completed_at_offer_end: completedAtOfferEnd, + started_at_ms: beganAt, finished_at_ms: Date.now(), + duration_seconds: duration, observer_seconds: observation / 1000, + elapsed_seconds: (performance.now() - began) / 1000, maximum_schedule_lag_ms: maximumLagMs, + p50_ms: percentile(0.5), p95_ms: percentile(0.95), p99_ms: percentile(0.99), + http_requests: requestTimes.length, http_errors: requestTimes.filter(item => item.status !== 200).length, + process_usage: process.resourceUsage(), failures: outcomes.filter(item => !item.completed), +}; +await writeFile(`/results/${profile}.json`, JSON.stringify(result)); +console.log(JSON.stringify(result)); +assert.equal(result.missed_offers, 0); +assert.equal(result.offered, offeredTarget); +assert.equal(result.accepted, offeredTarget); +assert.equal(result.completed, offeredTarget); +assert.equal(result.http_errors, 0); diff --git a/.github/tracing-performance/probe.test.mjs b/.github/tracing-performance/probe.test.mjs new file mode 100644 index 000000000..17452f919 --- /dev/null +++ b/.github/tracing-performance/probe.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import {execFile} from 'node:child_process'; +import {createServer} from 'node:http'; +import test from 'node:test'; +import {fileURLToPath} from 'node:url'; +import {promisify} from 'node:util'; + +const execute = promisify(execFile); +const probe = fileURLToPath(new URL('./probe.mjs', import.meta.url)); + +async function refusesBeforeAdmission(t, health, diagnostic, message) { + let submissions = 0; + const server = createServer((request, response) => { + if (request.method === 'POST') submissions++; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify({queue_driver: 'database', ...health})); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(async () => { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + }); + await assert.rejects(execute(process.execPath, [probe], { + timeout: 5000, + env: {...process.env, BASE_URL: `http://127.0.0.1:${server.address().port}`, + PROFILE_NAME: 'quality-refusal', MEMBERS: '1', OFFERED_RATE: '1', + DURATION_SECONDS: '1', DIAGNOSTIC_ONLY: String(diagnostic), BENCH_QUEUE_DRIVER: 'database'}, + }), error => { + assert.equal(error.code, 1); + assert.match(error.stderr, message); + return true; + }); + assert.equal(submissions, 0); +} + +for (const [name, metadata] of [ + ['explicitly disabled', {ready: true, queue_wake_enabled: false}], + ['missing', {ready: true}], + ['nonboolean', {ready: true, queue_wake_enabled: 'true'}], +]) { + test(`standard profile refuses ${name} queue-wake evidence`, async t => { + await refusesBeforeAdmission(t, metadata, false, /Standard profile requires automatic queue wake/); + }); +} + +test('diagnostic opt-in requires a diagnostic profile label', async t => { + await refusesBeforeAdmission(t, {ready: true, queue_wake_enabled: false}, true, /diagnostic-/); +}); + +test('standard profile refuses another queue driver before admission', async t => { + await refusesBeforeAdmission(t, {ready: true, queue_wake_enabled: true, queue_driver: 'redis'}, + false, /Expected queue driver/); +}); diff --git a/.github/tracing-performance/src/BenchmarkActivity.php b/.github/tracing-performance/src/BenchmarkActivity.php new file mode 100644 index 000000000..d52b9bf46 --- /dev/null +++ b/.github/tracing-performance/src/BenchmarkActivity.php @@ -0,0 +1,24 @@ +insert([ + 'activity_id' => $this->activityId(), + 'run_id' => $this->runId(), + 'member' => $member, + ]); + Log::info('benchmark callback', ['member' => $member]); + + return ['member' => $member, 'value' => [9007199254740993, null, '雪']]; + } +} diff --git a/.github/tracing-performance/src/BenchmarkApplication.php b/.github/tracing-performance/src/BenchmarkApplication.php new file mode 100644 index 000000000..c6913bd60 --- /dev/null +++ b/.github/tracing-performance/src/BenchmarkApplication.php @@ -0,0 +1,64 @@ +set([ + 'database.default' => 'benchmark', + 'database.connections.benchmark' => [ + 'driver' => 'mysql', 'host' => getenv('DB_HOST') ?: 'mysql', 'port' => 3306, + 'database' => getenv('DB_DATABASE') ?: 'benchmark', + 'username' => getenv('DB_USERNAME') ?: 'benchmark', + 'password' => getenv('DB_PASSWORD') ?: 'disposable-benchmark', + 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', 'strict' => true, + ], + 'workflows.storage.connection' => 'benchmark', + 'workflows.v1.enabled' => false, + 'workflows.v2.compatibility.namespace' => 'default', + 'workflows.v2.task_dispatch_mode' => 'queue', + 'workflows.v2.matching_role.queue_wake_enabled' => getenv('BENCH_QUEUE_WAKE') !== 'false', + 'workflows.tracing.enabled' => $enabled, + 'workflows.tracing.baggage_allowlist' => [], + 'queue.default' => $queue, + 'queue.connections.database' => [ + 'driver' => 'database', 'connection' => 'benchmark', 'table' => 'jobs', + 'queue' => 'default', 'retry_after' => 130, + ], + 'queue.connections.redis' => [ + 'driver' => 'redis', 'connection' => 'default', + 'queue' => 'default', 'retry_after' => 130, 'block_for' => 1, + ], + 'queue.failed' => [ + 'driver' => 'database-uuids', 'database' => 'benchmark', 'table' => 'failed_jobs', + ], + 'database.redis.client' => 'phpredis', + 'database.redis.default' => ['host' => getenv('REDIS_HOST') ?: 'redis', 'port' => 6379], + 'database.redis.cache' => ['host' => getenv('REDIS_HOST') ?: 'redis', 'port' => 6379, 'database' => 1], + 'cache.default' => 'redis', + 'cache.prefix' => 'tracing-benchmark:', + 'cache.stores.redis' => ['driver' => 'redis', 'connection' => 'cache', 'lock_connection' => 'cache'], + 'workflows.v2.long_poll.multi_node' => true, + 'workflows.v2.long_poll.validation_mode' => 'fail', + 'logging.default' => 'benchmark', + 'logging.channels.benchmark' => [ + 'driver' => 'single', 'path' => $app->basePath('callbacks.log'), + 'formatter' => JsonFormatter::class, 'tap' => $enabled ? [LogContext::class] : [], + ], + ]); + } +} diff --git a/.github/tracing-performance/src/BenchmarkWorkflow.php b/.github/tracing-performance/src/BenchmarkWorkflow.php new file mode 100644 index 000000000..179aa8747 --- /dev/null +++ b/.github/tracing-performance/src/BenchmarkWorkflow.php @@ -0,0 +1,24 @@ + self::activity(BenchmarkActivity::class, $member); + } + + return self::all($calls); + } +} diff --git a/.github/tracing-performance/src/CountingProcessor.php b/.github/tracing-performance/src/CountingProcessor.php new file mode 100644 index 000000000..e078526d8 --- /dev/null +++ b/.github/tracing-performance/src/CountingProcessor.php @@ -0,0 +1,58 @@ +processor->onStart($span, $parentContext); + } + + public function onEnd(ReadableSpanInterface $span): void + { + ++$this->ended; + $this->processor->onEnd($span); + } + + public function forceFlush(?\OpenTelemetry\SDK\Common\Future\CancellationInterface $cancellation = null): bool + { + return $this->processor->forceFlush($cancellation); + } + + public function shutdown(?\OpenTelemetry\SDK\Common\Future\CancellationInterface $cancellation = null): bool + { + if ($this->stopped) { + return false; + } + $this->stopped = true; + try { + return $this->processor->shutdown($cancellation); + } finally { + $usage = getrusage(); + file_put_contents(getenv('BENCH_STATE') . '/ended.jsonl', json_encode([ + 'instance' => gethostname(), 'pid' => getmypid(), + 'role' => PHP_SAPI === 'cli-server' ? 'http' : 'worker', + 'ended' => $this->ended, + 'cpu_user_seconds' => $usage['ru_utime.tv_sec'] + $usage['ru_utime.tv_usec'] / 1_000_000, + 'cpu_system_seconds' => $usage['ru_stime.tv_sec'] + $usage['ru_stime.tv_usec'] / 1_000_000, + 'maxrss_kib' => $usage['ru_maxrss'], + ], JSON_THROW_ON_ERROR) . PHP_EOL, FILE_APPEND | LOCK_EX); + } + } +} diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index da5f23a88..5db6bd419 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -253,6 +253,15 @@ jobs: - name: Validate composer.json and composer.lock run: composer validate --strict + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '22' + + - name: Check tracing overhead recipe + run: | + composer --working-dir=.github/tracing-performance validate --strict --no-check-all --no-check-publish + node --test .github/tracing-performance/probe.test.mjs + - name: Cache Composer packages uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: From 9a37221106d888f32cddaef948400d00a6d3a504 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sat, 17 Oct 2026 01:17:44 +0000 Subject: [PATCH 56/77] Address benchmark analysis and isolate synthetic traffic --- .github/tracing-performance/Dockerfile | 2 +- .github/tracing-performance/app.php | 10 +++++----- .github/tracing-performance/collector.php | 2 +- .github/tracing-performance/compose.yaml | 4 ++++ .github/tracing-performance/probe.mjs | 12 ++++++++---- .github/tracing-performance/probe.test.mjs | 5 +++++ 6 files changed, 24 insertions(+), 11 deletions(-) diff --git a/.github/tracing-performance/Dockerfile b/.github/tracing-performance/Dockerfile index 72e22c699..88a4cd539 100644 --- a/.github/tracing-performance/Dockerfile +++ b/.github/tracing-performance/Dockerfile @@ -1,6 +1,6 @@ FROM php:8.3.35-cli-bookworm RUN apt-get update && apt-get install -y --no-install-recommends \ - git unzip libonig-dev libxml2-dev \ + git libonig-dev libxml2-dev unzip \ && docker-php-ext-install pdo_mysql mbstring dom pcntl \ && pecl install redis-6.1.0 && docker-php-ext-enable redis \ && rm -rf /var/lib/apt/lists/* diff --git a/.github/tracing-performance/app.php b/.github/tracing-performance/app.php index 378105761..6f0f620a1 100644 --- a/.github/tracing-performance/app.php +++ b/.github/tracing-performance/app.php @@ -32,7 +32,7 @@ require __DIR__ . '/vendor/autoload.php'; $state = getenv('BENCH_STATE'); if (! is_string($state) || ! is_dir($state)) { - throw new RuntimeException('BENCH_STATE must select an existing disposable directory.'); + throw new InvalidArgumentException('BENCH_STATE must select an existing disposable directory.'); } foreach (['bootstrap/cache', 'storage/logs', 'storage/framework/views', 'config'] as $path) { if (! is_dir($state . '/' . $path)) { @@ -45,10 +45,10 @@ $mode = PHP_SAPI === 'cli-server' ? 'http' : ($argv[1] ?? ''); if ($mode === 'prepare') { if (Schema::hasTable('migrations')) { - throw new RuntimeException('Prepare requires a fresh disposable database.'); + throw new LogicException('Prepare requires a fresh disposable database.'); } if (Artisan::call('migrate', ['--force' => true]) !== 0) { - throw new RuntimeException(Artisan::output()); + throw new UnexpectedValueException(Artisan::output()); } if (config('queue.default') === 'database') { Schema::create('jobs', static function (Blueprint $table): void { @@ -113,7 +113,7 @@ ]))) === 0 && $result['callbacks'] === $result['activities']) ? 0 : 1); } if (! in_array($mode, ['work', 'http'], true)) { - throw new RuntimeException('Expected prepare, work, audit or the HTTP router.'); + throw new InvalidArgumentException('Expected prepare, work, audit or the HTTP router.'); } $http = new HttpFactory(); $exportScope = Configurator::createNoop()->storeInContext(Context::getRoot())->activate(); @@ -174,7 +174,7 @@ function respond(): void $input = json_decode(file_get_contents('php://input'), true, flags: JSON_THROW_ON_ERROR); if (! is_string($input['id'] ?? null) || ! preg_match('/^[a-z0-9-]{1,80}$/D', $input['id']) || ! in_array($input['members'] ?? null, [1, 64], true)) { - throw new RuntimeException('Expected a synthetic id and 1 or 64 members.'); + throw new InvalidArgumentException('Expected a synthetic id and 1 or 64 members.'); } $scope = TraceContextPropagator::getInstance()->extract(array_change_key_case(getallheaders()))->activate(); try { diff --git a/.github/tracing-performance/collector.php b/.github/tracing-performance/collector.php index 37874e42d..cde5b3bd6 100644 --- a/.github/tracing-performance/collector.php +++ b/.github/tracing-performance/collector.php @@ -2,7 +2,7 @@ declare(strict_types=1); -require __DIR__ . '/vendor/autoload.php'; +require_once __DIR__ . '/vendor/autoload.php'; $request = new Opentelemetry\Proto\Collector\Trace\V1\ExportTraceServiceRequest(); $request->mergeFromString(file_get_contents('php://input')); $count = 0; diff --git a/.github/tracing-performance/compose.yaml b/.github/tracing-performance/compose.yaml index d585eeeb5..ebbce9b47 100644 --- a/.github/tracing-performance/compose.yaml +++ b/.github/tracing-performance/compose.yaml @@ -115,3 +115,7 @@ services: volumes: database: + +networks: + default: + internal: true diff --git a/.github/tracing-performance/probe.mjs b/.github/tracing-performance/probe.mjs index 70b47c480..ad2dcd3ac 100644 --- a/.github/tracing-performance/probe.mjs +++ b/.github/tracing-performance/probe.mjs @@ -11,19 +11,23 @@ const rate = Number(process.env.OFFERED_RATE ?? (members === 1 ? 5 : 0.25)); const duration = Number(process.env.DURATION_SECONDS ?? 120); const observation = 90_000; const diagnostic = process.env.DIAGNOSTIC_ONLY === 'true'; +const queueDriver = process.env.BENCH_QUEUE_DRIVER ?? 'database'; const allowedLagMs = 100; assert.match(profile ?? '', /^[a-z0-9-]{1,55}$/); assert.match(workload, /^[a-z0-9-]{1,55}$/); assert.ok([1, 64].includes(members) && rate > 0 && duration > 0); +assert.ok(['database', 'redis'].includes(queueDriver)); const health = await fetch(base + '/health', {signal: AbortSignal.timeout(10_000)}); assert.equal(health.status, 200); const deployment = await health.json(); assert.equal(deployment.ready, true); -assert.equal(deployment.queue_driver, process.env.BENCH_QUEUE_DRIVER ?? 'database', 'Expected queue driver.'); +assert.equal(deployment.queue_driver, queueDriver, 'Expected queue driver.'); +const queueWakeEnabled = deployment.queue_wake_enabled === true; if (diagnostic) { + assert.equal(typeof deployment.queue_wake_enabled, 'boolean', 'Expected boolean queue-wake evidence.'); assert.ok(profile.startsWith('diagnostic-')); } else { - assert.equal(deployment.queue_wake_enabled, true, 'Standard profile requires automatic queue wake.'); + assert.equal(queueWakeEnabled, true, 'Standard profile requires automatic queue wake.'); } const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); const began = performance.now(); @@ -112,8 +116,8 @@ const sorted = completed.map(item => item.latency_ms).sort((a, b) => a - b); const percentile = p => sorted[Math.max(0, Math.ceil(sorted.length * p) - 1)] ?? null; const result = { profile, workload, members, rate, offered_target: offeredTarget, offered: pending.length, missed_offers: missedOffers, - diagnostic_only: diagnostic, queue_wake_enabled: deployment.queue_wake_enabled, - queue_driver: deployment.queue_driver, + diagnostic_only: diagnostic, queue_wake_enabled: queueWakeEnabled, + queue_driver: queueDriver, accepted: outcomes.filter(item => item.run_id).length, completed: completed.length, completed_at_offer_end: completedAtOfferEnd, started_at_ms: beganAt, finished_at_ms: Date.now(), diff --git a/.github/tracing-performance/probe.test.mjs b/.github/tracing-performance/probe.test.mjs index 17452f919..f6d527a5c 100644 --- a/.github/tracing-performance/probe.test.mjs +++ b/.github/tracing-performance/probe.test.mjs @@ -47,6 +47,11 @@ test('diagnostic opt-in requires a diagnostic profile label', async t => { await refusesBeforeAdmission(t, {ready: true, queue_wake_enabled: false}, true, /diagnostic-/); }); +test('diagnostic profile refuses nonboolean queue-wake evidence before admission', async t => { + await refusesBeforeAdmission(t, {ready: true, queue_wake_enabled: 'false'}, + true, /Expected boolean queue-wake evidence/); +}); + test('standard profile refuses another queue driver before admission', async t => { await refusesBeforeAdmission(t, {ready: true, queue_wake_enabled: true, queue_driver: 'redis'}, false, /Expected queue driver/); From 160e60acbdc9857dcd628920c0c1bb13f9ce7d65 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sat, 17 Oct 2026 01:33:31 +0000 Subject: [PATCH 57/77] Retain exact worker and probe CPU counters through shutdown --- .github/tracing-performance/README.md | 35 +++++++++++++++++++ .github/tracing-performance/app.php | 4 +++ .../tracing-performance/compose.measure.yaml | 11 ++++++ .github/tracing-performance/cpu-snapshot.sh | 34 ++++++++++++++++++ .github/tracing-performance/retain-process.sh | 17 +++++++++ 5 files changed, 101 insertions(+) create mode 100644 .github/tracing-performance/compose.measure.yaml create mode 100644 .github/tracing-performance/cpu-snapshot.sh create mode 100644 .github/tracing-performance/retain-process.sh diff --git a/.github/tracing-performance/README.md b/.github/tracing-performance/README.md index d1c121ea6..54dab6530 100644 --- a/.github/tracing-performance/README.md +++ b/.github/tracing-performance/README.md @@ -157,3 +157,38 @@ HTTP snapshots. Separate containers can use the same PID. Record ordinary worker returns, signal termination, forced stops and OOM separately. Preserve failures in the readable result, then remove disposable databases, provider bytes, images, dependencies and raw samples after review. + +## Exact CPU boundaries + +For measurements, combine `compose.yaml` with `compose.measure.yaml`. Its small +process retainer records each worker/probe exit code and leaves the container +idle so its final cgroup counters remain readable. It does not restart the +process. Start the probe detached with `docker compose run -d`; a returned +container ID is not completion. Wait for its `probe-*.exit` file and require +zero, then check the result JSON and original workflow outcomes. + +The measured probe initially waits for `results/release`. Take the initial CPU +snapshot after all nine roles exist, then create that file to release offers. +An absent release does not admit business work. Keep warmup in a separate probe +and remove its retainer before starting this measured probe. + +After the probe and business drain finish, create `runtime/stop-workers` in that +cell's state directory. The public Laravel queue loop requests ordinary worker +exit between jobs, followed by provider shutdown. Require four `worker-*.exit` +files containing zero. A missing/nonzero exit or unfinished original fails the +cell. The HTTP server and collector remain available while exports drain. +Record the child exit separately from later retainer-container SIGTERM. + +Run `bash cpu-snapshot.sh PROJECT` on the isolated Linux Docker host before the +offer window, at a declared interval, and after all four worker exits and final +exports. It reads exact cgroup-v2 CPU usage/throttling, memory events and limits +for only that Compose project's measured roles. Require exactly four workers +and one of each other role at every snapshot, with the same container identities +throughout the window. Missing counters or unexpected roles refuse. Keep the +detached probe and worker retainers alive until the last +snapshot. Take the final snapshot before audit queries or teardown, which are +outside the measured business interval. The reported lifetime memory peak +includes startup and warmup; also report the peak sampled within the window. + +The retainer and snapshot reader are measurement tools. They do not establish +that a workload meets its observer, overhead target or throughput requirement. diff --git a/.github/tracing-performance/app.php b/.github/tracing-performance/app.php index 6f0f620a1..adbb08ec4 100644 --- a/.github/tracing-performance/app.php +++ b/.github/tracing-performance/app.php @@ -136,9 +136,13 @@ if ($mode === 'work') { $nextFlush = hrtime(true) + 1_000_000_000; $app['events']->listen(\Illuminate\Queue\Events\Looping::class, static function () use ( + $app, $provider, &$nextFlush ): void { + if (is_file(getenv('BENCH_STATE') . '/stop-workers')) { + $app['queue.worker']->shouldQuit = true; + } if (hrtime(true) >= $nextFlush) { $provider->forceFlush(); $nextFlush = hrtime(true) + 1_000_000_000; diff --git a/.github/tracing-performance/compose.measure.yaml b/.github/tracing-performance/compose.measure.yaml new file mode 100644 index 000000000..3148c36b6 --- /dev/null +++ b/.github/tracing-performance/compose.measure.yaml @@ -0,0 +1,11 @@ +services: + worker: + environment: + BENCH_ROLE: worker + command: ["sh", "retain-process.sh", "php", "app.php", "work"] + probe: + environment: + BENCH_ROLE: probe + BENCH_STATE: /results + BENCH_RELEASE_FILE: /results/release + command: ["sh", "retain-process.sh", "node", "probe.mjs"] diff --git a/.github/tracing-performance/cpu-snapshot.sh b/.github/tracing-performance/cpu-snapshot.sh new file mode 100644 index 000000000..a24b0618c --- /dev/null +++ b/.github/tracing-performance/cpu-snapshot.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +project=${1:?Select the experiment Compose project} +[[ "$project" =~ ^[a-z0-9][a-z0-9-]{0,62}$ ]] +mapfile -t containers < <(docker ps -q --filter "label=com.docker.compose.project=$project") +[[ "${#containers[@]}" -eq 9 ]] +declare -A role_counts=([mysql]=0 [redis]=0 [app]=0 [worker]=0 [collector]=0 [probe]=0) + +for container in "${containers[@]}"; do + read -r process_id role full_id < <(docker inspect --format '{{.State.Pid}} {{index .Config.Labels "com.docker.compose.service"}} {{.Id}}' "$container") + [[ "$process_id" =~ ^[1-9][0-9]*$ ]] + [[ "$role" =~ ^(mysql|redis|app|worker|collector|probe)$ ]] + role_counts[$role]=$((role_counts[$role] + 1)) + group=$(awk -F: '$1 == "0" {print $3}' "/proc/$process_id/cgroup") + [[ "$group" == /* && "$group" != *..* ]] + limits="/sys/fs/cgroup$group" + jq -cn --arg container "$full_id" --arg role "$role" \ + --arg sampled_at "$(date -u '+%Y-%m-%dT%H:%M:%S.%NZ')" \ + --rawfile cpu "$limits/cpu.stat" --rawfile quota "$limits/cpu.max" \ + --rawfile memory_events "$limits/memory.events" \ + --rawfile memory_current "$limits/memory.current" \ + --rawfile memory_peak "$limits/memory.peak" \ + --rawfile memory_limit "$limits/memory.max" \ + --rawfile swap_limit "$limits/memory.swap.max" \ + '{container:$container,role:$role,sampled_at:$sampled_at,cpu:$cpu,cpu_max:$quota, + memory_events:$memory_events,memory_max:$memory_limit,swap_max:$swap_limit, + memory_current_bytes:($memory_current|tonumber),lifetime_memory_peak_bytes:($memory_peak|tonumber)}' +done + +[[ "${role_counts[worker]}" -eq 4 ]] +for role in mysql redis app collector probe; do + [[ "${role_counts[$role]}" -eq 1 ]] +done diff --git a/.github/tracing-performance/retain-process.sh b/.github/tracing-performance/retain-process.sh new file mode 100644 index 000000000..7359648a5 --- /dev/null +++ b/.github/tracing-performance/retain-process.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -u + +: "${BENCH_STATE:?Select disposable state}" "${BENCH_ROLE:?Select worker or probe}" "${HOSTNAME:?Missing container identity}" +case "$BENCH_ROLE" in worker|probe) ;; *) exit 2 ;; esac +case "$#" in 0) exit 2 ;; esac + +if [ -n "${BENCH_RELEASE_FILE:-}" ]; then + while [ ! -f "$BENCH_RELEASE_FILE" ]; do sleep 0.05; done +fi + +"$@" +retained_status=$? +printf '%s\n' "$retained_status" > "$BENCH_STATE/$BENCH_ROLE-$HOSTNAME.exit" + +# Keep the cgroup readable after the child and its provider shutdown finish. +exec sleep infinity From 17a1b934e9613fcb3dce64e62b6df7b269092109 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sat, 17 Oct 2026 01:47:02 +0000 Subject: [PATCH 58/77] Document detached probe retirement and explicit argument fallback --- .github/tracing-performance/README.md | 6 ++++++ .github/tracing-performance/retain-process.sh | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/tracing-performance/README.md b/.github/tracing-performance/README.md index 54dab6530..ee0388d4b 100644 --- a/.github/tracing-performance/README.md +++ b/.github/tracing-performance/README.md @@ -192,3 +192,9 @@ includes startup and warmup; also report the peak sampled within the window. The retainer and snapshot reader are measurement tools. They do not establish that a workload meets its observer, overhead target or throughput requirement. + +After the last snapshot and audit, stop and remove the exact detached probe +container explicitly. Compose `down` can leave a running one-off retainer even +with `--remove-orphans`. Then retire the ordinary services and volumes, and +verify no container with the experiment's project label remains. Cleanup +failure keeps the aggregate cell unsuccessful even when business work passes. diff --git a/.github/tracing-performance/retain-process.sh b/.github/tracing-performance/retain-process.sh index 7359648a5..53335b3ac 100644 --- a/.github/tracing-performance/retain-process.sh +++ b/.github/tracing-performance/retain-process.sh @@ -3,7 +3,7 @@ set -u : "${BENCH_STATE:?Select disposable state}" "${BENCH_ROLE:?Select worker or probe}" "${HOSTNAME:?Missing container identity}" case "$BENCH_ROLE" in worker|probe) ;; *) exit 2 ;; esac -case "$#" in 0) exit 2 ;; esac +case "$#" in 0) exit 2 ;; *) ;; esac if [ -n "${BENCH_RELEASE_FILE:-}" ]; then while [ ! -f "$BENCH_RELEASE_FILE" ]; do sleep 0.05; done From 8aa7ddd5a20b81145c1af3ce44708d41b0ffb934 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sat, 17 Oct 2026 01:47:57 +0000 Subject: [PATCH 59/77] Let detached probe retainers stop through an init process --- .github/tracing-performance/compose.measure.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/tracing-performance/compose.measure.yaml b/.github/tracing-performance/compose.measure.yaml index 3148c36b6..8304b0fc2 100644 --- a/.github/tracing-performance/compose.measure.yaml +++ b/.github/tracing-performance/compose.measure.yaml @@ -4,6 +4,8 @@ services: BENCH_ROLE: worker command: ["sh", "retain-process.sh", "php", "app.php", "work"] probe: + init: true + stop_signal: SIGTERM environment: BENCH_ROLE: probe BENCH_STATE: /results From 316316a827fadc7b5aa545842451ca9c8e7a9db5 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sat, 17 Oct 2026 02:02:38 +0000 Subject: [PATCH 60/77] Freeze representative inputs before overhead comparisons --- .github/tracing-performance/README.md | 14 +++++++++++--- .github/tracing-performance/compose.yaml | 4 ++-- .github/tracing-performance/probe.mjs | 9 ++++++--- .github/tracing-performance/workloads.json | 4 ++++ 4 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 .github/tracing-performance/workloads.json diff --git a/.github/tracing-performance/README.md b/.github/tracing-performance/README.md index ee0388d4b..357452be4 100644 --- a/.github/tracing-performance/README.md +++ b/.github/tracing-performance/README.md @@ -58,8 +58,16 @@ name to keep its original runs distinct from the measured offers. | Workload | Members per workflow | Offered workflows/second | Offer window | | --- | ---: | ---: | ---: | -| Ordinary activity | 1 | 5 | 120 seconds | -| Large fan-out | 64 | 0.25 | 120 seconds | +| Ordinary activity | 1 | 1 | 120 seconds | +| Large fan-out | 64 | 1/60 | 120 seconds | + +Defaults live in `workloads.json`. These rates are chosen prospectively after +the guest functional controls, before matched overhead windows. They seek a +healthy comparison, retaining all 64 concurrent member activities and the +original observers. They establish no maximum-capacity or throughput claim. +The earlier prospective 5/s and 0.25/s inputs were not qualified and are not +reinterpreted as these new profiles. Verify the new rates with the published +baseline before using them for the three-cell comparison. Each member performs one durable database insertion, one Laravel log call and returns the exact int64/null/Unicode result. A unique activity ID and run/member @@ -130,7 +138,7 @@ docker compose stop collector docker compose down --volumes --remove-orphans ``` -Select `MEMBERS=64` and `OFFERED_RATE=0.25` for fan-out. Short functional +Select `MEMBERS=64` for fan-out. Short functional controls use one offered workflow; their durations and results are not a measured comparison. Use a fresh project/database for the next cell. Keep the collector running until all workers and HTTP requests have stopped diff --git a/.github/tracing-performance/compose.yaml b/.github/tracing-performance/compose.yaml index ebbce9b47..7f7404671 100644 --- a/.github/tracing-performance/compose.yaml +++ b/.github/tracing-performance/compose.yaml @@ -107,8 +107,8 @@ services: PROFILE_NAME: ${PROFILE_NAME:?Set a unique synthetic profile} WORKLOAD_NAME: ${WORKLOAD_NAME:-} MEMBERS: ${MEMBERS:-1} - OFFERED_RATE: ${OFFERED_RATE:-5} - DURATION_SECONDS: ${DURATION_SECONDS:-120} + OFFERED_RATE: ${OFFERED_RATE:-} + DURATION_SECONDS: ${DURATION_SECONDS:-} DIAGNOSTIC_ONLY: ${DIAGNOSTIC_ONLY:-false} BENCH_QUEUE_DRIVER: ${BENCH_QUEUE_DRIVER:-database} command: ["node", "probe.mjs"] diff --git a/.github/tracing-performance/probe.mjs b/.github/tracing-performance/probe.mjs index ad2dcd3ac..c99ff70aa 100644 --- a/.github/tracing-performance/probe.mjs +++ b/.github/tracing-performance/probe.mjs @@ -1,14 +1,16 @@ import assert from 'node:assert/strict'; import {createHash} from 'node:crypto'; -import {writeFile} from 'node:fs/promises'; +import {readFile, writeFile} from 'node:fs/promises'; import {performance} from 'node:perf_hooks'; const base = process.env.BASE_URL ?? 'http://app:8080'; const profile = process.env.PROFILE_NAME; const members = Number(process.env.MEMBERS ?? 1); +const workloads = JSON.parse(await readFile(new URL('./workloads.json', import.meta.url), 'utf8')); +const defaults = workloads[members === 1 ? 'ordinary' : 'fanout']; const workload = process.env.WORKLOAD_NAME || (members === 1 ? 'ordinary' : 'fanout'); -const rate = Number(process.env.OFFERED_RATE ?? (members === 1 ? 5 : 0.25)); -const duration = Number(process.env.DURATION_SECONDS ?? 120); +const rate = Number(process.env.OFFERED_RATE || 1000 / defaults.interval_ms); +const duration = Number(process.env.DURATION_SECONDS || defaults.duration_seconds); const observation = 90_000; const diagnostic = process.env.DIAGNOSTIC_ONLY === 'true'; const queueDriver = process.env.BENCH_QUEUE_DRIVER ?? 'database'; @@ -16,6 +18,7 @@ const allowedLagMs = 100; assert.match(profile ?? '', /^[a-z0-9-]{1,55}$/); assert.match(workload, /^[a-z0-9-]{1,55}$/); assert.ok([1, 64].includes(members) && rate > 0 && duration > 0); +assert.equal(defaults.members, members); assert.ok(['database', 'redis'].includes(queueDriver)); const health = await fetch(base + '/health', {signal: AbortSignal.timeout(10_000)}); assert.equal(health.status, 200); diff --git a/.github/tracing-performance/workloads.json b/.github/tracing-performance/workloads.json new file mode 100644 index 000000000..bfec23927 --- /dev/null +++ b/.github/tracing-performance/workloads.json @@ -0,0 +1,4 @@ +{ + "ordinary": {"members": 1, "interval_ms": 1000, "duration_seconds": 120}, + "fanout": {"members": 64, "interval_ms": 60000, "duration_seconds": 120} +} From cf1c3c12b992511427bd967fabef40f7e5a2dfbe Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sat, 17 Oct 2026 02:36:28 +0000 Subject: [PATCH 61/77] Add strict paired CPU analysis for embedded tracing --- .github/tracing-performance/README.md | 31 +++ .github/tracing-performance/analyze.mjs | 226 +++++++++++++++++++ .github/tracing-performance/analyze.test.mjs | 106 +++++++++ .github/workflows/php.yml | 2 +- 4 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 .github/tracing-performance/analyze.mjs create mode 100644 .github/tracing-performance/analyze.test.mjs diff --git a/.github/tracing-performance/README.md b/.github/tracing-performance/README.md index 357452be4..437f146fc 100644 --- a/.github/tracing-performance/README.md +++ b/.github/tracing-performance/README.md @@ -206,3 +206,34 @@ container explicitly. Compose `down` can leave a running one-off retainer even with `--remove-orphans`. Then retire the ordinary services and volumes, and verify no container with the experiment's project label remains. Cleanup failure keeps the aggregate cell unsuccessful even when business work passes. + +## Analyze the fixed comparison + +After all declared cells finish, copy their `proof/tracing-measure-*` and +`state/tracing-measure-*` directories to a temporary local experiment root. +The state copy must include the worker and probe exit markers. Analysis runs +after measurement and does not share the experiment's CPU allocation. + +```sh +node --test analyze.test.mjs +node analyze.mjs /temporary/experiment-root > /temporary/result.json +``` + +The reader requires all 20 cells: three baseline/disabled/enabled repetitions +for each workload and one final baseline per workload. It refuses a failed +cell, missing role, changed container or resource limit, decreasing CPU, +missing samples, nonzero child exit, OOM/restart, unfinished business work or +span loss. It normalizes CPU by measured completions, excluding warmup counts. +Metadata, span and audit totals explicitly include warmup. + +The fixed statistical method uses paired relative differences and a +conventional two-sided 95% paired-t interval with three pairs, two degrees of +freedom and critical value 4.3026527299. This small sample relies on the usual +t-interval assumptions. A target passes only when both its interval upper +bound and absolute final baseline drift meet the previously declared limit. +Do not select extra repetitions after inspecting the result. + +Memory means and container sample peaks describe the observed sampling +interval, not RSS or an exact simultaneous peak. Worker peak fields are the +sum of the four individual container peaks. Lifetime peaks include startup +and warmup. Review the readable result, then delete the copied raw data. diff --git a/.github/tracing-performance/analyze.mjs b/.github/tracing-performance/analyze.mjs new file mode 100644 index 000000000..cd57d103b --- /dev/null +++ b/.github/tracing-performance/analyze.mjs @@ -0,0 +1,226 @@ +import assert from 'node:assert/strict'; +import {readdir, readFile} from 'node:fs/promises'; +import path from 'node:path'; +import {pathToFileURL} from 'node:url'; + +const roles = {app: [1, 50000, 512], worker: [4, 100000, 384], mysql: [1, 100000, 2048], + redis: [1, 10000, 128], collector: [1, 20000, 256], probe: [1, 20000, 256]}; +const repetitions = ['1', '2', '3']; +const critical = 4.3026527299; + +function counters(text) { + return Object.fromEntries(text.trim().split('\n').map(line => { + const [key, value] = line.trim().split(/\s+/); + const parsed = Number(value); + assert.ok(Number.isSafeInteger(parsed) && parsed >= 0, `Invalid ${key} counter`); + return [key, parsed]; + })); +} + +function snapshot(rows) { + assert.equal(rows.length, 9, 'All nine roles must be readable'); + const counts = {}; + const result = new Map(); + for (const row of rows) { + assert.ok(Object.hasOwn(roles, row.role), 'Unknown runtime role'); + const [, quota, memory] = roles[row.role]; + assert.match(row.container, /^[a-f0-9]{64}$/); + assert.ok(!result.has(row.container), 'Duplicate container'); + assert.deepEqual(row.cpu_max.trim().split(/\s+/).map(Number), [quota, 100000]); + assert.equal(Number(row.memory_max), memory * 1024 * 1024); + assert.equal(Number(row.swap_max), 0); + const memoryEvents = counters(row.memory_events); + assert.equal(memoryEvents.oom, 0); + assert.equal(memoryEvents.oom_kill, 0); + const cpu = counters(row.cpu); + assert.ok(Object.hasOwn(cpu, 'usage_usec') && Object.hasOwn(cpu, 'throttled_usec')); + assert.ok(Number.isFinite(Date.parse(row.sampled_at)), 'Missing sample timestamp'); + assert.ok(Number.isSafeInteger(row.memory_current_bytes) && row.memory_current_bytes >= 0); + assert.ok(Number.isSafeInteger(row.lifetime_memory_peak_bytes) && row.lifetime_memory_peak_bytes >= 0); + result.set(row.container, {...row, counters: cpu}); + counts[row.role] = (counts[row.role] ?? 0) + 1; + } + for (const [role, [count]] of Object.entries(roles)) assert.equal(counts[role], count); + return result; +} + +function validateBusiness(data) { + const {outcome, measure, warmup, audit} = data; + assert.equal(outcome.result, 0, 'An unsuccessful original is not a CPU sample'); + assert.equal(outcome.stage, 'complete'); + assert.ok(['base', 'off', 'on'].includes(outcome.cell)); + assert.ok(['ordinary', 'fanout'].includes(outcome.workload)); + const ordinary = outcome.workload === 'ordinary'; + const expected = ordinary ? 120 : 2; + const members = ordinary ? 1 : 64; + for (const [probe, count] of [[measure, expected], [warmup, ordinary ? 30 : 1]]) { + assert.equal(probe.offered_target, count); + for (const key of ['offered', 'accepted', 'completed']) assert.equal(probe[key], count); + assert.equal(probe.members, members); + assert.equal(probe.observer_seconds, 90); + assert.equal(probe.missed_offers, 0); + assert.equal(probe.http_errors, 0); + assert.deepEqual(probe.failures, []); + assert.equal(probe.queue_wake_enabled, true); + assert.equal(probe.queue_driver, 'database'); + assert.equal(probe.diagnostic_only, false); + } + assert.equal(measure.duration_seconds, 120); + assert.equal(measure.rate, ordinary ? 1 : 1 / 60); + assert.ok(Number.isFinite(measure.elapsed_seconds) && measure.elapsed_seconds >= 120); + assert.ok([measure.p50_ms, measure.p95_ms, measure.p99_ms].every(value => Number.isFinite(value) && value > 0)); + assert.ok(measure.p50_ms <= measure.p95_ms && measure.p95_ms <= measure.p99_ms); + const runs = expected + (ordinary ? 30 : 1); + assert.equal(audit.runs, runs); + assert.equal(audit.activities, runs * members); + assert.equal(audit.callbacks, runs * members); + assert.equal(audit.tasks, runs * (members + 2)); + assert.deepEqual(audit.history_types, {StartAccepted: runs, WorkflowStarted: runs, + ActivityScheduled: runs * members, ActivityStarted: runs * members, + ActivityCompleted: runs * members, WorkflowCompleted: runs}); + assert.ok(Number.isSafeInteger(audit.repairs) && audit.repairs >= 0); + assert.deepEqual(Object.keys(audit.metadata_bytes).sort(), ['activity_executions', 'workflow_runs', 'workflow_tasks']); + for (const bytes of Object.values(audit.metadata_bytes)) assert.ok(Number.isSafeInteger(bytes) && bytes >= 0); + for (const key of ['unfinished_runs', 'unfinished_activities', 'unfinished_tasks', 'nonfirst_tasks', + 'unfinished_attempts', 'nonfirst_attempts', 'queue', 'failed_jobs']) assert.equal(audit[key], 0, key); + return expected; +} + +function validateTelemetry({outcome, audit, telemetry}) { + assert.equal(telemetry.ended, telemetry.received, 'Span loss invalidates the cell'); + assert.ok(Number.isSafeInteger(telemetry.ended) && telemetry.ended >= 0); + if (outcome.cell === 'on') { + assert.ok(telemetry.ended > 0); + assert.ok(audit.metadata_bytes.workflow_runs > 0 && audit.metadata_bytes.workflow_tasks > 0); + } else { + assert.equal(telemetry.ended, 0); + for (const bytes of Object.values(audit.metadata_bytes)) assert.equal(bytes, 0); + } +} + +function summarizeComponents(data, before, after) { + assert.deepEqual([...before.keys()].sort(), [...after.keys()].sort(), 'Container replacement invalidates CPU'); + const samples = data.samples; + assert.ok(samples.length >= 18 && samples.length % 9 === 0, 'Incomplete sampler output'); + const components = {}; + const observedIntervals = []; + for (const [id, initial] of before) { + const final = after.get(id); + assert.equal(final.role, initial.role); + const delta = final.counters.usage_usec - initial.counters.usage_usec; + const throttle = final.counters.throttled_usec - initial.counters.throttled_usec; + assert.ok(delta >= 0 && throttle >= 0, 'Decreasing CPU counter'); + const component = components[initial.role] ??= {cpu_seconds: 0, throttled_seconds: 0, + sum_container_sampled_peaks_bytes: 0, sampled_memory_mean_bytes: 0, sum_container_lifetime_peaks_bytes: 0}; + component.cpu_seconds += delta / 1e6; + component.throttled_seconds += throttle / 1e6; + const observations = samples.filter(row => row.container === id); + assert.equal(observations.length, samples.length / 9, 'Sampler lost a role'); + const values = observations.map(row => row.memory_current_bytes); + assert.ok(values.every(value => Number.isSafeInteger(value) && value >= 0)); + component.sum_container_sampled_peaks_bytes += Math.max(...values); + component.sampled_memory_mean_bytes += values.reduce((sum, value) => sum + value, 0) / values.length; + component.sum_container_lifetime_peaks_bytes += final.lifetime_memory_peak_bytes; + const times = observations.map(row => Date.parse(row.sampled_at)); + const intervals = times.slice(1).map((value, index) => value - times[index]); + assert.ok(intervals.every(value => value > 0), 'Invalid sampling order'); + observedIntervals.push(...intervals); + assert.ok(Date.parse(final.sampled_at) > Date.parse(initial.sampled_at)); + } + return {components, observedIntervals}; +} + +function validateExits(data, before) { + assert.equal(data.workerExits.length, 4); + assert.equal(data.probeExits.length, 2); + for (const code of [...data.workerExits, ...data.probeExits]) assert.equal(code, 0); + assert.equal(data.exits.length, 9); + assert.deepEqual(data.exits.map(item => item.id).sort(), [...before.keys()].sort()); + for (const item of data.exits) { + assert.equal(item.oom, false); + assert.equal(item.restarts, 0); + const role = before.get(item.id).role; + assert.equal(item.exit, ['mysql', 'redis'].includes(role) ? 0 : 143); + } +} + +export function summarizeCell(data) { + const expected = validateBusiness(data); + validateTelemetry(data); + const before = snapshot(data.before); + const after = snapshot(data.after); + validateExits(data, before); + const {components, observedIntervals} = summarizeComponents(data, before, after); + const {outcome, measure, audit, telemetry} = data; + const cpuSeconds = Object.values(components).reduce((sum, item) => sum + item.cpu_seconds, 0); + assert.ok(cpuSeconds > 0); + return {...outcome, cpu_seconds: cpuSeconds, cpu_seconds_per_workflow: cpuSeconds / expected, + php_cpu_seconds: components.app.cpu_seconds + components.worker.cpu_seconds, components, + completed_per_offer_second: expected / 120, completed_per_observed_second: expected / measure.elapsed_seconds, + measured: measure, audit_including_warmup: audit, spans_including_warmup: telemetry, + sample_interval_ms: {min: Math.min(...observedIntervals), max: Math.max(...observedIntervals), + mean: observedIntervals.reduce((sum, value) => sum + value, 0) / observedIntervals.length}}; +} + +function interval(values) { + assert.equal(values.length, 3); + const mean = values.reduce((sum, value) => sum + value, 0) / 3; + const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / 2; + const margin = critical * Math.sqrt(variance / 3); + return {mean, lower: mean - margin, upper: mean + margin}; +} + +export function compare(cells) { + assert.equal(cells.length, 20, 'All twenty declared cells are required'); + const result = {}; + for (const workload of ['ordinary', 'fanout']) { + const select = (cell, repeat) => { + const matches = cells.filter(row => row.workload === workload && row.cell === cell && row.repeat === repeat); + assert.equal(matches.length, 1, 'Missing or repeated matched cell'); + assert.ok(Number.isFinite(matches[0].cpu_seconds_per_workflow) && matches[0].cpu_seconds_per_workflow > 0); + return matches[0].cpu_seconds_per_workflow; + }; + const baseline = repetitions.map(repeat => select('base', repeat)); + const meanBase = baseline.reduce((sum, value) => sum + value, 0) / 3; + const drift = select('base', 'drift') / meanBase - 1; + result[workload] = {baseline_cpu_seconds_per_workflow: baseline, baseline_drift_ratio: drift}; + for (const [cell, target] of [['off', 0.01], ['on', 0.05]]) { + const paired = repetitions.map((repeat, index) => select(cell, repeat) / baseline[index] - 1); + const confidence = interval(paired); + result[workload][cell] = {paired_ratios: paired, confidence_95: confidence, target, + qualified: confidence.upper <= target && Math.abs(drift) <= target}; + } + } + return result; +} + +const json = async file => JSON.parse(await readFile(file, 'utf8')); +const jsonLines = async file => (await readFile(file, 'utf8')).trim().split('\n').map(line => JSON.parse(line)); + +export async function loadCell(root, name) { + const proof = path.join(root, 'proof', name); + const state = path.join(root, 'state', name); + const exitCodes = async directory => { + const files = (await readdir(directory)).filter(file => file.endsWith('.exit')); + return Promise.all(files.map(async file => { + const value = (await readFile(path.join(directory, file), 'utf8')).trim(); + assert.match(value, /^(0|[1-9][0-9]*)$/, 'Missing or invalid child exit'); + return Number(value); + })); + }; + return summarizeCell({outcome: await json(path.join(proof, 'outcome.json')), + measure: await json(path.join(proof, 'measure-result.json')), warmup: await json(path.join(proof, 'warmup-result.json')), + audit: await json(path.join(proof, 'audit.json')), telemetry: await json(path.join(proof, 'telemetry.json')), + before: await jsonLines(path.join(proof, 'before.jsonl')), after: await jsonLines(path.join(proof, 'after.jsonl')), + samples: await jsonLines(path.join(proof, 'samples.jsonl')), exits: await jsonLines(path.join(proof, 'exits.jsonl')), + workerExits: await exitCodes(path.join(state, 'runtime')), probeExits: await exitCodes(path.join(state, 'results'))}); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const root = process.argv[2]; + assert.ok(root, 'Usage: node analyze.mjs COPIED_EXPERIMENT_ROOT'); + const names = (await readdir(path.join(root, 'proof'))).filter(name => name.startsWith('tracing-measure-')); + const cells = await Promise.all(names.map(name => loadCell(root, name))); + console.log(JSON.stringify({method: 'paired-t-95-n3-df2; targets and drift frozen before admission', + comparison: compare(cells), cells}, null, 2)); +} diff --git a/.github/tracing-performance/analyze.test.mjs b/.github/tracing-performance/analyze.test.mjs new file mode 100644 index 000000000..ebfcc8148 --- /dev/null +++ b/.github/tracing-performance/analyze.test.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {compare, summarizeCell} from './analyze.mjs'; + +function pairData() { + const cells = []; + for (const workload of ['ordinary', 'fanout']) { + for (const repeat of ['1', '2', '3']) { + for (const [cell, ratio] of [['base', 1], ['off', 1.002], ['on', 1.02]]) { + cells.push({workload, repeat, cell, cpu_seconds_per_workflow: 10 * ratio}); + } + } + cells.push({workload, repeat: 'drift', cell: 'base', cpu_seconds_per_workflow: 10}); + } + return cells; +} + +function cellData() { + const roles = [['app', 50000, 512], ...Array.from({length: 4}, () => ['worker', 100000, 384]), + ['mysql', 100000, 2048], ['redis', 10000, 128], ['collector', 20000, 256], ['probe', 20000, 256]]; + const rows = (seconds, usage) => roles.map(([role, quota, memory], index) => ({ + container: (index + 1).toString(16).padStart(64, '0'), role, + sampled_at: new Date(Date.UTC(2026, 0, 1, 0, 0, seconds)).toISOString(), + cpu: `usage_usec ${usage}\nthrottled_usec 0\n`, cpu_max: `${quota} 100000\n`, + memory_events: 'oom 0\noom_kill 0\n', memory_max: String(memory * 1024 * 1024), swap_max: '0', + memory_current_bytes: 1024, lifetime_memory_peak_bytes: 2048, + })); + const measure = {members: 1, offered_target: 120, offered: 120, accepted: 120, completed: 120, + observer_seconds: 90, missed_offers: 0, http_errors: 0, failures: [], queue_wake_enabled: true, + queue_driver: 'database', diagnostic_only: false, duration_seconds: 120, rate: 1, + elapsed_seconds: 120, p50_ms: 500, p95_ms: 750, p99_ms: 900}; + return {outcome: {result: 0, stage: 'complete', cell: 'base', workload: 'ordinary', repeat: '1'}, + measure, warmup: {...measure, offered_target: 30, offered: 30, accepted: 30, completed: 30}, + audit: {runs: 150, activities: 150, callbacks: 150, tasks: 450, unfinished_runs: 0, + unfinished_activities: 0, unfinished_tasks: 0, nonfirst_tasks: 0, unfinished_attempts: 0, + nonfirst_attempts: 0, queue: 0, failed_jobs: 0, repairs: 0, + history_types: {StartAccepted: 150, WorkflowStarted: 150, ActivityScheduled: 150, + ActivityStarted: 150, ActivityCompleted: 150, WorkflowCompleted: 150}, + metadata_bytes: {workflow_runs: 0, workflow_tasks: 0, activity_executions: 0}}, + telemetry: {ended: 0, received: 0}, workerExits: [0, 0, 0, 0], probeExits: [0, 0], + before: rows(0, 1000), after: rows(120, 101000), samples: [...rows(1, 2000), ...rows(2, 3000)], + exits: roles.map(([role], index) => ({id: (index + 1).toString(16).padStart(64, '0'), + exit: ['mysql', 'redis'].includes(role) ? 0 : 143, oom: false, restarts: 0}))}; +} + +test('normalizes all nine CPU deltas by measured work, excluding warmup counts', () => { + const result = summarizeCell(cellData()); + assert.ok(Math.abs(result.cpu_seconds - 0.9) < 1e-12); + assert.ok(Math.abs(result.cpu_seconds_per_workflow - 0.0075) < 1e-12); + assert.ok(Math.abs(result.php_cpu_seconds - 0.5) < 1e-12); + assert.equal(result.sample_interval_ms.mean, 1000); +}); + +for (const [name, corrupt] of [ + ['failed original', data => { data.outcome.result = 1; }], + ['lost span', data => { data.outcome.cell = 'on'; data.telemetry = {ended: 10, received: 9}; }], + ['replaced container', data => { data.after[0].container = 'f'.repeat(64); }], + ['decreasing CPU', data => { data.after[0].cpu = 'usage_usec 999\nthrottled_usec 0\n'; }], + ['missing sample role', data => { data.samples.pop(); }], + ['unexpected worker exit', data => { data.workerExits[0] = 137; }], + ['unfinished business work', data => { data.audit.unfinished_tasks = 1; }], + ['resource-limit drift', data => { data.after[0].cpu_max = '100000 100000'; }], +]) { + test(`refuses ${name} as a qualified CPU sample`, () => { + const data = cellData(); + corrupt(data); + assert.throws(() => summarizeCell(data)); + }); +} + +test('uses paired relative overhead and the frozen confidence targets', () => { + const result = compare(pairData()); + for (const workload of ['ordinary', 'fanout']) { + assert.ok(Math.abs(result[workload].off.confidence_95.mean - 0.002) < 1e-12); + assert.ok(Math.abs(result[workload].on.confidence_95.mean - 0.02) < 1e-12); + assert.equal(result[workload].off.qualified, true); + assert.equal(result[workload].on.qualified, true); + } +}); + +test('a low mean with a wide interval does not qualify enabled tracing', () => { + const cells = pairData(); + for (const [index, repeat] of ['1', '2', '3'].entries()) { + cells.find(row => row.workload === 'ordinary' && row.cell === 'on' && row.repeat === repeat) + .cpu_seconds_per_workflow = [10, 10.3, 9.7][index]; + } + const result = compare(cells).ordinary.on; + assert.ok(Math.abs(result.confidence_95.mean) < 1e-12); + assert.ok(result.confidence_95.upper > 0.05); + assert.equal(result.qualified, false); +}); + +test('baseline drift can refuse the tighter target while the enabled target passes', () => { + const cells = pairData(); + cells.find(row => row.workload === 'ordinary' && row.repeat === 'drift').cpu_seconds_per_workflow = 10.15; + const result = compare(cells).ordinary; + assert.equal(result.off.qualified, false); + assert.equal(result.on.qualified, true); +}); + +test('missing or duplicate cells cannot be silently selected out', () => { + assert.throws(() => compare(pairData().slice(1)), /twenty/); + const cells = pairData(); + cells[0] = {...cells[1]}; + assert.throws(() => compare(cells), /Missing or repeated/); +}); diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 5db6bd419..5106d7a4b 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -260,7 +260,7 @@ jobs: - name: Check tracing overhead recipe run: | composer --working-dir=.github/tracing-performance validate --strict --no-check-all --no-check-publish - node --test .github/tracing-performance/probe.test.mjs + node --test .github/tracing-performance/probe.test.mjs .github/tracing-performance/analyze.test.mjs - name: Cache Composer packages uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 From 336cfc060b045f9d6d117b26428492db0acedfde Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sat, 17 Oct 2026 02:43:58 +0000 Subject: [PATCH 62/77] Constrain tracing analysis inputs and compare resource sets --- .github/tracing-performance/README.md | 8 ++- .github/tracing-performance/analyze.mjs | 59 +++++++++++++------- .github/tracing-performance/analyze.test.mjs | 34 ++++++++++- 3 files changed, 80 insertions(+), 21 deletions(-) diff --git a/.github/tracing-performance/README.md b/.github/tracing-performance/README.md index 437f146fc..f7f30b23f 100644 --- a/.github/tracing-performance/README.md +++ b/.github/tracing-performance/README.md @@ -216,7 +216,8 @@ after measurement and does not share the experiment's CPU allocation. ```sh node --test analyze.test.mjs -node analyze.mjs /temporary/experiment-root > /temporary/result.json +cd /temporary +node /path/to/recipe/analyze.mjs ./experiment-root > ./result.json ``` The reader requires all 20 cells: three baseline/disabled/enabled repetitions @@ -226,6 +227,11 @@ missing samples, nonzero child exit, OOM/restart, unfinished business work or span loss. It normalizes CPU by measured completions, excluding warmup counts. Metadata, span and audit totals explicitly include warmup. +Run from the copied experiment's parent directory. The input root must resolve +inside the working directory. Each data file must resolve inside the selected +experiment root, including symbolic links. This keeps input selection scoped +to the data mounted for analysis. + The fixed statistical method uses paired relative differences and a conventional two-sided 95% paired-t interval with three pairs, two degrees of freedom and critical value 4.3026527299. This small sample relies on the usual diff --git a/.github/tracing-performance/analyze.mjs b/.github/tracing-performance/analyze.mjs index cd57d103b..e6128546b 100644 --- a/.github/tracing-performance/analyze.mjs +++ b/.github/tracing-performance/analyze.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import {readdir, readFile} from 'node:fs/promises'; +import {readdir, readFile, realpath} from 'node:fs/promises'; import path from 'node:path'; import {pathToFileURL} from 'node:url'; @@ -79,7 +79,7 @@ function validateBusiness(data) { ActivityScheduled: runs * members, ActivityStarted: runs * members, ActivityCompleted: runs * members, WorkflowCompleted: runs}); assert.ok(Number.isSafeInteger(audit.repairs) && audit.repairs >= 0); - assert.deepEqual(Object.keys(audit.metadata_bytes).sort(), ['activity_executions', 'workflow_runs', 'workflow_tasks']); + assert.deepEqual(new Set(Object.keys(audit.metadata_bytes)), new Set(['activity_executions', 'workflow_runs', 'workflow_tasks'])); for (const bytes of Object.values(audit.metadata_bytes)) assert.ok(Number.isSafeInteger(bytes) && bytes >= 0); for (const key of ['unfinished_runs', 'unfinished_activities', 'unfinished_tasks', 'nonfirst_tasks', 'unfinished_attempts', 'nonfirst_attempts', 'queue', 'failed_jobs']) assert.equal(audit[key], 0, key); @@ -99,7 +99,7 @@ function validateTelemetry({outcome, audit, telemetry}) { } function summarizeComponents(data, before, after) { - assert.deepEqual([...before.keys()].sort(), [...after.keys()].sort(), 'Container replacement invalidates CPU'); + assert.deepEqual(new Set(before.keys()), new Set(after.keys()), 'Container replacement invalidates CPU'); const samples = data.samples; assert.ok(samples.length >= 18 && samples.length % 9 === 0, 'Incomplete sampler output'); const components = {}; @@ -135,7 +135,7 @@ function validateExits(data, before) { assert.equal(data.probeExits.length, 2); for (const code of [...data.workerExits, ...data.probeExits]) assert.equal(code, 0); assert.equal(data.exits.length, 9); - assert.deepEqual(data.exits.map(item => item.id).sort(), [...before.keys()].sort()); + assert.deepEqual(new Set(data.exits.map(item => item.id)), new Set(before.keys())); for (const item of data.exits) { assert.equal(item.oom, false); assert.equal(item.restarts, 0); @@ -194,32 +194,53 @@ export function compare(cells) { return result; } -const json = async file => JSON.parse(await readFile(file, 'utf8')); -const jsonLines = async file => (await readFile(file, 'utf8')).trim().split('\n').map(line => JSON.parse(line)); +function within(directory, resolved) { + if (resolved !== directory && !resolved.startsWith(directory + path.sep)) { + throw new RangeError('Experiment input escapes its selected directory'); + } + return resolved; +} + +export async function experimentRoot(input, directory = process.cwd()) { + const allowed = await realpath(directory); + return within(allowed, await realpath(path.resolve(allowed, input))); +} + +export async function dataPath(root, file) { + return within(root, await realpath(file)); +} + +const json = async (root, file) => JSON.parse(await readFile(await dataPath(root, file), 'utf8')); +const jsonLines = async (root, file) => (await readFile(await dataPath(root, file), 'utf8')) + .trim().split('\n').map(line => JSON.parse(line)); export async function loadCell(root, name) { - const proof = path.join(root, 'proof', name); - const state = path.join(root, 'state', name); + root = await experimentRoot(root); + assert.match(name, /^tracing-measure-(base|off|on)-(ordinary|fanout)-(1|2|3|drift)$/); + const proof = await dataPath(root, path.join(root, 'proof', name)); + const state = await dataPath(root, path.join(root, 'state', name)); const exitCodes = async directory => { - const files = (await readdir(directory)).filter(file => file.endsWith('.exit')); + const files = (await readdir(await dataPath(root, directory))).filter(file => file.endsWith('.exit')); return Promise.all(files.map(async file => { - const value = (await readFile(path.join(directory, file), 'utf8')).trim(); - assert.match(value, /^(0|[1-9][0-9]*)$/, 'Missing or invalid child exit'); + const value = (await readFile(await dataPath(root, path.join(directory, file)), 'utf8')).trim(); + assert.match(value, /^(0|[1-9]\d*)$/, 'Missing or invalid child exit'); return Number(value); })); }; - return summarizeCell({outcome: await json(path.join(proof, 'outcome.json')), - measure: await json(path.join(proof, 'measure-result.json')), warmup: await json(path.join(proof, 'warmup-result.json')), - audit: await json(path.join(proof, 'audit.json')), telemetry: await json(path.join(proof, 'telemetry.json')), - before: await jsonLines(path.join(proof, 'before.jsonl')), after: await jsonLines(path.join(proof, 'after.jsonl')), - samples: await jsonLines(path.join(proof, 'samples.jsonl')), exits: await jsonLines(path.join(proof, 'exits.jsonl')), + return summarizeCell({outcome: await json(root, path.join(proof, 'outcome.json')), + measure: await json(root, path.join(proof, 'measure-result.json')), warmup: await json(root, path.join(proof, 'warmup-result.json')), + audit: await json(root, path.join(proof, 'audit.json')), telemetry: await json(root, path.join(proof, 'telemetry.json')), + before: await jsonLines(root, path.join(proof, 'before.jsonl')), after: await jsonLines(root, path.join(proof, 'after.jsonl')), + samples: await jsonLines(root, path.join(proof, 'samples.jsonl')), exits: await jsonLines(root, path.join(proof, 'exits.jsonl')), workerExits: await exitCodes(path.join(state, 'runtime')), probeExits: await exitCodes(path.join(state, 'results'))}); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const root = process.argv[2]; - assert.ok(root, 'Usage: node analyze.mjs COPIED_EXPERIMENT_ROOT'); - const names = (await readdir(path.join(root, 'proof'))).filter(name => name.startsWith('tracing-measure-')); + const input = process.argv[2]; + assert.ok(input, 'Usage: node analyze.mjs COPIED_EXPERIMENT_ROOT'); + const root = await experimentRoot(input); + const names = (await readdir(await dataPath(root, path.join(root, 'proof')))) + .filter(name => name.startsWith('tracing-measure-')); const cells = await Promise.all(names.map(name => loadCell(root, name))); console.log(JSON.stringify({method: 'paired-t-95-n3-df2; targets and drift frozen before admission', comparison: compare(cells), cells}, null, 2)); diff --git a/.github/tracing-performance/analyze.test.mjs b/.github/tracing-performance/analyze.test.mjs index ebfcc8148..69ec2a676 100644 --- a/.github/tracing-performance/analyze.test.mjs +++ b/.github/tracing-performance/analyze.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; +import {mkdir, mkdtemp, rm, symlink, writeFile} from 'node:fs/promises'; +import path from 'node:path'; import test from 'node:test'; -import {compare, summarizeCell} from './analyze.mjs'; +import {compare, dataPath, experimentRoot, summarizeCell} from './analyze.mjs'; function pairData() { const cells = []; @@ -104,3 +106,33 @@ test('missing or duplicate cells cannot be silently selected out', () => { cells[0] = {...cells[1]}; assert.throws(() => compare(cells), /Missing or repeated/); }); + +test('canonical input selection refuses parent, sibling-prefix and linked escapes', async t => { + const workspace = await mkdtemp(path.join(process.cwd(), '.analysis-input-')); + const sibling = workspace + '-sibling'; + await mkdir(sibling); + t.after(async () => { + await rm(workspace, {recursive: true}); + await rm(sibling, {recursive: true}); + }); + assert.equal(await experimentRoot('.', workspace), workspace); + await assert.rejects(experimentRoot('..', workspace), /escapes/); + await assert.rejects(experimentRoot(sibling, workspace), /escapes/); + await symlink(sibling, path.join(workspace, 'linked-root')); + await assert.rejects(experimentRoot('linked-root', workspace), /escapes/); +}); + +test('a copied result cannot link a data file outside its selected experiment', async t => { + const workspace = await mkdtemp(path.join(process.cwd(), '.analysis-data-')); + t.after(async () => { await rm(workspace, {recursive: true}); }); + const root = path.join(workspace, 'experiment'); + await mkdir(root); + const normal = path.join(root, 'result.json'); + const outside = path.join(workspace, 'outside.json'); + await writeFile(normal, '{}'); + await writeFile(outside, '{}'); + assert.equal(await dataPath(root, normal), normal); + const link = path.join(root, 'linked.json'); + await symlink(outside, link); + await assert.rejects(dataPath(root, link), /escapes/); +}); From 3c2b3fd09ec420275815f265cfceb60e37791251 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sat, 17 Oct 2026 03:17:23 +0000 Subject: [PATCH 63/77] Document tracing setup, correlation fields and qualification scope --- .github/tracing-performance/README.md | 4 + docs/embedded-tracing.md | 125 ++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/.github/tracing-performance/README.md b/.github/tracing-performance/README.md index f7f30b23f..03c560cad 100644 --- a/.github/tracing-performance/README.md +++ b/.github/tracing-performance/README.md @@ -91,6 +91,10 @@ a failed cell complete. qualification inputs, not production sizing advice. All three cells install the same application-owned OpenTelemetry provider. +The optional Laravel `LogContext` tap is registered only in the enabled cell, +using the same flag as tracing. Disabled CPU measurements cover that conditional +configuration. The [source integration guide](../../docs/embedded-tracing.md) +shows the corresponding application setup. Workers share Redis cache on a separate logical database from their queue. The runtime's repair throttle therefore coordinates across all four workers; a process-local array cache is not this multi-process profile. diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index 508d6fd65..95d949ed1 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -16,6 +16,131 @@ operation. An unavailable or non-array baggage allowlist permits no baggage; only string entries are used. Neither optional setting can prevent admission, timer or activity execution, or replace the workflow's own failure. +## Configure the source integration + +In a qualification application, use one application-owned environment flag for +tracing and its optional log tap. Set `DW_TRACING_ENABLED=true` in that +application's environment, then configure the existing `tracing` section of +`config/workflows.php`: + +```php +'tracing' => [ + 'enabled' => (bool) env('DW_TRACING_ENABLED', false), + 'baggage_allowlist' => [], +], +``` + +The empty allowlist propagates no baggage. Add only the application-approved +keys that must cross durable boundaries. The application's installed +propagator must support those fields. The encoded-baggage regression remains +a release blocker even when this example uses an empty allowlist. +The package reads the configured boolean; the environment mapping above is +part of the application example. + +Register the application's chosen OpenTelemetry provider and propagators +before admitting or processing workflows. Existing compatible HTTP/database +instrumentation then uses the active standard context inside an activity. +The application continues to own sampling, export and process shutdown. +The [optional HTTP dependency profile](../.github/tracing-http/composer.json) +shows the packages used by the source controls, with the exporter limits +described below. + +### Laravel log correlation + +Append the supported tap to each existing Laravel channel used by the workflow, +activity and injected services. In that channel's `config/logging.php` entry: + +```php +'tap' => [ + // Keep the channel's existing taps here. + ...((bool) env('DW_TRACING_ENABLED', false) + ? [\Workflow\V2\Tracing\LogContext::class] + : []), +], +``` + +Load this configuration before the channel is resolved. Choose a formatter +that emits the record's `context` fields, such as Monolog's `JsonFormatter`. +Enabling span emission alone does not install this channel tap. Application +code can keep its ordinary `Log::info(...)` calls and injected logger usage. +The [cold HTTP/worker control](../tests/Unit/V2/IncomingHttpTracingTest.php) +checks both actual callback logs and spans after the request process exits. + +The tap reads only the active durable scope. It restores ordinary logging +outside that scope and preserves an existing application `request_id`. Logging +adapter errors leave the original record usable. + +Restart the application's workers after changing their loaded configuration. +The overhead recipe uses this conditional tap profile. Always-installed custom +logging processors need their own measurement. + +## Correlation fields and instrumentation scope + +The tracing scope name is `durable-workflow/workflow`. Its version comes from +Composer's installed pretty version when available. A development or benchmark +alias remains that alias; it is not evidence of a published release. + +The current operation supplies these bounded fields when their identities are +available: + +| Field | Meaning | +| --- | --- | +| `workflow_instance_id` | Logical workflow identity, which can have multiple runs. | +| `workflow_run_id` | The exact run being admitted or processed. | +| `workflow_type` | The recorded workflow type. | +| `namespace` | The operation's namespace when scoped. | +| `task_id` | The current physical task. | +| `activity_id` | Logical activity identity, retained across its ordinary retries. | +| `activity_type` | The authored activity type. | +| `activity_attempt_id` | The concrete activity attempt when available. | +| `activity_attempt_number` | The positive attempt number. | +| `request_id` | Application-supplied request correlation, kept separate from durable identities. | +| `trace_id`, `span_id` | Active OpenTelemetry context identifiers, also exposed to the log tap. | +| `timer_id` | The timer associated with a timer operation. | +| `workflow_command_id` | The command associated with an admission or delivery. | +| `schedule_id` | The schedule associated with a trigger. | + +Trace and span IDs are part of OpenTelemetry's span context. They are not +duplicated as custom span attributes. Missing or invalid optional fields are +omitted. Runtime-supplied string correlation fields are bounded to 512 bytes +and exclude control characters; attempt numbers remain integers. Existing +application log context retains its application-defined format. + +Durable identifiers come from the current execution scope. An existing +`request_id` on a log record keeps its application meaning. These identifiers +are not automatically used as metric labels. The integration excludes workflow +arguments, results, HTTP bodies and credentials from its default attributes. +Application-chosen identifiers and explicitly allowlisted baggage still need +the access controls appropriate to their contents. + +## Runtime and SDK qualification + +This matrix describes this draft's durable propagation and correlation +contract. Ordinary application HTTP instrumentation has a separate scope. + +| Runtime or client | Scope established by this draft | Support status | +| --- | --- | --- | +| Embedded Laravel | Source controls cover incoming HTTP context, separate queue workers, retries, recovery and the transitions described below. | Unreleased. Baggage, remaining full gates and overhead acceptance still block stable support. | +| PHP Server | This embedded draft adds no service transport contract. | Separate integration and consumer qualification required. | +| Rust Server draft | Proposed observability columns and carriers have not been adopted by its catalog. | Unqualified for this contract. | +| Cloud | No deployment of this draft is claimed. | Separate release and deployment qualification required. | +| PHP service SDK | No durable carrier API or service propagation is added here. | Unqualified for this contract. | +| Python service SDK | No durable carrier API or service propagation is added here. | Unqualified for this contract. | +| Rust service SDK | No durable carrier API or service propagation is added here. | Unqualified for this contract. | + +The package's optional tracing API can be absent, and tracing is disabled by +default. In those modes the execution callback still runs with its ordinary +business outcome. Enabling this source requires review of its proposed nullable +`observability_context` columns on runs, commands and tasks. Existing rows keep +null metadata. Older executions use independent processing segments instead +of borrowing an unrelated worker's parent. Canonical histories, command receipt +comparisons, retry policies and deadlines retain their existing contracts. + +Worker processes can handle successive jobs, but this integration scopes +context to the current operation. It does not keep one execution span open +for the lifetime of a worker session. Concurrent Fiber and cold-process controls +check the scope restoration described below. + ## Keep collector outages outside business work PHP exporters can perform synchronous HTTP requests. A batch processor alone From 5a3a27c154ea78266fd7f2e5bbfe24db026d0dbd Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sat, 17 Oct 2026 03:44:27 +0000 Subject: [PATCH 64/77] Record tracing CPU study and validate full counter windows --- .github/tracing-performance/README.md | 11 +- .../tracing-performance/RESULTS-2026-10-17.md | 179 ++++++++++++++++++ .github/tracing-performance/analyze.mjs | 6 +- .github/tracing-performance/analyze.test.mjs | 18 ++ 4 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 .github/tracing-performance/RESULTS-2026-10-17.md diff --git a/.github/tracing-performance/README.md b/.github/tracing-performance/README.md index 03c560cad..eb170672b 100644 --- a/.github/tracing-performance/README.md +++ b/.github/tracing-performance/README.md @@ -1,7 +1,8 @@ # Embedded tracing overhead workload -This is a prospective qualification recipe for draft Workflow #788. It has no -measured result yet. The encoded-baggage regression and the other acceptance +This is the qualification recipe for draft Workflow #788. The +[October 17 measurement](RESULTS-2026-10-17.md) completed, but did not qualify +any of the four CPU budgets. The encoded-baggage regression and the other acceptance requirements of [#773](https://github.com/durable-workflow/workflow/issues/773) remain separate release gates. @@ -231,6 +232,12 @@ missing samples, nonzero child exit, OOM/restart, unfinished business work or span loss. It normalizes CPU by measured completions, excluding warmup counts. Metadata, span and audit totals explicitly include warmup. +The declared offer window remains 120 seconds. Node timers use integer-delay +precision, so the probe's actual elapsed value can be fractionally below its +nominal window. The reader preserves that value and requires every cgroup's +counter interval to cover the full declared window, as well as all offers and +original completions. It does not round or rescale measured CPU. + Run from the copied experiment's parent directory. The input root must resolve inside the working directory. Each data file must resolve inside the selected experiment root, including symbolic links. This keeps input selection scoped diff --git a/.github/tracing-performance/RESULTS-2026-10-17.md b/.github/tracing-performance/RESULTS-2026-10-17.md new file mode 100644 index 000000000..124f32857 --- /dev/null +++ b/.github/tracing-performance/RESULTS-2026-10-17.md @@ -0,0 +1,179 @@ +# Embedded tracing measurement, October 17, 2026 + +All 20 original cells completed and passed their business, resource and export +audits. **None of the four CPU-budget criteria qualifies.** This is an unreleased +embedded candidate measurement, with no stable, service-mode or capacity claim. + +## Method and software + +The [method was recorded before results](https://github.com/durable-workflow/workflow/issues/773#issuecomment-6228453784). +The [functional and offered-load prerequisites](https://github.com/durable-workflow/workflow/issues/773#issuecomment-6228327954) +are separate observations. This batch ran three rotating matched repetitions +of published Workflow 2.5.28, candidate tracing disabled, and candidate tracing +enabled. Two final published-baseline cells checked drift. Every cell used fresh +application/database state and the same four workers, logging call, dependency +references, resource limits and local accepting collector. + +Measured candidate: `316316a827fadc7b5aa545842451ca9c8e7a9db5`, tree +`c5ac80029a831e2664200e8b58aa301e657053dd`. Published source matched all 606 +files at tag `592dee`; other dependency references matched the candidate. +This compares the complete candidate with the published package. It does not +isolate every candidate change as a causal tracing cost. + +The dedicated amd64 host had eight logical CPUs, about 16 GiB RAM, AMD EPYC-Rome +as observed, Microsoft hypervisor, Linux 6.8.0-146 and cgroup v2. Docker was +29.1.3, Compose 2.40.3, PHP 8.3.35 with pcntl, Redis extension 6.1.0 and CLI +OPcache disabled. Builds and dependency installation finished before measurement. +Administrative SSH/status reads and one bounded first-cell reader-validation +copy occurred during the batch. No concurrent build or unrelated application +workload was admitted. This is not a matched hardware performance claim against +another product or an estimate of maximum capacity. + +| Role | CPU cap | Memory cap | +| --- | ---: | ---: | +| HTTP application | 0.5 | 512 MiB | +| Each of four workers | 1 | 384 MiB | +| MySQL | 1 | 2 GiB | +| Redis | 0.1 | 128 MiB | +| Collector | 0.2 | 256 MiB | +| Probe | 0.2 | 256 MiB | + +Swap was disabled. The primary quantity is the sum of exact CPU counter deltas +from all nine containers, divided by measured completed workflows. It includes +application, all workers, database, cache, collector and probe through child +completion/provider drain. Setup, warmup, audit queries, host sampling tools and +teardown are outside those deltas. Every container identity, cap and counter +interval was checked before/after. No OOM, restart or nonzero child exit occurred. + +Ordinary cells offered one one-activity workflow per second for 120 seconds, +120 measured completions. Fan-out cells offered one 64-activity workflow per +minute for 120 seconds, two measured completions. Separate warmups completed +30 ordinary workflows or one fan-out workflow before measurement. Original +90-second observer, 100-second run and 120-second execution bounds were retained. +Providers were installed in every cell. The application registered the log tap +only in the enabled profile, as described in the [recipe](README.md). + +## CPU acceptance + +Each estimate is the mean of three matched candidate/baseline ratios, with a +two-sided 95% paired-t interval, n=3, df=2. The frozen acceptance requires both +the upper interval endpoint and absolute final baseline drift to fit the target. +Negative point estimates do not demonstrate a speed improvement. + +| Workload | Profile | Mean CPU change | 95% interval | Baseline drift | Target | Qualified | +| --- | --- | ---: | --- | ---: | ---: | --- | +| Ordinary | Disabled | -1.28% | [-6.15%, +3.60%] | +0.30% | 1% | No | +| Ordinary | Enabled | +6.26% | [+3.16%, +9.36%] | +0.30% | 5% | No | +| 64-member fan-out | Disabled | -1.28% | [-8.27%, +5.70%] | +4.10% | 1% | No | +| 64-member fan-out | Enabled | +0.43% | [-12.20%, +13.07%] | +4.10% | 5% | No | + +All CPU observations below are seconds per completed measured workflow. The +three values are original repetitions, with no discarded cell or extra sample. + +| Workload | Profile | Repetition 1 | Repetition 2 | Repetition 3 | Final baseline drift cell | +| --- | --- | ---: | ---: | ---: | ---: | +| Ordinary | Published | 0.605717 | 0.596319 | 0.636958 | 0.614822 | +| Ordinary | Disabled | 0.595531 | 0.601429 | 0.617848 | | +| Ordinary | Enabled | 0.647830 | 0.638079 | 0.667641 | | +| Fan-out | Published | 84.775585 | 90.481546 | 92.066782 | 92.759297 | +| Fan-out | Disabled | 85.815143 | 89.805320 | 88.088379 | | +| Fan-out | Enabled | 90.109925 | 88.509247 | 89.471048 | | + +### Component CPU + +Arithmetic means across the three repetitions, seconds per completed workflow. +Worker means sum all four worker containers. + +| Workload/profile | HTTP | Workers | MySQL | Redis | Collector | Probe | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Ordinary/published | 0.101366 | 0.244040 | 0.220823 | 0.023822 | 0.000072 | 0.022874 | +| Ordinary/disabled | 0.099768 | 0.240792 | 0.218220 | 0.023648 | 0.000074 | 0.022434 | +| Ordinary/enabled | 0.109262 | 0.256221 | 0.220766 | 0.023732 | 0.018796 | 0.022405 | +| Fan-out/published | 2.725616 | 66.573568 | 17.606948 | 1.236480 | 0.004065 | 0.961294 | +| Fan-out/disabled | 2.695288 | 65.571619 | 17.429256 | 1.236462 | 0.004066 | 0.966255 | +| Fan-out/enabled | 2.719067 | 66.332326 | 17.513380 | 1.235851 | 0.586929 | 0.975852 | + +Ordinary enabled CPU is higher in HTTP, workers and the collector. The collector +accounts for about half the difference between the raw enabled and published +means. That identifies a useful investigation, without establishing which +source change or exporter setting causes the full observed difference. + +## Latency, memory and correctness + +All declared offers were accepted and completed, with no missed offer or HTTP +error. Per-cell observed completion rates were about 0.9960–1.00001 workflows/s +for ordinary work and 0.0166665–0.0166668 for fan-out. These are offered-load +results. Fan-out has only two measured observations per cell, so its high +percentiles describe those few observations, not a reliable tail distribution. + +Each latency entry is the arithmetic mean of three per-cell percentiles, in +milliseconds. These are not pooled percentiles. + +| Workload/profile | Mean p50 | Mean p95 | Mean p99 | +| --- | ---: | ---: | ---: | +| Ordinary/published | 887.50 | 1524.28 | 1688.58 | +| Ordinary/disabled | 891.34 | 1434.45 | 1698.46 | +| Ordinary/enabled | 915.68 | 1457.40 | 1629.62 | +| Fan-out/published | 54319.66 | 55497.18 | 55497.18 | +| Fan-out/disabled | 53638.97 | 55474.13 | 55474.13 | +| Fan-out/enabled | 53690.74 | 55916.89 | 55916.89 | + +Memory below is sampled cgroup memory in MiB: repetition-mean / largest sampled +sum of individual container peaks. Worker values sum four containers. Peaks +are not simultaneous aggregate peaks or PHP RSS. Requested sampling was one +second; observed intervals over all 20 cells were 1.288–1.461 seconds. Samples +can miss short peaks. Container lifetime peaks also include startup/warmup and +are excluded from this table. + +| Workload/profile | HTTP | Workers | MySQL | Redis | Collector | Probe | +| --- | --- | --- | --- | --- | --- | --- | +| Ordinary/published | 34.20 / 34.50 | 194.53 / 206.57 | 565.99 / 579.05 | 3.46 / 5.05 | 8.32 / 8.32 | 21.53 / 30.57 | +| Ordinary/disabled | 34.32 / 34.63 | 195.36 / 207.56 | 566.83 / 579.21 | 3.56 / 5.94 | 8.32 / 8.32 | 21.52 / 26.44 | +| Ordinary/enabled | 36.90 / 37.69 | 207.73 / 220.63 | 565.98 / 580.74 | 3.53 / 5.14 | 13.76 / 14.02 | 21.09 / 30.49 | +| Fan-out/published | 34.15 / 34.66 | 221.51 / 233.35 | 577.20 / 591.85 | 3.49 / 5.21 | 8.33 / 8.33 | 19.49 / 30.63 | +| Fan-out/disabled | 34.27 / 34.54 | 222.89 / 234.96 | 578.72 / 592.33 | 3.50 / 6.04 | 8.33 / 8.34 | 19.68 / 29.73 | +| Fan-out/enabled | 36.84 / 37.38 | 235.36 / 247.54 | 579.47 / 592.66 | 3.48 / 5.03 | 13.79 / 13.80 | 19.28 / 24.55 | + +Including warmup and drift cells, the audit counts 1,530 runs, 3,420 activities +and callbacks, 6,480 first-attempt tasks and 14,850 business events. Typed results, +history counts and identities match. No unfinished run/task/attempt/activity, +non-first task/attempt, residual queue or failed job remains. The summed task +`repair_count` is **375**, all in fan-out cells; do not describe this batch as +zero repair/redispatch work. Fan-out repetition counters are published +32/45/44, disabled 35/36/34 and enabled 41/30/38, plus 40 in the final baseline. +Every ordinary cell has zero. This work remains included in measured CPU. + +All enabled spans ended and were received: 4,456 / 4,456, including warmup. +Ordinary enabled cells each have 1,050 spans, 21,750 bytes of run metadata and +143,400 bytes of task metadata. Fan-out enabled cells have 440/429/437 spans, +435 bytes of run metadata and 102,813 bytes of task metadata each. These are +stored observability-field byte counts, not total database size or network +traffic. Activity-execution metadata is zero. Disabled/published cells produce +no spans or stored carrier metadata. No durable business payload/history field +was replaced by telemetry. + +## Reader correction and remaining limits + +The original analyzer rejected three actual callback elapsed values: +119.999219339, 119.999356686 and 119.999713308 seconds. Their original drivers +completed with all declared offers and results. Node's integer timer precision +does not guarantee an exact callback time. The corrected reader retains those +actual values, verifies the observation covers the final declared offer, and +requires **every cgroup counter interval to cover the full 120-second window**. +It neither rounds elapsed data nor rescales CPU. The new controls failed on the +prior reader and pass in the corrected 24-test analysis/probe suite. Original +samples, workloads, CPU targets and statistical method are unchanged. No cell +was rerun or relabeled as another original. + +An earlier functional baseline completed business work but failed probe teardown; +it remains unsuccessful. A separate corrected lifecycle control passed before +this batch. This batch's original drivers all completed successfully. The +experiment host, key and raw data are disposable; the code and this readable +result are the retained outcome. + +The encoded-baggage unit failure remains selected against the released OTel +dependency. Full tracing, migration/catalog integration and consumer acceptance +remain unfinished. These measurements do not authorize a release. Next source +work should investigate the enabled ordinary execution/export cost and improve +measurement sensitivity before considering a new, separately declared study. +No adaptive extra repetitions or relaxed targets are part of this result. diff --git a/.github/tracing-performance/analyze.mjs b/.github/tracing-performance/analyze.mjs index e6128546b..8587b3acc 100644 --- a/.github/tracing-performance/analyze.mjs +++ b/.github/tracing-performance/analyze.mjs @@ -67,7 +67,8 @@ function validateBusiness(data) { } assert.equal(measure.duration_seconds, 120); assert.equal(measure.rate, ordinary ? 1 : 1 / 60); - assert.ok(Number.isFinite(measure.elapsed_seconds) && measure.elapsed_seconds >= 120); + const lastOfferSeconds = (expected - 1) / measure.rate; + assert.ok(Number.isFinite(measure.elapsed_seconds) && measure.elapsed_seconds >= lastOfferSeconds); assert.ok([measure.p50_ms, measure.p95_ms, measure.p99_ms].every(value => Number.isFinite(value) && value > 0)); assert.ok(measure.p50_ms <= measure.p95_ms && measure.p95_ms <= measure.p99_ms); const runs = expected + (ordinary ? 30 : 1); @@ -125,7 +126,8 @@ function summarizeComponents(data, before, after) { const intervals = times.slice(1).map((value, index) => value - times[index]); assert.ok(intervals.every(value => value > 0), 'Invalid sampling order'); observedIntervals.push(...intervals); - assert.ok(Date.parse(final.sampled_at) > Date.parse(initial.sampled_at)); + const intervalMs = Date.parse(final.sampled_at) - Date.parse(initial.sampled_at); + assert.ok(intervalMs >= data.measure.duration_seconds * 1000, 'CPU interval must cover the offer window'); } return {components, observedIntervals}; } diff --git a/.github/tracing-performance/analyze.test.mjs b/.github/tracing-performance/analyze.test.mjs index 69ec2a676..033dae9a5 100644 --- a/.github/tracing-performance/analyze.test.mjs +++ b/.github/tracing-performance/analyze.test.mjs @@ -53,6 +53,24 @@ test('normalizes all nine CPU deltas by measured work, excluding warmup counts', assert.equal(result.sample_interval_ms.mean, 1000); }); +test('integer timer precision keeps the actual elapsed value without shortening CPU coverage', () => { + const data = cellData(); + data.measure.elapsed_seconds = 119.999219339; + assert.equal(summarizeCell(data).measured.elapsed_seconds, 119.999219339); +}); + +test('every cgroup interval must cover the declared offer window', () => { + const data = cellData(); + data.after[0].sampled_at = '2026-01-01T00:01:59.999Z'; + assert.throws(() => summarizeCell(data), /CPU interval/); +}); + +test('the observation cannot finish before the last declared offer', () => { + const data = cellData(); + data.measure.elapsed_seconds = 118.999; + assert.throws(() => summarizeCell(data)); +}); + for (const [name, corrupt] of [ ['failed original', data => { data.outcome.result = 1; }], ['lost span', data => { data.outcome.cell = 'on'; data.telemetry = {ended: 10, received: 9}; }], From 9d5251ca500f87410fc1d5576279e8e10fdf51bd Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sun, 18 Oct 2026 11:50:37 +0000 Subject: [PATCH 65/77] Specify official Collector proof refusal controls --- .github/tracing-performance/analyze.test.mjs | 31 ++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/.github/tracing-performance/analyze.test.mjs b/.github/tracing-performance/analyze.test.mjs index 033dae9a5..75dc74688 100644 --- a/.github/tracing-performance/analyze.test.mjs +++ b/.github/tracing-performance/analyze.test.mjs @@ -53,6 +53,37 @@ test('normalizes all nine CPU deltas by measured work, excluding warmup counts', assert.equal(result.sample_interval_ms.mean, 1000); }); +function officialCollectorData() { + const data = cellData(); + data.outcome.collector = 'otelcol'; + const initial = {instance: 'collector-one', version: '0.163.1', uptime_seconds: 1, + accepted: 1, sent: 1, refused: null, failed: null}; + data.telemetry.collector = {before: initial, after: {...initial, uptime_seconds: 2}}; + return data; +} + +test('an initialized official collector can prove zero new spans without inventing missing metrics', () => { + assert.equal(summarizeCell(officialCollectorData()).spans_including_warmup.received, 0); +}); + +for (const [name, corrupt] of [ + ['missing collector proof', data => { delete data.telemetry.collector; }], + ['collector replacement', data => { data.telemetry.collector.after.instance = 'collector-two'; }], + ['counter reset', data => { data.telemetry.collector.after.accepted = 0; }], + ['unreported accepted span', data => { + data.telemetry.collector.after.accepted = 2; + data.telemetry.collector.after.sent = 2; + }], + ['collector export loss', data => { data.telemetry.collector.after.accepted = 2; }], + ['new refusal observation', data => { data.telemetry.collector.after.refused = 1; }], +]) { + test(`official collector proof refuses ${name}`, () => { + const data = officialCollectorData(); + corrupt(data); + assert.throws(() => summarizeCell(data)); + }); +} + test('integer timer precision keeps the actual elapsed value without shortening CPU coverage', () => { const data = cellData(); data.measure.elapsed_seconds = 119.999219339; From 44bb24fb67f0ef7235cd7d29c0762957dbf553ca Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sun, 18 Oct 2026 11:59:05 +0000 Subject: [PATCH 66/77] Add a pinned official Collector measurement profile --- .github/tracing-performance/README.md | 58 +++++++++ .github/tracing-performance/analyze.mjs | 18 ++- .github/tracing-performance/analyze.test.mjs | 86 ++++++++++++- .../tracing-performance/collector-metrics.mjs | 114 ++++++++++++++++++ .../collector-otelcol.yaml | 29 +++++ .../tracing-performance/collector-probe.php | 38 ++++++ .../compose.collector.yaml | 5 + 7 files changed, 345 insertions(+), 3 deletions(-) create mode 100644 .github/tracing-performance/collector-metrics.mjs create mode 100644 .github/tracing-performance/collector-otelcol.yaml create mode 100644 .github/tracing-performance/collector-probe.php create mode 100644 .github/tracing-performance/compose.collector.yaml diff --git a/.github/tracing-performance/README.md b/.github/tracing-performance/README.md index eb170672b..02bf07fd9 100644 --- a/.github/tracing-performance/README.md +++ b/.github/tracing-performance/README.md @@ -6,6 +6,64 @@ any of the four CPU budgets. The encoded-baggage regression and the other accept requirements of [#773](https://github.com/durable-workflow/workflow/issues/773) remain separate release gates. +## Prospective official Collector profile + +`compose.collector.yaml` selects a separately labelled official Collector profile. +It pins Collector 0.163.1 to its verified image digest and retains the same nine +roles, CPU/memory/swap limits and 1%/5% acceptance targets. The default PHP receiver +and October 17 report remain their original method. Do not transfer or combine +measurements across these collector profiles. The new protocol/counter controls +do not qualify a complete application cohort, overhead budget or tracing release. + +The Collector decodes official OTLP protobuf and uses a basic debug exporter with +logging suppressed. It retains no span payloads. Its +[internal metrics](https://opentelemetry.io/docs/collector/internal-telemetry/) +provide accepted, refused, receiver-failed and exported span counters, plus +in-flight request counts. +The exporter-failure series can be absent before a failure; the reader preserves +that absence as `null` and refuses changed failure observations. It does not +report missing counters as zero or infer payload equality from delivery counts. + +Before warmup, send exactly one synthetic calibration span using +`collector-probe.php` with its default count. This bounded setup request creates +the required counter series. The helper uses the normal official PHP SDK/exporter +and never writes the application's `ended.jsonl`. Its one-second transport timeout +is setup-only; business exports retain their existing 100 ms timeout. Read a +drained initial snapshot after calibration, then retain the Collector through +warmup, measured work and complete application/provider drain. Subtract only the +initial snapshot. Application-ended spans, including warmup, must equal both the +accepted and exported deltas. Disabled cells can prove a real zero delta on these +initialized counters. An uninitialized/missing series refuses observation. + +Use the override explicitly for every command in this profile. For example, these +commands exercise only calibration and metrics, without starting database or +application roles: + +```sh +docker compose -f compose.yaml -f compose.collector.yaml up -d collector +docker compose -f compose.yaml -f compose.collector.yaml run --rm --no-deps prepare \ + php collector-probe.php +docker compose -f compose.yaml -f compose.collector.yaml run --rm --no-deps probe \ + node collector-metrics.mjs +``` + +The snapshot command reads only the fixed internal `collector:8888/metrics` +endpoint, with a two-second timeout and 256 KiB response bound. During a measured +cell, execute it within the existing probe container before the final CPU snapshot +so its work remains in the nine-role accounting. Do not create an uncounted +measurement-side helper. No public port or production collector is needed. + +Declare `outcome.collector` as `otelcol` and `outcome.collector_image` as the exact +digest-qualified override image. Store `telemetry.collector.before` and `.after` +snapshots alongside the existing `ended` and `received` counts. The analyzer +independently verifies those counts, version, instance, monotonic counters/uptime, +drained exports and unchanged failure observations. Missing proof, replacement, +reset, loss or a mixed-profile comparison refuses qualification. Historical +PHP-collector records lack these fields and remain explicitly the legacy method. +The official Collector must exit zero on SIGTERM. A forced 143 exit is not +graceful cleanup for this profile; the historical PHP receiver keeps its original +shutdown expectation. + ## Fixed comparison The primary profile uses Laravel's database queue on MySQL and shared Redis diff --git a/.github/tracing-performance/analyze.mjs b/.github/tracing-performance/analyze.mjs index 8587b3acc..f9643b97f 100644 --- a/.github/tracing-performance/analyze.mjs +++ b/.github/tracing-performance/analyze.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import {COLLECTOR_IMAGE, collectorDelta} from './collector-metrics.mjs'; import {readdir, readFile, realpath} from 'node:fs/promises'; import path from 'node:path'; import {pathToFileURL} from 'node:url'; @@ -88,6 +89,13 @@ function validateBusiness(data) { } function validateTelemetry({outcome, audit, telemetry}) { + if (outcome.collector === 'otelcol') { + assert.equal(outcome.collector_image, COLLECTOR_IMAGE, 'Unexpected Collector image'); + assert.equal(telemetry.received, collectorDelta(telemetry.collector), 'Collector count proof differs'); + } else { + assert.equal(outcome.collector, undefined, 'Unknown collector profile'); + assert.equal(telemetry.collector, undefined, 'Official proof requires its declared profile'); + } assert.equal(telemetry.ended, telemetry.received, 'Span loss invalidates the cell'); assert.ok(Number.isSafeInteger(telemetry.ended) && telemetry.ended >= 0); if (outcome.cell === 'on') { @@ -142,7 +150,9 @@ function validateExits(data, before) { assert.equal(item.oom, false); assert.equal(item.restarts, 0); const role = before.get(item.id).role; - assert.equal(item.exit, ['mysql', 'redis'].includes(role) ? 0 : 143); + const graceful = ['mysql', 'redis'].includes(role) + || (role === 'collector' && data.outcome.collector === 'otelcol'); + assert.equal(item.exit, graceful ? 0 : 143); } } @@ -174,6 +184,12 @@ function interval(values) { export function compare(cells) { assert.equal(cells.length, 20, 'All twenty declared cells are required'); + const collectors = new Set(cells.map(cell => cell.collector ?? 'legacy-php')); + assert.equal(collectors.size, 1, 'Collector profiles cannot be mixed'); + assert.ok(['legacy-php', 'otelcol'].includes([...collectors][0]), 'Unknown collector profile'); + if (collectors.has('otelcol')) { + for (const cell of cells) assert.equal(cell.collector_image, COLLECTOR_IMAGE, 'Collector image drift'); + } const result = {}; for (const workload of ['ordinary', 'fanout']) { const select = (cell, repeat) => { diff --git a/.github/tracing-performance/analyze.test.mjs b/.github/tracing-performance/analyze.test.mjs index 75dc74688..824e7f05e 100644 --- a/.github/tracing-performance/analyze.test.mjs +++ b/.github/tracing-performance/analyze.test.mjs @@ -3,6 +3,7 @@ import {mkdir, mkdtemp, rm, symlink, writeFile} from 'node:fs/promises'; import path from 'node:path'; import test from 'node:test'; import {compare, dataPath, experimentRoot, summarizeCell} from './analyze.mjs'; +import {COLLECTOR_IMAGE, COLLECTOR_VERSION, collectorDelta, parseCollectorMetrics, readCollectorSnapshot} from './collector-metrics.mjs'; function pairData() { const cells = []; @@ -56,9 +57,12 @@ test('normalizes all nine CPU deltas by measured work, excluding warmup counts', function officialCollectorData() { const data = cellData(); data.outcome.collector = 'otelcol'; - const initial = {instance: 'collector-one', version: '0.163.1', uptime_seconds: 1, - accepted: 1, sent: 1, refused: null, failed: null}; + data.outcome.collector_image = COLLECTOR_IMAGE; + const initial = {schema: 'durable-workflow.collector-snapshot-v1', instance: 'collector-one', + version: COLLECTOR_VERSION, uptime_seconds: 1, + accepted: 1, sent: 1, refused: 0, receiver_failed: 0, in_flight: 0, failed: null}; data.telemetry.collector = {before: initial, after: {...initial, uptime_seconds: 2}}; + data.exits[7].exit = 0; return data; } @@ -66,6 +70,18 @@ test('an initialized official collector can prove zero new spans without inventi assert.equal(summarizeCell(officialCollectorData()).spans_including_warmup.received, 0); }); +test('official Collector graceful termination is exit zero', () => { + const data = officialCollectorData(); + data.exits[7].exit = 0; + assert.doesNotThrow(() => summarizeCell(data)); +}); + +test('official Collector forced termination is not graceful cleanup', () => { + const data = officialCollectorData(); + data.exits[7].exit = 143; + assert.throws(() => summarizeCell(data)); +}); + for (const [name, corrupt] of [ ['missing collector proof', data => { delete data.telemetry.collector; }], ['collector replacement', data => { data.telemetry.collector.after.instance = 'collector-two'; }], @@ -76,6 +92,11 @@ for (const [name, corrupt] of [ }], ['collector export loss', data => { data.telemetry.collector.after.accepted = 2; }], ['new refusal observation', data => { data.telemetry.collector.after.refused = 1; }], + ['missing accepted counter', data => { delete data.telemetry.collector.after.accepted; }], + ['wrong image', data => { data.outcome.collector_image = 'unqualified:latest'; }], + ['new exporter failure', data => { data.telemetry.collector.after.failed = 1; }], + ['in-flight export', data => { data.telemetry.collector.after.in_flight = 1; }], + ['nonadvancing collector clock', data => { data.telemetry.collector.after.uptime_seconds = 1; }], ]) { test(`official collector proof refuses ${name}`, () => { const data = officialCollectorData(); @@ -84,6 +105,60 @@ for (const [name, corrupt] of [ }); } +function collectorText() { + const identity = `service_instance_id="collector-one",service_version="${COLLECTOR_VERSION}"`; + return [ + `otelcol_process_uptime{${identity}} 1`, + `otelcol_receiver_accepted_spans{receiver="otlp",${identity},transport="http"} 1`, + `otelcol_receiver_refused_spans{receiver="otlp",${identity},transport="http"} 0`, + `otelcol_receiver_failed_spans{receiver="otlp",${identity},transport="http"} 0`, + `otelcol_exporter_sent_spans{exporter="debug",${identity}} 1`, + `otelcol_exporter_in_flight_requests{data_type="traces",exporter="debug",${identity}} 0`, + ].join('\n'); +} + +test('reads initialized official metrics and preserves an unavailable failure series as null', () => { + const parsed = parseCollectorMetrics(collectorText()); + assert.deepEqual(parsed, officialCollectorData().telemetry.collector.before); + assert.equal(collectorDelta({before: parsed, after: {...parsed, uptime_seconds: 2, accepted: 4, sent: 4}}), 3); +}); + +for (const [name, corrupt] of [ + ['uninitialized series', text => text.split('\n')[0]], + ['missing refusal series', text => text.split('\n').filter(line => !line.includes('refused_spans')).join('\n')], + ['duplicate series', text => text + '\n' + text.split('\n')[1]], + ['fractional counter', text => text.replace('transport="http"} 1', 'transport="http"} 1.5')], + ['unsafe counter', text => text.replace('transport="http"} 1', 'transport="http"} 9007199254740992')], + ['nonfinite counter', text => text.replace('transport="http"} 1', 'transport="http"} NaN')], + ['mixed instances', text => text.replace('collector-one', 'collector-two')], + ['wrong version', text => text.replaceAll(COLLECTOR_VERSION, '0.0.0')], + ['wrong receiver', text => text.replaceAll('receiver="otlp"', 'receiver="other"')], + ['wrong transport', text => text.replaceAll('transport="http"', 'transport="grpc"')], + ['wrong exporter', text => text.replaceAll('exporter="debug"', 'exporter="other"')], + ['duplicate labels', text => text.replace('receiver="otlp"', 'receiver="otlp",receiver="other"')], +]) { + test(`Collector metrics refuse ${name}`, () => { + assert.throws(() => parseCollectorMetrics(corrupt(collectorText()))); + }); +} + +test('Collector HTTP and byte failures do not become zero-span observations', async () => { + for (const response of [ + new Response('unavailable', {status: 503}), + new Response(null, {status: 200}), + new Response('x'.repeat(256 * 1024 + 1), {status: 200}), + new Response(new Uint8Array([255]), {status: 200}), + ]) { + await assert.rejects(readCollectorSnapshot(async () => response)); + } + const parsed = await readCollectorSnapshot(async (url, options) => { + assert.equal(url, 'http://collector:8888/metrics'); + assert.ok(options.signal instanceof AbortSignal); + return new Response(collectorText()); + }); + assert.equal(parsed.accepted, 1); +}); + test('integer timer precision keeps the actual elapsed value without shortening CPU coverage', () => { const data = cellData(); data.measure.elapsed_seconds = 119.999219339; @@ -129,6 +204,13 @@ test('uses paired relative overhead and the frozen confidence targets', () => { } }); +test('collector profiles cannot be mixed within a matched CPU comparison', () => { + const cells = pairData(); + cells[0].collector = 'otelcol'; + cells[0].collector_image = COLLECTOR_IMAGE; + assert.throws(() => compare(cells), /Collector profiles/); +}); + test('a low mean with a wide interval does not qualify enabled tracing', () => { const cells = pairData(); for (const [index, repeat] of ['1', '2', '3'].entries()) { diff --git a/.github/tracing-performance/collector-metrics.mjs b/.github/tracing-performance/collector-metrics.mjs new file mode 100644 index 000000000..e6f100e35 --- /dev/null +++ b/.github/tracing-performance/collector-metrics.mjs @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import {pathToFileURL} from 'node:url'; + +export const COLLECTOR_VERSION = '0.163.1'; +export const COLLECTOR_IMAGE = 'otel/opentelemetry-collector:0.163.1@sha256:f0e23036dce3db3f9cb80d907c75a0d034cd32e93511a1943482d46129b9ae9c'; +const maximumBytes = 256 * 1024; +const metricFields = new Map([ + ['otelcol_process_uptime', 'uptime_seconds'], + ['otelcol_receiver_accepted_spans', 'accepted'], + ['otelcol_receiver_refused_spans', 'refused'], + ['otelcol_receiver_failed_spans', 'receiver_failed'], + ['otelcol_exporter_sent_spans', 'sent'], + ['otelcol_exporter_send_failed_spans', 'failed'], + ['otelcol_exporter_in_flight_requests', 'in_flight'], +]); + +function labels(value) { + const result = Object.create(null); + const pattern = /([a-zA-Z_][a-zA-Z0-9_]*)=("(?:[^"\\]|\\["\\n])*")(?:,|$)/gy; + let offset = 0; + for (const match of value.matchAll(pattern)) { + assert.equal(match.index, offset, 'Malformed metric labels'); + assert.ok(!Object.hasOwn(result, match[1]), 'Duplicate metric label'); + result[match[1]] = JSON.parse(match[2]); + offset = match.index + match[0].length; + } + assert.equal(offset, value.length, 'Incomplete metric labels'); + return result; +} + +function metric(line) { + const match = /^(otelcol_[a-z_]+)\{(.*)\} ([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)$/.exec(line); + assert.ok(match, 'Malformed Collector counter sample'); + const attributes = labels(match[2]); + if (match[1].startsWith('otelcol_receiver_')) { + assert.equal(attributes.receiver, 'otlp', 'Unexpected receiver'); + assert.equal(attributes.transport, 'http', 'Unexpected receiver transport'); + } + if (match[1].startsWith('otelcol_exporter_')) { + assert.equal(attributes.exporter, 'debug', 'Unexpected exporter'); + if (Object.hasOwn(attributes, 'data_type')) assert.equal(attributes.data_type, 'traces'); + } + return {field: metricFields.get(match[1]), attributes, value: Number(match[3])}; +} + +function validateSnapshot(snapshot) { + assert.equal(snapshot.schema, 'durable-workflow.collector-snapshot-v1'); + assert.equal(snapshot.version, COLLECTOR_VERSION, 'Unexpected Collector version'); + assert.match(snapshot.instance, /^[a-zA-Z0-9._-]{1,128}$/, 'Missing Collector instance'); + assert.ok(Number.isFinite(snapshot.uptime_seconds) && snapshot.uptime_seconds > 0, 'Invalid Collector uptime'); + for (const field of ['accepted', 'sent', 'refused', 'receiver_failed', 'in_flight']) { + assert.ok(Number.isSafeInteger(snapshot[field]) && snapshot[field] >= 0, `Missing/invalid ${field} counter`); + } + assert.ok(snapshot.accepted >= 1, 'Initialize the counter series with the declared synthetic probe'); + assert.equal(snapshot.accepted, snapshot.sent, 'Collector export has not drained'); + assert.equal(snapshot.in_flight, 0, 'Collector export is still in flight'); + assert.ok(snapshot.failed === null || (Number.isSafeInteger(snapshot.failed) && snapshot.failed >= 0), + 'Invalid exporter failure counter'); +} + +export function parseCollectorMetrics(text) { + assert.equal(typeof text, 'string'); + assert.ok(Buffer.byteLength(text) <= maximumBytes, 'Collector metrics exceed the bounded response size'); + const snapshot = {schema: 'durable-workflow.collector-snapshot-v1', failed: null}; + const seen = new Set(); + for (const line of text.split('\n')) { + const name = line.split(/[ {]/, 1)[0]; + if (!metricFields.has(name)) continue; + const sample = metric(line); + assert.ok(!seen.has(sample.field), 'Duplicate Collector counter series'); + seen.add(sample.field); + const {service_instance_id: instance, service_version: version} = sample.attributes; + snapshot.instance ??= instance; + snapshot.version ??= version; + assert.equal(instance, snapshot.instance, 'Mixed Collector instances'); + assert.equal(version, snapshot.version, 'Mixed Collector versions'); + snapshot[sample.field] = sample.value; + } + validateSnapshot(snapshot); + return snapshot; +} + +export function collectorDelta(proof) { + assert.ok(proof && proof.before && proof.after, 'Missing Collector proof'); + const {before, after} = proof; + validateSnapshot(before); + validateSnapshot(after); + assert.equal(before.instance, after.instance, 'Collector instance changed'); + assert.ok(after.uptime_seconds > before.uptime_seconds, 'Collector uptime did not advance'); + assert.ok(after.accepted >= before.accepted && after.sent >= before.sent, 'Collector counter reset'); + for (const field of ['refused', 'receiver_failed', 'failed']) { + assert.equal(after[field], before[field], `Collector ${field} observation changed`); + } + return after.accepted - before.accepted; +} + +export async function readCollectorSnapshot(fetcher = fetch) { + const response = await fetcher('http://collector:8888/metrics', {signal: AbortSignal.timeout(2000)}); + assert.equal(response.status, 200, 'Collector metrics HTTP refusal'); + assert.ok(response.body, 'Missing Collector metrics body'); + const chunks = []; + let bytes = 0; + for await (const chunk of response.body) { + bytes += chunk.byteLength; + assert.ok(bytes <= maximumBytes, 'Collector metrics exceed the bounded response size'); + chunks.push(chunk); + } + return parseCollectorMetrics(new TextDecoder('utf-8', {fatal: true}).decode(Buffer.concat(chunks))); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + assert.equal(process.argv.length, 2, 'This command reads the fixed isolated Collector endpoint'); + console.log(JSON.stringify(await readCollectorSnapshot())); +} diff --git a/.github/tracing-performance/collector-otelcol.yaml b/.github/tracing-performance/collector-otelcol.yaml new file mode 100644 index 000000000..9a06372d4 --- /dev/null +++ b/.github/tracing-performance/collector-otelcol.yaml @@ -0,0 +1,29 @@ +receivers: + otlp: + protocols: + http: + endpoint: 0.0.0.0:4318 + max_request_body_size: 1048576 +exporters: + debug: + verbosity: basic + sampling_initial: 1 + sampling_thereafter: 100000 +service: + telemetry: + logs: + level: error + metrics: + readers: + - pull: + exporter: + prometheus: + host: 0.0.0.0 + port: 8888 + without_type_suffix: true + with_resource_constant_labels: + included: [service.instance.id, service.version] + pipelines: + traces: + receivers: [otlp] + exporters: [debug] diff --git a/.github/tracing-performance/collector-probe.php b/.github/tracing-performance/collector-probe.php new file mode 100644 index 000000000..957f50221 --- /dev/null +++ b/.github/tracing-performance/collector-probe.php @@ -0,0 +1,38 @@ + ['min_range' => 1, 'max_range' => 64], +]); +if ($count === false) { + throw new InvalidArgumentException('Supply one to sixty-four synthetic calibration spans.'); +} +$http = new HttpFactory(); +$transport = (new PsrTransportFactory( + new Client(['timeout' => 1, 'connect_timeout' => 1]), $http, $http, +))->create('http://collector:4318/v1/traces', ContentTypes::PROTOBUF, maxRetries: 0); +$processor = new BatchSpanProcessor( + new SpanExporter($transport), Clock::getDefault(), + maxQueueSize: 256, maxExportBatchSize: 64, autoFlush: false, +); +$provider = new TracerProvider([$processor]); +$tracer = $provider->getTracer('durable-workflow.collector-calibration', '1'); +for ($index = 0; $index < $count; ++$index) { + $tracer->spanBuilder('qualification.collector.seed')->startSpan()->end(); +} +if (! $provider->shutdown()) { + throw new RuntimeException('Collector calibration did not drain successfully.'); +} +echo json_encode(['synthetic_spans' => $count, 'provider_shutdown' => true], JSON_THROW_ON_ERROR), PHP_EOL; diff --git a/.github/tracing-performance/compose.collector.yaml b/.github/tracing-performance/compose.collector.yaml new file mode 100644 index 000000000..e8037039c --- /dev/null +++ b/.github/tracing-performance/compose.collector.yaml @@ -0,0 +1,5 @@ +# Prospective official-Collector profile. The October 17 PHP receiver is unchanged. +services: + collector: + image: otel/opentelemetry-collector:0.163.1@sha256:f0e23036dce3db3f9cb80d907c75a0d034cd32e93511a1943482d46129b9ae9c + command: ["/otelcol", "--config=/app/collector-otelcol.yaml"] From ef999e492a8d317fa657fc2c217fbb59ff6b2020 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sun, 18 Oct 2026 12:09:22 +0000 Subject: [PATCH 67/77] Bind Collector proofs to process and CPU identity on the current baseline --- .github/tracing-performance/README.md | 8 ++++++++ .github/tracing-performance/analyze.mjs | 6 +++++- .github/tracing-performance/analyze.test.mjs | 14 ++++++++++++-- .../tracing-performance/collector-metrics.mjs | 8 ++++++++ .../tracing-performance/collector-otelcol.yaml | 4 +++- .github/tracing-performance/composer.json | 2 +- .github/tracing-performance/composer.lock | 16 ++++++++-------- 7 files changed, 45 insertions(+), 13 deletions(-) diff --git a/.github/tracing-performance/README.md b/.github/tracing-performance/README.md index 02bf07fd9..d6c7addc6 100644 --- a/.github/tracing-performance/README.md +++ b/.github/tracing-performance/README.md @@ -63,6 +63,14 @@ PHP-collector records lack these fields and remain explicitly the legacy method. The official Collector must exit zero on SIGTERM. A forced 143 exit is not graceful cleanup for this profile; the historical PHP receiver keeps its original shutdown expectation. +The configuration preserves the Collector's process instance identifier and adds +a separate Docker-hostname label. The analyzer requires that container label to +match the measured CPU role's container-ID prefix. A same-container process restart +still changes the process identifier and refuses the old counter proof. + +The prospective package baseline is published Workflow 2.6.1. The October 17 +study's 2.5.28 lock and measurements remain available at their recorded source; +they are not measurements of this new source/baseline/Collector combination. ## Fixed comparison diff --git a/.github/tracing-performance/analyze.mjs b/.github/tracing-performance/analyze.mjs index f9643b97f..206aede82 100644 --- a/.github/tracing-performance/analyze.mjs +++ b/.github/tracing-performance/analyze.mjs @@ -88,10 +88,14 @@ function validateBusiness(data) { return expected; } -function validateTelemetry({outcome, audit, telemetry}) { +function validateTelemetry({outcome, audit, telemetry, before}) { if (outcome.collector === 'otelcol') { assert.equal(outcome.collector_image, COLLECTOR_IMAGE, 'Unexpected Collector image'); assert.equal(telemetry.received, collectorDelta(telemetry.collector), 'Collector count proof differs'); + const collectorRows = before.filter(row => row.role === 'collector'); + assert.equal(collectorRows.length, 1, 'Missing Collector CPU role'); + assert.equal(telemetry.collector.before.container_prefix, collectorRows[0].container.slice(0, 12), + 'Counter proof belongs to another Collector CPU role'); } else { assert.equal(outcome.collector, undefined, 'Unknown collector profile'); assert.equal(telemetry.collector, undefined, 'Official proof requires its declared profile'); diff --git a/.github/tracing-performance/analyze.test.mjs b/.github/tracing-performance/analyze.test.mjs index 824e7f05e..16889911e 100644 --- a/.github/tracing-performance/analyze.test.mjs +++ b/.github/tracing-performance/analyze.test.mjs @@ -58,7 +58,9 @@ function officialCollectorData() { const data = cellData(); data.outcome.collector = 'otelcol'; data.outcome.collector_image = COLLECTOR_IMAGE; + const container = data.before.find(row => row.role === 'collector').container.slice(0, 12); const initial = {schema: 'durable-workflow.collector-snapshot-v1', instance: 'collector-one', + container_prefix: container, version: COLLECTOR_VERSION, uptime_seconds: 1, accepted: 1, sent: 1, refused: 0, receiver_failed: 0, in_flight: 0, failed: null}; data.telemetry.collector = {before: initial, after: {...initial, uptime_seconds: 2}}; @@ -97,6 +99,10 @@ for (const [name, corrupt] of [ ['new exporter failure', data => { data.telemetry.collector.after.failed = 1; }], ['in-flight export', data => { data.telemetry.collector.after.in_flight = 1; }], ['nonadvancing collector clock', data => { data.telemetry.collector.after.uptime_seconds = 1; }], + ['counter proof from another CPU role', data => { + data.telemetry.collector.before.container_prefix = 'ffffffffffff'; + data.telemetry.collector.after.container_prefix = 'ffffffffffff'; + }], ]) { test(`official collector proof refuses ${name}`, () => { const data = officialCollectorData(); @@ -106,7 +112,8 @@ for (const [name, corrupt] of [ } function collectorText() { - const identity = `service_instance_id="collector-one",service_version="${COLLECTOR_VERSION}"`; + const container = officialCollectorData().telemetry.collector.before.container_prefix; + const identity = `service_instance_id="collector-one",service_version="${COLLECTOR_VERSION}",qualification_container_id_prefix="${container}"`; return [ `otelcol_process_uptime{${identity}} 1`, `otelcol_receiver_accepted_spans{receiver="otlp",${identity},transport="http"} 1`, @@ -130,12 +137,15 @@ for (const [name, corrupt] of [ ['fractional counter', text => text.replace('transport="http"} 1', 'transport="http"} 1.5')], ['unsafe counter', text => text.replace('transport="http"} 1', 'transport="http"} 9007199254740992')], ['nonfinite counter', text => text.replace('transport="http"} 1', 'transport="http"} NaN')], - ['mixed instances', text => text.replace('collector-one', 'collector-two')], + ['mixed instances', text => text.replace('service_instance_id="', 'service_instance_id="other-')], ['wrong version', text => text.replaceAll(COLLECTOR_VERSION, '0.0.0')], ['wrong receiver', text => text.replaceAll('receiver="otlp"', 'receiver="other"')], ['wrong transport', text => text.replaceAll('transport="http"', 'transport="grpc"')], ['wrong exporter', text => text.replaceAll('exporter="debug"', 'exporter="other"')], ['duplicate labels', text => text.replace('receiver="otlp"', 'receiver="otlp",receiver="other"')], + ['missing series instance', text => text.replace('service_instance_id="collector-one",', '')], + ['missing series version', text => text.replace(`service_version="${COLLECTOR_VERSION}",`, '')], + ['missing series CPU role', text => text.replace(/,qualification_container_id_prefix="[^"]+"/, '')], ]) { test(`Collector metrics refuse ${name}`, () => { assert.throws(() => parseCollectorMetrics(corrupt(collectorText()))); diff --git a/.github/tracing-performance/collector-metrics.mjs b/.github/tracing-performance/collector-metrics.mjs index e6f100e35..4bd1fcaf1 100644 --- a/.github/tracing-performance/collector-metrics.mjs +++ b/.github/tracing-performance/collector-metrics.mjs @@ -47,6 +47,7 @@ function validateSnapshot(snapshot) { assert.equal(snapshot.schema, 'durable-workflow.collector-snapshot-v1'); assert.equal(snapshot.version, COLLECTOR_VERSION, 'Unexpected Collector version'); assert.match(snapshot.instance, /^[a-zA-Z0-9._-]{1,128}$/, 'Missing Collector instance'); + assert.match(snapshot.container_prefix, /^[a-f0-9]{12}$/, 'Missing Collector container identity'); assert.ok(Number.isFinite(snapshot.uptime_seconds) && snapshot.uptime_seconds > 0, 'Invalid Collector uptime'); for (const field of ['accepted', 'sent', 'refused', 'receiver_failed', 'in_flight']) { assert.ok(Number.isSafeInteger(snapshot[field]) && snapshot[field] >= 0, `Missing/invalid ${field} counter`); @@ -70,10 +71,16 @@ export function parseCollectorMetrics(text) { assert.ok(!seen.has(sample.field), 'Duplicate Collector counter series'); seen.add(sample.field); const {service_instance_id: instance, service_version: version} = sample.attributes; + const container = sample.attributes.qualification_container_id_prefix; + assert.match(instance, /^[a-zA-Z0-9._-]{1,128}$/, 'Missing series instance'); + assert.equal(version, COLLECTOR_VERSION, 'Missing/wrong series version'); + assert.match(container, /^[a-f0-9]{12}$/, 'Missing series container identity'); snapshot.instance ??= instance; snapshot.version ??= version; + snapshot.container_prefix ??= container; assert.equal(instance, snapshot.instance, 'Mixed Collector instances'); assert.equal(version, snapshot.version, 'Mixed Collector versions'); + assert.equal(container, snapshot.container_prefix, 'Mixed Collector containers'); snapshot[sample.field] = sample.value; } validateSnapshot(snapshot); @@ -86,6 +93,7 @@ export function collectorDelta(proof) { validateSnapshot(before); validateSnapshot(after); assert.equal(before.instance, after.instance, 'Collector instance changed'); + assert.equal(before.container_prefix, after.container_prefix, 'Collector container changed'); assert.ok(after.uptime_seconds > before.uptime_seconds, 'Collector uptime did not advance'); assert.ok(after.accepted >= before.accepted && after.sent >= before.sent, 'Collector counter reset'); for (const field of ['refused', 'receiver_failed', 'failed']) { diff --git a/.github/tracing-performance/collector-otelcol.yaml b/.github/tracing-performance/collector-otelcol.yaml index 9a06372d4..f5b126330 100644 --- a/.github/tracing-performance/collector-otelcol.yaml +++ b/.github/tracing-performance/collector-otelcol.yaml @@ -11,6 +11,8 @@ exporters: sampling_thereafter: 100000 service: telemetry: + resource: + qualification.container_id_prefix: ${env:HOSTNAME} logs: level: error metrics: @@ -22,7 +24,7 @@ service: port: 8888 without_type_suffix: true with_resource_constant_labels: - included: [service.instance.id, service.version] + included: [service.instance.id, service.version, qualification.container_id_prefix] pipelines: traces: receivers: [otlp] diff --git a/.github/tracing-performance/composer.json b/.github/tracing-performance/composer.json index b9b7735ad..07cb463a6 100644 --- a/.github/tracing-performance/composer.json +++ b/.github/tracing-performance/composer.json @@ -7,7 +7,7 @@ "php": "^8.3", "ext-pdo_mysql": "*", "ext-redis": "*", - "durable-workflow/workflow": "2.5.28", + "durable-workflow/workflow": "2.6.1", "guzzlehttp/guzzle": "8.2.0", "laravel/framework": "13.37.0", "open-telemetry/api": "1.10.0", diff --git a/.github/tracing-performance/composer.lock b/.github/tracing-performance/composer.lock index 9edbaacfa..72c905258 100644 --- a/.github/tracing-performance/composer.lock +++ b/.github/tracing-performance/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": "78035009973f2315ac4380270d94bcfd", + "content-hash": "9271a14dfa11690f2b5473d5b90370d7", "packages": [ { "name": "apache/avro", @@ -585,16 +585,16 @@ }, { "name": "durable-workflow/workflow", - "version": "2.5.28", + "version": "2.6.1", "source": { "type": "git", "url": "https://github.com/durable-workflow/workflow.git", - "reference": "592dee305f46ac707baadf55324407c43aeb2dc5" + "reference": "1f72c41fa527685d9bcc75ea86f02ebe3cc9adfb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/592dee305f46ac707baadf55324407c43aeb2dc5", - "reference": "592dee305f46ac707baadf55324407c43aeb2dc5", + "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/1f72c41fa527685d9bcc75ea86f02ebe3cc9adfb", + "reference": "1f72c41fa527685d9bcc75ea86f02ebe3cc9adfb", "shasum": "" }, "require": { @@ -627,7 +627,7 @@ "dev-main": "2.0.x-dev" }, "durable-workflow": { - "product-train": "2.5.28", + "product-train": "2.6.1", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" } @@ -654,9 +654,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.5.28" + "source": "https://github.com/durable-workflow/workflow/tree/2.6.1" }, - "time": "2026-10-16T21:07:01+00:00" + "time": "2026-10-18T06:13:32+00:00" }, { "name": "egulias/email-validator", From cf1960017d24ffb949d914173c3b7a5b065b0037 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sun, 18 Oct 2026 12:18:58 +0000 Subject: [PATCH 68/77] Address Collector reader and calibration findings --- .github/tracing-performance/analyze.test.mjs | 7 +++++++ .github/tracing-performance/collector-metrics.mjs | 8 +++++--- .github/tracing-performance/collector-probe.php | 4 ++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/tracing-performance/analyze.test.mjs b/.github/tracing-performance/analyze.test.mjs index 16889911e..15ef58f5d 100644 --- a/.github/tracing-performance/analyze.test.mjs +++ b/.github/tracing-performance/analyze.test.mjs @@ -130,6 +130,13 @@ test('reads initialized official metrics and preserves an unavailable failure se assert.equal(collectorDelta({before: parsed, after: {...parsed, uptime_seconds: 2, accepted: 4, sent: 4}}), 3); }); +test('Collector numeric samples retain decimal and exponent forms', () => { + for (const value of ['1.', '+1', '+1.0e0', '1E0']) { + const text = collectorText().replaceAll('} 1', '} ' + value); + assert.equal(parseCollectorMetrics(text).accepted, 1); + } +}); + for (const [name, corrupt] of [ ['uninitialized series', text => text.split('\n')[0]], ['missing refusal series', text => text.split('\n').filter(line => !line.includes('refused_spans')).join('\n')], diff --git a/.github/tracing-performance/collector-metrics.mjs b/.github/tracing-performance/collector-metrics.mjs index 4bd1fcaf1..7d8fdbe4d 100644 --- a/.github/tracing-performance/collector-metrics.mjs +++ b/.github/tracing-performance/collector-metrics.mjs @@ -16,7 +16,7 @@ const metricFields = new Map([ function labels(value) { const result = Object.create(null); - const pattern = /([a-zA-Z_][a-zA-Z0-9_]*)=("(?:[^"\\]|\\["\\n])*")(?:,|$)/gy; + const pattern = /([a-zA-Z_]\w*)=("(?:[^"\\]|\\["\\n])*")(?:,|$)/gy; let offset = 0; for (const match of value.matchAll(pattern)) { assert.equal(match.index, offset, 'Malformed metric labels'); @@ -29,8 +29,10 @@ function labels(value) { } function metric(line) { - const match = /^(otelcol_[a-z_]+)\{(.*)\} ([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)$/.exec(line); + const match = /^(otelcol_[a-z_]+)\{(.*)\} (\S+)$/.exec(line); assert.ok(match, 'Malformed Collector counter sample'); + assert.match(match[3], /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/, + 'Malformed Collector counter number'); const attributes = labels(match[2]); if (match[1].startsWith('otelcol_receiver_')) { assert.equal(attributes.receiver, 'otlp', 'Unexpected receiver'); @@ -88,7 +90,7 @@ export function parseCollectorMetrics(text) { } export function collectorDelta(proof) { - assert.ok(proof && proof.before && proof.after, 'Missing Collector proof'); + assert.ok(proof?.before && proof?.after, 'Missing Collector proof'); const {before, after} = proof; validateSnapshot(before); validateSnapshot(after); diff --git a/.github/tracing-performance/collector-probe.php b/.github/tracing-performance/collector-probe.php index 957f50221..26f30807a 100644 --- a/.github/tracing-performance/collector-probe.php +++ b/.github/tracing-performance/collector-probe.php @@ -11,7 +11,7 @@ use OpenTelemetry\SDK\Trace\SpanProcessor\BatchSpanProcessor; use OpenTelemetry\SDK\Trace\TracerProvider; -require __DIR__ . '/vendor/autoload.php'; +require_once __DIR__ . '/vendor/autoload.php'; $count = filter_var($argv[1] ?? '1', FILTER_VALIDATE_INT, [ 'options' => ['min_range' => 1, 'max_range' => 64], @@ -33,6 +33,6 @@ $tracer->spanBuilder('qualification.collector.seed')->startSpan()->end(); } if (! $provider->shutdown()) { - throw new RuntimeException('Collector calibration did not drain successfully.'); + throw new UnexpectedValueException('Collector calibration did not drain successfully.'); } echo json_encode(['synthetic_spans' => $count, 'provider_shutdown' => true], JSON_THROW_ON_ERROR), PHP_EOL; From 0c0f6420205f149cc45d6d936a0e0b3ed6fbde4a Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Mon, 19 Oct 2026 20:48:51 +0000 Subject: [PATCH 69/77] Align prospective tracing baseline with released history reads --- .github/tracing-performance/README.md | 9 ++++++--- .github/tracing-performance/composer.json | 2 +- .github/tracing-performance/composer.lock | 16 ++++++++-------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.github/tracing-performance/README.md b/.github/tracing-performance/README.md index d6c7addc6..0540488b4 100644 --- a/.github/tracing-performance/README.md +++ b/.github/tracing-performance/README.md @@ -68,9 +68,12 @@ a separate Docker-hostname label. The analyzer requires that container label to match the measured CPU role's container-ID prefix. A same-container process restart still changes the process identifier and refuses the old counter proof. -The prospective package baseline is published Workflow 2.6.1. The October 17 -study's 2.5.28 lock and measurements remain available at their recorded source; -they are not measurements of this new source/baseline/Collector combination. +The prospective package baseline is published Workflow 2.6.2. Both baseline +and candidate include the released bounded history-payload read optimization. +This source/baseline update precedes admission of any official-Collector +measurement. The earlier prospective 2.6.1 preparation and the October 17 +study's 2.5.28 lock remain available at their recorded sources. Their results +are not measurements of this new source/baseline/Collector combination. ## Fixed comparison diff --git a/.github/tracing-performance/composer.json b/.github/tracing-performance/composer.json index 07cb463a6..d01cdbb32 100644 --- a/.github/tracing-performance/composer.json +++ b/.github/tracing-performance/composer.json @@ -7,7 +7,7 @@ "php": "^8.3", "ext-pdo_mysql": "*", "ext-redis": "*", - "durable-workflow/workflow": "2.6.1", + "durable-workflow/workflow": "2.6.2", "guzzlehttp/guzzle": "8.2.0", "laravel/framework": "13.37.0", "open-telemetry/api": "1.10.0", diff --git a/.github/tracing-performance/composer.lock b/.github/tracing-performance/composer.lock index 72c905258..70f8b5d76 100644 --- a/.github/tracing-performance/composer.lock +++ b/.github/tracing-performance/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": "9271a14dfa11690f2b5473d5b90370d7", + "content-hash": "d456cee86e3016d5e5c646114c9d11de", "packages": [ { "name": "apache/avro", @@ -585,16 +585,16 @@ }, { "name": "durable-workflow/workflow", - "version": "2.6.1", + "version": "2.6.2", "source": { "type": "git", "url": "https://github.com/durable-workflow/workflow.git", - "reference": "1f72c41fa527685d9bcc75ea86f02ebe3cc9adfb" + "reference": "67833a7938b65e8ec663b4a63b25b4ac1b0beeef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/1f72c41fa527685d9bcc75ea86f02ebe3cc9adfb", - "reference": "1f72c41fa527685d9bcc75ea86f02ebe3cc9adfb", + "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/67833a7938b65e8ec663b4a63b25b4ac1b0beeef", + "reference": "67833a7938b65e8ec663b4a63b25b4ac1b0beeef", "shasum": "" }, "require": { @@ -627,7 +627,7 @@ "dev-main": "2.0.x-dev" }, "durable-workflow": { - "product-train": "2.6.1", + "product-train": "2.6.2", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" } @@ -654,9 +654,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.6.1" + "source": "https://github.com/durable-workflow/workflow/tree/2.6.2" }, - "time": "2026-10-18T06:13:32+00:00" + "time": "2026-10-18T14:40:29+00:00" }, { "name": "egulias/email-validator", From 3aaab4d61a6320a4baca41cb73e1fc3d1368a312 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 02:05:09 +0000 Subject: [PATCH 70/77] Require fixed stable OpenTelemetry API and isolate tracing setup --- .github/tracing-http/composer.json | 2 +- .github/tracing-performance/README.md | 12 +++++---- .github/tracing-performance/composer.json | 4 +-- .github/tracing-performance/composer.lock | 26 +++++++++--------- CHANGELOG.md | 11 ++++++++ composer.json | 9 ++++--- docs/embedded-tracing.md | 13 +++++---- tests/Fixtures/V2/tracing-process.php | 20 +++++++++++++- .../V2/CancellationExecutionTracingTest.php | 27 +++++++++++++++++-- 9 files changed, 92 insertions(+), 32 deletions(-) diff --git a/.github/tracing-http/composer.json b/.github/tracing-http/composer.json index ff1f635df..db6202874 100644 --- a/.github/tracing-http/composer.json +++ b/.github/tracing-http/composer.json @@ -1,6 +1,6 @@ { "require": { - "open-telemetry/api": "^1.10", + "open-telemetry/api": "^1.10.1", "open-telemetry/sdk": "^1.15", "open-telemetry/exporter-otlp": "^1.4", "open-telemetry/context": "^1.5", diff --git a/.github/tracing-performance/README.md b/.github/tracing-performance/README.md index 0540488b4..b8074ed6b 100644 --- a/.github/tracing-performance/README.md +++ b/.github/tracing-performance/README.md @@ -2,9 +2,10 @@ This is the qualification recipe for draft Workflow #788. The [October 17 measurement](RESULTS-2026-10-17.md) completed, but did not qualify -any of the four CPU budgets. The encoded-baggage regression and the other acceptance -requirements of [#773](https://github.com/durable-workflow/workflow/issues/773) -remain separate release gates. +any of the four CPU budgets. The current prospective recipe uses the stable +OpenTelemetry API baggage fix. The remaining acceptance requirements of +[#773](https://github.com/durable-workflow/workflow/issues/773) are separate +release gates. ## Prospective official Collector profile @@ -68,10 +69,11 @@ a separate Docker-hostname label. The analyzer requires that container label to match the measured CPU role's container-ID prefix. A same-container process restart still changes the process identifier and refuses the old counter proof. -The prospective package baseline is published Workflow 2.6.2. Both baseline +The prospective package baseline is published Workflow 2.7.0 with OpenTelemetry +API 1.10.1. Both baseline and candidate include the released bounded history-payload read optimization. This source/baseline update precedes admission of any official-Collector -measurement. The earlier prospective 2.6.1 preparation and the October 17 +measurement. The earlier prospective 2.6.1 and 2.6.2 preparations and the October 17 study's 2.5.28 lock remain available at their recorded sources. Their results are not measurements of this new source/baseline/Collector combination. diff --git a/.github/tracing-performance/composer.json b/.github/tracing-performance/composer.json index d01cdbb32..dcccc7858 100644 --- a/.github/tracing-performance/composer.json +++ b/.github/tracing-performance/composer.json @@ -7,10 +7,10 @@ "php": "^8.3", "ext-pdo_mysql": "*", "ext-redis": "*", - "durable-workflow/workflow": "2.6.2", + "durable-workflow/workflow": "2.7.0", "guzzlehttp/guzzle": "8.2.0", "laravel/framework": "13.37.0", - "open-telemetry/api": "1.10.0", + "open-telemetry/api": "1.10.1", "open-telemetry/context": "1.5.0", "open-telemetry/exporter-otlp": "^1.4", "open-telemetry/sdk": "1.15.0", diff --git a/.github/tracing-performance/composer.lock b/.github/tracing-performance/composer.lock index 70f8b5d76..26b386480 100644 --- a/.github/tracing-performance/composer.lock +++ b/.github/tracing-performance/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": "d456cee86e3016d5e5c646114c9d11de", + "content-hash": "04cb963a403abccf9a8c4606fc6f3177", "packages": [ { "name": "apache/avro", @@ -585,16 +585,16 @@ }, { "name": "durable-workflow/workflow", - "version": "2.6.2", + "version": "2.7.0", "source": { "type": "git", "url": "https://github.com/durable-workflow/workflow.git", - "reference": "67833a7938b65e8ec663b4a63b25b4ac1b0beeef" + "reference": "4423774074d66347c627c2c13ac516207e815e05" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/67833a7938b65e8ec663b4a63b25b4ac1b0beeef", - "reference": "67833a7938b65e8ec663b4a63b25b4ac1b0beeef", + "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/4423774074d66347c627c2c13ac516207e815e05", + "reference": "4423774074d66347c627c2c13ac516207e815e05", "shasum": "" }, "require": { @@ -627,7 +627,7 @@ "dev-main": "2.0.x-dev" }, "durable-workflow": { - "product-train": "2.6.2", + "product-train": "2.7.0", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" } @@ -654,9 +654,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.6.2" + "source": "https://github.com/durable-workflow/workflow/tree/2.7.0" }, - "time": "2026-10-18T14:40:29+00:00" + "time": "2026-10-20T00:29:36+00:00" }, { "name": "egulias/email-validator", @@ -3382,16 +3382,16 @@ }, { "name": "open-telemetry/api", - "version": "1.10.0", + "version": "1.10.1", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "7c029c4a6fd457094a20569bf98f93d95e9a7559" + "reference": "b2bc26bf8bd6b2b6447b3626b2a1b5733873aa0b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/7c029c4a6fd457094a20569bf98f93d95e9a7559", - "reference": "7c029c4a6fd457094a20569bf98f93d95e9a7559", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/b2bc26bf8bd6b2b6447b3626b2a1b5733873aa0b", + "reference": "b2bc26bf8bd6b2b6447b3626b2a1b5733873aa0b", "shasum": "" }, "require": { @@ -3448,7 +3448,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2026-07-06T12:28:04+00:00" + "time": "2026-09-16T01:59:09+00:00" }, { "name": "open-telemetry/context", diff --git a/CHANGELOG.md b/CHANGELOG.md index 177d33edb..20639cf82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 2.8.0 - Unreleased + +- Prepare optional embedded OpenTelemetry spans and log correlation across + durable admission, tasks, activities, retries and recovery. Applications own + providers, sampling, exporters and shutdown. Tracing is disabled by default. +- Keep bounded observability context separate from business payloads, command + equality and lease authority. Native catalogs and service wire adoption are + separately qualified. +- Require OpenTelemetry API 1.10.1 or later in the 1.x line when that optional + API is installed, excluding the older incompatible baggage parser. + ## 2.7.0 - 2026-10-20 - Deliver eligible signals accepted during a remote workflow task before its diff --git a/composer.json b/composer.json index 4c9c2b88d..284b9b6a3 100644 --- a/composer.json +++ b/composer.json @@ -67,23 +67,26 @@ }, "require-dev": { "doctrine/dbal": "^3.7", - "open-telemetry/api": "^1.10", + "open-telemetry/api": "^1.10.1", "open-telemetry/exporter-otlp": "^1.4", "open-telemetry/sdk": "^1.0", "orchestra/testbench": "^8.0|^11.0", "phpstan/phpstan": "^2.0", "symplify/easy-coding-standard": "^11.0" }, + "conflict": { + "open-telemetry/api": "<1.10.1 || >=2.0" + }, "suggest": { "durable-workflow/sdk": "Framework-neutral client and remote-worker SDK for the standalone Durable Workflow server.", - "open-telemetry/api": "Optional application-owned trace propagation API." + "open-telemetry/api": "Optional application-owned trace propagation API, version 1.10.1 or later in the 1.x line." }, "extra": { "branch-alias": { "dev-main": "2.0.x-dev" }, "durable-workflow": { - "product-train": "2.7.0", + "product-train": "2.8.0", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" }, diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index 95d949ed1..c1e1ca671 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -2,9 +2,10 @@ This document describes the optional embedded tracing implementation being qualified in the [tracing proposal](https://github.com/durable-workflow/workflow/issues/773). -It is not a published-package support claim. The released OpenTelemetry baggage -parser still fails the encoded-value regression. Full qualification and a -compatible stable release are required before enabling this in production. +It is not a published-package support claim. OpenTelemetry API 1.10.1 fixes the +encoded-value baggage regression and is the minimum supported API version. +Full qualification and a compatible Durable Workflow release are required +before enabling this in production. The application owns its OpenTelemetry providers, instrumentation, sampling, exporters and process shutdown. Durable Workflow uses those providers when @@ -32,8 +33,10 @@ application's environment, then configure the existing `tracing` section of The empty allowlist propagates no baggage. Add only the application-approved keys that must cross durable boundaries. The application's installed -propagator must support those fields. The encoded-baggage regression remains -a release blocker even when this example uses an empty allowlist. +propagator must support those fields. Install OpenTelemetry API `^1.10.1` when +using tracing. Older APIs have an incompatible baggage parser, so Composer +rejects those versions. Applications without OpenTelemetry do not gain a new +required dependency. The package reads the configured boolean; the environment mapping above is part of the application example. diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index a45eb30ee..4943fefef 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -91,6 +91,19 @@ require_once $autoload; } +function recordTracingProbeStage(string $stage): void +{ + $path = getenv('DW_TEST_TRACING_PROGRESS_FILE'); + if (! is_string($path) || $path === '') { + return; + } + file_put_contents($path, json_encode([ + 'stage' => $stage, + ], JSON_THROW_ON_ERROR) . PHP_EOL, FILE_APPEND); +} + +recordTracingProbeStage('autoloaded'); + final class TracingProcessWorkflow extends Workflow { public function __construct(TracingProcessWorkflowDependency $dependency) @@ -408,17 +421,20 @@ protected function defineEnvironment($app): void function prepareTracingSchema(): void { + recordTracingProbeStage('migrations-started'); if (Artisan::call('migrate', [ '--force' => true, ]) !== 0) { throw new \RuntimeException(Artisan::output()); } + recordTracingProbeStage('migrations-ready'); DB::statement('CREATE TABLE jobs (id INTEGER PRIMARY KEY AUTOINCREMENT, queue TEXT NOT NULL, payload TEXT NOT NULL, attempts INTEGER NOT NULL, reserved_at INTEGER, available_at INTEGER NOT NULL, created_at INTEGER NOT NULL)'); DB::statement('CREATE TABLE failed_jobs (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, connection TEXT NOT NULL, queue TEXT NOT NULL, payload TEXT NOT NULL, exception TEXT NOT NULL, failed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)'); + recordTracingProbeStage('schema-ready'); } $directory = $argv[1]; @@ -431,6 +447,7 @@ function prepareTracingSchema(): void if (! is_file($directory . '/database.sqlite')) { touch($directory . '/database.sqlite'); } +recordTracingProbeStage('application-started'); $app = TracingProcessApplication::create( basePath: $directory, options: [ @@ -440,6 +457,7 @@ function prepareTracingSchema(): void ], ], ); +recordTracingProbeStage('application-ready'); if ($mode === 'prepare-http') { prepareTracingSchema(); echo json_encode([ @@ -729,7 +747,7 @@ public function extract( $childOutcomeControl = null; try { if ($mode === 'admit') { - if (! $incomingHttp) { + if (! $incomingHttp && getenv('DW_TEST_TRACING_SCHEMA_PREPARED') !== 'true') { prepareTracingSchema(); } if ($request !== null) { diff --git a/tests/Unit/V2/CancellationExecutionTracingTest.php b/tests/Unit/V2/CancellationExecutionTracingTest.php index c2be9ba03..b433ab2b3 100644 --- a/tests/Unit/V2/CancellationExecutionTracingTest.php +++ b/tests/Unit/V2/CancellationExecutionTracingTest.php @@ -50,17 +50,40 @@ public function testCancellationKeepsOriginalContextAndUnwindsColdWorkerScopes( 'DW_TEST_TRACING_CANCELLATION' => $command, 'DW_TEST_TRACING_ENABLED' => ($mode === 'admit' ? $admissionEnabled : $enabled) ? 'true' : 'false', 'DW_TEST_TRACING_CANCEL_DUPLICATE' => $duplicate ? 'true' : 'false', + 'DW_TEST_TRACING_PROGRESS_FILE' => $directory . '/progress.jsonl', + 'DW_TEST_TRACING_SCHEMA_PREPARED' => $mode === 'prepare-http' ? 'false' : 'true', ]); - $process->setTimeout(15); - $process->mustRun(); + $process->setTimeout($mode === 'prepare-http' ? 45 : 15); + try { + $process->mustRun(); + } catch (\Throwable $error) { + $progress = is_file($directory . '/progress.jsonl') + ? substr((string) file_get_contents($directory . '/progress.jsonl'), -2048) : ''; + throw new \RuntimeException( + 'Tracing phase ' . $mode . ' failed; stages: ' . $progress, + previous: $error + ); + } self::assertSame('', $process->getErrorOutput()); $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + if ($mode === 'prepare-http') { + self::assertSame([ + 'workflow_runs' => 0, + 'commands' => 0, + 'history_events' => 0, + 'tasks' => 0, + 'jobs' => 0, + ], $result); + + return $result; + } self::assertSame([], $result['outside_context']); self::assertTrue($result['scope_restored']); return $result; }; try { + $worker('prepare-http'); $admitted = $worker('admit'); self::assertSame($admissionEnabled, $admitted['carrier'] !== null); $waiting = $worker('work'); From 052cb6141335fd887b72d61175a8d0c69a6b8649 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 02:41:12 +0000 Subject: [PATCH 71/77] Migrate tracing carriers for configured storage models --- docs/embedded-tracing.md | 9 +- ...observability_context_to_workflow_runs.php | 28 ++-- ...rvability_context_to_workflow_commands.php | 28 ++-- ...bservability_context_to_workflow_tasks.php | 28 ++-- .../Feature/V2/V2ConfiguredCoreModelsTest.php | 120 ++++++++++++++++++ tests/Feature/V2/V2TaskDispatchTest.php | 12 +- 6 files changed, 195 insertions(+), 30 deletions(-) diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index c1e1ca671..27295fc8c 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -123,7 +123,7 @@ contract. Ordinary application HTTP instrumentation has a separate scope. | Runtime or client | Scope established by this draft | Support status | | --- | --- | --- | -| Embedded Laravel | Source controls cover incoming HTTP context, separate queue workers, retries, recovery and the transitions described below. | Unreleased. Baggage, remaining full gates and overhead acceptance still block stable support. | +| Embedded Laravel | Source controls cover incoming HTTP context, separate queue workers, retries, recovery and the transitions described below. | Unreleased. Remaining full gates and overhead acceptance still block stable support. | | PHP Server | This embedded draft adds no service transport contract. | Separate integration and consumer qualification required. | | Rust Server draft | Proposed observability columns and carriers have not been adopted by its catalog. | Unqualified for this contract. | | Cloud | No deployment of this draft is claimed. | Separate release and deployment qualification required. | @@ -139,6 +139,13 @@ null metadata. Older executions use independent processing segments instead of borrowing an unrelated worker's parent. Canonical histories, command receipt comparisons, retry policies and deadlines retain their existing contracts. +Run package migrations with the application's model configuration in place. +The carrier migrations update both default tables and existing tables selected +by `run_model`, `command_model` and `task_model` on the workflow storage +connection. They preserve existing rows and can be reapplied. Applications that +create replacement tables later must include these nullable JSON columns in +their schema. This is a schema upgrade even when tracing is disabled. + Worker processes can handle successive jobs, but this integration scopes context to the current operation. It does not keep one execution span open for the lifetime of a worker session. Concurrent Fiber and cold-process controls diff --git a/src/migrations/2026_10_15_000100_add_observability_context_to_workflow_runs.php b/src/migrations/2026_10_15_000100_add_observability_context_to_workflow_runs.php index 518be586a..d1669dd57 100644 --- a/src/migrations/2026_10_15_000100_add_observability_context_to_workflow_runs.php +++ b/src/migrations/2026_10_15_000100_add_observability_context_to_workflow_runs.php @@ -5,24 +5,34 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Workflow\Support\WorkflowMigration; +use Workflow\V2\Models\WorkflowRun; +use Workflow\V2\Support\ConfiguredV2Models; return new class() extends WorkflowMigration { public function up(): void { - if (! Schema::hasColumn('workflow_runs', 'observability_context')) { - Schema::table('workflow_runs', static function (Blueprint $table): void { - $table->json('observability_context') - ->nullable(); - }); + $model = ConfiguredV2Models::resolve('run_model', WorkflowRun::class); + $schema = Schema::connection($this->getConnection()); + foreach (array_unique(['workflow_runs', (new $model())->getTable()]) as $tableName) { + if ($schema->hasTable($tableName) && ! $schema->hasColumn($tableName, 'observability_context')) { + $schema->table($tableName, static function (Blueprint $table): void { + $table->json('observability_context') + ->nullable(); + }); + } } } public function down(): void { - if (Schema::hasColumn('workflow_runs', 'observability_context')) { - Schema::table('workflow_runs', static function (Blueprint $table): void { - $table->dropColumn('observability_context'); - }); + $model = ConfiguredV2Models::resolve('run_model', WorkflowRun::class); + $schema = Schema::connection($this->getConnection()); + foreach (array_unique(['workflow_runs', (new $model())->getTable()]) as $tableName) { + if ($schema->hasTable($tableName) && $schema->hasColumn($tableName, 'observability_context')) { + $schema->table($tableName, static function (Blueprint $table): void { + $table->dropColumn('observability_context'); + }); + } } } }; diff --git a/src/migrations/2026_10_15_000200_add_observability_context_to_workflow_commands.php b/src/migrations/2026_10_15_000200_add_observability_context_to_workflow_commands.php index 7c5886b03..b275dbbff 100644 --- a/src/migrations/2026_10_15_000200_add_observability_context_to_workflow_commands.php +++ b/src/migrations/2026_10_15_000200_add_observability_context_to_workflow_commands.php @@ -5,24 +5,34 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Workflow\Support\WorkflowMigration; +use Workflow\V2\Models\WorkflowCommand; +use Workflow\V2\Support\ConfiguredV2Models; return new class() extends WorkflowMigration { public function up(): void { - if (! Schema::hasColumn('workflow_commands', 'observability_context')) { - Schema::table('workflow_commands', static function (Blueprint $table): void { - $table->json('observability_context') - ->nullable(); - }); + $model = ConfiguredV2Models::resolve('command_model', WorkflowCommand::class); + $schema = Schema::connection($this->getConnection()); + foreach (array_unique(['workflow_commands', (new $model())->getTable()]) as $tableName) { + if ($schema->hasTable($tableName) && ! $schema->hasColumn($tableName, 'observability_context')) { + $schema->table($tableName, static function (Blueprint $table): void { + $table->json('observability_context') + ->nullable(); + }); + } } } public function down(): void { - if (Schema::hasColumn('workflow_commands', 'observability_context')) { - Schema::table('workflow_commands', static function (Blueprint $table): void { - $table->dropColumn('observability_context'); - }); + $model = ConfiguredV2Models::resolve('command_model', WorkflowCommand::class); + $schema = Schema::connection($this->getConnection()); + foreach (array_unique(['workflow_commands', (new $model())->getTable()]) as $tableName) { + if ($schema->hasTable($tableName) && $schema->hasColumn($tableName, 'observability_context')) { + $schema->table($tableName, static function (Blueprint $table): void { + $table->dropColumn('observability_context'); + }); + } } } }; diff --git a/src/migrations/2026_10_16_000100_add_observability_context_to_workflow_tasks.php b/src/migrations/2026_10_16_000100_add_observability_context_to_workflow_tasks.php index c3e513a32..69f5737dd 100644 --- a/src/migrations/2026_10_16_000100_add_observability_context_to_workflow_tasks.php +++ b/src/migrations/2026_10_16_000100_add_observability_context_to_workflow_tasks.php @@ -5,24 +5,34 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Workflow\Support\WorkflowMigration; +use Workflow\V2\Models\WorkflowTask; +use Workflow\V2\Support\ConfiguredV2Models; return new class() extends WorkflowMigration { public function up(): void { - if (! Schema::hasColumn('workflow_tasks', 'observability_context')) { - Schema::table('workflow_tasks', static function (Blueprint $table): void { - $table->json('observability_context') - ->nullable(); - }); + $model = ConfiguredV2Models::resolve('task_model', WorkflowTask::class); + $schema = Schema::connection($this->getConnection()); + foreach (array_unique(['workflow_tasks', (new $model())->getTable()]) as $tableName) { + if ($schema->hasTable($tableName) && ! $schema->hasColumn($tableName, 'observability_context')) { + $schema->table($tableName, static function (Blueprint $table): void { + $table->json('observability_context') + ->nullable(); + }); + } } } public function down(): void { - if (Schema::hasColumn('workflow_tasks', 'observability_context')) { - Schema::table('workflow_tasks', static function (Blueprint $table): void { - $table->dropColumn('observability_context'); - }); + $model = ConfiguredV2Models::resolve('task_model', WorkflowTask::class); + $schema = Schema::connection($this->getConnection()); + foreach (array_unique(['workflow_tasks', (new $model())->getTable()]) as $tableName) { + if ($schema->hasTable($tableName) && $schema->hasColumn($tableName, 'observability_context')) { + $schema->table($tableName, static function (Blueprint $table): void { + $table->dropColumn('observability_context'); + }); + } } } }; diff --git a/tests/Feature/V2/V2ConfiguredCoreModelsTest.php b/tests/Feature/V2/V2ConfiguredCoreModelsTest.php index ec371ccc9..e9bc6ceae 100644 --- a/tests/Feature/V2/V2ConfiguredCoreModelsTest.php +++ b/tests/Feature/V2/V2ConfiguredCoreModelsTest.php @@ -7,7 +7,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; +use PHPUnit\Framework\Attributes\DataProvider; use Tests\Fixtures\V2\TestConfiguredStorageWorkflow; use Tests\SchemaTestCase; use Workflow\V2\Models\WorkflowCommand; @@ -32,6 +34,14 @@ public function testCoreRuntimeUsesConfiguredInstanceRunAndTaskModels(): void config() ->set('workflows.v2.task_model', ConfiguredCoreWorkflowTask::class); + foreach ([ + '2026_10_15_000100_add_observability_context_to_workflow_runs.php', + '2026_10_16_000100_add_observability_context_to_workflow_tasks.php', + ] as $migrationFile) { + $migration = require __DIR__ . '/../../../src/migrations/' . $migrationFile; + $migration->up(); + } + WorkflowStub::fake(); $workflow = WorkflowStub::make(TestConfiguredStorageWorkflow::class, 'configured-runtime-path'); @@ -70,6 +80,111 @@ public function testCoreRuntimeUsesConfiguredInstanceRunAndTaskModels(): void $this->assertSame($workflow->output(), WorkflowStub::loadRun((string) $workflow->runId())->output()); } + public static function carrierMigrations(): iterable + { + yield 'run' => [ + 'run_model', ConfiguredCoreWorkflowRun::class, + '2026_10_15_000100_add_observability_context_to_workflow_runs.php', + ]; + yield 'command' => [ + 'command_model', ConfiguredCoreWorkflowCommand::class, + '2026_10_15_000200_add_observability_context_to_workflow_commands.php', + ]; + yield 'task' => [ + 'task_model', ConfiguredCoreWorkflowTask::class, + '2026_10_16_000100_add_observability_context_to_workflow_tasks.php', + ]; + } + + /** + * @param class-string<\Illuminate\Database\Eloquent\Model> $model + */ + #[DataProvider('carrierMigrations')] + public function testCarrierMigrationsPreserveConfiguredTableRows( + string $configKey, + string $model, + string $migrationFile, + ): void { + $this->assertCarrierMigrationPreservesRows($configKey, $model, $migrationFile); + } + + /** + * @param class-string<\Illuminate\Database\Eloquent\Model> $model + */ + #[DataProvider('carrierMigrations')] + public function testCarrierMigrationsUseTheWorkflowStorageConnection( + string $configKey, + string $model, + string $migrationFile, + ): void { + $previous = config('workflows.storage.connection'); + config() + ->set('database.connections.carrier_storage', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + config() + ->set('workflows.storage.connection', 'carrier_storage'); + try { + $this->assertCarrierMigrationPreservesRows($configKey, $model, $migrationFile); + foreach (['workflow_runs', 'workflow_commands', 'workflow_tasks'] as $defaultTable) { + self::assertTrue(Schema::hasColumn($defaultTable, 'observability_context')); + } + } finally { + config()->set('workflows.storage.connection', $previous); + DB::purge('carrier_storage'); + } + } + + /** + * @param class-string<\Illuminate\Database\Eloquent\Model> $model + */ + private function assertCarrierMigrationPreservesRows(string $configKey, string $model, string $migrationFile): void + { + $connection = config('workflows.storage.connection'); + $connection = is_string($connection) ? $connection : null; + $schema = Schema::connection($connection); + $tableName = (new $model())->getTable(); + $schema->create($tableName, static function (Blueprint $table): void { + $table->string('id') + ->primary(); + $table->text('marker') + ->nullable(); + }); + DB::connection($connection)->table($tableName)->insert([ + [ + 'id' => 'legacy', + 'marker' => 'preserved 雪', + ], + [ + 'id' => 'nullable', + 'marker' => null, + ], + ]); + $rows = static fn (): array => DB::connection($connection)->table($tableName)->orderBy('id')->get() + ->map(static fn (object $row): array => (array) $row) + ->all(); + $before = $rows(); + config() + ->set('workflows.v2.' . $configKey, $model); + $migration = require __DIR__ . '/../../../src/migrations/' . $migrationFile; + $migration->up(); + $migration->up(); + self::assertTrue($schema->hasColumn($tableName, 'observability_context')); + $after = $rows(); + foreach ($after as &$row) { + self::assertNull($row['observability_context']); + unset($row['observability_context']); + } + unset($row); + self::assertSame($before, $after); + $migration->down(); + $migration->down(); + self::assertFalse($schema->hasColumn($tableName, 'observability_context')); + self::assertSame($before, $rows()); + } + private function createConfiguredInstancesTable(): void { Schema::create('configured_core_workflow_instances', static function (Blueprint $table): void { @@ -271,3 +386,8 @@ final class ConfiguredCoreWorkflowTask extends WorkflowTask { protected $table = 'configured_core_workflow_tasks'; } + +final class ConfiguredCoreWorkflowCommand extends WorkflowCommand +{ + protected $table = 'configured_core_workflow_commands'; +} diff --git a/tests/Feature/V2/V2TaskDispatchTest.php b/tests/Feature/V2/V2TaskDispatchTest.php index a9ba97903..99c08b62a 100644 --- a/tests/Feature/V2/V2TaskDispatchTest.php +++ b/tests/Feature/V2/V2TaskDispatchTest.php @@ -190,7 +190,10 @@ public function testQueuedTimerAndLocalWritersUseConfiguredWorkflowTaskLease(): $workflowClaim = new ReflectionMethod(RunWorkflowTask::class, 'claimTask'); $workflowClaim->setAccessible(true); - $this->assertTrue($workflowClaim->invoke(new RunWorkflowTask($workflowTask->id))); + $claimed = $workflowClaim->invoke(new RunWorkflowTask($workflowTask->id)); + $this->assertInstanceOf(WorkflowTask::class, $claimed); + $this->assertSame($workflowTask->id, $claimed->id); + $this->assertSame(TaskStatus::Leased, $workflowTask->fresh()->status); $this->assertTrue($workflowTask->fresh()->lease_expires_at->equalTo($now->copy()->addSeconds(8))); $timerRun = $this->createWaitingRun('01J00000000000000000TIMER'); @@ -222,7 +225,12 @@ public function testQueuedTimerAndLocalWritersUseConfiguredWorkflowTaskLease(): $timerClaim->setAccessible(true); $timerResult = $timerClaim->invoke(new RunTimerTask($timerTask->id)); - $this->assertSame([$timer->id, null], $timerResult); + [$claimedTimerTask, $deferSeconds] = $timerResult; + $this->assertInstanceOf(WorkflowTask::class, $claimedTimerTask); + $this->assertSame($timerTask->id, $claimedTimerTask->id); + $this->assertSame($timer->id, $claimedTimerTask->payload['timer_id']); + $this->assertNull($deferSeconds); + $this->assertSame(TaskStatus::Leased, $timerTask->fresh()->status); $this->assertTrue($timerTask->fresh()->lease_expires_at->equalTo($now->copy()->addSeconds(8))); $localRun = $this->createWaitingRun('01J00000000000000000LOCAL'); From b2d394c5c3e16484c798f3e7a7293bfb001b07eb Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 14:59:02 +0000 Subject: [PATCH 72/77] Compare tracing with the published Core 2.7.3 baseline --- .github/tracing-performance/README.md | 4 ++-- .github/tracing-performance/composer.json | 2 +- .github/tracing-performance/composer.lock | 17 +++++++++-------- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/tracing-performance/README.md b/.github/tracing-performance/README.md index b8074ed6b..0ed0a703c 100644 --- a/.github/tracing-performance/README.md +++ b/.github/tracing-performance/README.md @@ -69,11 +69,11 @@ a separate Docker-hostname label. The analyzer requires that container label to match the measured CPU role's container-ID prefix. A same-container process restart still changes the process identifier and refuses the old counter proof. -The prospective package baseline is published Workflow 2.7.0 with OpenTelemetry +The prospective package baseline is published Workflow 2.7.3 with OpenTelemetry API 1.10.1. Both baseline and candidate include the released bounded history-payload read optimization. This source/baseline update precedes admission of any official-Collector -measurement. The earlier prospective 2.6.1 and 2.6.2 preparations and the October 17 +measurement. The earlier prospective 2.6.1, 2.6.2 and 2.7.0 preparations and the October 17 study's 2.5.28 lock remain available at their recorded sources. Their results are not measurements of this new source/baseline/Collector combination. diff --git a/.github/tracing-performance/composer.json b/.github/tracing-performance/composer.json index dcccc7858..81e18c10f 100644 --- a/.github/tracing-performance/composer.json +++ b/.github/tracing-performance/composer.json @@ -7,7 +7,7 @@ "php": "^8.3", "ext-pdo_mysql": "*", "ext-redis": "*", - "durable-workflow/workflow": "2.7.0", + "durable-workflow/workflow": "2.7.3", "guzzlehttp/guzzle": "8.2.0", "laravel/framework": "13.37.0", "open-telemetry/api": "1.10.1", diff --git a/.github/tracing-performance/composer.lock b/.github/tracing-performance/composer.lock index 26b386480..aee34f7dd 100644 --- a/.github/tracing-performance/composer.lock +++ b/.github/tracing-performance/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": "04cb963a403abccf9a8c4606fc6f3177", + "content-hash": "29a6a55a2de8476ffa9fff63976f6432", "packages": [ { "name": "apache/avro", @@ -585,16 +585,16 @@ }, { "name": "durable-workflow/workflow", - "version": "2.7.0", + "version": "2.7.3", "source": { "type": "git", "url": "https://github.com/durable-workflow/workflow.git", - "reference": "4423774074d66347c627c2c13ac516207e815e05" + "reference": "dda951d93f58b82447348f1b3b4e4e17dba149fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/4423774074d66347c627c2c13ac516207e815e05", - "reference": "4423774074d66347c627c2c13ac516207e815e05", + "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/dda951d93f58b82447348f1b3b4e4e17dba149fb", + "reference": "dda951d93f58b82447348f1b3b4e4e17dba149fb", "shasum": "" }, "require": { @@ -608,6 +608,7 @@ "laravel-workflow/laravel-workflow": "self.version" }, "require-dev": { + "aws/aws-sdk-php": "^3.0", "doctrine/dbal": "^3.7", "orchestra/testbench": "^8.0|^11.0", "phpstan/phpstan": "^2.0", @@ -627,7 +628,7 @@ "dev-main": "2.0.x-dev" }, "durable-workflow": { - "product-train": "2.7.0", + "product-train": "2.7.3", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" } @@ -654,9 +655,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.7.0" + "source": "https://github.com/durable-workflow/workflow/tree/2.7.3" }, - "time": "2026-10-20T00:29:36+00:00" + "time": "2026-10-20T13:20:34+00:00" }, { "name": "egulias/email-validator", From e73dd4e9692b6d2f32026e2927aa7a64ffff756a Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 16:58:04 +0000 Subject: [PATCH 73/77] Report bounded tracing carrier diagnostics and preserve measured failures --- .github/tracing-performance/README.md | 5 +- .../tracing-performance/RESULTS-2026-10-20.md | 113 ++++++++++++ docs/embedded-tracing.md | 15 ++ src/V2/Jobs/RunActivityTask.php | 3 +- src/V2/Jobs/RunTimerTask.php | 4 +- src/V2/Jobs/RunWorkflowTask.php | 3 +- src/V2/Support/ExecutionTracing.php | 51 +++++- src/V2/Support/LocalActivityExecutor.php | 4 +- src/V2/Support/TaskTraceCarrier.php | 15 +- src/V2/Support/TraceCarrier.php | 69 ++++++-- src/V2/WorkflowStub.php | 11 +- .../Unit/V2/ActivityExecutionTracingTest.php | 163 ++++++++++++++++++ tests/Unit/V2/ExecutionTracingTest.php | 59 +++++++ tests/Unit/V2/TaskTraceCarrierTest.php | 4 +- tests/Unit/V2/TraceCarrierTest.php | 20 ++- 15 files changed, 499 insertions(+), 40 deletions(-) create mode 100644 .github/tracing-performance/RESULTS-2026-10-20.md diff --git a/.github/tracing-performance/README.md b/.github/tracing-performance/README.md index 0ed0a703c..0920acc31 100644 --- a/.github/tracing-performance/README.md +++ b/.github/tracing-performance/README.md @@ -1,8 +1,9 @@ # Embedded tracing overhead workload This is the qualification recipe for draft Workflow #788. The -[October 17 measurement](RESULTS-2026-10-17.md) completed, but did not qualify -any of the four CPU budgets. The current prospective recipe uses the stable +[October 17 measurement](RESULTS-2026-10-17.md) and +[October 20 official-Collector measurement](RESULTS-2026-10-20.md) completed; +neither qualified any of its four CPU budgets. The current recipe uses the stable OpenTelemetry API baggage fix. The remaining acceptance requirements of [#773](https://github.com/durable-workflow/workflow/issues/773) are separate release gates. diff --git a/.github/tracing-performance/RESULTS-2026-10-20.md b/.github/tracing-performance/RESULTS-2026-10-20.md new file mode 100644 index 000000000..5380d2287 --- /dev/null +++ b/.github/tracing-performance/RESULTS-2026-10-20.md @@ -0,0 +1,113 @@ +# Embedded tracing measurement, October 20, 2026 + +All 20 original cells passed their workload, resource, exit and official +Collector drain gates. **None of the four CPU budgets qualifies.** These results +do not qualify a release or maximum service capacity. The failed October 17 +study remains unchanged in [its original report](RESULTS-2026-10-17.md). + +## Fixed method + +The [method was declared before admission](https://github.com/durable-workflow/workflow/issues/773#issuecomment-6294532491). +Candidate `b2d394c5c3e16484c798f3e7a7293bfb001b07eb`, tree +`37651d791f918a80fab9d19cc31d408a9504599e`, was compared with normally installed +published Core **2.7.3**, `dda951d93f58b82447348f1b3b4e4e17dba149fb`. +All other Composer package references matched. No later diagnostic correction +was inserted into this source or measurement. + +The isolated amd64 host had eight logical AMD EPYC-Genoa CPUs, approximately +16 GiB RAM, Microsoft virtualization, Linux 6.8.0-146, cgroup v2 and no swap. +Docker was 29.1.3, Compose 2.40.3, PHP 8.3.35 with CLI OPcache disabled, +Laravel 13.37, OpenTelemetry API 1.10.1, SDK 1.15.0 and OTLP exporter 1.4.0. +Builds and installation completed before admission. No unrelated application +workload or build was run during the study. Bounded administrative SSH reads +continued. The host and all experiment state were retired after results. + +The accepting official Collector was +`otel/opentelemetry-collector:0.163.1@sha256:f0e23036dce3db3f9cb80d907c75a0d034cd32e93511a1943482d46129b9ae9c`. +The application used the existing four-span queue, two-span batch and 100 ms +transport timeout; Collector counters independently matched ended spans after +complete provider drain. Export failure observations did not change, and +refused/receiver-failed counters remained zero. No span payloads were retained. + +Each cell used a fresh database/application, four workers and identical caps: +HTTP application 0.5 CPU / 512 MiB; each worker 1 CPU / 384 MiB; MySQL 1 CPU / +2 GiB; Redis 0.1 CPU / 128 MiB; Collector and probe each 0.2 CPU / 256 MiB. +The primary quantity sums exact cgroup CPU deltas for these nine containers +through business completion and export drain. Setup, warmup, audit queries, +sampling tools and teardown are outside those deltas. The one-second requested +sampling interval was approximately 1.26 seconds in practice; exact CPU counters +do not depend on that interval. Memory observations are sampled, not allocation +or simultaneous-peak measurements. + +Ordinary cells offered one one-activity workflow per second for 120 seconds +(120 measured completions), following 30 warmups. Fan-out cells offered one +64-activity workflow per minute for 120 seconds (two measured completions), +following one warmup. Original observer/run/execution bounds remained +90/100/120 seconds. Repetitions rotated base/off/on, on/base/off, off/on/base, +then repeated the baseline for drift, separately for each workload. No extra +repetition was selected after results. + +## CPU acceptance + +Estimates use the mean of three paired candidate/baseline ratios, two-sided +95% paired-t intervals, n=3, df=2. Both the upper endpoint and absolute final +baseline drift must fit the declared budget. Negative estimates do not prove +an improvement. The budgets remain unchanged for this study. + +| Workload | Profile | Mean | 95% interval | Baseline drift | Budget | Qualified | +| --- | --- | ---: | --- | ---: | ---: | --- | +| Ordinary | Disabled | +1.11% | [-7.40%, +9.62%] | +1.62% | 1% | No | +| Ordinary | Enabled | +9.46% | [+1.35%, +17.56%] | +1.62% | 5% | No | +| 64-member fan-out | Disabled | -0.59% | [-13.91%, +12.72%] | +2.04% | 1% | No | +| 64-member fan-out | Enabled | +3.18% | [-7.85%, +14.21%] | +2.04% | 5% | No | + +Every original CPU observation below is seconds per completed measured workflow. + +| Workload | Repetition | Published baseline | Disabled | Enabled | +| --- | --- | ---: | ---: | ---: | +| Ordinary | 1 | 0.440138 | 0.450680 | 0.487149 | +| Ordinary | 2 | 0.433282 | 0.421278 | 0.484975 | +| Ordinary | 3 | 0.439790 | 0.456113 | 0.465107 | +| Ordinary | Drift | 0.444807 | — | — | +| Fan-out | 1 | 48.130522 | 47.228357 | 50.872936 | +| Fan-out | 2 | 47.496107 | 45.026346 | 46.572337 | +| Fan-out | 3 | 44.848782 | 47.221256 | 47.447672 | +| Fan-out | Drift | 47.780681 | — | — | + +## Other observations + +All offered workflows were accepted and completed. The independent reader +validated exact callback/history counts and results, no missed offers, extra +attempts, unfinished work, failed jobs or residual queue entries. No container +OOM/restart or nonzero worker child exit occurred. MySQL, Redis and the official +Collector exited zero; HTTP and retaining shells used their declared 143 stop. +Repairs remained observable and were not substituted for extra attempts. + +| Workload/profile | Observed p95 range | Observed p99 range | Sum of sampled container peaks | +| --- | --- | --- | --- | +| Ordinary/base | 1.131–1.371 s | 1.386–1.655 s | 919,670,784–924,098,560 bytes | +| Ordinary/off | 1.134–1.379 s | 1.374–1.401 s | 922,849,280–924,983,296 bytes | +| Ordinary/on | 1.388–1.393 s | 1.399–1.458 s | 944,701,440–949,186,560 bytes | +| Fan-out/base | 28.756–29.613 s | 28.756–29.613 s | 964,190,208–969,908,224 bytes | +| Fan-out/off | 28.866–29.791 s | 28.866–29.791 s | 963,702,784–971,960,320 bytes | +| Fan-out/on | 28.771–31.492 s | 28.771–31.492 s | 982,581,248–988,864,512 bytes | + +Fan-out tails have only two measured observations per cell. These ranges are +descriptive, without a throughput or latency regression claim. Maximum offer +lag across cells was 74.66 ms, below the original 100 ms admission gate. + +Disabled and baseline cells persisted zero tracing bytes and ended/exported +zero spans. Enabled ordinary cells, including warmup, each persisted 21,750 run +bytes and 143,400 task bytes and ended/accepted/exported exactly 1,050 spans. +Enabled fan-out cells persisted 435 run bytes and 102,813–102,814 task bytes; +their ended/accepted/exported counts were respectively 413, 403 and 401. +No activity-execution carrier bytes were stored. Different task processing and +repair observations account for differing physical span counts; the reader +verified business cardinality separately. + +The prerequisites also passed: functional baseline/off/on (two runs, 65 +activities each) and the published-baseline offered-load check (120 ordinary +plus two fan-out workflows). These are workload checks, not maximum-capacity +estimates. The next qualification must name its exact changed source and +published baseline, declare its method and budgets before admission, and retain +these original failures. Changing a target cannot turn this study into a pass. diff --git a/docs/embedded-tracing.md b/docs/embedded-tracing.md index 27295fc8c..5959bce1d 100644 --- a/docs/embedded-tracing.md +++ b/docs/embedded-tracing.md @@ -17,6 +17,21 @@ operation. An unavailable or non-array baggage allowlist permits no baggage; only string entries are used. Neither optional setting can prevent admission, timer or activity execution, or replace the workflow's own failure. +Malformed, oversized, unsupported or unreadable carrier metadata is discarded. +Recording processing/admission spans report only the bounded attribute +`durable.trace_context.disposition=discarded`. When normalization retains a valid +parent but filters or canonicalizes its propagation headers, the value is +`filtered`. Ordinary absent legacy metadata has no diagnostic. Captured caller +and signal/update-link diagnostics can also annotate the current recording +application span. These attributes contain no header values, baggage keys or +values, payloads or exception messages, and do not mark business work as failed. +They add no durable field, history event, independent export or logging call. +Sampling and application SDK limits still apply: no diagnostic is guaranteed +when no span records, and disabled instrumentation emits none. +Invalid queue-dispatch link metadata uses the separate bounded attribute +`durable.queue_context.disposition`; dropping that link never replaces the +run's durable parent or task authority. + ## Configure the source integration In a qualification application, use one application-owned environment flag for diff --git a/src/V2/Jobs/RunActivityTask.php b/src/V2/Jobs/RunActivityTask.php index 403085827..970d99966 100644 --- a/src/V2/Jobs/RunActivityTask.php +++ b/src/V2/Jobs/RunActivityTask.php @@ -76,7 +76,7 @@ public function handle(): void } $allowlist = ExecutionTracing::baggageAllowlist(); - $storedContext = TraceCarrier::fromRun($claim->run); + $storedContext = TraceCarrier::fromRun($claim->run, $diagnostic); ExecutionTracing::activity( [ 'workflow_instance_id' => $claim->run->workflow_instance_id, @@ -88,6 +88,7 @@ public function handle(): void 'activity_type' => $claim->execution->activity_type, 'activity_attempt_id' => $claim->attemptId(), 'activity_attempt_number' => $claim->attemptNumber(), + 'durable.trace_context.disposition' => $diagnostic, ...TaskTraceTimings::forTask($claim->task), ], $storedContext, diff --git a/src/V2/Jobs/RunTimerTask.php b/src/V2/Jobs/RunTimerTask.php index a14aa7180..f62e3da50 100644 --- a/src/V2/Jobs/RunTimerTask.php +++ b/src/V2/Jobs/RunTimerTask.php @@ -112,6 +112,7 @@ public function handle(): void /** @var WorkflowRun $run */ $run = $claimedTask->getRelation('run'); $allowlist = ExecutionTracing::baggageAllowlist(); + $storedContext = TraceCarrier::fromRun($run, $diagnostic); ExecutionTracing::timer( [ 'workflow_instance_id' => $run->workflow_instance_id, @@ -120,9 +121,10 @@ public function handle(): void 'namespace' => $run->namespace, 'task_id' => $claimedTask->id, 'timer_id' => $timer->id, + 'durable.trace_context.disposition' => $diagnostic, ...TaskTraceTimings::forTask($claimedTask, $timer), ], - TraceCarrier::fromRun($run), + $storedContext, fn () => $this->executeClaim($timer->id), $allowlist, true, diff --git a/src/V2/Jobs/RunWorkflowTask.php b/src/V2/Jobs/RunWorkflowTask.php index 0089a1ce1..057549545 100644 --- a/src/V2/Jobs/RunWorkflowTask.php +++ b/src/V2/Jobs/RunWorkflowTask.php @@ -76,7 +76,7 @@ public function handle(WorkflowExecutor $executor): void /** @var WorkflowRun $run */ $run = $task->getRelation('run'); $allowlist = ExecutionTracing::baggageAllowlist(); - $storedContext = TraceCarrier::fromRun($run); + $storedContext = TraceCarrier::fromRun($run, $diagnostic); // Activate and end the segment outside the execution transaction. ExecutionTracing::workflow( [ @@ -85,6 +85,7 @@ public function handle(WorkflowExecutor $executor): void 'workflow_type' => $run->workflow_type, 'namespace' => $run->namespace, 'task_id' => $task->id, + 'durable.trace_context.disposition' => $diagnostic, ...TaskTraceTimings::forTask($task), ], $storedContext, diff --git a/src/V2/Support/ExecutionTracing.php b/src/V2/Support/ExecutionTracing.php index 3b7b8ccba..7301fb613 100644 --- a/src/V2/Support/ExecutionTracing.php +++ b/src/V2/Support/ExecutionTracing.php @@ -390,14 +390,20 @@ public static function current(): array return $fields; } - public static function admission(WorkflowInstance $instance, callable $operation, bool $enabled = false): mixed - { + public static function admission( + WorkflowInstance $instance, + callable $operation, + bool $enabled = false, + ?string $diagnostic = null, + ): mixed { if (! $enabled || ! class_exists(Globals::class)) { return $operation(); } - $attributes = []; + $attributes = [ + 'durable.trace_context.disposition' => $diagnostic, + ]; try { - $attributes = [ + $attributes += [ 'workflow_instance_id' => $instance->getRawOriginal('id'), 'workflow_type' => $instance->getRawOriginal('workflow_type'), 'namespace' => $instance->getRawOriginal('namespace'), @@ -446,6 +452,21 @@ public static function recordFailure(Throwable $error): void } } + public static function recordCarrierDiagnostic(?string $diagnostic): void + { + if (! in_array($diagnostic, ['discarded', 'filtered'], true) || ! self::enabled()) { + return; + } + try { + $span = Span::getCurrent(); + if ($span->isRecording()) { + $span->setAttribute('durable.trace_context.disposition', $diagnostic); + } + } catch (Throwable) { + // Diagnostics cannot replace the business result or export independently. + } + } + public static function linkSignal(WorkflowRun $run, WorkflowCommand $command): void { self::linkCommand($run, $command, CommandType::Signal); @@ -471,7 +492,8 @@ public static function linkBufferedSchedule(WorkflowSchedule $schedule, mixed $s if (! $state instanceof TraceLinkState || ! $state->isRecording()) { return; } - $carrier = TraceCarrier::normalize($stored); + $carrier = TraceCarrier::normalize($stored, diagnostic: $diagnostic); + self::recordCarrierDiagnostic($diagnostic); if ($carrier !== null) { self::linkCarrier($state, $carrier, [ 'durable.link.type' => 'buffered_schedule', @@ -561,7 +583,8 @@ private static function linkCommand(WorkflowRun $run, WorkflowCommand $command, if (! $state instanceof TraceLinkState || ! $state->isRecording()) { return; } - $carrier = TraceCarrier::fromCommand($command); + $carrier = TraceCarrier::fromCommand($command, $diagnostic); + self::recordCarrierDiagnostic($diagnostic); if ($carrier === null) { return; } @@ -630,7 +653,9 @@ private static function segment( $scope = null; try { $fields = self::fields($attributes); - $parent = self::parentContext($name, $persistedContext, $baggageAllowlist, $fields); + $parent = self::parentContext($name, $persistedContext, $baggageAllowlist, $fields, $diagnostic); + $attributes['durable.trace_context.disposition'] ??= $diagnostic + ?? ($name === 'durable.schedule.trigger' ? TraceCarrier::preparedAdmissionDiagnostic() : null); $spanAttributes = self::spanAttributes($name, $attributes, $fields); $version = InstalledVersions::isInstalled(self::PACKAGE_NAME) ? InstalledVersions::getPrettyVersion(self::PACKAGE_NAME) @@ -646,7 +671,10 @@ private static function segment( ->setParent($parent) ->setAttributes($spanAttributes); if ($dispatchContext !== null) { - $dispatch = TaskTraceCarrier::fromJob($dispatchContext, $fields['task_id'] ?? ''); + $dispatch = TaskTraceCarrier::fromJob($dispatchContext, $fields['task_id'] ?? '', $dispatchDiagnostic); + if ($dispatchDiagnostic !== null) { + $builder->setAttribute('durable.queue_context.disposition', $dispatchDiagnostic); + } if ($dispatch !== null) { $linked = TraceContextPropagator::getInstance()->extract( $dispatch['carrier'], @@ -733,13 +761,15 @@ private static function parentContext( mixed $persistedContext, array $baggageAllowlist, array $fields, + ?string &$diagnostic, ): ContextInterface { // Local callbacks may share their hosting task. Other worker segments recover the stored parent. $localParent = in_array($name, ['durable.workflow.admission', 'durable.schedule.trigger'], true) || ($name === 'durable.task.dispatch' && self::current() !== []); $hostingTask = $name === 'durable.local_activity.attempt' && self::matchesHostingTask($fields); $parent = $localParent || $hostingTask ? Context::getCurrent() : Context::getRoot(); - $carrier = $hostingTask ? null : TraceCarrier::normalize($persistedContext, $baggageAllowlist); + $diagnostic = null; + $carrier = $hostingTask ? null : TraceCarrier::normalize($persistedContext, $baggageAllowlist, $diagnostic); if ($carrier !== null) { $parent = TraceContextPropagator::getInstance()->extract($carrier['carrier'], context: $parent); $parent = BaggagePropagator::getInstance()->extract($carrier['carrier'], context: $parent); @@ -778,6 +808,9 @@ private static function matchesHostingTask(array $fields): bool private static function spanAttributes(string $name, array $attributes, array $fields): array { $spanAttributes = $fields; + if (in_array($attributes['durable.trace_context.disposition'] ?? null, ['discarded', 'filtered'], true)) { + $spanAttributes['durable.trace_context.disposition'] = $attributes['durable.trace_context.disposition']; + } foreach (self::TIMINGS as $key) { $value = $attributes[$key] ?? null; if ((is_int($value) || is_float($value)) && is_finite((float) $value) && $value >= 0) { diff --git a/src/V2/Support/LocalActivityExecutor.php b/src/V2/Support/LocalActivityExecutor.php index 18b91c85a..88146f9db 100644 --- a/src/V2/Support/LocalActivityExecutor.php +++ b/src/V2/Support/LocalActivityExecutor.php @@ -272,6 +272,7 @@ public function execute( $operation = fn (): array => $this->invokeCallback($run, $task, $execution); if (ExecutionTracing::enabled() && ! WorkflowStub::hasMock($execution->activity_class)) { + $storedContext = TraceCarrier::fromRun($run, $diagnostic); [$result, $throwable] = ExecutionTracing::localActivity( [ 'workflow_instance_id' => $run->workflow_instance_id, @@ -283,8 +284,9 @@ public function execute( 'activity_type' => $execution->activity_type, 'activity_attempt_id' => $attempt->id, 'activity_attempt_number' => $attempt->attempt_number, + 'durable.trace_context.disposition' => $diagnostic, ], - TraceCarrier::fromRun($run), + $storedContext, static function () use ($operation): array { [$result, $throwable] = $operation(); if ($throwable !== null) { diff --git a/src/V2/Support/TaskTraceCarrier.php b/src/V2/Support/TaskTraceCarrier.php index 5907d1ba2..8d579fcfc 100644 --- a/src/V2/Support/TaskTraceCarrier.php +++ b/src/V2/Support/TaskTraceCarrier.php @@ -19,7 +19,8 @@ public static function capture(string $taskId): ?array if (! self::validTaskId($taskId)) { return null; } - $context = TraceCarrier::capture(); + $context = TraceCarrier::capture(diagnostic: $diagnostic); + ExecutionTracing::recordCarrierDiagnostic($diagnostic); if ($context === null) { return null; } @@ -40,8 +41,9 @@ public static function capture(string $taskId): ?array /** * @return array{schema: string, version: int, carrier: array}|null */ - public static function fromJob(mixed $value, string $taskId): ?array + public static function fromJob(mixed $value, string $taskId, ?string &$diagnostic = null): ?array { + $diagnostic = $value === null ? null : 'discarded'; if (! self::validTaskId($taskId) || ! is_array($value) || ($value['schema'] ?? null) !== self::SCHEMA || ($value['version'] ?? null) !== 1 || ($value['task_id'] ?? null) !== $taskId || array_diff(array_keys($value), ['schema', 'version', 'task_id', 'context']) !== [] @@ -49,13 +51,18 @@ public static function fromJob(mixed $value, string $taskId): ?array || array_diff(array_keys($value['context']['carrier']), ['traceparent', 'tracestate']) !== []) { return null; } - $context = TraceCarrier::normalize($value['context']); + $context = TraceCarrier::normalize($value['context'], diagnostic: $contextDiagnostic); if ($context === null) { return null; } try { - return strlen(json_encode($value, JSON_THROW_ON_ERROR)) <= 4096 ? $context : null; + if (strlen(json_encode($value, JSON_THROW_ON_ERROR)) > 4096) { + return null; + } + $diagnostic = $contextDiagnostic; + + return $context; } catch (Throwable) { return null; } diff --git a/src/V2/Support/TraceCarrier.php b/src/V2/Support/TraceCarrier.php index 181e344c9..641e01ab9 100644 --- a/src/V2/Support/TraceCarrier.php +++ b/src/V2/Support/TraceCarrier.php @@ -44,8 +44,10 @@ public static function withPreparedAdmission(callable $operation): mixed $fiber = Fiber::getCurrent(); try { self::$admissionKey ??= Context::createKey('durable-workflow.schedule-admission'); + $carrier = self::captureConfigured($diagnostic); $scope = Context::getCurrent()->with(self::$admissionKey, [ - 'carrier' => self::captureConfigured(), + 'carrier' => $carrier, + 'diagnostic' => $diagnostic, 'fiber' => $fiber, ])->activate(); if ($fiber !== null) { @@ -81,27 +83,43 @@ public static function hasPreparedAdmission(): bool return self::preparedAdmission() !== null; } + public static function preparedAdmissionDiagnostic(): ?string + { + return self::preparedAdmission()['diagnostic'] ?? null; + } + /** * @return array{schema: string, version: int, carrier: array}|null */ - public static function admissionParent(): ?array + public static function admissionParent(?string &$diagnostic = null): ?array { $prepared = self::preparedAdmission(); + if ($prepared !== null) { + $diagnostic = $prepared['diagnostic']; - return $prepared !== null ? $prepared['carrier'] : self::captureConfigured(); + return $prepared['carrier']; + } + + return self::captureConfigured($diagnostic); } /** * @return array{schema: string, version: int, carrier: array}|null */ - public static function captureConfigured(): ?array + public static function captureConfigured(?string &$diagnostic = null): ?array { + $diagnostic = null; try { if (! ExecutionTracing::enabled()) { return null; } - return self::capture(ExecutionTracing::baggageAllowlist()); + $carrier = self::capture(ExecutionTracing::baggageAllowlist(), $diagnostic); + ExecutionTracing::recordCarrierDiagnostic($diagnostic); + + return $carrier; } catch (Throwable) { + $diagnostic = 'discarded'; + return null; } } @@ -109,15 +127,18 @@ public static function captureConfigured(): ?array /** * @return array{schema: string, version: int, carrier: array}|null */ - public static function fromCommand(WorkflowCommand $command): ?array + public static function fromCommand(WorkflowCommand $command, ?string &$diagnostic = null): ?array { + $diagnostic = null; try { $stored = array_key_exists('observability_context', $command->getAttributes()) ? $command->getAttribute('observability_context') : null; // A link never imports the triggering caller's baggage into execution. - return self::normalize($stored); + return self::normalize($stored, diagnostic: $diagnostic); } catch (Throwable) { + $diagnostic = 'discarded'; + return null; } } @@ -125,18 +146,21 @@ public static function fromCommand(WorkflowCommand $command): ?array /** * @return array{schema: string, version: int, carrier: array}|null */ - public static function fromRun(WorkflowRun $run): ?array + public static function fromRun(WorkflowRun $run, ?string &$diagnostic = null): ?array { + $diagnostic = null; try { // Emission can be disabled without erasing a previously captured admission parent. $stored = array_key_exists('observability_context', $run->getAttributes()) ? $run->getAttribute('observability_context') : null; - if (! is_array($stored)) { + if ($stored === null) { return null; } - return self::normalize($stored, ExecutionTracing::baggageAllowlist()); + return self::normalize($stored, ExecutionTracing::baggageAllowlist(), $diagnostic); } catch (Throwable) { // Optional telemetry metadata must not interrupt child admission or retries. + $diagnostic = 'discarded'; + return null; } } @@ -145,8 +169,9 @@ public static function fromRun(WorkflowRun $run): ?array * @param list $baggageAllowlist * @return array{schema: string, version: int, carrier: array}|null */ - public static function capture(array $baggageAllowlist = []): ?array + public static function capture(array $baggageAllowlist = [], ?string &$diagnostic = null): ?array { + $diagnostic = null; if (! class_exists(Globals::class)) { return null; } @@ -157,13 +182,18 @@ public static function capture(array $baggageAllowlist = []): ?array // Applications may also inject unrelated propagation formats. $carrier = array_intersect_key($carrier, self::LIMITS); + if ($carrier === []) { + return null; + } return self::normalize([ 'schema' => self::SCHEMA, 'version' => 1, 'carrier' => $carrier, - ], $baggageAllowlist); + ], $baggageAllowlist, $diagnostic); } catch (Throwable) { + $diagnostic = 'discarded'; + return null; } } @@ -172,8 +202,12 @@ public static function capture(array $baggageAllowlist = []): ?array * @param list $baggageAllowlist * @return array{schema: string, version: int, carrier: array}|null */ - public static function normalize(mixed $value, array $baggageAllowlist = []): ?array - { + public static function normalize( + mixed $value, + array $baggageAllowlist = [], + ?string &$diagnostic = null, + ): ?array { + $diagnostic = $value === null ? null : 'discarded'; if (! is_array($value) || ($value['schema'] ?? null) !== self::SCHEMA || ($value['version'] ?? null) !== 1 || ! is_array($value['carrier'] ?? null) || array_diff(array_keys($value), ['schema', 'version', 'carrier']) !== [] @@ -219,7 +253,12 @@ public static function normalize(mixed $value, array $baggageAllowlist = []): ?a 'carrier' => $normalized, ]; - return strlen(json_encode($result, JSON_THROW_ON_ERROR)) <= 4096 ? $result : null; + if (strlen(json_encode($result, JSON_THROW_ON_ERROR)) > 4096) { + return null; + } + $diagnostic = $normalized === $carrier ? null : 'filtered'; + + return $result; } catch (Throwable) { return null; } diff --git a/src/V2/WorkflowStub.php b/src/V2/WorkflowStub.php index 044eb1137..04c1a54ce 100644 --- a/src/V2/WorkflowStub.php +++ b/src/V2/WorkflowStub.php @@ -947,9 +947,10 @@ public function attemptStart(...$arguments): StartResult $metadata = WorkflowMetadata::fromStartArguments($arguments); // Capture outside the storage transaction and preserve it across its retries. $observabilityContext = null; + $diagnostic = null; $tracing = ExecutionTracing::enabled(); if ($tracing) { - $observabilityContext = TraceCarrier::admissionParent(); + $observabilityContext = TraceCarrier::admissionParent($diagnostic); } if (! $tracing) { return $this->admitStart($metadata, $startOptions, $observabilityContext); @@ -958,7 +959,8 @@ public function attemptStart(...$arguments): StartResult return ExecutionTracing::admission( $this->instance, fn () => $this->admitStart($metadata, $startOptions, $observabilityContext), - enabled: true + enabled: true, + diagnostic: $diagnostic ); } @@ -3221,9 +3223,10 @@ private function attemptSignalWithStartInternal( $intakeGroupId = (string) Str::ulid(); // Capture before locking. An existing run retains its original admission carrier. $observabilityContext = null; + $diagnostic = null; $tracing = ExecutionTracing::enabled(); if ($tracing) { - $observabilityContext = TraceCarrier::admissionParent(); + $observabilityContext = TraceCarrier::admissionParent($diagnostic); } $operation = fn (): SignalWithStartResult => $this->admitSignalWithStart( $name, @@ -3237,7 +3240,7 @@ private function attemptSignalWithStartInternal( return $operation(); } - return ExecutionTracing::admission($this->instance, $operation, enabled: true); + return ExecutionTracing::admission($this->instance, $operation, enabled: true, diagnostic: $diagnostic); } private function admitSignalWithStart( diff --git a/tests/Unit/V2/ActivityExecutionTracingTest.php b/tests/Unit/V2/ActivityExecutionTracingTest.php index 975106c7f..24f8d75c4 100644 --- a/tests/Unit/V2/ActivityExecutionTracingTest.php +++ b/tests/Unit/V2/ActivityExecutionTracingTest.php @@ -12,11 +12,18 @@ use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; +use OpenTelemetry\API\Baggage\Baggage; +use OpenTelemetry\API\Baggage\Propagation\BaggagePropagator; use OpenTelemetry\API\Instrumentation\Configurator; use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator; use OpenTelemetry\API\Trace\SpanKind; use OpenTelemetry\API\Trace\StatusCode; +use OpenTelemetry\Context\Context; use OpenTelemetry\Context\ContextInterface; +use OpenTelemetry\Context\Propagation\MultiTextMapPropagator; +use OpenTelemetry\Context\Propagation\PropagationGetterInterface; +use OpenTelemetry\Context\Propagation\PropagationSetterInterface; +use OpenTelemetry\Context\Propagation\TextMapPropagatorInterface; use OpenTelemetry\Context\ScopeInterface; use OpenTelemetry\SDK\Trace\ReadableSpanInterface; use OpenTelemetry\SDK\Trace\ReadWriteSpanInterface; @@ -38,6 +45,7 @@ use Workflow\V2\Models\WorkflowCommand; use Workflow\V2\Models\WorkflowHistoryEvent; use Workflow\V2\Models\WorkflowRun; +use Workflow\V2\Models\WorkflowSchedule; use Workflow\V2\Models\WorkflowTask; use Workflow\V2\StartOptions; use Workflow\V2\Support\ActivityOutcomeRecorder; @@ -45,6 +53,7 @@ use Workflow\V2\Support\ExecutionTracing; use Workflow\V2\Support\ScheduleManager; use Workflow\V2\Support\TaskOutcomeCarrier; +use Workflow\V2\Support\TraceCarrier; use Workflow\V2\Workflow; use Workflow\V2\WorkflowStub; @@ -93,6 +102,133 @@ public static function modes(): array ]; } + #[DataProvider('dispatchFailures')] + public function testPreparedScheduleFilteringUsesBoundedSpanAttributesWithoutChangingBusinessWork( + bool $enabled, + ): void { + config([ + 'workflows.tracing.enabled' => $enabled, + ]); + $propagator = Configurator::create()->withPropagator(new MultiTextMapPropagator([ + TraceContextPropagator::getInstance(), BaggagePropagator::getInstance(), + ]))->activate(); + $caller = $this->provider->getTracer('application') + ->spanBuilder('request') + ->startSpan(); + $scope = Baggage::getBuilder()->set('private', 'secret')->build() + ->storeInContext($caller->storeInContext(Context::getCurrent()))->activate(); + $schedule = new WorkflowSchedule(); + $schedule->setRawAttributes([ + 'schedule_id' => 'bounded-diagnostic', + 'namespace' => '0', + ]); + try { + self::assertSame(7, ExecutionTracing::scheduleAdmission($schedule, static function () use ($enabled): int { + $carrier = TraceCarrier::admissionParent(); + if ($enabled) { + self::assertNotNull($carrier); + self::assertArrayNotHasKey('baggage', $carrier['carrier']); + } else { + self::assertNull($carrier); + ExecutionTracing::recordCarrierDiagnostic('discarded'); + } + ExecutionTracing::recordCarrierDiagnostic('private=secret'); + + return 7; + }, 'scheduled')); + } finally { + $scope->detach(); + $caller->end(); + $propagator->detach(); + } + $spans = $this->exporter->getSpans(); + self::assertCount($enabled ? 2 : 1, $spans); + foreach ($spans as $span) { + self::assertSame($enabled ? 'filtered' : null, $span->getAttributes() + ->get('durable.trace_context.disposition')); + self::assertSame([], $span->getEvents()); + self::assertNotSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + self::assertStringNotContainsString('secret', json_encode( + $span->getAttributes() + ->toArray(), + JSON_THROW_ON_ERROR + )); + } + self::assertSame(0, WorkflowRun::query()->count()); + self::assertSame(0, DB::table('jobs')->count()); + } + + #[DataProvider('dispatchFailures')] + public function testInvalidCapturedParentIsDiagnosedOnAdmissionWithoutAnApplicationSpan(bool $enabled): void + { + config([ + 'workflows.tracing.enabled' => $enabled, + ]); + $propagator = new class() implements TextMapPropagatorInterface { + public int $injections = 0; + + public function fields(): array + { + return ['traceparent']; + } + + public function inject( + mixed &$carrier, + ?PropagationSetterInterface $setter = null, + ?ContextInterface $context = null, + ): void { + ++$this->injections; + $carrier['traceparent'] = 'private-invalid-header'; + } + + public function extract( + $carrier, + ?PropagationGetterInterface $getter = null, + ?ContextInterface $context = null, + ): ContextInterface { + return $context ?? Context::getCurrent(); + } + }; + $scope = Configurator::create()->withPropagator($propagator)->activate(); + try { + $stub = WorkflowStub::make(TracedWorkflow::class, 'invalid-admission-parent', '0'); + $stub->start(false); + for ($remaining = 4; $remaining > 0 && DB::table('jobs')->count() > 0; --$remaining) { + $this->workOnce(); + } + $run = WorkflowRun::query()->findOrFail($stub->runId()); + self::assertSame('completed', $run->status->value); + self::assertSame([9007199254740993, null, '雪'], $run->workflowOutput()); + self::assertNull($run->observability_context); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + if ($enabled) { + self::assertGreaterThan(0, $propagator->injections); + } else { + self::assertSame(0, $propagator->injections); + } + $admissions = array_values(array_filter( + $this->exporter->getSpans(), + static fn ($span): bool => $span->getName() === 'durable.workflow.admission' + )); + self::assertCount($enabled ? 1 : 0, $admissions); + if ($enabled) { + self::assertSame('discarded', $admissions[0]->getAttributes() + ->get('durable.trace_context.disposition')); + self::assertNotSame(StatusCode::STATUS_ERROR, $admissions[0]->getStatus()->getCode()); + self::assertStringNotContainsString('private-invalid-header', json_encode( + $admissions[0]->getAttributes() + ->toArray(), + JSON_THROW_ON_ERROR + )); + } else { + self::assertSame([], $this->exporter->getSpans()); + } + } finally { + $scope->detach(); + } + } + public static function nonQueueModes(): array { return [ @@ -300,6 +436,23 @@ public function testOutcomeMetadataRollsBackWithItsEventAndDuplicateCannotReplac $history = WorkflowHistoryEvent::query()->orderBy('sequence')->get()->map( static fn ($row): array => $row->getRawOriginal(), )->all(); + $workflowTask = WorkflowTask::query()->where('workflow_run_id', $claim->run->id) + ->where('task_type', 'workflow') + ->latest('id') + ->firstOrFail(); + $runAttributes = $claim->run->fresh() + ->getRawOriginal(); + $taskAttributes = $storedTask->getRawOriginal(); + self::assertSame([ + $event->id => $contexts['outcomes'][0] + [ + 'task_id' => $storedTask->id, + ], + ], TaskOutcomeCarrier::forRun($claim->run, $workflowTask)); + self::assertSame($taskAttributes, $storedTask->fresh()->getRawOriginal()); + self::assertSame($runAttributes, $claim->run->fresh()->getRawOriginal()); + self::assertSame($history, WorkflowHistoryEvent::query()->orderBy('sequence')->get()->map( + static fn ($row): array => $row->getRawOriginal(), + )->all()); self::assertSame([ 'recorded' => false, 'reason' => 'stale_attempt', @@ -462,6 +615,16 @@ public function testUnreadableOptionalRunMetadataCannotInterruptClaimedBusinessW foreach ($processing as $index => $span) { self::assertSame($run->id, $span->getAttributes()->get('workflow_run_id')); self::assertSame($phase === 'activity' && $index === 0, $span->getParentContext()->isValid()); + self::assertSame( + $phase === 'activity' && $index === 0 ? null : 'discarded', + $span->getAttributes() + ->get('durable.trace_context.disposition') + ); + self::assertNotSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + self::assertStringNotContainsString( + 'optional telemetry cast failed', + json_encode($span->getAttributes()->toArray(), JSON_THROW_ON_ERROR) + ); } self::assertCount( 1, diff --git a/tests/Unit/V2/ExecutionTracingTest.php b/tests/Unit/V2/ExecutionTracingTest.php index 5ad34a3ee..3199723bb 100644 --- a/tests/Unit/V2/ExecutionTracingTest.php +++ b/tests/Unit/V2/ExecutionTracingTest.php @@ -25,6 +25,7 @@ use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor; use OpenTelemetry\SDK\Trace\TracerProvider; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use RuntimeException; use Symfony\Component\Process\Process; @@ -128,6 +129,64 @@ public function testRealProviderAndLoggingKeepDurableRequestAndTraceIdentitiesSe self::assertNull($spans[1]->getAttributes()->get('arguments')); } + public static function diagnosticCarriers(): iterable + { + foreach (TraceCarrierTest::invalidCarriers() as $name => [$carrier]) { + yield $name => [$carrier, $carrier === null ? null : 'discarded', false]; + } + yield 'valid' => [self::carrier(), null, true]; + $filtered = self::carrier(); + $filtered['carrier']['baggage'] = 'private=secret'; + yield 'filtered' => [$filtered, 'filtered', true]; + } + + #[DataProvider('diagnosticCarriers')] + public function testCarrierDiagnosticsAreBoundedAndDoNotTurnSuccessfulWorkIntoErrors( + mixed $carrier, + ?string $diagnostic, + bool $parentValid, + ): void { + self::assertSame(7, ExecutionTracing::activity( + self::fields(), + $carrier, + static fn (): int => 7, + enabled: true + )); + $spans = $this->exporter->getSpans(); + self::assertCount(1, $spans); + $span = $spans[0]; + self::assertSame($diagnostic, $span->getAttributes()->get('durable.trace_context.disposition')); + self::assertSame($parentValid, $span->getParentContext()->isValid()); + self::assertNotSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + self::assertSame([], $span->getEvents()); + $attributes = json_encode($span->getAttributes()->toArray(), JSON_THROW_ON_ERROR); + foreach (['secret', 'private', 'traceparent', 'baggage', 'must not be captured'] as $forbidden) { + self::assertStringNotContainsString($forbidden, $attributes); + } + } + + public function testDiscardedQueueLinkPreservesTheRunParentAndOriginalBusinessResult(): void + { + self::assertSame(7, ExecutionTracing::activity( + self::fields(), + self::carrier(), + static fn (): int => 7, + enabled: true, + dispatchContext: 'private-invalid-queue-context' + )); + $span = $this->exporter->getSpans()[0]; + self::assertSame('discarded', $span->getAttributes()->get('durable.queue_context.disposition')); + self::assertSame(self::PARENT_ID, $span->getParentContext()->getSpanId()); + self::assertSame([], $span->getLinks()); + self::assertSame([], $span->getEvents()); + self::assertNotSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); + self::assertStringNotContainsString('private-invalid-queue-context', json_encode( + $span->getAttributes() + ->toArray(), + JSON_THROW_ON_ERROR + )); + } + public function testAttemptsHaveDistinctSpansAndNestedScopesRestoreTheirCaller(): void { ExecutionTracing::activity(self::fields(), self::carrier(), static function (): void { diff --git a/tests/Unit/V2/TaskTraceCarrierTest.php b/tests/Unit/V2/TaskTraceCarrierTest.php index 43b7f6042..cecb34a6c 100644 --- a/tests/Unit/V2/TaskTraceCarrierTest.php +++ b/tests/Unit/V2/TaskTraceCarrierTest.php @@ -96,7 +96,9 @@ public static function invalidCarriers(): iterable #[DataProvider('invalidCarriers')] public function testInvalidMetadataSuppliesNoContext(mixed $value, string $taskId): void { - self::assertNull(TaskTraceCarrier::fromJob($value, $taskId)); + $diagnostic = 'stale'; + self::assertNull(TaskTraceCarrier::fromJob($value, $taskId, $diagnostic)); + self::assertSame($value === null ? null : 'discarded', $diagnostic); } public function testMetadataInspectionDoesNotExecuteUserObjectSerialization(): void diff --git a/tests/Unit/V2/TraceCarrierTest.php b/tests/Unit/V2/TraceCarrierTest.php index 97bb3f162..853cec0c9 100644 --- a/tests/Unit/V2/TraceCarrierTest.php +++ b/tests/Unit/V2/TraceCarrierTest.php @@ -183,7 +183,25 @@ public function testCaptureUsesTheApplicationPropagatorAndRestoresItsScope(): vo #[DataProvider('invalidCarriers')] public function testInvalidTelemetryIsDiscarded(mixed $input): void { - $this->assertNull(TraceCarrier::normalize($input, ['approved'])); + $diagnostic = 'stale'; + $this->assertNull(TraceCarrier::normalize($input, ['approved'], $diagnostic)); + $this->assertSame($input === null ? null : 'discarded', $diagnostic); + } + + public function testFilteringReportsOnlyAFixedDiagnosticAndKeepsTheCarrierSchemaUnchanged(): void + { + $input = self::envelope([ + 'traceparent' => self::PARENT, + 'baggage' => 'approved=value,private=secret', + ]); + $normalized = TraceCarrier::normalize($input, ['approved'], $diagnostic); + self::assertSame('filtered', $diagnostic); + self::assertSame(self::envelope([ + 'traceparent' => self::PARENT, + 'baggage' => 'approved=value', + ]), $normalized); + self::assertSame($normalized, TraceCarrier::normalize($normalized, ['approved'], $diagnostic)); + self::assertNull($diagnostic); } public static function invalidCarriers(): array From 31ab1fcfcb23008cbeb21ee28ae0190b9c43386e Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 16:59:21 +0000 Subject: [PATCH 74/77] Keep historical CPU gates and add the declared five-pair profile --- .github/tracing-performance/README.md | 54 +++++++--- .github/tracing-performance/analyze.mjs | 70 +++++++++---- .github/tracing-performance/analyze.test.mjs | 103 +++++++++++++++++++ 3 files changed, 196 insertions(+), 31 deletions(-) diff --git a/.github/tracing-performance/README.md b/.github/tracing-performance/README.md index 0920acc31..4d016b2e9 100644 --- a/.github/tracing-performance/README.md +++ b/.github/tracing-performance/README.md @@ -8,6 +8,34 @@ OpenTelemetry API baggage fix. The remaining acceptance requirements of [#773](https://github.com/durable-workflow/workflow/issues/773) are separate release gates. +## Prospective corrected-source method + +The [prospective decision](https://github.com/durable-workflow/workflow/issues/773#issuecomment-6296360973) +defines `embedded-300s-5pairs-v2`: five fixed matched repetitions, 300-second +offer windows, **5% disabled / 15% enabled** CPU budgets, and a final baseline +drift check for each workload. These agent-selected limits acknowledge the +observed cost and the uncertainty of the shorter studies. They do not relabel +either failed historical study or supply a new-source result. + +Record `outcome.study_method` with that exact identifier in every new cell, +plus exact 40-character `candidate` and `baseline` references. All 32 cells +must use one method, source pair and official Collector image. The fixed order +is base/off/on; on/base/off; off/on/base; base/on/off; off/base/on, then baseline +drift, separately for ordinary and fan-out. Keep all rates, 64-member fan-out, +sampling, original business deadlines, warmup, resource caps and drain gates +below. Set the measured probe's `DURATION_SECONDS=300`: ordinary offers 300 +workflows and fan-out five. Warmup remains 30 ordinary workflows or one fan-out. +Do not select extra repetitions after results. + +The new interval uses n=5, df=4 and critical value 2.7764451052. Its upper +endpoint and absolute final baseline drift must both meet the applicable +budget. Unknown/mixed methods, missing or mixed source identities, partial +batches and legacy Collector data refuse this method. Absent `study_method` +retains the original `embedded-120s-3pairs-v1` reader with 20 cells, n=3, df=2 +and unchanged 1%/5% budgets. `workloads.json` preserves those original defaults; +longer measured windows must be explicit. No sampling reduction or shorter +fan-out is part of the prospective method. + ## Prospective official Collector profile `compose.collector.yaml` selects a separately labelled official Collector profile. @@ -103,10 +131,10 @@ Its exact dependency constraints are deliberate experiment inputs. CI validates the manifest and lock with Composer's strict validation, excluding generic warnings about overly strict version constraints and package publication. -The previously declared acceptance targets remain at most **1% additional CPU -per completed workflow when disabled** and **5% when enabled**. Measure at least -three independent matched repetitions and repeat the published baseline to -check drift. If noise cannot resolve a target, its acceptance remains unproven. +The original method retains **1% additional CPU per completed workflow when +disabled** and **5% when enabled**, three matched pairs and baseline drift. +The prospective corrected-source method uses the explicitly named 5%/15%, +five-pair profile above. If noise cannot resolve a target, acceptance remains unproven. Do not infer overhead from functional test duration or compare old measurements with a changed machine or workload. @@ -129,10 +157,10 @@ prefix and trace-parent seed, so keep it identical across matched cells. Its defaults are `ordinary` and `fanout`. Give warmup a separate `warmup-*` workload name to keep its original runs distinct from the measured offers. -| Workload | Members per workflow | Offered workflows/second | Offer window | -| --- | ---: | ---: | ---: | -| Ordinary activity | 1 | 1 | 120 seconds | -| Large fan-out | 64 | 1/60 | 120 seconds | +| Workload | Members per workflow | Offered workflows/second | Original window | Prospective v2 window | +| --- | ---: | ---: | ---: | ---: | +| Ordinary activity | 1 | 1 | 120 seconds | 300 seconds | +| Large fan-out | 64 | 1/60 | 120 seconds | 300 seconds | Defaults live in `workloads.json`. These rates are chosen prospectively after the guest functional controls, before matched overhead windows. They seek a @@ -304,7 +332,8 @@ missing samples, nonzero child exit, OOM/restart, unfinished business work or span loss. It normalizes CPU by measured completions, excluding warmup counts. Metadata, span and audit totals explicitly include warmup. -The declared offer window remains 120 seconds. Node timers use integer-delay +The declared offer window is 120 seconds for the original method and 300 for +the prospective v2 method. Node timers use integer-delay precision, so the probe's actual elapsed value can be fractionally below its nominal window. The reader preserves that value and requires every cgroup's counter interval to cover the full declared window, as well as all offers and @@ -315,9 +344,10 @@ inside the working directory. Each data file must resolve inside the selected experiment root, including symbolic links. This keeps input selection scoped to the data mounted for analysis. -The fixed statistical method uses paired relative differences and a -conventional two-sided 95% paired-t interval with three pairs, two degrees of -freedom and critical value 4.3026527299. This small sample relies on the usual +Both fixed methods use paired relative differences and a conventional two-sided +95% paired-t interval: the original three pairs use two degrees of freedom and +critical value 4.3026527299; v2's five pairs use four degrees of freedom and +2.7764451052. These small samples rely on the usual t-interval assumptions. A target passes only when both its interval upper bound and absolute final baseline drift meet the previously declared limit. Do not select extra repetitions after inspecting the result. diff --git a/.github/tracing-performance/analyze.mjs b/.github/tracing-performance/analyze.mjs index 206aede82..fddd214f5 100644 --- a/.github/tracing-performance/analyze.mjs +++ b/.github/tracing-performance/analyze.mjs @@ -6,8 +6,18 @@ import {pathToFileURL} from 'node:url'; const roles = {app: [1, 50000, 512], worker: [4, 100000, 384], mysql: [1, 100000, 2048], redis: [1, 10000, 128], collector: [1, 20000, 256], probe: [1, 20000, 256]}; -const repetitions = ['1', '2', '3']; -const critical = 4.3026527299; +const legacyMethod = 'embedded-120s-3pairs-v1'; +const methods = { + [legacyMethod]: {repetitions: ['1', '2', '3'], seconds: 120, critical: 4.3026527299, + targets: {off: 0.01, on: 0.05}, label: 'paired-t-95-n3-df2; targets and drift frozen before admission'}, + 'embedded-300s-5pairs-v2': {repetitions: ['1', '2', '3', '4', '5'], seconds: 300, critical: 2.7764451052, + targets: {off: 0.05, on: 0.15}, label: 'paired-t-95-n5-df4; prospective 5%/15% targets and drift'}, +}; + +function studyMethod(id = legacyMethod) { + assert.ok(Object.hasOwn(methods, id), 'Unknown study method'); + return methods[id]; +} function counters(text) { return Object.fromEntries(text.trim().split('\n').map(line => { @@ -47,12 +57,18 @@ function snapshot(rows) { function validateBusiness(data) { const {outcome, measure, warmup, audit} = data; + const method = studyMethod(outcome.study_method); assert.equal(outcome.result, 0, 'An unsuccessful original is not a CPU sample'); assert.equal(outcome.stage, 'complete'); assert.ok(['base', 'off', 'on'].includes(outcome.cell)); assert.ok(['ordinary', 'fanout'].includes(outcome.workload)); const ordinary = outcome.workload === 'ordinary'; - const expected = ordinary ? 120 : 2; + assert.ok(method.repetitions.includes(outcome.repeat) || (outcome.repeat === 'drift' && outcome.cell === 'base'), + 'Unexpected repetition'); + if (outcome.study_method === 'embedded-300s-5pairs-v2') { + assert.equal(outcome.collector, 'otelcol', 'The new method requires the official Collector'); + } + const expected = ordinary ? method.seconds : method.seconds / 60; const members = ordinary ? 1 : 64; for (const [probe, count] of [[measure, expected], [warmup, ordinary ? 30 : 1]]) { assert.equal(probe.offered_target, count); @@ -66,7 +82,7 @@ function validateBusiness(data) { assert.equal(probe.queue_driver, 'database'); assert.equal(probe.diagnostic_only, false); } - assert.equal(measure.duration_seconds, 120); + assert.equal(measure.duration_seconds, method.seconds); assert.equal(measure.rate, ordinary ? 1 : 1 / 60); const lastOfferSeconds = (expected - 1) / measure.rate; assert.ok(Number.isFinite(measure.elapsed_seconds) && measure.elapsed_seconds >= lastOfferSeconds); @@ -172,22 +188,35 @@ export function summarizeCell(data) { assert.ok(cpuSeconds > 0); return {...outcome, cpu_seconds: cpuSeconds, cpu_seconds_per_workflow: cpuSeconds / expected, php_cpu_seconds: components.app.cpu_seconds + components.worker.cpu_seconds, components, - completed_per_offer_second: expected / 120, completed_per_observed_second: expected / measure.elapsed_seconds, + completed_per_offer_second: expected / measure.duration_seconds, + completed_per_observed_second: expected / measure.elapsed_seconds, measured: measure, audit_including_warmup: audit, spans_including_warmup: telemetry, sample_interval_ms: {min: Math.min(...observedIntervals), max: Math.max(...observedIntervals), mean: observedIntervals.reduce((sum, value) => sum + value, 0) / observedIntervals.length}}; } -function interval(values) { - assert.equal(values.length, 3); - const mean = values.reduce((sum, value) => sum + value, 0) / 3; - const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / 2; - const margin = critical * Math.sqrt(variance / 3); +function interval(values, method) { + const count = method.repetitions.length; + assert.equal(values.length, count); + const mean = values.reduce((sum, value) => sum + value, 0) / count; + const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (count - 1); + const margin = method.critical * Math.sqrt(variance / count); return {mean, lower: mean - margin, upper: mean + margin}; } export function compare(cells) { - assert.equal(cells.length, 20, 'All twenty declared cells are required'); + const methodId = cells[0]?.study_method ?? legacyMethod; + const method = studyMethod(methodId); + for (const cell of cells) assert.equal(cell.study_method ?? legacyMethod, methodId, 'Mixed study methods'); + assert.equal(cells.length, 2 * (3 * method.repetitions.length + 1), + methodId === legacyMethod ? 'All twenty declared cells are required' : 'All thirty-two declared cells are required'); + if (methodId !== legacyMethod) { + assert.equal(cells[0].collector, 'otelcol', 'The new method requires the official Collector'); + for (const key of ['candidate', 'baseline']) { + assert.match(cells[0][key] ?? '', /^[a-f0-9]{40}$/, 'Missing exact source identity'); + for (const cell of cells) assert.equal(cell[key], cells[0][key], 'Mixed source identities'); + } + } const collectors = new Set(cells.map(cell => cell.collector ?? 'legacy-php')); assert.equal(collectors.size, 1, 'Collector profiles cannot be mixed'); assert.ok(['legacy-php', 'otelcol'].includes([...collectors][0]), 'Unknown collector profile'); @@ -202,13 +231,13 @@ export function compare(cells) { assert.ok(Number.isFinite(matches[0].cpu_seconds_per_workflow) && matches[0].cpu_seconds_per_workflow > 0); return matches[0].cpu_seconds_per_workflow; }; - const baseline = repetitions.map(repeat => select('base', repeat)); - const meanBase = baseline.reduce((sum, value) => sum + value, 0) / 3; + const baseline = method.repetitions.map(repeat => select('base', repeat)); + const meanBase = baseline.reduce((sum, value) => sum + value, 0) / baseline.length; const drift = select('base', 'drift') / meanBase - 1; result[workload] = {baseline_cpu_seconds_per_workflow: baseline, baseline_drift_ratio: drift}; - for (const [cell, target] of [['off', 0.01], ['on', 0.05]]) { - const paired = repetitions.map((repeat, index) => select(cell, repeat) / baseline[index] - 1); - const confidence = interval(paired); + for (const [cell, target] of Object.entries(method.targets)) { + const paired = method.repetitions.map((repeat, index) => select(cell, repeat) / baseline[index] - 1); + const confidence = interval(paired, method); result[workload][cell] = {paired_ratios: paired, confidence_95: confidence, target, qualified: confidence.upper <= target && Math.abs(drift) <= target}; } @@ -238,7 +267,7 @@ const jsonLines = async (root, file) => (await readFile(await dataPath(root, fil export async function loadCell(root, name) { root = await experimentRoot(root); - assert.match(name, /^tracing-measure-(base|off|on)-(ordinary|fanout)-(1|2|3|drift)$/); + assert.match(name, /^tracing-measure-(base|off|on)-(ordinary|fanout)-(1|2|3|4|5|drift)$/); const proof = await dataPath(root, path.join(root, 'proof', name)); const state = await dataPath(root, path.join(root, 'state', name)); const exitCodes = async directory => { @@ -249,12 +278,15 @@ export async function loadCell(root, name) { return Number(value); })); }; - return summarizeCell({outcome: await json(root, path.join(proof, 'outcome.json')), + const summary = summarizeCell({outcome: await json(root, path.join(proof, 'outcome.json')), measure: await json(root, path.join(proof, 'measure-result.json')), warmup: await json(root, path.join(proof, 'warmup-result.json')), audit: await json(root, path.join(proof, 'audit.json')), telemetry: await json(root, path.join(proof, 'telemetry.json')), before: await jsonLines(root, path.join(proof, 'before.jsonl')), after: await jsonLines(root, path.join(proof, 'after.jsonl')), samples: await jsonLines(root, path.join(proof, 'samples.jsonl')), exits: await jsonLines(root, path.join(proof, 'exits.jsonl')), workerExits: await exitCodes(path.join(state, 'runtime')), probeExits: await exitCodes(path.join(state, 'results'))}); + assert.equal(name, `tracing-measure-${summary.cell}-${summary.workload}-${summary.repeat}`, + 'Cell name and declared identity differ'); + return summary; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { @@ -264,6 +296,6 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) const names = (await readdir(await dataPath(root, path.join(root, 'proof')))) .filter(name => name.startsWith('tracing-measure-')); const cells = await Promise.all(names.map(name => loadCell(root, name))); - console.log(JSON.stringify({method: 'paired-t-95-n3-df2; targets and drift frozen before admission', + console.log(JSON.stringify({method: studyMethod(cells[0]?.study_method).label, comparison: compare(cells), cells}, null, 2)); } diff --git a/.github/tracing-performance/analyze.test.mjs b/.github/tracing-performance/analyze.test.mjs index 15ef58f5d..6b2dee6e8 100644 --- a/.github/tracing-performance/analyze.test.mjs +++ b/.github/tracing-performance/analyze.test.mjs @@ -18,6 +18,60 @@ function pairData() { return cells; } +function longPairData() { + const cells = pairData(); + for (const workload of ['ordinary', 'fanout']) { + for (const repeat of ['4', '5']) { + for (const cell of ['base', 'off', 'on']) { + cells.push({workload, repeat, cell, cpu_seconds_per_workflow: 10}); + } + } + } + return cells.map(cell => ({...cell, study_method: 'embedded-300s-5pairs-v2', collector: 'otelcol', + collector_image: COLLECTOR_IMAGE, candidate: 'a'.repeat(40), baseline: 'b'.repeat(40), + cpu_seconds_per_workflow: cell.cell === 'off' ? 10.2 : cell.cell === 'on' ? 11 : 10})); +} + +test('the prospective method requires all five pairs and uses its declared interval and targets', () => { + const cells = longPairData(); + const values = [10.8, 10.9, 11, 11.1, 11.2]; + for (const [index, repeat] of ['1', '2', '3', '4', '5'].entries()) { + cells.find(row => row.workload === 'ordinary' && row.cell === 'on' && row.repeat === repeat) + .cpu_seconds_per_workflow = values[index]; + } + const result = compare(cells).ordinary; + assert.equal(result.off.target, 0.05); + assert.equal(result.on.target, 0.15); + assert.ok(Math.abs(result.on.confidence_95.mean - 0.1) < 1e-12); + assert.ok(Math.abs(result.on.confidence_95.upper - (0.1 + 2.7764451052 * Math.sqrt(0.00025 / 5))) < 1e-12); + assert.equal(result.off.qualified, true); + assert.equal(result.on.qualified, true); + assert.throws(() => compare(cells.slice(1)), /thirty-two/); +}); + +for (const [name, mutate] of [ + ['mixed methods', rows => { delete rows[0].study_method; }], + ['unknown method', rows => { rows[0].study_method = 'invented'; }], + ['mixed candidate', rows => { rows[0].candidate = 'c'.repeat(40); }], + ['mixed baseline', rows => { rows[0].baseline = 'c'.repeat(40); }], + ['missing source', rows => { delete rows[0].candidate; }], + ['legacy Collector', rows => { delete rows[0].collector; }], + ['duplicate cell', rows => { rows[0] = {...rows[1]}; }], +]) { + test(`the prospective comparison refuses ${name}`, () => { + const rows = longPairData(); + mutate(rows); + assert.throws(() => compare(rows)); + }); +} + +test('the new limits do not relabel the historical comparison', () => { + const cells = pairData().map(cell => ({...cell, + cpu_seconds_per_workflow: cell.cell === 'off' ? 10.2 : cell.cell === 'on' ? 11 : 10})); + assert.equal(compare(cells).ordinary.off.qualified, false); + assert.equal(compare(cells).ordinary.on.qualified, false); +}); + function cellData() { const roles = [['app', 50000, 512], ...Array.from({length: 4}, () => ['worker', 100000, 384]), ['mysql', 100000, 2048], ['redis', 10000, 128], ['collector', 20000, 256], ['probe', 20000, 256]]; @@ -68,6 +122,55 @@ function officialCollectorData() { return data; } +function longCellData() { + const data = officialCollectorData(); + data.outcome.study_method = 'embedded-300s-5pairs-v2'; + Object.assign(data.measure, {offered_target: 300, offered: 300, accepted: 300, completed: 300, + duration_seconds: 300, elapsed_seconds: 300}); + Object.assign(data.audit, {runs: 330, activities: 330, callbacks: 330, tasks: 990}); + for (const key of Object.keys(data.audit.history_types)) data.audit.history_types[key] = 330; + for (const row of data.after) row.sampled_at = '2026-01-01T00:05:00.000Z'; + return data; +} + +test('the longer profile validates 300 measured runs and retains the original warmup and deadline', () => { + const result = summarizeCell(longCellData()); + assert.equal(result.measured.completed, 300); + assert.equal(result.audit_including_warmup.runs, 330); + assert.equal(result.completed_per_offer_second, 1); + assert.equal(result.measured.observer_seconds, 90); +}); + +test('the longer fan-out profile requires five measured 64-member workflows', () => { + const data = longCellData(); + data.outcome.workload = 'fanout'; + for (const [probe, count] of [[data.measure, 5], [data.warmup, 1]]) { + Object.assign(probe, {members: 64, rate: 1 / 60, offered_target: count, + offered: count, accepted: count, completed: count}); + } + Object.assign(data.audit, {runs: 6, activities: 384, callbacks: 384, tasks: 396}); + data.audit.history_types = {StartAccepted: 6, WorkflowStarted: 6, ActivityScheduled: 384, + ActivityStarted: 384, ActivityCompleted: 384, WorkflowCompleted: 6}; + assert.equal(summarizeCell(data).completed_per_offer_second, 1 / 60); + data.measure.members = 32; + assert.throws(() => summarizeCell(data)); +}); + +for (const [name, mutate] of [ + ['old window', data => { data.measure.duration_seconds = 120; }], + ['old counts', data => { data.measure.offered = 120; }], + ['extended observer', data => { data.measure.observer_seconds = 300; }], + ['missing pair marker', data => { delete data.outcome.study_method; }], + ['extra repetition', data => { data.outcome.repeat = '6'; }], + ['legacy fourth repetition', data => { delete data.outcome.study_method; data.outcome.repeat = '4'; }], +]) { + test(`the longer profile refuses ${name}`, () => { + const data = longCellData(); + mutate(data); + assert.throws(() => summarizeCell(data)); + }); +} + test('an initialized official collector can prove zero new spans without inventing missing metrics', () => { assert.equal(summarizeCell(officialCollectorData()).spans_including_warmup.received, 0); }); From a917dc6f86fc6e6719e4737e75da717d20815c29 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 18:47:50 +0000 Subject: [PATCH 75/77] Verify committed child outcome lookup and malformed observation refusal --- .../Unit/V2/ActivityExecutionTracingTest.php | 79 +++++++++++++++++++ tests/Unit/V2/TaskOutcomeCarrierTest.php | 30 +++++++ 2 files changed, 109 insertions(+) diff --git a/tests/Unit/V2/ActivityExecutionTracingTest.php b/tests/Unit/V2/ActivityExecutionTracingTest.php index 24f8d75c4..5e259c1a7 100644 --- a/tests/Unit/V2/ActivityExecutionTracingTest.php +++ b/tests/Unit/V2/ActivityExecutionTracingTest.php @@ -44,6 +44,7 @@ use Workflow\V2\Models\ActivityExecution; use Workflow\V2\Models\WorkflowCommand; use Workflow\V2\Models\WorkflowHistoryEvent; +use Workflow\V2\Models\WorkflowLink; use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Models\WorkflowSchedule; use Workflow\V2\Models\WorkflowTask; @@ -373,6 +374,76 @@ public function testAdmissionPersistsItsOriginalParentAndDuplicateCannotReplaceI self::assertSame(0, DB::table('failed_jobs')->count()); } + public function testCompletedChildOutcomeIsReadFromCommittedRowsAndNeverChangesDurableState(): void + { + config([ + 'workflows.tracing.enabled' => true, + ]); + $stub = WorkflowStub::make(ParentTracedWorkflow::class, 'child-outcome-readback', '0'); + $stub->start(); + for ($remaining = 12; $remaining > 0 && DB::table('jobs')->count() > 0; --$remaining) { + $this->workOnce(); + } + $parent = WorkflowRun::query()->findOrFail($stub->runId()); + self::assertSame('completed', $parent->status->value); + self::assertSame([9007199254740993, null, '雪'], $parent->workflowOutput()); + self::assertSame(0, DB::table('jobs')->count()); + self::assertSame(0, DB::table('failed_jobs')->count()); + $link = WorkflowLink::query()->where('parent_workflow_run_id', $parent->id)->sole(); + $child = WorkflowRun::query()->findOrFail($link->child_workflow_run_id); + self::assertSame('completed', $child->status->value); + $terminal = WorkflowHistoryEvent::query()->where('workflow_run_id', $child->id) + ->where('event_type', 'WorkflowCompleted') + ->sole(); + $childTask = WorkflowTask::query()->findOrFail($terminal->workflow_task_id); + $claim = WorkflowTask::query()->where('workflow_run_id', $parent->id)->latest('id')->firstOrFail(); + $entry = TaskOutcomeCarrier::fromTask($childTask)['outcomes'][0]; + $before = []; + foreach ([ + WorkflowRun::class, + WorkflowTask::class, + WorkflowHistoryEvent::class, + WorkflowLink::class, + ] as $model) { + $before[$model] = $model::query()->orderBy('id')->get()->map( + static fn ($row): array => $row->getRawOriginal(), + )->all(); + } + $outcomes = TaskOutcomeCarrier::forRun($parent, $claim); + self::assertSame($entry + [ + 'task_id' => $childTask->id, + 'child_run_id' => $child->id, + 'child_instance_id' => $child->workflow_instance_id, + 'child_namespace' => $child->namespace, + 'parent_run_id' => $parent->id, + 'parent_namespace' => $parent->namespace, + 'parent_sequence' => $link->sequence, + 'link_id' => $link->id, + ], $outcomes['child:' . $child->id]); + self::assertSame($terminal->id, $entry['event_id']); + foreach ($before as $model => $rows) { + self::assertSame($rows, $model::query()->orderBy('id')->get()->map( + static fn ($row): array => $row->getRawOriginal(), + )->all()); + } + + // A valid carrier bound to a different physical event is not evidence of this child's outcome. + $wrong = TaskOutcomeCarrier::fromTask($childTask); + $wrong['outcomes'][0]['sequence'] = (int) $terminal->sequence + 1; + DB::table($childTask->getTable())->where('id', $childTask->id)->update([ + 'observability_context' => json_encode($wrong, JSON_THROW_ON_ERROR), + ]); + $withoutChild = TaskOutcomeCarrier::forRun($parent, $claim); + self::assertArrayNotHasKey('child:' . $child->id, $withoutChild); + self::assertArrayHasKey( + $parent->historyEvents()->where('event_type', 'WorkflowCompleted')->sole()->id, + $withoutChild + ); + self::assertSame([9007199254740993, null, '雪'], $parent->fresh()->workflowOutput()); + self::assertSame([9007199254740993, null, '雪'], $child->fresh()->workflowOutput()); + self::assertSame(0, (int) WorkflowTask::query()->sum('repair_count')); + } + public function testOutcomeMetadataRollsBackWithItsEventAndDuplicateCannotReplaceIt(): void { config([ @@ -1571,6 +1642,14 @@ public function set(Model $model, string $key, mixed $value, array $attributes): } } +final class ParentTracedWorkflow extends Workflow +{ + public function handle(): array + { + return self::child(TracedWorkflow::class, false); + } +} + final class TracedWorkflow extends Workflow { public function handle(bool $retry): array diff --git a/tests/Unit/V2/TaskOutcomeCarrierTest.php b/tests/Unit/V2/TaskOutcomeCarrierTest.php index c947eac12..c8db9173e 100644 --- a/tests/Unit/V2/TaskOutcomeCarrierTest.php +++ b/tests/Unit/V2/TaskOutcomeCarrierTest.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Collection; use JsonSerializable; use OpenTelemetry\API\Instrumentation\Configurator; +use OpenTelemetry\API\Trace\Span; use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor; use OpenTelemetry\SDK\Trace\TracerProvider; @@ -20,6 +21,35 @@ final class TaskOutcomeCarrierTest extends TestCase { + public function testLegacyClaimAndUnrecordedSpanNeedNoObservationBackend(): void + { + $claim = self::task(); + $attributes = $claim->getAttributes(); + unset($attributes['observability_context']); + $claim->setRawAttributes($attributes, true); + self::assertSame([], TaskOutcomeCarrier::forRun(new WorkflowRun(), $claim)); + self::assertNull(TaskOutcomeCarrier::capture(Span::getInvalid())); + self::assertSame($attributes, $claim->getRawOriginal()); + } + + public function testOversizedAndInvalidStoredOutcomesAreIgnoredWithoutRewriting(): void + { + $oversized = str_repeat('x', 4097); + $task = self::task($oversized); + self::assertNull(TaskOutcomeCarrier::fromTask($task)); + self::assertSame($oversized, $task->getAttributes()['observability_context']); + $task = self::task(); + TaskOutcomeCarrier::append($task, [self::entry()]); + $invalid = TaskOutcomeCarrier::fromTask($task); + $invalid['outcomes'][0]['sequence'] = 0; + $stored = json_encode($invalid, JSON_THROW_ON_ERROR); + $task->setRawAttributes(array_replace($task->getAttributes(), [ + 'observability_context' => $stored, + ]), true); + self::assertNull(TaskOutcomeCarrier::fromTask($task)); + self::assertSame($stored, $task->getRawOriginal('observability_context')); + } + public function testDuplicateKeepsTheFirstStoredBytesAndContext(): void { $task = self::task(); From 3c5c51edcc500e06ff4779e3b085f04b7574ff29 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 20:02:55 +0000 Subject: [PATCH 76/77] Keep Collector container identities string-typed during qualification --- .github/tracing-performance/analyze.test.mjs | 8 ++++++++ .github/tracing-performance/collector-metrics.mjs | 3 ++- .github/tracing-performance/collector-otelcol.yaml | 3 ++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/tracing-performance/analyze.test.mjs b/.github/tracing-performance/analyze.test.mjs index 6b2dee6e8..9bef1c344 100644 --- a/.github/tracing-performance/analyze.test.mjs +++ b/.github/tracing-performance/analyze.test.mjs @@ -233,6 +233,14 @@ test('reads initialized official metrics and preserves an unavailable failure se assert.equal(collectorDelta({before: parsed, after: {...parsed, uptime_seconds: 2, accepted: 4, sent: 4}}), 3); }); +test('Collector string-prefixed numeric container identity retains exact CPU-role binding', () => { + const identity = '5439113202e9'; + const previous = officialCollectorData().telemetry.collector.before.container_prefix; + const text = collectorText().replaceAll(previous, `container-${identity}`); + assert.equal(parseCollectorMetrics(text).container_prefix, identity); + assert.throws(() => parseCollectorMetrics(text.replaceAll(`container-${identity}`, 'container-invalid'))); +}); + test('Collector numeric samples retain decimal and exponent forms', () => { for (const value of ['1.', '+1', '+1.0e0', '1E0']) { const text = collectorText().replaceAll('} 1', '} ' + value); diff --git a/.github/tracing-performance/collector-metrics.mjs b/.github/tracing-performance/collector-metrics.mjs index 7d8fdbe4d..bdf19520b 100644 --- a/.github/tracing-performance/collector-metrics.mjs +++ b/.github/tracing-performance/collector-metrics.mjs @@ -73,7 +73,8 @@ export function parseCollectorMetrics(text) { assert.ok(!seen.has(sample.field), 'Duplicate Collector counter series'); seen.add(sample.field); const {service_instance_id: instance, service_version: version} = sample.attributes; - const container = sample.attributes.qualification_container_id_prefix; + const containerLabel = sample.attributes.qualification_container_id_prefix; + const container = containerLabel?.startsWith('container-') ? containerLabel.slice(10) : containerLabel; assert.match(instance, /^[a-zA-Z0-9._-]{1,128}$/, 'Missing series instance'); assert.equal(version, COLLECTOR_VERSION, 'Missing/wrong series version'); assert.match(container, /^[a-f0-9]{12}$/, 'Missing series container identity'); diff --git a/.github/tracing-performance/collector-otelcol.yaml b/.github/tracing-performance/collector-otelcol.yaml index f5b126330..d6ba23f49 100644 --- a/.github/tracing-performance/collector-otelcol.yaml +++ b/.github/tracing-performance/collector-otelcol.yaml @@ -12,7 +12,8 @@ exporters: service: telemetry: resource: - qualification.container_id_prefix: ${env:HOSTNAME} + # A hexadecimal container ID may look numeric to the environment provider. + qualification.container_id_prefix: container-${env:HOSTNAME} logs: level: error metrics: From 19525e332734ae8d56d3621c2bfa2d7dcd42c38b Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 21:16:41 +0000 Subject: [PATCH 77/77] Clarify tracing fallback guards and prepare the coordinated minor --- composer.json | 2 +- src/V2/Jobs/RunWorkflowTask.php | 23 +++++++----- src/V2/Support/WorkflowContinuationCache.php | 6 +-- .../Support/WorkflowContinuationSession.php | 13 ++++--- .../V2/CancellationExecutionTracingTest.php | 9 ++++- tests/Unit/V2/EmbeddedContinuationTest.php | 37 +++++++++++++++++++ 6 files changed, 70 insertions(+), 20 deletions(-) diff --git a/composer.json b/composer.json index ed69be3f2..baf80eed8 100644 --- a/composer.json +++ b/composer.json @@ -87,7 +87,7 @@ "dev-main": "2.0.x-dev" }, "durable-workflow": { - "product-train": "2.8.0", + "product-train": "2.9.0", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" }, diff --git a/src/V2/Jobs/RunWorkflowTask.php b/src/V2/Jobs/RunWorkflowTask.php index b85811851..60deefecb 100644 --- a/src/V2/Jobs/RunWorkflowTask.php +++ b/src/V2/Jobs/RunWorkflowTask.php @@ -120,16 +120,7 @@ private function executeClaim(WorkflowExecutor $executor, int $claimedAttempt): ->findOrFail($task->workflow_run_id); if (in_array($run->status->value, ['completed', 'failed', 'cancelled', 'terminated'], true)) { - $task->forceFill([ - 'status' => $task->status === TaskStatus::Cancelled ? TaskStatus::Cancelled : ( - $run->status->value === 'failed' ? TaskStatus::Failed : TaskStatus::Completed - ), - 'lease_expires_at' => null, - ])->save(); - - $this->projectRun( - $run->fresh(['instance', 'tasks', 'activityExecutions', 'timers', 'failures']) - ); + $this->settleTerminalTask($task, $run); return null; } @@ -177,6 +168,18 @@ private function executeClaim(WorkflowExecutor $executor, int $claimedAttempt): } } + private function settleTerminalTask(WorkflowTask $task, WorkflowRun $run): void + { + $task->forceFill([ + 'status' => $task->status === TaskStatus::Cancelled ? TaskStatus::Cancelled : ( + $run->status->value === 'failed' ? TaskStatus::Failed : TaskStatus::Completed + ), + 'lease_expires_at' => null, + ])->save(); + + $this->projectRun($run->fresh(['instance', 'tasks', 'activityExecutions', 'timers', 'failures'])); + } + /** * @return array{0: WorkflowTask|null, 1: WorkflowTask|null, 2: int} */ diff --git a/src/V2/Support/WorkflowContinuationCache.php b/src/V2/Support/WorkflowContinuationCache.php index b134ae57c..6d6a22fb1 100644 --- a/src/V2/Support/WorkflowContinuationCache.php +++ b/src/V2/Support/WorkflowContinuationCache.php @@ -47,11 +47,11 @@ public function session(?object $job, int $claimedAttempt): ?WorkflowContinuatio { // Retained Fibers keep their entry context. Traced tasks need a fresh // Fiber until restoring the new task context on each resume is qualified. - if (ExecutionTracing::enabled()) { + $tracing = ExecutionTracing::enabled(); + if ($tracing) { $this->clear('evict_tracing'); - return null; } - if (! $job instanceof DatabaseJob || PHP_SAPI !== 'cli' || WorkflowStub::faked() + if ($tracing || ! $job instanceof DatabaseJob || PHP_SAPI !== 'cli' || WorkflowStub::faked() || config('workflows.v2.live_execution.workflows', []) === []) { return null; } diff --git a/src/V2/Support/WorkflowContinuationSession.php b/src/V2/Support/WorkflowContinuationSession.php index 173e28b9b..f91c03366 100644 --- a/src/V2/Support/WorkflowContinuationSession.php +++ b/src/V2/Support/WorkflowContinuationSession.php @@ -45,18 +45,21 @@ public function __construct( public function begin(WorkflowRun $run, WorkflowTask $task, string $class): ?WorkflowContinuation { $this->observe($run); - if (ExecutionTracing::enabled()) { + $tracing = ExecutionTracing::enabled(); + if ($tracing) { $this->cache->clear('evict_tracing'); - $this->disable('cold_tracing'); - return null; } $build = config('workflows.v2.live_execution.build'); - $eligible = is_string($build) && $build !== '' && $this->eligibleClass($class) + $eligible = ! $tracing && is_string($build) && $build !== '' && $this->eligibleClass($class) && $this->database === $run->getConnection() && $this->database->transactionLevel() === 1 && $this->hasAuthority($run, $task) && $this->safeHistory($run); $prefix = $eligible ? $this->prefix($run) : null; if ($prefix === null) { - $this->disable($eligible ? 'cold_history_cursor' : 'cold_profile'); + $this->disable(match (true) { + $tracing => 'cold_tracing', + $eligible => 'cold_history_cursor', + default => 'cold_profile', + }); return null; } $this->cache->count('validated_history_events', count($prefix)); diff --git a/tests/Unit/V2/CancellationExecutionTracingTest.php b/tests/Unit/V2/CancellationExecutionTracingTest.php index b433ab2b3..dfbfb65db 100644 --- a/tests/Unit/V2/CancellationExecutionTracingTest.php +++ b/tests/Unit/V2/CancellationExecutionTracingTest.php @@ -161,9 +161,16 @@ public function testCancellationKeepsOriginalContextAndUnwindsColdWorkerScopes( $requested['cancellation']['request_carrier'], $finished['cancellation']['request_carrier'] ); + $terminal = end($finished['cancellation']['history_payloads']); self::assertSame($cooperative ? [ 'cleanup' => [9007199254740993, null, '雪'], - ] : [], $finished['cancellation']['memo']); + ] : [], $finished['cancellation']['memo'], json_encode([ + 'cleanup_deadline' => $finished['cancellation']['deadline'], + 'terminal' => is_array($terminal) ? array_intersect_key($terminal, [ + 'reason' => true, + 'cancellation_cleanup' => true, + ]) : null, + ], JSON_THROW_ON_ERROR)); self::assertCount($cooperative ? 1 : 0, $finished['cancellation']['activities']); if ($cooperative) { self::assertSame('completed', $finished['cancellation']['activities'][0]['status']); diff --git a/tests/Unit/V2/EmbeddedContinuationTest.php b/tests/Unit/V2/EmbeddedContinuationTest.php index 49347f4b2..e84f667f0 100644 --- a/tests/Unit/V2/EmbeddedContinuationTest.php +++ b/tests/Unit/V2/EmbeddedContinuationTest.php @@ -222,6 +222,43 @@ public function testTracingEnabledAtCommitCannotPublishThePreviouslyUntracedFram $this->assertSame(1, ContinuationActivity::$calls); } + public function testTracingEnabledAfterSessionCreationStillStartsCold(): void + { + $this->enable(); + $armed = false; + $toggle = true; + WorkflowTask::updated(static function (WorkflowTask $task) use (&$armed, &$toggle): void { + if (! $armed && $task->status->value === 'leased') { + $armed = true; + DB::afterCommit(static function () use (&$toggle): void { + WorkflowRun::retrieved(static function () use (&$toggle): void { + if ($toggle) { + $toggle = false; + config() + ->set('workflows.tracing.enabled', true); + } + }); + }); + } + }); + $stub = WorkflowStub::make(ContinuationWorkflow::class, 'entry-toggle', 'continuation-test'); + $stub->start(); + $this->workOnce(); + $this->assertFalse($toggle); + $this->assertTrue(ExecutionTracing::enabled()); + $this->assertSame(0, app(WorkflowContinuationCache::class)->snapshot()['entries']); + config() + ->set('workflows.tracing.enabled', false); + $this->workOnce(); + $this->workOnce(); + Carbon::setTestNow(now()->addSeconds(2)); + $this->workOnce(); + $this->workOnce(); + $this->assertSame('completed', $stub->refresh()->status()); + $this->assertSame(2, ContinuationWorkflow::$entries); + $this->assertSame(1, ContinuationActivity::$calls); + } + public static function profiles(): array { return [