Repository navigation
Commit c18b9d0
committed
fix(objectql): bound every lifecycle reap, not just the guarded ones (#5194)
`LifecycleService.reap()` issued a single unlimited `delete(..., { multi: true
})` per sweep on any object without a registered reap guard — no limit, no
paging. Batching lived only on the two side paths, `guardedReap` and the
Archiver (both 500 x 20 per sweep), whose comment states the reason plainly:
"bound one sweep's work, drain the backlog across sweeps". That reason never
depended on a guard being registered.
Steady state was never the problem — an hourly sweep deletes a small increment.
The cost landed exactly once per table, on the first sweep after `retention` is
declared on a table that already holds history: one DELETE scanning every
historical row. SQLite holds the whole database's write lock for its duration;
Postgres takes it as autovacuum debt.
Unguarded reaps now run the same batched machinery the guarded ones do, rather
than a second copy of it. An object with no guard is the EMPTY guard
intersection, which confirms every candidate row, so `guardedReap` already
computed the right answer for it — the guard loop just does not execute. It is
renamed `batchedReap` because bounding the work, not consulting guards, is what
it is for. One reap path, one place where a reap decides what to delete.
Three things the unification had to settle, each pinned by a test:
- An id-less candidate row is dropped before the guards see it. With guards,
the intersection dropped such rows as a side effect of matching ids; with
zero guards nothing narrows, and `where: { id: undefined }` with `multi: true`
is not a by-id delete at all — the engine's dispatch finds no scalar id and
routes to deleteMany over the batch's whole cutoff predicate.
- The paging loop checks the #4747 abort bit per page. `sweep()` checks it
between objects, which was the whole story when an unguarded reap was one
`await`; it is now up to 20 pages of reads and deletes.
- An engine with no `find` cannot page, so it keeps the previous single bulk
DELETE. That is the no-`find` path, not the unguarded path: losing retention
enforcement entirely would be the worse trade, and no guard is waiting to
confirm anything. Every real engine has `find`.
Cutoff and `retention.onlyWhen` predicates are unchanged and now select the
candidate read; the report's `deleted` count reflects rows actually deleted.
Cost of the bound, stated in the file: one afterDelete hook per reaped row
instead of one per object per sweep. No platform object changes its audit
output — all of them are in the audit writer's SKIP_OBJECTS, and `sys_file`,
the one that is audited, already reaped per id via its guards.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx1 parent 488b66c commit c18b9d0
3 files changed
Lines changed: 367 additions & 55 deletions
File tree
- .changeset
- packages/objectql/src/lifecycle
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
0 commit comments