Skip to content

Feat: show what a queued job promises when it goes wrong - #120

Merged
mrmarchone merged 6 commits into
laramint:mainfrom
webard:feat/job-lifecycle
Sep 4, 2026
Merged

Feat: show what a queued job promises when it goes wrong#120
mrmarchone merged 6 commits into
laramint:mainfrom
webard:feat/job-lifecycle

Conversation

@webard

@webard webard commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

A job node carried its name, its file and its flow — so "this runs on a queue" was the whole story. Not whether a failure is retried, not whether a second dispatch is dropped on the floor, not whether the job refuses to overlap with itself. Those are the differences that matter at three in the morning.

The section appears only when the job declares something. A job that declares none of it gets no section at all — six nulls say less than nothing.

Two sources, for two kinds of fact

Interfaces and the Batchable trait are asked of the loaded class, because ShouldBeUnique is as often inherited from a base job as written on the job itself.

Everything else is read from the source. A declared $tries = 5 is a fact the file states, and instantiating a job to ask it is not something an analyzer should do — a constructor can take models, open connections, or throw.

A fact expressed as a method is still a fact

Reading properties alone would have found a third of it. Measured on an application of 113 jobs:

declared as count
ShouldBeUnique 44
uniqueId() 32
middleware() 14
retryUntil() 8
$timeout 8
$tries 7
backoff() 4

So a method is reported too — as its value where the body is a single return 60;, and otherwise as decided at runtime. A computed backoff cannot be reduced to a number by reading the source, and printing a guess would be worse than printing nothing.

Middleware names come from the new at the root of each entry. Middleware is habitually configured by chaining, and the outermost node of

new WithoutOverlapping($key)->releaseAfter(60)->expireAfter(180)

is a method call, not the class anyone means. A bare SomeMiddleware::class entry is read too.

Scope

This enriches the job nodes a graph already has; it does not discover jobs. On the application measured that is 27 of 113 — the rest are not reachable from any traced call chain, which is a separate question from this one.

Bus::chain() and Bus::batch() are deliberately left out. They are call-site facts rather than class facts, and showing a chain properly means grouping its members and drawing the order between them — the region machinery from #117. Once that is in, a chain can reuse it rather than grow a second copy. The class-level half of it, the Batchable trait, is included here.

Verification

pint, phpstan and the suite are green — 413 passing, 8 new. Three rules were mutation-checked: dropping the literal-return read, taking the outer node of a middleware chain instead of its root, and letting a job that declares nothing through to the panel each turn a test red.

Batchable reported zero across the measured graph, so rather than trust the zero it was checked directly against a job known to use the trait, which reports batchable: true. None of the reachable job nodes happens to be one of the four that use it.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Benchmark

base 8b7851a → head c62789e · PHP 8.4.25 · 10 repetitions per arm, interleaved in one job

What the scan detects

Deterministic: both arms scan the same generated application on the same PHP version, and every repetition is checked to produce the same figures. A difference here is a change in what Brain detects, not machine noise — which is worth looking at rather than automatically worth fixing, since detecting more legitimately moves these.

Scenario Nodes Edges Tabs Routes Security issues parse() calls
Full scan — 398 files 721 1,961 196 160 80 219
Full scan — 1,188 files 2,133 5,785 584 480 240 639
Method tracing — every entry-point method 6,661 325

No count changed on either corpus, including the per-node-type breakdown.

Timing

Median wall clock on a shared CI runner. Noise is how far the base arm's own repetitions sat from its median, discounting the single worst on each side, with their full spread beside it; a delta inside the noise is not a measurable effect.

Scenario Base Head Δ Noise Base spread
Full scan — 398 files 322 ms 326 ms +1.2% ±0.8% 318–324 ms
Full scan — 1,188 files 988 ms 993 ms +0.5% ±0.5% 982–1006 ms
Method tracing — every entry-point method 368 ms 369 ms +0.2% (within noise) ±1.3% 364–374 ms
Phase split — Full scan — 1,188 files

A phase carries at least as much runner noise as the scan it is part of, so each one gets its own noise floor from the base arm's repetitions of that phase. Useful for locating a large move; a phase that is a few percent of the scan cannot be read at a few percent of accuracy.

Phase Base Head Δ Noise
lifecycle 288.8 ms 290.0 ms +0.4% (within noise) ±0.4%
graph 184.0 ms 185.2 ms +0.7% (within noise) ±1.0%
queries 122.5 ms 122.9 ms +0.3% (within noise) ±2.2%
controllers 116.1 ms 116.0 ms -0.1% (within noise) ±2.8%
security 84.7 ms 84.9 ms +0.2% (within noise) ±1.1%
commands 40.3 ms 40.6 ms +0.5% (within noise) ±0.9%
models 31.3 ms 31.1 ms -0.6% (within noise) ±1.2%
routes 27.1 ms 27.3 ms +0.7% (within noise) ±0.9%
cmd_chains 20.6 ms 20.7 ms +0.2% (within noise) ±2.2%
facades 18.6 ms 18.5 ms -0.3% (within noise) ±1.6%
split 16.9 ms 17.2 ms +1.8% ±1.7%
observers 3.3 ms 3.3 ms +1.2% (within noise) ±2.7%
policies 2.5 ms 2.5 ms +0.4% (within noise) ±0.8%

Scans a synthetic Laravel application generated by benchmark/generate-corpus.php, not a real one — the shapes are chosen to stress the scan, so treat the absolute times as a workload, not as a user's scan. Reproduce locally with composer benchmark.

@SanderMuller SanderMuller left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at aa537823. The facts it reads are the right ones and the reading is careful. Approving, with two small things.

What it reads, run on shapes rather than read

class MethodFactsJob {
    public $tries = 5;
    public $timeout = 120;
    public function backoff(): int { return 60; }
    public function retryUntil(): \DateTime { return now()->addMinutes(5); }
    public function uniqueId(): string { return 'x'; }
}

comes out as tries=5 timeout=120 backoff=60 dynamic=[retryUntil, uniqueId]. The literal return becomes a number, the two that cannot be reduced say so instead of guessing, and neither is silently dropped. That is the distinction the description rests on and it holds.

On real job classes across three applications: 26, 40 and 29 described, of which 6, 15 and 9 declare anything at all. So the panel appears on roughly a quarter to a third of jobs, which is the right shape for a section people are meant to read rather than scroll past.

::class inside a middleware entry is read as a middleware

public function middleware(): array
{
    return [
        (new WithoutOverlapping(Order::class))->releaseAfter(60)->expireAfter(180),
        new RateLimited('reports'),
        SomeBareMiddleware::class,
    ];
}

reports [WithoutOverlapping, RateLimited, Order, SomeBareMiddleware]. Order is the key handed to the overlap guard, not a middleware.

The new scan does what the docblock says. The ::class scan beside it walks the whole method body rather than the top level of each returned entry, so any class constant used as an argument comes back as a name. The bare SomeMiddleware::class form it exists for is the top-level case, so restricting it to entries of the returned array keeps that and drops this.

It is narrow: of the 16 middleware() methods across three applications, none passes a ::class as an argument, so this is a shape I constructed rather than one I found. Worth a follow-up rather than a block.

The superseded entry chunk is left behind

The branch adds index-BwWD5GvV.js and does not delete the one it replaces, so both ship and only the new one is referenced. #117, #118 and #119 each deleted their predecessor; this is the one that does not. One git rm and the count stays at one.

Everything else

Gate: node and edge ids are identical on all three applications and the null arms came out identical, so the enrichment adds data to job nodes and moves nothing else. That is what an enrichment should look like.

The tests bite, each on its own axis. Dropping the literal-return read fails it takes the value from a method whose body is just a return. Taking the outer node of a middleware chain instead of the new fails it names the middleware class, not the chained configuration around it. Making isInteresting() always true fails it says nothing at all about a job that declares nothing. One test each, nothing collateral.

Loading classes is guarded properly. class_exists() inside a try/catch (Throwable) covers the case that matters: a job whose parent or interface is missing raises an Error rather than a warning, and that is caught. Asking the loaded class for ShouldBeUnique and the trait is the right call given both are as often inherited as written.

413 passed (1132 assertions), PHPStan clean, Pint clean, CI green across 15 jobs with per-job results. The committed bundle reproduces from source.

Also worth saying: catching your own fixture for using syntax only 8.4 parses, in a second commit, is the kind of thing the matrix exists to find and usually finds later.

Verdict

Approving. The ::class case and the leftover chunk are both a line each, and neither is a reason to hold a change that turns "this runs on a queue" into something you can act on at three in the morning.

webard and others added 5 commits September 4, 2026 18:33
A job node carried its name, its file and its flow, so "this runs on a queue"
was the whole story — not whether a failure is retried, not whether a second
dispatch is dropped on the floor, not whether the job refuses to overlap with
itself. Those are the differences that matter at three in the morning.

Two sources, for two kinds of fact. Interfaces are asked of the loaded class,
because ShouldBeUnique is as often inherited from a base job as written on the
job itself. Everything else is read from the source: a declared `$tries = 5`
is a fact the file states, and instantiating a job to ask it is not something
an analyzer should do — a constructor can take models, open connections, or
throw.

Reading properties alone would have found a third of it. Measured on an
application of 113 jobs: 44 declare ShouldBeUnique, 32 a uniqueId(), 14 a
middleware(), and the retry envelope is spread across 8 `$timeout`, 7 `$tries`,
8 retryUntil() and 4 backoff(). So a fact expressed as a method is reported
too: as its value where the body is a single `return 60;`, and otherwise as
decided-at-runtime. A computed backoff cannot be reduced to a number by
reading the source, and printing a guess would be worse than printing nothing.

Middleware names come from the `new` at the root of each entry, because
middleware is habitually configured by chaining and the outermost node of
`new WithoutOverlapping($key)->releaseAfter(60)` is a method call, not the
class anyone means.

A job that declares none of this gets no panel section at all — six nulls say
less than nothing.
`new X()->method()` is PHP 8.4. The analyzer never runs it — PhpParser reads
both spellings into the same MethodCall(var: New_), so the test still covers
the chained-configuration shape it was written for — but Pint lints the
fixtures, and on the 8.2 jobs that is a parse error. It went unnoticed locally
because this machine runs 8.5, where the file parses fine.
@webard
webard force-pushed the feat/job-lifecycle branch from 506ff56 to 9173d23 Compare September 4, 2026 16:33
@mrmarchone
mrmarchone merged commit 2012519 into laramint:main Sep 4, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants