diff --git a/.gitignore b/.gitignore index 4a32f36..8fdd0e2 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,4 @@ cdcdb-test-* *.obj *.lst bin -/lib +lib diff --git a/dub.json b/dub.json index d435e7f..ac9ccba 100644 --- a/dub.json +++ b/dub.json @@ -7,7 +7,7 @@ "license": "BSL-1.0", "name": "cdcdb", "dependencies": { - "d2sqlite3": "~>1.0.0", + "arsd-official:sqlite": "~>12.0.0", "zstd": "~>0.2.1" }, "stringImportPaths": [ diff --git a/dub.selections.json b/dub.selections.json index 25dd8d4..caa3e75 100644 --- a/dub.selections.json +++ b/dub.selections.json @@ -2,7 +2,6 @@ "fileVersion": 1, "versions": { "arsd-official": "12.0.0", - "d2sqlite3": "1.0.0", "zstd": "0.2.1" } } diff --git a/source/cdcdb/dblite.d b/source/cdcdb/dblite.d index b0514e1..f5a0f6b 100644 --- a/source/cdcdb/dblite.d +++ b/source/cdcdb/dblite.d @@ -1,46 +1,32 @@ +/// Лёгкая обёртка над SQLite с повторными попытками, схемой БД +/// и удобными структурами данных для снимков/чанков/блобов. module cdcdb.dblite; -import d2sqlite3; +import arsd.sqlite : Sqlite, SqliteResult, DatabaseException; +import std.datetime : DateTime; import std.string : join, replace, toLower; import std.algorithm : canFind; import std.conv : to; import std.format : format; import std.exception : enforce; -import std.uuid : UUID, randomUUID; - -import cdcdb.lib; - -struct DBFile { - Identifier id; - string path; - - @trusted pure nothrow @nogc @property bool empty() const { - return id.empty(); - } -} - -struct DBProcess { - Identifier id; - string name; - - @trusted pure nothrow @nogc @property bool empty() const { - return id.empty(); - } -} +/// Статус снимка. +/// - `pending` — создаётся/заполняется; +/// - `ready` — полностью подготовлен. enum SnapshotStatus : ubyte { pending = 0, ready = 1 } +/// Запись о снимке в БД (агрегированные метаданные). struct DBSnapshot { - Identifier id; /// Идентификатор снимка. - DBFile file; /// Файл (таблица `files`). + long id; /// Идентификатор снимка. + string file; /// Имя файла (таблица `files`). ubyte[32] sha256; /// Хеш всего файла (SHA-256, 32 байта). string description; /// Описание/комментарий (может быть пустым). - UTS createdUtc; /// Время создания (UTC). + DateTime createdUtc; /// Время создания (UTC). long sourceLength; /// Длина исходного файла (байт). long algoMin; /// FastCDC: минимальный размер чанка. long algoNormal; /// FastCDC: нормальный (целевой) размер чанка. @@ -52,21 +38,19 @@ struct DBSnapshot { long ruid; /// Real UID процесса. string uidName; /// Имя пользователя для `uid`. string ruidName; /// Имя пользователя для `ruid`. - DBProcess process; /// Процесс (таблица `processes`). - - @trusted pure nothrow @nogc @property bool empty() const { - return id.empty(); - } + string process; /// Имя процесса (таблица `processes`). } +/// Связь снимка с чанками (индексы и хеши). struct DBSnapshotChunk { - Identifier snapshotId; /// ID снимка. - long chunkIndex; /// Порядковый номер чанка в снимке. - long offset; /// Смещение чанка в файле. - ubyte[32] sha256; /// Хеш чанка (SHA-256, 32 байта). + long snapshotId; /// ID снимка. + long chunkIndex; /// Порядковый номер чанка в снимке. + long offset; /// Смещение чанка в файле. + ubyte[32] sha256; /// Хеш чанка (SHA-256, 32 байта). } +/// Запись о блобе (уникальный чанк) в БД. struct DBBlob { ubyte[32] sha256; /// Хеш исходного содержимого. @@ -74,12 +58,14 @@ struct DBBlob long size; /// Размер исходного содержимого. long zSize; /// Размер сжатого содержимого. ubyte[] content; /// Контент (если хранится в БД). - UTS createdUtc; /// Время создания (UTC). - UTS lastSeenUtc; /// Последний доступ (UTC). + DateTime createdUtc; /// Время создания (UTC). + DateTime lastSeenUtc; /// Последний доступ (UTC). long refcount; /// Ссылки на блоб (сколькими снимками используется). bool zstd; /// Признак, что `content` хранится в сжатом виде. } +/// Расширенная выборка чанков для восстановления. +/// Содержит и метаданные, и (возможное) содержимое. struct DBSnapshotChunkData { long chunkIndex; /// Порядковый номер чанка. long offset; /// Смещение в файле. @@ -91,29 +77,23 @@ struct DBSnapshotChunkData { ubyte[32] zSha256; /// Хеш сжатого содержимого. } -final class DBLite +/// Простейший клиент SQLite с: +/// - автоматической инициализацией схемы (при пустой БД); +/// - повторными попытками при блокировках; +/// - удобными методами для CRUD по объектам домена. +final class DBLite : Sqlite { private: string _dbPath; /// Путь к файлу БД. size_t _maxRetries; /// Максимум повторов при `busy/locked`. - Database _db; /// Соединение с БД (d2sqlite3). - // SQL-схема (массив строковых запросов). mixin(import("scheme.d")); /// Выполняет SQL с повторными попытками при `locked/busy`. - ResultRange sql(T...)(string queryText, T args) + SqliteResult sql(T...)(string queryText, T args) { - // Готовим стейтмент сами, чтобы bindAll() работал и для BLOB. - auto attempt = () { - auto st = _db.prepare(queryText); - static if (T.length > 0) - st.bindAll(args); - return st.execute(); - }; - if (_maxRetries == 0) { - return attempt(); + return cast(SqliteResult) query(queryText, args); } string msg; @@ -121,11 +101,10 @@ private: while (tryNo) { try { - return attempt(); - } catch (SqliteException e) { + return cast(SqliteResult) query(queryText, args); + } catch (DatabaseException e) { msg = e.msg; - const code = e.code; - if (code == SQLITE_BUSY || code == SQLITE_LOCKED) { + if (msg.toLower.canFind("locked", "busy")) { if (--tryNo == 0) { throw new Exception( "Не удалось выполнить запрос к базе данных после %d неудачных попыток: %s" @@ -134,18 +113,17 @@ private: } continue; } - break; // другие ошибки — дальше по стеку + break; } } - // До сюда не дойдём, но для формальной полноты: - throw new Exception(msg.length ? msg : "SQLite error"); + throw new Exception(msg); } /// Проверяет наличие обязательных таблиц. /// Если все отсутствуют — создаёт схему; если отсутствует часть — бросает ошибку. void check() { - auto queryResult = sql( + SqliteResult queryResult = sql( q{ WITH required(name) AS (VALUES ("snapshots"), ("blobs"), ("snapshot_chunks"), ("users"), ("processes"), ("files")) @@ -163,7 +141,7 @@ private: foreach (row; queryResult) { - missingTables ~= row["missing_table"].as!string; + missingTables ~= row["missing_table"].to!string; } enforce(missingTables.length == 0 || missingTables.length == 6, @@ -179,220 +157,184 @@ private: } } + /// Переводит текстовую дату из SQLite (`YYYY-MM-DD HH:MM:SS.SSS`) + /// в `DateTime` (ISO 8601 с `T`). + DateTime toDateTime(string sqliteDate) + { + string isoDate = sqliteDate.replace(" ", "T"); + return DateTime.fromISOExtString(isoDate); + } + public: + /// Открывает БД, проверяет/инициализирует схему и настраивает PRAGMA. + /// + /// Параметры: + /// - `database` — путь к файлу БД; + /// - `busyTimeout` — таймаут ожидания блокировок (мс); + /// - `maxRetries` — число повторов при `busy/locked`. this(string database, size_t busyTimeout, size_t maxRetries) { _dbPath = database; - _db = Database(database); + super(database); check(); _maxRetries = maxRetries; - _db.execute("PRAGMA journal_mode=WAL"); - _db.execute("PRAGMA synchronous=NORMAL"); - _db.execute("PRAGMA foreign_keys=ON"); - _db.execute("PRAGMA busy_timeout=%d".format(busyTimeout)); + query("PRAGMA journal_mode=WAL"); + query("PRAGMA synchronous=NORMAL"); + query("PRAGMA foreign_keys=ON"); + query("PRAGMA busy_timeout=%d".format(busyTimeout)); } /// BEGIN IMMEDIATE. void beginImmediate() { - _db.execute("BEGIN IMMEDIATE"); + sql("BEGIN IMMEDIATE"); } /// COMMIT. void commit() { - _db.commit(); + sql("COMMIT"); } /// ROLLBACK. void rollback() { - _db.rollback(); + sql("ROLLBACK"); } - /************************************************* - **************** Работа с файлом ***************** - *************************************************/ - - DBFile[] getFiles() { - auto queryResult = sql( - q{ - SELECT id, name FROM files - } - ); - - DBFile[] files; - - foreach (row; queryResult) - { - DBFile file; - - file.id = row["id"].as!Blob(Blob.init); - file.path = row["name"].as!string; - - files ~= file; - } - - return files; - } - - DBFile getFile(string path) { - auto queryResult = sql( - q{ - SELECT id FROM files WHERE name = ?1 - }, path - ); - - DBFile file; - - if (!queryResult.empty) { - auto data = queryResult.front; - - file.id = data["id"].as!Blob(Blob.init); - file.path = path; - } - - return file; - } - - DBFile getFile(Identifier id) { - auto queryResult = sql( - q{ - SELECT id, name - FROM files - WHERE id = ?1 - }, id[] - ); - - DBFile file; - - if (!queryResult.empty) { - auto data = queryResult.front; - - file.id = id; - file.path = data["name"].as!string; - } - - return file; - } - - DBFile addFile(string path) { - ResultRange queryResult; - UUID uuid; - - // Исключение одинакового UUID первичного ключа - do { - uuid = randomUUID(); - queryResult = sql( - q{ - SELECT id FROM files WHERE id = ?1 - }, uuid.data[] - ); - } while (!queryResult.empty); - - queryResult = sql( - q{ - INSERT INTO files(id, name) - VALUES(?1, ?2) - RETURNING id, name - }, uuid.data[], path - ); - - enforce(!queryResult.empty, "Не удалось добавить новый файл в базу данных"); - - return DBFile(Identifier(uuid.data), path); - } - - DBFile[] findFile(string pattern) { - auto queryResult = sql( - q{ - SELECT id, name FROM files WHERE name LIKE ?1 - }, '%' ~ pattern ~ '%' - ); - - DBFile[] files; - - foreach (row; queryResult) - { - DBFile file; - - file.id = row["id"].as!Blob(Blob.init); - file.path = row["name"].as!string; - - files ~= file; - } - - return files; - } - - // Функция производит поиск по хешу - // Если младший ниббл является нулевым (0000), то ищем позицию вхождения подстроки в строку - // Иначе ищем по подстроке - DBFile[] findFile(Identifier id) { - auto queryResult = sql( - q{ - SELECT id, name - FROM files - WHERE length(?1) BETWEEN 1 AND 16 - AND CASE - WHEN substr(hex(?1), 2 * length(?1), 1) = '0' - THEN instr(hex(id), substr(hex(?1), 1, 2 * length(?1) - 1)) > 0 - ELSE substr(id, 1, length(?1)) = ?1 - END - }, id[] - ); - - DBFile[] files; - - foreach (row; queryResult) - { - DBFile file; - - file.id = row["id"].as!Blob(Blob.init); - file.path = row["name"].as!string; - - files ~= file; - } - - return files; - } - - bool deleteFile(Identifier id) { - auto queryResult = sql("DELETE FROM files WHERE id = ?1 RETURNING id", id[]); - return !queryResult.empty; - } - - bool deleteFile(string path) { - auto queryResult = sql("DELETE FROM files WHERE name = ?1 RETURNING id", path); - return !queryResult.empty; - } - - ///////////////////////////////////////////////////////////////////// - - bool isLast(Identifier id, ubyte[] sha256) { + /// Проверяет, совпадает ли последний снимок для `file` с заданным `sha256`. + /// + /// Возвращает `true`, если самый свежий снимок этого файла имеет тот же SHA-256. + bool isLast(string file, ubyte[] sha256) { auto queryResult = sql( q{ SELECT COALESCE( ( - SELECT (sha256 = ?2) - FROM snapshots - WHERE file = ?1 - ORDER BY created_utc DESC + 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; - }, id[], sha256[] + }, file, sha256 ); - if (!queryResult.empty) - return queryResult.front["is_last"].as!long > 0; + if (!queryResult.empty()) + return queryResult.front()["is_last"].to!long > 0; return false; } + /// Добавляет новый снимок. Возвращает его `id`. + long addSnapshot(DBSnapshot snapshot) + { + auto queryResult = sql( + q{ + INSERT INTO snapshots( + file, + sha256, + description, + source_length, + uid, + ruid, + process, + algo_min, + algo_normal, + algo_max, + mask_s, + mask_l, + status + ) + SELECT + (SELECT id FROM files WHERE name = ?), + ?,?,?,?,?, + (SELECT id FROM processes WHERE name = ?), + ?,?,?,?,?,? + RETURNING id + }, + snapshot.file, + snapshot.sha256[], + snapshot.description.length ? snapshot.description : null, + snapshot.sourceLength, + snapshot.uid, + snapshot.ruid, + snapshot.process, + snapshot.algoMin, + snapshot.algoNormal, + snapshot.algoMax, + snapshot.maskS, + snapshot.maskL, + snapshot.status.to!int + ); + + if (queryResult.empty()) { + throw new Exception("Ошибка добавления нового снимка в базу данных"); + } + + return queryResult.front()["id"].to!long; + } + + /// Добавляет блоб. Возвращает `true`, если вставка произошла (не было конфликта). + bool addBlob(DBBlob blob) + { + auto queryResult = sql( + q{ + INSERT INTO blobs (sha256, z_sha256, size, z_size, content, zstd) + SELECT ?1,?2,?3,?4,?5,?6 + WHERE NOT EXISTS ( + SELECT 1 FROM blobs WHERE sha256 = ?1 + ) + RETURNING sha256 + }, + blob.sha256[], + blob.zstd ? blob.zSha256[] : null, + blob.size, + blob.zSize, + blob.content, + blob.zstd.to!int + ); + + return !queryResult.empty(); + } + + /// Добавляет процесс по имени (идемпотентно). + bool addProcess(string name) + { + auto queryResult = sql( + q{ + INSERT INTO processes (name) + SELECT ?1 + WHERE NOT EXISTS ( + SELECT 1 FROM processes WHERE name = ?1 + ) + }, name + ); + + return !queryResult.empty(); + } + + /// Добавляет файл по имени (идемпотентно). + bool addFile(string name) + { + auto queryResult = sql( + q{ + INSERT INTO files (name) + SELECT ?1 + WHERE NOT EXISTS ( + SELECT 1 FROM files WHERE name = ?1 + ) + }, name + ); + + return !queryResult.empty(); + } + + /// Добавляет пользователя (uid, name) (идемпотентно). bool addUser(long uid, string name) { auto queryResult = sql( @@ -405,247 +347,35 @@ public: }, uid, name ); - return !queryResult.empty; - } - - DBProcess getProcess(string name) { - auto queryResult = sql( - q{ - SELECT id FROM processes WHERE name = ?1 - }, name - ); - - DBProcess process; - - if (!queryResult.empty) { - auto data = queryResult.front; - - process.id = data["id"].as!Blob(Blob.init); - process.name = name; - } - - return process; - } - - DBProcess getProcess(Identifier id) { - auto queryResult = sql( - q{ - SELECT id, name - FROM processes - WHERE id = ?1 - }, id[] - ); - - DBProcess process; - - if (!queryResult.empty) { - auto data = queryResult.front; - - process.id = id; - process.name = data["name"].as!string; - } - - return process; - } - - DBProcess addProcess(string name) { - ResultRange queryResult; - UUID uuid; - - // Исключение одинакового UUID первичного ключа - do { - uuid = randomUUID(); - queryResult = sql( - q{ - SELECT id FROM processes WHERE id = ?1 - }, uuid.data[] - ); - } while (!queryResult.empty); - - queryResult = sql( - q{ - INSERT INTO processes (id, name) - VALUES (?1, ?2) - RETURNING id, name - }, uuid.data[], name - ); - - enforce(!queryResult.empty, "Не удалось добавить новый файл в базу данных"); - - return DBProcess(Identifier(uuid.data), name); - } - - ///////////////////////////////////////////////////////////////////// - - bool addSnapshot(ref DBSnapshot snapshot) - { - ResultRange queryResult; - UUID uuid; - - // Исключение одинакового UUID первичного ключа - do { - uuid = randomUUID(); - queryResult = sql( - q{ - SELECT id FROM snapshots WHERE id = ?1 - }, uuid.data[] - ); - } while (!queryResult.empty); - - import std.datetime : Clock; - - snapshot.createdUtc = UTS(Clock.currTime()); - snapshot.id = Identifier(uuid.data); - - queryResult = sql( - q{ - INSERT INTO snapshots( - id, - file, - sha256, - description, - created_utc, - source_length, - uid, - ruid, - process, - algo_min, - algo_normal, - algo_max, - mask_s, - mask_l, - status - ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15) - RETURNING id - }, - snapshot.id[], // ?1 - snapshot.file.id[], // ?2 - snapshot.sha256[], // ?3 - snapshot.description.length ? snapshot.description : null, // ?4 - snapshot.createdUtc.unix, // ?5 - snapshot.sourceLength, // ?6 - snapshot.uid, // ?7 - snapshot.ruid, // ?8 - snapshot.process.id[], // ?9 - snapshot.algoMin, // ?10 - snapshot.algoNormal, // ?11 - snapshot.algoMax, // ?12 - snapshot.maskS, // ?13 - snapshot.maskL, // ?14 - snapshot.status.to!int // ?15 - ); - - return !queryResult.empty; - } - - ///////////////////////////////////////////////////////////////////// - - bool addBlob(ref DBBlob blob) - { - import std.datetime : Clock; - - blob.createdUtc = UTS(Clock.currTime()); - blob.lastSeenUtc = UTS(Clock.currTime()); - - auto queryResult = sql( - q{ - INSERT INTO blobs ( - sha256, - z_sha256, - size, - z_size, - content, - created_utc, - last_seen_utc, - zstd - ) - SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8 - WHERE NOT EXISTS ( - SELECT 1 FROM blobs WHERE sha256 = ?1 - ) - RETURNING sha256 - }, - blob.sha256[], // ?1 - blob.zstd ? blob.zSha256[] : null, // ?2 - blob.size, // ?3 - blob.zSize, // ?4 - blob.content, // ?5 - blob.createdUtc.unix, // ?6 - blob.lastSeenUtc.unix, // ?7 - blob.zstd.to!int // ?8 - ); - - return !queryResult.empty; + return !queryResult.empty(); } + /// Добавляет связь снимок–чанк. bool addSnapshotChunk(DBSnapshotChunk snapshotChunk) { auto queryResult = sql( q{ INSERT INTO snapshot_chunks (snapshot_id, chunk_index, offset, sha256) - VALUES (?1, ?2, ?3, ?4) + VALUES(?,?,?,?) RETURNING snapshot_id }, - snapshotChunk.snapshotId[], + snapshotChunk.snapshotId, snapshotChunk.chunkIndex, snapshotChunk.offset, snapshotChunk.sha256[] ); - return !queryResult.empty; + return !queryResult.empty(); } - DBSnapshotChunkData[] getChunks(Identifier id) - { - auto queryResult = sql( - q{ - SELECT sc.chunk_index, sc.offset, - b.size, b.content, b.zstd, b.z_size, b.sha256, b.z_sha256 - FROM snapshot_chunks sc - JOIN blobs b ON b.sha256 = sc.sha256 - WHERE sc.snapshot_id = ? - ORDER BY sc.chunk_index - }, id[] - ); - - DBSnapshotChunkData[] sdchs; - - foreach (row; queryResult) - { - DBSnapshotChunkData sdch; - - sdch.chunkIndex = row["chunk_index"].as!long; - sdch.offset = row["offset"].as!long; - sdch.size = row["size"].as!long; - - // content может быть NULL - auto contentBlob = cast(ubyte[]) row["content"].as!Blob(Blob.init); - sdch.content = contentBlob.length ? contentBlob.dup : null; - - sdch.zstd = row["zstd"].as!int != 0; - sdch.zSize = row["z_size"].as!long; - - auto sha = cast(ubyte[]) row["sha256"].as!Blob(Blob.init); - if (sha.length) sdch.sha256[] = sha; - - auto zsha = cast(ubyte[]) row["z_sha256"].as!Blob(Blob.init); - if (zsha.length) sdch.zSha256[] = zsha; - - sdchs ~= sdch; - } - - return sdchs; - } - - DBSnapshot[] getSnapshots(string file) + /// Возвращает один снимок по `id`. Если не найден — вернёт пустую структуру. + DBSnapshot getSnapshot(long id) { auto queryResult = sql( q{ SELECT s.id, - f.id file_id, - f.name file_name, + f.name file, s.sha256, s.description, s.created_utc, @@ -654,8 +384,67 @@ public: s.ruid, u.name uid_name, r.name ruid_name, - p.id process_id, - p.name process_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 + ); + + DBSnapshot snapshot; + + if (!queryResult.empty()) + { + auto data = queryResult.front(); + + snapshot.id = data["id"].to!long; + 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); + snapshot.sourceLength = data["source_length"].to!long; + snapshot.algoMin = data["algo_min"].to!long; + snapshot.algoNormal = data["algo_normal"].to!long; + snapshot.algoMax = data["algo_max"].to!long; + 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 file) + { + auto queryResult = sql( + q{ + 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, @@ -667,7 +456,7 @@ public: 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 + ORDER BY s.created_utc, s.id; }, file ); @@ -677,29 +466,23 @@ public: { DBSnapshot snapshot; - snapshot.id = row["id"].as!Blob(Blob.init); - snapshot.file = DBFile( - Identifier(row["file_id"].as!Blob(Blob.init)), - row["file_name"].as!string - ); - snapshot.sha256 = row["sha256"].as!Blob(Blob.init); - snapshot.description = row["description"].as!string(""); // может быть NULL - snapshot.createdUtc = row["created_utc"].as!long; - snapshot.sourceLength = row["source_length"].as!long; - snapshot.algoMin = row["algo_min"].as!long; - snapshot.algoNormal = row["algo_normal"].as!long; - snapshot.algoMax = row["algo_max"].as!long; - snapshot.maskS = row["mask_s"].as!long; - snapshot.maskL = row["mask_l"].as!long; - snapshot.status = cast(SnapshotStatus) row["status"].as!int; - snapshot.uid = row["uid"].as!long; - snapshot.ruid = row["ruid"].as!long; - snapshot.uidName = row["uid_name"].as!string; - snapshot.ruidName = row["ruid_name"].as!string; - snapshot.process = DBProcess( - Identifier(row["process_id"].as!Blob(Blob.init)), - row["process_name"].as!string - ); + snapshot.id = row["id"].to!long; + 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); + snapshot.sourceLength = row["source_length"].to!long; + snapshot.algoMin = row["algo_min"].to!long; + snapshot.algoNormal = row["algo_normal"].to!long; + snapshot.algoMax = row["algo_max"].to!long; + 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; } @@ -707,220 +490,64 @@ public: return snapshots; } - DBSnapshot[] getSnapshots(Identifier id) + /// Возвращает последовательность чанков снимка c данными. + DBSnapshotChunkData[] getChunks(long snapshotId) { auto queryResult = sql( q{ - SELECT - s.id, - f.id file_id, - f.name file_name, - s.sha256, - s.description, - s.created_utc, - s.source_length, - s.uid, - s.ruid, - u.name uid_name, - r.name ruid_name, - p.id process_id, - p.name process_name, - 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 = ?1 - ORDER BY s.created_utc - }, id[] + SELECT sc.chunk_index, sc.offset, + b.size, b.content, b.zstd, b.z_size, b.sha256, b.z_sha256 + FROM snapshot_chunks sc + JOIN blobs b ON b.sha256 = sc.sha256 + WHERE sc.snapshot_id = ? + ORDER BY sc.chunk_index + }, snapshotId ); - DBSnapshot[] snapshots; + DBSnapshotChunkData[] sdchs; foreach (row; queryResult) { - DBSnapshot snapshot; + DBSnapshotChunkData sdch; - snapshot.id = row["id"].as!Blob(Blob.init); - snapshot.file = DBFile( - Identifier(row["file_id"].as!Blob(Blob.init)), - row["file_name"].as!string - ); - snapshot.sha256 = row["sha256"].as!Blob(Blob.init); - snapshot.description = row["description"].as!string(""); - snapshot.createdUtc = row["created_utc"].as!long; - snapshot.sourceLength = row["source_length"].as!long; - snapshot.algoMin = row["algo_min"].as!long; - snapshot.algoNormal = row["algo_normal"].as!long; - snapshot.algoMax = row["algo_max"].as!long; - snapshot.maskS = row["mask_s"].as!long; - snapshot.maskL = row["mask_l"].as!long; - snapshot.status = cast(SnapshotStatus) row["status"].as!int; - snapshot.uid = row["uid"].as!long; - snapshot.ruid = row["ruid"].as!long; - snapshot.uidName = row["uid_name"].as!string; - snapshot.ruidName = row["ruid_name"].as!string; - snapshot.process = DBProcess( - Identifier(row["process_id"].as!Blob(Blob.init)), - row["process_name"].as!string - ); - snapshots ~= snapshot; + sdch.chunkIndex = row["chunk_index"].to!long; + sdch.offset = row["offset"].to!long; + sdch.size = row["size"].to!long; + sdch.content = cast(ubyte[]) row["content"].dup; + sdch.zstd = cast(bool) row["zstd"].to!int; + sdch.zSize = row["z_size"].to!long; + sdch.sha256 = cast(ubyte[]) row["sha256"].dup; + sdch.zSha256 = cast(ubyte[]) row["z_sha256"].dup; + + sdchs ~= sdch; } - return snapshots; + return sdchs; } - DBSnapshot getSnapshot(Identifier id) - { + /// Удаляет один снимок по `id`. Возвращает `id` удалённой строки или 0. + long deleteSnapshot(long id) { + auto queryResult = sql("DELETE FROM snapshots WHERE id = ? RETURNING id", id); + + if (!queryResult.empty()) { + return queryResult.front()["id"].to!long; + } + return 0; + } + + /// Удаляет все снимки по имени файла. Возвращает число удалённых строк. + long deleteSnapshots(string file) { auto queryResult = sql( q{ - SELECT - s.id, - f.id file_id, - f.name file_name, - s.sha256, - s.description, - s.created_utc, - s.source_length, - s.uid, - s.ruid, - u.name uid_name, - r.name ruid_name, - p.id process_id, - p.name process_name, - 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 f.id = ?1 - ORDER BY s.created_utc - }, id[] - ); + DELETE FROM snapshots + WHERE file = (SELECT id FROM files WHERE name = ?) + RETURNING 1; + }, file); - DBSnapshot snapshot; - - if (!queryResult.empty) { - auto data = queryResult.front; - - snapshot.id = data["id"].as!Blob(Blob.init); - snapshot.file = DBFile( - Identifier(data["file_id"].as!Blob(Blob.init)), - data["file_name"].as!string - ); - snapshot.sha256 = data["sha256"].as!Blob(Blob.init); - snapshot.description = data["description"].as!string(""); - snapshot.createdUtc = data["created_utc"].as!long; - snapshot.sourceLength = data["source_length"].as!long; - snapshot.algoMin = data["algo_min"].as!long; - snapshot.algoNormal = data["algo_normal"].as!long; - snapshot.algoMax = data["algo_max"].as!long; - snapshot.maskS = data["mask_s"].as!long; - snapshot.maskL = data["mask_l"].as!long; - snapshot.status = cast(SnapshotStatus) data["status"].as!int; - snapshot.uid = data["uid"].as!long; - snapshot.ruid = data["ruid"].as!long; - snapshot.uidName = data["uid_name"].as!string; - snapshot.ruidName = data["ruid_name"].as!string; - snapshot.process = DBProcess( - Identifier(data["process_id"].as!Blob(Blob.init)), - data["process_name"].as!string - ); + if (!queryResult.empty()) { + return queryResult.length.to!long; } - return snapshot; - } - - // Функция производит поиск по хешу - // Если младший ниббл является нулевым (0000), то ищем позицию вхождения подстроки в строку - // Иначе ищем по подстроке - DBSnapshot[] findSnapshot(Identifier id) { - auto queryResult = sql( - q{ - SELECT - s.id, - f.id file_id, - f.name file_name, - s.sha256, - s.description, - s.created_utc, - s.source_length, - s.uid, - s.ruid, - u.name uid_name, - r.name ruid_name, - p.id process_id, - p.name process_name, - 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 length(?1) BETWEEN 1 AND 16 - AND CASE - WHEN substr(hex(?1), 2 * length(?1), 1) = '0' - THEN instr(hex(s.id), substr(hex(?1), 1, 2 * length(?1) - 1)) > 0 - ELSE substr(s.id, 1, length(?1)) = ?1 - END - }, id[] - ); - - DBSnapshot[] snapshots; - - foreach (row; queryResult) - { - DBSnapshot snapshot; - - snapshot.id = row["id"].as!Blob(Blob.init); - snapshot.file = DBFile( - Identifier(row["file_id"].as!Blob(Blob.init)), - row["file_name"].as!string - ); - snapshot.sha256 = row["sha256"].as!Blob(Blob.init); - snapshot.description = row["description"].as!string(""); - snapshot.createdUtc = row["created_utc"].as!long; - snapshot.sourceLength = row["source_length"].as!long; - snapshot.algoMin = row["algo_min"].as!long; - snapshot.algoNormal = row["algo_normal"].as!long; - snapshot.algoMax = row["algo_max"].as!long; - snapshot.maskS = row["mask_s"].as!long; - snapshot.maskL = row["mask_l"].as!long; - snapshot.status = cast(SnapshotStatus) row["status"].as!int; - snapshot.uid = row["uid"].as!long; - snapshot.ruid = row["ruid"].as!long; - snapshot.uidName = row["uid_name"].as!string; - snapshot.ruidName = row["ruid_name"].as!string; - snapshot.process = DBProcess( - Identifier(row["process_id"].as!Blob(Blob.init)), - row["process_name"].as!string - ); - - snapshots ~= snapshot; - } - - return snapshots; - } - - bool deleteSnapshot(Identifier id) { - auto queryResult = sql("DELETE FROM snapshots WHERE id = ? RETURNING id", id[]); - return !queryResult.empty; + return 0; } } diff --git a/source/cdcdb/lib/hash.d b/source/cdcdb/lib/hash.d deleted file mode 100644 index ed58887..0000000 --- a/source/cdcdb/lib/hash.d +++ /dev/null @@ -1,110 +0,0 @@ -module cdcdb.lib.hash; - -import std.format : format; - -struct Identifier -{ -private: - ubyte[] _data; - - ubyte hxc(ref const char c) const - { - auto lc = cast(char)(c | 32); - if (lc >= '0' && lc <= '9') - return cast(ubyte)(lc - '0'); - if (lc >= 'a' && lc <= 'f') - return cast(ubyte)(10 + lc - 'a'); - throw new Exception("Некорректный символ hex"); - } - - ubyte[] fromHex(ref const string hash) const - { - import std.exception : enforce; - - enforce(hash.length > 0, "Hex-строка не может быть пустой."); - enforce(hash.length <= 32, "Длина hex-строки не должна превышать 32 символа."); - - size_t byteLen = (hash.length + 1) / 2; // Округление вверх для нечётной длины - ubyte[] data = new ubyte[byteLen]; - - foreach (i; 0 .. hash.length / 2) - { - data[i] = cast(ubyte)((hxc(hash[2 * i]) << 4) | hxc(hash[2 * i + 1])); - } - - if (hash.length % 2 != 0) - { - // Для нечётной длины: последний ниббл в старший разряд, младший = 0 - data[$ - 1] = cast(ubyte)(hxc(hash[$ - 1]) << 4); - } - - return data; - } - -public: - // alias _data this; - - this(const string hex) - { - _data = fromHex(hex); - } - - void opAssign(const string hex) - { - _data = fromHex(hex); - } - - this(ubyte[] data) - { - assert(data.length <= 16); - _data = data; - } - - this(immutable(ubyte[]) data) - { - assert(data.length <= 16); - _data = data.dup; - } - - this(ref const ubyte[16] data) - { - assert(data.length <= 16); - _data = data.dup; - } - - void opAssign(immutable(ubyte[]) data) - { - assert(data.length <= 16); - _data = data.dup; - } - - void opAssign(ubyte[] data) - { - assert(data.length <= 16); - _data = data; - } - - string toString() const @safe pure - { - return format("%(%02x%)", _data); - } - - string compact(int size = 4) const @safe pure - { - auto length = _data.length >= size && size > 0 ? size : _data.length; - return format("%(%02x%)", _data[0 .. length]); - } - - ubyte[] data() - { - return _data; - } - - ubyte[] opIndex() { - return _data; - } - - @trusted pure nothrow @nogc @property bool empty() const { - return _data.length == 0; - } -} diff --git a/source/cdcdb/lib/package.d b/source/cdcdb/lib/package.d deleted file mode 100644 index d5aaabf..0000000 --- a/source/cdcdb/lib/package.d +++ /dev/null @@ -1,4 +0,0 @@ -module cdcdb.lib; - -public import cdcdb.lib.hash; -public import cdcdb.lib.uts; diff --git a/source/cdcdb/lib/uts.d b/source/cdcdb/lib/uts.d deleted file mode 100644 index f699aa6..0000000 --- a/source/cdcdb/lib/uts.d +++ /dev/null @@ -1,61 +0,0 @@ -module cdcdb.lib.uts; - -import std.datetime : SysTime, msecs; - -// 2050-01-01 00:00:00 UTC -private enum UTS_LAST_TS = 0x967a7600; // 2524608000L -// Extended -private enum UTS_LAST_TS_EXT = UTS_LAST_TS * 1_000L; - -// Unix Timestamp с миллисекундами -struct UTS -{ -private: - long _ts; - - long calc(SysTime systime) { - long millis = systime.toUnixTime() * 1000L + systime.fracSecs.total!"msecs"; - return millis; - } - -public: - this(long ts) { - assert(ts < UTS_LAST_TS_EXT); - _ts = ts; - } - - this(SysTime systime) { - _ts = calc(systime); - } - - void opAssign(SysTime systime) { - _ts = calc(systime); - } - - void opAssign(long ts) { - assert(ts < UTS_LAST_TS_EXT); - _ts = ts; - } - - string toString() const - { - import std.format : format; - - string formatStr = "%04d-%02d-%02d %02d:%02d:%02d.%03d"; - long seconds = _ts / 1_000L; - long millis = _ts % 1_000L; - auto sysTime = SysTime.fromUnixTime(seconds) + msecs(millis); - return format(formatStr, - sysTime.year, sysTime.month, sysTime.day, - sysTime.hour, sysTime.minute, sysTime.second, - sysTime.fracSecs.total!"msecs"); - } - - @property const(SysTime) sys() const @safe { - return SysTime.fromUnixTime(_ts / 1_000L); - } - - @property long unix() const @safe { - return _ts; - } -} diff --git a/source/cdcdb/package.d b/source/cdcdb/package.d index 72dc0e6..167d305 100644 --- a/source/cdcdb/package.d +++ b/source/cdcdb/package.d @@ -1,6 +1,4 @@ module cdcdb; -public import cdcdb.lib; public import cdcdb.storage; -public import cdcdb.storagefile; public import cdcdb.snapshot; diff --git a/source/cdcdb/scheme.d b/source/cdcdb/scheme.d index 0a70b42..40ab4db 100644 --- a/source/cdcdb/scheme.d +++ b/source/cdcdb/scheme.d @@ -21,10 +21,10 @@ auto _scheme = [ -- ------------------------------------------------------------ CREATE TABLE IF NOT EXISTS processes ( -- идентификатор процесса - id BLOB PRIMARY KEY, + id INTEGER PRIMARY KEY AUTOINCREMENT, -- имя процесса name TEXT NOT NULL UNIQUE - ) WITHOUT ROWID + ) }, q{ -- Индекс по имени процесса @@ -37,10 +37,10 @@ auto _scheme = [ -- ------------------------------------------------------------ CREATE TABLE IF NOT EXISTS files ( -- идентификатор файла - id BLOB PRIMARY KEY, + id INTEGER PRIMARY KEY AUTOINCREMENT, -- имя файла name TEXT NOT NULL UNIQUE - ) WITHOUT ROWID + ) }, q{ -- Индекс по имени файла @@ -53,15 +53,15 @@ auto _scheme = [ -- ------------------------------------------------------------ CREATE TABLE IF NOT EXISTS snapshots ( -- идентификатор снимка - id BLOB PRIMARY KEY, + id INTEGER PRIMARY KEY AUTOINCREMENT, -- Файл - file BLOB NOT NULL, + file INTEGER NOT NULL, -- SHA-256 всего файла (BLOB(32)) sha256 BLOB NOT NULL CHECK (length(sha256) = 32), -- Комментарий/описание description TEXT DEFAULT NULL, -- время создания (UTC) - created_utc INTEGER NOT NULL, + created_utc TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), -- длина исходного файла в байтах source_length INTEGER NOT NULL, -- UID пользователя (эффективный) @@ -69,7 +69,7 @@ auto _scheme = [ -- RUID пользователя (реальный) ruid INTEGER NOT NULL, -- Процесс - process BLOB NOT NULL, + process INTEGER NOT NULL, -- FastCDC: минимальный размер чанка algo_min INTEGER NOT NULL, -- FastCDC: целевой размер чанка @@ -100,7 +100,7 @@ auto _scheme = [ REFERENCES files(id) ON UPDATE CASCADE ON DELETE CASCADE - ) WITHOUT ROWID + ) }, q{ -- ------------------------------------------------------------ @@ -118,9 +118,9 @@ auto _scheme = [ -- байты (сжатые при zstd=1, иначе исходные) content BLOB NOT NULL, -- время создания записи (UTC) - created_utc INTEGER NOT NULL, + created_utc TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), -- время последней ссылки (UTC) - last_seen_utc INTEGER NOT NULL, + last_seen_utc TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), -- число ссылок из snapshot_chunks refcount INTEGER NOT NULL DEFAULT 0, -- 0=нет сжатия, 1=zstd @@ -132,7 +132,7 @@ auto _scheme = [ (zstd = 0 AND length(content) = size) ), CHECK (z_sha256 IS NULL OR length(z_sha256) = 32) - ) WITHOUT ROWID + ) }, q{ -- ------------------------------------------------------------ @@ -140,7 +140,7 @@ auto _scheme = [ -- ------------------------------------------------------------ CREATE TABLE IF NOT EXISTS snapshot_chunks ( -- FK -> snapshots.id - snapshot_id BLOB NOT NULL, + snapshot_id INTEGER NOT NULL, -- порядковый номер чанка в снимке chunk_index INTEGER NOT NULL, -- смещение чанка в исходном файле, байт @@ -156,7 +156,7 @@ auto _scheme = [ REFERENCES blobs(sha256) ON UPDATE RESTRICT ON DELETE RESTRICT - ) WITHOUT ROWID + ) }, q{ -- Индекс для запросов вида: WHERE file=? AND sha256=? @@ -178,7 +178,7 @@ auto _scheme = [ BEGIN UPDATE blobs SET refcount = refcount + 1, - last_seen_utc = cast(unixepoch("subsecond") * 1000 as int) + last_seen_utc = CURRENT_TIMESTAMP WHERE sha256 = NEW.sha256; END }, @@ -213,7 +213,7 @@ auto _scheme = [ UPDATE blobs SET refcount = refcount + 1, - last_seen_utc = cast(unixepoch("subsecond") * 1000 as int) + last_seen_utc = CURRENT_TIMESTAMP WHERE sha256 = NEW.sha256; END }, diff --git a/source/cdcdb/snapshot.d b/source/cdcdb/snapshot.d index 8cd534e..88e4541 100644 --- a/source/cdcdb/snapshot.d +++ b/source/cdcdb/snapshot.d @@ -1,21 +1,42 @@ module cdcdb.snapshot; import cdcdb.dblite; -import cdcdb.lib; import zstd : uncompress; import std.digest.sha : SHA256, digest; +import std.datetime : DateTime; import std.exception : enforce; -import std.datetime : SysTime; +/** + * Чтение снимка и управление его жизненным циклом. + * + * Класс собирает полный файл из чанков, хранящихся через `DBLite`, + * проверяет целостность (SHA-256 каждого чанка и итогового файла) + * и предоставляет безопасное удаление записи о снимке. + * + * Пример: + * --- + * auto s1 = new Snapshot(db, snapshotId); + * auto bytes = s1.data(); // материализовать весь контент в память + * + * // или потоково, без крупной аллокации: + * s1.data((const(ubyte)[] part) { + * // обработать part + * }); + * --- + * + * Заметки: + * - Все проверки целостности обязательны; любое расхождение вызывает исключение. + * - Для очень больших файлов предпочтительнее потоковый вариант с делегатом. + */ final class Snapshot { private: DBLite _db; DBSnapshot _snapshot; - // Возвращает исходные байты чанка с учётом возможного сжатия и проверкой хеша. + /// Возвращает исходные байты чанка с учётом возможного сжатия и проверкой хеша. const(ubyte)[] getBytes(const ref DBSnapshotChunkData chunk) { ubyte[] bytes; @@ -36,8 +57,36 @@ private: } public: - this(DBLite dblite, DBSnapshot dbSnapshot) { _db = dblite; _snapshot = dbSnapshot; } + /// Создать `Snapshot` из уже загруженной строки `DBSnapshot`. + /// + /// Параметры: + /// dblite = хэндл базы данных + /// dbSnapshot = метаданные снимка, полученные ранее + this(DBLite dblite, DBSnapshot dbSnapshot) + { + _db = dblite; + _snapshot = dbSnapshot; + } + /// Создать `Snapshot`, подгрузив метаданные из базы. + /// + /// Параметры: + /// dblite = хэндл базы данных + /// idSnapshot = идентификатор снимка + this(DBLite dblite, long idSnapshot) + { + _db = dblite; + _snapshot = _db.getSnapshot(idSnapshot); + } + + /// Материализует полный контент файла в память. + /// + /// Собирает чанки по порядку, проверяет SHA-256 каждого чанка и + /// итоговый SHA-256 файла (`snapshots.sha256`). + /// + /// Возвращает: новый буфер `ubyte[]` с полным содержимым. + /// + /// Бросает: Exception при любой ошибке целостности. ubyte[] data() { auto chunks = _db.getChunks(_snapshot.id); @@ -58,6 +107,15 @@ public: return content; } + /// Потоково передаёт содержимое файла в заданный приёмник. + /// + /// Избегает одной большой аллокации: чанк декодируется, проверяется + /// и передаётся в `sink` по порядку. + /// + /// Параметры: + /// sink = делегат, вызываемый для каждого проверенного чанка. + /// + /// Бросает: Exception при любой ошибке целостности. void data(void delegate(const(ubyte)[]) sink) { auto chunks = _db.getChunks(_snapshot.id); @@ -73,46 +131,154 @@ public: enforce(_snapshot.sha256 == fctx.finish(), "Хеш итогового файла не совпадает"); } + /// Удаляет снимок из базы в транзакции. + /// + /// Открывает транзакцию IMMEDIATE, удаляет запись о снимке и коммитит. + /// В случае ошибки откатывает. + /// + /// Возвращает: `true`, если запись была удалена. + /// + /// Примечание: не выполняет сборку мусора по блобам. + bool remove() + { + _db.beginImmediate(); + + bool ok; + + scope (exit) + { + if (!ok) + _db.rollback(); + } + scope (success) + { + _db.commit(); + } + + long idDeleted = _db.deleteSnapshot(_snapshot.id); + + ok = true; + + return _snapshot.id == idDeleted; + } + // ----------------------------- // Доступ к метаданным снимка // ----------------------------- /// ID снимка (PRIMARY KEY). - @property Identifier id() nothrow @safe { return _snapshot.id; } + @property long id() const nothrow @safe + { + return _snapshot.id; + } + /// Имя файла (из таблицы `files`). - @property string file() const nothrow @safe { return _snapshot.file.path; } + @property string file() const nothrow @safe + { + return _snapshot.file; + } + /// Время создания (UTC). - @property const(SysTime) created() const @safe { return _snapshot.createdUtc.sys; } + @property DateTime created() const @safe + { + return _snapshot.createdUtc; + } + /// Длина исходного файла (байты). - @property long length() const nothrow @safe { return _snapshot.sourceLength; } + @property long length() const nothrow @safe + { + return _snapshot.sourceLength; + } + /// Ожидаемый SHA-256 всего файла (сырые 32 байта). - @property ubyte[32] sha256() const nothrow @safe { return _snapshot.sha256; } + @property ubyte[32] sha256() const nothrow @safe + { + return _snapshot.sha256; + } + /// Статус снимка (строкой). - @property string status() const { import std.conv : to; return _snapshot.status.to!string; } + @property string status() const + { + import std.conv : to; + + return _snapshot.status.to!string; + } + /// Необязательное описание. - @property string description() const nothrow @safe { return _snapshot.description; } + @property string description() const nothrow @safe + { + return _snapshot.description; + } + /// FastCDC: минимальный размер чанка. - @property long algoMin() const nothrow @safe { return _snapshot.algoMin; } + @property long algoMin() const nothrow @safe + { + return _snapshot.algoMin; + } + /// FastCDC: целевой (нормальный) размер чанка. - @property long algoNormal() const nothrow @safe { return _snapshot.algoNormal; } + @property long algoNormal() const nothrow @safe + { + return _snapshot.algoNormal; + } + /// FastCDC: максимальный размер чанка. - @property long algoMax() const nothrow @safe { return _snapshot.algoMax; } + @property long algoMax() const nothrow @safe + { + return _snapshot.algoMax; + } + /// FastCDC: строгая маска. - @property long maskS() const nothrow @safe { return _snapshot.maskS; } + @property long maskS() const nothrow @safe + { + return _snapshot.maskS; + } + /// FastCDC: слабая маска. - @property long maskL() const nothrow @safe { return _snapshot.maskL; } + @property long maskL() const nothrow @safe + { + return _snapshot.maskL; + } + /// UID процесса (effective). - @property long uid() const nothrow @safe { return _snapshot.uid; } + @property long uid() const nothrow @safe + { + return _snapshot.uid; + } + /// Real UID процесса. - @property long ruid() const nothrow @safe { return _snapshot.ruid; } + @property long ruid() const nothrow @safe + { + return _snapshot.ruid; + } + /// Имя пользователя для `uid`. - @property string uidName() const nothrow @safe { return _snapshot.uidName; } + @property string uidName() const nothrow @safe + { + return _snapshot.uidName; + } + /// Имя пользователя для `ruid`. - @property string ruidName() const nothrow @safe { return _snapshot.ruidName; } + @property string ruidName() const nothrow @safe + { + return _snapshot.ruidName; + } + /// Имя процесса (из таблицы `processes`). - @property string process() const nothrow @safe { return _snapshot.process.name; } + @property string process() const nothrow @safe + { + return _snapshot.process; + } + /// Удобный флаг: снимок «готов». - @property bool isReady() const nothrow @safe { return _snapshot.status == SnapshotStatus.ready; } + @property bool isReady() const nothrow @safe + { + return _snapshot.status == SnapshotStatus.ready; + } + /// Удобный флаг: снимок «в процессе». - @property bool isPending() const nothrow @safe { return _snapshot.status == SnapshotStatus.pending; } + @property bool isPending() const nothrow @safe + { + return _snapshot.status == SnapshotStatus.pending; + } } diff --git a/source/cdcdb/storage.d b/source/cdcdb/storage.d index 5002057..b9c3a90 100644 --- a/source/cdcdb/storage.d +++ b/source/cdcdb/storage.d @@ -1,15 +1,12 @@ module cdcdb.storage; import cdcdb.dblite; -import cdcdb.storagefile; -import cdcdb.snapshot; import cdcdb.core; -import cdcdb.lib : Identifier; +import cdcdb.snapshot; import zstd : compress, Level; -import std.exception : enforce; - +/// Контекст создания снимка (идентификаторы и процесс). struct Context { long uid; /// UID процесса (effective). @@ -19,6 +16,27 @@ struct Context string process; /// Имя процесса. } +/** + * Высокоуровневый фасад для хранения: разбивает данные на чанки CDC, + * сохраняет чанки/блобы в SQLite через `DBLite`, связывает их в снимки + * и возвращает объекты `Snapshot` для последующего чтения и удаления. + * + * Возможности: + * - Разбиение FastCDC (контентно-зависимое, настраиваемые размеры/маски). + * - Опциональное сжатие Zstandard (уровень задаётся). + * - Идемпотентное создание снимков: пропускает, если последний снимок совпадает. + * + * Типичное использование: + * --- + * auto store = new Storage("base.db", true, Level.max); + * store.setupCDC(4096, 8192, 16384, 0x3FFF, 0x03FF); + * Context ctx; + * auto snap = store.newSnapshot("my.txt", data, ctx, "первичный импорт"); + * auto bytes = snap.data(); // восстановить содержимое + * + * auto removed = store.removeSnapshots("my.txt"); // удалить по имени файла + * --- + */ final class Storage { private: @@ -48,6 +66,14 @@ private: } public: + /// Конструктор: открывает/создаёт БД и подготавливает фасад. + /// + /// Параметры: + /// database = путь к файлу SQLite + /// zstd = включить Zstd-сжатие для блобов + /// level = уровень сжатия (см. `zstd.Level`) + /// busyTimeout = таймаут ожидания блокировки SQLite (мс) + /// maxRetries = число повторов при SQLITE_BUSY/LOCKED this(string database, bool zstd = false, int level = Level.base, size_t busyTimeout = 3000, size_t maxRetries = 3) { @@ -57,27 +83,44 @@ public: initCDC(); } + /// Перенастроить параметры CDC (влияет на будущие снимки). void setupCDC(size_t minSize, size_t normalSize, size_t maxSize, size_t maskS, size_t maskL) { initCDC(minSize, normalSize, maxSize, maskS, maskL); } - Snapshot newSnapshot(string file, const(ubyte)[] data, Context context, string description = string.init) + /// Создаёт новый снимок из массива байт. + /// + /// - Разбивает данные по текущим параметрам FastCDC. + /// - Опционально сжимает чанки Zstd. + /// - Сохраняет уникальные блобы и связывает их со снимком. + /// - Если последний снимок для файла совпадает по SHA-256, возвращает `null`. + /// + /// Параметры: + /// file = имя файла (метка снимка) + /// data = содержимое файла + /// context = контекст (uid, ruid, процесс и т.д.) + /// description = необязательное описание + /// + /// Возвращает: объект `Snapshot` или `null` + /// + /// Исключения: при пустых данных или ошибках базы + Snapshot newSnapshot(string file, const(ubyte)[] data, Context context, + string description = string.init) { - enforce(data.length > 0, "Данные имеют нулевую длину"); - - auto dbFile = _db.getFile(file); + if (data.length == 0) + { + throw new Exception("Данные имеют нулевую длину"); + } import std.digest.sha : SHA256, digest; ubyte[32] sha256 = digest!SHA256(data); - if (dbFile.empty) { - dbFile = _db.addFile(file); - } else if (_db.isLast(dbFile.id, sha256)) { + // Если последний снимок совпадает — пропустить + if (_db.isLast(file, sha256)) return null; - } _db.beginImmediate(); bool ok; @@ -92,22 +135,18 @@ public: _db.commit(); } + // Запись пользователей/файлов/процессов _db.addUser(context.uid, context.uidName); if (context.uid != context.ruid) { _db.addUser(context.ruid, context.ruidName); } - - auto dbProcess = _db.getProcess(context.process); - - if (dbProcess.empty) { - dbProcess = _db.addProcess(context.process); - } + _db.addFile(file); + _db.addProcess(context.process); // Метаданные снимка DBSnapshot dbSnapshot; - - dbSnapshot.file = dbFile; + dbSnapshot.file = file; dbSnapshot.sha256 = sha256; dbSnapshot.description = description; dbSnapshot.sourceLength = data.length; @@ -118,9 +157,9 @@ public: dbSnapshot.maskL = _maskL; dbSnapshot.uid = context.uid; dbSnapshot.ruid = context.ruid; - dbSnapshot.process = dbProcess; + dbSnapshot.process = context.process; - enforce(_db.addSnapshot(dbSnapshot), "Не удалось добавить новый снимок в базу данных"); + auto idSnapshot = _db.addSnapshot(dbSnapshot); // Чанки и блобы DBSnapshotChunk dbSnapshotChunk; @@ -153,99 +192,54 @@ public: _db.addBlob(dbBlob); - dbSnapshotChunk.snapshotId = dbSnapshot.id; + dbSnapshotChunk.snapshotId = idSnapshot; dbSnapshotChunk.chunkIndex = chunk.index; dbSnapshotChunk.offset = chunk.offset; dbSnapshotChunk.sha256 = chunk.sha256; - enforce(_db.addSnapshotChunk(dbSnapshotChunk), "Не удалось привязать снимок к данным"); + _db.addSnapshotChunk(dbSnapshotChunk); } ok = true; - return new Snapshot(_db, dbSnapshot); + return new Snapshot(_db, idSnapshot); } - StorageFile getFile(string path) { - auto dbFile = _db.getFile(path); - if (dbFile.empty) return null; - return new StorageFile(_db, dbFile); + /// Удаляет все снимки по имени файла. + long removeSnapshots(string file) + { + return _db.deleteSnapshots(file); } - StorageFile getFile(Identifier id) { - auto dbFile = _db.getFile(id); - if (dbFile.empty) return null; - return new StorageFile(_db, dbFile); + /// Удаляет конкретный снимок по объекту `Snapshot`. + bool removeSnapshot(Snapshot snapshot) + { + return removeSnapshot(snapshot.id); } - StorageFile[] getFiles() { - StorageFile[] storageFiles; - foreach (dbFile; _db.getFiles()) { - storageFiles ~= new StorageFile(_db, dbFile); - } - return storageFiles; + /// Удаляет снимок по id. + bool removeSnapshot(long idSnapshot) + { + return _db.deleteSnapshot(idSnapshot) == idSnapshot; } - StorageFile[] findFile(string pattern) { - StorageFile[] storageFiles; - foreach (dbFile; _db.findFile(pattern)) { - storageFiles ~= new StorageFile(_db, dbFile); - } - return storageFiles; + /// Возвращает `Snapshot` по id. + Snapshot getSnapshot(long idSnapshot) + { + return new Snapshot(_db, idSnapshot); } - StorageFile[] findFile(Identifier id) { - StorageFile[] storageFiles; - foreach (dbFile; _db.findFile(id)) { - storageFiles ~= new StorageFile(_db, dbFile); - } - return storageFiles; - } - - Snapshot getSnapshot(Identifier id) { - DBSnapshot dbSnapshot = _db.getSnapshot(id); - if (dbSnapshot.empty) - return null; - return new Snapshot(_db, dbSnapshot); - } - - Snapshot[] getSnapshots(Identifier id) { + /// Возвращает список снимков (опционально фильтр по имени файла). + Snapshot[] getSnapshots(string file = string.init) + { Snapshot[] snapshots; - foreach (dbSnapshot; _db.getSnapshots(id)) + foreach (snapshot; _db.getSnapshots(file)) { - snapshots ~= new Snapshot(_db, dbSnapshot); + snapshots ~= new Snapshot(_db, snapshot); } return snapshots; } - Snapshot[] getSnapshots(string file) { - Snapshot[] snapshots; - foreach (dbSnapshot; _db.getSnapshots(file)) - { - snapshots ~= new Snapshot(_db, dbSnapshot); - } - return snapshots; - } - - Snapshot[] findSnapshot(Identifier id) { - Snapshot[] snapshots; - foreach (dbSnapshot; _db.findSnapshot(id)) { - snapshots ~= new Snapshot(_db, dbSnapshot); - } - return snapshots; - } - - bool deleteFile(Identifier id) { - return _db.deleteFile(id); - } - - bool deleteFile(string name) { - return _db.deleteFile(name); - } - - bool deleteSnapshot(Identifier id) { - return _db.deleteSnapshot(id); - } - + /// Версия библиотеки. string getVersion() const @safe nothrow { import cdcdb.version_ : cdcdbVersion; diff --git a/source/cdcdb/storagefile.d b/source/cdcdb/storagefile.d deleted file mode 100644 index a2c8101..0000000 --- a/source/cdcdb/storagefile.d +++ /dev/null @@ -1,25 +0,0 @@ -module cdcdb.storagefile; - -import cdcdb.snapshot; -import cdcdb.lib; -import cdcdb.dblite; - -final class StorageFile { -private: - DBLite _db; - DBFile _dbfile; - -public: - this(DBLite dblite, DBFile dbfile) { _db = dblite; _dbfile = dbfile; } - - @property ref Identifier id() return { return _dbfile.id; } - @property string name() const nothrow @safe { return _dbfile.path; } - - Snapshot[] snapshots() { - Snapshot[] snapshots; - foreach (dbSnapshot; _db.getSnapshots(_dbfile.id)) { - snapshots ~= new Snapshot(_db, dbSnapshot); - } - return snapshots; - } -} diff --git a/source/cdcdb/version_.d b/source/cdcdb/version_.d index b7cd2d0..ac00177 100644 --- a/source/cdcdb/version_.d +++ b/source/cdcdb/version_.d @@ -1,3 +1,3 @@ module cdcdb.version_; -enum cdcdbVersion = "0.2.0"; +enum cdcdbVersion = "0.1.0"; diff --git a/test/app.d b/test/app.d index 821aee9..e71c394 100644 --- a/test/app.d +++ b/test/app.d @@ -3,14 +3,14 @@ import std.stdio : writeln, File; import std.file : exists, remove, read; import zstd : Level; -import core.thread : Thread, msecs, dur; - void main() { // Создаем временную базу для примера string dbPath = "./bin/example.db"; - if (exists(dbPath)) { remove(dbPath); } + if (exists(dbPath)) { + remove(dbPath); + } Context context; @@ -31,18 +31,15 @@ void main() ubyte[] data2 = cast(ubyte[]) "Hello, updated cdcdb!".dup; // Создание первого снимка - Snapshot snap1 = storage.newSnapshot("example_file", data1, context, "Версия 1.0"); + auto snap1 = storage.newSnapshot("example_file", data1, context, "Версия 1.0"); if (snap1) { writeln("Создан снимок с ID: ", snap1.id); writeln("Файл: ", snap1.file); writeln("Размер: ", snap1.length, " байт"); writeln("Статус: ", snap1.status); - writeln("Время: ", snap1.created); } - Thread.sleep( dur!("msecs")( 50 ) ); - // Создание второго снимка (обновление) auto snap2 = storage.newSnapshot("example_file", data2, context, "Версия 2.0"); if (snap2) @@ -71,9 +68,9 @@ void main() writeln("Хэш совпадает: ", lastSnap.sha256 == digest!SHA256(restoredData)); } - // Удаление файла - if (storage.deleteFile("example_file")) - writeln("Файл example_file удален."); + // Удаление снимков по метке + long deleted = storage.removeSnapshots("example_file"); + writeln("Удалено снимков: ", deleted); // Проверка: снимки удалены auto remaining = storage.getSnapshots("example_file");