Skip to content

fix(orm): resume soft cascade delete instead of replaying its range - #422

Merged
zbeyens merged 7 commits into
mainfrom
fix/orm-soft-cascade-linear-reads
Aug 26, 2026
Merged

zbeyens merged 7 commits into
mainfrom
fix/orm-soft-cascade-linear-reads

Conversation

@MikeyZhang75

@MikeyZhang75 MikeyZhang75 commented Aug 21, 2026 •

Copy link
Copy Markdown
Collaborator
  • Auto release

🐛 Fixes #407
🧭 Task plan: docs/plans/407-orm-soft-cascade-linear-reads.md
🟢 95-100% confidence

Phase 🧪 Tests 🌐 Browser
Reproduced 🔴 1,100 scanned rows for 100 children; 117,177 for 4,000 ➖ N/A
Verified 🟢 8,152 for 4,000; 893 vitest + 1,333 bun + 12 stress pass ➖ N/A

✅ Outcome

Soft cascade delete only stamps deletionTime, so processed children stayed inside the foreign key index range and every scheduled batch replayed them. Reads grew with the square of the row count until the campaign died on Convex's per-execution read limit, which could strand aggregateIndex/rankIndex state at COUNT_STATUS_CLEARING and turn the lifecycle write barrier into a total write outage on that table.

Batches now resume where the previous one stopped, so a full campaign's reads scale linearly with the number of children. Hard cascade, set null, set default, and cascade update are untouched.

🏗️ Design

The issue recommended a _creationTime watermark. That is unsound: _creationTime is not unique within a Convex table — snapshot import takes a caller-supplied value verbatim (import_facing.rs:107), it is an f64 in ms that quantizes to ~244ns buckets, and the per-transaction base is not allocator-serialized the way commit timestamps are. .gt('_creationTime', wm) compiles to Bound::Excluded, whose start key is increment() of the byte prefix, so it drops every tie-twin regardless of _id — silently leaving a child uncascaded.

Soft cascade forwards the pagination cursor instead. A cursor is an index-key position that includes _id, so it is exact under ties and independent of the index shape. It is safe here specifically because deletionTime is not an indexed column, so nothing moves relative to the cursor; every other cascade action rewrites the foreign key columns or removes the row, so those keep re-querying from null — already linear, since processed rows leave the range. root-update and root-delete in the same worker already forward cursors across scheduled batches.

The deletionTime filter is dropped rather than kept: Convex pays to read those rows either way, and filtering the cursor's own row out of the result strands the cursor. Already-stamped rows are skipped in JS.

⚠️ Caveat

A child inserted behind the cursor mid-campaign is not stamped. That is inherent to not re-reading the range — any design meeting the issue's linear-reads criterion has it — and the race was never settled, since the campaign could have passed that key before the insert.

🧪 Verified

The wasted reads are consumed by .filter(), so documents reports 110 with and without the bug. These bounds assert on reads.scanned from #399, which landed on main while this PR was open; convex/setup.testing.ts here is byte-identical to main. This branch had briefly carried its own scanned counter — both implementations independently report 1,100 scanned rows for the 100-child repro and 4,214 for the byte-budget one, so the bounds are cross-validated rather than tuned to one mechanism.

Review caught a real defect in the first cut: the byte-truncation path called .paginate() twice in one execution, which Convex rejects with MultiplePaginatedDatabaseQueries and convex-test does not model. A truncated batch now re-pages from the same cursor at the size that fit; takeRowsWithinByteBudget always keeps one row and a truncated page is strictly shorter than requested, so the size strictly decreases and settles. That also cut the byte path from 626 to 82 scanned rows for 60 children. A per-invocation paginate-call guard was added and verified non-vacuous.

bun check green on this branch after merging main: lint, typecheck, bun test, vitest, test:cli, test:concave, fixtures:check, test:verify, test:runtime. Env-gated stress lane green at 4,000 descendants.

Soft delete only stamps deletionTime, so processed children stayed in the
foreign key index range and every scheduled batch re-read them, growing
reads with the square of row count until the campaign died on Convex's
read limit — stranding aggregate index state at CLEARING.

Soft cascade batches now forward the pagination cursor. Safe here because
deletionTime is not indexed, so nothing moves relative to the cursor; every
other action rewrites the FK columns or removes the row, so those keep
re-querying from null. Drops the deletionTime filter too: Convex pays for
those rows anyway and filtering them out strands the cursor, so already
stamped rows are skipped in JS.

A byte-truncated batch re-pages from the same cursor at the size that fit.
Convex allows one paginated query per execution, so it cannot ask for a
second cursor; the size strictly decreases and settles.

Adds opt-in countDocumentReads scanned metric — documents reads 110 with
and without the bug, so it cannot see filter-consumed reads.

4000 descendants: 117,177 scanned rows -> 8,152.
@changeset-bot

changeset-bot Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7620ba0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
kitcn Minor
@kitcn/resend Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
better-convex Ready Ready Preview Aug 26, 2026 1:42am

Request Review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

Resolves the countDocumentReads collision flagged in PR #422: #399 landed a
scanned-row counter on main that reads QueryImpl.state.query and walks a
paginated range cursor by cursor. That is strictly better than this branch's
chain-replay version, so convex/setup.testing.ts now matches main exactly and
the cascade read-bound tests assert on main's counter.

Both counters independently report 1100 scanned rows for the 100-child repro
and 4214 for the byte-budget one, so the bounds are cross-validated, not tuned
to one implementation. Red calibration re-run against main's pre-fix worker:
all three cases still fail.
@zbeyens

zbeyens commented Aug 26, 2026

Copy link
Copy Markdown
Member

Autoclosure terminal receipt

  • Exact PR head: 7620ba002a82a023223390773b3f01c55da62028 (local HEAD = GitHub head = fetched refs/pull/422/head).
  • Source-sync baseline: 26dd9b55357e4d49b7ada84812fefd52648f304a.
  • Per-PR task evidence: docs/plans/407-orm-soft-cascade-linear-reads.md; completion checker passes and identifies PR fix(orm): resume soft cascade delete instead of replaying its range #422.
  • P1 review: exact-head autoreview clean, confidence 0.9; no P0/P1 defect demonstrated.
  • Focused proof: soft-cascade regression 6/6, limit stress 12/12, root typecheck 5/5, lint clean, package build green.
  • Full proof: bun check green with 1,361 Bun tests and 929 Vitest tests.
  • Hosted proof: CI passed in 6m34s; Vercel deployment and preview-comments checks passed.
  • Changeset bot: informational; changeset detected at this exact head.
  • Vercel bot: informational; deployment passed with 0 unresolved live-feedback items.
  • Codex review-limit bot: informational service-limit notice; superseded by the required exact-head local autoreview.
  • Review bodies: 0. Review threads: 0. Deferred P2 items: 0.

Verdict: ready to squash-merge.

@zbeyens
zbeyens merged commit f677251 into main Aug 26, 2026
6 checks passed

This branch was successfully deployed

1 active deployment
Preview — 7620ba00 Deployed Aug 26, 2026 by vercel[bot]
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.

ORM: soft cascade delete re-scans every already-processed row each batch (quadratic reads, then a stranded CLEARING write outage)

2 participants