Boring Observability GitHub

A job middleware that checks the unique lock again when the job runs

Checks a ShouldBeUnique job's lock again when a worker picks it up, and skips the job if another one holds it. Fires an event for each skip, so you can count them.

$ composer require boring-o11y/unique-job-middleware

boring-o11y/unique-job-middleware · MIT · PHP 8.1+, Laravel 10 to 13, any queue driver · Experimental

Experimental

This is version 0.1, and it relies on a protected property of Laravel's queue job class that a framework release could change. Try it on one job class before adding it everywhere.

Laravel checks ShouldBeUnique in one place: dispatch(). It takes the lock there, and if another copy already holds it, the dispatch is quietly dropped. A job that reaches the queue any other way skips that check.

That includes the first job of a Bus::chain(), jobs in a Bus::batch(), Queue::push() and Queue::bulk(), and every queue:retry or press of Horizon's retry button. A unique job queued that way runs even while another copy holds the lock, so two copies run side by side. We cover the wider set of cases in Laravel ShouldBeUnique: what breaks under real traffic.

A second check when the worker picks the job up

class RecalculateInvoice implements ShouldQueue, ShouldBeUnique
{
    public function middleware(): array
    {
        return [new EnsureUniqueLock];
    }
}

The middleware looks at the lock when the job is about to run. If this job holds it, or it is free, the job runs. If another job holds it, this one is deleted without running and a UniqueJobSkipped event fires, which you can log or count.

The hard part is telling "my lock" from "someone else's lock". Laravel takes the lock under a random owner token and then throws the token away. The package records it in the job's payload at push time, so the middleware has something to compare.

When in doubt, it runs the job

A job is only skipped on proof that another job holds its lock. If the cache store has no locks, Redis errors, or the job was queued before the package was installed, the job runs as it would have without it. In a chain, a skipped job ends the chain, as a discarded dispatch does. In a batch it counts as a success, so the batch does not wait forever.

Limits

  • If uniqueFor expires while a job waits and a newer dispatch takes the lock, the older job is the one skipped.
  • On Laravel 10 and early 11.x, ShouldBeUniqueUntilProcessing jobs release their lock before middleware runs, so they are not protected there. Plain ShouldBeUnique jobs are, on every supported version.

Every configuration key, and how to run the test suite, is in the README on GitHub. Bugs and questions go in its issues.

Our other packages

  • Horizon Delayed Jobs

    A Retries page for the Horizon dashboard. It lists the jobs waiting out a backoff or a delay, which Horizon does not show, with a button to run one now.

  • httptheus

    Prometheus metrics for your application's outbound HTTP, with a Grafana dashboard to read them. Duration, host, endpoint and outcome of every transfer, and no bodies, headers or database.

  • wirestan

    PHPStan rules for Livewire. A public property the browser can set but the server trusts, like $tenantId seeded in mount(), fails CI until it is marked #[Locked].