Optimistic concurrency with `_rev`
Brainy 7.31.0 adds a per-entity revision counter so multiple writers can coordinate without a global lock or external coordinator. The pattern is the same one CouchDB, PouchDB, and ETag-based HTTP caches use: read the current revision, do your work, write back with ifRev: <whatRevYouSaw>. If the revision moved, your write is rejected and you retry against the latest state.
What gets added
| Surface | Behavior |
|---|---|
entity._rev: number |
Returned on every get(), find(), search(). Initialized to 1 on add(). Bumped by 1 on every successful update(). Pre-7.31.0 entities without _rev are surfaced as 1. |
update({ id, ..., ifRev: number }) |
If the persisted _rev does not equal ifRev, throws RevisionConflictError. Omitting ifRev keeps the prior (unconditional) update behavior. |
RevisionConflictError |
Carries { id, expected, actual } for principled recovery. |
add({ id, ifAbsent: true }) |
By-ID idempotent insert. Returns the existing id if one is already present; no throw, no overwrite. |
addMany({ items, ifAbsent: true }) |
Applies ifAbsent to every item. Per-item ifAbsent overrides the batch flag. |
_rev is the per-entity counter. Its store-wide counterpart is the generation counter behind the Db API: brain.transact(ops, { ifAtGeneration }) is CAS over the whole store, update({ ifRev }) (and ifRev on transact() update operations) is CAS over one entity.
The lock pattern
Every distributed-job scheduler eventually wants this exact loop:
import { Brainy, RevisionConflictError } from '@soulcraft/brainy'
const LOCK_ID = '...uuid for this job slot...'
// Bootstrap the lock document once (idempotent).
await brain.add({
id: LOCK_ID,
type: NounType.Document,
data: { owner: null, expiresAt: 0 },
ifAbsent: true
})
async function tryAcquireLock(workerId: string, ttlMs: number) {
const lock = await brain.get(LOCK_ID)
if (!lock) throw new Error('lock document missing')
const state = lock.data as { owner: string | null; expiresAt: number }
const now = Date.now()
// Only take the lock if it's free or expired.
if (state.owner && state.expiresAt > now) return false
try {
await brain.update({
id: LOCK_ID,
data: { owner: workerId, expiresAt: now + ttlMs },
ifRev: lock._rev
})
return true
} catch (err) {
if (err instanceof RevisionConflictError) {
// Another worker grabbed it between our read and write.
return false
}
throw err
}
}No external lock service, no Redis SETNX, no Cloud Tasks. The CAS check is the lock.
Read-modify-write with retry
The other common shape is "update a counter / config object" with bounded retries on conflict:
async function incrementCounter(id: string, by: number) {
for (let attempt = 0; attempt < 5; attempt++) {
const entity = await brain.get(id)
if (!entity) throw new Error('counter does not exist')
const current = (entity.data as { value: number }).value
try {
await brain.update({
id,
data: { value: current + by },
ifRev: entity._rev
})
return
} catch (err) {
if (err instanceof RevisionConflictError) continue // refetch + retry
throw err
}
}
throw new Error('counter update conflict after 5 attempts')
}The retry bound matters — without one, two unlucky writers can ping-pong forever.
Idempotent bootstrap with `ifAbsent`
For singletons (config rows, well-known seed entities, job-state documents) where the natural ID is deterministic:
await brain.add({
id: 'config:singleton',
type: NounType.Document,
data: { tenantQuota: 1000 },
ifAbsent: true
})- First caller writes; gets back
'config:singleton'. - Every subsequent caller short-circuits at the pre-read; gets back the same
'config:singleton'without touching the existing entity. _revis not bumped on the no-op path (no write happened).
ifAbsent is only meaningful when you supply an id. With no id, Brainy generates a fresh UUID that can never collide, so the flag is silently ignored.
addMany({ items, ifAbsent: true }) applies the flag to every item. Mixing per-item overrides with the batch flag works as you'd expect: per-item ifAbsent: false opts an individual row out, per-item ifAbsent: true opts it in.
Why no `addIfMissing({ match, add })` for attribute-based dedup?
You may want "create if no entity with this email exists" (lookup by attribute, not by ID). That's a different operation:
// What we DID NOT ship in 7.31.0 — the attribute-based variant.
await brain.addIfMissing({ // ← not a real API
match: { type: 'Person', where: { email: 'x@y.com' } },
add: { data: '...', metadata: { email: 'x@y.com' } }
})It's race-prone as a plain read-then-write: two concurrent imports both see "not found," both insert, you get duplicates. Without a unique-index primitive (which Brainy doesn't have today), close the race with whole-store CAS — read at a pinned generation, then commit only if nothing moved:
import { GenerationConflictError } from '@soulcraft/brainy'
async function addIfMissingByEmail(email: string, data: string) {
for (let attempt = 0; attempt < 5; attempt++) {
const db = brain.now()
try {
const existing = await db.find({
type: NounType.Person,
where: { email },
limit: 1
})
if (existing.length > 0) return existing[0].id
const committed = await brain.transact(
[{ op: 'add', type: NounType.Person, subtype: 'customer', data, metadata: { email } }],
{ ifAtGeneration: db.generation } // rejects if ANYTHING committed since the read
)
return committed.receipt!.ids[0]
} catch (err) {
if (err instanceof GenerationConflictError) continue // world moved — re-read + retry
throw err
} finally {
await db.release()
}
}
throw new Error('addIfMissingByEmail conflict after 5 attempts')
}ifAtGeneration is deliberately coarse — any committed write invalidates it — so keep the retry bound. When you control the ID, ifAbsent stays the cheaper tool.
How `_rev` relates to generations
Brainy 8.0 has exactly two write-coordination counters, at two granularities:
| Counter | Scope | What it tracks | CAS surface | Conflict error |
|---|---|---|---|---|
_rev |
One entity | Per-entity write count, bumped on every successful update | update({ ifRev }), { op: 'update', ifRev } in transact() |
RevisionConflictError |
| Generation | Whole store | One tick per committed transact() batch or single-operation write |
transact(ops, { ifAtGeneration }) |
GenerationConflictError |
They compose: a transact() batch can carry per-entity ifRev checks and a whole-store ifAtGeneration; any failed check rejects the entire batch before anything is staged. Generations also power snapshots and time travel (brain.now(), brain.asOf(), db.persist()) — see the consistency model and Snapshots & Time Travel.
A snapshot or historical view captures each entity including its _rev at that moment, so reading the past and writing back with ifRev against the live state works exactly as you'd hope: the write fails if the entity moved since the state you copied from.