fix(sync-metadata): refuse deletes all-or-nothing — no partial insert/update apply (SA) - #146
Conversation
…/update apply (SA) Dataset-separation SA acceptance criterion #8: "Refused metadata deletes begin no write transaction and leave logical table checksums/counts unchanged." Today `levels sync-metadata` does the opposite — when a CSV diff contains deletions and `--allow-deletes` is not passed, it still runs `upsert_csvs()` (committing every insert/update) and only refuses the deletes (exit 2), leaving a half-applied DB. Move the refusal BEFORE the write transaction: if the plan has deletes and `--allow-deletes` is absent, print the plan + per-source observation-drop counts (already computed by `compute_plan` and printed by `_print_plan`, both pre-transaction) and return 2 with zero writes. The in-transaction delete branch is now reached only when `--allow-deletes` is set, so a recovery run applies the whole batch (inserts/updates AND deletes) atomically — and is idempotent. The deploy flow is unchanged and strictly safer: `deploy.sh` step 3.1 still runs `sync-metadata --backup` without `--allow-deletes`, a delete still exits 2 and aborts the `set -e` deploy — but now leaves the DB untouched instead of half-applied; the operator's manual `--allow-deletes` run applies everything in one transaction, then re-runs deploy. Tests: `test_deletes_refused_without_flag` now asserts NOTHING applied, proven via `PRAGMA data_version` on a separate connection (bumps iff another connection commits) — a direct check that no write transaction committed. Add `test_refused_delete_then_allow_deletes_applies_whole_batch` (the deploy recovery flow: refuse → byte-unchanged → `--allow-deletes` applies the whole batch → idempotent) and `test_pure_upsert_without_allow_deletes_applies` (a delete-free diff still applies without the flag — guards the common deploy + kayak_data CI path). Sweep the module docstring, `--allow-deletes` help, `deploy.sh` step-3.1 comment, and the add-gauges runbook to describe all-or-nothing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Adversarial review of I focused on the operational safety claim: a delete-containing sync without Live-DB copy verification using the fresh
Additional verification:
Residual notes, not blockers:
|
Adversarial review — PR #146 (sync-metadata all-or-nothing delete refusal, SA)Verified the core fix on a copy of the fresh live DB (629 MB, 432 reaches / 328 sources) — not just the fixture. The change is correct and the bug it fixes is real. One precise, low-severity imprecision in the "no writes on refusal" claim. The fix works — proven on real dataConstructed a CSV diff with both halves the old code handled inconsistently — an UPDATE (rename source id 1
Code-flow confirms it: the refusal moved ahead of Finding [low]:
|
…ow-up) Adversarial review of #146 flagged that --backup runs BEFORE the delete-refusal gate, so a refused delete-containing run still wrote the full ~629 MB .pre-sync sidecar before returning 2 — making the "refused before a single write / DB untouched" claim imprecise (AC #8 held for the live DB's tables, but a file was written) and wasting a large write on every refused deploy. Move the --backup block to AFTER the refusal gate (and after the dry-run return), so a refused or dry run does ZERO disk I/O. The backup's purpose is to protect an actual apply (a FK-valid but logically-wrong UPDATE that commits and can't be undone from the one-line diff); there is nothing to protect when nothing applies. The online-backup still runs on its own fresh connections, independent of the sync connection. Because a refused deploy no longer leaves a snapshot behind, update deploy.sh's step-3.1 recovery instruction to `sync-metadata --allow-deletes --backup` — the recovery run is the one that mutates, so it should carry the snapshot. Tests: split test_backup_writes_pre_sync_snapshot into test_backup_skipped_on_refusal (refused run with --backup writes no .pre-sync — zero-I/O refusal) and test_backup_writes_pre_sync_snapshot_on_apply (an apply with --backup writes a pre-mutation snapshot: the copy still has the dropped source, the live DB does not). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the Moved the Verified on a scratch DB: refused run Follow-on: since a refused deploy no longer leaves a snapshot behind, I updated Tests: split The two other residual notes you raised are intentionally out of scope: the insert/delete-only plan tally (pre-existing, called out in the runbook) and the "would drop 0 observation(s)" wording for non-source-only deletes (pre-existing; the per-table plan above the refusal already shows the non-source deletion). Happy to pick either up as a separate change if you'd like. Still not merging — yours. |
…(SA-teardown-A) (#149) * refactor(import-metadata): sidecar-only; route CSV loads through sync-metadata (SA-teardown-A) dataset-separation SA-teardown, part A. `levels sync-metadata` already applies the CSV columns by stable id with delete-safety (#146); reach.geom/reach.gradient_profile are EXCLUDED_COLUMNS, applied only from reaches.json/reaches-gradient.json. So make import_metadata.py a **sidecar-only** applier and standardize CSV loads on sync-metadata: - import_metadata.py: drop the full-CSV upsert path (and the `metadata_csv` import); it now applies only the geometry sidecars. No flags = both; --geom-only / --gradient-only = one. This removes the parallel-to-sync-metadata CSV channel that skipped #146's delete-safety (the #148 review's "optional hardening" — the unsafe channel is gone, not just gated). It stays the sanctioned sidecar applier (no refuse_configured_db), per the safety.py enumeration; deploy.sh 3.25/3.26 (--geom-only/--gradient-only) are unchanged, and 3.1 already does the CSV via sync-metadata. - Fresh-load / recovery / onboarding runbooks now run init-db --no-seed → sync-metadata → import_metadata (sidecars) → pipeline: CLAUDE.md, README.md, CONTRIBUTING.md, deploy/SETUP.md (§4 + the §-755 block), and docs/migrations.md's recovery runbook (incl. the --no-seed-rationale paragraph: the collision + FK semantics are sync-metadata's now, not import's). - Docstring sweep: safety.py (import_metadata has no full-CSV mode) and metadata_csv.py (its sole upsert caller is now sync-metadata). - Tests: the full-CSV round-trip tests now apply the CSV half via sync_metadata (new `_sync_csvs` helper writes the minimal contract); the geom/gradient sidecar tests are unchanged. Renamed the two that were import-CSV-specific (test_csv_apply_preserves_geom_absent_from_snapshot, test_resync_idempotent_across_pk_shapes). Verified on a copy of the live DB: init-db --no-seed → sync-metadata → import_metadata reproduces prod exactly (328 sources / 231 gauges / 432 reaches incl. all 432 geoms + 432 gradients) and builds. No schema change; no kayak_data change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(import-metadata): fail loud on unmatched sidecars + DATASET_DIR in setup runbooks (review) Address the two P2s (and the Low/Nit notes) from the #149 review. [P2] import_metadata.py reported success while applying ZERO sidecar rows. After A removed the CSV-load path, a no-flag run against an empty/wrong DB (the "ran before `levels sync-metadata`" mistake) applied 0/432 geoms + 0/432 gradients and exited 0 — invisible under `set -e`. Now `_apply_geom`/`_apply_gradient` return the count of snapshot ids that matched no reach row, and main() rolls back + exits non-zero when any are unmatched, with `--allow-missing-reaches` to opt into a deliberate partial apply. Verified: empty DB no-flag → exit 1, nothing applied (rolled back); --allow-missing-reaches → exit 0; full DB (after sync-metadata) → exit 0. Deploy 3.25/3.26 (--geom-only/--gradient-only against the full prod DB, after the 3.1 sync) all match, so this never false-positives on deploy. New test: a sidecar id with no reach row fails loud + rolls back; the flag allows the partial. [P2] Two setup runbooks ran `levels sync-metadata` without cloning kayak_data / setting DATASET_DIR, so they dead-ended at "csv dir does not exist: …/data/db": CONTRIBUTING.md (standalone dev quick-start) and deploy/SETUP.md's self-contained condensed install recap (its .env omitted DATASET_DIR and it cloned no metadata repo). Both now clone kayak_data + set DATASET_DIR before the load. (Pre-existing — import_metadata needed DATASET_DIR too — surfaced because A touches these blocks.) [Low] Note in the recovery runbook that sync-metadata refuses a `status: scaffold` dataset (--allow-scaffold to override; the real kayak_data is publishable). [Nit] CHANGELOG entry for the import_metadata sidecar-only behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(setup): absolute import_metadata path in copy-paste-safe SETUP blocks (review) Address the #149 re-review P3. The condensed local-dev install recap (step 6) and the §4 "apply geometry by hand" snippet ran `python scripts/import_metadata.py` with a relative path while every other command in those blocks uses absolute paths (`/home/pat/.venv/bin/levels`, …) — so they read as self-contained but fail from outside /home/pat/kayak ("can't open file '.../scripts/import_metadata.py'"). Use the absolute `/home/pat/kayak/scripts/import_metadata.py` so both blocks are copy-paste-safe. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What
Dataset-separation SA, first sub-slice. Makes
levels sync-metadataall-or-nothing when a CSV diff contains deletions and--allow-deletesis not passed: it now refuses before any write (exit 2, zero commits), instead of committing the inserts/updates and only refusing the deletes.This satisfies SA acceptance criterion #8: "Refused metadata deletes begin no write transaction and leave logical table checksums/counts unchanged."
Why
Today (
src/kayak/cli/sync_metadata.py) a delete-containing diff without--allow-deletesstill runsupsert_csvs()— committing every insert/update — and only refuses the deletes (exit 2). That leaves a half-applied DB: on a deploy that unexpectedly drops a row, the safe half is already committed whendeploy.shaborts.Change
with conn:transaction. Ifplan.has_deletes and not args.allow_deletes→ print the plan + per-source observation-drop counts (both already computed pre-transaction) andreturn 2with no writes. The in-transaction delete branch is now reached only with--allow-deletes, so a recovery run applies the whole batch (inserts/updates AND deletes) atomically, and is idempotent.deploy.shstep 3.1 still runssync-metadata --backupwithout--allow-deletes; a delete still exits 2 and aborts theset -edeploy — but now the DB is untouched rather than half-applied. The operator's manual--allow-deletesrun applies everything in one transaction, then re-runs deploy.Tests
test_deletes_refused_without_flagnow asserts nothing applied, proven viaPRAGMA data_versionon a separate connection (bumps iff another connection commits) — a direct check that no write transaction committed.test_refused_delete_then_allow_deletes_applies_whole_batch— the deploy recovery flow: refuse → byte-unchanged →--allow-deletesapplies the whole batch → idempotent.test_pure_upsert_without_allow_deletes_applies— a delete-free diff still applies without the flag (guards the common deploy + kayak_data CI path).--allow-deleteshelp,deploy.shstep-3.1 comment, add-gauges runbook.Verification
pytest -m "not slow"= 1382 passed, wheel-smoke.--allow-deletesre-run applies rename + delete (exit 0); third run is a no-op.data_versionassertion verified robust in both journal modes.Scope / not in this PR
No schema, model, or migration change. kayak_data needs no change — its CI smoke (
init-db --no-seed→sync-metadata×2 → build) syncs into an empty DB (all inserts, zero deletes), so the gate never fires.Next SA sub-slices (separate PRs): SA-2 move
fetch_url.last_fetched_atto a runtime table; SA-3 CI writer-boundary guard; then the reverse-sync teardown.🤖 Generated with Claude Code