fix: metadata-only update() never rewrites the noun record — the unconditional whole-vector save turned per-entity stat touches into full rewrites+fsync, amplifying read-heavy sweeps into disk saturation on a production deployment
Also: idle PathResolver stats tick no longer logs NaN% every minute (logs only on new traffic, via prodLog); graph-lsm-* key family recognized as system resources (kills the per-boot unknown-key warning on provider-backed brains). Four regression pins in tests/integration/update-write-granularity.
This commit is contained in:
parent
64049631bc
commit
cb717be275
4 changed files with 155 additions and 15 deletions
|
|
@ -3161,7 +3161,15 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
new UpdateNounMetadataOperation(this.storage, params.id, updatedMetadata)
|
||||
)
|
||||
|
||||
// Operation 2: Update vector data (will use updated type cache)
|
||||
// Operations 2-4: vector-record write + HNSW reindex — ONLY when the
|
||||
// vector side actually changed (new data/vector/type). A metadata-only
|
||||
// update must never rewrite the noun record: the record carries the
|
||||
// full vector, so an unconditional save turned every metadata touch
|
||||
// into a whole-vector rewrite + fsync — under a read-heavy consumer
|
||||
// sweep that bumps per-entity stats, this amplified into disk
|
||||
// saturation on a production deployment (SELF-ENGINE-RESTART-GRIND,
|
||||
// 2026-07-29: 5.8GB written in 40min from ~50 recalls/min).
|
||||
if (needsReindexing) {
|
||||
tx.addOperation(
|
||||
new SaveNounOperation(this.storage, {
|
||||
id: params.id,
|
||||
|
|
@ -3170,9 +3178,6 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
level: 0
|
||||
})
|
||||
)
|
||||
|
||||
// Operation 3-4: Update HNSW index (remove and re-add if reindexing needed)
|
||||
if (needsReindexing) {
|
||||
tx.addOperation(
|
||||
new RemoveFromVectorIndexOperation(this.index, params.id, existing.vector)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -382,6 +382,10 @@ export abstract class BaseStorage extends BaseStorageAdapter {
|
|||
// identical to the unknown-key fallback these keys hit
|
||||
// before being listed here — this only kills the
|
||||
// per-boot "Unknown key format" warning)
|
||||
id.startsWith('graph-lsm-') || // Graph-LSM store manifests written through storage by
|
||||
// an active native graph provider — same
|
||||
// warn-then-route fallback as above; listing the family
|
||||
// silences the per-boot warning on provider-backed brains
|
||||
isSingletonSystemKey(id) // Known singletons (e.g. brainy:entityIdMapper) hit the
|
||||
// same warn-then-route fallback without this — the
|
||||
// routing below already handles them identically
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export class PathResolver {
|
|||
// Statistics
|
||||
private cacheHits = 0
|
||||
private cacheMisses = 0
|
||||
private lastLoggedLookups = 0 // last total the maintenance tick logged stats at
|
||||
private metadataIndexHits = 0
|
||||
private metadataIndexMisses = 0
|
||||
private graphTraversalFallbacks = 0
|
||||
|
|
@ -519,10 +520,14 @@ export class PathResolver {
|
|||
}
|
||||
}
|
||||
|
||||
// Log cache statistics (in production, send to monitoring)
|
||||
const hitRate = this.cacheHits / (this.cacheHits + this.cacheMisses)
|
||||
if ((this.cacheHits + this.cacheMisses) % 1000 === 0) {
|
||||
console.log(`[PathResolver] Cache stats: ${Math.round(hitRate * 100)}% hit rate, ${this.pathCache.size} entries, ${this.hotPaths.size} hot paths`)
|
||||
// Log cache statistics only when there is new traffic to report — an
|
||||
// idle resolver stays silent. 0/0 lookups previously rendered
|
||||
// "NaN% hit rate" (and the %1000 gate passes at zero), which spammed
|
||||
// production journals once a minute on every idle VFS.
|
||||
const totalLookups = this.cacheHits + this.cacheMisses
|
||||
if (totalLookups > 0 && totalLookups !== this.lastLoggedLookups && totalLookups % 1000 === 0) {
|
||||
this.lastLoggedLookups = totalLookups
|
||||
prodLog.debug(`[PathResolver] Cache stats: ${Math.round((this.cacheHits / totalLookups) * 100)}% hit rate, ${this.pathCache.size} entries, ${this.hotPaths.size} hot paths`)
|
||||
}
|
||||
}, 60000) // Every minute
|
||||
// Cache maintenance must never keep the host process alive.
|
||||
|
|
|
|||
126
tests/integration/update-write-granularity.test.ts
Normal file
126
tests/integration/update-write-granularity.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* @module tests/integration/update-write-granularity
|
||||
* @description Write-granularity law for update() (SELF-ENGINE-RESTART-GRIND,
|
||||
* 2026-07-29): a metadata-only update must NEVER rewrite the noun record —
|
||||
* the record carries the full vector, so an unconditional save turns every
|
||||
* metadata touch into a whole-vector rewrite + fsync. Under a read-heavy
|
||||
* consumer sweep bumping per-entity stats this amplified into disk saturation
|
||||
* on a production deployment. Laws:
|
||||
* (1) metadata-only update() → zero saveNoun calls (metadata leg only);
|
||||
* (2) data/vector/type-changing update() → saveNoun runs (the vector leg and
|
||||
* HNSW reindex still happen when the vector side actually changed);
|
||||
* (3) the metadata-only path still lands: merged metadata readable, _rev
|
||||
* bumped, find() by the new field sees the entity.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { Brainy } from '../../src/brainy.js'
|
||||
import { NounType } from '../../src/types/graphTypes.js'
|
||||
|
||||
const stubEmbedding = async (text: string): Promise<number[]> => {
|
||||
const hash = text.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||
return new Array(384).fill(0).map((_, i) => Math.sin(hash + i))
|
||||
}
|
||||
|
||||
describe('update() write granularity', () => {
|
||||
let brain: Brainy
|
||||
|
||||
beforeEach(async () => {
|
||||
brain = new Brainy({
|
||||
requireSubtype: false,
|
||||
storage: { type: 'memory' as const },
|
||||
embeddingFunction: stubEmbedding
|
||||
})
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await brain.close()
|
||||
})
|
||||
|
||||
it('metadata-only update never rewrites the noun record (no vector rewrite)', async () => {
|
||||
const id = await brain.add({
|
||||
data: 'granularity law subject',
|
||||
type: NounType.Concept,
|
||||
metadata: { touched: 0 }
|
||||
})
|
||||
|
||||
const storage = (brain as any).storage
|
||||
const saveNounSpy = vi.spyOn(storage, 'saveNoun')
|
||||
|
||||
await brain.update({ id, metadata: { touched: 1 } })
|
||||
|
||||
expect(saveNounSpy).not.toHaveBeenCalled()
|
||||
saveNounSpy.mockRestore()
|
||||
|
||||
// The metadata leg still landed with full semantics.
|
||||
const after = await brain.get(id, { includeVectors: true })
|
||||
expect(after?.metadata?.touched).toBe(1)
|
||||
expect(after?._rev).toBe(2)
|
||||
expect(Array.isArray(after?.vector) && after!.vector!.length).toBe(384)
|
||||
|
||||
const found = await brain.find({ where: { touched: 1 } })
|
||||
expect(found.some((r: any) => r.id === id)).toBe(true)
|
||||
})
|
||||
|
||||
it('confidence/weight/subtype-only updates also skip the noun record', async () => {
|
||||
const id = await brain.add({
|
||||
data: 'reserved-field touch subject',
|
||||
type: NounType.Concept,
|
||||
metadata: {}
|
||||
})
|
||||
|
||||
const storage = (brain as any).storage
|
||||
const saveNounSpy = vi.spyOn(storage, 'saveNoun')
|
||||
|
||||
await brain.update({ id, confidence: 0.5, weight: 2, subtype: 'note' })
|
||||
|
||||
expect(saveNounSpy).not.toHaveBeenCalled()
|
||||
saveNounSpy.mockRestore()
|
||||
|
||||
const after = await brain.get(id)
|
||||
expect(after?.confidence).toBe(0.5)
|
||||
expect(after?.subtype).toBe('note')
|
||||
})
|
||||
|
||||
it('data-changing update still writes the noun record and reindexes', async () => {
|
||||
const id = await brain.add({
|
||||
data: 'original embedded text',
|
||||
type: NounType.Concept,
|
||||
metadata: {}
|
||||
})
|
||||
|
||||
const before = await brain.get(id, { includeVectors: true })
|
||||
|
||||
const storage = (brain as any).storage
|
||||
const saveNounSpy = vi.spyOn(storage, 'saveNoun')
|
||||
|
||||
await brain.update({ id, data: 'completely different embedded text' })
|
||||
|
||||
expect(saveNounSpy).toHaveBeenCalled()
|
||||
saveNounSpy.mockRestore()
|
||||
|
||||
const after = await brain.get(id, { includeVectors: true })
|
||||
expect(after?.data).toBe('completely different embedded text')
|
||||
expect(after?.vector).not.toEqual(before?.vector)
|
||||
})
|
||||
|
||||
it('explicit-vector update still writes the noun record', async () => {
|
||||
const id = await brain.add({
|
||||
data: 'vector swap subject',
|
||||
type: NounType.Concept,
|
||||
metadata: {}
|
||||
})
|
||||
|
||||
const storage = (brain as any).storage
|
||||
const saveNounSpy = vi.spyOn(storage, 'saveNoun')
|
||||
|
||||
const newVector = new Array(384).fill(0).map((_, i) => Math.cos(i))
|
||||
await brain.update({ id, vector: newVector })
|
||||
|
||||
expect(saveNounSpy).toHaveBeenCalled()
|
||||
saveNounSpy.mockRestore()
|
||||
|
||||
const after = await brain.get(id, { includeVectors: true })
|
||||
expect(after?.vector?.[0]).toBeCloseTo(1) // cos(0)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue