Reference solution
The findings below are ordered by priority.
Findings
1. Offset pagination skips rows after each successful page
Location: src/delete-expired-rows.ts:10-18 and src/delete-expired-rows.ts:33-34
Deleting the first 100 rows shifts the next records to offset zero, but the worker advances to offset 100. It skips the next 100 matching rows and can report completion while expired data remains.
Request pages with afterId, advancing the cursor to the final listed id regardless of deletions.
2. Deletes omit the tenant predicate
Location: src/delete-expired-rows.ts:25-30
Although listing is scoped, each mutation sends only a row id and cutoff. A stale, colliding, or incorrectly scoped list result can delete a row outside the requested tenant, contrary to the defense required at the mutation boundary.
Pass tenantId with every delete and require the store to include it in the delete predicate.
3. A full page launches 100 deletes at once
Location: src/delete-expired-rows.ts:24-31
Promise.allSettled receives the entire 100-row page, exceeding the maximum concurrency of 10. The worker can overload the database precisely during a retention sweep.
Process each page through a bounded worker pool of 10 while still waiting for every started delete in the page.
4. Failed deletes are counted as successes and processing continues
Location: src/delete-expired-rows.ts:24-39
The settled results are discarded, deleted increases by the whole page, and the loop fetches another page. Callers receive a successful inflated count with no failed IDs, and continued mutation makes targeted retry harder.
Count only fulfilled deletes, collect rejected row IDs, finish the already-listed batch, then reject before fetching another batch.
Reasonable non-findings
- A page size of 100 is compatible with a separate concurrency limit of 10.
Promise.allSettled is useful for finishing a listed batch; the defect is ignoring its results and launching it without a limiter.
- Breaking on an empty page is correct when cursor pagination is used.