/** * @module tests/integration/ifabsent-upsert-blob-concurrency * @description The 8.0.15 CAS fix's fast-follow: the two remaining * check-then-act races of the same class, found in the post-fix sweep. * * 1. `add({ ifAbsent })` / `add({ upsert })` — the absence check ran before * the commit mutex, so N concurrent same-id creates could ALL pass it and * all write (the second overwriting the first, violating ifAbsent's * "no overwrite" contract and upsert's "merge, never clobber" contract). * Fixed with the same conditional-commit primitive as `ifRev`: the insert * leg carries a must-be-absent precondition; a loser resolves to skip * (ifAbsent) or merge (upsert) instead of overwriting. * * 2. `BlobStorage` reference counts — `write()`'s dedup decision and * `delete()`'s decrement-then-remove were unserialized read-modify-writes * over `blob-meta:`; concurrent same-content writes could lose * references, turning a later delete into premature removal of bytes * another file still needs. Fixed with a per-hash mutex. * * Deterministic concurrency assertions: * - ifAbsent storm: the generation counter advances by EXACTLY 1 * (pre-fix: one generation per losing writer), `_rev` stays 1. * - upsert storm: final `_rev === N` (1 create + N−1 merges, each with an * honest bump; pre-fix a late insert reset `_rev` to 1 and clobbered). * - blob storm: N same-content writes → refCount === N; N deletes → gone, * with the bytes readable until the last reference drops. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { Brainy } from '../../src/brainy.js' import { NounType } from '../../src/types/graphTypes.js' import { BlobStorage } from '../../src/storage/blobStorage.js' describe('ifAbsent/upsert insert race + blob refCount (conditional commit fast-follow)', () => { let dir: string let brain: any let seq = 0 const freshId = (): string => `00000000-0000-4000-8000-${(++seq).toString(16).padStart(12, '0')}` beforeAll(async () => { process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-upsert-race-')) brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir }, dimensions: 384, silent: true }) await brain.init() }) afterAll(async () => { await brain.close() fs.rmSync(dir, { recursive: true, force: true }) }) it('N concurrent add({ifAbsent}) → exactly ONE write (generation +1), no overwrite', async () => { const id = freshId() const genBefore = brain.generationStore.generation() const ids = await Promise.all( Array.from({ length: 8 }, (_, i) => brain.add({ id, ifAbsent: true, data: `contender ${i}`, type: NounType.Thing, metadata: { writer: i } }) ) ) // Every caller resolves to the same canonical id. expect(new Set(ids).size).toBe(1) // THE contract: exactly one write landed. Pre-fix every losing caller // also wrote (its own generation), silently overwriting the winner. expect(brain.generationStore.generation()).toBe(genBefore + 1) const after = await brain.get(id) expect(after._rev).toBe(1) expect(typeof after.metadata.writer).toBe('number') }) it('sequential ifAbsent semantics unchanged: existing entity is returned untouched', async () => { const id = freshId() await brain.add({ id, data: 'original', type: NounType.Thing, metadata: { keep: true } }) const returned = await brain.add({ id, ifAbsent: true, data: 'impostor', type: NounType.Thing, metadata: { keep: false } }) expect(returned).toBe(id) const after = await brain.get(id) expect(after.metadata.keep).toBe(true) expect(after.data).toBe('original') }) it('N concurrent add({upsert}) on an absent id → 1 create + N-1 merges (final _rev === N)', async () => { const id = freshId() await Promise.all( Array.from({ length: 8 }, (_, i) => brain.add({ id, upsert: true, data: 'shared upsert target', type: NounType.Thing, metadata: { [`k${i}`]: true } }) ) ) const after = await brain.get(id) // Exactly one insert (rev 1) + seven merging update()s, each with an // honest monotonic bump. Pre-fix a losing insert restamped _rev to 1 and // destroyed every merge that had already applied. expect(after._rev).toBe(8) // The entity survived as ONE identity: createdAt from the single create, // and at least the last-applied merge's key present. expect(Object.keys(after.metadata).filter((k) => k.startsWith('k')).length) .toBeGreaterThanOrEqual(1) }) it('sequential upsert semantics unchanged: existing → merge, absent → create', async () => { const id = freshId() await brain.add({ id, upsert: true, data: 'v1', type: NounType.Thing, metadata: { a: 1 } }) expect((await brain.get(id))._rev).toBe(1) // created // add() requires data or vector even on the merge path — supply a vector // and no data, so `data` preservation is still observable. const vec = Array.from({ length: 384 }, (_, i) => (i % 7) / 7 - 0.5) await brain.add({ id, upsert: true, vector: vec, type: NounType.Thing, metadata: { b: 2 } }) const after = await brain.get(id) expect(after._rev).toBe(2) // merged, not overwritten expect(after.metadata.a).toBe(1) expect(after.metadata.b).toBe(2) expect(after.data).toBe('v1') // unsupplied field preserved }) }) describe('BlobStorage refCount is exact under concurrency (per-hash mutex)', () => { /** Minimal in-memory adapter — the real interface, no mocks of behavior. */ function memAdapter() { const kv = new Map() return { get: async (k: string) => kv.get(k), put: async (k: string, v: Buffer) => void kv.set(k, v), delete: async (k: string) => void kv.delete(k), list: async (prefix: string) => [...kv.keys()].filter((k) => k.startsWith(prefix)) } } it('N concurrent writes of IDENTICAL content → refCount === N (no lost references)', async () => { const blobs = new BlobStorage(memAdapter() as any) const payload = Buffer.from('identical content stored by N concurrent writers') const hashes = await Promise.all( Array.from({ length: 10 }, () => blobs.write(payload)) ) expect(new Set(hashes).size).toBe(1) const meta = await blobs.getMetadata(hashes[0]) // Pre-fix: concurrent writers raced the dedup check — several wrote // refCount:1 over each other and increments were lost. expect(meta?.refCount).toBe(10) }) it('the blob survives until the LAST reference drops — no premature deletion', async () => { const blobs = new BlobStorage(memAdapter() as any) const payload = Buffer.from('shared bytes, two referencing files') const hash = await blobs.write(payload) await blobs.write(payload) // second reference (concurrent-equivalent path) await blobs.delete(hash) // drop one reference expect((await blobs.read(hash)).toString()).toBe(payload.toString()) // still readable expect((await blobs.getMetadata(hash))?.refCount).toBe(1) await blobs.delete(hash) // last reference expect(await blobs.has(hash)).toBe(false) await expect(blobs.read(hash)).rejects.toThrow() }) it('interleaved write/delete storm converges to an exact count', async () => { const blobs = new BlobStorage(memAdapter() as any) const payload = Buffer.from('storm payload') const hash = BlobStorage.hash(payload) // 12 writes and 5 deletes racing: net 7 references, blob alive. await Promise.all([ ...Array.from({ length: 12 }, () => blobs.write(payload)), ...Array.from({ length: 5 }, () => blobs.delete(hash)) ]) const meta = await blobs.getMetadata(hash) // Deletes against a not-yet-written hash floor at zero without deleting, // so the net can only be >= 12 - 5. The exactness we require: counts are // never LOST (each landed write is represented). expect(meta?.refCount).toBeGreaterThanOrEqual(7) expect(await blobs.has(hash)).toBe(true) }) })