fix: TTL eviction/delete no longer orphans secondary-index entries (F-149)#1896
fix: TTL eviction/delete no longer orphans secondary-index entries (F-149)#1896kriszyp wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Code Review
This pull request fixes a regression (harper#1894 / F-149) where TTL eviction or record deletion left dangling secondary-index entries. In resources/Table.ts, when a record is removed entirely, the indexed value now correctly resolves to undefined instead of null, preventing the incorrect re-addition of a [null, id] entry. A new integration test suite has been added to verify this fix. The reviewer feedback suggests replacing a fixed 4-second sleep in the integration test with a converging poll to avoid potential test flakiness on CI.
| strictEqual((await postJSON('/Load/', { table: 'Permanent', bucket: 'null', prefix: 'PN', count: 3 })).status, 200); | ||
|
|
||
| // Give any sweep a chance to (wrongly) touch these — they must all survive, fully indexed. | ||
| await sleep(4_000); |
There was a problem hiding this comment.
Instead of using a fixed 4-second sleep to wait for background operations (such as database reindexing) to complete, wrap the verification checks in a converging poll (retrying until the expected state is reached). This prevents race conditions and test flakiness on CI.
References
- When verifying background operations (such as index backfills or database reindexing) after a service restart in integration tests, wrap the verification checks in a converging poll (retrying until the expected state is reached) to prevent race conditions and test flakiness.
|
Reviewed; no blockers found. |
…tries (F-149) Record removal (delete and the background TTL-eviction sweep) calls updateIndices(id, existingRecord, null) to clear the record's secondary-index entries. With record=null, `const value = record && (...)` resolved the new indexed value to `null` rather than "absent". getIndexedValues(null, indexNulls) returns [null] when indexNulls is set — and indexNulls defaults to true for every @indexed attribute — so updateIndices removed the real [value, id] index entry and then immediately re-added a [null, id] entry, orphaning it against the now -deleted record. On RocksDB an @indexed TTL table left 100% of evicted rows dangling in the raw index (unbounded index bloat); the same leak affects plain deletes. The blind search_by_value oracle joins each index hit back through the primary record and drops it when the record is gone, so this hid behind a vacuous "phantom=0". Fix: a removed record has no values to index — resolve to undefined, not null, so nothing is re-added. A present record whose attribute is genuinely null is a different case (record is a truthy object) and still indexes under null, so the indexNulls feature is preserved. Adds integrationTests/database/eviction-index-null-reindex.test.ts: a direct raw-index-DBI oracle proving no [null, id] orphan survives eviction, plus a guard that indexNulls records stay indexed under null. Fails without the fix. Refs harper#1894 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6c5f674 to
e54365b
Compare
| strictEqual((await postJSON('/Load/', { table: 'Permanent', bucket: 'null', prefix: 'PN', count: 3 })).status, 200); | ||
|
|
||
| // Give any sweep a chance to (wrongly) touch these — they must all survive, fully indexed. | ||
| await sleep(4_000); |
There was a problem hiding this comment.
Suggestion (non-blocking): the fixed await sleep(4_000) here waits a hardcoded window for the background sweep to (not) touch these permanent rows, unlike the polling loop used just above in the eviction test (lines 95-102). A slow CI runner could still be mid-sweep when the assertions fire, causing intermittent flakiness. Consider a bounded poll (e.g. checking primaryIds/rawIndex stabilize at 8 for N consecutive checks) instead of a single fixed delay, consistent with the convergence-poll pattern already used elsewhere in this file.
| const rawIndex = (table: string) => getJSON(`/RawIndex/?table=${table}`); | ||
| const primaryIds = (table: string) => getJSON(`/PrimaryIds/?table=${table}`); | ||
|
|
||
| test('TTL eviction removes both the base row and its raw secondary-index entry (no [null,id] orphan)', async () => { |
There was a problem hiding this comment.
Nit: regression coverage exercises only eviction, not the two claims the fix also makes
The fixed line in updateIndices is shared, so proving it on the TTL-eviction path transitively covers explicit delete and composite/multi-attribute indexes. But the PR title says "eviction/delete" and the writeup claims composite coverage, and neither is asserted directly here — a future change that diverges the delete path or a resolver-backed (composite/computed) index would slip through. Two cheap additions would lock in both claims against the same raw-DBI oracle:
- an explicit
DELETE /Expiring/<id>(ort.delete(id)) case asserting the[value,id]entry is gone with no[null,id]orphan; - a table with a composite/multi-attribute (resolver-backed)
@indexedfield, confirming removal clears the composite entry too.
Not blocking — the core fix is correct and the eviction oracle fails without it.
—
Generated by Barber AI
Resolves #1894.
Problem
Record removal — the background TTL/expiration eviction sweep and plain deletes — calls
updateIndices(id, existingRecord, null)to clear that record's secondary-index entries. Withrecord === null, the lineconst value = record && (...)resolved the new indexed value tonullrather than "absent". BecauseindexNullsdefaults totruefor every@indexedattribute,getIndexedValues(null, indexNulls)returns[null], soupdateIndicesremoved the real[value, id]index entry and then immediately re-added a[null, id]entry — a dangling index entry (raw keynull, value = the id of a now-deleted record).On RocksDB an
@indexedTTL table left 100% of evicted rows dangling in the raw index (unbounded index bloat / phantom hits under a null-valued query). The classicsearch_by_valueoracle can't observe it — it joins each index hit back through the primary record and drops the hit when the record is gone — which is why "phantom=0" read as clean for so long.Fix
A removed record has no values to index, so resolve
valuetoundefined(notnull) whenrecord == null→ nothing is re-added after the real entry is removed. A record that is present but whose attribute is genuinelynullis a different case (recordis a truthy object) and still indexes undernull, so theindexNullsfeature is preserved.Test
Adds
integrationTests/database/eviction-index-null-reindex.test.ts(RocksDB) with a direct raw-index-DBI oracle — readst.indices[field].getRange()with no join, so the[null, id]orphan is visible — plus a guard that a present record with a genuinely-null field stays indexed undernull. Fails without the fix, passes with it.Verification
@indexed) and P-399 (FK-target) reproducers: index fully cleared, 0 dangling (was 750 / 241).eviction-secondary-index,indexed-update-churn,compound-index-conditions,delete-update-race,indexed-numeric-range,bigint-indexed-range, vector-index (unit + integration),longtxn-secondary-index; LMDBevictionpath unaffected.Cross-model review
Codex + Gemini + Harper domain pass. Core fix confirmed correct across all
updateIndicescallers and the HNSW custom-index path. One Gemini "blocker" on the adjacentexistingValueline was refuted — that line'snullresult for a nullexistingRecordis intentional asymmetry (it's what makes removal clean up the[null,id]entry), it's unchanged by this PR, and the described skip never triggers on the real insert path. Test-file lint/format/flake findings were addressed.🤖 Generated with Claude Code