fix: metadata index not cleaned up after delete/deleteMany
Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
This commit is contained in:
parent
cc286ba2fc
commit
1a628daa83
3 changed files with 365 additions and 13 deletions
|
|
@ -791,19 +791,23 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
}
|
||||
|
||||
// Build entity structure for indexing (NEW - with top-level fields)
|
||||
// Optional fields must use conditional spreading to match storageMetadata exactly.
|
||||
// If undefined values are included as explicit keys, extractIndexableFields indexes
|
||||
// them as '__NULL__' entries that removeFromIndex can never clean up (storageMetadata
|
||||
// omits those keys entirely via conditional spreading, so the fields don't match).
|
||||
const entityForIndexing = {
|
||||
id,
|
||||
vector,
|
||||
connections: new Map(),
|
||||
level: 0,
|
||||
type: params.type,
|
||||
confidence: params.confidence,
|
||||
weight: params.weight,
|
||||
...(params.confidence !== undefined && { confidence: params.confidence }),
|
||||
...(params.weight !== undefined && { weight: params.weight }),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
service: params.service,
|
||||
data: params.data,
|
||||
createdBy: params.createdBy,
|
||||
...(params.createdBy && { createdBy: params.createdBy }),
|
||||
// Only custom fields in metadata
|
||||
metadata: params.metadata || {}
|
||||
}
|
||||
|
|
@ -2780,6 +2784,13 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
for (let i = 0; i < idsToDelete.length; i += chunkSize) {
|
||||
const chunk = idsToDelete.slice(i, i + chunkSize)
|
||||
|
||||
// Track IDs queued during builder phase separately from confirmed deletions.
|
||||
// result.successful must only be updated AFTER the transaction commits — pushing
|
||||
// inside the builder runs before transaction.execute(), so a rollback would leave
|
||||
// successfully-queued IDs incorrectly listed as deleted.
|
||||
const chunkQueued: string[] = []
|
||||
const chunkBuilderFailed: Array<{ item: string; error: string }> = []
|
||||
|
||||
try {
|
||||
// Process chunk in single transaction for atomic deletion
|
||||
await this.transactionManager.executeTransaction(async (tx) => {
|
||||
|
|
@ -2818,9 +2829,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
)
|
||||
}
|
||||
|
||||
result.successful.push(id)
|
||||
chunkQueued.push(id)
|
||||
} catch (error) {
|
||||
result.failed.push({
|
||||
chunkBuilderFailed.push({
|
||||
item: id,
|
||||
error: (error as Error).message
|
||||
})
|
||||
|
|
@ -2830,15 +2841,18 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Transaction committed — queued IDs were actually deleted
|
||||
result.successful.push(...chunkQueued)
|
||||
result.failed.push(...chunkBuilderFailed)
|
||||
} catch (error) {
|
||||
// Transaction failed - mark remaining entities in chunk as failed if not already recorded
|
||||
for (const id of chunk) {
|
||||
if (!result.successful.includes(id) && !result.failed.find(f => f.item === id)) {
|
||||
result.failed.push({
|
||||
item: id,
|
||||
error: (error as Error).message
|
||||
})
|
||||
}
|
||||
// Transaction failed/rolled back — queued IDs were NOT deleted
|
||||
result.failed.push(...chunkBuilderFailed)
|
||||
for (const id of chunkQueued) {
|
||||
result.failed.push({
|
||||
item: id,
|
||||
error: (error as Error).message
|
||||
})
|
||||
}
|
||||
|
||||
// Stop processing if continueOnError is false
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue