fix(core): harden scan durability and idempotency (#303) - #325
fix(core): harden scan durability and idempotency (#303)#325SHAURYAKSHARMA24 wants to merge 18 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. |
Resolves the two must-fix items and the follow-ups raised in review of OWASP#325. Integration: leave the OWASP#263 evaluation contract to OWASP#321 ------------------------------------------------------ This branch created a second `rule_evaluations` table, near-identical to the one PR OWASP#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 OWASP#321/OWASP#325 merged second, independently of the Alembic head ordering. Issue OWASP#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 OWASP#303 asks for: the stable `finding_key` identity and its unique index. `save_scan()` marks where OWASP#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>
Resolves the two must-fix items and the follow-ups raised in review of OWASP#325. Integration: leave the OWASP#263 evaluation contract to OWASP#321 ------------------------------------------------------ This branch created a second `rule_evaluations` table, near-identical to the one PR OWASP#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 OWASP#321/OWASP#325 merged second, independently of the Alembic head ordering. Issue OWASP#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 OWASP#303 asks for: the stable `finding_key` identity and its unique index. `save_scan()` marks where OWASP#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>
b5c8506 to
9da3d3e
Compare
…sert Per the OWASP#321/OWASP#325 (OWASP#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): OWASP#325 owns the finding_key/upsert model for findings, since that requires schema OWASP#321 doesn't have. This change is scoped to rule_evaluations, which is OWASP#321's own table. Signed-off-by: Dipesh Ray <dipesh.ray.g@gmail.com>
ritiksah141
left a comment
There was a problem hiding this comment.
Re-reviewed the updated branch (rebase onto current dev, plus 8f27e8f and 9da3d3e). All three items from my earlier request for changes are resolved.
-
The duplicate rule_evaluations table is gone. The idempotent_findings_and_evaluations migration was replaced by idempotent_finding_identities, and there are now zero rule_evaluations references in this PR. save_scan carries an explicit comment noting the #263 evaluation contract belongs to #321 and that its writes belong inside this fenced transaction once #321 lands. That is the agreed split.
-
Terminal enrichment jobs are now recoverable. enqueue_enrichment_job explicitly requeues a terminally failed job: it resets attempt_count for a fresh retry budget, keeps the checkpoint so it resumes rather than redoing, and never steals a running claim. It is exposed via POST /api/scans/<scan_id>/enrich with a clear requeued outcome.
-
The INVALID-index risk on the one-active-scan index is handled. The admission migration now pre-checks and raises an actionable error listing exactly which subscriptions have multiple pending/running scans, and it drops both index names first so retrying after an interrupted CONCURRENTLY build is deterministic.
Verification: all eight migrations apply cleanly on a fresh Postgres to a single head d4a8c1e6b2f9, including the CONCURRENTLY index build. Full suite passes 978 tests, all 30 postgres-gated tests run and pass, and the previously flaky duplicate_enqueue_and_claim_race now passes. Ruff check and format are clean, CI is green including DCO. Also confirmed: trigger_scan masks internal errors and validates idempotency keys and subscription authorization; the NVD client uses a hardcoded HTTPS base URL with a timeout; and the heartbeat clamp (heartbeat must be shorter than the lease) is now tested.
What remains is coordination, not code. This needs to merge after #321 and be rebased onto 3f59f83a5253. Both branches currently sit off d8e4f6a1b2c3 and #321 is not yet merged, so merging this as-is would fork the migration chain into two heads. Two things to do at rebase time: fold #321's evaluation upsert block into this fenced save_scan (otherwise merging this PR would silently drop the #263 evaluation persistence that #321 adds), and confirm evaluate()-derived findings get stable finding_keys under the upsert model (they should, since the key derives from rule_id plus resource scope).
TFT444
left a comment
There was a problem hiding this comment.
Fencing design is correct and the fault-injection suite is the most thorough in the repo. Five items need fixing before this can merge.
-
recover_stale_scansdouble-claim race. Two sequential UPDATEs withoutFOR UPDATE SKIP LOCKEDlet concurrent workers claim the same scan twice. Collapse into one CTE withSKIP LOCKED. Also fix the asymmetricCOALESCE(attempt_count, 1)vsCOALESCE(attempt_count, 0)between the fail and retry branches -- scans exhaust retries one attempt early. -
oldest_lease_age.scanmeasures the wrong thing.MIN(claimed_at)grows forever during healthy operation. The enrichment counterpart useslast_heartbeat_atcorrectly -- align scans to match. -
request_fingerprintis a no-op. The hash only coverssubscription_id, so every request for the same subscription produces the same fingerprint. The 409 Conflict path is unreachable and the column does nothing. Include the request body in the hash or remove the field. -
Enrich route response contract mismatch. The
status == "COMPLETED"early return emits{message, scan_id}but the API reference documents{outcome, job_id}. Drop the early return --enqueue_enrichment_jobalready handles the completed case and returns the right shape. -
Migration
a7c5e9d2f1b4re-run fails on partial failure.op.add_columnruns before the INVALID-index cleanup, so a re-run after a failed attempt crashes on "column already exists" before reaching the recovery logic. UseADD COLUMN IF NOT EXISTS.
On the Alembic fork: #310 is currently conflicting with open blockers, so #325 merging first is the right order. No action needed beyond maintainer confirmation.
parthrohit22
left a comment
There was a problem hiding this comment.
I reviewed the lease and fencing implementation, scan admission, enrichment queue, migrations, worker lifecycle, observability, and regression tests.
The overall design is strong, but several blocking issues remain around stale-scan double claiming, incorrect lease-age metrics, incomplete request fingerprints, inconsistent enrichment responses, and migration rerun safety. The PR is also currently conflicted.
Please resolve these issues, rebase onto the current dev branch, and rerun the PostgreSQL and migration test suites before requesting another review.
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>
Resolves the two must-fix items and the follow-ups raised in review of OWASP#325. Integration: leave the OWASP#263 evaluation contract to OWASP#321 ------------------------------------------------------ This branch created a second `rule_evaluations` table, near-identical to the one PR OWASP#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 OWASP#321/OWASP#325 merged second, independently of the Alembic head ordering. Issue OWASP#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 OWASP#303 asks for: the stable `finding_key` identity and its unique index. `save_scan()` marks where OWASP#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>
PR OWASP#321 landed the rule_evaluations coverage contract on dev while this branch was outstanding. Both branches added their first migration on top of d8e4f6a1b2c3, which forked the Alembic chain into two heads. Repoint the first lease migration at 3f59f83a5253 so the chain stays linear and `alembic heads` reports the single head d4a8c1e6b2f9. save_scan now performs OWASP#321's evaluation upsert and stale-coverage cleanup inside this change's fenced transaction rather than alongside it. The evaluation semantics are OWASP#321's, unchanged; what this adds is that a worker which lost its lease can no longer rewrite another owner's coverage rows. The finding upsert keeps RETURNING id so a replayed delivery reuses the existing finding row and FAIL evaluations stay linked to it instead of pointing at a row that was deleted and recreated. Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
Adapt OWASP#321's save_scan tests to the fenced signature: the ownership probe is answered by the mocked cursor so finding ids still start at 1 and the FAIL-to-finding linkage assertions keep their original meaning. Add PostgreSQL regression coverage for the integration itself: an owner writes both findings and coverage rows, a worker whose lease was reclaimed raises LostLease and leaves no coverage behind, and a replayed delivery converges on the same rows with the FAIL row still pointing at the same finding id rather than a renumbered one. rule_evaluations references scans, so the lease fixture now purges it before deleting the scan row. Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
…ently recover_stale_scans issued two sequential UPDATEs whose attempt-count predicates disagreed: the fail branch read COALESCE(attempt_count, 1) while the retry branch read COALESCE(attempt_count, 0). A row predating the column therefore counted as one attempt already spent and was retired a run early. Reproduced: with max_attempts=1 a NULL row went straight to 'failed' even though the retry branch's own predicate admitted it. Neither statement took SKIP LOCKED, so recovery waited on any row another transaction held. Measured: one locked row blocked recovery for the full 3s the lock was held while no other stale scan made progress. This runs at the top of every worker iteration, so it stalls claiming and enrichment too. Collapse both branches into one CTE that selects candidates FOR UPDATE SKIP LOCKED and transitions them in the same statement. The same measured case now returns in 0.01s and recovers every stale scan except the held one. attempt_count counts claims already started (claim_next_pending_scan increments it with the lease), so a scan gets exactly max_attempts executions and NULL reads as zero. Documented on the method. Adds PostgreSQL regression coverage: two barrier-synchronised workers transition one stale scan exactly once and the winner's fencing token strictly advances; a row held by another transaction is stepped over rather than waited on; and the attempt budget is spent fully before a scan becomes terminal. Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
oldest_lease_age.scan measured CURRENT_TIMESTAMP - MIN(claimed_at), so it grew for the entire life of a scan even while its worker renewed the lease on schedule. A healthy long-running scan was indistinguishable from a stalled one, which is the situation the metric exists to detect. Measure MIN(COALESCE(last_heartbeat_at, claimed_at)) instead: that is what the lease actually renews, and it matches what the enrichment counterpart already reported. Rows migrated into leases have no heartbeat yet, so they fall back to their claim time rather than dropping out of the aggregate. Labels are unchanged, so scrape cardinality is unaffected. Regression test: a scan claimed an hour ago reports >= 3600s while its worker is silent, then drops as soon as the worker heartbeats even though claimed_at is still an hour old. Against the previous query that second assertion reported 3600.02s. Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
request_fingerprint hashed {"subscription_id": ...} only, while admission
already looked the key up as (subscription_id, idempotency_key). Every
request that could reach the comparison therefore carried an identical
fingerprint, so ScanAdmissionConflict and the documented 409 were dead.
The existing test only reached the 409 by passing hand-written
fingerprints straight to admit_scan, which is why it went unnoticed.
Making the fingerprint real would need a second semantic request input,
and there is none: trigger_scan rejects every body field except
subscription_id, and docs/api-reference.md deliberately scopes a key to
one subscription. Widening keys to be global would create a reachable 409
but contradict that documented contract, so the dead model is removed
instead of being propped up.
Removes the column from a7c5e9d2f1b4 (unreleased in this branch), the
parameter and comparison from admit_scan, the exception type, and the 409
row from the API reference, which now states why no changed-payload
conflict exists.
Tests: admission replays a repeated key and treats the same key under a
different subscription as its own scan; the route returns 200 with the
original scan_id on replay and passes no fingerprint to admission.
Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
The route short-circuited a COMPLETED scan to {message, scan_id} with 200,
while docs/api-reference.md and every other outcome use
{scan_id, job_id, status, outcome, message}. Clients had to special-case
the one response that carried no job_id.
Remove the early return so all four outcomes come from
enqueue_enrichment_job. Two details this exposes, both handled rather than
absorbed as behaviour changes:
A scan enriched before durable jobs existed reads COMPLETED but has no job
row, so a plain insert would queue fresh work and report "created".
enqueue_enrichment_job now records that work as already finished and
returns "completed", so the scan is not silently re-enriched and the
caller still gets a real job_id.
A clean scan can finish enrichment with nothing to enrich, so the findings
404 guard is skipped once a scan is enriched. Without that, removing the
early return would have turned an existing 200 into a 404.
Tests: every outcome returns the same keys with the documented status
code; the completed case asserts job_id/outcome/status rather than a
message; and a PostgreSQL test reproduces the legacy no-job-row scan and
asserts it resolves to completed without re-queueing.
Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
alembic/env.py does not set transaction_per_migration, so the whole upgrade shares one transaction -- but each autocommit_block() commits it. Any DDL issued before a CONCURRENTLY index build is therefore already durable when that build fails, while alembic_version still names the previous revision. The retry then died on its own committed work before it could reach the index recovery. This was reported against a7c5e9d2f1b4 but applies to all five revisions here, so each is fixed rather than only the one that was noticed: - e4f7a9b2c6d8, f2b6d8e1a4c9, a7c5e9d2f1b4 add their columns with ADD COLUMN IF NOT EXISTS. All are nullable or carry a default, and the finding_key backfill and NOT NULL tightening were already idempotent. - c9e1a5b7d3f2 and d4a8c1e6b2f9 skip their CREATE TABLE when the table is already present. - Every CONCURRENTLY build now drops its index name first. CREATE INDEX ... IF NOT EXISTS would have kept an INVALID index from an interrupted build, which owns the name but can never serve a query. No data is touched: the duplicate-active-scan preflight still refuses to choose which scan history to discard. Tests replay the real partial states against a throwaway database: columns committed with the version stamp behind, a table committed the same way, and an index marked invalid the way an interrupted build leaves it. All three previously failed with DuplicateColumn/DuplicateTable; they now reach head with the index rebuilt valid. A fourth covers the deployment path itself, dev head -> this head, asserting rule_evaluations survives. Signed-off-by: Shaurya K Sharma <shauryaksharma24@gmail.com>
9da3d3e to
2575640
Compare
|
Addressed the latest blocking review feedback and rebased #325 onto current
The PR is now GitHub-mergeable; the remaining |
parthrohit22
left a comment
There was a problem hiding this comment.
Hey @SHAURYAKSHARMA24 ,
Thank you for the thorough work on scan durability and idempotency. The lease/fencing design is correct and the fault-injection suite is comprehensive. However, I have the same concerns as TFT444 regarding terminally failed enrichment jobs being unrecoverable and the recover_stale_scans double-claim race. Additionally, migration re-run safety concerns need addressing. Please resolve these blocking issues, rebase onto current dev, and rerun the PostgreSQL/migration test suites before requesting another review.
TFT444
left a comment
There was a problem hiding this comment.
Re-reviewed. The double-claim race is fixed: recover_stale_scans now uses a single FOR UPDATE SKIP LOCKED CTE, and COALESCE(attempt_count, 0) is consistent between retry and fail branches. Approved.
Summary
Implements the #303 hardening contract: transaction recovery, fenced scan leases, idempotent result persistence, durable scan admission, durable CVE enrichment, and bounded operational signals.
Rebased onto current
dev(b7e9a40). #321 has since merged, so this branch now integrates the #263 evaluation contract instead of standing apart from it — see Relationship to #321.Problems fixed
Architecture
ON CONFLICT.pending/runningscan per subscription and a unique subscription/idempotency-key pair.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.Changes in this round (review follow-ups)
1.
recover_stale_scansis one atomic, non-blocking statement. It previously issued two sequentialUPDATEs whose attempt-count predicates disagreed —COALESCE(attempt_count, 1)on the fail branch versusCOALESCE(attempt_count, 0)on the retry branch — so a row predating the column counted as one attempt already spent and was retired a run early. Neither statement tookSKIP LOCKED: measured against PostgreSQL, one row held by another transaction blocked recovery for the full 3s the lock was held while four other stale scans made no progress at all. Recovery runs at the top of every worker iteration, so that stalls claiming and enrichment too. Both branches are now a single CTE selectingFOR UPDATE SKIP LOCKED; the same measured case returns in 0.01s and recovers every scan except the held one.attempt_countis documented as counting claims already started (claim_next_pending_scanincrements it with the lease), so a scan gets exactlymax_attemptsexecutions andNULLreads as zero.For the record: the two-worker double-claim this was reported as could not be reproduced. Under READ COMMITTED PostgreSQL re-checks the
UPDATEpredicate after taking the row lock, andclaim_next_pending_scanalready usedSKIP LOCKED, so exactly one worker won both the recovery and the claim before this change. The convoy and the attempt-count asymmetry were real; a barrier-synchronised regression test now pins the single-owner invariant regardless.2.
oldest_lease_age.scanmeasures lease freshness. It reportedCURRENT_TIMESTAMP - MIN(claimed_at), which grows for the whole life of a scan even while its worker heartbeats on schedule — a healthy long scan was indistinguishable from a stalled one, which is the situation the metric exists to detect. It now reportsMIN(COALESCE(last_heartbeat_at, claimed_at)), matching what the enrichment counterpart already did. Labels are unchanged.3.
request_fingerprintand the 409 path are removed. The hash covered{"subscription_id": ...}only, while admission already looked the key up as(subscription_id, idempotency_key)— so every request that could reach the comparison carried an identical fingerprint and the documented 409 was unreachable. The existing test only reached it by passing hand-written fingerprints straight toadmit_scan.Making it real needs a second semantic request input and there is none:
trigger_scanrejects every body field exceptsubscription_id, anddocs/api-reference.mddeliberately scopes a key to one subscription. Widening keys to be global would make the 409 reachable but would contradict that documented contract, so the dead model is removed rather than propped up. The column is dropped froma7c5e9d2f1b4(unreleased on this branch), and the API reference now states why no changed-payload conflict exists.4.
/enrichhas one response contract. TheCOMPLETEDearly return emitted{message, scan_id}with 200 while every other outcome — and the API reference — used{scan_id, job_id, status, outcome, message}. All four outcomes now come fromenqueue_enrichment_job. Two consequences are handled rather than absorbed: a scan enriched before durable jobs existed has no job row, soenqueue_enrichment_jobrecords that work as already finished and returnscompletedinstead of silently re-enriching it; and because a clean scan can finish enrichment with nothing to enrich, the findings-404 guard is skipped once a scan is enriched (otherwise an existing 200 would have become a 404).5. Every migration here is safe to re-run.
alembic/env.pydoes not settransaction_per_migration, so the whole upgrade shares one transaction — but eachautocommit_block()commits it. Any DDL issued before aCONCURRENTLYindex build is already durable when that build fails, whilealembic_versionstill names the previous revision, so the retry died on its own committed work before reaching the index recovery. This was reported againsta7c5e9d2f1b4but applies to all five revisions, so all five are fixed: columns useADD COLUMN IF NOT EXISTS, the twoCREATE TABLEs are skipped when the table is present, and every concurrent build drops its index name first (CREATE INDEX ... IF NOT EXISTSwould keep an INVALID index, which owns the name but can never serve a query). No data is touched — the duplicate-active-scan preflight still refuses to choose which scan history to discard.Enrichment job lifecycle
POST /api/scans/<scan_id>/enrichnever creates more than one job per scan and reports what it did via anoutcomefield. Every response carriesscan_id,job_id,status,outcomeandmessage.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
Chained from
3f59f83a5253(#321'srule_evaluations, the currentdevAlembic head):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.alembic headsreturns exactlyd4a8c1e6b2f9 (head). (These are Alembic revision ids, not git SHAs.)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 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, and now so is retrying any of the five revisions after a partial failure. 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, evaluation coverage, 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
#321 merged while this branch was outstanding, and both branches added their first migration on top of
d8e4f6a1b2c3. This branch's first migration is repointed at3f59f83a5253, so the chain is linear and there is one head — no merge revision.save_scannow performs #321's evaluation upsert and stale-coverage cleanup inside this PR's fenced transaction rather than alongside it. The evaluation semantics are #321's, unchanged; what this PR adds is that a worker which lost its lease can no longer rewrite another owner's coverage rows. The finding upsert keepsRETURNING id, so a replayed delivery reuses the existing finding row andFAILevaluations stay linked to it instead of pointing at a row that was deleted and recreated. #321'ssave_scantests are adapted to the fenced signature with their assertions intact.#310 is unmerged and still chains from
d8e4f6a1b2c3; whichever of it and this PR merges second repoints itsdown_revision.Tests
Re-run after the rebase onto
b7e9a40, againstpostgres:16-alpine:dev's own AI/RAG tests that need a locally built BM25 index (tests/test_ai_hallucination_guard.py); they are unrelated to this PR.devAlembic head (3f59f83a5253)→head; downgrade to3f59f83a5253→upgrade→head; andalembic headsreturning exactlyd4a8c1e6b2f9 (head).ruff check .andruff format --check .pass.New regression coverage this round, each verified to fail against the previous implementation:
>= 3600slease age while silent, then drops as soon as its worker heartbeats even thoughclaimed_atis still an hour old (the previous query still reported 3600.02s).scan_idon replay and passes no fingerprint to admission./enrichoutcome returns the same keys with the documented status code; a PostgreSQL test reproduces the legacy no-job-row scan and asserts it resolves tocompletedwithout re-queueing.DuplicateColumn/DuplicateTable, and now reach head with the index rebuilt valid.LostLeaseand leaves no coverage behind; a replayed delivery converges with theFAILrow still pointing at the same finding id.Acceptance criteria (#303)
Evaluation persistence is #321's contract; this PR does not define it, but its writes are now idempotent and fenced because they happen inside this transaction.
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.subscription_id. If the trigger body ever gains a semantic field, a real request fingerprint and the 409 should come back with it.Related
Closes #303