Four API follow-ups from the codebase review (#197) - #205
Merged
icebergai-review-bot[bot] merged 2 commits intoAug 20, 2026
Conversation
**A duplicate name raced to a 500.** Sources, notification channels, hand-over targets, owner groups and routing rules all answer "is this name free?" with a SELECT and then INSERT. That guards an operator retyping a name in use and nothing else: two concurrent creates both find nothing, both insert, and the loser got an unhandled IntegrityError for a condition the route already has a 409 and a message for. `commit_or_conflict` is `register_engine`'s pattern factored out and applied at ten call sites. Only a unique violation converts — read from SQLSTATE where there is one so a Postgres deployment never depends on matching English — because dressing a foreign-key failure as "that name is taken" sends an operator looking in the wrong place. **A lock-order inversion between the two engine writers.** Both take the scan row FOR UPDATE and write the task row; progress took them scan-then-task and results task-then-scan, which is AB/BA if a retried progress races the same task's final results. Results now takes the scan first. Postgres would abort one side and the engine's retry ladder would recover, so nothing was ever lost; it was simply free to make impossible. Pinned by reading the source, because SQLite has no FOR UPDATE and no test that runs these routes could catch it. **Cancelling a scan threw away the coverage its tasks had reported.** A running task's accumulated per-batch report was replaced by a synthetic zero-count failure, so a cancelled scan's manifest said a task which had demonstrably ingested findings scanned nothing — the opposite of what a manifest is for. The cancellation gap is merged onto the stored report instead. The bulk UPDATE becomes one conditional UPDATE per row, keeping the property that mattered: a task reaching a terminal state mid-cancellation keeps what it reported. **Ingest was an N+1 on the busiest transaction in the API.** One point select per finding payload, two during a pepper-rotation window, inside the transaction holding the scan row locked — so a 500-finding batch paid it in lock time as well as round trips. Both identities are now loaded in one pass. The map is kept live rather than treated as a snapshot: the loop registers what it creates and re-keys what it moves, because the per-payload select was seeing pending inserts through autoflush, and a duplicate fingerprint inside one batch must stay one finding rather than an insert the constraint refuses. Every fix has a test confirmed to fail against the code before it. Refs ADR 0006, ADR 0009, ADR 0013. Claude-Session: https://claude.ai/code/session_012sohE85sRDt6t2w3936rGJ Co-authored-by: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Verdict
CHANGES_REQUESTED
Completed bounded review across 1 immutable scope(s). 1 high-severity correctness regression found.
Scope health
Convergence: healthy. Review mode: initial.
Recommended action: CONTINUE_INCREMENTAL.
- No escalation signals.
Prior findings
| Finding | Status |
|---|---|
| — | No prior finding state |
New findings
Root cause: The merge is computed before the conditional UPDATE, but the UPDATE protects only terminal-state transitions, not intervening coverage changes.
- BLOCKER · high: Cancellation can still overwrite concurrently reported coverage —
apps/api/src/iceberg_api/scans/service.py
Status: NEW. Attribution: new_in_scope.
cancel_scanreads each nonterminal task and derivescancelled_report(task)from that in-memory coverage, then later conditionally updates only on task status. A concurrent progress submission can commit newer coverage while the task remains nonterminal; the cancellation UPDATE still matches and writes the stale merged report.
Invariant: Cancellation must preserve all coverage successfully reported before it takes effect.
Ownership: Scan cancellation and engine progress submission transactions.. Behaviour: Concurrent cancellation of a scan with an actively reporting task..
Evidence: Reproduction: cancellation reads a running task withscanned=7; before its per-task UPDATE, a progress request recordsscanned=10and leaves the task running; cancellation'sstatus NOT IN terminalpredicate remains true and overwrites coverage with a cancellation report merged from 7. The new loop has no task/scan lock or coverage-version predicate to prevent that stale write.
Independent assessment: The new select-then-per-row update derives coverage from a stale task snapshot while its UPDATE predicate guards only status. A concurrent nonterminal progress write can update coverage after the select and before the cancellation UPDATE, which still matches and overwrites the newer coverage.
Fix-induced regressions
- None evidenced.
Uncertainty
- No material uncertainty recorded.
Validation
- Reviewed the supplied immutable diff; exact-head CI was reported as passed.
Residual risks
- None identified.
Merging the cancellation gap onto a task's stored report is a read-modify-write, and the conditional UPDATE that follows it guards only the task's status. A progress submission committing between the read and the write leaves the task non-terminal, so the predicate still matches and the merged report — built from the snapshot before that submission — overwrites its coverage. Narrower than the loss this change set out to fix, but the same loss, so it is not much of a fix. `cancel_scan` now takes the scan row FOR UPDATE first, which is the lock `submit_progress` and `submit_results` already take before touching a task of that scan, in the order this PR just made them agree on. All three writers of a task's coverage now serialise on one row. Pinned the same way as the other two: by reading the source, because SQLite has no FOR UPDATE for a running test to observe. Claude-Session: https://claude.ai/code/session_012sohE85sRDt6t2w3936rGJ Co-authored-by: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Verdict
APPROVE
Completed bounded review across 1 immutable scope(s). No findings. The follow-up serializes cancellation with engine coverage writers using the shared scan-row lock before cancellation reads task coverage.
Scope health
Convergence: healthy. Review mode: delta.
Recommended action: CONTINUE_INCREMENTAL.
- No escalation signals.
Prior findings
| Finding | Status |
|---|---|
| F-3684AFBC53B3 | Resolved |
New findings
No new findings.
Fix-induced regressions
- None evidenced.
Uncertainty
- No material uncertainty recorded.
Validation
- Prior blocker F-3684AFBC53B3 resolved:
cancel_scanexecutesselect(Scan)...with_for_update()before iterating tasks and invokingcancelled_report; the added AST test verifies that ordering. - Reviewed the supplied immutable diff at head 004c52f.
- Exact-head CI is reported as passed.
Residual risks
- None identified.
icebergai-review-bot
Bot
deleted the
claude/codebase-review-cleanup-ovlli4
branch
August 20, 2026 01:52
This was referenced Aug 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The API section of #197. The core/connectors and web/deploy sections follow in their own PRs; #197 stays open until all three land.
1. A duplicate name raced to a 500
Sources, notification channels, hand-over targets, owner groups and routing rules all answer "is this name free?" with a SELECT and then an INSERT. That guards an operator retyping a name already in use, and nothing else — two concurrent creates both find nothing, both insert, and the loser gets an unhandled
IntegrityErrorfor a condition the route already has a 409 and a message for.commit_or_conflictisregister_engine's existing catch-rollback-409, factored out and applied at ten call sites (five creates, five renames). Two deliberate details:23505where the driver has one, falling back to the message only on SQLite — so a Postgres deployment never depends on matching English.All five models carry precisely one unique constraint (
name), so the message cannot misattribute.2. A lock-order inversion between the two engine writers
Both handlers take the same two row locks — the scan
FOR UPDATE, and the task through a conditional UPDATE:submit_progressadvance_checkpoint)submit_results(before)claim_result)Textbook AB/BA, reachable when a retried progress races the same task's final results. Postgres aborts one side and the engine's retry ladder recovers, so nothing was ever lost — it was simply free to make impossible. Results now takes the scan first.
Pinned by reading the source with
ast, because SQLite has noFOR UPDATEat all: no test that runs these routes could observe the ordering. Confirmed to fail against the old order (assert 13 < 12).3. Cancelling a scan threw away the coverage its tasks had reported
cancel_scansetcoverage = failure_report(...)in a bulk UPDATE, replacing a running task's accumulated per-batch report with zeros. So a cancelled scan's manifest said a task that had demonstrably ingested findings scanned nothing — the opposite of what a manifest is for.The cancellation gap is merged onto the stored report instead (
coverage.cancelled_report). The bulk statement becomes one conditional UPDATE per row, which keeps the property that mattered: a task reaching a terminal state mid-cancellation still keeps what it reported.4. Ingest was an N+1 on the busiest transaction in the API
One point select per finding payload — two during a pepper-rotation window — inside the transaction holding the scan row locked, so a 500-finding batch paid it in lock time as well as round trips. Both identities now load in one pass.
The map is kept live, not treated as a snapshot, and this is the subtle part: the per-payload select was seeing pending inserts through autoflush, so the loop now registers what it creates and re-keys what it moves. Without that, a duplicate fingerprint inside one batch turns from a harmless repeat into an insert the unique constraint refuses.
Measured: 20 payloads went from 20 selects to 2, and the test asserts the count doesn't move with batch size rather than pinning a magic number.
Validation
Every fix has a test confirmed to fail against the code before it — I stashed each change and re-ran. New:
test_conflicts.py,test_ingest_queries.py, plus tests intest_coverage_manifest.pyandtest_scan_progress.py.make checkgreen: ruff, mypy, docs check, 1972 passed / 2 skipped.Generated by Claude Code