# armdb: интеграция с armour (feature `armour`)
## Обзор
Feature `armour` добавляет интеграцию armdb с экосистемой armour:
- **Db** — менеджер именованных коллекций с метаданными, генерацией ID и
фоновыми воркерами (compaction, flush, TTL sweep)
- **TreeMeta** — описание коллекции (имя, тип ключа, тип значения, версия)
- **Миграции** — автоматическое применение миграций при открытии дерева
- **RPC-сервер** — TCP/UDS сервер с бинарным протоколом armour-rpc
`Db` — ещё и точка входа для всего, что охватывает больше одной коллекции.
Каждый из этих слоёв документирован отдельно:
| Транзакции (T5) | `db.tx()` | [`transactions.md`](./transactions.md) |
| TTL (T6) | `TtlPolicy` / `TtlHook`, `db.sweep_expired_now()` | [`hooks.md`](./hooks.md#ttl-t6) |
| Индексы / счётчики / уникальность / change stream (T8–T12) | `SchemaRegistry` | [`../../docs/schema-metadata.md`](../../docs/schema-metadata.md) |
| Durable-очереди (T11) | `db.open_queue::<N>()` | [`queues.md`](./queues.md) |
| Realtime-подписки (T14) | `WatchHook` + `collection.watch()` | [`watch.md`](./watch.md) |
| Диапазоны, страницы, bulk (T1/T2/T13) | `collection.scope::<N>()` | [`iterators.md`](./iterators.md) |
## Зависимости
### Feature `armour`
```toml
armour = [
"dep:serde", # DbInfo serialization
"dep:serde_json", # Persist format
"dep:rend", # rkyv portable endian types
"dep:arrayvec", # stack-allocated collections
"typed-tree", # TypedTree/TypedMap (Codec)
]
```
### Feature `rpc` (implies `armour`)
```toml
rpc = [
"armour",
"dep:armour-rpc", # протокол и клиент
"armour-rpc/schema", # schema introspection
"dep:ctrlc", # Ctrl-C handler
]
```
## Db
Менеджер базы данных — управляет директорией, метаданными коллекций и генерацией
последовательных ID.
```rust,ignore
use armdb::armour::Db;
let db = Db::open("data/myapp")?;
// Генерация ID для коллекции
let id = db.next_id("users")?; // 1, 2, 3, ...
// Путь для дерева
let path = db.tree_path("users"); // "data/myapp/users"
db.close()?;
```
### Открытие: пресеты и builder
`Db::open` — это пресет над `Db::builder(path)`; остальные отличаются только
тем, что билдер настраивают за вас:
| `Db::open` | compaction 60 s, flush 1 s, TTL sweep 60 s с бюджетом 4096 строк |
| `Db::open_test` | все три воркера выключены, `db.info`/`db.user` персистятся асинхронно |
| `Db::open_with_workers(path, WorkerIntervals)` | явные интервалы и бюджет sweep'а |
| `Db::open_with_compaction` / `open_with_intervals` | только compaction / compaction + flush; `None` выключает воркер |
| `Db::open_with_tx_config(path, TxConfig)` | конфигурация транзакций (см. [`transactions.md`](./transactions.md)) |
| `Db::builder(path)….open()` | всё вышеперечисленное в одном месте |
Директория берётся под **эксклюзивную аренду** на всё время жизни handle —
второй `open` той же директории (из этого процесса или из другого) вернёт
`DbError::Locked`.
### Каталог открывалок
Все `open_*` регистрируют коллекцию в `db.info`, применяют миграции, поднимают
RPC-адаптер (при feature `rpc`) и принимают хук — в том числе карриер,
собранный `SchemaRegistry` (T8–T12):
| `open_typed_tree` / `open_typed_map` | `TypedTree` / `TypedMap` (значение в памяти, кодек) | Bitcask |
| `open_zero_tree` / `open_zero_map` | `ZeroTree` / `ZeroMap` (zerocopy-значение) | Bitcask |
| `open_zero_tree_fixed` / `open_zero_map_fixed` | те же типы | FixedStore |
| `open_var_typed_tree` / `open_var_typed_map` | `VarTypedTree` / `VarTypedMap` (значение на диске + кэш) | Bitcask |
| `open_lsm_tree` | `LsmTree` (feature `fjall`) — данные больше RAM | fjall |
| `open_queue::<N>` | `Queue<N>` — durable-очередь (T11) | Bitcask / FixedStore заголовок |
| `open_unique_index::<N>` | `UniqueIndex<N>` — namespace уникальности (T10) | FixedStore |
`open_hooked` и другие прямые открывалки коллекций (без `Db`) остаются
доступными, но не участвуют в оркестрации: счётчики (T9) там отвергаются с
`DbError::CountersRequireManagedOpen`, потому что им некуда персистить
`counters_dirty`.
### Транзакции и производные данные
```rust,ignore
db.tx()
.put(&messages, key, msg)
.delete(&drafts, draft_key)
.durable() // fsync лога; по умолчанию выключено
.commit()?;
```
Внутри транзакции объявленные индексы, счётчики, claim'ы уникальности и
change-stream события разрешаются **до** записи в лог и попадают в ту же
commit-запись. Для счётчика после промоушна реплики есть отдельный вход —
`db.recount_counters("collection")`.
### Завершение работы
| `db.close()` | флашит и финализирует все коллекции, снимает `projections_dirty` / `counters_dirty` там, где финализация удалась |
| `db.clean_shutdown()` | то же плюс остановка фоновых воркеров; это то, что вызывает приложение |
| `db.shutdown(&mut self)` | останов воркеров без финализации коллекций |
Записи **после** `close()` крейт терпит, но `counters_dirty` снимается один раз
за сессию — останавливайте писателей до закрытия.
### Структура директории
```text
data/myapp/
db.lock # эксклюзивная аренда директории (flock), см. DbError::Locked
db.info # JSON — метаданные коллекций (Persist<DbInfo>)
db.user # JSON — произвольные пользовательские метаданные
txlog/ # redo-лог транзакций (T5); реплеится при открытии участника
__seq/ # FixedMap — счётчики последовательностей (next_id)
users/ # данные коллекции "users"
posts/ # данные коллекции "posts"
```
Директория коллекции называется её `NAME`; версия схемы живёт в `db.info`
(`CollectionInfo::version`), а не в имени директории.
### DbInfo / CollectionInfo
```rust,ignore
// Персистентные метаданные — версия и хеш типа для каждой коллекции
struct DbInfo {
collections: BTreeMap<String, CollectionInfo>,
}
struct CollectionInfo {
version: u16, // текущая версия схемы
typ_hash: u64, // Typ::h() — хеш типа значения; 0 = прерванная миграция
seq: u64, // последний выданный id (`next_id`)
projections_dirty: bool, // проекции индексов могли не доехать — реконсиль при открытии
counters_dirty: bool, // дельты счётчиков могли не доехать — пересчёт при открытии
}
```
`projections_dirty` и `counters_dirty` — это evidence-биты, а не настройки.
Первый ставится при открытии источника с проекцией индекса, второй — при
открытии counted-источника, и оба снимаются только успешной финализацией в
`close`. На **цели** счётчика тот же бит читается иначе: «содержимое цели
переехало под счётчиком» (миграция), и снимает его открытие её источника. Все
поля с `#[serde(default)]` — `db.info`, записанный старой версией, читается
без миграции.
## TreeMeta и CollectionMeta
`TreeMeta` — статическое описание коллекции. Используется при открытии дерева.
Можно создать вручную:
```rust,ignore
use armour_core::{KeyScheme, KeyType, Typ};
use armdb::armour::TreeMeta;
const USERS_META: TreeMeta = TreeMeta {
name: "users",
key_scheme: KeyScheme::Typed(&[KeyType::Fuid]),
ty: <User as GetType>::TYPE,
group_bits: 10,
version: 2,
};
```
Или через трейт `CollectionMeta` — тип описывает свою коллекцию:
```rust,ignore
use armdb::armour::{CollectionMeta, TreeMeta};
use armour_core::{GetType, KeyScheme, KeyType};
#[derive(GetType, Rapira)]
struct User {
name: String,
email: String,
}
impl CollectionMeta for User {
const NAME: &'static str = "users";
const VERSION: u16 = 2;
const KEY_SCHEME: KeyScheme = KeyScheme::Typed(&[KeyType::Fuid]);
const GROUP_BITS: u32 = 10;
}
// Typ берётся из GetType автоматически
const USERS_META: TreeMeta = TreeMeta::of::<User>();
```
## Миграции
### TypedTree
```rust,ignore
use armdb::armour::{Db, TypedMigration};
use armdb::{Config, MigrateAction, NoHook, RapiraCodec};
// Функция миграции: вызывается для каждой записи при смене версии
fn migrate_v1_to_v2(key: &[u8; 16], val: &User) -> MigrateAction<User> {
MigrateAction::Update(User { /* ... */ })
}
let migrations: &[TypedMigration<[u8; 16], User>] = &[
(1, migrate_v1_to_v2), // from_version=1 -> текущая версия
];
let tree = db.open_typed_tree::<User, RapiraCodec, NoHook>(
Config::balanced().build(),
NoHook,
migrations,
)?;
```
### ZeroTree
```rust,ignore
use armdb::{Config, NoHook};
let tree = db.open_zero_tree::<Session, { size_of::<Session>() }, NoHook>(
Config::balanced().build(),
NoHook,
&[], // migrations
)?;
```
### Логика миграции
1. Читается `CollectionInfo` из `db.info` для имени коллекции
2. Если `stored.version != meta.version`:
- Ищется миграция с `from_version == stored.version`
- Вызывается `tree.migrate(migration_fn)`
- Для каждой записи callback возвращает `Keep`, `Update(T)` или `Delete`
3. Обновляется `CollectionInfo` (version + typ_hash) в `db.info`
## Read-only Inspector
`Db::inspector()` возвращает заимствованный in-process диагностический API поверх
каждой коллекции, открытой через этот `Db`. Inspector **не требует** feature `rpc`,
не использует `armour-rpc` и **не изменяет** сетевой RPC-сервер. Документация RPC
ниже остаётся отдельной.
Приложение, которое уже владеет `Db`, может:
- обнаруживать зарегистрированные коллекции (`collections()`);
- читать схему, `StorageClass`, возможности сканирования и направление итерации;
- выполнять точный lookup по ключу, счётчики и ограниченные сканы.
Аутентификация, авторизация, редактирование секретов, JSON-сериализация и audit
logging остаются ответственностью приложения — Inspector возвращает только сырые
байты и метаданные armdb.
### Открытие и границы
```rust,ignore
use armdb::armour::{Db, Inspector, CountMode};
let db = Db::open("data/myapp")?;
// ... open_typed_tree / open_zero_map / ...
let inspector = db.inspector();
let collections = inspector.collections()?;
```
- Inspector привязан к lifetime `Db` и не переживает его.
- Мутаций на `Inspector` нет; внутренние handler-реестры недоступны снаружи.
- Видны только коллекции, открытые через `Db`; напрямую открытые `TypedTree` /
`ConstTree` и т.п. не регистрируются.
### Discovery и метаданные
`collections()` возвращает отсортированный по имени список `InspectionCollection`:
| `schema.version` | версия схемы коллекции |
| `schema.typ` | `Typ` для динамического декодирования |
| `schema.key_scheme` / `key_size` | схема и размер ключа в байтах |
| `schema.encoding` | как интерпретировать value-байты (см. ниже) |
| `storage` | `StorageClass` (ValueModel, CollectionShape, StorageBackend) |
| `count` | дешёвый O(1) счётчик live-ключей, если есть; иначе `None` |
| `scan` | `Ordered`, `UnorderedSnapshot` или `ExactOnly` |
| `direction` | `Ascending` / `Descending` для ordered-коллекций |
### Кодировка значений (`InspectionEncoding`)
| `Rapira` | Typed/Var с `RapiraCodec`, или Zero на little-endian когда `typ_fixed_width(Typ) == V` | generic `DynValue` по `Typ` |
| `RawLayout` | Typed/Var с `ZerocopyCodec`/`BytemuckCodec`/`BytemuckSliceCodec`; Zero при несовместимости width/shape/endian | сырой memory image; без обещания DynValue |
| `Opaque` | кастомный `Codec` без контракта | generic-декодер не объявлен |
Ключи не входят в контракт DynValue — Inspector возвращает их как сырые байты.
`GetType` должен описывать ту же последовательность полей и ширины, что и
memory layout. Несовпадения ширины (например `usize` описан как `U32`)
детектируются и остаются `RawLayout`. Same-width семантические ошибки в
описании типа (например big-endian байты `Fuid` описаны как little-endian
`u64`) Inspector не детектирует.
### Операции чтения
**Точный lookup** — `get(name, key)`: валидирует длину ключа; `Ok(None)` для
отсутствующего ключа; `InvalidKey` для неверной длины.
**Счётчики**:
- `CountMode::Current` — O(1) счётчик из метаданных (если handler его публикует);
- `CountMode::Exact` — строгий key-only подсчёт live-ключей без декодирования
значений; ошибки Var/LSM пробрасываются, частичный результат не возвращается.
**Ordered scan** — `range` / `prefix` для коллекций с `ScanCapability::Ordered`.
Используется байтовый порядок коллекции и её направление (`reversed` → `Descending`).
Пагинация: `limit` записей + честный `next_key` (lookahead на одну строку) — ключ
устанавливается только если есть следующая страница. Передайте `next_key` как `after`
для продолжения в том же направлении. Страницы **не** являются point-in-time
снимком через шарды.
**Unordered snapshot** — `snapshot_unordered` для Map без `iterable(true)`:
обход в неопределённом per-shard порядке, не глобальный снимок.
### Map iterability
Решение принимается при открытии Map:
- `iterable(true)` → ordered `range` / `prefix` (`ScanCapability::Ordered`);
- без iterability → только `snapshot_unordered` (`UnorderedSnapshot`).
### Отличие от RPC
| Транспорт | in-process, заимствованный | TCP / UDS |
| Мутации | нет | write, remove, batch, … |
| Зависимости | только `armour` | `armour-rpc`, `ctrlc`, … |
| Ошибки | `InspectionError` | wire status + `DbError` |
## RPC-сервер
Бинарный протокол поверх length-delimited framing. Поддерживает TCP и Unix Domain Sockets.
### Запуск
```rust,ignore
use armdb::armour::{Db, RpcConfig};
use armdb::{Config, NoHook, RapiraCodec};
let db = Db::open("data/myapp")?;
// open_typed_tree auto-registers RPC handler when `rpc` feature is enabled
let tree = db.open_typed_tree::<User, RapiraCodec, NoHook>(
Config::balanced().build(),
NoHook,
&[], // migrations
)?;
// Binding is synchronous; TCP reports the bound address.
let addr = db.listen_tcp("127.0.0.1:9000")?;
db.listen_uds("/tmp/myapp.sock")?;
// Use `listen_*_with` to override the blocking listener defaults.
let cfg = RpcConfig::builder().max_connections(128).max_frame(8 * 1024 * 1024).build();
let ephemeral = db.listen_tcp_with("127.0.0.1:0", cfg)?;
```
`listen_tcp` and `listen_tcp_with` bind before returning and yield the bound
`SocketAddr`; `listen_uds` and `listen_uds_with` return `DbResult<()>`. The
`RpcConfig` builder controls connection admission (`max_connections`), the
maximum decoded request frame and response body (`max_frame`),
buffer capacity, read/write deadlines, socket read timeout, and acceptor
polling interval. `max_frame` is at least the five-byte error envelope and caps
every response body, including `400`, `413`, and `503`; their messages are
truncated at a UTF-8 boundary when necessary.
### RpcHandler trait
```rust,ignore
// The six methods whose response ownership changed:
fn get_into(&self, key: &[u8], out: &mut Vec<u8>) -> DbResult<bool>;
fn take_into(&self, key: &[u8], soft: bool, out: &mut Vec<u8>) -> DbResult<bool>;
fn first_into(&self, writer: &mut RowWriter<'_>) -> DbResult<bool>;
fn last_into(&self, writer: &mut RowWriter<'_>) -> DbResult<bool>;
fn range_into(
&self,
start: Bound<KeyBytes>,
end: Bound<KeyBytes>,
writer: &mut RowWriter<'_>,
) -> DbResult<u32>;
fn range_keys_into(
&self,
start: Bound<KeyBytes>,
end: Bound<KeyBytes>,
writer: &mut RowWriter<'_>,
) -> DbResult<u32>;
```
`scannable()` сообщает клиенту, поддерживает ли коллекция `First`/`Last`/`Range`/`RangeKeys`.
По умолчанию `false`, чтобы `ListCollections` не рекламировал сканирование без реализации.
Остальные методы `RpcHandler` (`name`, `info`, `schema`, `contains`, `entry_len`,
`write`, `remove`, `count` и `apply_batch`) не изменили контракт. Для `get_into`
и `take_into` обработчик дописывает значение в `out` и возвращает `false` при
отсутствии ключа; `RowWriter` кодирует строки или ключи непосредственно в
response frame, соблюдает лимит строк и бюджет `max_frame`.
### Adapter
Единственная реализация `RpcHandler` (и `InspectionHandler`) на все семейства
коллекций — `Adapter<S, W>` (`armdb/src/access/adapter.rs`). `S: Access` даёт
типобезопасный доступ к конкретной коллекции (`TypedTree`, `TypedMap`,
`ZeroTree`/`ZeroMap` для обоих backend'ов, `VarTypedTree`/`VarTypedMap`,
`LsmTree`), `W: Wire` — кодирование значения в/из проводного формата. `Db`
создаёт и регистрирует `Adapter` автоматически при каждом `open_*`, поэтому
ручных обработчиков для встроенных семейств больше нет:
- **get_into/contains** — `K::from_bytes(key)` -> `Access::with_value`/`Access::contains` -> `Wire::encode_append`
- **write** — `Wire::decode(value)` -> `Access::put` / условный `Access::atomic_at` для `flag`
- **write с `WriteKey::Sequence`** — генерирует ID через `seq_tree`
- **write с `flag`** — `Some(true)` = только update, `Some(false)` = только insert; precondition через `HeldOps::try_contains` (existence-only: undecodable row → `KeyExists`/`KeyNotFound`, не corruption). Collection `upsert` по-прежнему декодирует значение.
- **range_into/first_into/last_into** — `Access::scan_range`/`with_first`/`with_last`; для Map требует `iterable(true)`, иначе `NotImplemented`
- **scannable** — `true`, когда `Access::scan_capability() == ScanCapability::Ordered`
- **apply_batch** — серия put/delete в цикле (не атомарна)
`RpcHandler` остаётся публичным трейтом — коллекция, открытая не через `Db`
(или нестандартное хранилище), по-прежнему может реализовать его вручную;
см. «Пользовательские RpcHandler» ниже.
### Протокол
Каждый запрос: `opcode(u8) + hashname(u64) + payload`. Ответ: `status(u8) + payload`.
| Get | Получить значение по ключу |
| Contains | Проверить наличие (возвращает длину значения) |
| First / Last | Первый / последний элемент |
| Range / RangeKeys | Диапазонный запрос (ключи+значения / только ключи) |
| Count | Количество элементов |
| Write | Вставка/обновление с опциональным флагом |
| Remove | Удаление |
| ApplyBatch | Пакетная операция |
| ListCollections | Список всех коллекций с метаданными |
### Shutdown
`Db::shutdown` (и `Drop`) выставляет stop flag каждого листенера, затем ждёт
acceptor и connection threads. Блокирующие соединения проверяют флаг не реже
одного `read_timeout`, поэтому idle connection завершается в этой границе.
### Trust model
RPC-сервер рассчитан на **доверенную сеть**: пиры не аутентифицируются, операции не
авторизуются. `RpcConfig::max_connections` ограничивает одновременно обслуживаемые
сокеты. Лишний принятый сокет помещается в ограниченную очередь отказов: если в ней
есть место, он получает `503 "too many connections"` и закрывается; если очередь
переполнена, сокет закрывается без ответа. `max_frame` отклоняет входящий request
frame больше лимита с `400` и закрытием и ограничивает каждый response: слишком
большой normal response получает `413`, а сообщения `400`/`413`/`503` обрезаются
на UTF-8 границе, чтобы поместиться. `max_frame` не может быть меньше пяти байтов
error envelope. `limit == 0` ограничивается
`UNLIMITED_RANGE_CAP`; явный больший ненулевой `limit` соблюдается. `Count { exact:
true }` по-прежнему может сканировать всю коллекцию. Держите RPC listener на
loopback/UDS или за аутентифицированным туннелем; не выставляйте листенер на
недоверенную сеть. Полное описание (включая репликацию) — раздел «Network trust
model» в `design.md`.
## Пользовательские RpcHandler
`Adapter` покрывает все типизированные семейства: `TypedTree`/`TypedMap`,
`ZeroTree`/`ZeroMap` (оба backend'а), `VarTypedTree`/`VarTypedMap`, `LsmTree` —
`Db::open_*` регистрирует его сам. Для нетипизированных коллекций (`ConstTree`,
`ConstMap`, `VarTree`, `VarMap` — без `CollectionMeta`-значения) `RpcHandler`
по-прежнему нужно реализовать вручную.
Scan-capable handler **обязан** переопределить `scannable()` → `true` и реализовать все четыре
метода сканирования (`first_into`, `last_into`, `range_into`, `range_keys_into`). Дефолт `scannable() == false`
предотвращает ложноположительное обнаружение в `ListCollections`.
```rust,ignore
use armdb::armour::handler::RowWriter;
struct MyConstHandler {
tree: Arc<ConstTree<[u8; 16], 64>>,
}
impl RpcHandler for MyConstHandler {
fn name(&self) -> &str { "my_const" }
fn scannable(&self) -> bool { true }
fn get_into(&self, key: &[u8], out: &mut Vec<u8>) -> DbResult<bool> {
let k: [u8; 16] = key.try_into().map_err(|_| DbError::KeyNotFound)?;
let Some(value) = self.tree.get(&k) else { return Ok(false) };
out.extend_from_slice(&value);
Ok(true)
}
fn first_into(&self, writer: &mut RowWriter<'_>) -> DbResult<bool> {
let Some((key, value)) = self.tree.first() else { return Ok(false) };
writer.row(&key, |out| {
out.extend_from_slice(&value);
Ok(())
})?;
Ok(true)
}
// ... implement last_into, range_into, range_keys_into, take_into,
// and the unchanged RpcHandler methods.
}
```