armdb 0.7.0

sharded bitcask key-value storage optimized for NVMe
Documentation
# Хуки (Write Hooks)

Система хуков позволяет подписаться на изменения данных в коллекциях.
Используется для поддержки вторичных индексов, аудит-логов и любых
побочных эффектов, которые должны синхронно наблюдать каждую мутацию.

---

## Трейты

### `WriteHook<K>` — для ConstTree / ConstMap / VarTree / VarMap

```rust,ignore
pub trait WriteHook<K: Key>: Send + Sync {
    const NEEDS_OLD_VALUE: bool;
    const NEEDS_INIT: bool = false;
    const NEEDS_WRITE: bool = true;

    fn on_write(&self, key: &K, old: Option<&[u8]>, new: Option<&[u8]>);
    fn on_init(&self, _key: &K, _value: &[u8]) {}
}
```

### `TypedWriteHook<K, T>` — для TypedTree / TypedMap

```rust,ignore
pub trait TypedWriteHook<K: Key, T>: Send + Sync {
    const NEEDS_OLD_VALUE: bool;
    const NEEDS_INIT: bool = false;
    const NEEDS_WRITE: bool = true;

    fn on_write(&self, key: &K, old: Option<&T>, new: Option<&T>);
    fn on_init(&self, _key: &K, _value: &T) {}
}
```

ZeroTree / ZeroMap используют `TypedWriteHook` через `ZeroHookAdapter`,
который конвертирует сырые байты в `&T` через safe
`zerocopy::FromBytes::read_from_bytes`.

---

## Два режима

### 1. Init-only (`NEEDS_INIT = true`, `on_write` — no-op)

Хук строит состояние один раз при открытии коллекции.
Последующие мутации не наблюдаются — `on_write` пустой (или `NEEDS_WRITE = false`).

Пример: построение вторичного индекса из стабильной схемы,
где индекс полностью перестраивается при старте.

### 2. Init + Write (`NEEDS_INIT = true`, `on_write` — активный)

Хук строит состояние при открытии (`on_init`), затем
поддерживает его актуальным через `on_write` при каждой мутации.

Пример: инкрементальный вторичный индекс, который обновляется
на каждый put/delete.

### NoHook (по умолчанию)

`NEEDS_INIT = false`, `NEEDS_OLD_VALUE = false`, `NEEDS_WRITE = false`.
Все вызовы хуков устраняются компилятором — нулевой overhead.

---

## Когда вызываются хуки

| Операция | `on_write` | `on_init` |
|----------|-----------|-----------|
| `put()` | да — `(key, old, Some(new))` ||
| `insert()` | да — `(key, None, Some(new))` ||
| `delete()` | да — `(key, Some(old), None)` ||
| `cas()` | да — `(key, Some(expected), Some(new))` ||
| `update()` | да — `(key, Some(old), Some(new))` ||
| `compare_delete()` | да — `(key, Some(old), None)` при успехе ||
| `fetch_update()` | да — `(key, Some(old), Some(new))` при успехе ||
| `update_many()` | да — по каждой применённой мутации, после unlock шарда ||
| `atomic()` | **да** — buffered replay после освобождения shard-локов, в порядке применения ||
| `Shard::put` / `insert` / `delete` / `update` / `compare_delete` / `fetch_update` (внутри `atomic()`) | да — через buffered replay `atomic()` ||
| `Tx::put` / `insert` / `delete` / … (`Db::atomicN`) | да — через buffered replay мульти-транзакции ||
| Recovery | **нет** ||
| `migrate()` Keep | **нет** | да — `(key, value)` |
| `migrate()` Update | **нет** | да — `(key, new_value)` |
| `migrate()` Delete | **нет** | **нет** |
| `replay_init()` | **нет** | да — `(key, value)` |

### Блокировки и reentrancy

`on_write` и `on_init` вызываются **вне** shard lock (и index lock для Map).
Реализация хука может безопасно читать и писать в ту же коллекцию,
в том же шарде, без риска deadlock.

`on_write` наблюдает post-commit состояние: reentrant `get()` из хука видит
закоммиченное значение (или более позднюю конкурентную запись).

Для Typed* коллекций: хук получает пару `(old, new)` мутации напрямую как `&T`.

### `atomic()` — buffered replay

Операции внутри `atomic()` (`put`, `insert`, `delete`, `update`, … на `*Shard`)
выполняются через `*_locked` методы под shard lock. Каждая мутация копится
в буфере событий; после освобождения lock'а `on_write` реплеится **в порядке
применения**.

Если closure возвращает `Err`, уже применённый префикс мутаций всё равно
реплеится в хуки — side-effect состояние должно отражать in-memory commit.

**Не обновляйте вторичный индекс вручную внутри `atomic()`** — хуки уже
срабатывают через replay; ручное обновление приведёт к **двойному применению**.

### Sync-error семантика

Хук наблюдает **in-memory commit**: мутация уже видна читателям до `sync()`.
Caller может получить `Err` от `sync()` (или от `atomic()` после replay хуков)
для операции, которую хук уже увидел. Side-effect состояние (вторичный индекс,
счётчик) при этом уже обновлено — проектируйте хуки с учётом этого разрыва
между наблюдаемым commit и durable flush.

---

## Жизненный цикл при открытии коллекции

```text
1. open()        — recovery: disk → in-memory index
                   Хуки НЕ вызываются.

2a. migrate()    — если версия схемы изменилась:
                   итерация по всем live-записям,
                   on_init для Keep/Update (если fire_init),
                   put_no_hook/delete_no_hook для Update/Delete.
                   on_write НЕ вызывается.

2b. replay_init() — итерация по in-memory индексу,
                   on_init для каждой live-записи.
```

Всегда ровно **2 прохода**: recovery (диск) + migrate и/или replay_init (память).
`on_init` вызывается **ровно 1 раз** для каждого live-ключа с **финальным**
значением. При оркестрации через `Db` init **всегда** приходит из `replay_init`:
шаги `run_migration` вызывают `migrate_inner(..., fire_init = false)` и
не стреляют `on_init` — единственный init-проход после всех шагов миграции.

`VarTree`/`VarMap` следуют той же модели: `migrate` использует hook-free
`put_locked`/`delete_locked` и фитит `on_init` для `Keep`/`Update` под
`H::NEEDS_INIT` (при прямом вызове `migrate()`). `replay_init` для Var* читает
значения с диска через существующий cached-read path (snapshot ключей по шардам
+ `get`).

### Миграции: идемпотентность, strict Var migrate, sentinel

Миграционный callback должен быть **deterministic** (состояние сходится между
replication-нодами) **и idempotent**: crash между durable data flush и commit
версии в `db.info` повторно запускает шаг на уже мигрированных данных.

Для `VarTree`/`VarMap`: ошибка чтения значения при `migrate` — **fail-loud**
(`Err`), шаг не коммитится и перезапустится при следующем open. Одна
нечитаемая запись блокирует открытие до исправления I/O (или восстановления
с реплики/бэкапа) — consistency over availability by design.

Промежуточные шаги multi-step миграции пишут `typ_hash = 0` (`TYP_HASH_MIGRATING`
sentinel) в `db.info`; V-005 пропускает сравнение typ_hash для этого значения.
Финальный шаг записывает реальный `meta.ty.h()`.

### Оркестрация в `Db` (feature `armour`)

```rust,ignore
let tree = TypedTree::open_hooked(path, config, codec, hook)?;
self.run_migration(&meta, &stored, migrations, |mfn| tree.migrate_inner(mfn, false))?;
tree.replay_init(); // всегда — шаги миграции init-free
```

При прямом использовании коллекций (без `Db`) вызывайте
`migrate(|_, _| MigrateAction::Keep)` после `open_hooked()` чтобы
запустить `on_init`.

---

## Почему on_init вызывается после recovery, а не во время

Recovery читает data-файлы с диска и восстанавливает in-memory индекс.

### Проблема дубликатов при full-scan

Когда hint-файлы отсутствуют (первый запуск после сбоя, hint не был записан),
recovery выполняет **full-scan** — последовательное чтение всех data-файлов.
При этом **один и тот же ключ может встречаться в нескольких файлах**:

```text
file_001.data:  key_A → value_1   (старая версия)
file_002.data:  key_A → value_2   (новая версия)
```

Recovery корректно обрабатывает это: при повторном обнаружении ключа
старое значение в индексе заменяется новым. Но если бы `on_init`
вызывался во время recovery, хук бы увидел **оба значения** для одного
ключа — сначала `value_1`, потом `value_2`.

Для HashMap-подобных потребителей (overwrite-семантика) последний вызов
перезаписал бы первый и результат был бы корректен. Но для потребителей
с additive-семантикой (счётчики, агрегаты) это привело бы к ошибкам.

### Решение: post-recovery итерация

После завершения recovery in-memory индекс содержит **ровно одну запись
на ключ** с финальным значением. Итерация по этому индексу
(через `replay_init` или `migrate`) гарантирует:

- **Ровно 1 вызов `on_init` на ключ**
- Значение всегда **финальное** (после всех перезаписей)
- Удалённые ключи **не видны** (tombstone'ы уже отфильтрованы)

### Hint-based recovery

При наличии hint-файлов (нормальный путь после graceful shutdown)
дубликатов нет — hint содержит только живые записи. Но архитектурно
on_init всё равно вызывается после recovery для единообразия и
потому что recovery работает в параллельных потоках (по шарду на поток),
а хуки могут требовать однопоточного доступа.

---

## `NEEDS_OLD_VALUE`

Контролирует, читается ли старое значение перед записью.
**Актуально только для VarTree / VarMap.**

### In-memory коллекции (Const / Typed / Zero)

Старое значение всегда хранится в in-memory индексе — `old` в `on_write`
**всегда содержит предыдущее значение** при update/delete, независимо
от `NEEDS_OLD_VALUE`. Это бесплатно — нет disk I/O, нет дополнительных
аллокаций. `NEEDS_OLD_VALUE` игнорируется.

### VarTree / VarMap (feature `var-collections`)

Значения хранятся на диске (с block cache). Чтение старого значения
может потребовать disk I/O при cache miss.

- `NEEDS_OLD_VALUE = true``old` содержит предыдущее значение (возможен disk I/O)
- `NEEDS_OLD_VALUE = false``old` всегда `None` (disk I/O не выполняется)

---

## `NEEDS_INIT` и compile-time elimination

```rust,ignore
const NEEDS_INIT: bool = false; // default

// В replay_init:
pub(crate) fn replay_init(&self) {
    if !H::NEEDS_INIT { return; } // компилятор убирает весь код
    // ...
}

// В migrate:
MigrateAction::Keep => {
    if H::NEEDS_INIT {            // компилятор убирает ветку
        self.hook.on_init(&key, &value);
    }
}
```

Когда `NEEDS_INIT = false` (NoHook, или пользовательский хук без init),
весь код инициализации удаляется при мономорфизации — **нулевой overhead**.

---

## `NEEDS_WRITE`

Когда `false`, коллекция и адаптеры (`ZeroHookAdapter`, `VarTypedHookAdapter`)
могут полностью пропускать `on_write` (включая decode/zerocopy). `NoHook`
устанавливает `NEEDS_WRITE = false` — нулевой overhead.

---

## Какие коллекции поддерживают хуки

| Коллекция | `WriteHook` | `TypedWriteHook` | `on_init` |
|-----------|------------|-------------------|-----------|
| ConstTree | да || да |
| ConstMap | да || да |
| TypedTree || да | да |
| TypedMap || да | да |
| ZeroTree || да (через ZeroHookAdapter) | да |
| ZeroMap || да (через ZeroHookAdapter) | да |
| VarTree | да || да |
| VarMap | да || да |
| VarTypedTree || да (через VarTypedHookAdapter) | да |
| VarTypedMap || да (через VarTypedHookAdapter) | да |

### Decode errors в VarTyped* коллекциях

При миграции (`migrate`): если `Codec::decode_from` возвращает `Err`, entry
сохраняется как `MigrateAction::Keep` — raw bytes остаются на диске,
пользовательский callback не вызывается для этого entry.

При `on_write` / `on_init`: decode failure в `VarTypedHookAdapter` приводит к
`None` для соответствующей стороны (`old` или `new`). `on_init` пропускает
entry целиком при decode failure.

Это permissive поведение — коллекция предпочитает сохранять данные при ошибке
декодирования, а не терять entries.

---

## Примеры

### Init-only хук (вторичный индекс при старте)

```rust,ignore
struct SecondaryIndex {
    by_category: Mutex<HashMap<u32, Vec<[u8; 8]>>>,
}

impl WriteHook<[u8; 8]> for SecondaryIndex {
    const NEEDS_OLD_VALUE: bool = false;
    const NEEDS_INIT: bool = true;
    const NEEDS_WRITE: bool = false;

    fn on_write(&self, _key: &[u8; 8], _old: Option<&[u8]>, _new: Option<&[u8]>) {
        // no-op: индекс перестраивается только при старте
    }

    fn on_init(&self, key: &[u8; 8], value: &[u8]) {
        let category = u32::from_be_bytes(value[0..4].try_into().unwrap());
        self.by_category.lock().entry(category).or_default().push(*key);
    }
}
```

### Init + write хук (инкрементальный индекс)

```rust,ignore
impl WriteHook<[u8; 8]> for SecondaryIndex {
    const NEEDS_OLD_VALUE: bool = true;
    const NEEDS_INIT: bool = true;
    const NEEDS_WRITE: bool = true;

    fn on_write(&self, key: &[u8; 8], old: Option<&[u8]>, new: Option<&[u8]>) {
        let mut idx = self.by_category.lock();
        // Удалить из старой категории
        if let Some(old_val) = old {
            let old_cat = u32::from_be_bytes(old_val[0..4].try_into().unwrap());
            if let Some(keys) = idx.get_mut(&old_cat) {
                keys.retain(|k| k != key);
            }
        }
        // Добавить в новую категорию
        if let Some(new_val) = new {
            let new_cat = u32::from_be_bytes(new_val[0..4].try_into().unwrap());
            idx.entry(new_cat).or_default().push(*key);
        }
    }

    fn on_init(&self, key: &[u8; 8], value: &[u8]) {
        let category = u32::from_be_bytes(value[0..4].try_into().unwrap());
        self.by_category.lock().entry(category).or_default().push(*key);
    }
}
```