@@ -87,6 +87,109 @@ public static function claims(): array
8787 ];
8888 }
8989
90+ #[DataProvider('claims ' )]
91+ public function testOrdinaryBusyTimeoutLeavesTimeToRetryTheUnclaimedDelivery (string $ kind , bool $ separate ): void
92+ {
93+ config ()->set ('logging.default ' , 'null ' );
94+ if ($ separate ) {
95+ config ()->set ('database.connections.application_sqlite ' , [
96+ 'driver ' => 'sqlite ' ,
97+ 'database ' => ':memory: ' ,
98+ 'prefix ' => '' ,
99+ 'foreign_key_constraints ' => true ,
100+ ]);
101+ DB ::setDefaultConnection ('application_sqlite ' );
102+ $ this ->createQueueTables (DB ::connection ());
103+ config ()
104+ ->set ('queue.failed.database ' , 'application_sqlite ' );
105+ }
106+ $ stub = WorkflowStub::make (SqliteContentionWorkflow::class, 'ordinary-busy- ' . $ kind , 'contention-test ' );
107+ $ stub ->start (false , false , new StartOptions (executionTimeoutSeconds: 120 , runTimeoutSeconds: 100 ));
108+ if ($ kind === 'activity ' ) {
109+ $ this ->workOnce ();
110+ }
111+ $ task = WorkflowTask::query ()->where ('workflow_run_id ' , $ stub ->runId ())
112+ ->where ('task_type ' , $ kind )
113+ ->sole ();
114+ $ run = $ task ->run ;
115+ $ deadlines = [$ run ->execution_deadline_at ->toJSON (), $ run ->run_deadline_at ->toJSON ()];
116+ $ prefix = WorkflowHistoryEvent::query ()->where ('workflow_run_id ' , $ run ->id )->orderBy ('sequence ' )
117+ ->get ()
118+ ->map ->getAttributes ()
119+ ->all ();
120+ $ connection = DB ::connection ('contention_sqlite ' );
121+ $ connection ->getPdo ()
122+ ->exec ('PRAGMA busy_timeout=5000 ' );
123+ $ writer = new PDO ('sqlite: ' . $ this ->path );
124+ $ writer ->setAttribute (PDO ::ATTR_ERRMODE , PDO ::ERRMODE_EXCEPTION );
125+ $ injected = false ;
126+ $ rollbacks = 0 ;
127+ $ connection ->beforeExecuting (static function (string $ sql ) use ($ writer , &$ injected ): void {
128+ if ($ injected || ! str_starts_with ($ sql , 'update "workflow_tasks" set "id" = ' )) {
129+ return ;
130+ }
131+ $ writer ->beginTransaction ();
132+ $ writer ->exec ('INSERT INTO contention_probe (id) VALUES (1) ' );
133+ $ injected = true ;
134+ });
135+ $ connection ->getEventDispatcher ()
136+ ->listen (TransactionRolledBack::class, static function ($ event ) use (
137+ $ connection ,
138+ $ writer ,
139+ &$ rollbacks
140+ ): void {
141+ if ($ event ->connection === $ connection && $ writer ->inTransaction ()) {
142+ self ::assertSame (0 , $ connection ->transactionLevel ());
143+ self ::assertFalse ($ connection ->getPdo ()->inTransaction ());
144+ self ::assertSame (0 , SqliteContentionActivity::$ invocations );
145+ $ rollbacks ++;
146+ $ writer ->commit ();
147+ }
148+ });
149+ try {
150+ $ this ->workOnce ();
151+ } finally {
152+ if ($ writer ->inTransaction ()) {
153+ $ writer ->rollBack ();
154+ }
155+ }
156+ self ::assertTrue ($ injected );
157+ self ::assertSame (1 , $ rollbacks );
158+ self ::assertSame (5000 , (int ) $ connection ->getPdo ()->query ('PRAGMA busy_timeout ' )->fetchColumn ());
159+ self ::assertSame (
160+ 0 ,
161+ DB ::table ('failed_jobs ' )->count (),
162+ 'A rolled-back pre-claim wait must leave time to retry. '
163+ );
164+ self ::assertSame ('completed ' , $ task ->fresh ()->status ->value );
165+ self ::assertSame (1 , (int ) $ task ->fresh ()->attempt_count );
166+ for ($ remaining = 0 ; $ remaining < 3 && $ stub ->refresh ()->status () !== 'completed ' ; $ remaining ++) {
167+ $ this ->workOnce ();
168+ }
169+ self ::assertSame ('completed ' , $ stub ->refresh ()->status ());
170+ self ::assertSame ([
171+ 'integer ' => 9007199254740993 ,
172+ 'optional ' => null ,
173+ 'unicode ' => 'λ ' ,
174+ ], $ stub ->output ());
175+ self ::assertSame (1 , SqliteContentionActivity::$ invocations );
176+ self ::assertSame ($ deadlines , [
177+ $ run ->fresh ()
178+ ->execution_deadline_at ->toJSON (),
179+ $ run ->fresh ()
180+ ->run_deadline_at ->toJSON (),
181+ ]);
182+ self ::assertSame ($ prefix , WorkflowHistoryEvent::query ()->where ('workflow_run_id ' , $ run ->id )
183+ ->orderBy ('sequence ' )
184+ ->limit (count ($ prefix ))
185+ ->get ()
186+ ->map ->getAttributes ()
187+ ->all ());
188+ self ::assertSame (0 , WorkflowTask::query ()->where ('workflow_run_id ' , $ run ->id )->sum ('repair_count ' ));
189+ self ::assertSame (0 , DB ::table ('jobs ' )->count ());
190+ self ::assertSame (0 , DB ::table ('failed_jobs ' )->count ());
191+ }
192+
90193 public function testActivityOutcomeWaitsForTheWriterWithinItsOriginalClaim (): void
91194 {
92195 config ()->set ('logging.default ' , 'null ' );
@@ -109,6 +212,7 @@ public function testActivityOutcomeWaitsForTheWriterWithinItsOriginalClaim(): vo
109212 $ admissions = 0 ;
110213 $ rollbacks = 0 ;
111214 $ claim = null ;
215+ $ releaseAt = hrtime (true ) + 5_100_000_000 ;
112216 $ connection ->beforeExecuting (static function (string $ sql ) use (
113217 $ writer ,
114218 $ task ,
@@ -130,13 +234,16 @@ public function testActivityOutcomeWaitsForTheWriterWithinItsOriginalClaim(): vo
130234 ->listen (TransactionRolledBack::class, static function ($ event ) use (
131235 $ writer ,
132236 $ connection ,
133- &$ rollbacks
237+ &$ rollbacks ,
238+ $ releaseAt ,
134239 ): void {
135240 if ($ event ->connection === $ connection && $ writer ->inTransaction ()) {
136241 self ::assertSame (0 , $ connection ->transactionLevel ());
137242 self ::assertFalse ($ connection ->getPdo ()->inTransaction ());
138243 $ rollbacks ++;
139- $ writer ->commit ();
244+ if (hrtime (true ) >= $ releaseAt ) {
245+ $ writer ->commit ();
246+ }
140247 }
141248 });
142249 $ started = hrtime (true );
@@ -149,7 +256,7 @@ public function testActivityOutcomeWaitsForTheWriterWithinItsOriginalClaim(): vo
149256 }
150257 $ elapsed = (hrtime (true ) - $ started ) / 1_000_000_000 ;
151258 self ::assertGreaterThanOrEqual (5 , $ elapsed );
152- self ::assertSame (1 , $ rollbacks );
259+ self ::assertGreaterThanOrEqual (1 , $ rollbacks );
153260 self ::assertSame (1 , SqliteContentionActivity::$ invocations );
154261 self ::assertIsArray ($ claim );
155262 self ::assertSame ('leased ' , $ claim ['status ' ]);
0 commit comments