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 |
|---|---|
| Returned on every |
| If the persisted |
| Carries |
| By-ID idempotent insert. Returns the existing |
| Applies |
_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 |
|---|---|---|---|---|
| One entity | Per-entity write count, bumped on every successful update |
|
|
Generation | Whole store | One tick per committed |
|
|
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.
The transact envelope: batch size, budget, and bulk imports
transact() applies its batch atomically under one commit — which means the whole batch shares one apply budget. Since 8.7.0 the budget scales with the batch: max(30 s, opCount × 2 s), or exactly what you pass as timeoutMs. A tripped budget rolls the entire batch back (nothing partial survives) and throws a retryable TransactionTimeoutError that names the operation it stopped at, the batch size, and the elapsed vs budgeted time — a diagnosis, not just a failure:
Transaction timed out at operation 41/120 ('add') — 246012ms elapsed, budget 240000ms.
The batch rolled back atomically; retry with a higher timeoutMs or a smaller batch.Practical envelope guidance for bulk work:
Precompute embeddings outside the commit path. Embedding inside
transact()spends the budget on model inference. Usebrain.embedBatch(texts)and pass each vector via the op'svectorfield — the commit then pays only storage costs, and a retried batch never re-pays inference. (The win is where the inference happens, not raw embedding throughput: on the default WASM engine, batch and sequential embedding measure comparably, ~160 ms/text; native embedding providers may batch faster.)Chunk very large imports into batches of a few hundred ops with one
transact()each. You lose whole-import atomicity but keep per-chunk atomicity, bounded memory, and resumability — pair withifAbsentupserts so a retried chunk is idempotent.Slow disks change the math, not the contract. On network-attached storage a single op can cost ~2 s (canonical write + fsync + index maintenance). The scaled default absorbs that; pass an explicit
timeoutMsonly when you know better than the scale.addMany/relateManyare the convenience tier — they chunk and batch-embed for you, with per-item error reporting instead of batch atomicity. Choose by what you need: atomic-all-or-nothing →transact(); resilient bulk load →addMany.