diff --git a/src/V2/Support/ScheduleManager.php b/src/V2/Support/ScheduleManager.php index 17275caa..57703b39 100644 --- a/src/V2/Support/ScheduleManager.php +++ b/src/V2/Support/ScheduleManager.php @@ -502,10 +502,12 @@ public static function tick(int $limit = 100): array } // Phase 2: evaluate due schedules. + // DateTime query bindings otherwise lose the model's fractional precision. + $dueAt = (new WorkflowSchedule())->fromDateTime(now()); $due = self::fairScheduleBatch(WorkflowSchedule::query() ->where('status', ScheduleStatus::Active->value) ->whereNotNull('next_fire_at') - ->where('next_fire_at', '<=', now()), $limit, 'next_fire_at'); + ->where('next_fire_at', '<=', $dueAt), $limit, 'next_fire_at'); foreach ($due as $schedule) { $occurrenceTime = $schedule->next_fire_at; diff --git a/tests/Feature/V2/V2ScheduleTest.php b/tests/Feature/V2/V2ScheduleTest.php index cc3c51b7..2b1201c7 100644 --- a/tests/Feature/V2/V2ScheduleTest.php +++ b/tests/Feature/V2/V2ScheduleTest.php @@ -389,6 +389,53 @@ public function testTickProcessesDueSchedules(): void $this->assertSame($dueAt->format('Y-m-d\TH:i:s.uP'), $triggered->payload['occurrence_time']); } + /** + * @return array + */ + public static function tickTimestampBoundaries(): array + { + return [ + 'exact second' => ['00.000000', '00.000000', true], + 'exact microsecond' => ['00.500000', '00.500000', true], + 'past within same second' => ['00.500000', '00.750000', true], + 'future within same second' => ['00.500000', '00.499999', false], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('tickTimestampBoundaries')] + public function testTickPreservesPersistedTimestampPrecision(string $dueSecond, string $tickSecond, bool $due): void + { + WorkflowStub::fake(); + $dueAt = Carbon::parse('2026-01-01 12:00:' . $dueSecond, 'UTC'); + Carbon::setTestNow(Carbon::parse('2026-01-01 12:00:' . $tickSecond, 'UTC')); + + try { + $schedule = ScheduleManager::create( + scheduleId: 'precise-tick', + workflowClass: TestScheduledWorkflow::class, + cronExpression: '* * * * *', + ); + $schedule->forceFill([ + 'next_fire_at' => $dueAt, + ])->save(); + $schedule = WorkflowSchedule::query()->findOrFail($schedule->id); + $this->assertSame($dueAt->format('Y-m-d H:i:s.u'), $schedule->next_fire_at->format('Y-m-d H:i:s.u')); + + $results = ScheduleManager::tick(); + + $this->assertCount($due ? 1 : 0, $results); + if ($due) { + $this->assertSame('triggered', $results[0]['outcome']); + $this->assertSame($dueAt->format('Y-m-d\TH:i:s.uP'), $results[0]['occurrence_time']); + $this->assertNotNull($results[0]['instance_id']); + $this->assertSame([], ScheduleManager::tick()); + } + $this->assertSame($due ? 1 : 0, (int) $schedule->refresh()->fires_count); + } finally { + Carbon::setTestNow(); + } + } + public function testTickSharesABoundedBatchAcrossNamespaces(): void { WorkflowStub::fake();