Skip to content

ci(preview): bisect invalid seed rows instead of replaying batches row-by-row (stage 3) - #1151

Draft
kyle-compute wants to merge 1 commit into
ci/preview-fencingfrom
ci/preview-seed-loader
Draft

ci(preview): bisect invalid seed rows instead of replaying batches row-by-row (stage 3)#1151
kyle-compute wants to merge 1 commit into
ci/preview-fencingfrom
ci/preview-seed-loader

Conversation

@kyle-compute

Copy link
Copy Markdown
Collaborator

Stacked on #1144 (ci/preview-fencing). This diff contains only stage-3 changes; GitHub retargets to staging when the chain merges. Opened as draft: merge only after the earlier stages land.

Problem

#1139's 318s seed: one constraint-violating row in a multi-row upsert batch dropped the whole chunk into a row-by-row fallback, so five legacy rows among ~900-row chunks cost thousands of pooler round trips. Worse, the fallback caught DBAPIError, so a dead connection or serialization failure was silently recorded as "skipped rows" — transient infrastructure failure masquerading as bad data.

The plan's second seed concern (migration/seed ordering) is already enforced on staging: the bootstrap's trust marker folds in a migration-content fingerprint, so an edited or added migration invalidates the cached schema and forces a full rebuild, and _rebuild seeds before alembic upgrade head — pinned by test_rebuild_snapshots_prod_before_touching_the_branch (seed precedes upgrade:oddish/upgrade:backend, trust marker last). No code change needed there; this PR pins the remaining gap, the loader.

What changes

backend/preview_seed.py — the row-by-row fallback is replaced by _isolate_bad_rows, a divide-and-conquer driver:

  1. a failed batch rolls back at its savepoint
  2. splits in half, retries both halves
  3. recurses only into failing halves
  4. isolates and reports invalid single rows

k bad rows in an n-row batch: O(k log n) round trips instead of O(n). Only IntegrityError (SQLSTATE class 23) is splittable; network/timeout/serialization errors propagate and fail the seed loudly. The recursion is the only fallback path — the old batch-then-row-by-row two-step is gone.

Every seed() run now returns a deterministic report: batches_attempted, batches_split, rows_skipped, skip causes keyed by SQLSTATE + constraint name with table-qualified row ids (sorted, capped at 100/cause), and before/after row counts per drawn table (one UNION ALL round trip — per-table COUNTs would each pay a NullPool pooler handshake).

Emission path: seed_preview_db.py writes the report as single-line JSON to $PREVIEW_SEED_STATS_FILEprepare_preview_database.sh forwards it as the seed_stats job output and prints a summary line → the gate's SEED_STATS env → record_preview_metrics.py folds it into the timing artifact under seed. Malformed payloads degrade to an empty section; telemetry never fails the deploy.

Fixture repair (test_preview_seed.py): the DB-backed suite had silently rotted — it skips without ODDISH_DATABASE_URL, so drift accumulated: SQLAlchemy compiles a multi-row insert from the first dict's keys, so sparse rows had extra keys (e.g. t-del's deleted_at) silently dropped and missing keys (created_by_user_id) hard-errored. A _rows() helper now unions the keys of each heterogeneous fixture group.

Validation

Pure unit tests (test_preview_seed_isolation.py, no DB) — the plan's enumerated scenarios:

  • one invalid row in a 1,000-row batch → ≤ 2·⌈log₂ n⌉+1 probes (was: 1001 statements), valid 999 all land
  • five invalid rows spread across 1,000 → all five isolated, bounded probes
  • all rows valid → exactly 1 batch; all rows invalid → exactly 2n−1 probes, every row reported
  • composite primary keys → task-9:exp-3 key format
  • transient OperationalError → propagates, zero skips recorded
  • reported causes/row ids deterministic across runs (sorted, recursion order pinned)

DB-backed suite (test_preview_seed.py, local Postgres 16 container): updated the bulk-fallback test — a colliding task now correctly cascades to its task_versions/task_experiments children, and the report pins all three skips with their constraint identities. 104 tests pass, plus an end-to-end seed_preview_db.py smoke (sampled 36 rows, isolated a real unique-violation skip in 0.5s, wrote the stats JSON).

The ordering invariant (item A) is pinned by the existing bootstrap orchestration tests — 21 pass.

Risk

  • A deadlock between concurrent upsert streams now fails the seed instead of being masked as skips. Disjoint PK ranges make this unlikely; if a canary shows it, the fix is fewer streams for that table, not restoring the catch-all.
  • Two extra COUNT round trips per seed (before/after) — negligible against the seconds saved.

Rollback

Revert this PR. The stats output is additive; the artifact tolerates its absence (seed: {}).

Acceptance criteria

  • fix: preserve verdict state and skip cancelled preview gates #1139 reproduction seeds in < 60s (canary on this PR's own preview run: the branch's preview exercises the new loader against prod-sampled legacy rows)
  • invalid rows remain visible with constraint names (report + job summary)
  • no constraint relaxed, no migration check skipped
  • transient DB errors fatal, not misclassified

…w-by-row

The #1139 seed pathology: one constraint-violating row in a multi-row
upsert batch dropped the whole chunk into a row-by-row fallback, so five
legacy rows among ~900-row chunks cost thousands of pooler round trips
(318s). The fallback now rolls the failed batch back at its savepoint,
splits it in half, and recurses only into failing halves until invalid
rows stand alone: O(k log n) probes for k bad rows in n rows, with valid
rows retained.

Only IntegrityError (SQLSTATE class 23) is splittable. The old catch-all
also swallowed DBAPIError, so a dead connection or serialization failure
was silently recorded as skipped rows -- transient infrastructure errors
now abort the load instead of masquerading as bad data.

Every seed run emits a deterministic report (batches attempted/split,
rows skipped, SQLSTATE + constraint names with table-qualified row ids,
before/after row counts by table) that flows through a new prepare-job
output into the timing artifact and the job summary.

Also repairs the local seed-test fixtures, which had silently rotted
(they skip without ODDISH_DATABASE_URL): SQLAlchemy compiles multi-row
inserts from the first dict's keys, so sparse rows had extra keys
silently dropped and missing keys hard-error; a _rows() helper now
unions the keys of each heterogeneous fixture group.
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
oddish-app Ready Ready Preview Aug 11, 2026 12:56am

@github-actions

Copy link
Copy Markdown
Contributor

Oddish preview

Commit: 4c1c5565b948656f6d795ab367dfbe01531e76a6

Surface Link Target
Frontend https://pr-1151.oddish.app Vercel preview for 4c1c556
Backend oddish-pr-1151 oddish-pr-1151
Database project lkzbtctcircfrhrurhak project lkzbtctcircfrhrurhak

Vercel deployment URL: https://oddish-meg535pol.oddish.app

Plan:

  • Frontend deploy: true
  • Backend deploy: true
  • Migrations: false

This comment is updated by the PR Preview workflow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant