italix / jobs
Database-backed queue and cron scheduling: JSON payloads not serialized objects, atomic claiming, backoff, stalled-worker recovery
Requires
- php: >=7.4
- ext-json: *
- psr/container: ^1.0 || ^2.0
Requires (Dev)
- italix/testing: ^2.0
Suggests
- ext-pdo: Required by PdoJobStore, the only store shipped in core
This package is not auto-updated.
Last update: 2026-08-30 23:05:18 UTC
README
A database-backed queue and cron scheduling. Work is described now and run later, by a worker that cannot run it twice and does not lose it when it dies.
One dependency: psr/container, which is interface-only.
php src/Libs/Italix/Jobs/tests/CronTest.php
php src/Libs/Italix/Jobs/tests/QueueTest.php # needs ITALIX_TEST_DSN
Writing a job
final class SendInvoiceMail implements Job { private int $invoice_id; public function __construct(int $invoice_id) { $this->invoice_id = $invoice_id; } public static function name_code(): string { return 'invoice.mail'; } public function payload(): array { return ['invoice_id' => $this->invoice_id]; } public static function from_payload(array $payload): Job { return new self((int) $payload['invoice_id']); } public function run(ContainerInterface $services): void { $services->get(Mailer::class)->send_invoice($this->invoice_id); } }
$queue->push(new SendInvoiceMail($invoice_id)); $queue->push(new BuildReport($id), '+5 minutes'); $queue->push(new Reconcile(), null, 'reconcile:' . date('Y-m-d')); // once a day, whoever asks
Ids in the payload, not objects. A payload carrying a whole record is stale by the time it runs.
name_code() is not the class name. It is what jobs:status groups by and what jobs:retry --name= matches, so renaming a class must not break either.
Why payload()/from_payload() and not serialize()
Two reasons, and the second is the serious one.
A serialized object carries its private properties by name. Rename one, or add a constructor argument, and every row already queued becomes unrestorable — silently, at 3am, for work somebody is waiting on.
And unserialize() on a value read back from a table instantiates whatever class the string names
and runs its magic methods. Any path that can write to that table becomes remote code execution. A
JSON payload plus an explicit factory can only ever produce the class the code asked for.
Running
ix jobs:install # create ix_jobs ix jobs:work --max=50 # one pass, then exit ix jobs:status # counts + recent failures; exit 1 if anything failed ix jobs:retry --name=invoice.mail ix jobs:schedule --list
One crontab entry drives everything:
* * * * * cd /path && php bin/ix jobs:schedule && php bin/ix jobs:work --max=50
jobs:work does one pass and exits — supervision belongs to cron or systemd, and a PHP process
that runs for days accumulates every leak its dependencies have.
Watch jobs:status, not jobs:work. The worker exiting 0 only means the worker ran; a failed
job is reported by jobs:status, which exits 1 when anything is in failed.
What happens when a job fails
| throws | error_c = threw, attempt counted, requeued after backoff |
| throws again, attempts exhausted | state failed, stays until somebody looks |
| class missing or payload rejected | error_c = unrestorable, failed immediately — retrying cannot help |
| worker dies mid-job | reclaimed after the visibility timeout, error_c = stalled |
Backoff doubles: 30s, 60s, 120s. A mail server that is down stays down for a while, and retrying it every second turns one outage into a thousand log lines.
Nothing is ever dropped. A job that stops existing is the queue equivalent of a validation rule that silently passes.
Scheduling
Declared in configuration, like routes and commands:
Schedule::class => static fn(): Schedule => (new Schedule()) ->daily_at('03:30', new PurgeExpiredTokens()) ->every_minutes(5, new RetryFailedMail()) ->cron('0 9 * * 1-5', new WeekdayDigest()),
ix jobs:schedule --list prints them, which is only possible because there is a list.
Running the cron twice enqueues once. due() returns a deduplication key naming the job and the
minute, and the store's unique index turns the second push into a no-op. The system crontab is not
a precision instrument: a minute can fire twice, a catch-up run can replay, two hosts can both be
scheduled. Nothing anywhere has to be careful.
That is also why the resolution is a minute and not a second — the key has to be stable across the whole window in which the cron might fire.
Cron syntax: five fields, with *, n, a-b, a,b,c and a step suffix on any of those. 0 and
7 both mean Sunday, because both spellings are in circulation.
Concurrency, in one paragraph
claim() selects a candidate and then updates it conditionally on it still being pending. If
another worker got there first the update affects zero rows and this one looks again. No
SELECT … FOR UPDATE, no transaction held open across the work — just an update that can only
succeed once. That is why claiming is a method on JobStore rather than a sequence the caller
assembles: a store that cannot do it atomically is a store that runs jobs twice.
Deliberately not
- No Redis or SQS driver in core.
JobStoreis the seam; an out-of-tree implementation costs nothing to core. - No batching, chaining, or unique-job locks. The deduplication key covers the case that actually recurs.
- No
@daily, no seconds field, noL/W/#. Little code, large surprise. - No long-lived worker process.