docs(8.0): consistency-model concept + snapshots guide — Db API replaces branching docs
This commit is contained in:
parent
e5feae4104
commit
cc8037db10
23 changed files with 1053 additions and 1871 deletions
|
|
@ -7,8 +7,8 @@ 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
|
||||
- api/README
|
||||
---
|
||||
|
||||
# Optimistic concurrency with `_rev`
|
||||
|
|
@ -25,7 +25,7 @@ Brainy 7.31.0 adds a per-entity revision counter so multiple writers can coordin
|
|||
| `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 independent of `brain.versions.*` (named snapshots), of `brain.fork()` / branches (COW), and of VFS file versioning. They all coexist; `_rev` is the per-write counter used for CAS.
|
||||
`_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
|
||||
|
||||
|
|
@ -134,42 +134,49 @@ await brain.addIfMissing({ // ← not a real API
|
|||
})
|
||||
```
|
||||
|
||||
It's race-prone outside a transaction: two concurrent imports both see "not found," both insert, you get duplicates. Without a unique-index primitive (which Brainy doesn't have today), this pattern needs to live inside a transaction:
|
||||
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
|
||||
// The race-safe shape, available once brain.transact() ships in 8.0.
|
||||
await brain.transact(async tx => {
|
||||
const existing = await tx.find({
|
||||
type: 'Person',
|
||||
where: { email: 'x@y.com' },
|
||||
limit: 1
|
||||
})
|
||||
if (existing.length === 0) {
|
||||
await tx.add({ type: 'Person', data: '...', metadata: { email: 'x@y.com' } })
|
||||
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')
|
||||
}
|
||||
```
|
||||
|
||||
For 7.31.0, lean on `ifAbsent` when you control the ID, and accept the inherent dedup race when you don't. The 8.0 `brain.transact()` is the natural home for the attribute-based variant.
|
||||
`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` interacts with the other versioning systems
|
||||
## How `_rev` relates to generations
|
||||
|
||||
Brainy has several persistence primitives that all touch the word "version" in different ways. `_rev` is independent of every one of them:
|
||||
Brainy 8.0 has exactly two write-coordination counters, at two granularities:
|
||||
|
||||
| System | What it tracks | When it advances |
|
||||
|---|---|---|
|
||||
| **`_rev`** (new in 7.31.0) | Per-entity write counter | Auto, on every successful `update()` |
|
||||
| **`brain.versions.save()`** | Named snapshots per entity | Explicit — you call `save()` with a tag |
|
||||
| **`brain.fork()` / branches** | Whole-brain copy-on-write | Explicit — you call `fork(name)` |
|
||||
| **VFS file versioning** | Per-VFS-file snapshots (Document entity) | Same as `brain.versions.save()` |
|
||||
| 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` |
|
||||
|
||||
If you're on a branch, each branch has its own copy of every entity (that's what COW means), so each branch has its own `_rev` per entity — same as every other field. Snapshots taken via `brain.versions.save()` capture the entity at that moment including its `_rev` at that time; the snapshot's own `version: number` is the snapshot index, distinct from `_rev`.
|
||||
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).
|
||||
|
||||
## What's coming in 8.0
|
||||
|
||||
Brainy 8.0 ships a Datomic-style immutable `Db` API where `brain.transact(tx)` is the public multi-write atomicity primitive. Two things change for code written against 7.31.0's surface:
|
||||
|
||||
- `_rev` survives unchanged. It's part of the 8.0 entity shape; the auto-bump moves to the new `transact()` write path. Code that uses `update({ ifRev })` keeps working.
|
||||
- A new `brain.transact(tx, { ifAtGeneration: prev.generation })` adds generation-based CAS for whole-transaction atomicity, layered on top of `_rev`. Per-entity for single-record patterns (the lock above), generation-based for "did the world move under me."
|
||||
|
||||
If you adopt `_rev` + `ifRev` in 7.31.0, no migration work is needed for 8.0.
|
||||
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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue