armdb 0.11.2

sharded bitcask key-value storage optimized for NVMe
Documentation
# `db.tx()` — crash-atomic multi-collection transactions

A transaction writes several collections **all-or-none across a crash**. It is
not an isolation mechanism: there is no snapshot, no read set, no MVCC. What it
guarantees is that after any crash the database contains either every write of
the transaction or none of them.

For the history of the `atomic2`/`atomic3`/`atomic4` API this replaced, see
[`cross-collection-transactions.md`](./cross-collection-transactions.md).

```rust
db.tx()
    .put(&accounts, &from, &Account { balance: a - amount })?
    .put(&accounts, &to, &Account { balance: b + amount })?
    .put(&ledger, &entry_id, &Entry { from, to, amount })?
    .durable()
    .commit()?;
```

## What is guaranteed

| | |
|---|---|
| **Atomicity across a crash** | yes — the record is durable in the transaction log before anything is applied, and replay finishes it at the next open |
| **Atomicity of an error before the commit point** | yes — zero writes, zero index mutations, zero hooks |
| **Durability against power loss** | only with `.durable()`, which waits for the log fsync |
| **Isolation from concurrent readers** | **no** — writes become visible per shard as they are applied |
| **Rollback after the commit point** | **no** — this is a redo log; a failed apply is finished by replay, never undone |
| **Write-hook effects** | **not** covered — hooks replay in-process after the locks drop; a crash between apply and hook loses the hook, not the write — see `TxFailStage::Finish` in the failure contract below |

Isolation is worth restating: a reader that touches two of the participating
collections can observe one collection's write before the other's. The
transaction bounds what survives a crash, not what a concurrent reader sees.

## Failure contract

`commit()` splits its failures at the log append, and the split is visible in
the error:

* Any error that is **not** `DbError::TxFailedAfterLog` happened *before* the
  append: no value write, no index mutation, no hook, no reservation. Retry is
  safe.
* `DbError::TxFailedAfterLog { tx_id, stage, fenced, source }` happened *after*
  it. The transaction is not a no-op — retrying would apply it twice.

What survives depends on `stage`, and the three answers are genuinely different:

| `stage` | Primary data | Write hooks | `fenced` | What fixes it |
|---|---|---|---|---|
| `Apply` | partially applied; the record stays pending | fire for the writes that landed; the rest fire once replay finishes them at the next open | the shards that owe the writes | reopening the database — replay finishes the record |
| `Finish` | fully applied; the record is settled | **dropped, unrecoverably — the only stage where they are** | empty | nothing; the primary data is already correct |
| `Sync` | fully applied and visible | replayed normally | empty | nothing; only power-loss durability is in doubt |

Three things this table does *not* say:

* `Apply`'s replay guarantee is against a **process** crash — the same guarantee
  the rest of this document describes. Surviving a power loss still requires
  `.durable()`.
* `Finish` is where the "write-hook effects are not covered" row above is felt
  from the failure side: the record is marked applied, so a later open replays
  nothing and produces no hook events.
* `Sync` means the append reached the process but was not confirmed durable. If
  power is lost, the record may be absent and no replay happens at all.

`fenced` names Bitcask shards only. FixedStore replay reads the reserved slot's
exact state, which no later write can disturb, so it is never fenced.

One gap: a panic unwinding through the locked section fences its shards
correctly through `Drop`, but `commit()` never returns, so nothing is reported.

`stage == Finish` has no end-to-end test, not because it has no real source but
because none of its sources has an injection point. Three things can produce
it: `tx_arc()` failing to upgrade the collection's own weak self-reference
(`armdb/src/txn/families/typed.rs:142,308`) — which needs the collection to have
been dropped out from under a commit that is, by construction, still holding a
live handle to it — and two internal bookkeeping checks in
`armdb/src/txn/held.rs`: "buffered hook events and apply order disagree", which
fails only if a family's total buffered-event count across shards disagrees
with the length of its own apply order, and "missing buffered hook event for an
applied write", which fails only if replaying that apply order pops an empty
event queue for some shard. All three are invariants of the crate's own state,
not conditions a caller or test can bring about from outside, so `stage ==
Finish` is covered by a unit test on the stage-mapping function
(`stage_and_source` in `armdb/src/txn/mod.rs`) instead.

## Who can participate

The six managed families — `TypedTree`, `TypedMap`, `ZeroTree`, `ZeroMap`,
`VarTypedTree`, `VarTypedMap` — opened through **the same `Db`**. Membership is
checked by `Arc` identity, so a handle from another `Db` is rejected at
`commit()` with `DbError::Client`.

**FixedStore** collections (`open_zero_tree_fixed` / `open_zero_map_fixed`) are
eligible alongside the Bitcask families above. A transaction can therefore span
both backends when an application needs to combine eligible collections.

An **encrypted** Bitcask collection may participate when `TxConfig::log_encryption_key`
is set; without it `commit()` refuses before any lock is taken. FixedStore
collections are never encrypted.

Not eligible today:

- **`LsmTree`** — never participates.
- Raw `VarTree` / `VarMap` — wrap them in a `VarTyped*` to take part.

Engine-owned queues opened from the same `Db` also participate through
`push(&queue, payload, not_before)`, `ack(&queue, token)`, and
`nack(&queue, token)`. A fixed queue contributes its header row; a variable
queue contributes its FixedStore header and Bitcask payload rows. A declared
change stream lowers its emissions to the same absolute queue writes before the
log point. Replay applies recorded queue rows and never re-runs the stream
closure; an aborted transaction emits none. See [`queues.md`](./queues.md).

`txlog` is a reserved collection name.

## Operations

| Method | Effect |
|---|---|
| `put(&coll, &key, &value)` | write, encoded at the call site |
| `insert(&coll, &key, &value)` | write **only if the key is absent**; an occupied key fails the whole transaction with `DbError::KeyExists`, before the log point |
| `delete(&coll, &key)` | delete; a key that is absent is a legal no-op |
| `upsert(&coll, &key, f)` | `f(Option<&T>) -> Upsert<T>`, evaluated under the locks |
| `guard(&coll, &key, f)` | `f(Option<&T>) -> DbResult<()>`; an `Err` fails the whole transaction before anything is written |
| `delete_scope(&scope, limit)` | queue up to `limit` source deletes from a collection-bound `TxScope`; returns the queued count and whether the range was exhausted |

Actions evaluate in their insertion order, and each action sees the
transaction's own pending write for that key before the stored value.
Only the resulting writes collapse: the last write is kept at the position of
the key's first appearance. This does not turn the sequence into an
unconditional "last decision wins" rule — `insert` still rejects an occupied
key when it is evaluated. Thus a `put` followed by `insert` on one key fails
with `DbError::KeyExists`, while a `delete` followed by `insert` on that key
succeeds.

`insert` decides on the **raw** stored bytes and never decodes a pre-existing
occupied value — an occupied key whose value no longer decodes is still
`KeyExists`, matching a collection-level `insert`, which rejects on the index
before decoding the existing value. The new caller value is encoded at the
transaction builder call site, so its codec may run before the presence check.
The `guard` + `put` form people reach for instead decodes first
(`guard` hands its closure a typed value), so on a corrupt row that form
reports the codec error. For decodable values the two are the same outcome,
with `insert` costing one operation against `max_ops` instead of two.

### Closures run under the shard locks

`upsert` and `guard` closures are evaluated at `commit()` while this thread holds
every shard the transaction touches. **Do not call back into the database from
one.** `atomic()` and a nested `commit()` return `DbError::ReentrantTx` instead
of deadlocking, but a facade call (`put` / `get` / `delete` straight on a
collection handle) is not instrumented and will park forever on a lock this
thread already owns. Read what you need *before* building the transaction.

The same guard works in the other direction: `commit()` inside an `atomic()`
block is `ReentrantTx`.

### Chunked range deletion

`TxScope` is constructed from one of the six managed collection families and
therefore cannot accidentally select keys from one collection and delete them
from another. A call to `delete_scope` only selects source keys; it performs no
write until `commit()`.

```rust
let scope = messages.tx_scope::<1>(&MessageKey::for_room(room_id)).asc();
let (tx, selected) = db.tx().delete_scope(&scope, 256)?;
tx.durable().commit()?;
if !selected.done {
    // Commit this chunk, then start a new transaction with this same scope.
}
```

Each committed chunk is one transaction: its source deletes and every derived
secondary-index, counter, unique, or queue row are crash-atomic together.
Chunks are deliberately independent; a crash after one commit never makes a
later, uncommitted chunk visible.

`limit` is a source-operation limit, not a promise that the final transaction
fits. It is also clamped by the transaction's remaining `max_ops` capacity.
Projection and counter expansion happens under the commit locks, so final
derived `max_ops`, record-byte, queue-admission, and projection-budget checks
can reject the transaction **before** the log append. Those size-related
refusals have the ordinary pre-log no-write guarantee; rebuild the same scope
with a smaller limit (normally halve it) and retry. `UniqueViolation`,
`CounterTargetMissing`, `AmbiguousCounterTarget`, and decode failures are also
pre-log but are data validation errors, so a smaller chunk does not repair
them. Do not retry a post-log `TxFailedAfterLog` error.

Selection is not a snapshot: keys inserted after the selection walk may survive
even when `selected.done` is true. Each `delete_scope` call walks persisted
storage anew, so commit one chunk in one transaction before selecting the next.
V1 deliberately has no transactional `retain` or transactional `count`.

## Limits

Checked at the builder or before any lock, never after:

| Limit | `TxConfig` field | Default |
|---|---|---|
| Operations per transaction (before duplicate collapse) | `max_ops` | 1024 |
| Encoded record size | `max_bytes` | 8 MiB |
| Key length | — | 64 KiB, fixed |

`TxConfig::durable_by_default` sets the starting value of the `durable` flag;
it is **off**, so a plain `commit()` is atomic against a process crash but not
against power loss. `checkpoint_bytes` decides how large the log grows before a
commit triggers a cut.

## How it works

1. **Plan** — group operations by participant, order participants by
   `collection_id` (position in `Db`'s publish list) and shards ascending. This
   canonical order is what makes two overlapping transactions deadlock-free.
2. **Lock** — take every shard in that order.
3. **Evaluate** — evaluate `put`, `insert`, `delete`, `upsert`, and `guard` in
   operation insertion order under the locks, with read-your-writes over the
   pending set. `insert` checks presence without reading the stored payload.
4. **Reserve** — per backend. Bitcask reserves a contiguous GSN block per shard,
   so a shard's entries keep ascending GSNs; replay depends on that. FixedStore
   resolves each key to `(slot, target_meta)` and holds the slot in memory until
   the record is logged. An abort returns a freshly allocated slot to the allocator
   and writes nothing — with the one documented exception that a file which grew
   to make room stays grown.
5. **Log** — encode one record and append it. Any failure up to here aborts with
   nothing written. With `.durable()`, fsync before continuing.
6. **Apply** — write through the same per-shard mutation seam `atomic()` uses.
   A failure here is *not* rolled back: the record is durable, so replay
   finishes it at the next open. The failing shard is **fenced** — it applies no
   further operation in this transaction and refuses every later write with
   `DbError::ShardFenced` until the database is reopened. A Bitcask shard's
   replay rule compares the reserved GSN with the shard's recovered watermark, so
   any entry written after the gap would bury it and the next open would drop the
   committed operation. FixedStore is not fenced: its rule reads the reserved
   slot's exact state, which no unrelated write can disturb. Reads and other
   shards are unaffected.
7. **Release, then hooks** — every lock drops, then buffered hook events replay
   in operation order.
8. **Settle and maybe cut** — mark the record applied (or, on a partial apply,
   leave it pending but unpin the floor) and checkpoint if the log is over
   `checkpoint_bytes`.

### Replay

When a collection is opened, pending records naming it are replayed **before its
migrations run** — the bytes in the log are in the collection's stored,
pre-migration encoding. An operation is replayed when its reserved GSN is above
the shard's recovered watermark; at or below means it is already durably present
(or was superseded, which is equally a reason to leave it alone).

An operation that **does** need replay and whose logged `schema_fingerprint`
disagrees with the collection's stored `typ_hash` **fails the open** with
`DbError::TxSchemaMismatch` rather than injecting a stale encoding.
`TxConfig::discard_pending_on_schema_mismatch` turns that into a drop-and-log; it
is off, because losing a committed transaction silently is worse than refusing to
start.

The fingerprint is checked per operation and only after the replay decision,
because it guards *decoding*. A record can outlive the migration that replayed
it — a participant that never opens keeps the record pending while the collection
migrates — and refusing every later open over bytes nobody will decode would turn
a schema change into an unopenable database.

A record is retired only after every collection it names has been opened in this
process and the checkpoint has durably flushed them. A record naming a collection
this process never opens stays in the log.

**FixedStore** operations replay against the slot's recovered state, not a
per-shard GSN watermark. The logged `(slot_id, target_meta)` is compared under
the shard lock:

| Condition | Action |
|---|---|
| `slot_id == NO_SLOT` | skip (logged no-op) |
| `current_meta == target_meta`, put, CRC valid, **another key** in the slot | `DbError::TxFixedSlot` — fail closed (or skip, if *superseded*) |
| `current_meta == target_meta` and state matches the kind | skip (already durable) |
| `is_newer(current_version, target_version)` | skip (slot moved on) |
| `is_newer(target_version, current_version)` | replay (our write never landed) |
| `current_version == target_version`, state differs | replay (torn write, or status differs) |
| otherwise (unorderable) | `DbError::TxFixedSlot` — fail closed (or skip, if *superseded*) |

**Superseded** is the one condition that overrides the rest: if the collection's
index maps the operation's key to a *different* slot, the key has a newer
incarnation and this reservation is an orphan — every "replay" verdict above
becomes a skip, and both fail-closed verdicts become a skip too. Replaying an
orphan would overwrite whatever now lives in the reserved slot. (An orphan arises
when an apply failed after the log point: the record stays durable, the slot stays
allocated, and a later commit for the same key lands elsewhere.)

"State matches the kind" means: for a put, the slot's CRC validates and its stored
key equals the operation's key; for a delete, the status is `STATUS_DELETED`. A
torn write at the target version recovers as FREE while preserving the version,
so equality on version alone would wrongly skip — the state check catches it.

A slot holding a **different** key's intact row at the exact target version means
the reservation was recycled while the record stayed durable. Neither answer is
safe to guess — replaying overwrites a live row that is not ours, skipping drops a
committed write — so unless the key is superseded the open fails with
`DbError::TxFixedSlot`. Reservations are log-aware (they are never handed back to
the allocator once the record is durable), so this state should not arise; the
check is the backstop that keeps a bug from becoming cross-key corruption.

Versions more than 2²⁹ apart cannot be ordered in the 30-bit ring, so — again
unless the key is superseded — the open fails with `DbError::TxFixedSlot` rather
than guessing.

## Encryption

When any participant reports `encrypted()`, `TxConfig::log_encryption_key` must
be set (behind the `encryption` feature). The key seals every log record with
AES-256-GCM: HKDF over a `txlog`-local salt stored in `txlog/db.salt`, the record
header as AAD, and the `tx_id` as the nonce. Without a log key `commit()` refuses,
so a collection's values are never written to the log in the clear. A pending
encrypted record with a missing or wrong key fails the open. FixedStore
collections are never encrypted.

### Operating an encrypted log

- **Back up `txlog/db.salt` with the segments.** The key alone cannot open a
  sealed record; losing the salt is losing the pending log.
- **Rotating the key requires a drained log.** A record sealed under the old key
  fails the open once the new key is configured, and nothing migrates it. Drain
  first (every record applied and checkpointed away), then change the key.
- **A plaintext record stays readable** after the key is configured, so turning
  encryption on does not orphan a log written before it.
- **The plaintext is resident in memory** for the log's lifetime: `TxLog` keeps
  every pending record so it can replay it. The envelope protects the log at rest.
- **What it defends against.** Confidentiality of logged keys and values, plus
  per-record integrity: a modified record fails its GCM tag and fails the open,
  even if its CRC was repaired. It is **not** an anti-truncation or anti-rollback
  mechanism — an attacker with write access to `txlog/` can still corrupt the
  newest segment's tail (which recovery discards as torn) or unlink a segment
  file, and no authenticated chain links the segments.

## Errors

| Error | Meaning |
|---|---|
| `ReentrantTx` | `commit()` inside `atomic()`, or a collection call inside an `upsert`/`guard` closure |
| `Config` | an encrypted participant with no log key |
| `Client` | limit exceeded, or a handle from another `Db` |
| `TxSchemaMismatch` | a pending record's encoding no longer matches the collection (raised at open) |
| `TxFixedSlot` | a pending FixedStore operation cannot be ordered against the slot's current version (raised at open) |