Problem
A normal embedded v2 activity retry can become stranded when Laravel delivers its delayed job during the fractional second before the durable task is due. RunActivityTask releases the job, but its $tries = 1 means the next delivery fails with MaxAttemptsExceededException before the activity can run. The durable task stays ready while the workflow stays incomplete.
Observed with published Workflow 2.0.4 (f7b910958a186a11f52e916843f9019ea7b85ad8), PHP 8.4.25, Laravel 13.26.1 (e4a1bc52ef551d52e60244bb004256d6861da7ab), and Laravel's real database queue backed by SQLite. This is not a fake-queue result.
Minimal Reproduction
In a migrated Laravel application using queue.default=database and workflows.v2.connection=database, define a workflow calling an activity with new ActivityOptions(maxAttempts: 2, backoff: [1]). Have the activity throw on its first call and return on its second.
- Freeze Carbon at
12:00:00.500000, start the workflow, and drain currently available jobs with queue:work --stop-when-empty --sleep=0. The first activity call fails and its retry task is due at 12:00:01.500000.
- Move Carbon to
12:00:01.000000 and drain. Laravel's integer-second queue timestamp makes the job available. ActivityTaskClaimer correctly reports task_not_due; RunActivityTask calls release(...).
- Move Carbon to
12:00:02.000000 and drain. The same queue job is now on transport attempt 2, exceeds $tries = 1, and enters failed_jobs without executing the second business attempt.
Observed output:
12:00:00.500000 activity_calls=1 completed=false failed_jobs=0 ready_activity_tasks=1
12:00:01.000000 activity_calls=1 completed=false failed_jobs=0 ready_activity_tasks=1
12:00:02.000000 activity_calls=1 completed=false failed_jobs=1 ready_activity_tasks=1
12:00:03.000000 activity_calls=1 completed=false failed_jobs=1 ready_activity_tasks=1
The failed job reports Illuminate\Queue\MaxAttemptsExceededException: Workflow\V2\Jobs\RunActivityTask has been attempted too many times.
Relevant code: src/V2/Support/TaskDispatcher.php passes the fractional available_at directly as the activity job delay; src/V2/Jobs/RunActivityTask.php combines a one-attempt wrapper with an early-delivery release() path. The business retry policy and transport-delivery attempts are distinct.
Acceptance
- Add regression coverage using the actual Laravel queue worker with fractional-second deadlines, not only direct
handle() calls or large clock jumps.
- The configured second activity attempt runs once, the workflow completes, and no transport failure is produced merely because delivery was early.
- Preserve durable activity attempt limits, not-before semantics, duplicate-delivery safety, and bounded handling of genuine infrastructure failures. Do not blindly make business retries unlimited.
- Check database and Redis queue timestamp behavior and other task wrappers that release early deliveries.
- Publish the correction and verify it with the same reproduction.
Problem
A normal embedded v2 activity retry can become stranded when Laravel delivers its delayed job during the fractional second before the durable task is due.
RunActivityTaskreleases the job, but its$tries = 1means the next delivery fails withMaxAttemptsExceededExceptionbefore the activity can run. The durable task stays ready while the workflow stays incomplete.Observed with published Workflow 2.0.4 (
f7b910958a186a11f52e916843f9019ea7b85ad8), PHP 8.4.25, Laravel 13.26.1 (e4a1bc52ef551d52e60244bb004256d6861da7ab), and Laravel's real database queue backed by SQLite. This is not a fake-queue result.Minimal Reproduction
In a migrated Laravel application using
queue.default=databaseandworkflows.v2.connection=database, define a workflow calling an activity withnew ActivityOptions(maxAttempts: 2, backoff: [1]). Have the activity throw on its first call and return on its second.12:00:00.500000, start the workflow, and drain currently available jobs withqueue:work --stop-when-empty --sleep=0. The first activity call fails and its retry task is due at12:00:01.500000.12:00:01.000000and drain. Laravel's integer-second queue timestamp makes the job available.ActivityTaskClaimercorrectly reportstask_not_due;RunActivityTaskcallsrelease(...).12:00:02.000000and drain. The same queue job is now on transport attempt 2, exceeds$tries = 1, and entersfailed_jobswithout executing the second business attempt.Observed output:
The failed job reports
Illuminate\Queue\MaxAttemptsExceededException: Workflow\V2\Jobs\RunActivityTask has been attempted too many times.Relevant code:
src/V2/Support/TaskDispatcher.phppasses the fractionalavailable_atdirectly as the activity job delay;src/V2/Jobs/RunActivityTask.phpcombines a one-attempt wrapper with an early-deliveryrelease()path. The business retry policy and transport-delivery attempts are distinct.Acceptance
handle()calls or large clock jumps.