# VarTree / VarMap migrate hook semantics

## Context

`VarTree`/`VarMap::migrate` сейчас фитят `on_write` для `Keep`, а
`Update`/`Delete` идут через публичные `put`/`delete`, которые **снова** фитят
`on_write`. Контракт хуков, описанный в `armdb/docs/hooks.md`, ожидает
противоположное: миграция вообще не фитит `on_write` и зовёт `on_init` для
`Keep`/`Update` под `H::NEEDS_INIT`. Та же страница доков сама себе
противоречит: таблица в `hooks.md:67-80` обещает `on_init` для VarTree/VarMap
на миграции, а таблица в `hooks.md:221-234` помечает их как не поддерживающие
`on_init` вовсе. В `armour/db.rs` VarTree/VarMap не имеют `replay_init()`,
поэтому при открытии без миграции `on_init` не фитится никогда.

Целевое поведение — полный паритет с `TypedTree`/`TypedMap`
(typed_tree.rs:883-930, typed_map.rs:492-...): `migrate` фитит `on_init` под
`NEEDS_INIT` и пишет через hook-free путь; при отсутствии миграции работает
`replay_init()`. Это закрывает issue #7 из
`armdb/docs/reviews/codex-vartree-varmap.md` и приводит код в соответствие с
доками.

## Goals

- `VarTree::migrate`/`VarMap::migrate` фитят `on_init` (если
  `H::NEEDS_INIT`) для `Keep`/`Update` и не фитят `on_write` ни при каком
  `MigrateAction`.
- `VarTree`/`VarMap` имеют `replay_init()`, который зовётся `Db` после
  recovery, если миграция не запускалась. Поведение симметрично Typed*.
- Публичные `put`/`insert`/`delete`/`cas`/`update`/`fetch_update` продолжают
  фитить `on_write` ровно один раз — двойной фит, возникавший из-за вызова
  публичных методов внутри `migrate`, устраняется.
- `atomic()` по-прежнему не фитит ни `on_write`, ни `on_init`
  (`hooks.md:74,81-89`).
- `VarTypedTree`/`VarTypedMap` (обёртки через `VarTypedHookAdapter`)
  автоматически получают `on_init` — адаптер `hook.rs:120-158` уже имеет
  декодирующий `on_init`.
- Документация `armdb/docs/hooks.md` обновлена и согласована.

## Non-Goals

- Изменения trait `WriteHook` (поля `NEEDS_INIT`, `on_init` уже есть с
  default-реализацией).
- Перевод миграции/`replay_init` на параллельный обход шардов — оставляем
  последовательный путь, как у Typed*.
- Любые правки `Const*`/`Typed*`/`Zero*` миграции/`replay_init` — они уже
  корректны.
- Изменения формата хинт-файлов или recovery — `replay_init` для Var* читает
  значения по живым ключам через существующий read path.
- Публикация `replay_init` в публичном API — оставляем `pub(crate)`, как у
  Typed*.
- Const-generic refactor `*_locked_inner::<HOOKS>` для Var* — не требуется,
  см. секцию «Hook-free пути записи (already in place)».

## Design

### Семантика migrate (целевая)

| Действие        | `on_write` | `on_init` (под `H::NEEDS_INIT`)  | Запись                              |
| --------------- | ---------- | -------------------------------- | ----------------------------------- |
| `Keep`          | —          | `on_init(key, &value)`           | —                                   |
| `Update(new)`   | —          | `on_init(key, &new)` *(до swap)* | hook-free `put_locked`              |
| `Delete`        | —          | —                                | hook-free `delete_locked`           |

`Update` фитит `on_init` **до** атомарного swap'а — semantically `on_init`
означает «финальное состояние при старте», старое значение хук не видит.
Совпадает с порядком из `typed_tree.rs:908-916`.

### Hook-free пути записи (already in place)

Текущая структура `VarTree`/`VarMap` уже соответствует hook-free контракту
для `_locked` методов:

- `VarTree::put_locked` (var_tree.rs:301-358), `insert_locked`
  (var_tree.rs:360-393), `delete_locked` (var_tree.rs:395-419) **не зовут**
  хук изнутри.
- Хук фитят публичные обёртки (`put` — var_tree.rs:237-238,
  `insert` — :251-252, `delete` — :274-276) **после** возврата из `_locked`.
- `VarShard` в `atomic()` (var_tree.rs:1123-1140) зовёт `_locked` напрямую
  без хуков — уже соответствует контракту «atomic не фитит хуки»
  (`hooks.md:74,81-89`).
- `VarMap` устроен идентично: hook-free `_locked` (var_map.rs:194-249 для
  public + `_locked` ниже), хук в публичных обёртках; `VarMapShard::atomic`
  зовёт `_locked` напрямую.

Поэтому `migrate` и `replay_init` берут shard lock (+ guard для VarTree, +
index lock для VarMap) и зовут существующие `put_locked`/`delete_locked`
напрямую — никаких новых методов и никакого const-generic переключателя.

Текущий `VarTree::migrate` (var_tree.rs:693-734) и
`VarMap::migrate` (var_map.rs:407-442) рефакторятся:

- `MigrateAction::Update(new)` — берёт shard lock + guard (для VarTree) /
  shard lock + index lock (для VarMap), зовёт `put_locked(...)` напрямую.
- `MigrateAction::Delete` — берёт те же локи, зовёт `delete_locked(...)`.
- `MigrateAction::Keep` — без хуков; `on_init(key, &value)` под
  `H::NEEDS_INIT`.

Все три ветки **больше не зовут** `self.put`/`self.delete`.

### replay_init для VarTree

```rust
pub(crate) fn replay_init(&self) {
    if !H::NEEDS_INIT {
        return; // мономорфизация убирает всё тело
    }
    let guard = self.index.collector().enter();
    let mut current = crate::skiplist::strip_mark(unsafe {
        (*self.index.head_ptr())
            .tower(0)
            .load(std::sync::atomic::Ordering::Acquire)
    });
    while !current.is_null() {
        let node = unsafe { &*current };
        current = crate::skiplist::strip_mark(
            node.tower(0).load(std::sync::atomic::Ordering::Acquire),
        );
        if node.is_marked() {
            continue;
        }
        let value = match self.read_value_cached(node, &guard) {
            Some(v) => v,
            None => continue,
        };
        self.hook.on_init(&node.key, &value);
    }
}
```

`read_value_cached` (var_tree.rs:785-806) уже умеет retry на `StaleDiskLoc`
(на старте конкурентных compaction'ов нет, retry — формальность). Block
cache и write buffer амортизируют последовательные чтения.

### replay_init для VarMap (lock-aware)

`VarMap` хранит index отдельно: `self.indexes[shard_id]: Mutex<HashMap<K,
DiskLoc>>`. Public writes берут shard lock **до** index lock
(`var_map.rs:199-200`, `222-223`, `235-236`). `read_value_cached_inner` в
write-buf ветке берёт shard lock (`var_map.rs:546-554`). Если держать index
lock во время чтения значения — получим обратный порядок lock'ов и риск
deadlock'а.

Решение: использовать тот же snapshot-paттерн, что и существующий
`VarMap::get` (var_map.rs:159-186) и текущий `VarMap::migrate`
(var_map.rs:413-422):

```rust
pub(crate) fn replay_init(&self) {
    if !H::NEEDS_INIT { return; }
    for i in 0..self.engine.shards().len() {
        let keys: Vec<K> = {
            let index = sync::lock(&self.indexes[i]);
            index.keys().copied().collect()
        };
        for key in keys {
            // self.get(&key) уже делает snapshot DiskLoc под index lock,
            // отпускает lock и читает значение через read_value_cached_inner
            // с retry на StaleDiskLoc.
            let value = match self.get(&key) {
                Some(v) => v,
                None => continue,
            };
            self.hook.on_init(&key, &value);
        }
    }
}
```

Гарантии:

- snapshot ключей делается под `indexes[i]` lock; lock отпускается до
  чтения значения.
- `self.get(&key)` повторно берёт index lock коротко (snapshot `DiskLoc`),
  отпускает, потом зовёт `read_value_cached_inner` без удерживаемого index
  lock — порядок lock'ов совпадает с public read path.
- `DbError::StaleDiskLoc` уже обрабатывается внутри `get` с retry до
  `MAX_STALE_RETRIES`; replay_init не дублирует логику.
- `on_init` зовётся только для live ключа с актуальным значением.

Метрика `armdb.ops op="get"` будет увеличена для каждого replay'a — для
аналитики это допустимо (она и сейчас прыгает на старте под migrate). При
желании в плане можно ввести `read_value_for_init` helper, не инкрементящий
ops counter, но это не блокер дизайна.

### Db оркестрация

`armdb/src/armour/db.rs`:

```rust
// open_var_typed_tree (db.rs:312-341)
let migrated = self.run_migration(&meta, &stored, migrations, |mfn| tree.migrate(mfn))?;
if !migrated {
    tree.replay_init();
}
```

Сейчас результат `run_migration` для var-типов игнорируется
(`let _ = ...`). Меняем на `let migrated = ...` + ветку `replay_init()`.
Идентично для `open_var_typed_map` (db.rs:347-376).

`VarTypedTree::replay_init` и `VarTypedMap::replay_init` —
`pub(crate)` обёртки, делегирующие на `self.inner.replay_init()`.
`VarTypedHookAdapter` (hook.rs:120-158) уже имеет декодирующий `on_init`,
расширений не требуется.

### Прямое использование VarTree/VarMap без Db

Гайд из `hooks.md:123-125` сохраняется: пользователь после `open_hooked()`
зовёт `migrate(|_, _| MigrateAction::Keep)`, чтобы прогнать `on_init`.
`replay_init()` остаётся `pub(crate)` для consistency с Typed*.

## Documentation

Правки `armdb/docs/hooks.md`:

1. Таблица `hooks.md:67-80` — без изменений (после фикса станет правдой).
2. Таблица `hooks.md:221-234`: для VarTree/VarMap столбец «`on_init`»
   меняется с `нет*` на `да`; сноска *«VarTree/VarMap поддерживают
   on_write, но on_init не реализован…»* удаляется.
3. Раздел `hooks.md:93-125` («Жизненный цикл при открытии коллекции») —
   уточнение, что VarTree/VarMap следуют той же модели recovery → migrate
   ИЛИ replay_init.

## Tests

Все тесты ниже компилируются только под `feature = "var-collections"`
(и `typed-tree` для Db-orchestration тестов).

### Raw VarTree / VarMap (без Db)

Модульные тесты в `armdb/src/var_tree.rs` и `armdb/src/var_map.rs` —
прямой вызов `replay_init`, чтобы поймать ошибки lock-order и обхода до
того, как они скроются за adapter/orchestration layer:

1. `var_tree_replay_init_fires_on_init_per_live_key_raw` —
   `VarTree::open_hooked` с `NEEDS_INIT=true` счётным хуком, вставка N
   ключей, вручную `replay_init()`. Ожидание: `on_init` = N, `on_write`
   соответствует только put'ам, не replay'у.
2. `var_tree_replay_init_no_hook_is_noop` — `NoHook`,
   `NEEDS_INIT=false`: счётчик в хуке не растёт.
3. `var_tree_migrate_keep_fires_on_init_not_on_write_raw` — после
   `migrate(|_,_| Keep)` ожидание: `on_init` = N (под `NEEDS_INIT`),
   `on_write` = 0.
4. `var_tree_migrate_update_fires_on_init_with_new_value_raw` —
   `Update(new)`: `on_init` с новым значением, `on_write` = 0, диск
   обновлён.
5. `var_tree_migrate_delete_fires_no_hooks_raw` — `Delete`: ни `on_init`,
   ни `on_write`, ключ удалён.
6. `var_tree_atomic_does_not_fire_hooks` — sanity, что `_locked` методы
   из `atomic()` не дёргают хуки.
7. Зеркальные 6 тестов для `var_map`.

### Db orchestration

В `armdb/src/armour/db.rs` (или existing `armour/tests/`):

8. `db_open_var_typed_tree_calls_replay_init_when_no_migration` —
   `VarTypedTree` с `TypedWriteHook` (`NEEDS_INIT=true`), без изменения
   схемы. Ожидание: `on_init` зовётся для каждого live-ключа.
9. `db_open_var_typed_tree_skips_replay_init_when_migration_ran` —
   schema bump → migrate отработал → replay_init **не** зовётся
   (`on_init` уже сработал внутри migrate).
10. Зеркальные 2 теста для `VarTypedMap`.

### Обновление существующих тестов

Любые существующие тесты, ожидающие `on_write` от `VarTree::migrate Keep`
или двойной фит после `Update`/`Delete`, перепрописываются на новый
контракт (или удаляются, если они зеркалили только сломанное поведение).

## Verification

Все команды используют `--features var-collections`, иначе изменяемые
модули не компилируются (gating: `armdb/Cargo.toml:20` default без
`var-collections`; `lib.rs:306-309` `#[cfg(feature = "var-collections")]`).

- `cargo nextest run -p armdb --features var-collections -E 'test(var_tree) | test(var_map) | test(var_typed)'`
- `cargo nextest run -p armdb --features var-collections`
- `cargo nextest run -p armdb --features "var-collections encryption"`
- `cargo clippy -p armdb --features var-collections -- -D warnings`
- `cargo clippy -p armdb --features "var-collections encryption" -- -D warnings`
- `cargo fmt --check`

## Follow-Up Work

Остальные follow-ups из
`armdb/docs/superpowers/specs/26-05-15-var-collections-critical-correctness.md:141-148`
сохраняются:

- `DiskLoc.file_id` overflow (issue #8 из codex-ревью).
- direct-write/multi-block cache для значений больше write buffer
  (issue #4 из codex-ревью).
