italix/jobs

Database-backed queue and cron scheduling: JSON payloads not serialized objects, atomic claiming, backoff, stalled-worker recovery

Maintainers

Package info

github.com/italix-net/jobs

pkg:composer/italix/jobs

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

2.0.0 2026-08-29 21:46 UTC

This package is not auto-updated.

Last update: 2026-08-30 23:05:18 UTC


README

PHP Version License

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. JobStore is 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, no L/W/#. Little code, large surprise.
  • No long-lived worker process.