# 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.
## Тяжело ли добавить hooks в методы atomic()?
Несложно и почти бесплатно. Сейчас `ConstShard::put/insert/delete` внутри
`atomic()` вызывают те же `put_locked`/`delete_locked`, что и обычный путь, но
hook там не вызывается: в обычном `put` (`const_tree.rs`) `hook.on_write()`
срабатывает *после* `drop(inner)`, то есть вне shard lock. Внутри `atomic()`
лок удерживается всё замыкание, поэтому хуков там нет.
Рекомендуемый дизайн - **буферизация событий + replay после разблокировки**:
```rust
struct ConstShard<'a, ...> {
// ...
events: SmallVec<[(K, Option<[u8; V]>, Option<[u8; V]>); 4]>,
}
// в atomic(): f(&mut shard) -> drop(inner) -> for e in events { hook.on_write(...) }
```
- **Производительность.** Для `NoHook` - ноль: добавить в `WriteHook` константу
`const ACTIVE: bool` (у `NoHook` - `false`), ветка `if H::ACTIVE { events.push(...) }`
вырезается мономорфизацией, как уже сделано с `NEEDS_OLD_VALUE`. С активным
hook - один push в SmallVec на операцию; для Const/Zero значения `Copy`,
копия дешёвая.
- **Семантика совпадает с обычным путём** (hook после unlock). Вызывать hook под
локом нельзя ещё и из-за reentrancy: пользовательский hook, трогающий то же
дерево, словил бы deadlock на parking_lot Mutex (не reentrant).
- **Два нюанса, которые надо решить и задокументировать:**
1. *Ошибка посреди замыкания.* `atomic()` не откатывает уже применённые записи
(rollback'а нет) - значит, hooks должны срабатывать для успешно применённых
операций даже при `Err` из замыкания. Иначе secondary index разъедется с
данными.
2. *Typed-коллекции.* `TypedWriteHook` получает `&T`, старое значение защищено
seize guard (`TypedRef`). Буферизация требует либо удержания guard до replay
(он и так живёт в `TypedShard`), либо вызова hook до swap, как сейчас в
обычном пути TypedTree. Решаемо, но основная аккуратность именно здесь.
Трудоёмкость: механика тривиальная, но тиражируется на ~10 shard-типов - хороший
повод сначала консолидировать core-операции коллекций.
**Реализовано** в `docs/superpowers/specs/26-06-13-armdb-atomic-hooks.md` (buffer + replay, fire-for-applied, std `Vec` buffer).
## Можно ли сделать кросс-коллекционные транзакции умеренно дешёвыми?
Важный факт: в `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
Стоимость - ровно 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** (план уже есть) - при большом block cache двойное кэширование с
page cache ОС заметно жжёт память.
**Про сжатие: блоками, не файлами - и есть третий вариант, самый дешёвый.**
- **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.
## Можно ли переиспользовать 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
операции, итерация с мутацией), которую конкурентная модель дать не может.
## Чем уменьшить место под множество мелких значений (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 для коллекции сообщений.