# Хуки (Write Hooks)
Система хуков позволяет подписаться на изменения данных в коллекциях.
Используется для поддержки вторичных индексов, аудит-логов и любых
побочных эффектов, которые должны синхронно наблюдать каждую мутацию.
---
## Трейты
### `WriteHook<K>` — для ConstTree / ConstMap / VarTree / VarMap
```rust,ignore
pub trait WriteHook<K: Key>: Send + Sync {
const NEEDS_OLD_VALUE: bool = true;
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]) {}
// TTL (T6). Supplied by `TtlHook` on managed collections — see below;
// hand-written hooks leave these at their defaults.
const EXPIRES: bool = false;
const TTL_REVISION: u32 = 0;
fn deadline(&self, _key: &K, _value: &[u8]) -> Option<u64> { None }
fn ttl_decode_failed(&self, _value: &[u8]) -> bool { false }
// Realtime watch (T14). Supplied by `WatchHook`; см. ниже.
const WATCHES: bool = false;
fn watch_channel(&self) -> Option<&Arc<WatchChannel<K>>> { None }
fn watch_old(&self) -> bool { false }
}
```
### `TypedWriteHook<K, T>` — для TypedTree / TypedMap
```rust,ignore
pub trait TypedWriteHook<K: Key, T>: Send + Sync {
const NEEDS_OLD_VALUE: bool = true;
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) {}
// TTL (T6) — as above.
const EXPIRES: bool = false;
const TTL_REVISION: u32 = 0;
fn deadline(&self, _key: &K, _value: &T) -> Option<u64> { None }
// Realtime watch (T14) — as above.
const WATCHES: bool = false;
fn watch_channel(&self) -> Option<&Arc<WatchChannel<K>>> { None }
fn watch_old(&self) -> bool { false }
}
```
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.
---
## Когда вызываются хуки
| `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()` | да — по каждой применённой мутации (`Upsert::Put` / `Delete`), после unlock шарда; `Keep` — **нет** | — |
| `upsert()` `Put` | да — `(key, old, Some(new))` | — |
| `upsert()` `Delete` | да — `(key, Some(old), None)`, если строка была; на отсутствующем ключе — **нет** | — |
| `upsert()` `Keep` | **нет** — записи не происходит | — |
| `atomic()` | **да** — buffered replay после освобождения shard-локов, в порядке применения | — |
| `Shard::put` / `insert` / `delete` / `update` / `compare_delete` / `fetch_update` / `upsert` (внутри `atomic()`) | да — через buffered replay `atomic()` | — |
| Recovery | **нет** | — |
| `migrate()` Keep | **нет** | да — `(key, value)` |
| `migrate()` Update | **нет** | да — `(key, new_value)` |
| `migrate()` Delete | **нет** | **нет** |
| `replay_init()` | **нет** | да — `(key, value)` |
> **Исключение — `LsmTree`.** Внутри `LsmTree::atomic` хуки вызываются
> **синхронно, под удерживаемым stripe-локом**, а не буферизуются с реплеем
> после разблокировки. Реентрантное обращение к той же коллекции из хука в этом
> случае приведёт к deadlock. Расхождение отслеживается в libfunc/armour#119.
### Блокировки и reentrancy
`on_write` и `on_init` вызываются **вне** shard lock (и index lock для Map).
Реализация хука может безопасно читать и писать в ту же коллекцию,
в том же шарде, без риска deadlock.
`on_write` наблюдает post-commit состояние: reentrant `get()` из хука видит
закоммиченное значение (или более позднюю конкурентную запись).
Для Typed* коллекций: хук получает пару `(old, new)` мутации напрямую как `&T`.
### Производное не может отказать в записи
Вне `db.tx()` строка источника коммитится **под шардовым локом**, а всё
производное — `on_write` хука, проекция индекса (T8), дельта счётчика (T9) —
выполняется **после** `locked.release()` (`Owned::with_shard` в
`armdb/src/ctx.rs`; внутри `atomic()` — в teardown после снятия локов). Значит
производная половина может **сообщить** об ошибке, но не может **предотвратить**
запись: к моменту, когда она возвращает `Err`, строка источника уже видна
читателям.
Отсюда правило: **инвариант, который обязан уметь отказать, должен получить
вердикт до коммита источника** — либо проверкой под шардовым локом, не берущей
чужих локов, либо в коммите `db.tx()`, где участники уже залочены в каноническом
порядке. Это один и тот же шов, на котором T9 завёл `counters_dirty`, а T10 и
T11 — отказ вместо починки.
Как движок отвечает на «постфактум не починить»:
| T8 вторичные индексы | пишем, потом чиним: ошибка доходит как `ProjectionIncomplete` с планом `PendingProjection`; восстановление — `PendingProjection::apply` / `schema::repair`, **не** повтор логической операции |
| T9 счётчики | улика + пересчёт: `counters_dirty` персистится при открытии counted-источника, нечистое завершение → пересчёт при следующем open |
| T10 unique namespace | **отказ до применения**: запись, меняющая набор claim'ов, отвергается `UniqueIndexRequiresTx` и не применяется; сама проверка уникальности — только в коммите `db.tx()` |
| T11 change stream | **отказ до применения**: любая нетранзакционная запись источника с объявленным потоком — `ChangeStreamRequiresTx`; поэтому lowering события в очередь вообще не бывает «после релиза лока» — он живёт только внутри коммита, до лог-поинта |
> Оба отказа стоят **под** локом источника, и ни один из них не читает свою
> цель. Гард T10 диффит два claim-множества, посчитанных проекцией для `old` и
> `new`, целиком в памяти (`unique::diff::first_changed`). Именно поэтому он не
> порождает второго порядка локов — «ключ занят или нет» решается уже в коммите
> транзакции, где namespace залочен канонически.
Что из этого следует автору **ручного** хука: `on_write` — не место для
валидации. Он ничего не возвращает, вызывается после коммита и видит уже
применённое состояние; отказ, который он мог бы захотеть, физически опоздал.
Инвариант, который должен блокировать запись, выражается либо декларацией
(`reg.unique_index`), либо проверкой внутри самой транзакции — `tx.guard(&coll,
&key, f)` отказывает всей транзакции до того, как что-либо записано.
Почему нельзя иначе — двухфазная резервация (crash-окно: claim есть, владельца
нет, имя заблокировано навсегда) и вложенный лок namespace под локом источника
(второй порядок локов против канонического порядка `db.tx()`) разобраны в спеке
[`26-08-10-unique-index-cas-reservation.md`](../../docs/superpowers/specs/26-08-10-unique-index-cas-reservation.md),
раздел «The fork this spec closes». Транзакционный контракт —
[`transactions.md`](./transactions.md), контракты производных половин —
[`docs/schema-metadata.md`](../../docs/schema-metadata.md).
### `atomic()` — buffered replay
Операции внутри `atomic()` (`put`, `insert`, `delete`, `update`, … на `*Shard`)
выполняются через `*_locked` методы под shard lock. Каждая мутация копится
в буфере событий; после освобождения lock'а `on_write` реплеится **в порядке
применения**.
Если closure возвращает `Err`, уже применённый префикс мутаций всё равно
реплеится в хуки — side-effect состояние должно отражать in-memory commit.
**Не обновляйте вторичный индекс вручную внутри `atomic()`** — хуки уже
срабатывают через replay; ручное обновление приведёт к **двойному применению**.
> **Изменение поведения (#44).** До #44 `atomic()` хуки **не** вызывал, и этот
> документ прямо советовал обновлять индексы вручную внутри замыкания. Теперь
> вызывает — код, написанный по старому совету, применяет обновление дважды.
> Что именно удалять при переходе — в
> [`docs/breaking-changes/26-08-20-armdb-0-11-0.md`](../../docs/breaking-changes/26-08-20-armdb-0-11-0.md).
### 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 не выполняется)
---
## `WATCHES` и realtime-подписки (T14)
`WatchHook<T, H, OLD>` оборачивает любой хук и объявляет `WATCHES = true`,
пробрасывая внутрь всё остальное — так watch компонуется с TTL, проекциями,
счётчиками и claim'ами. Полная документация: [`watch.md`](watch.md).
Для собственной обёртки над хуком (по образцу `TtlHook`) правило одно: **проброс
всех трёх items**, иначе watch на внутреннем хуке молча перестанет работать.
```rust,ignore
const WATCHES: bool = H::WATCHES;
fn watch_channel(&self) -> Option<&Arc<WatchChannel<K>>> { self.inner.watch_channel() }
fn watch_old(&self) -> bool { self.inner.watch_old() }
```
`NEEDS_OLD_VALUE` у `WatchHook` — объединение: `OLD || H::NEEDS_OLD_VALUE`.
Поэтому `new_only` не экономит ничего, если внутренний хук и так требует старое
значение (например `Projected`).
---
## `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.
---
## Какие коллекции поддерживают хуки
| 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.
---
## Движковые карриеры вместо ручных хуков (T8–T12)
Хук — это **низкий уровень**: он даёт уведомление, но всю поддержку производных
данных пишет приложение, и валидатор потом ловит расхождения. Для четырёх
типовых случаев движок делает работу сам. Каждый объявляется на
`SchemaRegistry` и **возвращает готовый хук**, который передаётся в `open_*`
источника:
| вторичный индекс `1→N` | `reg.secondary_index::<S, _>(IndexTargets…, project)` | put/overwrite/delete строк индекса на каждой мутации источника, включая удаление строк с изменившимся ключом |
| счётчик по строкам источника | `reg.counted::<S, _>(CounterTargets…, project)` | диффит вклады старой и новой строки, применяет знаковую дельту под локом цели, восстанавливает счётчик после краха (`counters_dirty`) |
| уникальное имя (username и т.п.) | `reg.unique_index::<S, _>(UniqueTargets…, project)` | **отказывает** в записи, нарушающей уникальность (`UniqueViolation`), внутри `db.tx()` |
| поток изменений в очередь | `reg.change_stream::<S, _>(QueueTargets…, project)` | lowering события в durable-очередь одной commit-записью с самой строкой |
Все четыре — половины **одного** карриера и композируются на одном источнике:
`Projected::and_counters(...)` / `.and_unique(...)`, либо
`reg.counted_on(hook, …)` / `reg.unique_index_on(hook, …)` /
`reg.change_stream_on(hook, …)` поверх уже собранного. Порядок внутри коммита
фиксирован: проекция индекса и счётчика → lowering потока → разрешение claim'ов.
Что это меняет по сравнению с ручным хуком:
- **Ошибки записи производных строк доходят до вызывающего** как `DbError`
(включая `ProjectionIncomplete` с планом `PendingProjection`), а не теряются
внутри `on_write`, который ничего не возвращает.
- **Внутри `db.tx()`** проекции идут через engine-owned канал: они буферизуются
под локом шарда и применяются одной commit-записью, а не через replay
`on_write`. Две половины из четырёх поэтому вообще не работают вне
транзакции — см. [«Производное не может отказать в записи»](#производное-не-может-отказать-в-записи).
- **Есть чем чинить дрейф**: `schema::validate` находит расхождения,
`schema::repair` их устраняет, `Db::recount_counters` пересчитывает счётчик
на промоушне реплики.
Полный контракт (объявления, бюджеты проекции, находки валидатора, границы v1) —
в [`docs/schema-metadata.md`](../../docs/schema-metadata.md); очередь как
коллекция — в [`queues.md`](./queues.md).
Ручной хук остаётся правильным выбором там, где производные данные живут **вне**
armdb (RAM-индекс, метрика, вебхук) или где проекция не выражается как набор
строк-ключей.
---
## Примеры
### 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);
}
}
```
---
## TTL (T6)
Per-entry TTL через `CollectionMeta::TTL` и внутренний time-index. Управляемые
открыватели (`Db::open_typed_tree`, `open_typed_map`, `open_zero_*`,
`open_var_typed_*`) всегда оборачивают пользовательский хук в `TtlHook<T, H>`.
### Объявление политики
```rust,ignore
impl CollectionMeta for Invite {
type SelfId = InviteId;
const NAME: &'static str = "invites";
const TTL: Option<TtlPolicy<Self>> = Some(TtlPolicy {
revision: 1,
deadline: |row, _key| (row.expires_at_ms > 0).then_some(row.expires_at_ms),
});
}
```
`revision` зарезервирован для будущего sidecar-индекса (Task 6); без sidecar
переоткрытие БД всегда выполняет cold-rebuild in-memory индекса. `deadline` —
чистая функция: `Option<u64>` deadline в миллисекундах (wall-clock, как у
sweeper'а).
### Контракт extractor'а
- **Чистая и ограниченная** — без I/O, без блокировок, без обращения к
коллекции (non-reentrant).
- **Non-panicking на write path** — паника в `deadline` при `put`/`update`
пробрасывается вызывающему как любая другая паника.
- **Паника в sweep** — `Db` worker оборачивает `sweep_expired` каждой
коллекции в `catch_unwind`; при панике коллекция пропускается на этот проход,
в лог пишется `error`, остальные коллекции продолжают sweep.
`TtlHook` пробрасывает `NEEDS_OLD_VALUE`, `NEEDS_INIT`, `NEEDS_WRITE` от
внутреннего хука без изменений.
### Cold build (Var*)
При `build_ttl_index` Var-семейства читают каждую live-строку с диска. Если
значение не удаётся прочитать (corrupt/missing), строка **не попадает** в
in-memory индекс и не станет кандидатом на sweep до следующей успешной записи
или явного `rebuild_ttl_index`. В лог пишется `warn` (per-row + итоговый счётчик
при `skipped > 0`). Stage-2 sweep при read/decode ошибке переносит deadline
(`failed` + `warn` once per pass).
### Физическое истечение (physical expiry)
Строка с прошедшим deadline **остаётся видимой** для `get`, итераторов и
`len()` до тех пор, пока background sweeper не выполнит обычный `delete`.
Логического «мягкого» истечения в v1 нет.
`Db::close` / `Db::clean_shutdown` синхронно отключают TTL sweep на всех
коллекциях (ждут in-flight pass) **до** финального flush / `save_len` /
Fixed clean-sidecar, чтобы sweeper не писал tombstone во время финализации.
Поток sweeper при этом не join’ится — это делает `Db::shutdown`.
TTL при этом **не включается обратно**: `close` допускает записи после себя, но
expiry сам не возобновится. Если `Db` продолжает работать после `close`,
включите sweep обратно по каждой коллекции через
`Db::set_ttl_sweep(name, true)`. Само отключение пишется в лог на уровне `info`.
### Не вызывайте `set_ttl_sweep` из хука своей коллекции
`set_ttl_sweep(name, false)` возвращается только когда ни один проход не идёт —
на этом построены demotion в follower и `Db::close`. Хуки же вызываются
**внутри** прохода (события буферизуются под shard lock и воспроизводятся после
его снятия, но проход ещё удерживается). Поэтому `on_write`, который в ответ на
expiry выключает sweep своей же коллекции, будет ждать сам себя.
Ограничение касается только этого вызова: читать и писать свою коллекцию из
хука по-прежнему безопасно (см. раздел о реентерабельности выше), как и
выключать sweep **другой** коллекции.
### Hand-off при expiry и `NEEDS_OLD_VALUE`
Sweeper удаляет просроченную строку через тот же путь, что и обычный
`delete`: `on_write(key, old, None)`. Для Var* семейств старое значение
передаётся **только если** `H::NEEDS_OLD_VALUE && H::NEEDS_WRITE` — как при
любом delete. При `NEEDS_OLD_VALUE = false` хук получает `old = None` на
expiry.
### Периметр raw-семейств
TTL maintenance встроен в raw `Const*` / `Var*` / `Typed*` под shard lock.
`Zero*` / `VarTyped*` форвардят мутации во внутреннее raw-семейство через
адаптеры (`ZeroHookAdapter`, `VarTypedHookAdapter`), которые пробрасывают
`EXPIRES`, `TTL_REVISION` и `deadline`.
`ConstTree` / `ConstMap` / `VarTree` / `VarMap` **вне** поддерживаемой
поверхности для приложений: публичный `WriteHook` теоретически позволяет
включить TTL на raw-коллекции вручную, но это не документированный контракт.
`LsmTree` не участвует.
### Репликация и promotion
На replication follower sweep отключён; in-memory индекс — произвольный stale
snapshot. После promotion вызовите `rebuild_ttl_index` (или
`Db::rebuild_ttl_index(name)`) **до** повторного включения sweep —
`set_ttl_sweep(true)` иначе вернёт `DbError::Client`.
### Schema: FK на TTL-коллекции
Строка может исчезнуть между валидацией FK и чтением (sweeper). Для FK,
указывающих на TTL-коллекцию, используйте `OnMissing::AllowMissing` в
`reg.fk` — иначе `validate()` будет сообщать ложные `DanglingFk` на уже
удалённые ключи.
### Zero-cost без TTL
`CollectionMeta::TTL = None` (default) даёт `TtlHook::EXPIRES = false`; все
ветки `if H::EXPIRES` в raw-семействах убираются при мономорфизации. Gates
(`just gates`) фиксируют, что alloc / `on_write` / `should_sync` / `sync`
baselines для коллекций без TTL не двигаются.
### A/B timing (deferred)
Сценарии TTL в `examples/ab_harness.rs` добавлены; полный pre/post прогон
`armdb/benches/ab.sh` **ещё не выполнен** — см. `armdb/docs/ttl-ab-timing.md`.
Перед merge на default branch нужно подтвердить, что non-TTL control medians
остаются в пределах +3% gate.