armdb 0.5.3

sharded bitcask key-value storage optimized for NVMe
Documentation
# armdb QA

## Можно ли хранить несколько коллекций в одном файле?

Да, теоретически можно хранить несколько коллекций в одном наборе shard-файлов,
но это уже не текущая модель `database per collection`, а общий storage engine с
логическими коллекциями.

Сейчас модель явно обратная:

- `armdb/src/lib.rs` фиксирует `one tree = one database directory`.
- `Db::tree_path()` кладет коллекцию в отдельный путь `name:vN`.
- Каждый `Engine::open()` открывает `config.shard_count` отдельных shard-директорий.
- Для Bitcask свежий shard держит минимум два file descriptor: active write file
  и active read file. После rotations immutable files тоже остаются открытыми для
  чтения.
- `__seq` дополнительно использует FixedMap с 4 shard'ами.

Грубая оценка для Bitcask:

```text
fd ~= collections * shards * 2
   + immutable rotated files
   + __seq fixed shards
   + rpc/socket/прочие fd процесса
```

Чтобы хранить несколько коллекций в одном физическом логе, нужно добавить
`collection_id` в record/log format и recovery, оставить отдельные in-memory
indexes per collection, а физический append-log сделать общим per shard. Тогда
range, prefix, CAS, delete и typed API функционально сохраняются.

Но "без потерь" это не бесплатная замена:

- общий shard writer даст больше contention между горячими коллекциями;
- compaction станет сложнее, потому что live/dead данные разных коллекций будут
  смешаны в одних files;
- migrations, drop collection, backup, restore и replication должны стать
  collection-aware;
- изоляция отказов и ручная диагностика станут слабее, потому что одна
  поврежденная физическая область потенциально затрагивает несколько коллекций.

Для множества маленьких коллекций общий набор shard-файлов может быть даже лучше:
меньше fd, меньше директорий, лучше locality, меньше startup overhead. Для
нескольких горячих коллекций текущая физическая изоляция может быть быстрее и
предсказуемее.

Практичные промежуточные варианты:

- уменьшать `shard_count` для маленьких коллекций;
- сделать fd budget и lazy-open для immutable files;
- закрывать cold collections;
- шарить `Engine` только внутри группы коллекций одного backend'а;
- отдельно рассматривать Bitcask и FixedStore, потому что Fixed сейчас имеет
  другую модель: один `fixed.data` per shard и in-place writes.

## Вредит ли Linux-серверу много открытых file descriptor?

Сами по себе сотни или тысячи открытых fd на Linux нормальны. Производительность
сервера обычно не падает только из-за того, что у процесса открыто 300, 1000 или
5000 обычных файлов.

I/O latency определяется диском, page/direct I/O, locks, fsync, cache misses,
очередями и contention, а не самим числом открытых fd. Открытый fd в основном
занимает kernel memory и учитывается в лимитах процесса.

Реальные проблемы начинаются, когда:

- процесс упирается в `RLIMIT_NOFILE` и получает `EMFILE` / `Too many open files`;
- системный лимит fd слишком низкий для DB/server процесса;
- код регулярно сканирует все fd или держит слишком много неиспользуемых файлов
  открытыми;
- из-за открытых immutable files растет kernel memory и сложность управления
  lifecycle'ом файлов.

Для среднего или большого server/DB проекта `ulimit -n 512` скорее низкий лимит.
Нормально выставлять `4096`, `8192`, `65536` через systemd `LimitNOFILE=`,
container limits или service manager.

Но если обычный проект с 20 коллекциями уже требует поднятия лимита, это сигнал,
что дефолтная модель fd стоит пересмотреть. В armdb основной множитель сейчас:

```text
collection_count * shard_count * files_per_shard
```

Поэтому ближайшая практичная оптимизация без большого рефакторинга - дать мелким
коллекциям меньший `shard_count` или сделать lazy-open/reopen для immutable files.

## Можно ли сделать кросс-коллекционные транзакции умеренно дешёвыми?

Важный факт: в `Db` каждая коллекция открывается в **своей директории со своими
шардами и mutex'ами** (`db.rs: open_fn(self.tree_path(meta.name), hook)`). Общий
у них только процессный GSN. Поэтому «транзакция» распадается на два независимых
уровня.

**Уровень A - изоляция записи (дёшево, рекомендуется):** multi-lock API с
каноническим порядком захвата:

```rust
// Db::atomic_multi: сортировка по (collection_id, shard_id) -> deadlock-free
db.atomic2(&users, &user_key, &balances, &balance_key, |u_shard, b_shard| { ... })
```

Стоимость - ровно N shard mutex'ов вместо одного. Канонический порядок захвата
(по паре `(tree_id, shard_id)` или адресу mutex) полностью снимает deadlock.

Чего это НЕ даёт (надо честно зафиксировать в доках):

- *атомарной видимости* - читатели lock-free и не берут локов: конкурентный
  reader может увидеть `balances` уже обновлённым, а `users` ещё нет. Это верно
  уже сейчас даже для двух ключей внутри одного `atomic()`;
- *crash-атомарности* - у коллекций отдельные write buffer'ы без WAL: при
  падении буфер одной коллекции мог уйти на диск, другой - нет.

**Уровень B - crash-атомарность (умеренно, платят только использующие):**
intent log на уровне `Db` - мини-WAL только для мульти-коллекционных транзакций:
записать intent-запись `[(collection, key, value), ...] + commit marker` ->
fsync -> применить к коллекциям -> пометить applied. Recovery в `Db::open`
доигрывает закоммиченные intents (apply идемпотентен - put по ключу). Обычный
путь записи не затрагивается, цена - один fsync на транзакцию.

**Чего делать не стоит:** полноценный MVCC/snapshot isolation - смена класса
системы (версионирование в узлах, вакуум, validation), противоречит модели
«lock-free reads + SeqLock inline». Если понадобится атомарная видимость,
реалистичный путь - co-location транзакционных коллекций в одном engine с общим
shard-файлом и батч-фреймингом (транзакция пишется одним непрерывным блоком в
один лог: recovery либо видит весь батч с валидным CRC, либо отбрасывает хвост).
Это отдельная спека.

**Уровень A реализован** как `Db::atomic2`/`atomic3`/`atomic4` (#46): write-изоляция
над 2-4 коллекциями, типизированный `Tx` на коллекцию, deadlock-free порядок
захвата, replay write-хуков после снятия локов. Контракт и не-гарантии:
`armdb/docs/cross-collection-transactions.md`. Уровень B (intent log /
crash-атомарность) пока не реализован.

## Будет ли выигрыш от замены SkipList на Bw-tree, B+tree или ART?

- **Bw-tree - нет.** Эмпирика (Wang et al., «Building a Bw-Tree Takes More Than
  Just Buzz Words», SIGMOD'18): корректно реализованный Bw-tree сложнее всех
  альтернатив и при этом *проигрывает* оптимизированным in-memory B+tree и ART.
  Delta-chains оправданы для flash-резидентных индексов, а здесь индекс целиком
  в RAM.
- **B+tree** даст лучшую кэш-локальность на скан и экономию памяти (указатели
  амортизируются по 16-64 ключей на узел против ~60B overhead на узел SkipList),
  но lock-free B+tree с seize - это retire целых страниц при split/merge
  (design.md сам называет это сложным). Concurrent B+tree обычно означает OLC
  (optimistic lock coupling) - другая модель синхронизации, обесценивающая
  текущую loom/miri-валидацию.
- **ART (+ OLC) - единственный осмысленный кандидат** при коротких фиксированных
  ключах 8-16B: O(длина ключа) cache miss'ов вместо O(log n). При 100M+ записей
  разница ощутимая (SkipList на 100M - ~25+ зависимых cache miss'ов на lookup).
  Prefix scan у ART натуральный, path compression экономит память на
  кластеризованных ключах (Fuid/Id64 с timestamp-префиксом кластеризуются).
  design.md уже это предусматривает («можно заменить на ART позже - API тот же»).

**Прагматично:** point-lookup-heavy нагрузки уже закрыты HashMap-коллекциями
(O(1)); Tree-типы нужны для ordered/prefix scan, где write path упирается в
диск, а read редко упирается в индекс. Не трогать SkipList, пока профилировка
на реальной нагрузке не покажет индекс как bottleneck. Более дешёвые ближние
выигрыши: CAS-based tower linking (убирает глобальный `write_lock` - уже в
плане), и только потом ART как отдельный проект с переносом loom/miri-обвязки.

## Рекомендации для VarTree/VarMap при очень больших объёмах. Сжимать ли, и как - блоками или файлами?

**Сначала - не про сжатие.** Главный лимит масштабирования Var-коллекций - не
диск, а RAM индекса: ~60B узел + 12B DiskLoc + ключ ≈ 75-90B на запись -> 1 млрд
записей ≈ 80-90GB RAM только на индекс. Сжатие диска это не лечит. Рекомендации
в порядке важности:

1. **`hints: true` обязательно** - recovery без hints читает все значения с
   диска, на терабайтах это часы.
2. **Block cache включить явно** — baseline `max_size: 0` (кроме `Config::large_values()`).
   Var `open()` логирует `warn!`, если cache выключен — каждый `get` тогда идёт на диск.
   Для больших данных: `Config::large_values().build()` или задайте `cache` в builder.
3. **`max_file_size` увеличить** (256MB default -> 1-4GB), иначе тысячи файлов
   на шард = fd-давление + длинный recovery-листинг.
4. **O_DIRECT** (`Config.direct_io`, реализован — PR #34; см. `Config::large_values()`)
   - при большом block cache двойное кэширование с page cache ОС заметно жжёт
   память. Только Bitcask на Linux, с graceful fallback на буферизованный I/O.

**Про сжатие: блоками, не файлами - и есть третий вариант, самый дешёвый.**

- **Per-file - нет** (зафиксировано в design.md): убивает random `pread`.
  Seekable-zstd-фреймы формально «файловое» сжатие, но фактически те же блоки.
- **Per-entry для крупных значений - сделать первым.** Если значения сами по
  себе большие (≥4-16KB: документы, JSON, blob'ы), сжимать отдельные значения:
  ratio на таком размере уже хороший, а реализация почти бесплатна - compress в
  `append_entry`, decompress при чтении, `DiskLoc` не меняется, нужен только
  флаг (старший бит `value_len` или резервный бит 61/60 в GSN). Никаких
  изменений в compaction, block cache, репликации.
- **Block compression (16-64KB, zstd) при compaction - для миллионов мелких
  значений.** «Правильный» вариант из design.md: active file несжатый, при
  compaction записи группируются в блоки, каждый сжимается отдельно; адресация
  меняется на `(file_id, block_id, offset_in_block)` + per-file block index
  (~8B на блок, грузится при open); block cache хранит *распакованные* блоки
  (weight = uncompressed). Цена чтения - decompress 32KB zstd ≈ 1-5µs на cache
  miss, на NVMe приемлемо. Но это инвазивно: меняется DiskLoc, hint-формат,
  catch-up репликации. Делать только когда подтверждён выигрыш ratio на
  реальных данных (rapira-сериализованные структуры часто уже компактны - ratio
  может оказаться 1.3-1.5x, не окупающим сложность).

**Бесплатная проверка перед всем этим:** сжатие на стороне клиента (rapira ->
zstd -> `VarTree::put`) - ноль изменений в движке, и сразу видно реальный ratio
данных. Если он <1.5x - тема сжатия закрывается без единой строчки в armdb.

## Чем уменьшить место под множество мелких значений (RAM и диск)?

Отдельная аналитическая записка с замерами из бенчмарка и ранжировкой рычагов:
[space-optimization.md](./space-optimization.md). Кратко: главный перерасход —
**RAM-индекс**, не диск (VarTree ~6.6 ГБ против ~1 ГБ у LSM на тех же данных).
Дешёвая дорожка — брать VarTree (не Const/Typed), инлайнить `DiskLoc` через
SeqLock и ужать его до 12 Б (≈ −25…30% RAM, низкий риск). Диск при rapira-
значениях — слабый рычаг (сжатие ~1.2–1.5×). На миллиардах записей честный
вывод — ART, ленивый keydir или fjall для коллекции сообщений.

## Можно ли переиспользовать Bitcask/FixedStore для новой single-threaded коллекции (без примитивов синхронизации)?

Да, storage-слой переиспользуется почти целиком - он **уже однопоточный по
построению**. Вся синхронизация в armdb живёт ровно в двух местах *выше*
дискового слоя, и оба заменяемы:

1. Mutex вокруг shard'а: `Durability::lock_shard() -> MutexGuard<Inner>`
   (`durability.rs`). Сами `DurabilityInner`-реализации - `ShardInner` (Bitcask:
   `append_entry`, WriteBuffer, rotation, hints) и `FixedShardInner` (slots,
   bitmap) - принимают `&mut self` и не содержат внутренней синхронизации.
2. Concurrent index: SkipList + seize + SeqLock/RCU узлы.

GSN - `Arc<AtomicU64>` per engine с `fetch_add(Relaxed)` (`shard.rs:886`);
формат entry от него зависит, так что его стоит оставить как есть
(uncontended atomic ≈ 5ns).

### Что переиспользуется как есть (~80% дискового слоя)

- `entry.rs` (формат записи, CRC, GSN-флаги), `disk_loc.rs`;
- `ShardInner` / `FixedShardInner` целиком - и значит ротация файлов, write
  buffer, hint-генерация, slot allocation, bitmap, grow;
- `io/` (AlignedBuf, direct, uring), `crypto.rs`, `meta.rs` (db.meta
  валидация), `cache.rs` (BlockCache - сам по себе concurrent, но работает и
  из одного потока);
- внутренности recovery: скан data-файлов, CRC-проверка, hint parsing,
  page-padding sentinel, truncate хвоста;
- file-level helpers compaction (tmp -> fsync -> rename -> dir_fsync);
- **leader-side репликация Bitcask**: SPSC push живёт в `append_entry`, а
  catch-up (`ShardLogReader`) читает файлы с диска независимо от коллекции -
  single-threaded коллекция может быть лидером без изменений.

### Что придётся написать своё (немного, и всё - простое)

- **Индекс**: вместо SkipList - std `BTreeMap<K, ([u8; V], Loc)>` или
  `HashMap`. Это главный выигрыш всей затеи: исчезает почти весь unsafe
  (skiplist/node.rs + seqlock + seize - крупнейшая unsafe-поверхность крейта),
  бесплатно появляются Entry API, нормальный `DoubleEndedIterator`, range
  delete.
- **Тонкий слой коллекции**: put/get/delete/iter поверх `BTreeMap` +
  `DurabilityInner`. Без concurrency-хореографии это ~500-800 строк против
  1.5-2.7k у конкурентных аналогов.
- **Recovery-glue**: текущие `recover_const_tree` и др. (`recovery.rs`)
  аллоцируют concurrent-узлы - нужна своя функция, но из переиспользуемых
  scan/hint примитивов. Параллельность по шардам опциональна (потоки только на
  старте; для строго однопоточного окружения - последовательный fallback).
- **Compaction-проход** (только Bitcask): текущий привязан к CAS
  liveness-check через concurrent index. В single-threaded варианте compaction
  радикально проще: нет конкурентных мутаций во время прохода, liveness -
  обычный lookup в BTreeMap.

### Ограничения

- `!Sync`-коллекция не может быть **follower'ом** репликации: `apply_entry`
  вызывается из потока репликации. Нужен owner-thread + канал, либо не
  поддерживать follower-режим.
- `shard_count: 1` - естественный выбор: шарды существуют ради параллельной
  записи; single-threaded они дают только параллельный recovery и гранулярность
  compaction.
- Из `Db`/Compactor (фоновый поток) такую коллекцию надо исключить - `compact()`
  звать inline.

### Два пути реализации Mutex-вопроса

1. **Прагматичный**: использовать существующие `Bitcask`/`Fixed` как есть -
   uncontended `parking_lot::Mutex` ≈ 10-20ns, для прототипа незаметно. Новая
   коллекция = новый индекс + тонкий слой, ноль изменений в backend'ах.
2. **Чистый**: расщепить `Durability` - добавить вариант, отдающий
   `&mut Inner`/`RefCell<Inner>` без Mutex. Стоит делать только если бенчмарк
   покажет, что 15ns на операцию вообще видны на фоне memcpy + индекса.

### Вывод

Это не «совершенно другое» - это новый *index+API слой* поверх готового
storage. Honest-оценка мотивации: выигрыш в **скорости небольшой**
(uncontended-примитивы почти бесплатны; чтение Const-коллекций и так
lock-free), главный выигрыш - в **простоте и безопасности** (минус ~всё unsafe,
std-структуры, `&mut`-семантика API) и в **гибкости API** (Entry API, batch
операции, итерация с мутацией), которую конкурентная модель дать не может.