fix(core): harden scan durability and idempotency (#303) - #325
fix(core): harden scan durability and idempotency (#303)#325SHAURYAKSHARMA24 wants to merge 11 commits into
Conversation
|
@SHAURYAKSHARMA24, this is the canonical track for #303’s durability layer: transaction recovery, leases/fencing, idempotent admission and writes, durable enrichment, and worker telemetry. One integration boundary must be resolved before lead review: migration |
ritiksah141
left a comment
There was a problem hiding this comment.
Reviewed all 28 files end to end: the five migrations, the lease and fencing layer in api/models/finding.py, the worker loop, the new enrichment worker, the scan routes, observability, and the NVD client. Also ran the suite locally against a scratch Postgres with the migrations applied from base to head.
The lease and fencing design is correct and applied consistently. The fault-injection tests are thorough and map to every acceptance criterion in #303. Two functional gaps should be resolved before merge, plus a few smaller items.
Must fix
-
Terminally failed enrichment jobs are unrecoverable. After 3 attempts fail_enrichment_job marks the job failed. From there POST /api/scans//enrich returns the dead job via ON CONFLICT DO NOTHING, claim_next_enrichment_job only picks pending, and recover_stale_enrichment_jobs only handles expired running leases. So a scan whose enrichment exhausts its retries is stuck unless someone edits the DB by hand. This is a regression from the previous thread-based path, where a re-POST simply worked. Please reset a terminal failed job back to pending on enqueue, or return an explicit 409 telling the operator.
-
rule_evaluations is dead in production. The migration, unique constraint, the save_scan upsert, and the tests all exist, but nothing populates it: scanner/engine.py run_scan returns no evaluations key and no production code emits one. Either wire the engine to emit evaluations, or scope this explicitly as storage-only for now. As written, the #303 evaluations criterion looks met but is not observable in production.
Should fix
-
uq_scans_one_active_per_subscription can leave an INVALID index. If a deployment already has two or more active (pending/running) scans for one subscription, CREATE UNIQUE INDEX CONCURRENTLY fails and leaves an invalid index behind silently. Add a dedupe/cleanup note to the deployment-order doc, or a cleanup step in the migration before the index is created.
-
The enrichment fixture assumes the pending queue is empty. This is a general test-isolation issue, not an environment quirk. claim_next_pending_scan claims the oldest pending scan, but the fixture assumes it claims the scan it just created. Any developer who sets both DATABASE_URL and AZURE_SUBSCRIPTION_ID (common when developing against a real local Postgres plus Azure) and runs the full suite will hit this: the pre-existing role tests in test_auth.py admit a real pending scan, and the enrichment fixture then claims that older row instead of its own scan, so save_scan correctly raises LostLease. Make the fixture robust by truncating scans/enrichment_jobs in setup, or by asserting on the claimed scan_id.
Nits
- docs/api-reference.md is not updated for Idempotency-Key, the 409/429 responses, the 200 replay response, and the enrich job_id response.
- The new env vars OPENSHIELD_MAX_SCANS_PER_SUBSCRIPTION_PER_HOUR, SCAN_LEASE_SECONDS, and SCAN_HEARTBEAT_SECONDS are missing from .env.example.
- worker_heartbeats grows unbounded: one row per worker restart, never cleaned.
- /metrics now runs full DB aggregates on every scrape with no caching. Fine at current scale, worth a note.
- Enrichment jobs run serially ahead of scans in the same worker loop. Worth documenting as a throughput characteristic.
What looks good
Fencing is correct and applied uniformly on every authoritative write. The fault-injection suite is the most thorough in the repo and covers every #303 acceptance criterion. The deployment-order doc is honest about the migrate-then-run-workers constraint. Error sanitization is preserved. NVD pagination (resultsPerPage=2000, following totalResults, bounded retries with 429 backoff) is done properly. Metrics are bounded-cardinality.
|
Following up on @m-khan-97's integration note, here is the concrete side-by-side between this PR's rule_evaluations handling and the one in #321, so we converge on a single canonical model. Short version: this PR should drop the evaluation schema and keep the fencing, and the two save_scan implementations need to be reconciled, not just the table. Schema: rule_evaluations
The core columns, keys, and constraints overlap and share names, so whichever migration runs second fails with "relation already exists." #321 is a strict superset: it adds the rule_id and status indexes and the reason_code-required CHECK. Producer (who emits evaluations)
Decisive difference: this PR has no producer, so its evaluations are always empty in production. Only #321 makes evaluations observable. save_scan persistence semanticsBoth PRs are full rewrites of the same method with incompatible idempotency models, so the conflict is larger than the table.
Compliance score (the actual #263 bug)
Only #321 fixes the score-inflation bug. Recommended resolution
One integration detail for whoever reconciles: #321's engine adds evaluate()-derived FAIL findings to the findings list, and this PR derives finding_key from rule_id plus resource scope plus discriminator. Those compose, but make sure evaluate()-derived findings get stable finding_keys so the upsert stays idempotent. |
parthrohit22
left a comment
There was a problem hiding this comment.
This is careful work — the connection-lifecycle rework (discard-and-reacquire on an aborted/unknown-status transaction instead of trusting a poisoned connection), the stable_finding_key() identity hash excluding presentation fields so retries update rather than duplicate, and the legacy:<id> backfill for existing rows before adding the unique index (with CREATE INDEX CONCURRENTLY in an autocommit block, so it doesn't lock writes) are all the right calls. CI is green.
One real blocker before this can merge, not about the code itself: this PR's first migration (e4f7a9b2c6d8) forks off d8e4f6a1b2c3, same as #310's 3a76ff935bf6 — both currently share that parent, so if both land as-is alembic heads ends up with two heads. Whichever of #310/#325 merges second needs to rebase and repoint its down_revision, same as the #308/#310 fork we resolved earlier. Given this PR also touches api/models/finding.py/scanner/worker.py/api/routes/scans.py — the same files #310 rewrites — that rebase is going to be a real one, not just a migration-pointer fix. Worth coordinating merge order with #310 explicitly before either goes in.
Requesting changes only for the migration fork — nothing else jumped out as wrong in what I read.
|
Ordering proposal for this PR and #321. My suggestion: merge #321 first as the canonical evaluation contract, then rebase this PR on top of it. Concretely that means dropping the duplicate rule_evaluations migration and the evaluation upsert from this PR, re-chaining the remaining migrations on top of 3f59f83a5253, and keeping the leases, fencing, admission, enrichment, and metrics work here. This matches the resolution agreed above. Two reasons I want to move this now rather than wait. @SHAURYAKSHARMA24 does not seem active at the moment, and my own draft PR #293 also depends on this resolution landing, since it needs the canonical contract in place before it can move forward. @m-khan-97 can you approve the ordering, #321 first? With your sign-off we can lock in the rebase plan and unblock #293. Happy to help drive it however is useful. |
…g#303) Resolves the two must-fix items and the follow-ups raised in review of openshield-org#325. Integration: leave the openshield-org#263 evaluation contract to openshield-org#321 ------------------------------------------------------ This branch created a second `rule_evaluations` table, near-identical to the one PR openshield-org#321 adds, that no production code ever populated: `run_scan()` emits no `evaluations` key, so the storage, the upsert and its tests described a contract that could not be observed in production. Two `CREATE TABLE rule_evaluations` statements would also have broken whichever of openshield-org#321/openshield-org#325 merged second, independently of the Alembic head ordering. Issue openshield-org#263 owns that contract - the table, the PASS/FAIL/UNKNOWN/ERROR/ NOT_APPLICABLE semantics, engine emission and the compliance-score fix - so this branch drops it entirely and keeps only what openshield-org#303 asks for: the stable `finding_key` identity and its unique index. `save_scan()` marks where openshield-org#321's evaluation writes belong, inside the fenced completion transaction, so they inherit the lease/ownership check without re-implementing it. Terminally failed enrichment jobs are recoverable again ------------------------------------------------------- After three failed attempts a job became `failed` and nothing could move it: enqueue used ON CONFLICT DO NOTHING, claim only selected `pending`, and stale recovery only handled expired `running` leases. That regressed the operator retry the old thread-based path gave for free. `enqueue_enrichment_job()` now returns an explicit outcome - `created`, `requeued`, `active` or `completed` - and atomically resets a `failed` job to `pending` with a fresh retry budget. It keeps the same job row, its last error message (audit) and its checkpoint (so the retry resumes), never revives a `completed` job, and never disturbs a live `running` lease. The conditional UPDATE is the whole guard, so concurrent re-POSTs converge on one logical job. Admission migration cannot leave an INVALID index ------------------------------------------------- `CREATE UNIQUE INDEX CONCURRENTLY uq_scans_one_active_per_subscription` fails, and leaves an unusable index behind, on a deployment that already holds several active scans for one subscription. The migration now preflights, changes nothing, and names the offending subscriptions in an actionable error; deciding which production scan is authoritative stays an operator call, and no scan history is deleted. A retry is safe: an INVALID index from an interrupted build is dropped before rebuilding. PostgreSQL tests no longer race the shared queue ------------------------------------------------ Fixtures called `claim_next_pending_scan()`, which takes the globally oldest pending scan, then persisted against the scan they had just created - so any unrelated pending row made `save_scan()` raise LostLease. Reproduced on a real database: with one older pending scan present, the old fixture claims someone else's row and fails; the new one does not. `claim_next_pending_scan()` and `claim_next_enrichment_job()` take an optional `scan_id` so a caller can claim a known row under identical lease and fencing semantics, and the fixtures use it. Queue-wide `recover_stale_*()` counts are asserted as progression of the test's own row rather than as global totals. Follow-ups ---------- - worker_heartbeats is bounded: rows past WORKER_HEARTBEAT_RETENTION_SECONDS are pruned on the beat that registers a new worker identity - once per process, not on every beat, and never for a live worker. - The scan and enrichment queues alternate one item per loop iteration instead of draining enrichment first, so neither can starve the other. - Added the partial index that keeps /metrics' last-successful-scan lookup from degrading into a sequential scan as history grows. - Documented SCAN_LEASE_SECONDS, SCAN_HEARTBEAT_SECONDS, OPENSHIELD_MAX_SCANS_PER_SUBSCRIPTION_PER_HOUR and WORKER_HEARTBEAT_RETENTION_SECONDS in .env.example. - docs/api-reference.md now documents the real admission and enrichment contracts, including which status codes are actually returned. Tests ----- New PostgreSQL coverage for terminal-failure requeue (explicit requeue, no restart of a completed job, a live lease is not stolen, concurrent requeues converge, a stale token cannot write after reclaim, a requeued job completes), the duplicate-active-scan migration preflight, stale heartbeat pruning, and worker fairness with both queues backlogged. Full suite on postgres:16-alpine: 897 passed, 2 skipped. The one failure, test_vector_store_purity, is a local checkout artifact - it needs a BM25 index this checkout's ai/vectorstore lacks, and passes in a clean worktree. Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
…g#303) Resolves the two must-fix items and the follow-ups raised in review of openshield-org#325. Integration: leave the openshield-org#263 evaluation contract to openshield-org#321 ------------------------------------------------------ This branch created a second `rule_evaluations` table, near-identical to the one PR openshield-org#321 adds, that no production code ever populated: `run_scan()` emits no `evaluations` key, so the storage, the upsert and its tests described a contract that could not be observed in production. Two `CREATE TABLE rule_evaluations` statements would also have broken whichever of openshield-org#321/openshield-org#325 merged second, independently of the Alembic head ordering. Issue openshield-org#263 owns that contract - the table, the PASS/FAIL/UNKNOWN/ERROR/ NOT_APPLICABLE semantics, engine emission and the compliance-score fix - so this branch drops it entirely and keeps only what openshield-org#303 asks for: the stable `finding_key` identity and its unique index. `save_scan()` marks where openshield-org#321's evaluation writes belong, inside the fenced completion transaction, so they inherit the lease/ownership check without re-implementing it. Terminally failed enrichment jobs are recoverable again ------------------------------------------------------- After three failed attempts a job became `failed` and nothing could move it: enqueue used ON CONFLICT DO NOTHING, claim only selected `pending`, and stale recovery only handled expired `running` leases. That regressed the operator retry the old thread-based path gave for free. `enqueue_enrichment_job()` now returns an explicit outcome - `created`, `requeued`, `active` or `completed` - and atomically resets a `failed` job to `pending` with a fresh retry budget. It keeps the same job row, its last error message (audit) and its checkpoint (so the retry resumes), never revives a `completed` job, and never disturbs a live `running` lease. The conditional UPDATE is the whole guard, so concurrent re-POSTs converge on one logical job. Admission migration cannot leave an INVALID index ------------------------------------------------- `CREATE UNIQUE INDEX CONCURRENTLY uq_scans_one_active_per_subscription` fails, and leaves an unusable index behind, on a deployment that already holds several active scans for one subscription. The migration now preflights, changes nothing, and names the offending subscriptions in an actionable error; deciding which production scan is authoritative stays an operator call, and no scan history is deleted. A retry is safe: an INVALID index from an interrupted build is dropped before rebuilding. PostgreSQL tests no longer race the shared queue ------------------------------------------------ Fixtures called `claim_next_pending_scan()`, which takes the globally oldest pending scan, then persisted against the scan they had just created - so any unrelated pending row made `save_scan()` raise LostLease. Reproduced on a real database: with one older pending scan present, the old fixture claims someone else's row and fails; the new one does not. `claim_next_pending_scan()` and `claim_next_enrichment_job()` take an optional `scan_id` so a caller can claim a known row under identical lease and fencing semantics, and the fixtures use it. Queue-wide `recover_stale_*()` counts are asserted as progression of the test's own row rather than as global totals. Follow-ups ---------- - worker_heartbeats is bounded: rows past WORKER_HEARTBEAT_RETENTION_SECONDS are pruned on the beat that registers a new worker identity - once per process, not on every beat, and never for a live worker. - The scan and enrichment queues alternate one item per loop iteration instead of draining enrichment first, so neither can starve the other. - Added the partial index that keeps /metrics' last-successful-scan lookup from degrading into a sequential scan as history grows. - Documented SCAN_LEASE_SECONDS, SCAN_HEARTBEAT_SECONDS, OPENSHIELD_MAX_SCANS_PER_SUBSCRIPTION_PER_HOUR and WORKER_HEARTBEAT_RETENTION_SECONDS in .env.example. - docs/api-reference.md now documents the real admission and enrichment contracts, including which status codes are actually returned. Tests ----- New PostgreSQL coverage for terminal-failure requeue (explicit requeue, no restart of a completed job, a live lease is not stolen, concurrent requeues converge, a stale token cannot write after reclaim, a requeued job completes), the duplicate-active-scan migration preflight, stale heartbeat pruning, and worker fairness with both queues backlogged. Full suite on postgres:16-alpine: 897 passed, 2 skipped. The one failure, test_vector_store_purity, is a local checkout artifact - it needs a BM25 index this checkout's ai/vectorstore lacks, and passes in a clean worktree. Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
`lease_configuration()` guarantees the heartbeat interval stays strictly shorter than the lease - a worker that heartbeats no more often than its lease expires would lose its own claim mid-scan and have its results fenced out. .env.example documents that constraint, but nothing tested it. Covers the defaults, valid overrides, heartbeat == lease, heartbeat > lease, a lease small enough that `lease // 3` would floor to a zero-second heartbeat, and malformed/non-positive values falling back to the defaults. Verified the tests fail when the clamp is removed (3 failures) and pass when it is restored. Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
b5c8506 to
9da3d3e
Compare
…sert Per the openshield-org#321/openshield-org#325 (openshield-org#303) reconciliation discussion: a retried or replayed scan result must converge on the same rule_evaluations rows via ON CONFLICT (scan_id, rule_id, resource_id) DO UPDATE, not a delete-then-reinsert that could momentarily leave a concurrent reader seeing zero coverage for a scan that already has some. Rows for a rule/resource no longer present in the current evaluation set are removed afterward (delete-absent), scoped to the current scan. Findings persistence is unchanged (still delete-then-reinsert): openshield-org#325 owns the finding_key/upsert model for findings, since that requires schema openshield-org#321 doesn't have. This change is scoped to rule_evaluations, which is openshield-org#321's own table. Signed-off-by: Dipesh Ray <dipesh.ray.g@gmail.com>
Summary
Implements the #303 hardening contract: transaction recovery, fenced scan leases, idempotent result persistence, durable scan admission, durable CVE enrichment, and bounded operational signals.
Scope note (changed after review): this PR does not implement the #263 rule-evaluation coverage contract. That contract — the
rule_evaluationstable,PASS/FAIL/UNKNOWN/ERROR/NOT_APPLICABLEsemantics, engine emission, and theget_compliance_score()fix — is owned by #321. An earlier revision of this branch carried a second, near-identicalrule_evaluationstable that nothing populated; it has been removed. See Relationship to #321 and #310.Problems fixed
Architecture
ON CONFLICT.pending/runningscan per subscription and a unique subscription/idempotency-key pair. Same semantics replay the logical scan; changed semantics conflict.OPENSHIELD_MAX_SCANS_PER_SUBSCRIPTION_PER_HOURprovides an explicit optional time-window policy; one active scan remains the enforced concurrency quota.totalResultsthrough every page./metricsreads bounded PostgreSQL aggregates; labels are onlyqueue(scan/enrichment) andworker_type.Enrichment job lifecycle
POST /api/scans/<scan_id>/enrichnever creates more than one job per scan and reports what it did via anoutcomefield:outcomecreatedrequeuedfailedjob was reset topendingwith a fresh retry budget.activepending/runningjob already exists; a live lease is never disturbed.completedA requeue keeps the same job row, its last
error_message(audit) and itscheckpoint(so the retry resumes rather than re-enriching findings that already succeeded). Concurrent re-POSTs converge: exactly one reportsrequeued, the rest reportactive.Database migrations
e4f7a9b2c6d8— renewable scan leases and fencing tokens.f2b6d8e1a4c9— stable finding identities. Existing findings receive distinctlegacy:<id>keys; no legacy rows are silently collapsed.a7c5e9d2f1b4— durable scan admission/idempotency indexes.c9e1a5b7d3f2— durable fenced enrichment jobs.d4a8c1e6b2f9— worker heartbeat storage and the metrics index for completed scans.Rebased onto current
dev(f69db7a). There is exactly one Alembic head,d4a8c1e6b2f9, chained fromd8e4f6a1b2c3— which is stilldev's Alembic head, since no migration has landed ondevsince. (d8e4f6a1b2c3is an Alembic revision id, not a git SHA.)Migration prerequisite: one active scan per subscription
a7c5e9d2f1b4enforces onepending/runningscan per subscription. On a deployment that already violates that rule,CREATE UNIQUE INDEX CONCURRENTLYwould fail and leave an INVALID index behind. The migration now preflights instead: it stops before creating any index, changes nothing, and names the offending subscriptions:Deciding which production scan is authoritative is deliberately left to the operator — no scan history is deleted or rewritten automatically. Retrying is safe: the migration drops the INVALID index left by an interrupted concurrent build before rebuilding. Cleanup order is documented in
docs/async-scan-architecture.md.Concurrency guarantees
All authoritative scan-result writes re-check lease owner, fencing token,
runningstate, and unexpired lease underFOR UPDATEin the same transaction as persistence. Once worker A loses its lease and worker B reclaims with a newer token, A cannot update scan state, findings, or enrichment progress. The same holds for enrichment checkpoints, retry state, completion, and CVE outputs. PostgreSQL unique constraints and upserts make duplicate API/result/job delivery converge on one logical record.Deployment
d4a8c1e6b2f9.scanner/worker.py. The worker now processes both scan and enrichment jobs./metricsfor worker liveness, queue age, lease age, retries, and last successful scan.Configuration
SCAN_LEASE_SECONDS,SCAN_HEARTBEAT_SECONDS,OPENSHIELD_MAX_SCANS_PER_SUBSCRIPTION_PER_HOURandWORKER_HEARTBEAT_RETENTION_SECONDSare documented in.env.example, including the heartbeat-must-be-shorter-than-lease constraint (a heartbeat >= lease logs a warning and falls back to one third of the lease).Relationship to #321 and #310
rule_evaluations— the table, the statuses, engine emission, and the compliance-score fix. This PR previously created a competing near-identical table that no production code populated; that is removed, so the two PRs no longer collide onCREATE TABLE rule_evaluations. When feat(engine): add rule evaluation coverage contract (#263) #321 lands, its evaluation writes belong inside this PR's fenced completion transaction and inherit the ownership check for free (a comment insave_scanmarks the exact spot).3a76ff935bf6), feat(engine): add rule evaluation coverage contract (#263) #321 (3f59f83a5253) and this PR (e4f7a9b2c6d8) all still chain fromd8e4f6a1b2c3, the currentdevAlembic head. Neither fix(compliance): make framework reports evidence-based and non-certifying #310 nor feat(engine): add rule evaluation coverage contract (#263) #321 is merged (fix(compliance): make framework reports evidence-based and non-certifying #310 is currently conflicting againstdev; feat(engine): add rule evaluation coverage contract (#263) #321 is still a draft), so this PR depends on neither and produces exactly one head on its own. Whichever merges second repoints itsdown_revision— a one-line change now, since no two of them create the same object. Merge order remains an open maintainer decision; happy to rebase onto whichever lands first.Tests
Re-run after the rebase onto current
dev:dev's own AI/RAG tests that require a locally built BM25 index; they are unrelated to this PR.#303-focused suites againstpostgres:16-alpine: scan leases (12), admission (3), admission-migration (3), enrichment jobs (10), operational metrics (2), worker (11 + 4 subtests), enrich route (7), NVD (16), observability (12), database reliability (7), async persistence (9).dev-Alembic-head→head, full 5-step downgrade→upgrade round trip, andalembic headsreturning exactlyd4a8c1e6b2f9 (head).ruff check .andruff format --check .pass.Acceptance criteria (#303)
Evaluation persistence is idempotent by construction once #321 lands (its writes sit inside this fenced transaction), but this PR does not itself persist evaluations and does not claim that criterion.
Known limitations
/metricsrecomputes its aggregates on every scrape. The queue/lease/heartbeat/last-success lookups are index-served; the tworetry_attemptssums scan their whole table and grow with scan history. Acceptable at current volumes; a short-TTL in-process cache is the first thing to add if scrape latency becomes visible.Related
Closes #303