diff --git a/CHANGELOG.ru.md b/CHANGELOG.ru.md deleted file mode 100644 index f292e6d..0000000 --- a/CHANGELOG.ru.md +++ /dev/null @@ -1,12 +0,0 @@ -# Changelog - -## [0.1.0] — 2025-09-13 -### Added -- Библиотека для снимков данных на базе SQLite с контентно-зависимым разбиением (FastCDC). -- Дедупликация по SHA-256 чанков, опциональная компрессия Zstd. -- Сквозная проверка целостности: хеш каждого чанка и итогового файла. -- Транзакции (WAL), базовые ограничения целостности и триггеры. -- Высокоуровневый API: - - `Storage`: `newSnapshot`, `getSnapshots`, `getSnapshot`, `removeSnapshots`, `setupCDC`, `getVersion`. - - `Snapshot`: `data()` (буфер) и потоковый `data(void delegate(const(ubyte)[]))`, `remove()`, свойства (`id`, `label`, `created`, `length`, `sha256`, `status`, `description`). -- Инструмент для генерации Gear-таблицы для FastCDC (`tools/gen.d`). diff --git a/README.ru.md b/README.ru.md deleted file mode 100644 index 28a99f7..0000000 --- a/README.ru.md +++ /dev/null @@ -1,98 +0,0 @@ -# cdcdb - -Библиотека для хранения и управления снимками текстовых данных в базе SQLite. Использует механизм content-defined chunking (CDC) на основе алгоритма FastCDC для разделения данных на чанки переменного размера, что обеспечивает эффективную дедупликацию. Поддерживает опциональную компрессию Zstd, транзакции и проверку целостности данных через SHA-256. Основное применение — резервное копирование и версионирование текстовых файлов с минимизацией занимаемого пространства. - -## Алгоритм FastCDC -FastCDC — это алгоритм разделения данных на чанки переменного размера, основанный на хэшировании содержимого. Он использует таблицу Gear для вычисления "отпечатков" данных, определяя точки разделения с учетом минимального, целевого и максимального размеров чанка. Это позволяет эффективно выявлять изменения в данных и хранить только уникальные чанки, снижая объем хранилища. - -## Основные классы - -### Storage -Класс для работы с базой SQLite и управления снимками. - -- **Конструктор**: Инициализирует подключение к базе SQLite. -- **Методы**: - - `newSnapshot`: Создает снимок данных. Возвращает объект `Snapshot` или `null`, если данные совпадают с последним снимком. - - `getSnapshots`: Получает список снимков (все или по метке). Возвращает массив объектов `Snapshot`. - - `getSnapshot`: Получает снимок по ID. Возвращает объект `Snapshot`. - - `setupCDC`: Настраивает параметры разделения данных CDC. Ничего не возвращает. - - `removeSnapshots`: Удаляет снимки по метке, ID или объекту Snapshot. Возвращает количество удаленных снимков (для метки) или `true`/`false` (для ID или объекта). - - `getVersion`: Возвращает строку с версией библиотеки. - -### Snapshot -Класс для работы с отдельным снимком данных. - -- **Конструктор**: Создает объект снимка для работы с данными по его ID. -- **Методы**: - - `data`: Извлекает полные данные снимка. Возвращает массив байтов (`ubyte[]`). - - `data`: Извлекает данные снимка потоково через делегат. Ничего не возвращает. - - `remove`: Удаляет снимок из базы. Возвращает `true` при успешном удалении, иначе `false`. - -- **Свойства**: - - `id`: ID снимка (long). - - `label`: Метка снимка (string). - - `created`: Временная метка создания (UTC, `DateTime`). - - `length`: Длина исходных данных (long). - - `sha256`: Хэш SHA-256 данных (ubyte[32]). - - `status`: Статус снимка ("pending" или "ready"). - - `description`: Описание снимка (string). - -## Пример использования -```d -import cdcdb; - -import std.stdio : writeln, File; -import std.file : exists, remove; - -void main() -{ - // Создаем базу - string dbPath = "example.db"; - - // Инициализация Storage с компрессией Zstd - auto storage = new Storage(dbPath, true, 22); - - // Создание снимка - ubyte[] data = cast(ubyte[]) "Hello, cdcdb!".dup; - auto snap = storage.newSnapshot("example_file", data, "Версия 1.0"); - if (snap) - { - writeln("Создан снимок: ID=", snap.id, ", Метка=", snap.label); - } - - // Восстановление данных - auto snapshots = storage.getSnapshots("example_file"); - if (snapshots.length > 0) - { - auto lastSnap = snapshots[0]; - File outFile = File("restored.txt", "wb"); - lastSnap.data((const(ubyte)[] chunk) => outFile.rawWrite(chunk)); - outFile.close(); - writeln("Данные восстановлены в restored.txt"); - } - - // Удаление снимков - long deleted = storage.removeSnapshots("example_file"); - writeln("Удалено снимков: ", deleted); -} -``` - -## Инструменты -В директории `tools` находится скрипт на D для создания таблицы Gear, используемой в алгоритме FastCDC. Он позволяет генерировать пользовательские таблицы хэшей для настройки разделения данных. Для создания новой таблицы выполните: - -```bash -chmod +x ./tools/gen.d -./tools/gen.d > ./source/gear.d -``` - -## Установка -- **В `dub.json`**: - ```json - "dependencies": { - "cdcdb": "~>0.1.0" - } - ``` -- **Сборка**: `dub build`. - -## Лицензия -Boost Software License 1.0 (BSL-1.0). diff --git a/source/cdcdb/dblite.d b/source/cdcdb/dblite.d index 3e01338..e64cf3f 100644 --- a/source/cdcdb/dblite.d +++ b/source/cdcdb/dblite.d @@ -17,7 +17,7 @@ enum SnapshotStatus : ubyte struct DBSnapshot { long id; - string label; + string file; ubyte[32] sha256; string description; DateTime createdUtc; @@ -28,6 +28,11 @@ struct DBSnapshot { long maskS; long maskL; SnapshotStatus status; + long uid; + long ruid; + string uidName; + string ruidName; + string process; } struct DBSnapshotChunk @@ -104,7 +109,8 @@ private: { SqliteResult queryResult = sql( q{ - WITH required(name) AS (VALUES ("snapshots"), ("blobs"), ("snapshot_chunks")) + WITH required(name) + AS (VALUES ("snapshots"), ("blobs"), ("snapshot_chunks"), ("users"), ("processes"), ("files")) SELECT name AS missing_table FROM required WHERE NOT EXISTS ( @@ -122,11 +128,11 @@ private: missingTables ~= row["missing_table"].to!string; } - enforce(missingTables.length == 0 || missingTables.length == 3, + enforce(missingTables.length == 0 || missingTables.length == 6, "Database is corrupted. Missing tables: " ~ missingTables.join(", ") ); - if (missingTables.length == 3) + if (missingTables.length == 6) { foreach (schemeQuery; _scheme) { @@ -172,17 +178,21 @@ public: sql("ROLLBACK"); } - bool isLast(string label, ubyte[] sha256) { + bool isLast(string file, ubyte[] sha256) { auto queryResult = sql( q{ SELECT COALESCE( - (SELECT (label = ? AND sha256 = ?) - FROM snapshots - ORDER BY created_utc DESC - LIMIT 1), + ( + SELECT (s.sha256 = ?2) + FROM snapshots s + JOIN files f ON f.id = s.file + WHERE f.name = ?1 + ORDER BY s.created_utc DESC + LIMIT 1 + ), 0 ) AS is_last; - }, label, sha256 + }, file, sha256 ); if (!queryResult.empty()) @@ -195,23 +205,34 @@ public: auto queryResult = sql( q{ INSERT INTO snapshots( - label, + file, sha256, description, source_length, + uid, + ruid, + process, algo_min, algo_normal, algo_max, mask_s, mask_l, status - ) VALUES (?,?,?,?,?,?,?,?,?,?) + ) + SELECT + (SELECT id FROM files WHERE name = ?), + ?,?,?,?,?, + (SELECT id FROM processes WHERE name = ?), + ?,?,?,?,?,? RETURNING id }, - snapshot.label, + snapshot.file, snapshot.sha256[], snapshot.description.length ? snapshot.description : null, snapshot.sourceLength, + snapshot.uid, + snapshot.ruid, + snapshot.process, snapshot.algoMin, snapshot.algoNormal, snapshot.algoMax, @@ -247,6 +268,43 @@ public: return !queryResult.empty(); } + bool addProcess(string name) + { + auto queryResult = sql( + q{ + INSERT INTO processes (name) VALUES (?) + ON CONFLICT(name) DO NOTHING + }, name + ); + + return !queryResult.empty(); + } + + bool addFile(string name) + { + auto queryResult = sql( + q{ + INSERT INTO files (name) VALUES (?) + ON CONFLICT(name) DO NOTHING + }, name + ); + + return !queryResult.empty(); + } + + bool addUser(long uid, string name) + { + auto queryResult = sql( + q{ + INSERT INTO users (uid, name) + VALUES (?, ?) + ON CONFLICT(uid) DO NOTHING; + }, uid, name + ); + + return !queryResult.empty(); + } + bool addSnapshotChunk(DBSnapshotChunk snapshotChunk) { auto queryResult = sql( @@ -268,9 +326,30 @@ public: { auto queryResult = sql( q{ - SELECT id, label, sha256, description, created_utc, source_length, - algo_min, algo_normal, algo_max, mask_s, mask_l, status - FROM snapshots WHERE id = ? + SELECT + s.id, + f.name file, + s.sha256, + s.description, + s.created_utc, + s.source_length, + s.uid, + s.ruid, + u.name uid_name, + r.name ruid_name, + p.name process, + s.algo_min, + s.algo_normal, + s.algo_max, + s.mask_s, + s.mask_l, + s.status + FROM snapshots s + JOIN processes p ON p.id = s.process + JOIN users u ON u.uid = s.uid + JOIN users r ON r.uid = s.ruid + JOIN files f ON f.id = s.file + WHERE s.id = ?; }, id ); @@ -281,7 +360,7 @@ public: auto data = queryResult.front(); snapshot.id = data["id"].to!long; - snapshot.label = data["label"].to!string; + snapshot.file = data["file"].to!string; snapshot.sha256 = cast(ubyte[]) data["sha256"].dup; snapshot.description = data["description"].to!string; snapshot.createdUtc = toDateTime(data["created_utc"].to!string); @@ -292,19 +371,45 @@ public: snapshot.maskS = data["mask_s"].to!long; snapshot.maskL = data["mask_l"].to!long; snapshot.status = cast(SnapshotStatus) data["status"].to!int; + snapshot.uid = data["uid"].to!long; + snapshot.ruid = data["ruid"].to!long; + snapshot.uidName = data["uid_name"].to!string; + snapshot.ruidName = data["ruid_name"].to!string; + snapshot.process = data["process"].to!string; } return snapshot; } - DBSnapshot[] getSnapshots(string label) + DBSnapshot[] getSnapshots(string file) { auto queryResult = sql( q{ - SELECT id, label, sha256, description, created_utc, source_length, - algo_min, algo_normal, algo_max, mask_s, mask_l, status - FROM snapshots WHERE (length(?) = 0 OR label = ?1); - }, label + SELECT + s.id, + f.name file, + s.sha256, + s.description, + s.created_utc, + s.source_length, + s.uid, + s.ruid, + u.name uid_name, + r.name ruid_name, + p.name process, + s.algo_min, + s.algo_normal, + s.algo_max, + s.mask_s, + s.mask_l, + s.status + FROM snapshots s + JOIN processes p ON p.id = s.process + JOIN users u ON u.uid = s.uid + JOIN users r ON r.uid = s.ruid + JOIN files f ON f.id = s.file AND (length(?) = 0 OR f.name = ?1) + ORDER BY s.created_utc, s.id; + }, file ); DBSnapshot[] snapshots; @@ -314,7 +419,7 @@ public: DBSnapshot snapshot; snapshot.id = row["id"].to!long; - snapshot.label = row["label"].to!string; + snapshot.file = row["file"].to!string; snapshot.sha256 = cast(ubyte[]) row["sha256"].dup; snapshot.description = row["description"].to!string; snapshot.createdUtc = toDateTime(row["created_utc"].to!string); @@ -325,6 +430,11 @@ public: snapshot.maskS = row["mask_s"].to!long; snapshot.maskL = row["mask_l"].to!long; snapshot.status = cast(SnapshotStatus) row["status"].to!int; + snapshot.uid = row["uid"].to!long; + snapshot.ruid = row["ruid"].to!long; + snapshot.uidName = row["uid_name"].to!string; + snapshot.ruidName = row["ruid_name"].to!string; + snapshot.process = row["process"].to!string; snapshots ~= snapshot; } @@ -375,13 +485,13 @@ public: return 0; } - long deleteSnapshot(string label) { + long deleteSnapshots(string file) { auto queryResult = sql( q{ DELETE FROM snapshots - WHERE label = ? + WHERE file = (SELECT id FROM files WHERE name = ?) RETURNING 1; - }, label); + }, file); if (!queryResult.empty()) { return queryResult.length.to!long; diff --git a/source/cdcdb/scheme.d b/source/cdcdb/scheme.d index b3b10c4..0c28718 100644 --- a/source/cdcdb/scheme.d +++ b/source/cdcdb/scheme.d @@ -1,4 +1,52 @@ auto _scheme = [ + q{ + -- ------------------------------------------------------------ + -- Таблица users + -- ------------------------------------------------------------ + CREATE TABLE IF NOT EXISTS users ( + -- Linux UID + uid INTEGER PRIMARY KEY, + -- текстовое представление пользователя + name TEXT NOT NULL UNIQUE + ) + }, + q{ + -- Индекс по имени пользователя + CREATE INDEX IF NOT EXISTS idx_users_name + ON users(name) + }, + q{ + -- ------------------------------------------------------------ + -- Таблица processes + -- ------------------------------------------------------------ + CREATE TABLE IF NOT EXISTS processes ( + -- идентификатор процесса + id INTEGER PRIMARY KEY AUTOINCREMENT, + -- имя процесса + name TEXT NOT NULL UNIQUE + ) + }, + q{ + -- Индекс по имени процесса + CREATE INDEX IF NOT EXISTS idx_processes_name + ON processes(name) + }, + q{ + -- ------------------------------------------------------------ + -- Таблица files + -- ------------------------------------------------------------ + CREATE TABLE IF NOT EXISTS files ( + -- идентификатор процесса + id INTEGER PRIMARY KEY AUTOINCREMENT, + -- имя процесса + name TEXT NOT NULL UNIQUE + ) + }, + q{ + -- Индекс по имени процесса + CREATE INDEX IF NOT EXISTS idx_processes_name + ON processes(name) + }, q{ -- ------------------------------------------------------------ -- Таблица snapshots @@ -6,8 +54,8 @@ auto _scheme = [ CREATE TABLE IF NOT EXISTS snapshots ( -- идентификатор снимка id INTEGER PRIMARY KEY AUTOINCREMENT, - -- метка/название снимка - label TEXT NOT NULL, + -- Файл + file INTEGER NOT NULL, -- SHA-256 всего файла (BLOB(32)) sha256 BLOB NOT NULL CHECK (length(sha256) = 32), -- Комментарий/описание @@ -16,6 +64,12 @@ auto _scheme = [ created_utc TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), -- длина исходного файла в байтах source_length INTEGER NOT NULL, + -- UID пользователя (эффективный) + uid INTEGER NOT NULL, + -- RUID пользователя (реальный) + ruid INTEGER NOT NULL, + -- Процесс + process INTEGER NOT NULL, -- FastCDC: минимальный размер чанка algo_min INTEGER NOT NULL, -- FastCDC: целевой размер чанка @@ -28,7 +82,24 @@ auto _scheme = [ mask_l INTEGER NOT NULL, -- 0=pending, 1=ready status INTEGER NOT NULL DEFAULT 0 - CHECK (status IN (0,1)) + CHECK (status IN (0,1)), + -- Внешние ключи + FOREIGN KEY (uid) + REFERENCES users(uid) + ON UPDATE RESTRICT + ON DELETE RESTRICT, + FOREIGN KEY (ruid) + REFERENCES users(uid) + ON UPDATE RESTRICT + ON DELETE RESTRICT, + FOREIGN KEY (process) + REFERENCES processes(id) + ON UPDATE RESTRICT + ON DELETE RESTRICT + FOREIGN KEY (file) + REFERENCES files(id) + ON UPDATE CASCADE + ON DELETE CASCADE ) }, q{ @@ -88,9 +159,9 @@ auto _scheme = [ ) }, q{ - -- Индекс для запросов вида: WHERE label=? AND sha256=? + -- Индекс для запросов вида: WHERE file=? AND sha256=? CREATE INDEX IF NOT EXISTS idx_snapshots_path_sha - ON snapshots(label, sha256) + ON snapshots(file, sha256) }, q{ -- Индекс для обратного поиска использования blob по sha256 @@ -215,11 +286,14 @@ auto _scheme = [ FOR EACH ROW WHEN NEW.id IS NOT OLD.id OR - NEW.label IS NOT OLD.label OR + NEW.file IS NOT OLD.file OR NEW.sha256 IS NOT OLD.sha256 OR NEW.description IS NOT OLD.description OR NEW.created_utc IS NOT OLD.created_utc OR NEW.source_length IS NOT OLD.source_length OR + NEW.uid IS NOT OLD.uid OR + NEW.ruid IS NOT OLD.ruid OR + NEW.process IS NOT OLD.process OR NEW.algo_min IS NOT OLD.algo_min OR NEW.algo_normal IS NOT OLD.algo_normal OR NEW.algo_max IS NOT OLD.algo_max OR @@ -249,5 +323,20 @@ auto _scheme = [ BEGIN SELECT RAISE(ABORT, "blobs: разрешён UPDATE только полей last_seen_utc и refcount"); END + }, + q{ + -- ------------------------------------------------------------ + -- Удаление записи из files, если удалён последний snapshot + -- ------------------------------------------------------------ + CREATE TRIGGER IF NOT EXISTS trg_snapshots_delete_file + AFTER DELETE ON snapshots + FOR EACH ROW + BEGIN + DELETE FROM files + WHERE id = OLD.file + AND NOT EXISTS ( + SELECT 1 FROM snapshots WHERE file = OLD.file + ); + END; } ]; diff --git a/source/cdcdb/snapshot.d b/source/cdcdb/snapshot.d index 49d6d33..15c11c4 100644 --- a/source/cdcdb/snapshot.d +++ b/source/cdcdb/snapshot.d @@ -167,9 +167,9 @@ public: } /// User-defined label. - @property string label() const @safe + @property string file() const @safe { - return _snapshot.label; + return _snapshot.file; } /// Creation timestamp (UTC) from the database. diff --git a/source/cdcdb/storage.d b/source/cdcdb/storage.d index 74df78d..2d83f19 100644 --- a/source/cdcdb/storage.d +++ b/source/cdcdb/storage.d @@ -6,6 +6,14 @@ import cdcdb.snapshot; import zstd : compress, Level; +struct Context { + long uid; + long ruid; + string uidName; + string ruidName; + string process; +} + /** * High-level storage facade: splits data into CDC chunks, stores chunks/blobs * into SQLite via `DBLite`, links them into snapshots, and returns `Snapshot` @@ -97,7 +105,7 @@ public: /// /// Throws: /// Exception if `data` is empty or on database/storage errors - Snapshot newSnapshot(string label, const(ubyte)[] data, string description = string.init) + Snapshot newSnapshot(string file, const(ubyte)[] data, Context context, string description = string.init) { if (data.length == 0) { @@ -109,21 +117,9 @@ public: ubyte[32] sha256 = digest!SHA256(data); // If the last snapshot for the label matches current content - if (_db.isLast(label, sha256)) + if (_db.isLast(file, sha256)) return null; - DBSnapshot dbSnapshot; - - dbSnapshot.label = label; - dbSnapshot.sha256 = sha256; - dbSnapshot.description = description; - dbSnapshot.sourceLength = data.length; - dbSnapshot.algoMin = _minSize; - dbSnapshot.algoNormal = _normalSize; - dbSnapshot.algoMax = _maxSize; - dbSnapshot.maskS = _maskS; - dbSnapshot.maskL = _maskL; - _db.beginImmediate(); bool ok; @@ -138,6 +134,30 @@ public: _db.commit(); } + _db.addUser(context.uid, context.uidName); + if (context.uid != context.ruid) { + _db.addUser(context.ruid, context.ruidName); + } + + _db.addFile(file); + _db.addProcess(context.process); + + DBSnapshot dbSnapshot; + + dbSnapshot.file = file; + dbSnapshot.sha256 = sha256; + dbSnapshot.description = description; + dbSnapshot.sourceLength = data.length; + dbSnapshot.algoMin = _minSize; + dbSnapshot.algoNormal = _normalSize; + dbSnapshot.algoMax = _maxSize; + dbSnapshot.maskS = _maskS; + dbSnapshot.maskL = _maskL; + + dbSnapshot.uid = context.uid; + dbSnapshot.ruid = context.ruid; + dbSnapshot.process = context.process; + auto idSnapshot = _db.addSnapshot(dbSnapshot); DBSnapshotChunk dbSnapshotChunk; @@ -193,8 +213,8 @@ public: /// label = snapshot label /// /// Returns: number of deleted snapshots - long removeSnapshots(string label) { - return _db.deleteSnapshot(label); + long removeSnapshots(string file) { + return _db.deleteSnapshots(file); } /// Delete a specific snapshot instance. @@ -203,8 +223,8 @@ public: /// snapshot = `Snapshot` to remove /// /// Returns: `true` on success, `false` otherwise - bool removeSnapshots(Snapshot snapshot) { - return removeSnapshots(snapshot.id); + bool removeSnapshot(Snapshot snapshot) { + return removeSnapshot(snapshot.id); } /// Delete a snapshot by id. @@ -213,7 +233,7 @@ public: /// idSnapshot = snapshot id /// /// Returns: `true` if the row was deleted - bool removeSnapshots(long idSnapshot) { + bool removeSnapshot(long idSnapshot) { return _db.deleteSnapshot(idSnapshot) == idSnapshot; } @@ -233,10 +253,10 @@ public: /// label = filter by exact label; empty string returns all /// /// Returns: array of `Snapshot` handles - Snapshot[] getSnapshots(string label = string.init) { + Snapshot[] getSnapshots(string file = string.init) { Snapshot[] snapshots; - foreach (snapshot; _db.getSnapshots(label)) { + foreach (snapshot; _db.getSnapshots(file)) { snapshots ~= new Snapshot(_db, snapshot); } diff --git a/test/app.d b/test/app.d index f39639e..e71c394 100644 --- a/test/app.d +++ b/test/app.d @@ -8,6 +8,18 @@ void main() // Создаем временную базу для примера string dbPath = "./bin/example.db"; + if (exists(dbPath)) { + remove(dbPath); + } + + Context context; + + context.uid = 1000; + context.ruid = 1001; + context.uidName = "user1"; + context.ruidName = "user2"; + context.process = "mcedit"; + // Инициализация Storage с компрессией Zstd auto storage = new Storage(dbPath, true, Level.speed); @@ -19,17 +31,17 @@ void main() ubyte[] data2 = cast(ubyte[]) "Hello, updated cdcdb!".dup; // Создание первого снимка - auto snap1 = storage.newSnapshot("example_file", data1, "Версия 1.0"); + auto snap1 = storage.newSnapshot("example_file", data1, context, "Версия 1.0"); if (snap1) { writeln("Создан снимок с ID: ", snap1.id); - writeln("Метка: ", snap1.label); + writeln("Файл: ", snap1.file); writeln("Размер: ", snap1.length, " байт"); writeln("Статус: ", snap1.status); } // Создание второго снимка (обновление) - auto snap2 = storage.newSnapshot("example_file", data2, "Версия 2.0"); + auto snap2 = storage.newSnapshot("example_file", data2, context, "Версия 2.0"); if (snap2) { writeln("Создан обновленный снимок с ID: ", snap2.id);