Prepares the repo for its new home at soulcraftlabs/open-brainy ahead of the Forgejo transfer: package name, publish registry, release script, and every install/import reference across docs, src, tests, examples, and integrations now point at @soulcraftlabs/brainy on The Source. The npmjs storefront leg and byte-identity pair verification are stripped from the release script — The Source is now the only publish target. README gains an Open Brainy explainer and a registry note for consumers. @soulcraft/brainy 10.4.2 was the last release under the old name.
214 lines
9.8 KiB
Markdown
214 lines
9.8 KiB
Markdown
---
|
||
title: Optimistic concurrency with _rev
|
||
slug: guides/optimistic-concurrency
|
||
public: true
|
||
category: guides
|
||
template: guide
|
||
order: 8
|
||
description: Use the per-entity `_rev` counter and `update({ ifRev })` to coordinate concurrent writes safely. Covers the lock pattern, idempotent inserts with `ifAbsent`, and recovery on conflict.
|
||
next:
|
||
- concepts/consistency-model
|
||
- guides/find-limits
|
||
---
|
||
|
||
# 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](../concepts/consistency-model.md): `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:
|
||
|
||
```ts
|
||
import { Brainy, RevisionConflictError } from '@soulcraftlabs/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:
|
||
|
||
```ts
|
||
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:
|
||
|
||
```ts
|
||
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.
|
||
- `_rev` is **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:
|
||
|
||
```ts
|
||
// 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:
|
||
|
||
```ts
|
||
import { GenerationConflictError } from '@soulcraftlabs/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](../concepts/consistency-model.md) and [Snapshots & Time Travel](./snapshots-and-time-travel.md).
|
||
|
||
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:
|
||
|
||
1. **Precompute embeddings outside the commit path.** Embedding inside `transact()` spends
|
||
the budget on model inference. Use `brain.embedBatch(texts)` and pass each vector via
|
||
the op's `vector` field — 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.)
|
||
2. **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 with `ifAbsent` upserts so a retried chunk is idempotent.
|
||
3. **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 `timeoutMs` only when you know better than the scale.
|
||
4. **`addMany`/`relateMany` are 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`.
|