fix: ifRev CAS is atomic — the revision check now runs under the commit mutex

N concurrent update({ ifRev }) calls carrying the same expected revision all
fulfilled (zero RevisionConflictErrors, last-writer-wins) — the check ran
before the commit mutex, so interleaved callers all passed it before any apply
landed. Sequential calls conflicted correctly, which hid the race. A production
cutover rehearsal caught it: 8 parallel ifRev-guarded ledger writes all
"succeeded" and 7 were silently lost. Advisory locks built on exactly-one-winner
semantics could hand the same lock to two workers.

Fix: conditional commit. commitSingleOp and commitTransaction accept an
optional precommit(beforeImages) precondition, invoked under the commit mutex
against the just-read authoritative before-images and before anything is staged
or applied — the per-record analogue of ifAtGeneration, which always ran there.
A throw aborts the commit atomically (generation reservation returned, zero
staging I/O in the transaction path). The store stays domain-ignorant; update()
and transact() supply the ifRev predicate:

- update(): the planning-time check remains as a fast-fail (avoids embedding
  cost on an obviously stale expectation); the authoritative check re-verifies
  the before-image's _rev and re-stamps the update's _rev from it, so the
  counter is monotonic even for concurrent non-CAS updates (N plain updates
  advance _rev by N). CAS against a concurrently-removed entity now throws
  EntityNotFoundError instead of silently resurrecting it; plain updates keep
  their last-writer-wins re-create semantics.
- transact(): per-op ifRev expectations registered during planning
  (PlannedTransact.casUpdates) are re-verified in the batch's precommit; any
  conflict rejects the whole batch before staging. Multiple updates to one
  entity sequence through a running rev; ids the batch itself adds
  (PlannedTransact.createdNouns — add resets the rev baseline even on
  overwrite) keep their exact plan-time sequencing.

Regression suite (tests/integration/ifrev-concurrent-cas.test.ts): 8 parallel
same-rev updates → exactly 1 winner + 7 conflicts; same for transact batches;
_rev monotonicity; the documented read→CAS→retry ledger loop converging exactly
(8 workers, 8 decrements, 0 lost); sequential behavior unchanged; forward-ref
add+update in one batch unchanged.
This commit is contained in:
David Snelling 2026-07-08 14:53:34 -07:00
parent b6c4d693cd
commit 9a3d1bd494
3 changed files with 393 additions and 13 deletions

View file

@ -0,0 +1,187 @@
/**
* @module tests/integration/ifrev-concurrent-cas
* @description Regression for the P0 report that `update({ ifRev })` CAS was
* not atomic under concurrent in-process calls: N concurrent updates all
* carrying the same `ifRev` ALL fulfilled (0 conflicts, last-writer-wins)
* the check ran before the commit mutex, so interleaved callers all passed it
* before any apply landed. In production that silently lost 7 of 8
* ifRev-guarded ledger writes and let two workers both "acquire" an advisory
* lock built on exactly-one-winner semantics.
*
* The fix is a conditional commit: the generation store's `precommit` hook
* re-verifies `ifRev` against the authoritative before-image UNDER the commit
* mutex, atomically with the apply (the per-record analogue of
* `ifAtGeneration`, which always ran there). These tests pin:
* - exactly-one-winner for N concurrent same-rev `update({ ifRev })`
* - the same for `transact()` per-op ifRev (whole batch rejected)
* - `_rev` monotonicity under concurrent plain updates (no ifRev)
* - the CAS retry loop converging exactly (the ledger/credit-consume shape)
* - sequential conflict behavior unchanged
*/
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'
const isRevConflict = (e: unknown): boolean =>
(e as Error)?.name === 'RevisionConflictError' ||
String(e).includes('RevisionConflict')
describe('ifRev CAS is atomic under concurrency (conditional commit)', () => {
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-cas-'))
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 update({ifRev}) → exactly 1 winner, N-1 RevisionConflictError', async () => {
const id = await brain.add({
id: freshId(),
data: 'contended entity',
type: NounType.Thing,
metadata: { credits: 8 }
})
const rev = (await brain.get(id))._rev
const results = await Promise.allSettled(
Array.from({ length: 8 }, (_, i) =>
brain.update({ id, metadata: { writer: i }, merge: false, ifRev: rev })
)
)
const wins = results.filter((r) => r.status === 'fulfilled')
const conflicts = results.filter(
(r) => r.status === 'rejected' && isRevConflict(r.reason)
)
expect(wins.length).toBe(1)
expect(conflicts.length).toBe(7)
// Exactly one bump; the surviving metadata belongs to the single winner.
const after = await brain.get(id)
expect(after._rev).toBe(rev + 1)
expect(typeof after.metadata.writer).toBe('number')
})
it('transact() per-op ifRev → exactly 1 batch wins, others rejected whole', async () => {
const id = await brain.add({
id: freshId(),
data: 'transact contended',
type: NounType.Thing,
metadata: { state: 'initial' }
})
const rev = (await brain.get(id))._rev
const results = await Promise.allSettled(
Array.from({ length: 6 }, (_, i) =>
brain.transact([
{ op: 'update', id, metadata: { batch: i }, merge: false, ifRev: rev }
])
)
)
expect(results.filter((r) => r.status === 'fulfilled').length).toBe(1)
expect(
results.filter((r) => r.status === 'rejected' && isRevConflict(r.reason))
.length
).toBe(5)
expect((await brain.get(id))._rev).toBe(rev + 1)
})
it('_rev is monotonic under concurrent plain updates (no ifRev): N updates → +N', async () => {
const id = await brain.add({
id: freshId(),
data: 'plain concurrent updates',
type: NounType.Thing,
metadata: { v: 0 }
})
const rev = (await brain.get(id))._rev
await Promise.all(
Array.from({ length: 8 }, (_, i) => brain.update({ id, metadata: { v: i } }))
)
// Every applied update gets its own honest bump (previously all stamped
// the same stale rev+1 they computed before the commit).
expect((await brain.get(id))._rev).toBe(rev + 8)
})
it('the CAS retry loop converges exactly (the ledger shape: 8 workers, 8 consumes)', async () => {
const id = await brain.add({
id: freshId(),
data: 'credit budget',
type: NounType.Thing,
metadata: { credits: 8 }
})
// Each worker: read → CAS-decrement → retry on conflict. The docs' documented
// pattern; with atomic CAS this MUST land on exactly 0 with zero lost updates.
const consumeOne = async (): Promise<void> => {
for (let attempt = 0; attempt < 50; attempt++) {
const cur = await brain.get(id)
if (cur.metadata.credits <= 0) throw new Error('budget exhausted')
try {
await brain.update({
id,
metadata: { ...cur.metadata, credits: cur.metadata.credits - 1 },
merge: false,
ifRev: cur._rev
})
return
} catch (e) {
if (!isRevConflict(e)) throw e
}
}
throw new Error('no convergence after 50 attempts')
}
await Promise.all(Array.from({ length: 8 }, () => consumeOne()))
expect((await brain.get(id)).metadata.credits).toBe(0)
})
it('sequential conflict behavior is unchanged (stale ifRev throws, fresh succeeds)', async () => {
const id = await brain.add({
id: freshId(),
data: 'sequential control',
type: NounType.Thing,
metadata: { n: 1 }
})
const rev = (await brain.get(id))._rev
await brain.update({ id, metadata: { n: 2 }, ifRev: rev })
await expect(
brain.update({ id, metadata: { n: 3 }, ifRev: rev })
).rejects.toMatchObject({ name: 'RevisionConflictError' })
// Fresh rev succeeds.
const fresh = (await brain.get(id))._rev
await brain.update({ id, metadata: { n: 3 }, ifRev: fresh })
expect((await brain.get(id)).metadata.n).toBe(3)
})
it('forward-ref add+update of the same entity in one transact() batch still works', async () => {
const id = freshId()
await brain.transact([
{ op: 'add', id, data: 'created in batch', type: NounType.Thing, metadata: { step: 1 } },
{ op: 'update', id, metadata: { step: 2 } }
])
const after = await brain.get(id)
expect(after.metadata.step).toBe(2)
expect(after._rev).toBe(2) // add stamps 1, in-batch update sequences to 2
})
})