# armdb cross-collection transactions (`atomic2` / `atomic3` / `atomic4`)

armdb collections are independent: each owns its own engine, shards and mutexes;
`Db` only orchestrates. The single-collection `atomic(shard_key, |shard| …)` locks
**one shard of one collection**. The `atomicN` family extends this to **write
isolation across N collections** — while the transaction runs, no other writer can
touch the involved shards of any participating collection, and lock acquisition is
**deadlock-free by construction**.

This is **level A** (write isolation via multi-lock). It deliberately does **not**
provide crash-atomicity (level B / intent log) or atomic visibility for readers.

## API

```rust
impl Db {
    pub fn atomic2<A, B, R>(
        &self,
        a: &A, a_keys: &[A::Key],
        b: &B, b_keys: &[B::Key],
        f: impl FnOnce(&mut A::Tx<'_>, &mut B::Tx<'_>) -> DbResult<R>,
    ) -> DbResult<R> where A: MultiTx, B: MultiTx;

    pub fn atomic3<A, B, C, R>(…) -> DbResult<R>;          // three collections
    pub fn atomic4<A, B, C, D, R>(…) -> DbResult<R>;       // four collections
}
```

Each collection is given a slice of **known keys**; the transaction locks the union
of their unique shards. The closure receives a **typed** per-collection handle
`A::Tx<'_>`. `atomicN` is a `Db` method for discoverability but does not use `self`.

Every collection family that has `atomic()` implements [`MultiTx`] and can
participate, on **both** durability backends:
`ConstTree`/`ConstMap`, `ZeroTree`/`ZeroMap`, `TypedTree`/`TypedMap`,
`VarTree`/`VarMap`, `VarTypedTree`/`VarTypedMap`. Backends may be mixed in one
transaction (e.g. a Bitcask `ConstTree` + a Fixed `ConstMap`).

How you pass `&A` depends on the opener:

- **Typed/Zero/Var/VarTyped** are opened via `Db::open_*` returning `Arc<Coll>` →
  pass `users.as_ref()`.
- **Const** collections open standalone (`ConstTree::open` / `ConstMap::open`,
  returning `Self`) → pass `&tree`. For multi-threaded use wrap in `Arc` and pass
  `acc.as_ref()`.

```rust
db.atomic2(
    &users,    &[alice],
    &balances, &[acc1, acc2],
    |u_tx, b_tx| {
        let from = b_tx.get_or_err(&acc1)?;
        let to   = b_tx.get_or_err(&acc2)?;
        if from < amount { return Err(DbError::TxConflict); } // validate-then-write
        b_tx.put(&acc1, &(from - amount))?;
        b_tx.put(&acc2, &(to + amount))?;
        u_tx.put(&alice, &touch(u_tx.get_or_err(&alice)?))?;
        Ok(())
    },
)?;
```

## The `Tx` read/write surface

The handle may hold **several** shard guards (one per unique shard reached by the
keys) and routes each operation to the right guard. Because a key whose shard was
**not** locked has no guard to read under, the read surface is **result-returning**
so out-of-scope keys are reported instead of silently looking absent:

| Method | Signature | Out-of-scope key |
|--------|-----------|------------------|
| `try_get`      | `(&K) -> DbResult<Option<V>>` | `Err(DbError::ShardMismatch)` |
| `try_contains` | `(&K) -> DbResult<bool>`      | `Err(DbError::ShardMismatch)` |
| `get_or_err`   | `(&K) -> DbResult<V>`         | `Err(DbError::ShardMismatch)` (vs `KeyNotFound` when genuinely absent) |
| `put` / `insert` / `delete` | `… -> DbResult<…>` | `Err(DbError::ShardMismatch)` |

Plain `Option`/`bool` `get`/`contains` are **not** exposed on `Tx` — they would hide
the out-of-scope case. `try_get`/`try_contains` distinguish out-of-scope (`Err`)
from genuinely-absent (`Ok(None)` / `Ok(false)`).

## Guarantees

- **Write isolation.** While the transaction holds its locks, no other writer can
  modify the involved shards of any participating collection.
- **Consistent reads inside the closure.** `try_get`/`get_or_err`/`try_contains`
  observe state under the lock — no writer interleaves between a read and a later
  `put` in the same transaction.
- **Deadlock-freedom.** Lock acquisition follows a global canonical order
  (`(collection_id, shard_id)`), so two overlapping transactions — even
  `atomic2(A, B)` against `atomic2(B, A)` — cannot deadlock. (Modelled in
  `tests/loom_multi_tx.rs`.)

## Non-guarantees (read these carefully)

- **No atomic visibility for readers.** A transaction takes write locks only.
  A concurrent reader may observe one collection updated while another is not. This
  is already true today even for two keys inside a single `atomic()`. armdb reads
  are not uniformly lock-free (map reads take the per-shard index mutex, a Var read
  can hit disk on a cache miss), but that per-shard locking does **not** give
  cross-collection atomicity.
- **No crash-atomicity.** Participating collections have separate write buffers and
  no shared WAL; shards are `sync`'d independently, without a barrier. On a crash,
  part of a transaction may be on disk and part not. Durability needs level B.
- **No replicated atomicity.** Each collection pushes to its own replication stream
  independently; a follower observes the writes per collection, not atomically.
- **No rollback on error/panic.** Writes apply to the write buffer immediately;
  there is no undo. An `Err` from the closure **propagates**, but applied writes
  **remain**, and hooks for already-applied events **still replay** (otherwise a
  hook-backed secondary index would desync). The `Err` path differs from the `Ok`
  path only in the returned value. Prefer **validate-then-write**: do all
  reads/checks first, all writes last.

## Duplicate collections

Passing the same collection twice (`atomic2(&users, …, &users, …)`) would double-lock
its shards from the same thread → self-deadlock. This is detected up front and
returns `DbError::DuplicateCollectionInTx`.

## Hook replay order

Each transaction handle owns **one collection-wide event log**; every routed
mutation appends to it in **closure order**, so application order is preserved even
across multiple shards of the same collection. Hooks replay **after every lock of
the transaction is released** (so a hook may freely write to any other collection
without reentrancy deadlock), in `atomicN` argument order across collections and
application order within a collection.

## Optimistic concurrency for dynamic keys

The API requires **known** keys, so the "key of B depends on a value read from A"
case is handled by an optimistic pattern (no engine support needed). Read outside
any transaction, then open a narrow transaction with the now-known keys and
**revalidate**; on a change, return `DbError::TxConflict` and retry:

```rust
loop {
    // Phase 1 — ordinary read outside any transaction.
    let alice = users.get_or_err(&alice_id)?;
    let gid = alice.group_id;
    // Phase 2 — narrow transaction with now-known keys + revalidation.
    match db.atomic2(&users, &[alice_id], &groups, &[gid], |u, g| {
        if u.get_or_err(&alice_id)?.group_id != gid {
            return Err(DbError::TxConflict); // changed between phases → retry
        }
        let grp = g.get_or_err(&gid)?;
        g.put(&gid, &grp.with_member())?;
        Ok(())
    }) {
        Err(DbError::TxConflict) => continue,
        other => break other,
    }?;
}
```

## What is not supported

- `LockScope::All` (lock every shard so keys need not be known) — use known keys or
  the optimistic pattern.
- Dynamic lock acquisition inside the closure (would break the canonical order).
- Level B intent log / crash-atomicity, replicated atomicity, MVCC / snapshot
  isolation — separate, larger features.

See the spec for the full rationale:
`docs/superpowers/specs/26-06-13-armdb-cross-collection-transactions.md`.
