forked from dlang/cdcdb
Русификация DDoc и сообщений
This commit is contained in:
parent
49ee7a4053
commit
f34b26c2b5
7 changed files with 387 additions and 361 deletions
19
CHANGELOG.md
19
CHANGELOG.md
|
@ -1,12 +1,13 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [0.1.0] — 2025-09-13
|
## [0.1.0] — 2025-09-14
|
||||||
### Added
|
### Added
|
||||||
- SQLite-backed snapshot library with content-defined chunking (FastCDC).
|
- Библиотека для хранения и управления снимками данных на базе SQLite с контентно-зависимым разбиением (FastCDC).
|
||||||
- Deduplication by SHA-256 of chunks; optional Zstd compression.
|
- Дедупликация по SHA-256 чанков, опциональная компрессия Zstd.
|
||||||
- End-to-end integrity checks: per-chunk hash and final file hash.
|
- Сквозная проверка целостности: хеш каждого чанка и итогового файла.
|
||||||
- Transactions (WAL), basic integrity constraints, and triggers.
|
- Поддержка транзакций (WAL), ограничения целостности и триггеры в БД.
|
||||||
- High-level API:
|
- Высокоуровневый API:
|
||||||
- `Storage`: `newSnapshot`, `getSnapshots`, `getSnapshot`, `removeSnapshots`, `setupCDC`, `getVersion`.
|
- `Storage`: `newSnapshot`, `getSnapshots`, `getSnapshot`, `removeSnapshots`, `removeSnapshot`, `setupCDC`, `getVersion`.
|
||||||
- `Snapshot`: `data()` (buffered) and streaming `data(void delegate(const(ubyte)[]))`, `remove()`, properties (`id`, `label`, `created`, `length`, `sha256`, `status`, `description`).
|
- `Snapshot`: `data()` (возврат буфера) и потоковый `data(void delegate(const(ubyte)[]))`, `remove()`, свойства (`id`, `file`, `created`, `length`, `sha256`, `status`, `description`, `algoMin`, `algoNormal`, `algoMax`, `maskS`, `maskL`, `uid`, `ruid`, `uidName`, `ruidName`, `process`, `isReady`, `isPending`).
|
||||||
- Tool to generate a Gear table for FastCDC (`tools/gen.d`).
|
- `Context`: структура для передачи UID, RUID, имён пользователей и процесса при создании снимка.
|
||||||
|
- Инструмент для генерации Gear-таблицы для FastCDC (`tools/gen.d`).
|
||||||
|
|
82
README.md
82
README.md
|
@ -1,66 +1,42 @@
|
||||||
# cdcdb
|
# cdcdb
|
||||||
|
|
||||||
A library for storing and managing snapshots of textual data in an SQLite database. It uses content-defined chunking (CDC) based on the FastCDC algorithm to split data into variable-size chunks for efficient deduplication. Supports optional Zstd compression, transactions, and end-to-end integrity verification via SHA-256. Primary use cases: backups and versioning of text files while minimizing storage footprint.
|
Библиотека для версионирования и хранения данных в SQLite с опорой на content-defined chunking (CDC). Алгоритм **FastCDC** используется для разбиения входного потока на чанки переменного размера, что обеспечивает эффективную дедупликацию и экономию места. Поддерживаются опциональное сжатие **Zstd**, транзакции и проверка целостности на основе **SHA-256**. Дополнительно фиксируется контекст выполнения при создании снимков (UID, RUID, процесс). Основные сценарии применения — резервное копирование и контроль версий файлов.
|
||||||
|
|
||||||
## FastCDC algorithm
|
## Алгоритм FastCDC
|
||||||
FastCDC splits data into variable-size chunks using content hashing. A Gear table is used to compute rolling “fingerprints” and choose cut points while respecting minimum, target, and maximum chunk sizes. This efficiently detects changes and stores only unique chunks, reducing storage usage.
|
**FastCDC** — это алгоритм разделения данных на чанки переменной длины, основанный на скользящем хэшировании содержимого. Он использует таблицу **Gear** для вычисления "отпечатков" данных и выбирает точки разреза с учётом минимального, целевого и максимального размеров чанка. Такой подход позволяет эффективно выявлять изменения и сохранять только уникальные части данных.
|
||||||
|
|
||||||
## Core classes
|
## Архитектура и основные компоненты
|
||||||
|
|
||||||
### Storage
|
Библиотека разделена на несколько уровней:
|
||||||
High-level API for the SQLite store and snapshot management.
|
|
||||||
|
|
||||||
- **Constructor**: Initializes a connection to SQLite.
|
- **Storage** — фасад для работы с БД SQLite. Отвечает за разбиение данных (FastCDC), сохранение/удаление снимков, управление блобами и транзакциями.
|
||||||
- **Methods**:
|
- **Snapshot** — объект-ссылка на конкретный снимок. Предоставляет доступ к данным (буфером или потоково), а также к метаданным и операциям удаления.
|
||||||
- `newSnapshot`: Creates a snapshot. Returns a `Snapshot` object or `null` if the data matches the latest snapshot.
|
- **Context** — структура с параметрами окружения (UID, RUID, имена пользователей, процесс), передаваемыми при создании снимка.
|
||||||
- `getSnapshots`: Returns a list of snapshots (all, or filtered by label). Returns an array of `Snapshot`.
|
|
||||||
- `getSnapshot`: Fetches a snapshot by ID. Returns a `Snapshot`.
|
|
||||||
- `setupCDC`: Configures CDC splitting parameters. Returns nothing.
|
|
||||||
- `removeSnapshots`: Deletes snapshots by label, ID, or a `Snapshot` object. Returns the number of deleted snapshots (for label) or `true`/`false` (for ID or object).
|
|
||||||
- `getVersion`: Returns the library version string.
|
|
||||||
|
|
||||||
### Snapshot
|
## Пример использования
|
||||||
Work with an individual snapshot.
|
|
||||||
|
|
||||||
- **Constructor**: Creates a snapshot handle by its ID.
|
|
||||||
- **Methods**:
|
|
||||||
- `data`: Restores full snapshot data. Returns a byte array (`ubyte[]`).
|
|
||||||
- `data`: Streams restored data via a delegate sink. Returns nothing.
|
|
||||||
- `remove`: Deletes the snapshot from the database. Returns `true` on success, otherwise `false`.
|
|
||||||
|
|
||||||
- **Properties**:
|
|
||||||
- `id`: Snapshot ID (`long`).
|
|
||||||
- `label`: Snapshot label (`string`).
|
|
||||||
- `created`: Creation timestamp (UTC, `DateTime`).
|
|
||||||
- `length`: Original data length (`long`).
|
|
||||||
- `sha256`: Data SHA-256 hash (`ubyte[32]`).
|
|
||||||
- `status`: Snapshot status (`"pending"` or `"ready"`).
|
|
||||||
- `description`: Snapshot description (`string`).
|
|
||||||
|
|
||||||
## Example
|
|
||||||
```d
|
```d
|
||||||
import cdcdb;
|
import cdcdb;
|
||||||
|
|
||||||
import std.stdio : writeln, File;
|
import std.stdio : writeln, File;
|
||||||
import std.file : exists, remove;
|
|
||||||
|
|
||||||
void main()
|
void main()
|
||||||
{
|
{
|
||||||
// Create DB
|
|
||||||
string dbPath = "example.db";
|
string dbPath = "example.db";
|
||||||
|
|
||||||
// Initialize Storage with Zstd compression
|
// Storage с Zstd-сжатием
|
||||||
auto storage = new Storage(dbPath, true, 22);
|
auto storage = new Storage(dbPath, true, 22);
|
||||||
|
|
||||||
// Create a snapshot
|
// Контекст
|
||||||
|
Context ctx = Context(1000, 1000, "user", "user", "example");
|
||||||
|
|
||||||
|
// Новый снимок
|
||||||
ubyte[] data = cast(ubyte[]) "Hello, cdcdb!".dup;
|
ubyte[] data = cast(ubyte[]) "Hello, cdcdb!".dup;
|
||||||
auto snap = storage.newSnapshot("example_file", data, "Version 1.0");
|
auto snap = storage.newSnapshot("example_file", data, ctx, "Версия 1.0");
|
||||||
if (snap)
|
if (snap)
|
||||||
{
|
{
|
||||||
writeln("Snapshot created: ID=", snap.id, ", Label=", snap.label);
|
writeln("Создан снимок: ID=", snap.id, ", Файл=", snap.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore data
|
// Восстановление через потоковую запись
|
||||||
auto snapshots = storage.getSnapshots("example_file");
|
auto snapshots = storage.getSnapshots("example_file");
|
||||||
if (snapshots.length > 0)
|
if (snapshots.length > 0)
|
||||||
{
|
{
|
||||||
|
@ -68,35 +44,25 @@ void main()
|
||||||
File outFile = File("restored.txt", "wb");
|
File outFile = File("restored.txt", "wb");
|
||||||
lastSnap.data((const(ubyte)[] chunk) => outFile.rawWrite(chunk));
|
lastSnap.data((const(ubyte)[] chunk) => outFile.rawWrite(chunk));
|
||||||
outFile.close();
|
outFile.close();
|
||||||
writeln("Data restored to restored.txt");
|
writeln("Данные восстановлены в restored.txt");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete snapshots
|
// Удаление
|
||||||
long deleted = storage.removeSnapshots("example_file");
|
long deleted = storage.removeSnapshots("example_file");
|
||||||
writeln("Deleted snapshots: ", deleted);
|
writeln("Удалено снимков: ", deleted);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tools
|
## Инструменты
|
||||||
|
|
||||||
The `tools` directory contains a small D script for generating a Gear table used by FastCDC. It lets you build custom hash tables to tune splitting behavior. To generate a new table:
|
В директории `tools` есть скрипт на D для генерации таблицы Gear, используемой FastCDC.
|
||||||
|
Чтобы создать новую таблицу:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chmod +x ./tools/gen.d
|
chmod +x ./tools/gen.d
|
||||||
./tools/gen.d > ./source/gear.d
|
./tools/gen.d > ./source/gear.d
|
||||||
```
|
```
|
||||||
|
|
||||||
## Installation
|
## Лицензия
|
||||||
|
|
||||||
* **In `dub.json`**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"dependencies": {
|
|
||||||
"cdcdb": "~>0.1.0"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
* **Build**: `dub build`.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Boost Software License 1.0 (BSL-1.0).
|
Boost Software License 1.0 (BSL-1.0).
|
||||||
|
|
|
@ -1,24 +1,45 @@
|
||||||
|
/// Модуль базовых структур и алгоритмов CDC (content-defined chunking).
|
||||||
module cdcdb.core;
|
module cdcdb.core;
|
||||||
|
|
||||||
import std.digest.sha : SHA256, digest;
|
import std.digest.sha : SHA256, digest;
|
||||||
|
|
||||||
|
/// Описание чанка данных.
|
||||||
|
///
|
||||||
|
/// Поля:
|
||||||
|
/// - `index` — порядковый номер чанка, начиная с 1.
|
||||||
|
/// - `offset` — смещение чанка в исходном буфере (в байтах).
|
||||||
|
/// - `size` — размер чанка (в байтах).
|
||||||
|
/// - `sha256` — SHA-256 содержимого (сырые 32 байта).
|
||||||
struct Chunk
|
struct Chunk
|
||||||
{
|
{
|
||||||
size_t index; // 1..N
|
size_t index;
|
||||||
size_t offset; // offset in the source buffer
|
size_t offset;
|
||||||
size_t size; // chunk size
|
size_t size;
|
||||||
immutable(ubyte)[32] sha256; // hex(SHA-256) of the content
|
immutable(ubyte)[32] sha256;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Change Data Capture (CDC)
|
/// Change Data Capture (CDC) — алгоритм нарезки потока на чанки по содержимому.
|
||||||
|
///
|
||||||
|
/// Класс реализует скользящее шифрование (rolling hash) с двумя масками:
|
||||||
|
/// строгой (`_maskS`) до «нормального» размера и более слабой (`_maskL`) до «максимального».
|
||||||
final class CDC
|
final class CDC
|
||||||
{
|
{
|
||||||
private:
|
private:
|
||||||
size_t _minSize, _normalSize, _maxSize;
|
size_t _minSize, _normalSize, _maxSize;
|
||||||
ulong _maskS, _maskL;
|
ulong _maskS, _maskL;
|
||||||
// _gear
|
// Таблица случайных значений Gear (должна быть сгенерирована отдельно в "gear.d")
|
||||||
mixin(import("gear.d"));
|
mixin(import("gear.d"));
|
||||||
|
|
||||||
|
/// Вычисляет длину следующего чанка, начиная с начала `src`.
|
||||||
|
///
|
||||||
|
/// Параметры:
|
||||||
|
/// - `src` — оставшийся участок данных.
|
||||||
|
///
|
||||||
|
/// Возвращает: длину чанка в байтах.
|
||||||
|
///
|
||||||
|
/// Детали:
|
||||||
|
/// - Если данных меньше либо равно минимальному размеру — возвращает их длину.
|
||||||
|
/// - Сначала ищется граница по строгой маске до `_normalSize`, затем по слабой до `_maxSize`.
|
||||||
size_t cut(const(ubyte)[] src) pure nothrow @safe @nogc
|
size_t cut(const(ubyte)[] src) pure nothrow @safe @nogc
|
||||||
{
|
{
|
||||||
size_t size = src.length;
|
size_t size = src.length;
|
||||||
|
@ -36,13 +57,13 @@ private:
|
||||||
ulong fingerprint = 0;
|
ulong fingerprint = 0;
|
||||||
size_t index;
|
size_t index;
|
||||||
|
|
||||||
// initialization without a cut-check
|
// Инициализация без проверки на разрез
|
||||||
while (index < _minSize)
|
while (index < _minSize)
|
||||||
{
|
{
|
||||||
fingerprint = (fingerprint << 1) + _gear[src[index]];
|
fingerprint = (fingerprint << 1) + _gear[src[index]];
|
||||||
++index;
|
++index;
|
||||||
}
|
}
|
||||||
// strict mask
|
// Строгая маска
|
||||||
while (index < normalSize)
|
while (index < normalSize)
|
||||||
{
|
{
|
||||||
fingerprint = (fingerprint << 1) + _gear[src[index]];
|
fingerprint = (fingerprint << 1) + _gear[src[index]];
|
||||||
|
@ -50,7 +71,7 @@ private:
|
||||||
return index;
|
return index;
|
||||||
++index;
|
++index;
|
||||||
}
|
}
|
||||||
// weak mask
|
// Слабая маска
|
||||||
while (index < size)
|
while (index < size)
|
||||||
{
|
{
|
||||||
fingerprint = (fingerprint << 1) + _gear[src[index]];
|
fingerprint = (fingerprint << 1) + _gear[src[index]];
|
||||||
|
@ -62,10 +83,21 @@ private:
|
||||||
}
|
}
|
||||||
|
|
||||||
public:
|
public:
|
||||||
|
/// Создаёт экземпляр CDC.
|
||||||
|
///
|
||||||
|
/// Параметры:
|
||||||
|
/// - `minSize` — минимальный размер чанка.
|
||||||
|
/// - `normalSize` — целевой (нормальный) размер чанка.
|
||||||
|
/// - `maxSize` — максимальный размер чанка.
|
||||||
|
/// - `maskS` — строгая маска (для поиска границы до `normalSize`).
|
||||||
|
/// - `maskL` — слабая маска (для поиска границы до `maxSize`).
|
||||||
|
///
|
||||||
|
/// Замечания:
|
||||||
|
/// - Требуется `0 < minSize < normalSize < maxSize`.
|
||||||
this(size_t minSize, size_t normalSize, size_t maxSize, ulong maskS, ulong maskL) @safe @nogc nothrow
|
this(size_t minSize, size_t normalSize, size_t maxSize, ulong maskS, ulong maskL) @safe @nogc nothrow
|
||||||
{
|
{
|
||||||
assert(minSize > 0 && minSize < normalSize && normalSize < maxSize,
|
assert(minSize > 0 && minSize < normalSize && normalSize < maxSize,
|
||||||
"Invalid sizes: require min < normal < max and min > 0");
|
"Некорректные размеры: требуется 0 < min < normal < max");
|
||||||
_minSize = minSize;
|
_minSize = minSize;
|
||||||
_normalSize = normalSize;
|
_normalSize = normalSize;
|
||||||
_maxSize = maxSize;
|
_maxSize = maxSize;
|
||||||
|
@ -73,6 +105,12 @@ public:
|
||||||
_maskL = maskL;
|
_maskL = maskL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Разбивает буфер `data` на последовательность чанков.
|
||||||
|
///
|
||||||
|
/// Параметры:
|
||||||
|
/// - `data` — исходные байты.
|
||||||
|
///
|
||||||
|
/// Возвращает: массив `Chunk` в порядке следования.
|
||||||
Chunk[] split(const(ubyte)[] data) @safe nothrow
|
Chunk[] split(const(ubyte)[] data) @safe nothrow
|
||||||
{
|
{
|
||||||
Chunk[] chunks;
|
Chunk[] chunks;
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
/// Лёгкая обёртка над SQLite с повторными попытками, схемой БД
|
||||||
|
/// и удобными структурами данных для снимков/чанков/блобов.
|
||||||
module cdcdb.dblite;
|
module cdcdb.dblite;
|
||||||
|
|
||||||
import arsd.sqlite : Sqlite, SqliteResult, DatabaseException;
|
import arsd.sqlite : Sqlite, SqliteResult, DatabaseException;
|
||||||
|
@ -9,72 +11,85 @@ import std.conv : to;
|
||||||
import std.format : format;
|
import std.format : format;
|
||||||
import std.exception : enforce;
|
import std.exception : enforce;
|
||||||
|
|
||||||
|
/// Статус снимка.
|
||||||
|
/// - `pending` — создаётся/заполняется;
|
||||||
|
/// - `ready` — полностью подготовлен.
|
||||||
enum SnapshotStatus : ubyte
|
enum SnapshotStatus : ubyte
|
||||||
{
|
{
|
||||||
pending = 0,
|
pending = 0,
|
||||||
ready = 1
|
ready = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Запись о снимке в БД (агрегированные метаданные).
|
||||||
struct DBSnapshot {
|
struct DBSnapshot {
|
||||||
long id;
|
long id; /// Идентификатор снимка.
|
||||||
string file;
|
string file; /// Имя файла (таблица `files`).
|
||||||
ubyte[32] sha256;
|
ubyte[32] sha256; /// Хеш всего файла (SHA-256, 32 байта).
|
||||||
string description;
|
string description; /// Описание/комментарий (может быть пустым).
|
||||||
DateTime createdUtc;
|
DateTime createdUtc; /// Время создания (UTC).
|
||||||
long sourceLength;
|
long sourceLength; /// Длина исходного файла (байт).
|
||||||
long algoMin;
|
long algoMin; /// FastCDC: минимальный размер чанка.
|
||||||
long algoNormal;
|
long algoNormal; /// FastCDC: нормальный (целевой) размер чанка.
|
||||||
long algoMax;
|
long algoMax; /// FastCDC: максимальный размер чанка.
|
||||||
long maskS;
|
long maskS; /// Строгая маска FastCDC.
|
||||||
long maskL;
|
long maskL; /// Слабая маска FastCDC.
|
||||||
SnapshotStatus status;
|
SnapshotStatus status; /// Статус снимка.
|
||||||
long uid;
|
long uid; /// UID процесса (effective).
|
||||||
long ruid;
|
long ruid; /// Real UID процесса.
|
||||||
string uidName;
|
string uidName; /// Имя пользователя для `uid`.
|
||||||
string ruidName;
|
string ruidName; /// Имя пользователя для `ruid`.
|
||||||
string process;
|
string process; /// Имя процесса (таблица `processes`).
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Связь снимка с чанками (индексы и хеши).
|
||||||
struct DBSnapshotChunk
|
struct DBSnapshotChunk
|
||||||
{
|
{
|
||||||
long snapshotId;
|
long snapshotId; /// ID снимка.
|
||||||
long chunkIndex;
|
long chunkIndex; /// Порядковый номер чанка в снимке.
|
||||||
long offset;
|
long offset; /// Смещение чанка в файле.
|
||||||
ubyte[32] sha256;
|
ubyte[32] sha256; /// Хеш чанка (SHA-256, 32 байта).
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Запись о блобе (уникальный чанк) в БД.
|
||||||
struct DBBlob
|
struct DBBlob
|
||||||
{
|
{
|
||||||
ubyte[32] sha256;
|
ubyte[32] sha256; /// Хеш исходного содержимого.
|
||||||
ubyte[32] zSha256;
|
ubyte[32] zSha256; /// Хеш сжатого содержимого (если zstd=true).
|
||||||
long size;
|
long size; /// Размер исходного содержимого.
|
||||||
long zSize;
|
long zSize; /// Размер сжатого содержимого.
|
||||||
ubyte[] content;
|
ubyte[] content; /// Контент (если хранится в БД).
|
||||||
DateTime createdUtc;
|
DateTime createdUtc; /// Время создания (UTC).
|
||||||
DateTime lastSeenUtc;
|
DateTime lastSeenUtc; /// Последний доступ (UTC).
|
||||||
long refcount;
|
long refcount; /// Ссылки на блоб (сколькими снимками используется).
|
||||||
bool zstd;
|
bool zstd; /// Признак, что `content` хранится в сжатом виде.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Расширенная выборка чанков для восстановления.
|
||||||
|
/// Содержит и метаданные, и (возможное) содержимое.
|
||||||
struct DBSnapshotChunkData {
|
struct DBSnapshotChunkData {
|
||||||
long chunkIndex;
|
long chunkIndex; /// Порядковый номер чанка.
|
||||||
long offset;
|
long offset; /// Смещение в файле.
|
||||||
long size;
|
long size; /// Размер исходного чанка.
|
||||||
ubyte[] content;
|
ubyte[] content; /// Содержимое (может быть пустым, если хранится вне БД).
|
||||||
bool zstd;
|
bool zstd; /// Сжат ли контент Zstd.
|
||||||
long zSize;
|
long zSize; /// Размер сжатого контента.
|
||||||
ubyte[32] sha256;
|
ubyte[32] sha256; /// Хеш исходного содержимого.
|
||||||
ubyte[32] zSha256;
|
ubyte[32] zSha256; /// Хеш сжатого содержимого.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Простейший клиент SQLite с:
|
||||||
|
/// - автоматической инициализацией схемы (при пустой БД);
|
||||||
|
/// - повторными попытками при блокировках;
|
||||||
|
/// - удобными методами для CRUD по объектам домена.
|
||||||
final class DBLite : Sqlite
|
final class DBLite : Sqlite
|
||||||
{
|
{
|
||||||
private:
|
private:
|
||||||
string _dbPath;
|
string _dbPath; /// Путь к файлу БД.
|
||||||
size_t _maxRetries;
|
size_t _maxRetries; /// Максимум повторов при `busy/locked`.
|
||||||
// _scheme
|
// SQL-схема (массив строковых запросов).
|
||||||
mixin(import("scheme.d"));
|
mixin(import("scheme.d"));
|
||||||
|
|
||||||
|
/// Выполняет SQL с повторными попытками при `locked/busy`.
|
||||||
SqliteResult sql(T...)(string queryText, T args)
|
SqliteResult sql(T...)(string queryText, T args)
|
||||||
{
|
{
|
||||||
if (_maxRetries == 0) {
|
if (_maxRetries == 0) {
|
||||||
|
@ -92,7 +107,7 @@ private:
|
||||||
if (msg.toLower.canFind("locked", "busy")) {
|
if (msg.toLower.canFind("locked", "busy")) {
|
||||||
if (--tryNo == 0) {
|
if (--tryNo == 0) {
|
||||||
throw new Exception(
|
throw new Exception(
|
||||||
"Failed to connect to the database after %d failed attempts: %s"
|
"Не удалось выполнить запрос к базе данных после %d неудачных попыток: %s"
|
||||||
.format(_maxRetries, msg)
|
.format(_maxRetries, msg)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -104,7 +119,8 @@ private:
|
||||||
throw new Exception(msg);
|
throw new Exception(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that the database contains the required tables; otherwise create them
|
/// Проверяет наличие обязательных таблиц.
|
||||||
|
/// Если все отсутствуют — создаёт схему; если отсутствует часть — бросает ошибку.
|
||||||
void check()
|
void check()
|
||||||
{
|
{
|
||||||
SqliteResult queryResult = sql(
|
SqliteResult queryResult = sql(
|
||||||
|
@ -129,7 +145,7 @@ private:
|
||||||
}
|
}
|
||||||
|
|
||||||
enforce(missingTables.length == 0 || missingTables.length == 6,
|
enforce(missingTables.length == 0 || missingTables.length == 6,
|
||||||
"Database is corrupted. Missing tables: " ~ missingTables.join(", ")
|
"База данных повреждена. Отсутствуют таблицы: " ~ missingTables.join(", ")
|
||||||
);
|
);
|
||||||
|
|
||||||
if (missingTables.length == 6)
|
if (missingTables.length == 6)
|
||||||
|
@ -141,6 +157,8 @@ private:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Переводит текстовую дату из SQLite (`YYYY-MM-DD HH:MM:SS.SSS`)
|
||||||
|
/// в `DateTime` (ISO 8601 с `T`).
|
||||||
DateTime toDateTime(string sqliteDate)
|
DateTime toDateTime(string sqliteDate)
|
||||||
{
|
{
|
||||||
string isoDate = sqliteDate.replace(" ", "T");
|
string isoDate = sqliteDate.replace(" ", "T");
|
||||||
|
@ -148,6 +166,12 @@ private:
|
||||||
}
|
}
|
||||||
|
|
||||||
public:
|
public:
|
||||||
|
/// Открывает БД, проверяет/инициализирует схему и настраивает PRAGMA.
|
||||||
|
///
|
||||||
|
/// Параметры:
|
||||||
|
/// - `database` — путь к файлу БД;
|
||||||
|
/// - `busyTimeout` — таймаут ожидания блокировок (мс);
|
||||||
|
/// - `maxRetries` — число повторов при `busy/locked`.
|
||||||
this(string database, size_t busyTimeout, size_t maxRetries)
|
this(string database, size_t busyTimeout, size_t maxRetries)
|
||||||
{
|
{
|
||||||
_dbPath = database;
|
_dbPath = database;
|
||||||
|
@ -163,21 +187,27 @@ public:
|
||||||
query("PRAGMA busy_timeout=%d".format(busyTimeout));
|
query("PRAGMA busy_timeout=%d".format(busyTimeout));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// BEGIN IMMEDIATE.
|
||||||
void beginImmediate()
|
void beginImmediate()
|
||||||
{
|
{
|
||||||
sql("BEGIN IMMEDIATE");
|
sql("BEGIN IMMEDIATE");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// COMMIT.
|
||||||
void commit()
|
void commit()
|
||||||
{
|
{
|
||||||
sql("COMMIT");
|
sql("COMMIT");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ROLLBACK.
|
||||||
void rollback()
|
void rollback()
|
||||||
{
|
{
|
||||||
sql("ROLLBACK");
|
sql("ROLLBACK");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Проверяет, совпадает ли последний снимок для `file` с заданным `sha256`.
|
||||||
|
///
|
||||||
|
/// Возвращает `true`, если самый свежий снимок этого файла имеет тот же SHA-256.
|
||||||
bool isLast(string file, ubyte[] sha256) {
|
bool isLast(string file, ubyte[] sha256) {
|
||||||
auto queryResult = sql(
|
auto queryResult = sql(
|
||||||
q{
|
q{
|
||||||
|
@ -200,6 +230,7 @@ public:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Добавляет новый снимок. Возвращает его `id`.
|
||||||
long addSnapshot(DBSnapshot snapshot)
|
long addSnapshot(DBSnapshot snapshot)
|
||||||
{
|
{
|
||||||
auto queryResult = sql(
|
auto queryResult = sql(
|
||||||
|
@ -242,12 +273,13 @@ public:
|
||||||
);
|
);
|
||||||
|
|
||||||
if (queryResult.empty()) {
|
if (queryResult.empty()) {
|
||||||
throw new Exception("Error adding a new snapshot to the database");
|
throw new Exception("Ошибка добавления нового снимка в базу данных");
|
||||||
}
|
}
|
||||||
|
|
||||||
return queryResult.front()["id"].to!long;
|
return queryResult.front()["id"].to!long;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Добавляет блоб. Возвращает `true`, если вставка произошла (не было конфликта).
|
||||||
bool addBlob(DBBlob blob)
|
bool addBlob(DBBlob blob)
|
||||||
{
|
{
|
||||||
auto queryResult = sql(
|
auto queryResult = sql(
|
||||||
|
@ -268,6 +300,7 @@ public:
|
||||||
return !queryResult.empty();
|
return !queryResult.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Добавляет процесс по имени (идемпотентно).
|
||||||
bool addProcess(string name)
|
bool addProcess(string name)
|
||||||
{
|
{
|
||||||
auto queryResult = sql(
|
auto queryResult = sql(
|
||||||
|
@ -280,6 +313,7 @@ public:
|
||||||
return !queryResult.empty();
|
return !queryResult.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Добавляет файл по имени (идемпотентно).
|
||||||
bool addFile(string name)
|
bool addFile(string name)
|
||||||
{
|
{
|
||||||
auto queryResult = sql(
|
auto queryResult = sql(
|
||||||
|
@ -292,6 +326,7 @@ public:
|
||||||
return !queryResult.empty();
|
return !queryResult.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Добавляет пользователя (uid, name) (идемпотентно).
|
||||||
bool addUser(long uid, string name)
|
bool addUser(long uid, string name)
|
||||||
{
|
{
|
||||||
auto queryResult = sql(
|
auto queryResult = sql(
|
||||||
|
@ -305,6 +340,7 @@ public:
|
||||||
return !queryResult.empty();
|
return !queryResult.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Добавляет связь снимок–чанк.
|
||||||
bool addSnapshotChunk(DBSnapshotChunk snapshotChunk)
|
bool addSnapshotChunk(DBSnapshotChunk snapshotChunk)
|
||||||
{
|
{
|
||||||
auto queryResult = sql(
|
auto queryResult = sql(
|
||||||
|
@ -322,6 +358,7 @@ public:
|
||||||
return !queryResult.empty();
|
return !queryResult.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Возвращает один снимок по `id`. Если не найден — вернёт пустую структуру.
|
||||||
DBSnapshot getSnapshot(long id)
|
DBSnapshot getSnapshot(long id)
|
||||||
{
|
{
|
||||||
auto queryResult = sql(
|
auto queryResult = sql(
|
||||||
|
@ -381,6 +418,7 @@ public:
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Возвращает список снимков (опционально фильтр по имени файла).
|
||||||
DBSnapshot[] getSnapshots(string file)
|
DBSnapshot[] getSnapshots(string file)
|
||||||
{
|
{
|
||||||
auto queryResult = sql(
|
auto queryResult = sql(
|
||||||
|
@ -442,6 +480,7 @@ public:
|
||||||
return snapshots;
|
return snapshots;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Возвращает последовательность чанков снимка c данными.
|
||||||
DBSnapshotChunkData[] getChunks(long snapshotId)
|
DBSnapshotChunkData[] getChunks(long snapshotId)
|
||||||
{
|
{
|
||||||
auto queryResult = sql(
|
auto queryResult = sql(
|
||||||
|
@ -476,6 +515,7 @@ public:
|
||||||
return sdchs;
|
return sdchs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Удаляет один снимок по `id`. Возвращает `id` удалённой строки или 0.
|
||||||
long deleteSnapshot(long id) {
|
long deleteSnapshot(long id) {
|
||||||
auto queryResult = sql("DELETE FROM snapshots WHERE id = ? RETURNING id", id);
|
auto queryResult = sql("DELETE FROM snapshots WHERE id = ? RETURNING id", id);
|
||||||
|
|
||||||
|
@ -485,6 +525,7 @@ public:
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Удаляет все снимки по имени файла. Возвращает число удалённых строк.
|
||||||
long deleteSnapshots(string file) {
|
long deleteSnapshots(string file) {
|
||||||
auto queryResult = sql(
|
auto queryResult = sql(
|
||||||
q{
|
q{
|
||||||
|
|
|
@ -9,26 +9,26 @@ import std.datetime : DateTime;
|
||||||
import std.exception : enforce;
|
import std.exception : enforce;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snapshot reader and lifecycle helper.
|
* Чтение снимка и управление его жизненным циклом.
|
||||||
*
|
*
|
||||||
* This class reconstructs full file content from chunked storage persisted
|
* Класс собирает полный файл из чанков, хранящихся через `DBLite`,
|
||||||
* via `DBLite`, verifies integrity (per-chunk SHA-256 and final file hash),
|
* проверяет целостность (SHA-256 каждого чанка и итогового файла)
|
||||||
* and provides a safe way to remove a snapshot record.
|
* и предоставляет безопасное удаление записи о снимке.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Пример:
|
||||||
* ---
|
* ---
|
||||||
* auto s1 = new Snapshot(db, snapshotId);
|
* auto s1 = new Snapshot(db, snapshotId);
|
||||||
* auto bytes = s1.data(); // materialize full content in memory
|
* auto bytes = s1.data(); // материализовать весь контент в память
|
||||||
*
|
*
|
||||||
* // or stream into a sink to avoid large allocations:
|
* // или потоково, без крупной аллокации:
|
||||||
* s1.data((const(ubyte)[] part) {
|
* s1.data((const(ubyte)[] part) {
|
||||||
* // consume part
|
* // обработать part
|
||||||
* });
|
* });
|
||||||
* ---
|
* ---
|
||||||
*
|
*
|
||||||
* Notes:
|
* Заметки:
|
||||||
* - All integrity checks are enforced; any mismatch throws.
|
* - Все проверки целостности обязательны; любое расхождение вызывает исключение.
|
||||||
* - `data(void delegate(...))` is preferred for very large files.
|
* - Для очень больших файлов предпочтительнее потоковый вариант с делегатом.
|
||||||
*/
|
*/
|
||||||
final class Snapshot
|
final class Snapshot
|
||||||
{
|
{
|
||||||
|
@ -36,55 +36,57 @@ private:
|
||||||
DBLite _db;
|
DBLite _db;
|
||||||
DBSnapshot _snapshot;
|
DBSnapshot _snapshot;
|
||||||
|
|
||||||
|
/// Возвращает исходные байты чанка с учётом возможного сжатия и проверкой хеша.
|
||||||
const(ubyte)[] getBytes(const ref DBSnapshotChunkData chunk)
|
const(ubyte)[] getBytes(const ref DBSnapshotChunkData chunk)
|
||||||
{
|
{
|
||||||
ubyte[] bytes;
|
ubyte[] bytes;
|
||||||
if (chunk.zstd)
|
if (chunk.zstd)
|
||||||
{
|
{
|
||||||
enforce(chunk.zSize == chunk.content.length, "Compressed chunk size does not match the expected value");
|
enforce(chunk.zSize == chunk.content.length,
|
||||||
|
"Размер сжатого чанка не совпадает с ожидаемым значением");
|
||||||
bytes = cast(ubyte[]) uncompress(chunk.content);
|
bytes = cast(ubyte[]) uncompress(chunk.content);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
bytes = chunk.content.dup;
|
bytes = chunk.content.dup;
|
||||||
}
|
}
|
||||||
enforce(chunk.size == bytes.length, "Original size does not match the expected value");
|
enforce(chunk.size == bytes.length, "Исходный размер чанка не совпадает с ожидаемым значением");
|
||||||
enforce(chunk.sha256 == digest!SHA256(bytes), "Chunk hash does not match");
|
enforce(chunk.sha256 == digest!SHA256(bytes), "Хеш чанка не совпадает");
|
||||||
|
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
public:
|
public:
|
||||||
/// Construct a `Snapshot` from an already fetched `DBSnapshot` row.
|
/// Создать `Snapshot` из уже загруженной строки `DBSnapshot`.
|
||||||
///
|
///
|
||||||
/// Params:
|
/// Параметры:
|
||||||
/// dblite = database handle
|
/// dblite = хэндл базы данных
|
||||||
/// dbSnapshot = snapshot row (metadata) previously retrieved
|
/// dbSnapshot = метаданные снимка, полученные ранее
|
||||||
this(DBLite dblite, DBSnapshot dbSnapshot)
|
this(DBLite dblite, DBSnapshot dbSnapshot)
|
||||||
{
|
{
|
||||||
_db = dblite;
|
_db = dblite;
|
||||||
_snapshot = dbSnapshot;
|
_snapshot = dbSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Construct a `Snapshot` by loading metadata from the database.
|
/// Создать `Snapshot`, подгрузив метаданные из базы.
|
||||||
///
|
///
|
||||||
/// Params:
|
/// Параметры:
|
||||||
/// dblite = database handle
|
/// dblite = хэндл базы данных
|
||||||
/// idSnapshot = snapshot id to load
|
/// idSnapshot = идентификатор снимка
|
||||||
this(DBLite dblite, long idSnapshot)
|
this(DBLite dblite, long idSnapshot)
|
||||||
{
|
{
|
||||||
_db = dblite;
|
_db = dblite;
|
||||||
_snapshot = _db.getSnapshot(idSnapshot);
|
_snapshot = _db.getSnapshot(idSnapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Materialize the full file content in memory.
|
/// Материализует полный контент файла в память.
|
||||||
///
|
///
|
||||||
/// Reassembles all chunks in order, validates each chunk SHA-256 and the
|
/// Собирает чанки по порядку, проверяет SHA-256 каждого чанка и
|
||||||
/// final file SHA-256 (`snapshots.sha256`).
|
/// итоговый SHA-256 файла (`snapshots.sha256`).
|
||||||
///
|
///
|
||||||
/// Returns: full file content as a newly allocated `ubyte[]`
|
/// Возвращает: новый буфер `ubyte[]` с полным содержимым.
|
||||||
///
|
///
|
||||||
/// Throws: Exception on any integrity check failure
|
/// Бросает: Exception при любой ошибке целостности.
|
||||||
ubyte[] data()
|
ubyte[] data()
|
||||||
{
|
{
|
||||||
auto chunks = _db.getChunks(_snapshot.id);
|
auto chunks = _db.getChunks(_snapshot.id);
|
||||||
|
@ -100,20 +102,20 @@ public:
|
||||||
fctx.put(bytes);
|
fctx.put(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
enforce(_snapshot.sha256 == fctx.finish(), "File hash does not match");
|
enforce(_snapshot.sha256 == fctx.finish(), "Хеш итогового файла не совпадает");
|
||||||
|
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stream the full file content into a caller-provided sink.
|
/// Потоково передаёт содержимое файла в заданный приёмник.
|
||||||
///
|
///
|
||||||
/// This variant avoids allocating a single large buffer. Chunks are
|
/// Избегает одной большой аллокации: чанк декодируется, проверяется
|
||||||
/// decoded, verified, and passed to `sink` in order.
|
/// и передаётся в `sink` по порядку.
|
||||||
///
|
///
|
||||||
/// Params:
|
/// Параметры:
|
||||||
/// sink = delegate invoked for each verified chunk (may be called many times)
|
/// sink = делегат, вызываемый для каждого проверенного чанка.
|
||||||
///
|
///
|
||||||
/// Throws: Exception on any integrity check failure
|
/// Бросает: Exception при любой ошибке целостности.
|
||||||
void data(void delegate(const(ubyte)[]) sink)
|
void data(void delegate(const(ubyte)[]) sink)
|
||||||
{
|
{
|
||||||
auto chunks = _db.getChunks(_snapshot.id);
|
auto chunks = _db.getChunks(_snapshot.id);
|
||||||
|
@ -126,17 +128,17 @@ public:
|
||||||
fctx.put(bytes);
|
fctx.put(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
enforce(_snapshot.sha256 == fctx.finish(), "File hash does not match");
|
enforce(_snapshot.sha256 == fctx.finish(), "Хеш итогового файла не совпадает");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove this snapshot from the database inside a transaction.
|
/// Удаляет снимок из базы в транзакции.
|
||||||
///
|
///
|
||||||
/// Starts an IMMEDIATE transaction, deletes the snapshot row, and commits.
|
/// Открывает транзакцию IMMEDIATE, удаляет запись о снимке и коммитит.
|
||||||
/// On any failure it rolls back.
|
/// В случае ошибки откатывает.
|
||||||
///
|
///
|
||||||
/// Returns: `true` if the snapshot row was deleted, `false` otherwise
|
/// Возвращает: `true`, если запись была удалена.
|
||||||
///
|
///
|
||||||
/// Note: Does not garbage-collect unreferenced blobs; perform that separately.
|
/// Примечание: не выполняет сборку мусора по блобам.
|
||||||
bool remove()
|
bool remove()
|
||||||
{
|
{
|
||||||
_db.beginImmediate();
|
_db.beginImmediate();
|
||||||
|
@ -160,37 +162,41 @@ public:
|
||||||
return _snapshot.id == idDeleted;
|
return _snapshot.id == idDeleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot id (primary key).
|
// -----------------------------
|
||||||
|
// Доступ к метаданным снимка
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
/// ID снимка (PRIMARY KEY).
|
||||||
@property long id() const nothrow @safe
|
@property long id() const nothrow @safe
|
||||||
{
|
{
|
||||||
return _snapshot.id;
|
return _snapshot.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// User-defined label.
|
/// Имя файла (из таблицы `files`).
|
||||||
@property string file() const @safe
|
@property string file() const nothrow @safe
|
||||||
{
|
{
|
||||||
return _snapshot.file;
|
return _snapshot.file;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creation timestamp (UTC) from the database.
|
/// Время создания (UTC).
|
||||||
@property DateTime created() const @safe
|
@property DateTime created() const @safe
|
||||||
{
|
{
|
||||||
return _snapshot.createdUtc;
|
return _snapshot.createdUtc;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Original file length in bytes.
|
/// Длина исходного файла (байты).
|
||||||
@property long length() const nothrow @safe
|
@property long length() const nothrow @safe
|
||||||
{
|
{
|
||||||
return _snapshot.sourceLength;
|
return _snapshot.sourceLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expected SHA-256 of the full file (32 raw bytes).
|
/// Ожидаемый SHA-256 всего файла (сырые 32 байта).
|
||||||
@property ubyte[32] sha256() const nothrow @safe
|
@property ubyte[32] sha256() const nothrow @safe
|
||||||
{
|
{
|
||||||
return _snapshot.sha256;
|
return _snapshot.sha256;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot status as a string (enum to string).
|
/// Статус снимка (строкой).
|
||||||
@property string status() const
|
@property string status() const
|
||||||
{
|
{
|
||||||
import std.conv : to;
|
import std.conv : to;
|
||||||
|
@ -198,9 +204,81 @@ public:
|
||||||
return _snapshot.status.to!string;
|
return _snapshot.status.to!string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Optional human-readable description.
|
/// Необязательное описание.
|
||||||
@property string description() const nothrow @safe
|
@property string description() const nothrow @safe
|
||||||
{
|
{
|
||||||
return _snapshot.description;
|
return _snapshot.description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// FastCDC: минимальный размер чанка.
|
||||||
|
@property long algoMin() const nothrow @safe
|
||||||
|
{
|
||||||
|
return _snapshot.algoMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FastCDC: целевой (нормальный) размер чанка.
|
||||||
|
@property long algoNormal() const nothrow @safe
|
||||||
|
{
|
||||||
|
return _snapshot.algoNormal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FastCDC: максимальный размер чанка.
|
||||||
|
@property long algoMax() const nothrow @safe
|
||||||
|
{
|
||||||
|
return _snapshot.algoMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FastCDC: строгая маска.
|
||||||
|
@property long maskS() const nothrow @safe
|
||||||
|
{
|
||||||
|
return _snapshot.maskS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FastCDC: слабая маска.
|
||||||
|
@property long maskL() const nothrow @safe
|
||||||
|
{
|
||||||
|
return _snapshot.maskL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UID процесса (effective).
|
||||||
|
@property long uid() const nothrow @safe
|
||||||
|
{
|
||||||
|
return _snapshot.uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real UID процесса.
|
||||||
|
@property long ruid() const nothrow @safe
|
||||||
|
{
|
||||||
|
return _snapshot.ruid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Имя пользователя для `uid`.
|
||||||
|
@property string uidName() const nothrow @safe
|
||||||
|
{
|
||||||
|
return _snapshot.uidName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Имя пользователя для `ruid`.
|
||||||
|
@property string ruidName() const nothrow @safe
|
||||||
|
{
|
||||||
|
return _snapshot.ruidName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Имя процесса (из таблицы `processes`).
|
||||||
|
@property string process() const nothrow @safe
|
||||||
|
{
|
||||||
|
return _snapshot.process;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Удобный флаг: снимок «готов».
|
||||||
|
@property bool isReady() const nothrow @safe
|
||||||
|
{
|
||||||
|
return _snapshot.status == SnapshotStatus.ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Удобный флаг: снимок «в процессе».
|
||||||
|
@property bool isPending() const nothrow @safe
|
||||||
|
{
|
||||||
|
return _snapshot.status == SnapshotStatus.pending;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,43 +6,45 @@ import cdcdb.snapshot;
|
||||||
|
|
||||||
import zstd : compress, Level;
|
import zstd : compress, Level;
|
||||||
|
|
||||||
struct Context {
|
/// Контекст создания снимка (идентификаторы и процесс).
|
||||||
long uid;
|
struct Context
|
||||||
long ruid;
|
{
|
||||||
string uidName;
|
long uid; /// UID процесса (effective).
|
||||||
string ruidName;
|
long ruid; /// Real UID процесса.
|
||||||
string process;
|
string uidName; /// Имя пользователя для UID.
|
||||||
|
string ruidName; /// Имя пользователя для RUID.
|
||||||
|
string process; /// Имя процесса.
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* High-level storage facade: splits data into CDC chunks, stores chunks/blobs
|
* Высокоуровневый фасад для хранения: разбивает данные на чанки CDC,
|
||||||
* into SQLite via `DBLite`, links them into snapshots, and returns `Snapshot`
|
* сохраняет чанки/блобы в SQLite через `DBLite`, связывает их в снимки
|
||||||
* objects for retrieval and deletion.
|
* и возвращает объекты `Snapshot` для последующего чтения и удаления.
|
||||||
*
|
*
|
||||||
* Features:
|
* Возможности:
|
||||||
* - FastCDC-based content-defined chunking (configurable sizes/masks)
|
* - Разбиение FastCDC (контентно-зависимое, настраиваемые размеры/маски).
|
||||||
* - Optional Zstandard compression (level configurable)
|
* - Опциональное сжатие Zstandard (уровень задаётся).
|
||||||
* - Idempotent snapshot creation: skips if identical to the latest for label
|
* - Идемпотентное создание снимков: пропускает, если последний снимок совпадает.
|
||||||
*
|
*
|
||||||
* Typical usage:
|
* Типичное использование:
|
||||||
* ---
|
* ---
|
||||||
* auto store = new Storage("cdc.sqlite", true, Level.default_);
|
* auto store = new Storage("base.db", true, Level.max);
|
||||||
* store.setupCDC(4096, 8192, 16384, 0x3FFF, 0x03FF);
|
* store.setupCDC(4096, 8192, 16384, 0x3FFF, 0x03FF);
|
||||||
|
* Context ctx;
|
||||||
|
* auto snap = store.newSnapshot("my.txt", data, ctx, "первичный импорт");
|
||||||
|
* auto bytes = snap.data(); // восстановить содержимое
|
||||||
*
|
*
|
||||||
* auto snap = store.newSnapshot("my.txt", data, "initial import");
|
* auto removed = store.removeSnapshots("my.txt"); // удалить по имени файла
|
||||||
* auto bytes = snap.data(); // retrieve
|
|
||||||
*
|
|
||||||
* auto removed = store.removeSnapshots("my.txt"); // remove by label
|
|
||||||
* ---
|
* ---
|
||||||
*/
|
*/
|
||||||
final class Storage
|
final class Storage
|
||||||
{
|
{
|
||||||
private:
|
private:
|
||||||
// Database parameters
|
// Параметры БД
|
||||||
DBLite _db;
|
DBLite _db;
|
||||||
bool _zstd;
|
bool _zstd;
|
||||||
int _level;
|
int _level;
|
||||||
// CDC settings
|
// Настройки CDC
|
||||||
CDC _cdc;
|
CDC _cdc;
|
||||||
size_t _minSize;
|
size_t _minSize;
|
||||||
size_t _normalSize;
|
size_t _normalSize;
|
||||||
|
@ -50,6 +52,7 @@ private:
|
||||||
size_t _maskS;
|
size_t _maskS;
|
||||||
size_t _maskL;
|
size_t _maskL;
|
||||||
|
|
||||||
|
/// Инициализация параметров FastCDC.
|
||||||
void initCDC(size_t minSize = 256, size_t normalSize = 512, size_t maxSize = 1024,
|
void initCDC(size_t minSize = 256, size_t normalSize = 512, size_t maxSize = 1024,
|
||||||
size_t maskS = 0xFF, size_t maskL = 0x0F)
|
size_t maskS = 0xFF, size_t maskL = 0x0F)
|
||||||
{
|
{
|
||||||
|
@ -58,20 +61,21 @@ private:
|
||||||
_maxSize = maxSize;
|
_maxSize = maxSize;
|
||||||
_maskS = maskS;
|
_maskS = maskS;
|
||||||
_maskL = maskL;
|
_maskL = maskL;
|
||||||
// CDC holds no dynamically allocated state; reinitialization is safe
|
// CDC не хранит динамического состояния, переинициализация безопасна
|
||||||
_cdc = new CDC(_minSize, _normalSize, _maxSize, _maskS, _maskL);
|
_cdc = new CDC(_minSize, _normalSize, _maxSize, _maskS, _maskL);
|
||||||
}
|
}
|
||||||
|
|
||||||
public:
|
public:
|
||||||
/// Construct the storage facade and open (or create) the database.
|
/// Конструктор: открывает/создаёт БД и подготавливает фасад.
|
||||||
///
|
///
|
||||||
/// Params:
|
/// Параметры:
|
||||||
/// database = path to SQLite file
|
/// database = путь к файлу SQLite
|
||||||
/// zstd = enable Zstandard compression for stored blobs
|
/// zstd = включить Zstd-сжатие для блобов
|
||||||
/// level = Zstd compression level (see `zstd.Level`)
|
/// level = уровень сжатия (см. `zstd.Level`)
|
||||||
/// busyTimeout = SQLite busy timeout in milliseconds
|
/// busyTimeout = таймаут ожидания блокировки SQLite (мс)
|
||||||
/// maxRetries = max retries on SQLITE_BUSY/LOCKED errors
|
/// maxRetries = число повторов при SQLITE_BUSY/LOCKED
|
||||||
this(string database, bool zstd = false, int level = Level.base, size_t busyTimeout = 3000, size_t maxRetries = 3)
|
this(string database, bool zstd = false, int level = Level.base,
|
||||||
|
size_t busyTimeout = 3000, size_t maxRetries = 3)
|
||||||
{
|
{
|
||||||
_db = new DBLite(database, busyTimeout, maxRetries);
|
_db = new DBLite(database, busyTimeout, maxRetries);
|
||||||
_zstd = zstd;
|
_zstd = zstd;
|
||||||
|
@ -79,49 +83,46 @@ public:
|
||||||
initCDC();
|
initCDC();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconfigure CDC parameters (takes effect for subsequent snapshots).
|
/// Перенастроить параметры CDC (влияет на будущие снимки).
|
||||||
///
|
void setupCDC(size_t minSize, size_t normalSize, size_t maxSize,
|
||||||
/// Params:
|
size_t maskS, size_t maskL)
|
||||||
/// minSize, normalSize, maxSize, maskS, maskL = FastCDC parameters
|
|
||||||
void setupCDC(size_t minSize, size_t normalSize, size_t maxSize, size_t maskS, size_t maskL)
|
|
||||||
{
|
{
|
||||||
initCDC(minSize, normalSize, maxSize, maskS, maskL);
|
initCDC(minSize, normalSize, maxSize, maskS, maskL);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new snapshot from raw data.
|
/// Создаёт новый снимок из массива байт.
|
||||||
///
|
///
|
||||||
/// - Splits data with FastCDC using current settings.
|
/// - Разбивает данные по текущим параметрам FastCDC.
|
||||||
/// - Optionally compresses chunks with Zstd.
|
/// - Опционально сжимает чанки Zstd.
|
||||||
/// - Stores unique blobs and links them to the created snapshot.
|
/// - Сохраняет уникальные блобы и связывает их со снимком.
|
||||||
/// - If the latest snapshot for `label` already has the same file SHA-256,
|
/// - Если последний снимок для файла совпадает по SHA-256, возвращает `null`.
|
||||||
/// returns `null` (idempotent).
|
|
||||||
///
|
///
|
||||||
/// Params:
|
/// Параметры:
|
||||||
/// label = user-provided snapshot label (file identifier)
|
/// file = имя файла (метка снимка)
|
||||||
/// data = raw file bytes
|
/// data = содержимое файла
|
||||||
/// description = optional human-readable description
|
/// context = контекст (uid, ruid, процесс и т.д.)
|
||||||
|
/// description = необязательное описание
|
||||||
///
|
///
|
||||||
/// Returns: a `Snapshot` instance for the created snapshot, or `null`
|
/// Возвращает: объект `Snapshot` или `null`
|
||||||
///
|
///
|
||||||
/// Throws:
|
/// Исключения: при пустых данных или ошибках базы
|
||||||
/// Exception if `data` is empty or on database/storage errors
|
Snapshot newSnapshot(string file, const(ubyte)[] data, Context context,
|
||||||
Snapshot newSnapshot(string file, const(ubyte)[] data, Context context, string description = string.init)
|
string description = string.init)
|
||||||
{
|
{
|
||||||
if (data.length == 0)
|
if (data.length == 0)
|
||||||
{
|
{
|
||||||
throw new Exception("Data has zero length");
|
throw new Exception("Данные имеют нулевую длину");
|
||||||
}
|
}
|
||||||
|
|
||||||
import std.digest.sha : SHA256, digest;
|
import std.digest.sha : SHA256, digest;
|
||||||
|
|
||||||
ubyte[32] sha256 = digest!SHA256(data);
|
ubyte[32] sha256 = digest!SHA256(data);
|
||||||
|
|
||||||
// If the last snapshot for the label matches current content
|
// Если последний снимок совпадает — пропустить
|
||||||
if (_db.isLast(file, sha256))
|
if (_db.isLast(file, sha256))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
_db.beginImmediate();
|
_db.beginImmediate();
|
||||||
|
|
||||||
bool ok;
|
bool ok;
|
||||||
|
|
||||||
scope (exit)
|
scope (exit)
|
||||||
|
@ -134,16 +135,17 @@ public:
|
||||||
_db.commit();
|
_db.commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Запись пользователей/файлов/процессов
|
||||||
_db.addUser(context.uid, context.uidName);
|
_db.addUser(context.uid, context.uidName);
|
||||||
if (context.uid != context.ruid) {
|
if (context.uid != context.ruid)
|
||||||
|
{
|
||||||
_db.addUser(context.ruid, context.ruidName);
|
_db.addUser(context.ruid, context.ruidName);
|
||||||
}
|
}
|
||||||
|
|
||||||
_db.addFile(file);
|
_db.addFile(file);
|
||||||
_db.addProcess(context.process);
|
_db.addProcess(context.process);
|
||||||
|
|
||||||
|
// Метаданные снимка
|
||||||
DBSnapshot dbSnapshot;
|
DBSnapshot dbSnapshot;
|
||||||
|
|
||||||
dbSnapshot.file = file;
|
dbSnapshot.file = file;
|
||||||
dbSnapshot.sha256 = sha256;
|
dbSnapshot.sha256 = sha256;
|
||||||
dbSnapshot.description = description;
|
dbSnapshot.description = description;
|
||||||
|
@ -153,22 +155,19 @@ public:
|
||||||
dbSnapshot.algoMax = _maxSize;
|
dbSnapshot.algoMax = _maxSize;
|
||||||
dbSnapshot.maskS = _maskS;
|
dbSnapshot.maskS = _maskS;
|
||||||
dbSnapshot.maskL = _maskL;
|
dbSnapshot.maskL = _maskL;
|
||||||
|
|
||||||
dbSnapshot.uid = context.uid;
|
dbSnapshot.uid = context.uid;
|
||||||
dbSnapshot.ruid = context.ruid;
|
dbSnapshot.ruid = context.ruid;
|
||||||
dbSnapshot.process = context.process;
|
dbSnapshot.process = context.process;
|
||||||
|
|
||||||
auto idSnapshot = _db.addSnapshot(dbSnapshot);
|
auto idSnapshot = _db.addSnapshot(dbSnapshot);
|
||||||
|
|
||||||
|
// Чанки и блобы
|
||||||
DBSnapshotChunk dbSnapshotChunk;
|
DBSnapshotChunk dbSnapshotChunk;
|
||||||
DBBlob dbBlob;
|
DBBlob dbBlob;
|
||||||
|
|
||||||
dbBlob.zstd = _zstd;
|
dbBlob.zstd = _zstd;
|
||||||
|
|
||||||
// Split into chunks
|
|
||||||
Chunk[] chunks = _cdc.split(data);
|
Chunk[] chunks = _cdc.split(data);
|
||||||
|
|
||||||
// Write chunks to DB
|
|
||||||
foreach (chunk; chunks)
|
foreach (chunk; chunks)
|
||||||
{
|
{
|
||||||
dbBlob.sha256 = chunk.sha256;
|
dbBlob.sha256 = chunk.sha256;
|
||||||
|
@ -176,7 +175,8 @@ public:
|
||||||
|
|
||||||
auto content = data[chunk.offset .. chunk.offset + chunk.size];
|
auto content = data[chunk.offset .. chunk.offset + chunk.size];
|
||||||
|
|
||||||
if (_zstd) {
|
if (_zstd)
|
||||||
|
{
|
||||||
ubyte[] zBytes = compress(content, _level);
|
ubyte[] zBytes = compress(content, _level);
|
||||||
size_t zSize = zBytes.length;
|
size_t zSize = zBytes.length;
|
||||||
ubyte[32] zHash = digest!SHA256(zBytes);
|
ubyte[32] zHash = digest!SHA256(zBytes);
|
||||||
|
@ -184,11 +184,12 @@ public:
|
||||||
dbBlob.zSize = zSize;
|
dbBlob.zSize = zSize;
|
||||||
dbBlob.zSha256 = zHash;
|
dbBlob.zSha256 = zHash;
|
||||||
dbBlob.content = zBytes;
|
dbBlob.content = zBytes;
|
||||||
} else {
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
dbBlob.content = content.dup;
|
dbBlob.content = content.dup;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store/ensure blob
|
|
||||||
_db.addBlob(dbBlob);
|
_db.addBlob(dbBlob);
|
||||||
|
|
||||||
dbSnapshotChunk.snapshotId = idSnapshot;
|
dbSnapshotChunk.snapshotId = idSnapshot;
|
||||||
|
@ -196,76 +197,49 @@ public:
|
||||||
dbSnapshotChunk.offset = chunk.offset;
|
dbSnapshotChunk.offset = chunk.offset;
|
||||||
dbSnapshotChunk.sha256 = chunk.sha256;
|
dbSnapshotChunk.sha256 = chunk.sha256;
|
||||||
|
|
||||||
// Link chunk to snapshot
|
|
||||||
_db.addSnapshotChunk(dbSnapshotChunk);
|
_db.addSnapshotChunk(dbSnapshotChunk);
|
||||||
}
|
}
|
||||||
|
|
||||||
ok = true;
|
ok = true;
|
||||||
|
|
||||||
Snapshot snapshot = new Snapshot(_db, idSnapshot);
|
|
||||||
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete snapshots by label.
|
|
||||||
///
|
|
||||||
/// Params:
|
|
||||||
/// label = snapshot label
|
|
||||||
///
|
|
||||||
/// Returns: number of deleted snapshots
|
|
||||||
long removeSnapshots(string file) {
|
|
||||||
return _db.deleteSnapshots(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a specific snapshot instance.
|
|
||||||
///
|
|
||||||
/// Params:
|
|
||||||
/// snapshot = `Snapshot` to remove
|
|
||||||
///
|
|
||||||
/// Returns: `true` on success, `false` otherwise
|
|
||||||
bool removeSnapshot(Snapshot snapshot) {
|
|
||||||
return removeSnapshot(snapshot.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a snapshot by id.
|
|
||||||
///
|
|
||||||
/// Params:
|
|
||||||
/// idSnapshot = snapshot id
|
|
||||||
///
|
|
||||||
/// Returns: `true` if the row was deleted
|
|
||||||
bool removeSnapshot(long idSnapshot) {
|
|
||||||
return _db.deleteSnapshot(idSnapshot) == idSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a `Snapshot` object by id.
|
|
||||||
///
|
|
||||||
/// Params:
|
|
||||||
/// idSnapshot = snapshot id
|
|
||||||
///
|
|
||||||
/// Returns: `Snapshot` handle (metadata loaded lazily via constructor)
|
|
||||||
Snapshot getSnapshot(long idSnapshot) {
|
|
||||||
return new Snapshot(_db, idSnapshot);
|
return new Snapshot(_db, idSnapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List snapshots (optionally filtered by label).
|
/// Удаляет все снимки по имени файла.
|
||||||
///
|
long removeSnapshots(string file)
|
||||||
/// Params:
|
{
|
||||||
/// label = filter by exact label; empty string returns all
|
return _db.deleteSnapshots(file);
|
||||||
///
|
|
||||||
/// Returns: array of `Snapshot` handles
|
|
||||||
Snapshot[] getSnapshots(string file = string.init) {
|
|
||||||
Snapshot[] snapshots;
|
|
||||||
|
|
||||||
foreach (snapshot; _db.getSnapshots(file)) {
|
|
||||||
snapshots ~= new Snapshot(_db, snapshot);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Удаляет конкретный снимок по объекту `Snapshot`.
|
||||||
|
bool removeSnapshot(Snapshot snapshot)
|
||||||
|
{
|
||||||
|
return removeSnapshot(snapshot.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Удаляет снимок по id.
|
||||||
|
bool removeSnapshot(long idSnapshot)
|
||||||
|
{
|
||||||
|
return _db.deleteSnapshot(idSnapshot) == idSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Возвращает `Snapshot` по id.
|
||||||
|
Snapshot getSnapshot(long idSnapshot)
|
||||||
|
{
|
||||||
|
return new Snapshot(_db, idSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Возвращает список снимков (опционально фильтр по имени файла).
|
||||||
|
Snapshot[] getSnapshots(string file = string.init)
|
||||||
|
{
|
||||||
|
Snapshot[] snapshots;
|
||||||
|
foreach (snapshot; _db.getSnapshots(file))
|
||||||
|
{
|
||||||
|
snapshots ~= new Snapshot(_db, snapshot);
|
||||||
|
}
|
||||||
return snapshots;
|
return snapshots;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Library version string.
|
/// Версия библиотеки.
|
||||||
///
|
|
||||||
/// Returns: semantic version of the `cdcdb` library
|
|
||||||
string getVersion() const @safe nothrow
|
string getVersion() const @safe nothrow
|
||||||
{
|
{
|
||||||
import cdcdb.version_ : cdcdbVersion;
|
import cdcdb.version_ : cdcdbVersion;
|
||||||
|
|
|
@ -1,72 +0,0 @@
|
||||||
import cdcdb;
|
|
||||||
|
|
||||||
import std.file : read, write, remove, exists;
|
|
||||||
import std.path : buildPath;
|
|
||||||
import std.digest.sha : digest, SHA256;
|
|
||||||
import std.exception : assertThrown, assertNotThrown;
|
|
||||||
import std.datetime : DateTime;
|
|
||||||
import core.thread : Thread;
|
|
||||||
import core.time : msecs, seconds;
|
|
||||||
|
|
||||||
unittest
|
|
||||||
{
|
|
||||||
const string dbPath = "./bin/test_cdcdb.db";
|
|
||||||
|
|
||||||
if (exists(dbPath)) {
|
|
||||||
remove(dbPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Тест конструктора Storage
|
|
||||||
auto storage = new Storage(dbPath, true, 22);
|
|
||||||
|
|
||||||
// Тест настройки CDC
|
|
||||||
storage.setupCDC(128, 256, 512, 0xFF, 0x0F);
|
|
||||||
|
|
||||||
// Тест создания снимка
|
|
||||||
ubyte[] data1 = cast(ubyte[]) "Hello, World!".dup;
|
|
||||||
auto snap1 = storage.newSnapshot("test_label", data1, "First snapshot");
|
|
||||||
assert(snap1 !is null);
|
|
||||||
assert(snap1.label == "test_label");
|
|
||||||
assert(snap1.length == data1.length);
|
|
||||||
assert(snap1.sha256 == digest!SHA256(data1));
|
|
||||||
assert(snap1.status == "ready");
|
|
||||||
assert(snap1.description == "First snapshot");
|
|
||||||
|
|
||||||
// Тест дубликата (не должен создать новый)
|
|
||||||
auto snapDup = storage.newSnapshot("test_label", data1);
|
|
||||||
assert(snapDup is null);
|
|
||||||
|
|
||||||
// Тест изменения данных
|
|
||||||
ubyte[] data2 = cast(ubyte[]) "Hello, Changed World!".dup;
|
|
||||||
auto snap2 = storage.newSnapshot("test_label", data2);
|
|
||||||
assert(snap2 !is null);
|
|
||||||
assert(snap2.sha256 == digest!SHA256(data2));
|
|
||||||
|
|
||||||
// Тест восстановления данных
|
|
||||||
auto restored = snap1.data();
|
|
||||||
assert(restored == data1);
|
|
||||||
bool streamedOk = false;
|
|
||||||
snap2.data((const(ubyte)[] chunk) {
|
|
||||||
assert(chunk == data2); // Поскольку маленький файл — один фрагмент
|
|
||||||
streamedOk = true;
|
|
||||||
});
|
|
||||||
assert(streamedOk);
|
|
||||||
|
|
||||||
// Тест getSnapshots
|
|
||||||
auto snaps = storage.getSnapshots("test_label");
|
|
||||||
assert(snaps.length == 2);
|
|
||||||
assert(snaps[0].id == snap1.id);
|
|
||||||
assert(snaps[1].id == snap2.id);
|
|
||||||
|
|
||||||
auto allSnaps = storage.getSnapshots();
|
|
||||||
assert(allSnaps.length == 2);
|
|
||||||
|
|
||||||
// Тест удаления
|
|
||||||
assert(snap1.remove());
|
|
||||||
snaps = storage.getSnapshots("test_label");
|
|
||||||
assert(snaps.length == 1);
|
|
||||||
assert(snaps[0].id == snap2.id);
|
|
||||||
|
|
||||||
// Тест пустых данных
|
|
||||||
assertThrown!Exception(storage.newSnapshot("empty", []));
|
|
||||||
}
|
|
Loading…
Add table
Add a link
Reference in a new issue