Scale the two halves separately
Requests are milliseconds; scans are minutes. Those workloads saturate different resources and want different replica counts, which is why the split process shape exists at all.
Set
SCAN_WORKER_ENABLED=false on API replicas once you run workers separately,
or every API replica also becomes a worker and you lose the ability to size them
independently.
Why more workers are always safe
Two claims, both single conditional writes against the row itself. Interactive scans. A worker claims a scan withUPDATE … WHERE status = 'queued'. Exactly one worker wins; every other gets
ErrNotClaimable and moves on. Duplicate delivery is the normal case for a
cheap queue, and at-least-once delivery is only safe because the claim is one
statement rather than a read followed by a write.
Scheduled rescans. A schedule is claimed via a database lease
(claim_owner plus claim_expires_at), with the claim predicate repeated
inside the UPDATE’s WHERE so it is re-evaluated at write time. Under READ
COMMITTED a second worker blocks on the row, re-checks, finds the claim live,
and takes nothing.
The lease is an optimisation on top of a stronger guarantee. The scan a schedule
produces carries a derived idempotency key — schedule:<id>:<planned occurrence> — under a UNIQUE (org_id, idempotency_key) constraint. Two
schedulers that both believe they own the same due occurrence, because a lease
expired at exactly the wrong moment or a clock skewed, produce the same key and
the constraint admits exactly one row.
So running several worker replicas needs no configuration. There is no
“scheduler enabled” flag to set on exactly one of them, and no leader election
to misconfigure. Built the other way round — the lease as the guarantee — the
system would be correct only while leases behaved.
Worker concurrency versus worker replicas
Prefer replicas to concurrency.
SCAN_CONCURRENCY is small on purpose: the
scanning engine shares an on-disk cache between concurrent scans, so raising it
without limit produces lock contention rather than throughput. A second worker
process with its own cache scales more cleanly than doubling the concurrency of
one.
Each concurrent scan unpacks an image to disk while it matches, so plan disk for
SCAN_CONCURRENCY × your largest image, plus the shared vulnerability database.
Per-tenant fairness
Scaling out increases capacity; it does not by itself stop one tenant consuming all of it. Two mechanisms do that, and both are in SQL. Round-robin claiming. Both the interactive queue and the scheduler useROW_NUMBER() partitioned by tenant, so each tenant’s oldest item is taken
before anyone’s second. With N tenants due and a batch of B, no tenant gets
more than ceil(B/N) slots. The failure this prevents is the standard
multi-tenant one: a tenant with 10,000 assets due at midnight occupies every
worker slot while a tenant with one asset waits behind all of them.
A per-tenant concurrency cap. Each organisation’s plan bounds how many of
its scans may run at once across the deployment — the scan_concurrency
limit, 1 on Free, 5 on Pro and 20 on Enterprise — enforced at dispatch and
re-checked at claim time, because the wakeup queue is at-least-once and a
message can arrive long after it was published. SCAN_TENANT_CONCURRENCY is
the fallback for a tenant whose plan cannot be read. The cap is a ceiling, not
a reservation: capacity is SCAN_CONCURRENCY × worker replicas, shared by
every tenant, so a plan’s 20 means nothing extra on a deployment with two
slots.
The cap is a throttle, not a quota. Work beyond it stays queued and is
dispatched as that tenant’s running scans finish. Nothing is dropped and nothing
fails, which is the distinction that matters when a customer asks what happens
if they submit ten thousand images.
It is honest about its own strength: two workers claiming two different scans of
one tenant in the same instant can both see room, so it can be exceeded by the
number of workers racing. Exactness would need a per-tenant lock on the hot path
of every claim, which would cost more than the overshoot it prevents.
What survives what
The retry decision is made in SQL against the row’s own attempt count, so a
worker that dies between deciding and writing cannot leave a scan in a state its
attempt count contradicts. Backoff is exponential, capped at ten minutes, with
three attempts.
Scaling PostgreSQL and Redis
PostgreSQL is the system of record and the thing to size for durability. Every claim, every lease and every fairness query runs against it, so it is also where scaling out workers eventually shows up as load. Redis carries wakeups only. It can be small, and it can be lost. The wakeup queue gets its own connection pool rather than sharing the cache’s, because a blocking read holds a connection for its whole duration and would otherwise starve request-path reads.What is not here yet
None of these change the claiming mechanism; all of them change the ordering,
which is why they can be added without disturbing the guarantees above.
Next
- Health and metrics — the numbers that tell you which half to scale.
- Scanning — the concurrency settings in detail.

