# Хуки (Write Hooks)
Система хуков позволяет подписаться на изменения данных в коллекциях.
Используется для поддержки вторичных индексов, аудит-логов и любых
побочных эффектов, которые должны синхронно наблюдать каждую мутацию.
---
## Трейты
### `WriteHook<K>` — для ConstTree / ConstMap
```rust
pub trait WriteHook<K: Key>: Send + Sync {
const NEEDS_OLD_VALUE: bool;
const NEEDS_INIT: bool = false;
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
pub trait TypedWriteHook<K: Key, T>: Send + Sync {
const NEEDS_OLD_VALUE: bool;
const NEEDS_INIT: bool = false;
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` через unsafe ptr read.
---
## Два режима
### 1. Init-only (`NEEDS_INIT = true`, `on_write` — no-op)
Хук строит состояние один раз при открытии коллекции.
Последующие мутации не наблюдаются — `on_write` пустой.
Пример: построение вторичного индекса из стабильной схемы,
где индекс полностью перестраивается при старте.
### 2. Init + Write (`NEEDS_INIT = true`, `on_write` — активный)
Хук строит состояние при открытии (`on_init`), затем
поддерживает его актуальным через `on_write` при каждой мутации.
Пример: инкрементальный вторичный индекс, который обновляется
на каждый put/delete.
### NoHook (по умолчанию)
`NEEDS_INIT = false`, `NEEDS_OLD_VALUE = false`. Все вызовы хуков
устраняются компилятором — нулевой overhead.
---
## Когда вызываются хуки
| `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))` | — |
| `atomic()` | **нет** | — |
| Recovery | **нет** | — |
| `migrate()` Keep | **нет** | да — `(key, value)` |
| `migrate()` Update | **нет** | да — `(key, new_value)` |
| `migrate()` Delete | **нет** | **нет** |
| `replay_init()` | **нет** | да — `(key, value)` |
### `atomic()` не вызывает хуки
Операции внутри `atomic()` (`put`, `insert`, `delete` на `*Shard`) выполняются
через `*_locked` методы напрямую, минуя обёртки с хуками. Это сделано намеренно:
атомарный блок захватывает shard lock на всё время выполнения, и вызов хуков
внутри него создал бы риск deadlock и непредсказуемой задержки.
Если вторичный индекс должен отражать атомарные операции — обновляйте
его вручную внутри `atomic()` замыкания.
---
## Жизненный цикл при открытии коллекции
```
1. open() — recovery: disk → in-memory index
Хуки НЕ вызываются.
2a. migrate() — если версия схемы изменилась:
итерация по всем live-записям,
on_init для Keep/Update,
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` (feature `armour`)
```rust
let tree = TypedTree::open_hooked(path, config, codec, hook)?;
let migrated = self.run_migration(&meta, &stored, migrations, |mfn| tree.migrate(mfn))?;
if !migrated {
tree.replay_init();
}
```
При прямом использовании коллекций (без `Db`) вызывайте
`migrate(|_, _| MigrateAction::Keep)` после `open_hooked()` чтобы
запустить `on_init`.
---
## Почему on_init вызывается после recovery, а не во время
Recovery читает data-файлы с диска и восстанавливает in-memory индекс.
### Проблема дубликатов при full-scan
Когда hint-файлы отсутствуют (первый запуск после сбоя, hint не был записан),
recovery выполняет **full-scan** — последовательное чтение всех data-файлов.
При этом **один и тот же ключ может встречаться в нескольких файлах**:
```
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
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**.
---
## Какие коллекции поддерживают хуки
| ConstTree | да | — | да |
| ConstMap | да | — | да |
| TypedTree | — | да | да |
| TypedMap | — | да | да |
| ZeroTree | — | да (через ZeroHookAdapter) | да |
| ZeroMap | — | да (через ZeroHookAdapter) | да |
| VarTree | да | — | нет* |
| VarMap | да | — | нет* |
*VarTree/VarMap поддерживают `on_write`, но `on_init` не реализован —
значения хранятся на диске, а не в in-memory индексе, поэтому
hint-based recovery не содержит байты значений.
---
## Примеры
### Init-only хук (вторичный индекс при старте)
```rust
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;
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
impl WriteHook<[u8; 8]> for SecondaryIndex {
const NEEDS_OLD_VALUE: bool = true;
const NEEDS_INIT: 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);
}
}
```