RFC 0018-bis — The worker under load: invocations, slots, and results by content
| Field | Value |
|---|---|
| Status | Draft |
| Short | Worker under load |
| Settles | How one worker turns a job into concurrent, resumable, content-cached scanner invocations, and how a fleet of them drains a backlog |
| Author | Max Batleforc maxleriche.60@gmail.com |
| Co-author | — |
| Created | 2026-09-10 |
| Supersedes | — |
| Complements | RFC 0018 — the worker's inner loop (§5.4, §6.3): what happens between a lease and a verdict, which 0018 left sequential, all-or-nothing and unshared |
| Depends on | RFC 0022 for the sandbox slot: a run's memory cost is memory_limit_mb + max_extracted_mb there, and this RFC sizes its pool from it |
| Touches | crates/core, crates/adapters, crates/config, server, helm, docs |
1. Summary
RFC 0018 put the scan queue in PostgreSQL, gave it four priority lanes and an anti-starvation slot, deduplicated open jobs per coordinate, and scaled worker replicas on queue depth. That skeleton is right and this RFC keeps it. What it changes is everything inside one worker between a lease and a verdict. Today ScanWorker::run_once leases up to max_concurrent jobs and runs them one after the other; run_job runs a job's scanners one after the other; record writes the findings and the scanners_done list once, at the end; and a lease that expires on the third scanner re-runs the first two. A worker's parallelism is one, a crash is a full retry, and the same bytes republished under another name are scanned again from nothing.
This RFC makes the invocation — one scanner over one job — the unit of work. A leased job is turned into a plan of invocations; each runs from one of two pools, a network pool for the scanners that call out and a sandbox pool whose slot count is derived from the memory the worker has and the memory one sandbox costs; each records its own result row as it finishes, so a retry re-runs only what is missing and max_attempts counts per invocation. The verdict becomes a fold over the rows, computed the moment the last one lands. Scanners that judge bytes rather than a coordinate — trivy, postmortem scan, GuardDog, the SBOM gate — declare it, and their results are keyed on the scanner's fingerprint and the artifact's SHA-256, which the deduplicating storage router already knows: a mirror, a re-publish under a new name, or a rescan after nothing changed reuses the row instead of the sandbox. Around it, three smaller things: workers wake on a NOTIFY instead of a two-second poll, the lease takes turns across registries inside a lane, and the fleet scales on the age of the oldest open job rather than on depth, which cannot tell a backlog that is draining from one that is falling behind.
Nothing a client sees changes. The verdict model, the reason codes, the policies and batlehub why are byte-identical; what changes is how many sandboxes one worker keeps busy, how much of a job survives a crash, and how many times the same bytes are opened.
Before / after
# today — one job at a time, one scanner at a time, everything at the end
INFO security worker: leased 4 jobs
INFO security worker: verdict recorded package=npm:left-pad@1.3.0 to=allowed (38 s later; the other three waited under lease)
WARN security worker: job failed package=pypi:requests@2.32.0 error=scanner timed out (postmortem and trivy had answered; all three run again)
# with this RFC — invocations, from two pools, each recorded as it lands
INFO security worker: pools network=16 sandbox=2 (cgroup 8192 MiB, reserve 2256 MiB, 2560 MiB per run)
INFO security worker: job npm:left-pad@1.3.0 planned: 5 invocations (2 reused by content, 2 network, 1 sandbox)
INFO security worker: invocation done package=npm:left-pad@1.3.0 scanner=trivy source=reused content=sha256:9f2c…
INFO security worker: invocation done package=npm:left-pad@1.3.0 scanner=osv source=ran 0.4s
INFO security worker: verdict recorded package=npm:left-pad@1.3.0 to=allowed (6 s; three jobs ran beside it)
WARN security worker: invocation failed package=pypi:requests@2.32.0 scanner=guarddog attempt=2/3 error=scanner timed out (the other four rows stand)# today
[worker]
max_concurrent = 4 # the lease batch — and, in practice, nothing else
# with this RFC — the same key means what it says, and the pools are sized
[worker]
max_concurrent = 4 # jobs in flight at once, per worker
[worker.pools]
network = 16 # network-scanner invocations at once
sandbox = 0 # 0 = derived from the memory budget and one run's cost
memory_budget_mb = 0 # 0 = the cgroup's memory.max, else what is set here
[worker.results]
reuse_by_content = true # content-scope scanners reuse a result for the same bytes
reuse_ttl_secs = 604800 # a reused result older than a week is run again2. Motivation
- A worker's parallelism is one.
ScanWorker::run_once(crates/core/src/services/scan_worker.rs) leases up tomax_concurrentjobs, thenfor job in jobs { self.run_and_close(&job, …).await }; inside,run_jobdoesfor scanner in applicable { self.run_scanner(…).await }. Four leased jobs are one running and three waiting under a lease they heartbeat. A fleet of four replicas withmax_concurrent = 4has four sandboxes busy and twelve jobs leased and idle.queued()counts every job withcompleted_at IS NULL, leased or not, so those twelve still count as backlog and the HPA adds replicas for work the fleet has already taken and cannot start;batlehub_scan_jobs_leasedreports four per worker as if all four were being worked on. - A crash retries everything.
run_scanneraccumulatesfindingsanddonein memory andrecordwrites them once, after the last scanner and the enrichers. A lease that expires while GuardDog is on the third scanner (an OOM on a hostile archive, the case RFC 0018 §5.4 designs for) returns the job to the queue and the next attempt re-runspostmortemandtrivy, which had answered.max_attempts = 3is three runs of every scanner, not three of the one that fails. - The same bytes are scanned as many times as they have names. The storage router deduplicates by content:
artifact_dedup_refsmaps every logical key to acontent_hash(an artifact cached before dedup has no row and is hashed as it is read), andscan_jobs.artifact_sha256was created for this and is read back byJOB_COLUMNS— but nothing writes it. A tarball mirrored into two registries, re-published under a scoped name, or rescanned after arescan.interval_secsduring which neither the bytes nor the scanner changed, opens a sandbox fortrivy,postmortemand GuardDog each time, and the three answer the same thing. - Depth is the wrong signal to scale on. The chart's HPA targets
batlehub_scan_jobs_queuedattargetQueued = 20per replica. A queue of 200 that drains at 50 a minute and a queue of 200 that grows by 50 a minute are the same number, and the second is the one that needs replicas. The age of the oldest open job per lane says which is which; nothing exports it. - A worker sleeps two seconds between the enqueue and the lease. With an empty queue
runsleepsidle_poll(2 s) and polls. For a backfill it is nothing; for the first request on a version, where a person is looking atSCAN_PENDING(RFC 0018 §4.2), it is two seconds of a budget measured in tens. PostgreSQL hasLISTEN/NOTIFYand the queue is PostgreSQL. - Inside a lane, the first registry to burst wins.
lease_orderedorders bypriority, created_at. Two registries both producingfirst_seenjobs are served in arrival order, so a registry that just saw a monorepo publish 400 versions parks every other registry's next first request behind them at the same priority. - A rate limit is an answer today, and the wrong one. RFC 0019 gave the forge clients a shared
rate_limit_budgettable; OSV, Socket and mlab have nothing shared, so a fleet multiplies its load on them by the replica count. When Socket answers429,socket.rsreturnsScannerError::Upstreamwith a message that until 2026-09-10 read "the job is retried" (mlab's too) — and nothing retries it:run_scannerturns everyErrinto aSCANNER_ERRORfinding on the spot,evaluateappliesscanner_error_effectto it, and under the defaultquarantinethe version is held until a rescan or an administrator's rescan. A transient rate limit becomes a quarantine. The two messages now say what happens ("no answer this scan — the policy'sscanner_errormode applies until a rescan"); the behaviour they describe is what this RFC changes.
3. Goals / non-goals
Goals
- The unit of work is the invocation: one scanner over one job, with its own result row, its own attempts and its own timeout.
- A job whose worker died resumes where it stopped; only the invocations without a result run again.
- One worker keeps several sandboxes busy, and the number is derived from memory, never guessed: a worker cannot admit more sandboxes than it can hold.
- Network scanners and sandboxed scanners draw from separate pools, so a slow upstream never idles a sandbox slot and a heavy archive never blocks a lookup.
- A content-scope scanner's result is reused for the same bytes and the same scanner, across names, registries and rescans, with a TTL.
- Workers wake on enqueue; the poll remains as the fallback.
- The lease takes turns across registries inside a priority lane.
- The fleet scales on the age of the oldest open job per lane, and the metric exists whether or not the chart uses it.
- The verdict for a version is the same as today for the same scanner answers:
evaluateis untouched; only when and from what rows it is called changes.
Non-goals
- Replacing the queue. PostgreSQL with
SKIP LOCKEDis not the ceiling of this system and will not be at any load this project targets (§8). - Changing where a scanner's process runs. That is RFC 0022; this RFC consumes its
SandboxRuntimeand its per-run memory cost and adds nothing to it. - Warm sandboxes, pre-fetching artifacts before a lease, or speculative scanning. A sandbox that exists before its job is rejected in RFC 0022 §8 and stays rejected.
- Reusing results across scanner versions. A new
trivyis a new fingerprint and a new result; the point of a rescan is that the scanner may have changed its mind. - Any change to what a scanner finds, to the verdict model, the reason codes, the policies, or the client-facing surface.
4. User-facing design
4.1 Configuration
[worker]
# Jobs in flight at once per worker — leased *and* being worked on. This is
# what the key always said; until this RFC it was only the lease batch.
max_concurrent = 4
job_timeout_secs = 600 # unchanged: one invocation's ceiling, and the lease
max_attempts = 3 # now per invocation, for the errors that are retried (§4.2)
# The poll interval when the queue cannot notify (the in-memory queue, a
# connection that lost its LISTEN); on PostgreSQL the worker wakes on
# NOTIFY and this is only the safety net.
idle_poll_secs = 2
[worker.pools]
# Network-scanner invocations at once: osv, socket, mlab, sigstore, the
# forge checks, postmortem's `timeline`, trivy against a server.
network = 16
# Sandboxed invocations at once. 0 derives it:
# floor((memory_budget_mb - reserve) / (sandbox.memory_limit_mb + sandbox.max_extracted_mb)), at least 1
# where reserve = 256 + max_concurrent × max_artifact_size_mb, the worker's
# own working set (§4.2). A value here may lower the derived number; one
# above it is refused at worker start (§4.3).
sandbox = 0
# What the worker has. 0 reads the cgroup's memory.max (the pod's limit; on
# a bare host, the host's memory less 1 GiB); a value here overrides it for
# a worker that shares its cgroup with something else.
memory_budget_mb = 0
[worker.results]
# A content-scope scanner (§4.2) reuses a result recorded for the same
# artifact bytes and the same scanner fingerprint.
reuse_by_content = true
# Older than this, a reusable result is run again anyway. Keep it at or
# above the longest `[registries.security.rescan] interval_secs`, or a
# rescan reuses what it was meant to refresh (§4.3 warns).
reuse_ttl_secs = 604800Nothing is required. A config with today's [worker] block, or none, loads and behaves as §4.2 says with every default: the pools are derived, reuse is on, and max_concurrent finally means four jobs at once.
4.2 Behaviour rules
The life of one job, from lease to verdict:
- A leased job is planned, not run.
planlists the scanners the registry's profile names and thatsupports(kind), exactly as today, then subtracts every one that already has a standing result row for this job (a previous attempt's) or, for a content-scope scanner, a reusable one (§4.2 Results by content). What is left is the plan; each entry is an invocation withattempts = 0. - A scanner declares its scope.
ArtifactScannergainsfn scope(&self) -> ScanScope:Content { input }for a scanner whose answer depends on what it read and nothing else, naming what it read —Artifact(trivy fs,postmortem scanwithonline = false, GuardDog) orSbom(trivy sbom, the SBOM gate), so the reuse key is the hash of the artifact bytes or of the SBOM document respectively;Coordinatefor one whose answer depends on the name, the version, the date, the registry, or a lookup (osv,socket,mlab,sigstore, the forge checks,postmortem timeline,postmortem scanwithonline = true—--enrichasks the code hosts by name — and everyRuleAsScanner). The scope is answered per configuration, not per binary: the samepostmortemisContentoffline andCoordinateonline. The default isCoordinate: a scanner that does not say is never reused. - A scanner declares its fingerprint.
fn fingerprint(&self) -> String: for a binary scanner the SHA-256 of the binary it runs (read once at build, from the image on the image runtimes of RFC 0022 — the probe reports it); for GuardDog the package version plus the hash of its rules directory; for a network scanner the API version string it targets. Two results are comparable only when the fingerprint is equal. - Two pools, one job. Every invocation runs from the pool its scanner belongs to —
Contentandpostmortem scanfrom the sandbox pool, everything else from the network pool — under atokio::sync::Semaphoreeach. A job's invocations run concurrently across both pools; the job holds its lease and heartbeats while any of them is running. There is no ordering between scanners: none reads another's output, and the enrichers (mlab) run after the fold as today. - The sandbox pool is sized by memory. One run costs
sandbox.memory_limit_mb + sandbox.max_extracted_mb(RFC 0022 §4.2: the scanner'sRLIMIT_ASplus the memory-backed/work). The worker keeps a reserve for itself first:256 MiB + max_concurrent × max_artifact_size_bytes, becauseScanInput.artifactholds a job's bytes asBytesfor the life of the job and every job in flight may hold one (500 MiB by default). The pool isfloor((budget - reserve) / cost), at least 1, where the budget is the cgroup'smemory.maxunless configured. With the defaults — four jobs in flight, 500 MiB artifacts, 2048 + 512 per run — an 8 GiB pod reserves 2256 MiB and runs two sandboxes; a 16 GiB pod runs five; a 4 GiB pod cannot hold one and §4.3 refuses it with the arithmetic. The reserve is what it is because the artifact is held in memory; q3 records the follow-up that shrinks it.pools.sandboxmay lower the derived number; a value above it is refused. - A job in flight holds a slot in
max_concurrent, and the lease batch ismax_concurrent - in_flight, taken only when a slot frees. The three-of-four-idle case of §2 is gone: a leased job is always being worked on, andbatlehub_scan_jobs_leasedbecomes a saturation gauge that means it. - Each invocation records itself — on its own row, not in
artifact_findings. When a scanner answers, ascan_resultsrow is written carrying the outcome, the duration, the fingerprint, the content hash forContentscope, and the findings themselves as JSON.artifact_findingsis still written once, by the verdict, at the fold:batlehub whyand the console read that table beside the verdict, and a finding visible there before the verdict that judged it would be a finding with the wrong verdict next to it. When an invocation fails, what happens depends on the error, exactly as it does today for all but one class:Unsupported,Output,Crashed,TimeoutandOtherare answers on the first failure — aSCANNER_ERROR(orSCANNER_UNSUPPORTED) finding is written to the row and the row isexhausted, asrun_scannerdecides now. OnlyUpstreamis retried: the invocation'sattemptsgoes up,last_erroris kept,not_beforeis set fromRetry-Afterwhen there is one (capped atjob_timeout), and it goes back to the plan; atmax_attemptsit isexhaustedwith the same finding. A rate limit is the one error that means "ask again", and it is the one this RFC asks again. - The verdict is a fold over the rows, taken when the last one lands. With every planned invocation answered or exhausted, the worker runs the enrichers and calls
VerdictService::record_scanwith the union of the scanners' findings and the list of scanners that answered — the same call, the sameevaluate, the samescanners_done. Anexhaustedinvocation contributes itsSCANNER_ERRORfinding and is not in the list, sorecord_scancarries that scanner's previous findings forward exactly as it does today for a scanner that errored. A job whose every invocation was reused still reaches this step: the verdict is re-derived under the registry's current policy, which is what a rescan is for. - Resumption is the plan. A worker that dies leaves the job leased until
leased_until; the next worker to lease it plans again, finds the rows the dead worker wrote, and runs only the rest. Nothing is re-done that was recorded. Job-levelattemptsstays as RFC 0018's outer bound: a job re-leasedmax_attemptstimes without reaching the fold is closed byexhaustedas today. - Results by content. Before planning a
Content-scope invocation the worker looks upscan_resultsby(scanner, fingerprint, artifact_sha256)for a row younger thanreuse_ttl_secswhose outcome isok. Found, the invocation is reused: ascan_resultsrow is written for this job with the original row's findings (aFindingcarries no coordinate of its own, only the scanner and what it saw),source = reusedand the original's id, and no sandbox opens. The lookup needs the hash, so a job with anyContent { Artifact }invocation fetches the artifact first — from the cache blob, whose hashartifact_dedup_refsalready holds so no bytes are read for it; from a pre-dedup key or from upstream, hashed on the way asartifact_bytesstreams it — and recordsartifact_sha256on the job; aContent { Sbom }invocation hashes the stored SBOM's canonical JSON instead. ACoordinate-scope scanner is never reused, whatever the bytes. - Wake on enqueue.
PgScanQueue::enqueueruns inside a statement that alsoNOTIFYs thescan_jobschannel with no payload; an idle workerLISTENs on a dedicated connection and leases on the first notification, then falls back toidle_poll_secswhile the connection is down. The notification carries nothing and grants nothing: the lease is stillSKIP LOCKED, and a worker woken for a job another took simply finds none. - Turns across registries. Inside a priority lane,
leaseorders bypriority, registry_turn, created_at, whereregistry_turnranks each registry's oldest open job by how long ago that registry was last leased by anyone; the anti-starvation slot of RFC 0018 decision 16 is unchanged. A burst on one registry still drains in order; it no longer parks the others' next job behind it. - Rate limits are the invocation's, not the job's, and they are retried. A network scanner that answers
429(Socket today, OSV under a burst) fails its invocation withScannerError::Upstream, which is the retried class above; the job's other invocations are unaffected, and the job stays leased and heartbeats untilnot_beforepasses orjob_timeoutends it. Socket's and mlab's429messages then change once more, to say that the invocation is retried and when. The network pool additionally reads RFC 0019'srate_limit_budgetfor the forge scanners, as that RFC said the worker would. - Embedded mode (
roles = ["proxy", "worker"]) derives the sandbox pool from the same cgroup, which the proxy shares; the docs say to setmemory_budget_mbexplicitly there, and §4.3 warns when it is derived in a process that also serves.
4.3 Validation
AppConfig::validate() rejects:
| Condition | Rationale |
|---|---|
pools.network < 1 | A pool of zero never runs the scanners the profile requires; the job would wait for job_timeout and fail. |
pools.sandbox set above the derived value | The key is a ceiling on what memory allows; a value above it would admit a sandbox the budget cannot hold, which is the OOM §2 describes. Refused at worker start, where the cgroup is known, not at load. |
memory_budget_mb (set or derived) less the reserve below one run's cost | A worker that cannot hold one sandbox beside its own working set can scan nothing; refused at worker start with the three numbers in the message. |
max_concurrent < 1 | Unchanged. |
reuse_ttl_secs < 3600 | A result reused for less than an hour is a cache that saves nothing and complicates every explanation. |
Warnings (logged at load, and printed as !! lines by explain-config):
| Condition | Behaviour |
|---|---|
reuse_ttl_secs shorter than a registry's rescan.interval_secs | Allowed: the rescan re-runs the content scanners, as the operator seems to want; the warning names the registry. |
reuse_ttl_secs longer than a registry's rescan.interval_secs | Allowed and expected: the rescan re-runs the coordinate scanners (OSV, the forges) and reuses the content ones; the warning says so once, at load, so nobody reads "rescanned" as "re-run trivy". |
embedded mode with memory_budget_mb = 0 | Derived from a cgroup the proxy shares; the worker takes half of it and says so. |
pools.network > 64 | Allowed; the warning points at the upstream rate limits the pool will meet first. |
5. Architecture
5.1 The invocation tier
RFC 0018 has a job tier (the queue, the lease, the replicas) and RFC 0022 has a sandbox tier (where one scanner's process runs). This RFC is the tier between them: what one worker does with one leased job.
The invariant the tier protects: a scanner's answer is written once and survives the process that produced it. Everything else — concurrency, resumption, reuse — follows from every invocation having a row of its own.
5.2 Results by content
Reuse is safe for exactly one reason: a Content-scope scanner's answer is a function of the bytes and the scanner, and the key is both. A policy is not part of the key — the same trivy findings feed a block profile and a warn profile and produce different verdicts, which is why the fold always runs under the coordinate's own policy and the findings, not the verdict, are what is reused.
5.3 Wake, turns and slots
6. Detailed design
6.1 crates/core
ports/scanner.rs—ArtifactScannergainsfn scope(&self) -> ScanScope(defaultCoordinate) andfn fingerprint(&self) -> String(default: the scanner's name, which makes two builds of a scanner that does not say comparable — acceptable only forCoordinatescope, which is never reused; the doc comment says so).ScannerError::Upstreamgains an optionalretry_after: Option<Duration>carried from the response.entities/security.rs—ScanScope { Content { input: ContentInput }, Coordinate }withContentInput { Artifact, Sbom };ScanInvocation { job_id, scanner, attempts, not_before, last_error };ScanResult { id, job_id, coordinate, scanner, fingerprint, scope, content_sha256: Option<String>, outcome: ResultOutcome, source: ResultSource, findings: Vec<Finding>, duration, recorded_at }withResultOutcome { Ok, Exhausted }andResultSource { Ran, Reused { of: Uuid } }.ScannerError::retryable()—trueforUpstreamonly.ports/security.rs—ScanQueuegainsleaseordering by registry turn (a parameter, not a new method),record_artifact_hash(job_id, sha256), andwait_for_work(timeout) -> Result<bool, CoreError>(returns on a notification or the timeout; the in-memory queue returns after the timeout). A new port,ScanResultStore:standing(job_id) -> Vec<ScanResult>,reusable(scanner, fingerprint, sha256, max_age) -> Option<ScanResult>,record(result)(one row, findings included),invocation_failed(job_id, scanner, error, not_before),exhausted(job_id, scanner).services/scan_worker.rs— restructured aroundplan_job,run_invocationandfold;run_oncebecomes the lease loop of §4.2 (max_concurrent - in_flight,wait_for_workbetween passes). The two pools areArc<Semaphore>s held by the worker;run_invocationacquires the right one, runs the scanner withjob_timeout, and records throughScanResultStore.run_scanner's finding-on-error mapping moves intorecord's exhausted path unchanged.WorkerConfiggains the pool sizes andmemory_budget_mb; deriving them from the cgroup isserver's (§6.4),corereceives numbers.services/verdict.rs— untouched.record_scanis called with the same arguments it takes today; what changes is that they come from rows.
Deliberately untouched, so reviewers do not go looking:
services/rescan.rs— a rescan enqueues a job as today; reuse is decided at plan time by the TTL, not by the trigger.services/verdict.rs::evaluateand everyReasonCode— a result reused is a result, and the fold does not know the difference.- The proxy's
SCAN_PENDINGpath,batlehub why, the console's verdict views — they readartifact_verdictsandartifact_findings, both of which keep their shape.
6.2 crates/config
schema/security.rs—WorkerConfiggainsidle_poll_secs,pools: PoolsConfig { network, sandbox, memory_budget_mb }andresults: ResultsConfig { reuse_by_content, reuse_ttl_secs }, with the defaults of §4.1;validate()gains the load-time rows of §4.3. The start-time checks live inserver.
6.3 crates/adapters
migrations/060_scan_invocations.sql—scan_invocations (job_id, scanner, attempts, not_before, last_error, PRIMARY KEY (job_id, scanner))andscan_lease_turns (registry PRIMARY KEY, last_leased_at);scan_jobsgains nothing (artifact_sha256exists and is now written).migrations/061_scan_results.sql—scan_results (id, job_id, registry, package_name, version, scanner, fingerprint, scope, content_sha256, outcome, source, reused_of, findings JSONB, duration_ms, recorded_at)with the lookup index(scanner, fingerprint, content_sha256, recorded_at DESC) WHERE scope LIKE 'content%' AND outcome = 'ok'and the job index(job_id).findingsis the scanner's own answer and is hostile data: read as JSON intoVec<Finding>, never interpolated, the same ruleartifact_findings.rawcarries.migrations/062_scan_jobs_notify.sql— anAFTER INSERTtrigger onscan_jobsthatpg_notify('scan_jobs', ''). A trigger rather than a second statement inenqueue, so the CLI'sbackfilland the rescan timer wake workers too without knowing to.db/security.rs—PgScanQueue::lease_orderedtakes the order of §4.2:ORDER BY priority, COALESCE(t.last_leased_at, 'epoch'), created_atover aLEFT JOIN scan_lease_turns t USING (registry), and the same statement upsertsscan_lease_turnsfor the registries it leased — one small table, not a scan ofscan_jobsper lease;wait_for_workover asqlx::postgres::PgListener(thepostgresfeature the workspace already enables; no macros) on its own connection;record_artifact_hash. Newdb/scan_results.rsimplementingScanResultStore;recordis one insert.in_memory/security.rs— the same two ports in memory, for the web and CLI suites;wait_for_worksleeps the timeout.scanners/*.rs— each binary scanner implementsscope()andfingerprint()(the binary's SHA-256, computed once in the constructor fromcommand; on RFC 0022's image runtimes, read from the probe report).osv,socket,mlab,sigstorereturnCoordinateand their API version;socketandosvcarryRetry-AfterintoScannerError::Upstream.
6.4 server
setup.rs—build_scannersunchanged but for passing the fingerprint source; a newworker_pools(&WorkerConfig, &SandboxConfig) -> Result<Pools>reads/sys/fs/cgroup/memory.max(cgroup v2; v1'smemory.limit_in_bytesas the fallback;maxor unreadable means the host'sMemTotal - 1 GiB), applies the override, derives the sandbox pool, and performs the start-time refusals of §4.3.main.rs—start_scan_workerbuilds the pools and the result store and logs the line of §1 (pools network=… sandbox=… (memory budget …)).
6.5 helm, docs
values.yaml—worker.pools.*andworker.results.*rendered into the worker'sconfig.toml;worker.autoscaling.metricNamedefaults tobatlehub_scan_jobs_oldest_age_secondswithtargetAge: 120(seconds) and keepstargetQueuedas the legacy alternative behindworker.autoscaling.metric = age | queued. The worker'sresources.limits.memorycomment explains that it is the sandbox pool: 8 GiB is two sandboxes at the defaults with four jobs in flight, and the arithmetic is indocs/operations/scan-worker.md.docs/operations/scan-worker.md— "What one pass does" is rewritten around the plan and the pools; a "Sizing a worker" section gives the arithmetic; "What to watch" gains the age metric and the reuse ratio.docs/guide/configuration.md— the new keys.
6.6 Observability
batlehub_scan_jobs_oldest_age_seconds{trigger}gauge — the HPA input.batlehub_scan_invocations_total{scanner, outcome, source}counter —outcome=ok,error,exhausted;source=ran,reused.batlehub_scan_pool_in_use{pool}andbatlehub_scan_pool_size{pool}gauges — saturation per pool;sandboxsize is the derived number.batlehub_scan_job_duration_seconds{registry, scanner, outcome}keeps its name, its labels and its meaning — it is already per scanner, which is to say per invocation — and gainssource(ran,reused). A newbatlehub_scan_job_total_seconds{registry, trigger}histogram is the lease-to-verdict time, which nothing measures today.batlehub_scan_results_reused_bytes_total{scanner}counter — the bytes a reuse did not open, which is the number the reuse feature is judged on.- Spans: one per job, one per invocation under it, with the pool, the scanner, the source and the sandbox run's fields (RFC 0022 §6.6) nested.
7. Security considerations
The invariant of RFC 0018 §7 — the worker never executes artifact-supplied code with credentials in reach — is RFC 0022's to keep and this RFC does not touch it. What this RFC adds is a second kind of trust: a result written by one run and believed by another.
- Reuse is keyed on the exact bytes and the exact scanner. The key is the artifact's SHA-256 and the scanner's fingerprint, which for a binary scanner is the SHA-256 of the binary. Two coordinates share a result only when a collision-resistant hash says their bytes are identical and the same program judged them. An attacker who can make a hostile artifact hash like a clean one has broken SHA-256, not this design.
- A reused finding is as trustworthy as the run that produced it, and no more. A scanner that was wrong once is wrong for every coordinate that reuses it, for
reuse_ttl_secs. That is already true of a scanner's database (a staletrivyDB is wrong for every scan) and the TTL bounds it the same way. A rescan after a scanner upgrade changes the fingerprint and re-runs; the TTL catches the rest. - Reuse never crosses a policy. Findings are reused, verdicts are not. A coordinate under a
blockprofile is judged by the fold under its own policy, whatever profile the original run's coordinate had. - Reuse never crosses a scope.
Coordinate-scope scanners — the ones whose answer depends on who published, when, and under which name — are never reused, whatever the bytes. A typosquat is a property of the name; the same tarball underlodashandlodahsgets the sametrivyrow and a differentpostmortem timelinerow. - The result rows are the worker's, written with the worker's credentials, inside the wall's outer side. Nothing inside a sandbox can write
scan_results; the agent of RFC 0022 has one PUT on one key and it is not a table. - More sandboxes per worker is more blast radius per worker. The pool is derived from memory so that the OOM a hostile archive causes is the sandbox's (RFC 0022 §2, its own cgroup on the image runtimes,
RLIMIT_ASonbwrap) and never the worker's. Onbwrap, where the runs share the worker's cgroup, the derived pool is what keepsn × (memory_limit + max_extracted)under the limit; apools.sandboxabove it is refused for this reason, not as a style rule. - The notification is a wake-up, not a message.
NOTIFY scan_jobscarries no payload; a worker that receives it still leases throughSKIP LOCKED. Nothing an insert can say reaches the worker's parser. - Per-registry turns cannot be gamed into starvation. A registry with no open job takes no turn; a registry that floods takes one turn per round like every other. The anti-starvation slot across lanes is untouched.
8. Alternatives considered
| Alternative | Why rejected |
|---|---|
| Replace the queue with NATS, Redis Streams or RabbitMQ | The queue is not the bottleneck: the scanner CPU is. A SKIP LOCKED lease is a few hundred microseconds and a fleet of fifty workers polling every two seconds is twenty-five leases a second. RFC 0018 chose PostgreSQL because every deployment has it; that reason has not changed. |
| Temporal or another workflow engine for the plan | A second stateful system for a plan that is one table and one fold. The plan's state is scan_invocations and scan_results; a workflow engine would hold the same rows somewhere else. |
| Scale only by replicas, keep one sandbox per worker | A worker pod sized for one sandbox wastes the memory of the network scanners' wait and cannot use a large node; a pod sized for three needs the pool to exist. Replicas stay the horizontal knob; the pool is the vertical one. |
| One Kubernetes Job per scan job | Rejected in RFC 0018 §8 and RFC 0022 §8 for the same reason: it is a worker replica with a controller's latency and a second object to sweep. |
| Reuse verdicts, not findings | A verdict is a policy's judgement of findings; two registries with different profiles must judge the same findings differently. Reusing the verdict would silently apply one registry's policy to another. |
| Reuse by coordinate on rescan (skip the content scanners when the bytes did not change) | A special case of reuse by content with a worse key: it needs the previous hash on the verdict and cannot help a mirror or a re-publish. The content key subsumes it. |
| Reuse across scanner versions with a "compatible" flag | The point of a scanner upgrade is that it may find something the old one did not. A fingerprint change is a new result, and the TTL is the only other expiry. |
A shorter poll instead of LISTEN/NOTIFY | Polling at 200 ms from fifty workers is 250 queries a second for nothing; LISTEN is one idle connection per worker and a trigger. The poll stays as the fallback. |
scan_results rows in scan_jobs (one row per invocation) | The job's identity — the coordinate, the trigger, the lease — is one thing and a scanner's answer is another; a row per invocation would carry the coordinate five times and make the open-job unique index meaningless. |
| Order the lease by registry round-robin across lanes | Fairness inside a lane is enough; across lanes, priority is the point (a first_seen on any registry outranks a backfill on every registry), and decision 16's slot already keeps the lowest lane moving. |
9. Rollout and compatibility
- Default behaviour. With no config change: the pools are derived from the cgroup (a worker that ran one sandbox at a time in a 4 GiB pod still runs one, in an 8 GiB pod two),
max_concurrent = 4means four jobs in flight (it meant a batch of four before, of which one ran), reuse is on with a week's TTL, workers wake onNOTIFY. The observable change for an operator is throughput andbatlehub_scan_jobs_leasedbecoming honest. - One behavioural change a client can see. A transient
Upstreamerror — a429, an upstream that did not answer — no longer becomes aSCANNER_ERRORverdict at once; the invocation is retried up tomax_attemptswithRetry-Afterhonoured, and only then does the finding land. A version that was quarantined by a rate limit today is judged on a real answer tomorrow, a little later. Every other error class keeps today's timing. max_concurrentchanges meaning without changing name. The docs and the release notes say so in one sentence: "it now does what it says". A deployment that tuned it high to widen the lease batch gets that many jobs in flight, bounded by the pools; the pools, not the number, are what limits load.- Migrations. 060–062 are additive: three tables and one trigger. No existing row changes;
artifact_sha256starts being written. NoCURRENT_CONFIG_VERSIONmove — every new key has a default. - Mixed fleets during an upgrade. An old worker and a new one can share the queue: the old one ignores
scan_invocationsand writes the verdict at the end as before; the new one plans from rows that do not exist and runs everything. A job leased by a new worker and re-leased by an old one after a crash is scanned in full by the old one, which is today's behaviour. Nothing is corrupted either way. - Rollback. The previous release ignores the two tables and the trigger (an unread
NOTIFYis nothing). Rows inscan_resultsstay and are harmless; a later re-upgrade reuses them within the TTL, which is correct. - Operator prerequisites. None new for the worker. The HPA on age needs the same metrics adapter the HPA on depth needs, with a new rule for the new gauge; the chart's README carries both rules.
10. Test plan
- Unit (
crates/core,services/scan_worker.rstests with the in-memory stores): a job with three scanners produces three result rows and one verdict; a worker "killed" after two rows (the test drops the future) resumes and runs only the third; an invocation failingmax_attemptstimes writes oneSCANNER_ERRORfinding for that scanner and the verdict still lands; aContentscanner with a standing row within TTL isreusedand its findings appear on the new coordinate; aCoordinatescanner with identical bytes is not; a reused result older than the TTL runs; the fold under awarnprofile and ablockprofile from the same reused findings gives the two verdictsevaluategives today; the sandbox pool admits exactlyfloor(budget / cost)runs at once (Semaphorepermits observed); a429withRetry-Afteron one invocation leaves the others running and the job leased, and aCrashedon one is an answer at once; the findings of a landed invocation are not inartifact_findingsuntil the fold. - Unit (
server):worker_poolsagainst fixture cgroup files — v2memory.maxwith a number, withmax, unreadable; v1 fallback; the override; the refusal below one run's cost. - Integration (
crates/adapters/tests/pg_scan_worker.rs,task test:pg-scan):leasetakes turns across two registries in one lane while keepingpriorityfirst and decision 16's slot;wait_for_workreturns within 100 ms of an insert made through a separate connection (the trigger, not the enqueue method);recordwrites one row whosefindingsread back as theVec<Finding>that went in, andartifact_findingsis untouched by it;scan_lease_turnsis upserted by the lease statement itself; the reuse index answers the lookup withEXPLAINshowing the partial index. - Existing suites that must pass unchanged: every
crates/web/testsfile that reads verdicts (vuln_proxy_endpoints.rs, thewhyand console tests) — they prove the client surface did not move; RFC 0018'stests/heavy/quarantine.sh— every client-facing claim of its §13.4, re-run green; RFC 0022'stask test:sandbox-ocionce it exists — the pool composes with a real runtime. - Load (
tests/heavy/scan_load.sh, on the same trigger as the heavy suites): enqueue 2 000backfilljobs over 200 distinct artifacts (ten names per blob) against one worker with a 6 GiB budget; assert the pool size logged is 2, the reuse ratio ontrivyandpostmortemis above 0.85,batlehub_scan_jobs_oldest_age_seconds{trigger="backfill"}falls monotonically after the first minute, and the drain time is under a fifth of the sequential run's measured on the same host. The numbers are recorded in §13 when the suite runs, not promised here.
11. Decisions and open questions
Resolved
| # | Question | Decision |
|---|---|---|
| 1 | Is the unit of work the job or the invocation? | The invocation. §4.2. It is what has a timeout, an attempt count and a result; the job is the coordinate and the lease. |
| 2 | Where does an invocation's result live? | In its own row, written when it lands. §4.2, §6.3. Resumption, concurrency and reuse all follow from it; nothing follows from an in-memory list written at the end. |
| 3 | How many sandboxes per worker? | Derived from memory, never configured up. §4.2, §7. floor(budget / (memory_limit + max_extracted)); a configured value is a ceiling. |
| 4 | One pool or two? | Two. §4.2. Network scanners wait on upstreams; sandboxes hold memory; one pool would let either starve the other. |
| 5 | Reuse verdicts or findings? | Findings, keyed on bytes and scanner. §5.2, §7, §8. Policy is applied at the fold, per coordinate. |
| 6 | Which scanners are reusable? | Those that declare Content scope. §4.2. The default is Coordinate, so a scanner is never reused by accident. |
| 7 | What is a scanner's fingerprint? | The binary's SHA-256; for GuardDog its version plus its rules hash; for a network scanner its API version. §4.2, §6.3. Open for GuardDog's exact rule set (q1). |
| 8 | Wake on NOTIFY or poll faster? | NOTIFY from a trigger, poll as the fallback. §4.2, §6.3, §8. A trigger so that every enqueuer wakes workers. |
| 9 | Keep max_concurrent's name? | Yes, and make it mean what it says. §9. The chart, the docs and RFC 0018 use it; a rename would be a migration for a key that was simply under-implemented. |
| 10 | Scale on depth or age? | Age, with depth kept as the legacy option. §2, §6.5. Depth cannot tell draining from falling behind. |
| 11 | Fairness across registries? | Turns inside a lane; priority across lanes untouched. §4.2, §8. |
| 12 | Does this RFC change RFC 0022? | Its concurrency assumption only. 0022 §4.2 said "one sandbox at a time per worker" because that was the tree; it now reads "the sandbox pool of 0018-bis", and its quota is sized from replicas × pool. |
| 13 | Where do an invocation's findings live before the verdict? | On its scan_results row, as JSON. §4.2, §6.3. artifact_findings is written by the verdict at the fold, as today, so nothing reads a finding beside a verdict that has not judged it. |
| 14 | Which errors are retried? | Upstream only. §4.2, §9. Every other class is an answer on the first failure, as run_scanner decides today; a rate limit is the one error that means "ask again". |
| 15 | How does the lease take turns? | A scan_lease_turns row per registry, upserted by the lease. §6.3. One small table rather than an aggregate over scan_jobs on every lease. |
Still open
- q1 — GuardDog's fingerprint. GuardDog's findings depend on its Python package version and on the semgrep rules it ships, which move with the package; whether the package version alone is a sufficient fingerprint, or the rules directory must be hashed, is decided by reading how the rules are versioned in the release. Recommendation: hash the rules directory too; it costs one walk at build time.
- q2 — the budget's share in embedded mode. Half of the cgroup is the proposal; the proxy's own memory under load is what decides whether half is right, and it has not been measured with the pools on. Recommendation: half, with the warning of §4.3, and revisit with the load suite's numbers.
- q3 — the artifact held as
Bytes. The reserve of §4.2 is large becauseScanInput.artifactkeeps a job's bytes in memory for the life of the job, andmax_concurrentjobs may each hold 500 MiB. RFC 0022's bundle writer reads a cached artifact from the storage blob and never needs the bytes in the worker at all; once the scanners take the artifact by reference (a storage key, or aTempDirpath) rather than by value, the reserve drops to the base and the pool grows accordingly. Recommendation: do it with 0022 phase 1, whereScanInputchanges anyway, and reduce the reserve then. - q4 — whether
trivyin server mode isContentscope. Its answer depends on the bytes and on the server's database, which the fingerprint does not see. Recommendation:Coordinatefor the server mode (never reused) andContentfor the local-database mode, where the database version is folded into the fingerprint.
12. Implementation phases
| Phase | Content |
|---|---|
| 1 | Rows and resumption. ScanResultStore, migrations 060–061, scope() and fingerprint() on the port with every scanner implementing them, plan_job / run_invocation / fold with no concurrency yet (the pools exist with size 1), findings on the row and artifact_findings written at the fold, Upstream as the one retried class. Same verdicts; a crash resumes; a 429 waits instead of holding. Useful alone. One PR. |
| 2 | Pools and slots. worker_pools from the cgroup, the two semaphores, max_concurrent as jobs in flight, the start-time refusals, the pool metrics, the chart's memory comment. Depends on RFC 0022 phase 1 only for the per-run cost being the documented one; works on today's bwrap with the same arithmetic. |
| 3 | Results by content. artifact_sha256 written, the reuse lookup, source = reused, the reuse metrics, q1 and q4 answered. |
| 4 | Wake, turns, age. Migration 062 and wait_for_work, scan_lease_turns and the registry turn, batlehub_scan_jobs_oldest_age_seconds, the chart's metric = age, Retry-After on the network scanners, with their 429 messages saying so. |
| 5 | The load suite and a §13 with its numbers. |