# Ревью спецификации FixedStore Replication (2026-04-17-fixed-replication-design.md)

### Общее впечатление
Отличный и прагматичный дизайн. Отказ от лога и глобального GSN в пользу 30-битной версии на каждый слот — это самое элегантное решение для `FixedStore`, где все обновления происходят in-place. Использование модульной арифметики (`wrapping_add` и `is_newer`) отлично справляется с переполнениями, а 4-байтный атомарный pwrite для удалений позволяет избежать дорогостоящих перезаписей всего слота.

Однако, в спецификации есть **два критичных бага в логике применения (apply) на фолловере**, которые приведут к поломке индекса при определенных паттернах изменения данных.

---

### 🚨 Критичные баги (Блокирующие)

#### 1. Баг в логике Apply для `DELETED`
В спецификации (раздел 8) сказано:
```rust
index.remove(&key);   // no-op если отсутствует
return Applied;
```
**Проблема**: В `FixedStore` ключи могут перемещаться между слотами (например, ключ удалили из `slot_A`, а затем вставили заново, и аллокатор выдал ему `slot_B`). В этом случае индекс фолловера указывает на `slot_B`.
Если во время streaming'а или catch-up скана (особенно при скане, когда порядок обработки слотов не хронологический) фолловер получит событие `DELETED` для `slot_A` с этим ключом, он слепо вызовет `index.remove(&key)` и **ошибочно удалит валидный маппинг для `slot_B`** из индекса.

**Решение**: Удалять ключ из индекса нужно *только* если индекс все еще указывает на удаляемый слот.
```rust
if index.get(&key) == Some(slot_id) {
    index.remove(&key);
}
```

#### 2. Баг в логике Apply для `OCCUPIED` (Stale Key Cleanup)
В спецификации (раздел 8) логика очистки старого ключа описана так:
```
None and old was OCCUPIED        → missed DELETE, pread for stale key cleanup
```
**Проблема**: Решение о том, нужно ли вычищать старый ключ (`old_key`), привязано к ветке `None` (когда *нового* ключа нет в индексе). Но эти вещи ортогональны. 
Если слот ранее был `OCCUPIED`, он мог содержать совершенно другой ключ (`old_key`). Если пришел `OCCUPIED` с `new_key`, мы обязаны удалить `old_key` из индекса (если он все еще указывает на этот слот), **независимо от того, где сейчас в индексе находится `new_key`**.

**Решение**: Логику обновления индекса нужно переписать, явно разделив очистку старого состояния слота и применение нового:
```rust
let old_meta = versions[slot_id];
if status_of(old_meta) == OCCUPIED {
    // Оптимизация: если в индексе new_key УЖЕ указывает на slot_id, 
    // значит old_key == new_key, и ничего чистить не нужно.
    if index.get(&new_key) != Some(slot_id) {
        let old_key = read_key_from_disk(slot_id);
        if old_key != new_key {
            if index.get(&old_key) == Some(slot_id) {
                index.remove(&old_key);
            }
        }
    }
}
// Вставляем новый ключ (перезапишет старое значение, если ключ переехал из другого слота)
index.insert(new_key, slot_id);
```

---

### 🏗️ Архитектурные замечания и предложения

#### 3. Целостность `fixed.versions` (Sidecar Integrity)
Замена `fixed.bitmap` на массив `fixed.versions` — хорошая идея для быстрого рестарта. При 100M слотов файл будет весить ~400 MB. 
Однако, если при `clean_shutdown` место на диске закончится или ОС упадет во время `fdatasync`, файл `fixed.versions` может оказаться обрезанным или поврежденным (битые нули в хвосте).
**Предложение**: Добавьте в конец файла `fixed.versions` простой 8-байтный trailer: `[CRC32:u32][slot_count:u32]`. При старте лидера проверяйте его. Если trailer отсутствует или CRC не сходится — значит sidecar поврежден, файл удаляется, и лидер откатывается на безопасный, но долгий (dirty) full scan слотов с диска.

#### 4. Поведение при CRC mismatch во время dirty recovery
Что происходит на лидере при dirty startup, если во время полного скана у слота не сходится CRC (например, torn write при сбое питания)?
Слот пропускается и в индекс не попадает. Но что записывается в его `versions[slot_id]` и `bitmap`? 
Если слот пометить как `FREE`, то его версия теряется. Если аллокатор потом выдаст этот слот для нового ключа, вызовется `bump_version()`. Если брать версию из битого `meta`, она может оказаться любой (например, из будущего), и фолловеры отвергнут новые апдейты по `is_newer()`.
**Предложение**: Явно пропишите в спеке: если при скане CRC не сходится, слот помечается как `FREE` (битмап = 0), а в `versions[slot_id]` записывается безопасное значение (например, 0, либо `version` из `meta`, но с обнуленным статусом, если `version` кажется консистентным с соседями).

#### 5. Искусственное замедление старта фолловера (`follower_mode = true`)
Спецификация гласит: *"Follower mode всегда full slot scan, игнорирует sidecar... Это ведет к более простому code path'у"*.
Для 1-10 млн слотов это 25-250 мс, что незаметно. Но для 100 млн слотов старт фолловера будет занимать ~3 секунды. Если нода часто рестартует (Kubernetes/Spot instances), это может стать раздражающим фактором. 
Фолловер — это та же база данных. Искусственно лишать его оптимизации быстрого старта только ради экономии пары строк if-else при shutdown'е кажется неоправданным. **Предложение**: позволить фолловеру также писать и читать `fixed.versions`, если это не усложняет код фатально.

#### 6. Catch-up трафик для DELETED слотов (на будущее)
При `SyncRequest` лидер делает full scan и отправляет батчи. При этом отправляются все `DELETED` слоты (с ключами). Для восстанавливающегося фолловера это критически важно. 
Но если фолловер подключается **с чистого листа** (empty follower, `current_slot_count = 0`), ему вообще не нужны `DELETED` слоты — он и так пустой.
В базах с большой текучестью данных (например, 50 млн живых и 50 млн удаленных слотов) пересылка `DELETED` слотов удвоит сетевой трафик. 
**Предложение**: В v1 это можно оставить как "Known limitation", но в будущем можно добавить в `SyncRequest` флаг `is_empty: bool` (или определять это по `from_gsn == 0`), чтобы лидер скипал отправку `DELETED` событий и экономил I/O и сеть.

---

### Мелкие комментарии по тексту
* В сниппетах `write_slot` и `delete_slot` возвращаемый тип указан как `DbResult<u32>`. Очевидно, возвращается `new_meta`. Это полезно, стоит оставить.
* Atomicity 4-byte delete: ваше обоснование абсолютно верно. Aligned 4-byte pwrite атомарен на уровне контроллера NVMe (гарантируется AWUN) и в page cache ОС. Torn writes в пределах 4-х байт не бывает.
* Обработка SPSC Overflow: Silent drop + full scan на reconnect — отличное архитектурное решение. Упрощает код лидера и делает систему self-healing.
