From 3fbc69ee1d569e352496825f44f85512364975ce Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:00:56 +0000 Subject: [PATCH 1/7] fix(tests): restore framework tables after schema scope changes --- tests/TestCase.php | 47 +++++++++++++++++ tests/Unit/TestDatabaseSchemaBoundaryTest.php | 51 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 tests/Unit/TestDatabaseSchemaBoundaryTest.php diff --git a/tests/TestCase.php b/tests/TestCase.php index d6d1b15f3..48957614d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -10,6 +10,7 @@ use Illuminate\Database\Schema\Builder as SchemaBuilder; use Illuminate\Filesystem\Filesystem; use Illuminate\Foundation\Testing\DatabaseTruncation; +use Illuminate\Foundation\Testing\RefreshDatabaseState; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Queue; @@ -64,6 +65,11 @@ abstract class TestCase extends BaseTestCase */ private static array $workers = []; + /** + * @var array}>|null + */ + private static ?array $truncationSchema = null; + /** * @var arraytruncationSchema()) { + RefreshDatabaseState::$migrated = false; + } + + if (! RefreshDatabaseState::$migrated) { + // A fresh migration also invalidates Laravel's per-connection table + // cache; retaining it can miss new tables or query removed ones. + static::$allTables = []; + } + } + protected function setUpDatabaseTruncation(): void { + self::$truncationSchema = $this->truncationSchema(); // Testbench's PendingCommand binds a mocked OutputStyle while it runs // migrations. Remove that per-command binding so application commands // invoked by a test can write to the output buffer supplied by Artisan. @@ -426,6 +449,30 @@ protected static function workerDiagnostics(): array return $diagnostics; } + /** + * @return array}> + */ + private function truncationSchema(): array + { + $schema = []; + foreach ($this->connectionsToTruncate() as $name) { + $connection = $this->app->make('db') + ->connection($name); + $tables = $connection->getSchemaBuilder() + ->getTableListing(); + sort($tables); + $schema[$connection->getName()] = [ + 'database' => $connection->getDatabaseName(), + 'prefix' => $connection->getTablePrefix(), + 'tables' => $tables, + ]; + } + + ksort($schema); + + return $schema; + } + private static function normalizeJsonObject(array $value): array { $normalized = array_map( diff --git a/tests/Unit/TestDatabaseSchemaBoundaryTest.php b/tests/Unit/TestDatabaseSchemaBoundaryTest.php new file mode 100644 index 000000000..81233ad30 --- /dev/null +++ b/tests/Unit/TestDatabaseSchemaBoundaryTest.php @@ -0,0 +1,51 @@ +insert([ + 'id' => 'schema-boundary-probe', + 'workflow_class' => self::class, + 'workflow_type' => 'test.schema-boundary', + 'run_count' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + self::assertFalse(Schema::hasTable('users')); + self::assertFalse(Schema::hasTable('jobs')); + self::assertSame(1, DB::table('workflow_instances')->count()); + } + + #[Depends('testAnotherMigrationScopeCanLeaveOnlyWorkflowTables')] + public function testNextTestRestoresTheWholeSchemaAndClearsCommittedRows(): void + { + foreach (['users', 'jobs', 'workflow_instances', 'workflow_runs'] as $table) { + self::assertTrue(Schema::hasTable($table), "Expected [{$table}] to be restored."); + self::assertSame(0, DB::table($table)->count(), "Expected [{$table}] to be empty."); + } + self::assertGreaterThan(0, DB::table('migrations')->count()); + } +} From 97731fa39668e1efff60ff8657f0c8c0c407a7f3 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:03:48 +0000 Subject: [PATCH 2/7] ci: refresh PostgreSQL shard weights from retained timings --- .github/feature-test-timings.json | 240 +++++++++++++++--------------- 1 file changed, 120 insertions(+), 120 deletions(-) diff --git a/.github/feature-test-timings.json b/.github/feature-test-timings.json index 3fe54fca9..651ed3442 100644 --- a/.github/feature-test-timings.json +++ b/.github/feature-test-timings.json @@ -156,158 +156,158 @@ "tests/Feature/V2/WaterlineEngineSourceMysqlReadinessTest.php": 0.111111 }, "postgresql": { - "tests/Feature/ActivityCompletionWakeTest.php": 2.026388, + "tests/Feature/ActivityCompletionWakeTest.php": 2.529449, "tests/Feature/ActivityDispatchBoundaryTest.php": 2.515037, "tests/Feature/AiWorkflowTest.php": 6.452186, - "tests/Feature/AsyncWorkflowTest.php": 0.829306, + "tests/Feature/AsyncWorkflowTest.php": 4.115086, "tests/Feature/AwaitWithTimeoutWorkflowTest.php": 13.551864, - "tests/Feature/AwaitWorkflowTest.php": 3.824984, - "tests/Feature/Base64WorkflowTest.php": 6.870856, - "tests/Feature/ChildWorkflowSignalingTest.php": 1.742559, - "tests/Feature/ConcurrentWorkflowTest.php": 0.416966, - "tests/Feature/ContinueAsNewWorkflowTest.php": 0.524386, - "tests/Feature/ContinuedSignalRetirementTest.php": 15.841058, - "tests/Feature/DatabaseTruncationTest.php": 0.494281, + "tests/Feature/AwaitWorkflowTest.php": 3.873056, + "tests/Feature/Base64WorkflowTest.php": 9.967882, + "tests/Feature/ChildWorkflowSignalingTest.php": 5.358504, + "tests/Feature/ConcurrentWorkflowTest.php": 0.489613, + "tests/Feature/ContinueAsNewWorkflowTest.php": 0.662055, + "tests/Feature/ContinuedSignalRetirementTest.php": 16.021818, + "tests/Feature/DatabaseTruncationTest.php": 0.55943, "tests/Feature/DispatchWorkflowInTransactionTest.php": 3.52357, - "tests/Feature/ExceptionLoggingReplayTest.php": 2.071905, - "tests/Feature/ExceptionWorkflowTest.php": 4.018654, - "tests/Feature/FailingWorkflowTest.php": 3.328663, - "tests/Feature/HeartbeatWorkflowTest.php": 10.451845, - "tests/Feature/MigrationTest.php": 8.944018, - "tests/Feature/NestedSignalRaceConditionTest.php": 3.575562, - "tests/Feature/ParentContinueAsNewChildWorkflowTest.php": 0.618604, - "tests/Feature/ParentWorkflowTest.php": 7.56673, - "tests/Feature/PendingContinuationTimerTest.php": 1.73139, - "tests/Feature/RaceConditionTest.php": 25.70657, + "tests/Feature/ExceptionLoggingReplayTest.php": 7.890764, + "tests/Feature/ExceptionWorkflowTest.php": 7.517667, + "tests/Feature/FailingWorkflowTest.php": 3.456438, + "tests/Feature/HeartbeatWorkflowTest.php": 10.511488, + "tests/Feature/MigrationTest.php": 11.699223, + "tests/Feature/NestedSignalRaceConditionTest.php": 4.464755, + "tests/Feature/ParentContinueAsNewChildWorkflowTest.php": 0.754034, + "tests/Feature/ParentWorkflowTest.php": 8.288772, + "tests/Feature/PendingContinuationTimerTest.php": 2.618792, + "tests/Feature/RaceConditionTest.php": 27.56608, "tests/Feature/RetriesWorkflowTest.php": 6.58394, - "tests/Feature/SagaChildWorkflowTest.php": 1.137772, + "tests/Feature/SagaChildWorkflowTest.php": 1.342347, "tests/Feature/SagaWorkflowTest.php": 1.887817, - "tests/Feature/SideEffectWorkflowTest.php": 0.472538, - "tests/Feature/SignalReplayTest.php": 21.575294, - "tests/Feature/StateChangedEventTest.php": 0.057861, + "tests/Feature/SideEffectWorkflowTest.php": 0.542267, + "tests/Feature/SignalReplayTest.php": 33.980343, + "tests/Feature/StateChangedEventTest.php": 0.070922, "tests/Feature/StateMachineWorkflowTest.php": 13.019252, - "tests/Feature/TimeoutWorkflowTest.php": 6.36286, - "tests/Feature/TimerWorkflowTest.php": 6.928675, - "tests/Feature/V2/TaskQueuePriorityFairnessTest.php": 0.906942, - "tests/Feature/V2/V2ActivityArgumentCodecTest.php": 7.472758, - "tests/Feature/V2/V2ActivityEntryArgumentsTest.php": 3.052142, - "tests/Feature/V2/V2ActivityExceptionCodecTest.php": 2.03129, - "tests/Feature/V2/V2ActivityLeaseRepairLockTest.php": 0.322502, - "tests/Feature/V2/V2ActivityMetadataProjectionTest.php": 0.151415, - "tests/Feature/V2/V2ActivityOptionsTest.php": 4.353198, - "tests/Feature/V2/V2ActivityOutcomeHistoryRoleTest.php": 13.428072, - "tests/Feature/V2/V2ActivityTaskBridgeTest.php": 20.23884, - "tests/Feature/V2/V2ActivityTimeoutTest.php": 6.128291, - "tests/Feature/V2/V2ArchiveWorkflowTest.php": 1.704112, - "tests/Feature/V2/V2AuthoredActivityRetryReplayTest.php": 2.171525, + "tests/Feature/TimeoutWorkflowTest.php": 6.450548, + "tests/Feature/TimerWorkflowTest.php": 10.369624, + "tests/Feature/V2/TaskQueuePriorityFairnessTest.php": 1.109565, + "tests/Feature/V2/V2ActivityArgumentCodecTest.php": 7.553102, + "tests/Feature/V2/V2ActivityEntryArgumentsTest.php": 3.504955, + "tests/Feature/V2/V2ActivityExceptionCodecTest.php": 2.537439, + "tests/Feature/V2/V2ActivityLeaseRepairLockTest.php": 0.824027, + "tests/Feature/V2/V2ActivityMetadataProjectionTest.php": 0.173025, + "tests/Feature/V2/V2ActivityOptionsTest.php": 5.745601, + "tests/Feature/V2/V2ActivityOutcomeHistoryRoleTest.php": 17.648521, + "tests/Feature/V2/V2ActivityTaskBridgeTest.php": 29.17335, + "tests/Feature/V2/V2ActivityTimeoutTest.php": 9.344849, + "tests/Feature/V2/V2ArchiveWorkflowTest.php": 2.234442, + "tests/Feature/V2/V2AuthoredActivityRetryReplayTest.php": 3.336993, "tests/Feature/V2/V2AvroAdapterCodecTest.php": 1.232987, - "tests/Feature/V2/V2AvroParitySuiteTest.php": 2.612987, - "tests/Feature/V2/V2AwaitWorkflowTest.php": 29.259942, + "tests/Feature/V2/V2AvroParitySuiteTest.php": 3.568755, + "tests/Feature/V2/V2AwaitWorkflowTest.php": 30.247965, "tests/Feature/V2/V2CallerSuppliedReservationSnapshotTest.php": 1, "tests/Feature/V2/V2CancellationCascadeViewTest.php": 18.593924, - "tests/Feature/V2/V2CancellationCleanupOutcomeTest.php": 7.12353, - "tests/Feature/V2/V2CancellationRequestConcurrencyTest.php": 1.708519, - "tests/Feature/V2/V2CancellationScopeDeliveryTest.php": 87.361209, - "tests/Feature/V2/V2CancellationScopeDescendantsTest.php": 155.651835, - "tests/Feature/V2/V2CancellationScopeHistoryTest.php": 17.110712, + "tests/Feature/V2/V2CancellationCleanupOutcomeTest.php": 8.205373, + "tests/Feature/V2/V2CancellationRequestConcurrencyTest.php": 2.661747, + "tests/Feature/V2/V2CancellationScopeDeliveryTest.php": 89.953995, + "tests/Feature/V2/V2CancellationScopeDescendantsTest.php": 187.832504, + "tests/Feature/V2/V2CancellationScopeHistoryTest.php": 17.375912, "tests/Feature/V2/V2CancellationScopeRequestsTest.php": 9.761648, - "tests/Feature/V2/V2ChildCancellationPolicyTest.php": 9.212526, - "tests/Feature/V2/V2ChildTerminalOutcomeTest.php": 4.071461, + "tests/Feature/V2/V2ChildCancellationPolicyTest.php": 11.999885, + "tests/Feature/V2/V2ChildTerminalOutcomeTest.php": 6.537606, "tests/Feature/V2/V2ChildWorkflowExternalOutputReplayTest.php": 0.166546, - "tests/Feature/V2/V2ChildWorkflowNamespaceProjectionTest.php": 0.244653, + "tests/Feature/V2/V2ChildWorkflowNamespaceProjectionTest.php": 0.245271, "tests/Feature/V2/V2CommandDeadlockRetryTest.php": 1, - "tests/Feature/V2/V2CompatibilityWorkflowTest.php": 8.051238, - "tests/Feature/V2/V2ConfiguredCoreModelsTest.php": 5.078147, - "tests/Feature/V2/V2ContinueAsNewMetadataTest.php": 4.584991, + "tests/Feature/V2/V2CompatibilityWorkflowTest.php": 9.253263, + "tests/Feature/V2/V2ConfiguredCoreModelsTest.php": 7.920337, + "tests/Feature/V2/V2ContinueAsNewMetadataTest.php": 5.263439, "tests/Feature/V2/V2CooperativeParentCloseTest.php": 11.84518, - "tests/Feature/V2/V2DefaultConnectionRecoveryTest.php": 0.892021, - "tests/Feature/V2/V2DependencyInjectionTest.php": 1.937956, - "tests/Feature/V2/V2DeterministicTimeReplayTest.php": 1.650429, - "tests/Feature/V2/V2DeterministicTimeTest.php": 0.949423, - "tests/Feature/V2/V2DuplicateStartPolicyTest.php": 2.842647, - "tests/Feature/V2/V2EmbeddedParallelChildConcurrencyTest.php": 7.173231, - "tests/Feature/V2/V2EmbeddedReplayRegressionCorpusTest.php": 57.044786, + "tests/Feature/V2/V2DefaultConnectionRecoveryTest.php": 1.085312, + "tests/Feature/V2/V2DependencyInjectionTest.php": 2.240485, + "tests/Feature/V2/V2DeterministicTimeReplayTest.php": 1.685011, + "tests/Feature/V2/V2DeterministicTimeTest.php": 1.527654, + "tests/Feature/V2/V2DuplicateStartPolicyTest.php": 4.364422, + "tests/Feature/V2/V2EmbeddedParallelChildConcurrencyTest.php": 12.157051, + "tests/Feature/V2/V2EmbeddedReplayRegressionCorpusTest.php": 63.741297, "tests/Feature/V2/V2EntryMethodTest.php": 1.775283, "tests/Feature/V2/V2FailedRunRedrivePlanTest.php": 0.927652, - "tests/Feature/V2/V2FailedRunRedriveTest.php": 154.555648, - "tests/Feature/V2/V2FailureMessageStorageMigrationTest.php": 1.189762, - "tests/Feature/V2/V2FinallyCleanupTest.php": 29.310307, - "tests/Feature/V2/V2GoldenHistoryReplayTest.php": 0.438675, - "tests/Feature/V2/V2HistoryRecordedAtTimezoneTest.php": 1.49179, - "tests/Feature/V2/V2HistoryTimelineTest.php": 24.935598, + "tests/Feature/V2/V2FailedRunRedriveTest.php": 194.395623, + "tests/Feature/V2/V2FailureMessageStorageMigrationTest.php": 1.332573, + "tests/Feature/V2/V2FinallyCleanupTest.php": 39.264448, + "tests/Feature/V2/V2GoldenHistoryReplayTest.php": 0.591279, + "tests/Feature/V2/V2HistoryRecordedAtTimezoneTest.php": 1.64462, + "tests/Feature/V2/V2HistoryTimelineTest.php": 33.22083, "tests/Feature/V2/V2LeasedSignalDeliveryTest.php": 3.232855, - "tests/Feature/V2/V2LegacyEventCompatibilityTest.php": 2.433334, - "tests/Feature/V2/V2LifecycleEventTest.php": 2.060007, - "tests/Feature/V2/V2LocalActivityTest.php": 3.682331, - "tests/Feature/V2/V2MemoUpsertTest.php": 5.213396, + "tests/Feature/V2/V2LegacyEventCompatibilityTest.php": 2.701171, + "tests/Feature/V2/V2LifecycleEventTest.php": 2.647315, + "tests/Feature/V2/V2LocalActivityTest.php": 4.161761, + "tests/Feature/V2/V2MemoUpsertTest.php": 5.228222, "tests/Feature/V2/V2MessageCursorContinueAsNewTest.php": 2.550752, - "tests/Feature/V2/V2MessageStreamAuthoringTest.php": 0.21162, - "tests/Feature/V2/V2MessageStreamReplayTest.php": 21.653911, - "tests/Feature/V2/V2NamespaceScopedLoadTest.php": 0.89254, - "tests/Feature/V2/V2NexusCallerDeliveryTest.php": 0.238693, + "tests/Feature/V2/V2MessageStreamAuthoringTest.php": 0.288304, + "tests/Feature/V2/V2MessageStreamReplayTest.php": 28.277207, + "tests/Feature/V2/V2NamespaceScopedLoadTest.php": 1.206635, + "tests/Feature/V2/V2NexusCallerDeliveryTest.php": 0.450994, "tests/Feature/V2/V2NonRetryableFailureTest.php": 4.108963, "tests/Feature/V2/V2OperatorMetricsTest.php": 19.277848, - "tests/Feature/V2/V2OperatorQueueVisibilityTest.php": 0.528625, + "tests/Feature/V2/V2OperatorQueueVisibilityTest.php": 0.814248, "tests/Feature/V2/V2ParentClosePolicyTest.php": 16.868698, - "tests/Feature/V2/V2PortableCancellationDeliveryTest.php": 45.06524, - "tests/Feature/V2/V2PortableLocalActivityCheckpointTest.php": 8.56786, + "tests/Feature/V2/V2PortableCancellationDeliveryTest.php": 60.26707, + "tests/Feature/V2/V2PortableLocalActivityCheckpointTest.php": 8.875992, "tests/Feature/V2/V2PortableLocalActivityCleanupTest.php": 21.941789, - "tests/Feature/V2/V2PortableLocalActivityControlTest.php": 16.248075, - "tests/Feature/V2/V2PortableLocalActivityOutcomeTest.php": 17.233391, - "tests/Feature/V2/V2PortableLocalActivityPreparationTest.php": 31.888981, + "tests/Feature/V2/V2PortableLocalActivityControlTest.php": 17.415491, + "tests/Feature/V2/V2PortableLocalActivityOutcomeTest.php": 20.720282, + "tests/Feature/V2/V2PortableLocalActivityPreparationTest.php": 33.133779, "tests/Feature/V2/V2PortableLocalActivityRecoveryTest.php": 10.650852, - "tests/Feature/V2/V2ProjectionTimestampTimezoneTest.php": 1.294764, - "tests/Feature/V2/V2PrunedHistoryExportTest.php": 1.596691, - "tests/Feature/V2/V2QueryWorkflowTest.php": 33.173211, + "tests/Feature/V2/V2ProjectionTimestampTimezoneTest.php": 1.728511, + "tests/Feature/V2/V2PrunedHistoryExportTest.php": 2.472738, + "tests/Feature/V2/V2QueryWorkflowTest.php": 40.47602, "tests/Feature/V2/V2ReplayDiffTest.php": 6.64434, - "tests/Feature/V2/V2ResumeTaskSchedulingTest.php": 2.989604, - "tests/Feature/V2/V2RunDetailViewTest.php": 56.570893, + "tests/Feature/V2/V2ResumeTaskSchedulingTest.php": 3.441385, + "tests/Feature/V2/V2RunDetailViewTest.php": 57.628946, "tests/Feature/V2/V2RunSummarySortKeyTest.php": 0.216123, - "tests/Feature/V2/V2RunTimerTaskHistoryRoleTest.php": 1.091628, - "tests/Feature/V2/V2SagaWorkflowTest.php": 7.203592, + "tests/Feature/V2/V2RunTimerTaskHistoryRoleTest.php": 1.193952, + "tests/Feature/V2/V2SagaWorkflowTest.php": 13.155393, "tests/Feature/V2/V2ScheduleNamespaceInstanceIdTest.php": 7.948329, - "tests/Feature/V2/V2ScheduleTest.php": 54.224884, - "tests/Feature/V2/V2ScheduleTickBurstTest.php": 16.32619, - "tests/Feature/V2/V2ScheduleTimezoneTest.php": 6.997584, + "tests/Feature/V2/V2ScheduleTest.php": 76.025593, + "tests/Feature/V2/V2ScheduleTickBurstTest.php": 21.382673, + "tests/Feature/V2/V2ScheduleTimezoneTest.php": 11.284269, "tests/Feature/V2/V2SchedulerDegradedModeTest.php": 2.51774, - "tests/Feature/V2/V2ScopedActivityCancellationTest.php": 35.460769, - "tests/Feature/V2/V2ScopedChildCancellationTest.php": 36.381158, - "tests/Feature/V2/V2ScopedLocalControlTest.php": 4.524745, - "tests/Feature/V2/V2ScopedTimerCancellationTest.php": 8.259559, - "tests/Feature/V2/V2ScopedWaitCancellationTest.php": 24.017865, - "tests/Feature/V2/V2SearchAttributeDatetimeRepairTest.php": 5.103543, - "tests/Feature/V2/V2SearchAttributeTest.php": 5.54853, - "tests/Feature/V2/V2ServiceModeParallelChildBarrierTest.php": 4.550518, - "tests/Feature/V2/V2ServiceResponseReplayTest.php": 4.687719, - "tests/Feature/V2/V2SideEffectWorkflowTest.php": 4.956409, - "tests/Feature/V2/V2StandaloneActivityHostTest.php": 23.732433, - "tests/Feature/V2/V2StartNamespaceTest.php": 2.236521, - "tests/Feature/V2/V2StartSearchAttributeTest.php": 3.591054, - "tests/Feature/V2/V2StructuralLimitTest.php": 17.06471, + "tests/Feature/V2/V2ScopedActivityCancellationTest.php": 40.118812, + "tests/Feature/V2/V2ScopedChildCancellationTest.php": 36.749277, + "tests/Feature/V2/V2ScopedLocalControlTest.php": 5.597546, + "tests/Feature/V2/V2ScopedTimerCancellationTest.php": 14.677299, + "tests/Feature/V2/V2ScopedWaitCancellationTest.php": 26.069812, + "tests/Feature/V2/V2SearchAttributeDatetimeRepairTest.php": 5.63362, + "tests/Feature/V2/V2SearchAttributeTest.php": 7.257545, + "tests/Feature/V2/V2ServiceModeParallelChildBarrierTest.php": 6.017454, + "tests/Feature/V2/V2ServiceResponseReplayTest.php": 5.500206, + "tests/Feature/V2/V2SideEffectWorkflowTest.php": 5.557043, + "tests/Feature/V2/V2StandaloneActivityHostTest.php": 26.776451, + "tests/Feature/V2/V2StartNamespaceTest.php": 2.610987, + "tests/Feature/V2/V2StartSearchAttributeTest.php": 3.82006, + "tests/Feature/V2/V2StructuralLimitTest.php": 23.440594, "tests/Feature/V2/V2TaskDispatchHistoryRoleTest.php": 0.86386, - "tests/Feature/V2/V2TaskDispatchTest.php": 8.578854, - "tests/Feature/V2/V2TaskWatchdogRepairConcurrencyTest.php": 2.086296, - "tests/Feature/V2/V2TerminalReasonDiagnosticsTest.php": 1.487169, - "tests/Feature/V2/V2TimerWorkflowTest.php": 6.195278, - "tests/Feature/V2/V2UpdateTailCheckpointTest.php": 4.606148, - "tests/Feature/V2/V2UpdateWorkflowTest.php": 45.113583, - "tests/Feature/V2/V2UuidWorkflowTest.php": 0.49641, - "tests/Feature/V2/V2VersionWorkflowTest.php": 5.795418, - "tests/Feature/V2/V2WebhookPollAndControlPlaneTest.php": 1.59608, - "tests/Feature/V2/V2WebhookWorkflowTest.php": 55.536225, + "tests/Feature/V2/V2TaskDispatchTest.php": 9.865985, + "tests/Feature/V2/V2TaskWatchdogRepairConcurrencyTest.php": 2.15653, + "tests/Feature/V2/V2TerminalReasonDiagnosticsTest.php": 1.908443, + "tests/Feature/V2/V2TimerWorkflowTest.php": 10.076926, + "tests/Feature/V2/V2UpdateTailCheckpointTest.php": 6.426899, + "tests/Feature/V2/V2UpdateWorkflowTest.php": 52.255852, + "tests/Feature/V2/V2UuidWorkflowTest.php": 0.614381, + "tests/Feature/V2/V2VersionWorkflowTest.php": 9.443393, + "tests/Feature/V2/V2WebhookPollAndControlPlaneTest.php": 1.801798, + "tests/Feature/V2/V2WebhookWorkflowTest.php": 62.290421, "tests/Feature/V2/V2WorkflowControlPlaneTest.php": 13.861575, - "tests/Feature/V2/V2WorkflowEntryArgumentsTest.php": 6.493541, + "tests/Feature/V2/V2WorkflowEntryArgumentsTest.php": 7.697432, "tests/Feature/V2/V2WorkflowReplayerTest.php": 5.004639, - "tests/Feature/V2/V2WorkflowRunRetentionCleanupTest.php": 4.356015, + "tests/Feature/V2/V2WorkflowRunRetentionCleanupTest.php": 6.962748, "tests/Feature/V2/V2WorkflowStubContractTest.php": 0.545134, - "tests/Feature/V2/V2WorkflowStubFakeTest.php": 4.698474, - "tests/Feature/V2/V2WorkflowTaskBridgeTest.php": 92.067556, + "tests/Feature/V2/V2WorkflowStubFakeTest.php": 9.882591, + "tests/Feature/V2/V2WorkflowTaskBridgeTest.php": 135.179811, "tests/Feature/V2/V2WorkflowTest.php": 132.504356, - "tests/Feature/V2/V2WorkflowTimeoutTest.php": 5.951249, - "tests/Feature/V2/V2WorkflowVersionPinningTest.php": 0.462265, - "tests/Feature/V2/WaterlineEngineSourceMysqlReadinessTest.php": 0.07153, - "tests/Feature/VersionWorkflowTest.php": 2.874045, - "tests/Feature/WebhookWorkflowTest.php": 2.240674, + "tests/Feature/V2/V2WorkflowTimeoutTest.php": 6.469266, + "tests/Feature/V2/V2WorkflowVersionPinningTest.php": 0.864909, + "tests/Feature/V2/WaterlineEngineSourceMysqlReadinessTest.php": 0.106905, + "tests/Feature/VersionWorkflowTest.php": 9.113917, + "tests/Feature/WebhookWorkflowTest.php": 8.391191, "tests/Feature/WorkflowPollingTest.php": 18.190797, "tests/Feature/WorkflowTest.php": 9.056121 } From 23c8fde91318df4327e62008f08c5e268d8c0166 Mon Sep 17 00:00:00 2001 From: loosand Date: Thu, 5 Nov 2026 18:06:22 +0800 Subject: [PATCH 3/7] Avoid signal gap locks in owned embedded workflow task transactions --- src/V2/Jobs/RunWorkflowTask.php | 6 +- src/V2/Support/ContinuedRunMessageHandoff.php | 20 +- src/V2/Support/PendingSignalCloser.php | 20 +- src/V2/Support/WorkflowSignalReadScope.php | 60 ++++ .../V2/WorkflowTaskSignalGapLockTest.php | 323 ++++++++++++++++++ .../V2/TestSignalContinuationWorkflow.php | 21 ++ tests/Unit/V2/WorkflowSignalReadScopeTest.php | 130 +++++++ 7 files changed, 571 insertions(+), 9 deletions(-) create mode 100644 src/V2/Support/WorkflowSignalReadScope.php create mode 100644 tests/Feature/V2/WorkflowTaskSignalGapLockTest.php create mode 100644 tests/Fixtures/V2/TestSignalContinuationWorkflow.php create mode 100644 tests/Unit/V2/WorkflowSignalReadScopeTest.php diff --git a/src/V2/Jobs/RunWorkflowTask.php b/src/V2/Jobs/RunWorkflowTask.php index 60deefecb..f4f6837b2 100644 --- a/src/V2/Jobs/RunWorkflowTask.php +++ b/src/V2/Jobs/RunWorkflowTask.php @@ -29,6 +29,7 @@ use Workflow\V2\Support\WorkerCompatibilityFleet; use Workflow\V2\Support\WorkflowContinuationCache; use Workflow\V2\Support\WorkflowExecutor; +use Workflow\V2\Support\WorkflowSignalReadScope; use Workflow\V2\Support\WorkflowTaskLease; final class RunWorkflowTask implements ShouldQueue @@ -130,7 +131,10 @@ private function executeClaim(WorkflowExecutor $executor, int $claimedAttempt): return null; } - $nextTask = $executor->run($run, $task, $continuation); + $nextTask = WorkflowSignalReadScope::forLockedRun( + $run, + static fn (): ?WorkflowTask => $executor->run($run, $task, $continuation), + ); $continuation?->offer($task); return $nextTask; diff --git a/src/V2/Support/ContinuedRunMessageHandoff.php b/src/V2/Support/ContinuedRunMessageHandoff.php index e943d86f1..94ab4882e 100644 --- a/src/V2/Support/ContinuedRunMessageHandoff.php +++ b/src/V2/Support/ContinuedRunMessageHandoff.php @@ -47,13 +47,25 @@ public static function closePredecessor( public static function transferInstanceScoped(WorkflowRun $closingRun, WorkflowRun $continuedRun): void { - $signals = ConfiguredV2Models::query('signal_model', WorkflowSignal::class) + $signalQuery = ConfiguredV2Models::query('signal_model', WorkflowSignal::class) ->where('workflow_run_id', $closingRun->id) ->where('target_scope', 'instance') ->where('status', SignalStatus::Received->value) - ->whereNull('closed_at') - ->lockForUpdate() - ->get(); + ->whereNull('closed_at'); + if (WorkflowSignalReadScope::allowsConsistentRead($closingRun, $signalQuery->getModel()->getConnection())) { + // Lock exact candidates without reserving neighboring index gaps. + $signals = $signalQuery->get() + ->map(static function (WorkflowSignal $candidate) use ($signalQuery): ?WorkflowSignal { + return (clone $signalQuery)->whereKey($candidate->getKey()) + ->lockForUpdate() + ->first(); + })->filter(); + } else { + // Outside the owned task, the caller may already have an older read view. + // Preserve current reads for every other execution path. + $signals = $signalQuery->lockForUpdate() + ->get(); + } if ($signals->isNotEmpty()) { $declaredSignals = RunCommandContract::forRun($closingRun)['signals']; $signals = $signals->filter( diff --git a/src/V2/Support/PendingSignalCloser.php b/src/V2/Support/PendingSignalCloser.php index dd2c85476..2cd771d9c 100644 --- a/src/V2/Support/PendingSignalCloser.php +++ b/src/V2/Support/PendingSignalCloser.php @@ -30,15 +30,27 @@ public static function closeForTerminalEvent( return; } - $signals = ConfiguredV2Models::query('signal_model', WorkflowSignal::class) + $signalQuery = ConfiguredV2Models::query('signal_model', WorkflowSignal::class) ->where('workflow_run_id', $run->id) ->where('status', SignalStatus::Received->value) ->orderByRaw('CASE WHEN command_sequence IS NULL THEN 1 ELSE 0 END') ->orderBy('command_sequence') ->orderBy('received_at') - ->orderBy('id') - ->lockForUpdate() - ->get(); + ->orderBy('id'); + if (WorkflowSignalReadScope::allowsConsistentRead($run, $signalQuery->getModel()->getConnection())) { + // Lock exact candidates without reserving neighboring index gaps. + $signals = $signalQuery->get() + ->map(static function (WorkflowSignal $candidate) use ($signalQuery): ?WorkflowSignal { + return (clone $signalQuery)->whereKey($candidate->getKey()) + ->lockForUpdate() + ->first(); + })->filter(); + } else { + // Outside the owned task, the caller may already have an older read view. + // Preserve current reads for every other execution path. + $signals = $signalQuery->lockForUpdate() + ->get(); + } foreach ($signals as $signal) { if (! $signal instanceof WorkflowSignal) { diff --git a/src/V2/Support/WorkflowSignalReadScope.php b/src/V2/Support/WorkflowSignalReadScope.php new file mode 100644 index 000000000..74198b921 --- /dev/null +++ b/src/V2/Support/WorkflowSignalReadScope.php @@ -0,0 +1,60 @@ +|null + */ + private static ?WeakMap $scopes = null; + + /** + * @template T + * + * @param Closure(): T $operation + * @return T + */ + public static function forLockedRun(WorkflowRun $run, Closure $operation): mixed + { + $connection = $run->getConnection(); + if ($connection->getDriverName() !== 'mysql' || $connection->transactionLevel() !== 1) { + return $operation(); + } + + self::$scopes ??= new WeakMap(); + $previous = self::$scopes[$connection] ?? null; + self::$scopes[$connection] = [ + 'pdo' => $connection->getPdo(), + 'run_id' => $run->id, + ]; + try { + return $operation(); + } finally { + if ($previous === null) { + unset(self::$scopes[$connection]); + } else { + self::$scopes[$connection] = $previous; + } + } + } + + public static function allowsConsistentRead(WorkflowRun $run, Connection $connection): bool + { + $scope = self::$scopes[$connection] ?? null; + + return $scope !== null + && $connection->transactionLevel() > 0 + && $scope['pdo'] === $connection->getRawPdo() + && $scope['run_id'] === $run->id; + } +} diff --git a/tests/Feature/V2/WorkflowTaskSignalGapLockTest.php b/tests/Feature/V2/WorkflowTaskSignalGapLockTest.php new file mode 100644 index 000000000..bdc8a6af9 --- /dev/null +++ b/tests/Feature/V2/WorkflowTaskSignalGapLockTest.php @@ -0,0 +1,323 @@ + 'poll', + 'workflows.storage.transaction_attempts' => 1, + 'cache.default' => 'array', + ]); + if (DB::connection()->getDriverName() !== 'mysql' + || str_contains(strtolower((string) DB::selectOne('SELECT VERSION() AS version')->version), 'mariadb')) { + $this->markTestSkipped('MySQL 8 InnoDB REPEATABLE READ is required.'); + } + if (! function_exists('pcntl_fork') || ! function_exists('posix_kill') + || ! function_exists('stream_socket_pair')) { + $this->markTestSkipped('Process control and local sockets are required.'); + } + DB::statement('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ'); + DB::statement('SET SESSION innodb_lock_wait_timeout = 5'); + } + + public static function races(): iterable + { + foreach ([false, true] as $named) { + foreach (['history', 'task'] as $variant) { + yield ($named ? 'named' : 'default') . '/' . $variant => [$variant, $named]; + } + } + } + + #[DataProvider('races')] + public function testContinueAsNewDoesNotDeadlockWithAnUnrelatedSignal(string $variant, bool $named): void + { + $this->configureStorage($named); + $runs = []; + // A < B < C bounds A's signal gap with C. For the successor-task + // variant, A < D < B bounds B's history gap with D instead. + foreach ($variant === 'task' ? ['a', 'd', 'b'] : ['a', 'b', 'c'] as $name) { + $stub = WorkflowStub::make(TestSignalContinuationWorkflow::class); + $stub->start(0); + $this->executeReadyTask($stub->runId()); + $runs[$name] = $stub; + } + foreach ($variant === 'task' ? ['a'] : ['a', 'c'] as $name) { + $this->assertTrue($runs[$name]->signal('resume')->accepted()); + } + $task = WorkflowTask::query()->where('workflow_run_id', $runs['a']->runId()) + ->where('status', TaskStatus::Ready->value)->sole(); + $sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + $this->assertIsArray($sockets); + $connectionName = $this->storage() + ->getName(); + foreach (array_keys(DB::getConnections()) as $openConnection) { + DB::purge($openConnection); + } + $pid = pcntl_fork(); + $this->assertNotSame(-1, $pid); + if ($pid === 0) { + fclose($sockets[0]); + $this->signalActor($sockets[1], $runs['b']->id(), $runs['b']->runId()); + } + fclose($sockets[1]); + stream_set_timeout($sockets[0], 15); + $reaped = false; + $armed = true; + try { + DB::listen(function (QueryExecuted $query) use (&$armed, $runs, $sockets): void { + if (! $armed || ! str_starts_with($query->sql, 'update `workflow_runs` set `status`') + || ! in_array('continued', $query->bindings, true) + || ! in_array($runs['a']->runId(), $query->bindings, true)) { + return; + } + $armed = false; + fwrite($sockets[0], "continue-ready\n"); + $this->assertSame("history-locked\n", fgets($sockets[0])); + fwrite($sockets[0], "proceed\n"); + }); + $error = null; + try { + app()->call([new RunWorkflowTask($task->id), 'handle']); + } catch (Throwable $exception) { + $error = $exception; + } + $line = fgets($sockets[0]); + $this->assertIsString($line, 'The signal actor did not finish.'); + $signal = json_decode($line, true, flags: JSON_THROW_ON_ERROR); + pcntl_waitpid($pid, $status); + $reaped = true; + $this->assertTrue(pcntl_wifexited($status)); + $this->assertSame(0, pcntl_wexitstatus($status)); + $this->assertNull($error, $error === null ? '' : $this->describeError($error)); + $this->assertTrue($signal['accepted'] ?? false, json_encode($signal, JSON_THROW_ON_ERROR)); + $this->assertSame(1, $signal['attempts'], 'Signal admission must not hide the cycle with a retry.'); + $this->assertFalse($armed, 'The terminal-turn barrier was not reached.'); + $oldRun = WorkflowRun::query()->findOrFail($runs['a']->runId()); + $this->assertSame('continued', $oldRun->closed_reason); + $this->assertSame(TaskStatus::Completed, $task->fresh()->status); + $this->assertSame(2, WorkflowRun::query()->where('workflow_instance_id', $runs['a']->id())->count()); + $this->assertSame(1, $oldRun->historyEvents() + ->where('event_type', HistoryEventType::WorkflowContinuedAsNew->value)->count()); + $this->assertSame(1, WorkflowSignal::query()->where('workflow_run_id', $runs['b']->runId())->count()); + $this->assertSame(0, $this->storage()->transactionLevel()); + } finally { + $armed = false; + if (! $reaped) { + posix_kill($pid, SIGKILL); + pcntl_waitpid($pid, $status); + } + fclose($sockets[0]); + DB::purge($connectionName); + } + } + + public static function callerTransactions(): iterable + { + foreach ([false, true] as $named) { + foreach (['cancel', 'continue', 'record', 'failed-task'] as $mode) { + yield ($named ? 'named' : 'default') . '/' . $mode => [$mode, $named]; + } + } + } + + #[DataProvider('callerTransactions')] + public function testCallerReadViewDoesNotHideAnAcceptedSignal(string $mode, bool $named): void + { + $this->configureStorage($named); + if ($mode === 'continue') { + $stub = WorkflowStub::make(TestConfiguredContinueSignalWorkflow::class); + $stub->start(0); + } else { + $stub = WorkflowStub::make(TestSignalContinuationWorkflow::class); + $stub->start(0); + $this->executeReadyTask($stub->runId()); + } + if ($mode === 'failed-task') { + $this->failContinuation($stub); + } + $runId = $stub->runId(); + $primary = $this->storage(); + $storage = config('workflows.storage.connection'); + $writer = $primary->getConfig(); + unset($writer['name']); + $writer['url'] = null; + config([ + 'database.connections.signal_gap_writer' => $writer, + ]); + $primary->beginTransaction(); + try { + $primary->table('workflow_signal_records') + ->where('workflow_run_id', $runId) + ->count(); + config([ + 'workflows.storage.connection' => 'signal_gap_writer', + ]); + $receipt = $mode === 'continue' + ? WorkflowStub::load($stub->id())->signal('name-provided', 'late') + : WorkflowStub::load($stub->id())->signal('resume'); + $signalId = WorkflowSignal::query()->where('workflow_command_id', $receipt->commandId())->sole()->id; + config([ + 'workflows.storage.connection' => $storage, + ]); + if ($mode === 'continue') { + $this->executeReadyTask($runId); + } elseif ($mode === 'cancel') { + WorkflowStub::load($stub->id())->cancel('caller transaction'); + } else { + // record() itself adds a transaction level. Depth alone is + // insufficient to establish ownership of the caller's read view. + $run = WorkflowRun::query()->whereKey($runId)->lockForUpdate()->firstOrFail(); + $run->forceFill([ + 'status' => RunStatus::Cancelled, + 'closed_reason' => 'cancelled', + 'closed_at' => now(), + ])->save(); + WorkflowHistoryEvent::record($run, HistoryEventType::WorkflowCancelled); + } + $signal = WorkflowSignal::query()->whereKey($signalId)->lockForUpdate()->firstOrFail(); + $this->assertTrue($receipt->accepted()); + $this->assertSame($mode === 'continue' ? SignalStatus::Received : SignalStatus::Rejected, $signal->status); + if ($mode === 'continue') { + $this->assertNotSame($runId, $signal->workflow_run_id); + } + } finally { + config([ + 'workflows.storage.connection' => $storage, + ]); + if ($primary->transactionLevel() > 0) { + $primary->rollBack(); + } + DB::purge('signal_gap_writer'); + } + } + + private function configureStorage(bool $named): void + { + if ($named) { + $config = DB::connection()->getConfig(); + unset($config['name']); + $config['url'] = null; + config([ + 'database.connections.signal_gap_store' => $config, + 'workflows.storage.connection' => 'signal_gap_store', + ]); + } + $this->storage() + ->statement('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ'); + $this->storage() + ->statement('SET SESSION innodb_lock_wait_timeout = 5'); + } + + private function storage(): Connection + { + return DB::connection(config('workflows.storage.connection')); + } + + private function executeReadyTask(string $runId): void + { + $task = WorkflowTask::query()->where('workflow_run_id', $runId) + ->where('status', TaskStatus::Ready->value)->sole(); + app() + ->call([new RunWorkflowTask($task->id), 'handle']); + } + + private function failContinuation(WorkflowStub $stub): void + { + $stub->signal('resume'); + $armed = true; + DB::listen(static function (QueryExecuted $query) use (&$armed, $stub): void { + if ($armed && str_starts_with($query->sql, 'update `workflow_runs` set `status`') + && in_array('continued', $query->bindings, true) + && in_array($stub->runId(), $query->bindings, true)) { + throw new RuntimeException('terminal-turn rollback control'); + } + }); + try { + $this->executeReadyTask($stub->runId()); + $this->fail('The rollback control did not execute.'); + } catch (RuntimeException $error) { + $this->assertSame('terminal-turn rollback control', $error->getMessage()); + } finally { + $armed = false; + } + $this->assertSame(0, $this->storage()->transactionLevel()); + } + + /** + * @param resource $socket + */ + private function signalActor($socket, string $instanceId, string $runId): never + { + stream_set_timeout($socket, 15); + $attempts = 0; + try { + if (fgets($socket) !== "continue-ready\n") { + throw new RuntimeException('The continuation did not reach its terminal turn.'); + } + $this->storage() + ->statement('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ'); + $this->storage() + ->statement('SET SESSION innodb_lock_wait_timeout = 5'); + DB::listen(static function (QueryExecuted $query) use ($socket, $runId, &$attempts): void { + if (str_contains($query->sql, 'from `workflow_history_events`') + && str_contains($query->sql, 'for update') && in_array($runId, $query->bindings, true)) { + $attempts++; + fwrite($socket, "history-locked\n"); + if (fgets($socket) !== "proceed\n") { + throw new RuntimeException('The continuation did not release the barrier.'); + } + } + }); + $receipt = WorkflowStub::load($instanceId)->signal('resume'); + $result = [ + 'accepted' => $receipt->accepted(), + 'attempts' => $attempts, + ]; + } catch (Throwable $error) { + $result = [ + 'accepted' => false, + 'attempts' => $attempts, + 'error' => $this->describeError($error), + ]; + } + fwrite($socket, json_encode($result, JSON_THROW_ON_ERROR) . PHP_EOL); + DB::disconnect($this->storage()->getName()); + fclose($socket); + exit(0); + } + + private function describeError(Throwable $error): string + { + return $error::class . ': ' . $error->getMessage(); + } +} diff --git a/tests/Fixtures/V2/TestSignalContinuationWorkflow.php b/tests/Fixtures/V2/TestSignalContinuationWorkflow.php new file mode 100644 index 000000000..4cc0f0241 --- /dev/null +++ b/tests/Fixtures/V2/TestSignalContinuationWorkflow.php @@ -0,0 +1,21 @@ + ['mysql', 1, true]; + yield 'no transaction' => ['mysql', 0, false]; + yield 'caller transaction' => ['mysql', 2, false]; + yield 'other driver' => ['sqlite', 1, false]; + } + + #[DataProvider('entryConditions')] + public function testOnlyTheOwnedMysqlRootTransactionOpensAScope(string $driver, int $depth, bool $allowed): void + { + $connection = $this->connection($driver); + $run = $this->makeRun($connection, 'one'); + for ($i = 0; $i < $depth; $i++) { + $connection->beginTransaction(); + } + try { + $this->assertFalse(WorkflowSignalReadScope::allowsConsistentRead($run, $connection)); + $result = WorkflowSignalReadScope::forLockedRun($run, function () use ( + $run, + $connection, + $allowed + ): string { + $this->assertSame($allowed, WorkflowSignalReadScope::allowsConsistentRead($run, $connection)); + return 'returned'; + }); + $this->assertSame('returned', $result); + $this->assertFalse(WorkflowSignalReadScope::allowsConsistentRead($run, $connection)); + } finally { + if ($connection->transactionLevel() > 0) { + $connection->rollBack(0); + } + } + } + + public function testScopeRequiresTheSameRunConnectionAndPdo(): void + { + $connection = $this->connection(); + $other = $this->connection(); + $run = $this->makeRun($connection, 'one'); + $connection->beginTransaction(); + try { + WorkflowSignalReadScope::forLockedRun($run, function () use ($connection, $other, $run): void { + $this->assertTrue(WorkflowSignalReadScope::allowsConsistentRead($run, $connection)); + $this->assertFalse( + WorkflowSignalReadScope::allowsConsistentRead($this->makeRun($connection, 'two'), $connection) + ); + $this->assertFalse(WorkflowSignalReadScope::allowsConsistentRead($run, $other)); + $pdo = $connection->getPdo(); + try { + $connection->setPdo(new PDO('sqlite::memory:')); + $connection->beginTransaction(); + $this->assertFalse(WorkflowSignalReadScope::allowsConsistentRead($run, $connection)); + $connection->rollBack(); + } finally { + $connection->setPdo($pdo); + } + // setPdo resets Laravel's depth even though the old PDO still + // has its transaction. A stale transaction must stay refused. + $this->assertFalse(WorkflowSignalReadScope::allowsConsistentRead($run, $connection)); + }); + } finally { + if ($connection->getPdo()->inTransaction()) { + $connection->getPdo() + ->rollBack(); + } + } + } + + public function testExceptionsClearTheScopeBeforeAnotherCallerCanUseTheConnection(): void + { + $connection = $this->connection(); + $run = $this->makeRun($connection, 'one'); + $connection->beginTransaction(); + try { + try { + WorkflowSignalReadScope::forLockedRun($run, function () use ($run, $connection): never { + $this->assertTrue(WorkflowSignalReadScope::allowsConsistentRead($run, $connection)); + throw new RuntimeException('scope control'); + }); + $this->fail('The original exception was hidden.'); + } catch (RuntimeException $error) { + $this->assertSame('scope control', $error->getMessage()); + } + $this->assertFalse(WorkflowSignalReadScope::allowsConsistentRead($run, $connection)); + } finally { + $connection->rollBack(); + } + } + + private function connection(string $driver = 'mysql'): Connection + { + // SQLite backs only scope/identity unit controls. The separate feature + // suite exercises actual InnoDB locking with independent processes. + return new Connection(new PDO('sqlite::memory:'), config: [ + 'driver' => $driver, + ]); + } + + private function makeRun(Connection $connection, string $id): WorkflowRun + { + $run = new class() extends WorkflowRun { + public Connection $scopeConnection; + + public function getConnection(): Connection + { + return $this->scopeConnection; + } + }; + $run->scopeConnection = $connection; + $run->id = $id; + return $run; + } +} From 2199fc1241cac446ea08846aab0b2f97d9d99c62 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:31:21 +0000 Subject: [PATCH 4/7] test: cover owned signal handoff and prepare 2.20.4 --- composer.json | 2 +- .../V2/WorkflowTaskSignalGapLockTest.php | 56 +++++++++++++++++++ tests/Unit/V2/WorkflowSignalReadScopeTest.php | 32 +++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index db3ff7f3c..2009a01b2 100644 --- a/composer.json +++ b/composer.json @@ -87,7 +87,7 @@ "dev-main": "2.0.x-dev" }, "durable-workflow": { - "product-train": "2.20.3", + "product-train": "2.20.4", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" }, diff --git a/tests/Feature/V2/WorkflowTaskSignalGapLockTest.php b/tests/Feature/V2/WorkflowTaskSignalGapLockTest.php index bdc8a6af9..08fd5ff7f 100644 --- a/tests/Feature/V2/WorkflowTaskSignalGapLockTest.php +++ b/tests/Feature/V2/WorkflowTaskSignalGapLockTest.php @@ -19,6 +19,7 @@ use Workflow\V2\Enums\SignalStatus; use Workflow\V2\Enums\TaskStatus; use Workflow\V2\Jobs\RunWorkflowTask; +use Workflow\V2\Models\WorkflowCommand; use Workflow\V2\Models\WorkflowHistoryEvent; use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Models\WorkflowSignal; @@ -141,6 +142,61 @@ public function testContinueAsNewDoesNotDeadlockWithAnUnrelatedSignal(string $va } } + public static function storageConnections(): iterable + { + yield 'default' => [false]; + yield 'named' => [true]; + } + + #[DataProvider('storageConnections')] + public function testOwnedContinuationPreservesThePendingInstanceSignalReceipt(bool $named): void + { + $this->configureStorage($named); + $stub = WorkflowStub::make(TestConfiguredContinueSignalWorkflow::class); + $stub->start(0); + $run = WorkflowRun::query()->findOrFail($stub->runId()); + $receipt = $stub->signal('name-provided', 'Taylor 雪'); + $this->assertTrue($receipt->accepted()); + $signal = WorkflowSignal::query()->where('workflow_command_id', $receipt->commandId())->sole(); + $originalSignal = $signal->getAttributes(); + $originalCommand = WorkflowCommand::query()->findOrFail($receipt->commandId())->getAttributes(); + + $this->executeReadyTask($run->id); + + $this->assertSame('continued', $run->fresh()->closed_reason); + $successor = WorkflowRun::query()->where('workflow_instance_id', $stub->id()) + ->where('id', '!=', $run->id)->sole(); + $signal = $signal->fresh(); + $this->assertSame($successor->id, $signal->workflow_run_id); + $this->assertSame($successor->id, $signal->resolved_workflow_run_id); + $this->assertSame(SignalStatus::Received, $signal->status); + foreach (['id', 'workflow_command_id', 'command_sequence', 'arguments', 'payload_codec', 'received_at'] as $field) { + $this->assertSame($originalSignal[$field], $signal->getRawOriginal($field)); + } + $command = WorkflowCommand::query()->findOrFail($receipt->commandId()); + foreach (['id', 'request_id', 'command_type', 'status', 'outcome', 'accepted_at'] as $field) { + $this->assertSame($originalCommand[$field], $command->getRawOriginal($field)); + } + $this->assertSame(0, WorkflowHistoryEvent::query()->where('workflow_run_id', $run->id) + ->where('event_type', HistoryEventType::SignalRejected->value)->count()); + $this->assertSame(1, WorkflowHistoryEvent::query()->where('workflow_run_id', $successor->id) + ->where('event_type', HistoryEventType::SignalReceived->value)->count()); + $this->assertSame(0, $this->storage()->transactionLevel()); + + DB::purge($this->storage()->getName()); + $this->executeReadyTask($successor->id); + + $signal = WorkflowSignal::query()->whereKey($originalSignal['id'])->sole(); + $this->assertSame(SignalStatus::Applied, $signal->status); + $this->assertNotNull($signal->closed_at); + $this->assertSame($receipt->commandId(), $signal->workflow_command_id); + $this->assertSame('Taylor 雪', $stub->refresh()->output()['name']); + $this->assertSame(1, WorkflowHistoryEvent::query()->where('workflow_run_id', $successor->id) + ->where('event_type', HistoryEventType::SignalApplied->value)->count()); + $this->assertSame(2, WorkflowRun::query()->where('workflow_instance_id', $stub->id())->count()); + $this->assertSame(0, $this->storage()->transactionLevel()); + } + public static function callerTransactions(): iterable { foreach ([false, true] as $named) { diff --git a/tests/Unit/V2/WorkflowSignalReadScopeTest.php b/tests/Unit/V2/WorkflowSignalReadScopeTest.php index 314bd3d0b..117261c2d 100644 --- a/tests/Unit/V2/WorkflowSignalReadScopeTest.php +++ b/tests/Unit/V2/WorkflowSignalReadScopeTest.php @@ -104,6 +104,38 @@ public function testExceptionsClearTheScopeBeforeAnotherCallerCanUseTheConnectio } } + public function testNestedFailureRestoresTheOuterRunScope(): void + { + $connection = $this->connection(); + $outer = $this->makeRun($connection, 'outer'); + $inner = $this->makeRun($connection, 'inner'); + $connection->beginTransaction(); + try { + $result = WorkflowSignalReadScope::forLockedRun($outer, function () use ($connection, $outer, $inner): string { + $this->assertTrue(WorkflowSignalReadScope::allowsConsistentRead($outer, $connection)); + try { + WorkflowSignalReadScope::forLockedRun($inner, function () use ($connection, $outer, $inner): never { + $this->assertTrue(WorkflowSignalReadScope::allowsConsistentRead($inner, $connection)); + $this->assertFalse(WorkflowSignalReadScope::allowsConsistentRead($outer, $connection)); + throw new RuntimeException('nested scope control'); + }); + $this->fail('The inner failure was hidden.'); + } catch (RuntimeException $error) { + $this->assertSame('nested scope control', $error->getMessage()); + } + $this->assertTrue(WorkflowSignalReadScope::allowsConsistentRead($outer, $connection)); + $this->assertFalse(WorkflowSignalReadScope::allowsConsistentRead($inner, $connection)); + + return 'outer restored'; + }); + $this->assertSame('outer restored', $result); + $this->assertFalse(WorkflowSignalReadScope::allowsConsistentRead($outer, $connection)); + $this->assertFalse(WorkflowSignalReadScope::allowsConsistentRead($inner, $connection)); + } finally { + $connection->rollBack(); + } + } + private function connection(string $driver = 'mysql'): Connection { // SQLite backs only scope/identity unit controls. The separate feature From 483a8615bda5c701172832cd25bdb9e28ba6d2cf Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:39:17 +0000 Subject: [PATCH 5/7] style: format signal scope coverage controls --- tests/Feature/V2/WorkflowTaskSignalGapLockTest.php | 12 ++++++++++-- tests/Unit/V2/WorkflowSignalReadScopeTest.php | 6 +++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/Feature/V2/WorkflowTaskSignalGapLockTest.php b/tests/Feature/V2/WorkflowTaskSignalGapLockTest.php index 08fd5ff7f..140e754a2 100644 --- a/tests/Feature/V2/WorkflowTaskSignalGapLockTest.php +++ b/tests/Feature/V2/WorkflowTaskSignalGapLockTest.php @@ -165,12 +165,20 @@ public function testOwnedContinuationPreservesThePendingInstanceSignalReceipt(bo $this->assertSame('continued', $run->fresh()->closed_reason); $successor = WorkflowRun::query()->where('workflow_instance_id', $stub->id()) - ->where('id', '!=', $run->id)->sole(); + ->where('id', '!=', $run->id) + ->sole(); $signal = $signal->fresh(); $this->assertSame($successor->id, $signal->workflow_run_id); $this->assertSame($successor->id, $signal->resolved_workflow_run_id); $this->assertSame(SignalStatus::Received, $signal->status); - foreach (['id', 'workflow_command_id', 'command_sequence', 'arguments', 'payload_codec', 'received_at'] as $field) { + foreach ([ + 'id', + 'workflow_command_id', + 'command_sequence', + 'arguments', + 'payload_codec', + 'received_at', + ] as $field) { $this->assertSame($originalSignal[$field], $signal->getRawOriginal($field)); } $command = WorkflowCommand::query()->findOrFail($receipt->commandId()); diff --git a/tests/Unit/V2/WorkflowSignalReadScopeTest.php b/tests/Unit/V2/WorkflowSignalReadScopeTest.php index 117261c2d..a84468874 100644 --- a/tests/Unit/V2/WorkflowSignalReadScopeTest.php +++ b/tests/Unit/V2/WorkflowSignalReadScopeTest.php @@ -111,7 +111,11 @@ public function testNestedFailureRestoresTheOuterRunScope(): void $inner = $this->makeRun($connection, 'inner'); $connection->beginTransaction(); try { - $result = WorkflowSignalReadScope::forLockedRun($outer, function () use ($connection, $outer, $inner): string { + $result = WorkflowSignalReadScope::forLockedRun($outer, function () use ( + $connection, + $outer, + $inner + ): string { $this->assertTrue(WorkflowSignalReadScope::allowsConsistentRead($outer, $connection)); try { WorkflowSignalReadScope::forLockedRun($inner, function () use ($connection, $outer, $inner): never { From e74dc26bb2444ab8d846ae5d46e8b77acc265a76 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:50:27 +0000 Subject: [PATCH 6/7] test: compare emitted OpenTelemetry trace flags --- tests/Fixtures/V2/tracing-process.php | 2 ++ .../Unit/V2/DispatchExecutionTracingTest.php | 14 +++++++- tests/Unit/V2/TraceCarrierTest.php | 32 +++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/tests/Fixtures/V2/tracing-process.php b/tests/Fixtures/V2/tracing-process.php index a0785dab8..99871157a 100644 --- a/tests/Fixtures/V2/tracing-process.php +++ b/tests/Fixtures/V2/tracing-process.php @@ -1182,6 +1182,8 @@ public function extract( ->getTraceId(), 'span_id' => $span->getContext() ->getSpanId(), + 'trace_flags' => $span->getContext() + ->getTraceFlags(), 'parent_id' => $span->getParentContext() ->getSpanId(), 'fields' => $span->getAttributes() diff --git a/tests/Unit/V2/DispatchExecutionTracingTest.php b/tests/Unit/V2/DispatchExecutionTracingTest.php index a6a4f5846..ccbeb6c4a 100644 --- a/tests/Unit/V2/DispatchExecutionTracingTest.php +++ b/tests/Unit/V2/DispatchExecutionTracingTest.php @@ -7,6 +7,7 @@ use Composer\Autoload\ClassLoader; use Illuminate\Filesystem\Filesystem; use OpenTelemetry\API\Trace\SpanKind; +use OpenTelemetry\API\Trace\TraceFlags; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\Process\Process; @@ -126,8 +127,19 @@ public function testActualQueuedDispatchLinksColdProcessingWithoutChangingTheDur self::assertLessThanOrEqual(4096, strlen(json_encode($carrier, JSON_THROW_ON_ERROR))); self::assertArrayNotHasKey('baggage', $carrier['context']['carrier']); $producer = $dispatchByTask[$queued['task_id']]; + $supportedFlags = TraceFlags::SAMPLED; + if (defined(TraceFlags::class . '::RANDOM')) { + $supportedFlags |= constant(TraceFlags::class . '::RANDOM'); + } + self::assertSame(TraceFlags::SAMPLED, $producer['trace_flags'] & TraceFlags::SAMPLED); + self::assertSame(0, $producer['trace_flags'] & ~$supportedFlags); self::assertSame( - '00-' . $producer['trace_id'] . '-' . $producer['span_id'] . '-01', + sprintf( + '00-%s-%s-%02x', + $producer['trace_id'], + $producer['span_id'], + $producer['trace_flags'] + ), $carrier['context']['carrier']['traceparent'] ); } diff --git a/tests/Unit/V2/TraceCarrierTest.php b/tests/Unit/V2/TraceCarrierTest.php index 853cec0c9..4bf900da1 100644 --- a/tests/Unit/V2/TraceCarrierTest.php +++ b/tests/Unit/V2/TraceCarrierTest.php @@ -10,6 +10,7 @@ use OpenTelemetry\API\Instrumentation\Configurator; use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator; use OpenTelemetry\API\Trace\Span; +use OpenTelemetry\API\Trace\TraceFlags; use OpenTelemetry\Context\Context; use OpenTelemetry\Context\Propagation\MultiTextMapPropagator; use PHPUnit\Framework\Attributes\DataProvider; @@ -89,6 +90,37 @@ public function testValidCarrierPreservesTraceIdentityAndState(): void $this->assertSame($input, TraceCarrier::normalize($input)); } + public static function sampledTraceFlags(): iterable + { + yield 'sampled' => ['01']; + yield 'sampled and random' => ['03']; + } + + #[DataProvider('sampledTraceFlags')] + public function testNormalizationPreservesTheSupportedSampledFlags(string $flags): void + { + $supportedFlags = TraceFlags::SAMPLED; + if (defined(TraceFlags::class . '::RANDOM')) { + $supportedFlags |= constant(TraceFlags::class . '::RANDOM'); + } + $prefix = substr(self::PARENT, 0, -2); + $input = self::envelope([ + 'traceparent' => $prefix . $flags, + ]); + $normalized = TraceCarrier::normalize($input, diagnostic: $diagnostic); + self::assertNotNull($normalized); + $expected = $prefix . sprintf('%02x', hexdec($flags) & $supportedFlags); + self::assertSame($expected, $normalized['carrier']['traceparent']); + self::assertSame( + $expected === $input['carrier']['traceparent'] ? null : 'filtered', + $diagnostic + ); + $context = TraceContextPropagator::getInstance()->extract($normalized['carrier']); + $actualFlags = Span::fromContext($context)->getContext()->getTraceFlags(); + self::assertSame(TraceFlags::SAMPLED, $actualFlags & TraceFlags::SAMPLED); + self::assertSame(0, $actualFlags & ~$supportedFlags); + } + public function testAbsentStoredMetadataDoesNotRequireApplicationInstrumentation(): void { $run = new WorkflowRun(); From 0ae6ab2ea98ed6c1ea05764b1e854f1e79f307c4 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:54:59 +0000 Subject: [PATCH 7/7] style: apply trace carrier assertion formatting --- tests/Unit/V2/TraceCarrierTest.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/Unit/V2/TraceCarrierTest.php b/tests/Unit/V2/TraceCarrierTest.php index 4bf900da1..adf2237ad 100644 --- a/tests/Unit/V2/TraceCarrierTest.php +++ b/tests/Unit/V2/TraceCarrierTest.php @@ -111,10 +111,7 @@ public function testNormalizationPreservesTheSupportedSampledFlags(string $flags self::assertNotNull($normalized); $expected = $prefix . sprintf('%02x', hexdec($flags) & $supportedFlags); self::assertSame($expected, $normalized['carrier']['traceparent']); - self::assertSame( - $expected === $input['carrier']['traceparent'] ? null : 'filtered', - $diagnostic - ); + self::assertSame($expected === $input['carrier']['traceparent'] ? null : 'filtered', $diagnostic); $context = TraceContextPropagator::getInstance()->extract($normalized['carrier']); $actualFlags = Span::fromContext($context)->getContext()->getTraceFlags(); self::assertSame(TraceFlags::SAMPLED, $actualFlags & TraceFlags::SAMPLED);