fix(delete): the null-metadata skip closes — index legs run id-keyed or narrate, never silently strand postings
remove() guarded its index legs with 'if (metadata)': a row whose canonical metadata was unreadable at delete time kept its postings forever, silently (the leak class a partner audit confirmed at this exact site). Closed at all three provider shapes: a provider exposing the id-keyed removal contract (removeEntityById, arriving with the accelerator's next minor) gets exact per-entity retraction; the JS index gets id-keyed cleanup (deleted bitmap + id mapper — field stats reconcile at rebuild), narrated; a native provider without the contract is NEVER called metadata-omitted (that path walks the store's value space) — its skip is narrated and tracked in the degraded set for repairIndex, never silent. The pre-reads are torn-tolerant: a torn record is deletable (the delete is the cure). Pinned: a metadata-less row with live postings deletes cleanly and leaves the query universe; the delete-family suites stay green alongside.
This commit is contained in:
parent
8d45f964e9
commit
607e9f5492
2 changed files with 129 additions and 4 deletions
|
|
@ -3692,9 +3692,22 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
// stored, so remove() deletes the same entity. A real UUID passes through.
|
// stored, so remove() deletes the same entity. A real UUID passes through.
|
||||||
id = resolveEntityId(id)
|
id = resolveEntityId(id)
|
||||||
|
|
||||||
// Get entity metadata and related verbs before deletion
|
// Get entity metadata and related verbs before deletion. TORN-TOLERANT:
|
||||||
const metadata = await this.storage.getNounMetadata(id)
|
// a torn record must still be deletable (the delete IS the cure) — a
|
||||||
const noun = await this.storage.getNoun(id)
|
// torn pre-read reads as null and the null-path below handles it loudly.
|
||||||
|
let metadata: any = null
|
||||||
|
let noun: any = null
|
||||||
|
try {
|
||||||
|
metadata = await this.storage.getNounMetadata(id)
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as { code?: string }).code !== 'TORN_RECORD') throw err
|
||||||
|
prodLog.warn(`[Brainy] remove(${id}): metadata pre-read is TORN — deleting anyway; index legs run id-keyed`)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
noun = await this.storage.getNoun(id)
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as { code?: string }).code !== 'TORN_RECORD') throw err
|
||||||
|
}
|
||||||
const verbs = await this.storage.getVerbsBySource(id)
|
const verbs = await this.storage.getVerbsBySource(id)
|
||||||
const targetVerbs = await this.storage.getVerbsByTarget(id)
|
const targetVerbs = await this.storage.getVerbsByTarget(id)
|
||||||
const allVerbs = [...verbs, ...targetVerbs]
|
const allVerbs = [...verbs, ...targetVerbs]
|
||||||
|
|
@ -3712,11 +3725,61 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Operation 2: Remove from metadata index
|
// Operation 2: Remove from metadata index. THE NULL-METADATA SKIP IS
|
||||||
|
// CLOSED (a posting-leak class, confirmed at this site): when the
|
||||||
|
// pre-read missed, the leg no longer silently skips —
|
||||||
|
// - a provider exposing removeEntityById (the id-keyed contract)
|
||||||
|
// gets it: exact per-entity retraction via its reverse record;
|
||||||
|
// - the JS index gets removeFromIndex(id) — safe id-keyed cleanup
|
||||||
|
// (deleted bitmap + id mapper; field stats reconcile at rebuild);
|
||||||
|
// - a NATIVE provider WITHOUT the contract is never called
|
||||||
|
// metadata-omitted (that path walks its value space) — the skip
|
||||||
|
// happens, but NARRATED and tracked in the degraded set so
|
||||||
|
// repairIndex reconciles it. Silence is the only thing outlawed.
|
||||||
if (metadata) {
|
if (metadata) {
|
||||||
tx.addOperation(
|
tx.addOperation(
|
||||||
new RemoveFromMetadataIndexOperation(this.metadataIndex, id, metadata, this.indexWriteGeneration)
|
new RemoveFromMetadataIndexOperation(this.metadataIndex, id, metadata, this.indexWriteGeneration)
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
const prov = this.metadataIndex as unknown as {
|
||||||
|
removeEntityById?: (id: string) => Promise<void>
|
||||||
|
removeFromIndex?: (id: string, metadata?: unknown, generation?: bigint) => Promise<void>
|
||||||
|
}
|
||||||
|
if (typeof prov.removeEntityById === 'function') {
|
||||||
|
const g = this.indexWriteGeneration
|
||||||
|
tx.addOperation({
|
||||||
|
name: 'RemoveEntityByIdTombstone',
|
||||||
|
execute: async () => {
|
||||||
|
await prov.removeEntityById!(id)
|
||||||
|
return async () => {
|
||||||
|
// Undo of an id-keyed tombstone on an absent row: nothing
|
||||||
|
// to restore (the row had no readable metadata to re-post).
|
||||||
|
void g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (this.metadataIndex instanceof MetadataIndexManager) {
|
||||||
|
const gv = this.indexWriteGeneration
|
||||||
|
tx.addOperation({
|
||||||
|
name: 'IdKeyedIndexCleanup',
|
||||||
|
execute: async () => {
|
||||||
|
await prov.removeFromIndex!(id, undefined, typeof gv === 'function' ? gv() : gv)
|
||||||
|
return async () => {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
prodLog.warn(
|
||||||
|
`[Brainy] remove(${id}): no metadata at delete — id-keyed index cleanup ran ` +
|
||||||
|
`(deleted bitmap + id mapper); field statistics reconcile at the next rebuild/repairIndex.`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this._indexDegradedIds.add(id)
|
||||||
|
prodLog.warn(
|
||||||
|
`[Brainy] remove(${id}): no metadata at delete and this provider has no id-keyed ` +
|
||||||
|
`removal — its postings for this id are NOT tombstoned yet (tracked as degraded; ` +
|
||||||
|
`repairIndex() reconciles). Never calling a metadata-omitted native removal: that ` +
|
||||||
|
`path walks the store's value space.`
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Operation 3: Delete noun (full removal). The pre-read metadata rides
|
// Operation 3: Delete noun (full removal). The pre-read metadata rides
|
||||||
|
|
|
||||||
62
tests/integration/null-metadata-delete.test.ts
Normal file
62
tests/integration/null-metadata-delete.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
/**
|
||||||
|
* @module tests/integration/null-metadata-delete
|
||||||
|
* @description The null-metadata delete skip is CLOSED. remove() used to
|
||||||
|
* guard its index legs with `if (metadata)` — a row whose canonical
|
||||||
|
* metadata was unreadable at delete time (torn, or a leg lost to an old
|
||||||
|
* defect) kept its postings FOREVER, silently. Now: the JS index gets an
|
||||||
|
* id-keyed cleanup (deleted bitmap + id mapper), the id-keyed native
|
||||||
|
* contract is used when a provider offers it, and the one remaining
|
||||||
|
* skip-shape (native without the contract) is narrated and tracked, never
|
||||||
|
* silent. Pinned: a metadata-less row with live postings deletes cleanly
|
||||||
|
* and leaves the query universe.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { Brainy } from '../../src/index.js'
|
||||||
|
import { NounType } from '../../src/types/graphTypes.js'
|
||||||
|
|
||||||
|
type RawBox = {
|
||||||
|
storage: {
|
||||||
|
readNounRaw(id: string): Promise<{ metadata: unknown; vector: unknown }>
|
||||||
|
writeNounRaw(id: string, r: { metadata: unknown; vector: unknown }): Promise<void>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
const brains: Brainy[] = []
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const b of brains.splice(0)) await b.close().catch(() => {})
|
||||||
|
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('null-metadata delete', () => {
|
||||||
|
it('a row whose metadata leg is gone still deletes — id-keyed cleanup, no silent skip, gone from the query universe', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'brainy-nullmeta-del-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
const brain = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false, silent: true })
|
||||||
|
await brain.init()
|
||||||
|
brains.push(brain)
|
||||||
|
|
||||||
|
const keep = await brain.add({ data: 'survivor', type: NounType.Document, metadata: { team: 'atlas' } })
|
||||||
|
const victim = await brain.add({ data: 'doomed', type: NounType.Document, metadata: { team: 'atlas' } })
|
||||||
|
await brain.flush()
|
||||||
|
expect((await brain.find({ where: { team: 'atlas' } })).length).toBe(2)
|
||||||
|
|
||||||
|
// Manufacture the shape: the victim's metadata leg vanishes behind the
|
||||||
|
// engine's back (vector leg + postings stay live).
|
||||||
|
const storage = (brain as unknown as RawBox).storage
|
||||||
|
const raw = await storage.readNounRaw(victim)
|
||||||
|
await storage.writeNounRaw(victim, { metadata: null, vector: raw.vector })
|
||||||
|
|
||||||
|
// THE PIN: the delete neither throws nor silently strands postings.
|
||||||
|
await brain.remove(victim)
|
||||||
|
await brain.flush()
|
||||||
|
|
||||||
|
const after = await brain.find({ where: { team: 'atlas' } })
|
||||||
|
expect(after.length, 'victim left the query universe; survivor serves').toBe(1)
|
||||||
|
expect(after[0].id).toBe(keep)
|
||||||
|
expect(await brain.get(victim)).toBeNull()
|
||||||
|
}, 120000)
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue