Scaling TRUSTIVAN is adding replicas. There is no leader to elect, no shard to rebalance, and no coordination step to get right — because the mechanisms that make concurrency safe are in the database rather than in the deployment.

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 with UPDATE … 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 use ROW_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