Skip to content

Commit c18b9d0

Browse files
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_01V7WetGmnfoXNn8cLieKKmx
1 parent 488b66c commit c18b9d0

3 files changed

Lines changed: 367 additions & 55 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): bound every lifecycle reap, not just the guarded ones
6+
7+
`LifecycleService.reap()` issued a single unlimited `delete(..., { multi: true })`
8+
per sweep for any object without a registered reap guard — no limit, no paging.
9+
Batching existed only on the two side paths (`guardedReap` and the Archiver,
10+
both 500 × 20 per sweep), whose comment gives the reason plainly: "bound one
11+
sweep's work, drain the backlog across sweeps". That reason never depended on a
12+
guard being registered.
13+
14+
Steady state was fine — an hourly sweep deletes a small increment. The cost
15+
landed exactly once per table, on the first sweep after `retention` is declared
16+
on a table that already holds history: one DELETE scanning every historical row,
17+
which on SQLite holds the whole database's write lock for its duration and on
18+
Postgres arrives later as autovacuum debt.
19+
20+
Unguarded reaps now run the same batched machinery the guarded ones do — an
21+
object with no guard is simply the empty guard intersection, which confirms
22+
every candidate row — so there is one reap path rather than two parallel ones.
23+
Candidates are read a page at a time and deleted by id, at most 500 × 20 rows
24+
per object per sweep, with the remainder draining on later sweeps. Cutoff and
25+
`retention.onlyWhen` predicates are unchanged; they now select the candidate
26+
read. The sweep report's `deleted` count reflects rows actually deleted this
27+
sweep.
28+
29+
Two consequences worth knowing:
30+
31+
- A reap fires one `afterDelete` hook per reaped row instead of one per object
32+
per sweep. Every lifecycle-declaring platform object is in the audit writer's
33+
`SKIP_OBJECTS`, and `sys_file` already reaped per id via its guards, so no
34+
object in the platform changes its audit output.
35+
- An engine that does not implement `find` cannot page and keeps the previous
36+
single bulk DELETE, so retention enforcement never silently stops on it. Every
37+
real engine implements `find`.

0 commit comments

Comments
 (0)