pushHandler(new NullHandler()); $hot = new StubJob('hot', 60); // due every minute $cold = new StubJob('cold', 3600); // due every hour $never = new StubJob('never-run', 60); // no prior run → always due // Pre-write `job_runs` rows: "hot" finished 30 seconds ago (NOT // due yet), "cold" finished 30 seconds ago (also not due). $this->db->insert('job_runs', [ 'job_name' => 'hot', 'status' => 'success', 'items_processed' => 0, 'triggered_by' => 'manual', 'started_at' => $clock->now()->modify('-31 seconds')->format('Y-m-d H:i:s'), 'finished_at' => $clock->now()->modify('-30 seconds')->format('Y-m-d H:i:s'), ]); $this->db->insert('job_runs', [ 'job_name' => 'cold', 'status' => 'success', 'items_processed' => 0, 'triggered_by' => 'manual', 'started_at' => $clock->now()->modify('-31 seconds')->format('Y-m-d H:i:s'), 'finished_at' => $clock->now()->modify('-30 seconds')->format('Y-m-d H:i:s'), ]); $runner = new JobRunner( new JobLockRepository($this->db), new JobRunRepository($this->db), $clock, $logger, ); $tick = new TickJob( jobsResolver: static fn (): array => [ 'hot' => $hot, 'cold' => $cold, 'never-run' => $never, ], runner: $runner, runs: new JobRunRepository($this->db), ); $result = $tick->run(new JobContext($clock, $logger, [])); // Only `never-run` should fire — the other two are within their // interval window. self::assertSame(1, $result->itemsProcessed); self::assertSame(['never-run'], $result->details['invoked']); self::assertFalse($hot->wasInvoked, 'hot must not have run'); self::assertFalse($cold->wasInvoked, 'cold must not have run'); self::assertTrue($never->wasInvoked, 'never-run should have run'); } public function testDueAfterIntervalElapses(): void { $clock = FixedClock::at('2026-04-29T00:00:00Z'); $logger = new Logger('test'); $logger->pushHandler(new NullHandler()); $job = new StubJob('schedulable', 60); $this->db->insert('job_runs', [ 'job_name' => 'schedulable', 'status' => 'success', 'items_processed' => 0, 'triggered_by' => 'schedule', 'started_at' => $clock->now()->modify('-2 minutes')->format('Y-m-d H:i:s'), 'finished_at' => $clock->now()->modify('-2 minutes')->format('Y-m-d H:i:s'), ]); $runner = new JobRunner( new JobLockRepository($this->db), new JobRunRepository($this->db), $clock, $logger, ); $tick = new TickJob( jobsResolver: static fn (): array => ['schedulable' => $job], runner: $runner, runs: new JobRunRepository($this->db), ); $result = $tick->run(new JobContext($clock, $logger, [])); self::assertSame(1, $result->itemsProcessed); self::assertTrue($job->wasInvoked); } public function testFailingChildJobDoesNotAbortDispatcher(): void { $clock = FixedClock::at('2026-04-29T00:00:00Z'); $logger = new Logger('test'); $logger->pushHandler(new NullHandler()); $broken = new StubJob('broken', 60, throw: true); $healthy = new StubJob('healthy', 60); $runner = new JobRunner( new JobLockRepository($this->db), new JobRunRepository($this->db), $clock, $logger, ); $tick = new TickJob( jobsResolver: static fn (): array => ['broken' => $broken, 'healthy' => $healthy], runner: $runner, runs: new JobRunRepository($this->db), ); $result = $tick->run(new JobContext($clock, $logger, [])); // Both due (no prior runs); both invoked. self::assertSame(2, $result->itemsProcessed); // The broken child's row exists with failure status. $rows = $this->db->fetchAllAssociative('SELECT job_name, status FROM job_runs ORDER BY id'); $brokenRow = array_values(array_filter( $rows, static fn (array $r): bool => $r['job_name'] === 'broken' ))[0]; self::assertSame(JobStatus::Failure->value, $brokenRow['status']); } } final class StubJob implements Job { public bool $wasInvoked = false; public function __construct( private readonly string $name, private readonly int $interval, private readonly bool $throw = false, ) { } public function name(): string { return $this->name; } public function defaultIntervalSeconds(): int { return $this->interval; } public function maxRuntimeSeconds(): int { return 30; } public function run(JobContext $context): JobResult { $this->wasInvoked = true; if ($this->throw) { throw new \RuntimeException('stub failure'); } return new JobResult(itemsProcessed: 1); } }