Compare commits

..
98 Commits
Author SHA1 Message Date
gvsafronov d511e347df Update README.md 2026-10-06 21:58:56 +00:00
gvsafronov 0394ad893e Delete internal/storage/aof.go 2026-10-06 21:43:05 +00:00
gvsafronov 3f4ad87e59 Update internal/storage/transactions.go 2026-10-06 21:42:52 +00:00
gvsafronov bd6c8acda6 Update internal/storage/collection.go 2026-10-06 21:42:15 +00:00
gvsafronov dd5f11fd3d Update README.md 2026-10-06 21:41:33 +00:00
gvsafronov b75dffab84 Update README.md 2026-10-06 21:35:16 +00:00
gvsafronov 2541233e40 Update internal/cluster/raft_coordinator.go 2026-10-05 21:30:44 +00:00
gvsafronov f191e5b328 Update internal/storage/transactions.go 2026-10-05 21:29:54 +00:00
gvsafronov cc4ec0c029 Update internal/repl/pager.go 2026-10-05 21:29:14 +00:00
gvsafronov 06e9558839 Update internal/repl/repl.go 2026-10-05 21:28:43 +00:00
gvsafronov 3f1748bd6c Update internal/repl/repl.go 2026-10-05 21:27:58 +00:00
gvsafronov 79c1a0171e Update internal/config/dynamic_config.go 2026-10-05 21:27:17 +00:00
gvsafronov ba4f7bfaa7 Update internal/config/config.go 2026-10-05 21:26:41 +00:00
gvsafronov 1607f4108d Update cmd/futriis/main.go 2026-10-05 21:25:52 +00:00
gvsafronov 7804ce2338 Update internal/migration/schema_migrator.go 2026-10-04 21:04:40 +00:00
gvsafronov 904f8081bd Upload files to "internal/repl" 2026-10-04 21:03:46 +00:00
gvsafronov d3353b85ba Delete internal/repl/repl.go 2026-10-04 21:03:30 +00:00
gvsafronov 5762b7f6db Upload files to "internal/storage" 2026-10-04 21:03:01 +00:00
gvsafronov b4ccb910c4 Delete internal/storage/document.go 2026-10-04 21:02:48 +00:00
gvsafronov 43fda3f796 Upload files to "internal/storage" 2026-10-04 21:02:26 +00:00
gvsafronov cfe1538fcf Upload files to "internal/storage" 2026-10-04 21:02:12 +00:00
gvsafronov f0694ca563 Delete internal/storage/collection.go 2026-10-04 21:01:50 +00:00
gvsafronov 3c25122cd1 Delete internal/storage/persistence.go 2026-10-04 20:59:32 +00:00
gvsafronov 523f12fec0 Update internal/backup/backup_scheduler.go 2026-10-04 20:39:51 +00:00
gvsafronov 7fd163af92 Update internal/serializer/msgpack.go 2026-10-04 20:18:32 +00:00
gvsafronov afbc88c3e8 Update internal/compression/compression.go 2026-10-04 18:22:30 +00:00
gvsafronov c9a5c8307c Upload files to "internal/cluster" 2026-10-04 17:48:50 +00:00
gvsafronov 0fcc0998ba Delete internal/cluster/raft_coordinator.go 2026-10-04 17:48:27 +00:00
gvsafronov 6c599b94bf Upload files to "internal/storage" 2026-10-04 17:47:46 +00:00
gvsafronov ae12264e27 Delete internal/storage/transactions.go 2026-10-04 17:47:19 +00:00
gvsafronov a7a3cd8cdb Delete cmd/futriis/main-old.go 2026-10-04 17:47:02 +00:00
gvsafronov 73061c2883 Upload files to "cmd/futriis" 2026-10-04 17:46:49 +00:00
gvsafronov 7fb359a462 Update cmd/futriis/main-old.go 2026-10-04 17:46:09 +00:00
gvsafronov ec05741a70 Upload files to "internal/cluster" 2026-10-03 22:53:22 +00:00
gvsafronov b418243503 Delete internal/cluster/node.go 2026-10-03 22:53:07 +00:00
gvsafronov 4adf1b62db Upload files to "internal/repl" 2026-10-03 22:51:14 +00:00
gvsafronov 96930e8e42 Delete internal/repl/repl.go 2026-10-03 22:50:59 +00:00
gvsafronov f0e28510e9 Delete internal/plugin/3plugin.go 2026-10-03 22:50:36 +00:00
gvsafronov e96c5a7a24 Upload files to "internal/plugin" 2026-10-03 22:50:26 +00:00
gvsafronov a9d6aa42e5 Update internal/plugin/3plugin.go 2026-10-03 22:50:05 +00:00
gvsafronov 78bff3e4d8 Upload files to "internal/storage" 2026-10-03 22:49:20 +00:00
gvsafronov d8673b747f Delete internal/storage/trigger.go 2026-10-03 22:49:08 +00:00
gvsafronov 96dc6311ea Upload files to "internal/storage" 2026-10-03 22:48:32 +00:00
gvsafronov 93385022d2 Delete internal/storage/document.go 2026-10-03 22:48:17 +00:00
gvsafronov e41f557719 Upload files to "internal/repl" 2026-10-03 22:42:49 +00:00
gvsafronov 452c8853f4 Delete internal/repl/repl.go 2026-10-03 22:42:34 +00:00
gvsafronov 2a9114083b Upload files to "internal/storage" 2026-10-03 22:42:04 +00:00
gvsafronov 13fe261b40 Upload files to "internal/storage" 2026-10-03 22:41:53 +00:00
gvsafronov 73773c27da Delete internal/storage/audit.go 2026-10-03 22:41:35 +00:00
gvsafronov b934417417 Delete internal/storage/trigger.go 2026-10-03 22:41:28 +00:00
gvsafronov 5422e39ab2 Delete internal/plugin/2plugin.go 2026-10-03 22:40:50 +00:00
gvsafronov 98bae0cbd6 Upload files to "internal/plugin" 2026-10-03 22:40:40 +00:00
gvsafronov 52d0216676 Update internal/plugin/2plugin.go 2026-10-03 22:40:17 +00:00
gvsafronov 3a1de92233 Upload files to "internal/commands" 2026-10-03 22:09:29 +00:00
gvsafronov 56a91d9fc8 Delete internal/commands/commands.go 2026-10-03 22:09:14 +00:00
gvsafronov 1559e12a51 Update README.md 2026-10-03 21:26:09 +00:00
gvsafronov 2e84b86d23 Update README.md 2026-10-03 21:24:45 +00:00
gvsafronov 6b9ee6b3d2 Update README.md 2026-10-03 21:24:07 +00:00
gvsafronov a373f05b18 Upload files to "internal/repl" 2026-10-03 21:16:32 +00:00
gvsafronov a73aa8bc9d Delete internal/repl/repl.go 2026-10-03 21:16:16 +00:00
gvsafronov a024994943 Upload files to "internal/storage" 2026-10-03 21:15:29 +00:00
gvsafronov aaada4008c Upload files to "internal/storage" 2026-10-03 21:15:14 +00:00
gvsafronov c5a5286314 Upload files to "internal/storage" 2026-10-03 21:14:48 +00:00
gvsafronov b26a42ab26 Delete internal/storage/engine.go 2026-10-03 21:14:20 +00:00
gvsafronov d0613d6aa1 Delete internal/storage/document.go 2026-10-03 21:14:12 +00:00
gvsafronov 11e79007cc Delete internal/storage/audit.go 2026-10-03 21:14:03 +00:00
gvsafronov 6b3317ce94 Delete internal/plugin/old_plugin.go 2026-10-03 21:13:30 +00:00
gvsafronov e5b34c75d8 Upload files to "internal/plugin" 2026-10-03 21:13:19 +00:00
gvsafronov d4ea0533d2 Update internal/plugin/old_plugin.go 2026-10-03 21:12:52 +00:00
gvsafronov 9b5bb176b3 Delete cmd/futriis/old_main.go 2026-10-03 20:40:15 +00:00
gvsafronov a533d973c0 Upload files to "cmd/futriis" 2026-10-03 20:40:05 +00:00
gvsafronov 7fd2addabe Update cmd/futriis/old_main.go 2026-10-03 20:39:37 +00:00
gvsafronov 5ffccfe885 Delete internal/api/old-http.go 2026-10-03 20:38:39 +00:00
gvsafronov ffc66dbed7 Upload files to "internal/api" 2026-10-03 20:38:28 +00:00
gvsafronov afff868301 Update internal/api/old-http.go 2026-10-03 20:37:40 +00:00
gvsafronov 87bed4495b Upload files to "internal/repl" 2026-10-03 20:36:36 +00:00
gvsafronov 58ba3b363e Delete internal/repl/repl.go 2026-10-03 20:36:15 +00:00
gvsafronov d59b878cd4 Delete internal/plugin/old_plugin.go 2026-10-03 20:35:26 +00:00
gvsafronov 54d1c0ba09 Upload files to "internal/plugin" 2026-10-03 20:35:11 +00:00
gvsafronov f480b910fc Update internal/plugin/old_plugin.go 2026-10-03 20:34:46 +00:00
gvsafronov a772e2d2a0 Delete internal/migration/old_schema_migrator.go 2026-10-03 20:33:50 +00:00
gvsafronov 90ca83f9a3 Upload files to "internal/migration" 2026-10-03 20:33:34 +00:00
gvsafronov 16267d2ea8 Update internal/migration/old_schema_migrator.go 2026-10-03 20:32:50 +00:00
gvsafronov 5e230b1a9c Upload files to "internal/storage" 2026-10-03 20:32:20 +00:00
gvsafronov ab48d50988 Upload files to "internal/storage" 2026-10-03 20:31:37 +00:00
gvsafronov bc445ad7be Upload files to "internal/storage" 2026-10-03 20:30:46 +00:00
gvsafronov e895568beb Delete internal/storage/document.go 2026-10-03 20:29:33 +00:00
gvsafronov b8217788e4 Delete internal/storage/collection.go 2026-10-03 20:29:24 +00:00
gvsafronov 9c6400ba3e Delete internal/storage/runtime_limits.go 2026-10-03 20:28:57 +00:00
gvsafronov c736906ad6 Delete internal/storage/transactions.go 2026-10-03 20:28:48 +00:00
gvsafronov 69385655a2 Delete internal/storage/skiplist.go 2026-10-03 20:28:34 +00:00
gvsafronov 4bb819b397 Update README.md 2026-10-03 20:00:24 +00:00
gvsafronov 82d073dd82 Delete internal/storage/collection-old.go 2026-09-29 20:45:25 +00:00
gvsafronov 0153d30d05 Delete internal/storage/document-old.go 2026-09-29 20:44:40 +00:00
gvsafronov 3ebbf8a55c Upload files to "internal/storage" 2026-09-29 20:43:58 +00:00
gvsafronov fbd5f209ca Update internal/storage/collection-old.go 2026-09-29 20:43:11 +00:00
gvsafronov 955fa27ab3 Update internal/storage/document-old.go 2026-09-29 20:42:23 +00:00
gvsafronov 233b280835 Update README.md 2026-09-29 18:50:47 +00:00
22 changed files with 8634 additions and 7647 deletions

No files matched your search

+98 -151
View File
@@ -41,7 +41,7 @@
<li><a href="#тестирование">Тестирование</a></li> <li><a href="#тестирование">Тестирование</a></li>
<li><a href="#crud-операции">CRUD операции</a></li> <li><a href="#crud-операции">CRUD операции</a></li>
<li><a href="#индексы">Индексы</a></li> <li><a href="#индексы">Индексы</a></li>
<li><a href="#транзакции">Транзакции</a></li> <li><a href="#batch">Batch</a></li>
<li><a href="#кластеризация-и-шардинг">Кластеризация и шардинг</a></li> <li><a href="#кластеризация-и-шардинг">Кластеризация и шардинг</a></li>
<li><a href="#backpressure">Backpressure</a></li> <li><a href="#backpressure">Backpressure</a></li>
<li><a href="#геораспределенная-миграция">Геораспределенная миграция</a></li> <li><a href="#геораспределенная-миграция">Геораспределенная миграция</a></li>
@@ -68,14 +68,15 @@
futriix — это лёгкая распределённая NoSQL‑СУБД на Go с MongoDB‑совместимым интерфейсом. Работает в памяти (in‑memory), использует неблокирующие структуры (wait‑free и lock‑free) и алгоритм консенсуса Raft — что даёт предсказуемую производительность под высокой нагрузкой.<br> futriix — это лёгкая распределённая NoSQL‑СУБД на Go с MongoDB‑совместимым интерфейсом. Работает в памяти (in‑memory), использует неблокирующие структуры (wait‑free и lock‑free) и алгоритм консенсуса Raft — что даёт предсказуемую производительность под высокой нагрузкой.<br>
Это HTAP‑система: сочетает OLTP (с ACID‑транзакциями) и OLAP (через индексы, триггеры, Lua‑плагины и аналитические функции) — можно обрабатывать и анализировать данные почти в реальном времени. <br> Это HTAP‑система: сочетает OLTP (Batch-команды вместо ACID‑транзакций) и OLAP (через индексы, триггеры, Lua‑плагины и аналитические функции) — можно обрабатывать и анализировать данные почти в реальном времени. <br>
В основе управления данными лежат — временные метки: они обеспечивают версионирование записей, корректную работу MVCC и упорядочивание событий в распределённой среде. Это позволяет точно отслеживать изменения, гарантировать согласованность данных между узлами и корректно разрешать конфликты при параллельных обновлениях. <br> В основе управления данными лежат — временные метки: они обеспечивают версионирование записей и упорядочивание событий в распределённой среде. Это позволяет точно отслеживать изменения, гарантировать согласованность данных между узлами и корректно разрешать конфликты при параллельных обновлениях. <br>
Ключевые механизмы надёжности и масштабируемости: Ключевые механизмы надёжности и масштабируемости:
* WAL — устойчивость к сбоям * WAL — устойчивость к сбоям
* MVCC — изоляция транзакций * Batch — атомарные группы операций
* SAGA — для внешних batch
* Горизонтальное масштабирование * Горизонтальное масштабирование
@@ -215,19 +216,19 @@ futriix — это лёгкая распределённая NoSQL‑СУБД н
|----------|----------|----------| |----------|----------|----------|
| **Хранилище (Storage)** | `sync.Map` | Конкурентная хэш‑таблица для хранения баз данных, обеспечивает wait‑free чтение и запись | | **Хранилище (Storage)** | `sync.Map` | Конкурентная хэш‑таблица для хранения баз данных, обеспечивает wait‑free чтение и запись |
| | Атомарные счётчики (`atomic.Int64`) | Для отслеживания общего количества документов без блокировок | | | Атомарные счётчики (`atomic.Int64`) | Для отслеживания общего количества документов без блокировок |
| **База данных (Database)** | `sync.Map` коллекций | Синоним термнина "база данных" в реляционных СУБД | | **База данных (Database)** | `sync.Map` коллекций | Синоним термина "база данных" в реляционных СУБД |
| | Мьютексы (`sync.RWMutex`) | Для операций изменения структуры (создание/удаление коллекций) | | | Мьютексы (`sync.RWMutex`) | Для операций изменения структуры (создание/удаление коллекций) |
| **Коллекция (Collection)** | `sync.Map` документов | Ключ: ID документа, значение: указатель на `Document` | | **Коллекция (Collection)** | `sync.Map` документов | Ключ: ID документа, значение: указатель на `Document` |
| | `sync.Map` индексов | Отдельное хранилище для инвертированных индексов | | | `sync.Map` индексов | Отдельное хранилище для инвертированных индексов |
| | Атомарные счётчики | Количество документов (`docCount`) и размер в байтах (`sizeBytes`) | | | Атомарные счётчики | Количество документов (`docCount`) и размер в байтах (`sizeBytes`) |
| **Индекс (Index)** | `sync.Map` (значение → ID) | Реализация инвертированного индекса | | **Индекс (Index)** | **`lock-free skip-list`** (значение → ID) | Реализация инвертированного индекса. Заменяет `sync.Map`: устраняет внутренние блокировки, даёт упорядоченный обход и эффективный префиксный поиск |
| | Уникальный индекс | Прямая lookup‑операция $O(1)$ | | | Уникальный индекс | Прямая lookup‑операция $O(\log n)$ в среднем (было $O(1)$ у `sync.Map`, но без упорядоченности и с внутренними блокировками) |
| | Неуникальный индекс | Range‑сканирование с оптимизированным сравнением | | | Неуникальный индекс | Range‑сканирование в порядке возрастания ключей (детерминированный результат) |
| | Составной индекс | Конкатенация значений полей с разделителем `"|"` | | | Составной индекс | Конкатенация значений полей с разделителем `"|"` |
| **Документ (Document)** | `map[string]interface{}` | Динамическая схема полей | | **Документ (Document)** | `map[string]interface{}` | Динамическая схема полей |
| | Версия (`uint64`) | Для оптимистичных блокировок | | | Версия (`uint64`) | Для оптимистичных блокировок |
| | Временные метки | Создание, обновление, удаление (Unix‑миллисекунды) | | | Временные метки | Создание, обновление, удаление (Unix‑миллисекунды) |
| **Транзакция (Transaction)** | WAL (`Write‑Ahead Log`) | Журнал упреждающей записи для durability | | **Batch (Batch)** | WAL (`Write‑Ahead Log`) | Журнал упреждающей записи для durability |
| | Кэш операций | Локальное хранение изменений до коммита | | | Кэш операций | Локальное хранение изменений до коммита |
| | Список блокировок | Отслеживание затронутых документов | | | Список блокировок | Отслеживание затронутых документов |
| **Триггер (Trigger)** | `sync.Map` с составным ключом | `collection|event|name` для быстрого поиска | | **Триггер (Trigger)** | `sync.Map` с составным ключом | `collection|event|name` для быстрого поиска |
@@ -246,7 +247,7 @@ futriix — это лёгкая распределённая NoSQL‑СУБД н
|:-:|----------|-------------------|-----------|-----------| |:-:|----------|-------------------|-----------|-----------|
| 1 | **Wait-free операции доступа** | • Чтение: `sync.Map.Load()` — атомарная операция без блокировок<br>• Запись: `sync.Map.Store()` — атомарная замена значения<br>• Удаление: `sync.Map.Delete()` — атомарное удаление | **O(1) амортизированное** | *Амортизированная сложность — средняя стоимость операции в серии операций, даже если отдельные операции могут быть дорогими* | | 1 | **Wait-free операции доступа** | • Чтение: `sync.Map.Load()` — атомарная операция без блокировок<br>• Запись: `sync.Map.Store()` — атомарная замена значения<br>• Удаление: `sync.Map.Delete()` — атомарное удаление | **O(1) амортизированное** | *Амортизированная сложность — средняя стоимость операции в серии операций, даже если отдельные операции могут быть дорогими* |
| 2 | **Поиск по индексу** | **Уникальный индекс:**<br>1. `index.data.Load(value)` → получение ID документа<br>2. `docs.Load(ID)` → возврат документа<br><br>**Неуникальный индекс:**<br>1. `index.data.Range()` → сканирование всех записей<br>2. Сравнение значений с учётом типов (`compareValues`)<br>3. Сбор всех подходящих ID | **Уникальный: O(1)**<br><br>**Неуникальный: O(n)** | `n` — размер индекса | | 2 | **Поиск по индексу** | **Уникальный индекс:**<br>1. `index.data.Load(value)` → получение ID документа<br>2. `docs.Load(ID)` → возврат документа<br><br>**Неуникальный индекс:**<br>1. `index.data.Range()` → сканирование всех записей<br>2. Сравнение значений с учётом типов (`compareValues`)<br>3. Сбор всех подходящих ID | **Уникальный: O(1)**<br><br>**Неуникальный: O(n)** | `n` — размер индекса |
| 3 | **Оптимистичная блокировка транзакций** | 1. `BeginTransaction()` → фиксация текущих версий документа<br>2. Выполнение операций в локальном кэше<br>3. `Prepare()` → проверка версий всех затронутых документов<br>4. `Commit()` → применение изменений, инкремент версий<br>5. При конфликте → `Abort()` и повтор | **E[T] = (1 / (1 - P_conflict)) × [O(M) + O(M × (V + K)) + O(WAL)]** | `𝔼[T]` — мат. ожидание времени выполнения<br>`1 - P_conflict` — вероятность успешного коммита<br>`M` — количество документов в транзакции<br>`V` — количество полей в документе<br>`K` — количество индексов<br>`WAL` — размер журнала | | 3 | **Batch (атомарная группа операций)** | 1. `NewBatch()` → создание batch<br>2. Добавление операций (`AddInsert`, `AddUpdate`, `AddDelete`, `AddRestore`)<br>3. `Commit()` → запись всего batch в WAL с fsync<br>4. Применение операций к in-memory структурам<br>5. При ошибке — откат (best-effort) | **E[T] = O(M) + O(WAL)** | `M` — количество операций в batch<br>`WAL` — размер журнала |
| 4 | **Валидация ограничений (Constraints)** | Последовательная проверка документа:<br>1. Обязательные поля → проверка наличия<br>2. Минимальные значения (`MinValues`) → сравнение чисел<br>3. Максимальные значения (`MaxValues`) → сравнение чисел<br>4. Regex паттерны → сопоставление строк<br>5. Enum значения → поиск в разрешённом списке | **O(k + m)** | `k` — количество полей в документе<br>`m` — количество ограничений | | 4 | **Валидация ограничений (Constraints)** | Последовательная проверка документа:<br>1. Обязательные поля → проверка наличия<br>2. Минимальные значения (`MinValues`) → сравнение чисел<br>3. Максимальные значения (`MaxValues`) → сравнение чисел<br>4. Regex паттерны → сопоставление строк<br>5. Enum значения → поиск в разрешённом списке | **O(k + m)** | `k` — количество полей в документе<br>`m` — количество ограничений |
| 5 | **Выполнение триггеров** | Для каждого триггера события:<br>1. Проверка `Enabled`<br>2. Оценка условия (`TriggerCondition`)<br>3. Сопоставление оператора с полем документа<br>4. Выполнение действия (`abort`/`skip`/`modify`/`log`)<br>5. Логирование с временной меткой и длительностью | **O(T × (C + O))** | `T` — количество триггеров на событие<br>`C` — количество условий в триггере<br>`O` — количество операций в триггере | | 5 | **Выполнение триггеров** | Для каждого триггера события:<br>1. Проверка `Enabled`<br>2. Оценка условия (`TriggerCondition`)<br>3. Сопоставление оператора с полем документа<br>4. Выполнение действия (`abort`/`skip`/`modify`/`log`)<br>5. Логирование с временной меткой и длительностью | **O(T × (C + O))** | `T` — количество триггеров на событие<br>`C` — количество условий в триггере<br>`O` — количество операций в триггере |
| 6 | **Мягкое удаление** | **SoftDelete:**<br>1. `doc.DeletedAt = now`<br>2. `doc.Version++`<br>3. `docs.Store(id, doc)` — сохранение, не удаление<br>4. `removeFromIndexes(doc)` — исключение из поиска<br><br>**Restore:**<br>1. `doc.DeletedAt = 0`<br>2. `doc.Version++`<br>3. `docs.Store(id, doc)`<br>4. `addToIndexes(doc)` — возврат в индексы | **O(1) + O(K)** | `K` — количество индексов в коллекции | | 6 | **Мягкое удаление** | **SoftDelete:**<br>1. `doc.DeletedAt = now`<br>2. `doc.Version++`<br>3. `docs.Store(id, doc)` — сохранение, не удаление<br>4. `removeFromIndexes(doc)` — исключение из поиска<br><br>**Restore:**<br>1. `doc.DeletedAt = 0`<br>2. `doc.Version++`<br>3. `docs.Store(id, doc)`<br>4. `addToIndexes(doc)` — возврат в индексы | **O(1) + O(K)** | `K` — количество индексов в коллекции |
@@ -271,6 +272,7 @@ futriix — это лёгкая распределённая NoSQL‑СУБД н
| Мягкое удаление | Флаг deleted_at + удаление из индексов | Возможность восстановления | | Мягкое удаление | Флаг deleted_at + удаление из индексов | Возможность восстановления |
| Асинхронные триггеры | Буферизированный канал | Не блокируют основную операцию | | Асинхронные триггеры | Буферизированный канал | Не блокируют основную операцию |
| Плагины в песочнице | Изолированное Lua‑состояние | Безопасное расширение | | Плагины в песочнице | Изолированное Lua‑состояние | Безопасное расширение |
| Batch | WAL + локальный кэш | Атомарность без ACID |
<br> <br>
<br> <br>
@@ -287,7 +289,7 @@ futriix — это лёгкая распределённая NoSQL‑СУБД н
| Поиск по неуникальному индексу | $O(n)$ | $n$ — размер индекса | | Поиск по неуникальному индексу | $O(n)$ | $n$ — размер индекса |
| Обновление документа | $O(1) + O(k)$ | + проверка версий | | Обновление документа | $O(1) + O(k)$ | + проверка версий |
| Удаление документа | $O(1) + O(k)$ | + возможная очистка индексов | | Удаление документа | $O(1) + O(k)$ | + возможная очистка индексов |
| Транзакция (commit) | $O(m) + \text{Raft}$ | $m$ — количество операций | | Batch (commit) | $O(m) + \text{Raft}$ | $m$ — количество операций |
| Валидация ограничений | $O(f + c)$ | $f$ — поля, $c$ — ограничения | | Валидация ограничений | $O(f + c)$ | $f$ — поля, $c$ — ограничения |
| Экспорт базы данных | $O(N)$ | $N$ — общее количество документов | | Экспорт базы данных | $O(N)$ | $N$ — общее количество документов |
@@ -314,12 +316,12 @@ futriix — это лёгкая распределённая NoSQL‑СУБД н
- **Процессор:** 64‑битный Intel или AMD (x86‑64) - **Процессор:** 64‑битный Intel или AMD (x86‑64)
- **Оперативная память:** - **Оперативная память:**
- Linux: минимум 6 ГБ - Linux: минимум 6 ГБ
- Illumos: минимум 8 ГБ (из‑за особенностей управления памятью и ZFS по умолчанию) - Illumos: минимум 8 ГБ (из‑за особенностей управления памятью и ZFS по умолчанию)
> [!IMPORTANT] > [!IMPORTANT]
> **Важно!!!** > **Важно!!!**
> Для нагрузочного тестирования, шардинга и одновременной работы множества узлов(от 5 и выше) рекомендуется не менее 16 ГБ оперативной памяти на узел > Для нагрузочного тестирования, шардинга и одновременной работы множества узлов(от 5 и выше) рекомендуется не менее 16 ГБ оперативной памяти на узел
<br> <br>
@@ -357,7 +359,7 @@ unzip -v
> * Дисковая подсистема: SSD/NVMe; HDD допустим только для тестов > * Дисковая подсистема: SSD/NVMe; HDD допустим только для тестов
> * Файловая система: предпочтительно ZFS (особенно на Illumos) или XFS/ext4 на Linux > * Файловая система: предпочтительно ZFS (особенно на Illumos) или XFS/ext4 на Linux
> * Сеть: стабильное соединение между узлами, желательно 1 Gbps+; для Raft‑кластеров важна низкая задержка (latency) > * Сеть: стабильное соединение между узлами, желательно 1 Gbps+; для Raft‑кластеров важна низкая задержка (latency)
> * Время: синхронизация времени между узлами (NTP/Chrony) обязательна для корректной работы MVCC, WAL и транзакций > * Время: синхронизация времени между узлами (NTP/Chrony) обязательна для корректной работы WAL и batch-операций
<br> <br>
@@ -392,7 +394,7 @@ go version
* **[wal]** — журнал предзаписи (сегменты, синхронизация) * **[wal]** — журнал предзаписи (сегменты, синхронизация)
* **[mvcc]** — многоверсионность (версии, TTL) * **[batch]** — настройки batch-операций (размер, таймауты)
* **[saga]** — распределённые транзакции * **[saga]** — распределённые транзакции
@@ -544,8 +546,8 @@ curl -X POST http://localhost:8080/api/v1/config \
"replication.sync_replication": true, "replication.sync_replication": true,
"wal.segment_size_mb": 128, "wal.segment_size_mb": 128,
"wal.sync_interval_sec": 3, "wal.sync_interval_sec": 3,
"mvcc.max_versions_per_doc": 20, "batch.max_size": 1000,
"mvcc.retention_days": 14, "batch.timeout_ms": 5000,
"saga.enabled": true, "saga.enabled": true,
"saga.coordinator_count": 5, "saga.coordinator_count": 5,
"backpressure.enabled": true, "backpressure.enabled": true,
@@ -618,7 +620,7 @@ $ ./futriix
В субд **"futriix"** используется два журнала для ведение логов: `"futriix.log"`-основной журнал, в котором ведутся логи при работе в субд через терминал. В субд **"futriix"** используется два журнала для ведение логов: `"futriix.log"`-основной журнал, в котором ведутся логи при работе в субд через терминал.
**futriix.log** — основной системный журнал, фиксирующий все события жизненного цикла СУБД: запуск/остановку сервера, инициализацию компонентов (транзакции, Raft-координатор, ACL), состояние кластера и критические ошибки выполнения запросов. **futriix.log** — основной системный журнал, фиксирующий все события жизненного цикла СУБД: запуск/остановку сервера, инициализацию компонентов (batch, Raft-координатор, ACL), состояние кластера и критические ошибки выполнения запросов.
Журнал использует структурированный JSON-формат (для webui.log) и текстовый формат с временными метками (для futriis.log), что обеспечивает удобный парсинг и интеграцию с системами мониторинга. Журнал использует структурированный JSON-формат (для webui.log) и текстовый формат с временными метками (для futriis.log), что обеспечивает удобный парсинг и интеграцию с системами мониторинга.
@@ -629,8 +631,8 @@ $ ./futriix
### Тестирование ### Тестирование
Для проверки корректности функционирования субд на уровне исходного кода, был разработа набор из пяти тестов: (регрессионный, smoke-тест, функциональный, интеграционный и нагрузочный). Для проверки корректности функционирования субд на уровне исходного кода, был разработан набор из пяти тестов: (регрессионный, smoke-тест, функциональный, интеграционный и нагрузочный).
Разработанный набор из пяти вышеупомянутых тестов на языке Lua обеспечивает комплексную проверку всех ключевых компонентов СУБД: CRUD-операций, индексов, транзакций, ограничений целостности, ACL, триггеров, MVCC-версионирования, а также взаимодействия API с хранилищем и кластерной координации. Разработанный набор из пяти вышеупомянутых тестов на языке Lua обеспечивает комплексную проверку всех ключевых компонентов СУБД: CRUD-операций, индексов, batch-операций, ограничений целостности, ACL, триггеров, версионирования через WAL, а также взаимодействия API с хранилищем и кластерной координации.
Регрессионный тест гарантирует, что изменения кода не нарушили существующую функциональность, smoke-тест выполняет быструю проверку доступности и базовой работоспособности системы. Регрессионный тест гарантирует, что изменения кода не нарушили существующую функциональность, smoke-тест выполняет быструю проверку доступности и базовой работоспособности системы.
Функциональный и интеграционный тесты проверяют корректность реализации бизнес-требований и взаимодействие между компонентами, а нагрузочный тест оценивает производительность (латентность, пропускную способность) под различными сценариями использования. Функциональный и интеграционный тесты проверяют корректность реализации бизнес-требований и взаимодействие между компонентами, а нагрузочный тест оценивает производительность (латентность, пропускную способность) под различными сценариями использования.
@@ -690,7 +692,7 @@ Futriix поддерживает привычный MongoDB‑синтаксис
> [!NOTE] > [!NOTE]
>**Выполнение операций**<br> >**Выполнение операций**<br>
> Операции выполняются в рамках гарантий изоляции, обеспечиваемых MVCC и протоколом консенсуса Raft. > Операции выполняются в рамках гарантий изоляции, обеспечиваемых WAL и протоколом консенсуса Raft.
> Для распределённых транзакций применяется протокол Saga. > Для распределённых транзакций применяется протокол Saga.
@@ -942,147 +944,94 @@ futriix:~> db.startSession()
<p align="right">(<a href="#readme-top">К началу</a>)</p> <p align="right">(<a href="#readme-top">К началу</a>)</p>
## Транзакции ## Batch
В СУБД **futriix** для обеспечения надёжности (durability), атомарности и быстрого восстановления базы данных реализована система журналирования на основе **WAL (Write-Ahead Log)**.
Все мутации — как групповые (batch), так и одиночные (прямые вызовы `Insert`, `Update`, `Delete`, `Restore` из REPL, HTTP-API и Lua-плагинов) — проходят через WAL. Это даёт:
- **Атомарность** для сложных составных операций (batch).
- **Гарантированную сохранность** каждой отдельной мутации.
- **Быстрое восстановление** через воспроизведение WAL при старте.
- **Единый источник истины** — один журнал, один fsync-путь, один формат хранения.
В СУБД **futriix** для обеспечения надёжности (durability) и быстрого восстановления БД из резервной копии реализована полноценная поддержка WAL (Write-Ahead Logging). Журнал WAL по умолчанию хранится в файле futriix.wal, находящемся в каталоге futriix. ### Что такое Batch
СУБД поддерживает ACID‑транзакции с MVCC (Multi‑Version Concurrency Control) — для локальной атомарности и изоляции на уровне отдельных узлов кластера. Для распределённых операций используется паттерн SAGA с оркестратором: глобальная согласованность достигается через последовательность компенсируемых локальных транзакций, управляемых централизованным оркестратором, который отслеживает состояние шагов, инициирует компенсацию при сбоях и гарантирует завершение сценария в согласованном состоянии. **Batch** — это группа операций, применяемых **атомарно**: либо все операции из группы применяются, либо ни одна. Batch заменяет полноценные ACID-транзакции в локальном узле и работает по принципу «всё или ничего» через WAL.
Парадигма eventual consistency обеспечивает согласованность данных в распределённой среде: изменения распространяются асинхронно между узлами, а финальное согласованное состояние достигается со временем с учётом гарантий доставки событий и правил разрешения конфликтов. Такой подход позволяет сохранить высокую доступность и производительность даже при временных сетевых сбоях. **Ключевые свойства batch:**
Доступны команды, с восстановлением после сбоев через журнал предзаписи: - **Атомарность** через WAL: сначала весь batch сериализуется и записывается в WAL с `fsync`, только после успешной записи операции применяются к in-memory структурам.
- **Откат при ошибке применения**: если операция падает на середине применения, уже применённые операции откатываются (best-effort) — полная атомарность гарантируется на уровне WAL при восстановлении.
- **Короткое время жизни**: batch создаётся, наполняется операциями и коммитится в рамках одной логической операции. Не поддерживает savepoints, таймауты, распределённую координацию.
- **Реестр активных batch'ей**: `BatchManager.activeBatches` (см. `runtime_limits.go`) защищает документы, участвующие в незакоммиченном batch, от вытеснения (eviction) при нехватке памяти.
* `startSession()`- Начать сессию (устойчивый контекст взаимодействия клиента с СУБД, в рамках которого поддерживается состояние) в субд в рамках которой будет открыта транзакция **Операции batch:**
* `startTransaction()`- Начать транзакцию
* `commitTransaction()`- Сделать коммит транзакцию
* `abortTransaction()` - Прервать транзакцию
> [!IMPORTANT] | Команда | Действие |
> **Матрица гарантий согласованности** <br> |---------|----------|
> Ниже приведена матрица гарантий согласованности, призванная дать ответ на потенциальный возможный вопрос пользователя: "А вдруг у меня | `NewBatch(db, coll)` | Создать новый batch для указанной БД и коллекции |
> данные потеряются"? | `batch.AddInsert(doc)` | Добавить операцию вставки документа |
| `batch.AddUpdate(docID, updates)` | Добавить операцию обновления |
# Матрица гарантий согласованности Futriix | `batch.AddDelete(docID)` | Добавить операцию удаления |
| `batch.AddRestore(docID)` | Добавить операцию восстановления |
| Операция | Уровень изоляции | Поведение при нормальной работе | Поведение при сетевых сбоях | Восстановление после сбоя | | `batch.Commit()` | Применить batch атомарно через WAL |
|----------|------------------|--------------------------------|----------------------------|---------------------------|
| **Чтение (Read)** | **Read Committed** + MVCC | • Чтение всегда возвращает только закоммиченные данные<br>• MVCC предоставляет снапшот на момент начала чтения<br>• Используется Visibility Map для быстрого доступа к версиям<br>• Кеширование через ReadTimestampCache | • При потере соединения - операция повторяется автоматически (идиемпотентность)<br>• Если узел недоступен - запрос перенаправляется к реплике (Raft)<br>• Чтение из кэша возможно при недоступности основного узла | • После восстановления узла - чтение продолжается с момента прерывания<br>• WAL не требуется для чтения (только для записи)<br>• Снапшоты MVCC восстанавливаются из documentVersions |
| **Запись (Write)** | **Read Committed** | • Запись сначала сохраняется в WAL (атомарно)<br>• Затем применяется к in-memory структурам<br>• Индексы обновляются атомарно с документом<br>• Атомарное обновление метаданных коллекции | • Если WAL запись не подтверждена - операция не применяется<br>• При сбое до коммита - данные не теряются (WAL)<br>• При потере лидера (Raft) - запись перенаправляется к новому лидеру<br>• Используется fsync для гарантии записи на диск | • После восстановления - запись повторяется из WAL<br>• Автоматическое применение WAL при загрузке<br>• Консистентность через LSN (Log Sequence Number)<br>• Segmented WAL позволяет восстанавливать частичные записи |
| **Транзакция (BEGIN...COMMIT)** | **Read Committed / Repeatable Read** (MVCC) | • Транзакция атомарна: все или ничего<br>• MVCC предоставляет снапшот на момент начала транзакции<br>• Операции внутри транзакции изолированы<br>• Savepoints для частичного отката<br>• Deadlock detection с автоматическим abort‑ом | • При сбое в середине транзакции — автоматический откат<br>• WAL гарантирует восстановление закоммиченных транзакций<br>• SAGA с оркестратором для распределённых транзакций с таймаутом (10 сек)<br>• Pending‑шаги Saga автоматически компенсируются при таймауте | • Асинхронное восстановление из WAL<br>• Восстановление только закоммиченных транзакций<br>• Pending‑шаги SAGA проверяются на таймаут<br>• Recovery Manager обрабатывает записи параллельно |
| **Индексация** | **Read Committed** | • Индексы обновляются атомарно с документом<br>• Уникальные индексы проверяются перед вставкой<br>• Индексы хранятся в sync.Map (wait-free)<br>• Поддержка составных индексов | • Индексы восстанавливаются из данных при загрузке<br>• При сбое - индексы перестраиваются автоматически<br>• Консистентность через LSN<br>• Уникальные индексы проверяются при восстановлении | • Индексы перестраиваются из документов при старте<br>• WAL содержит операции обновления индексов<br>• Автоматическое восстановление целостности индексов<br>• Проверка дубликатов при восстановлении уникальных индексов |
| **Удаление (Delete)** | **Read Committed** | • Soft delete сохраняет данные (метка deleted_at)<br>• Удаление из индексов происходит атомарно<br>• Физическое удаление - только после подтверждения<br>• Атомарное обновление счётчиков | • При сбое - операция повторяется из WAL<br>• Soft delete защищает от потери данных<br>• Окончательное удаление - после подтверждения всеми репликами<br>• Permanent delete требует подтверждения | • Soft delete записи восстанавливаются из WAL<br>• Permanent delete повторяется из WAL<br>• Счётчики документов восстанавливаются атомарно |
| **Репликация (Raft)** | **Linearizable** (для записей) / **Read Committed** (для чтений) | • Записи проходят через Raft-журнал<br>• Линейная консистентность для записей<br>• Чтения могут быть из реплик (Read Committed)<br>• Автоматическое переизбрание лидера | • При сбое лидера - автоматический выбор нового (Raft)<br>• Запросы перенаправляются к новому лидеру<br>• Данные согласованы через журнал Raft<br>• Read-запросы могут обслуживаться репликами | • Raft журнал восстанавливается из WAL<br>• Новый лидер применяет все закоммиченные записи<br>• Реплики синхронизируются с лидером<br>• Автоматическое восстановление кластера |
| **Шардирование (Range Shards)** | **Read Committed** | • Диапазонные шарды с динамическим сплитом/мерджем<br>• Данные распределены по диапазонам ключей<br>• Предсказуемая нагрузка на узлы<br>• Возможность предварительного добавления узлов под горячие диапазоны | • При сбое узла - шард перераспределяется<br>• Данные реплицируются для отказоустойчивости<br>• Автоматический сплит при переполнении шарда<br>• Мердж при уменьшении нагрузки | • Шарды восстанавливаются из чекпоинтов<br>• WAL применяется к каждому шарду<br>• Балансировка после восстановления<br>• Автоматическое перераспределение |
| **Checkpoint / Snapshot** | **Read Committed** | • Периодическое сохранение снапшотов (каждые 5 мин)<br>• Атомарная запись с временным файлом<br>• Контрольная сумма для верификации<br>• Сжатие снапшотов (gzip)<br>• Хранение последних N чекпоинтов | • При сбое во время создания - временный файл удаляется<br>• Предыдущий чекпоинт остаётся целым<br>• Атомарное переименование гарантирует целостность<br>• WAL LSN сохраняется в чекпоинте | • Загрузка из последнего валидного чекпоинта<br>• Применение WAL после чекпоинта<br>• Проверка контрольной суммы при загрузке<br>• Автоматическое восстановление из чекпоинта |
| **WAL (Write-Ahead Log)** | **Persistent** | • Все записи сначала идут в WAL<br>• Segmented WAL с ротацией (64 MB)<br>• Пакетная запись с fsync<br>• CRC32 для верификации целостности<br>• Асинхронная запись с буферизацией | • При сбое — WAL сохраняет все записи<br>• fsync гарантирует запись на диск<br>• Segments позволяют восстанавливать частичные данные<br>• Индекс LSN для быстрого поиска | • Полное восстановление из WAL<br>• Асинхронный Recovery Manager<br>• Параллельное применение записей (4 воркера)<br>• Восстановление Saga с оркестратором (pending‑шагов и компенсаций) |
| **SAGA с оркестратором** | **Read Committed** | • Оркестрированная Saga для распределённых транзакций<br>• Последовательность шагов с компенсационными действиями для каждого этапа<br>• Оркестратор управляет порядком выполнения и обработкой отказов<br>• Таймаут на выполнение шага (10 сек) | • При сбое оркестратора — шаг остаётся в незавершённом состоянии<br>• Автоматическая компенсация при превышении таймаута<br>• Отдельные шаги могут выполняться автономно на узлах<br>• Pending шаги и компенсации сохраняются в WAL | • Восстановление pending Saga‑шагов при старте<br>• Проверка таймаутов и статуса шагов<br>• Автоматический запуск компенсационных действий для просроченных шагов<br>• Координация узлов для завершения/отката цепочки шагов |
| **Deadlock Detection** | **N/A** | • Обнаружение циклических ожиданий<br>• Автоматический abort одной из транзакций<br>• Периодическая проверка (каждую секунду)<br>• Поддержка распределённых deadlock-ов | • При сетевых задержках - возможны ложные срабатывания<br>• Abort транзакции с последующим повтором<br>• Распределённый deadlock detection через координатор | • Deadlock граф восстанавливается из активных транзакций<br>• Автоматическое разрешение при старте<br>• Логирование deadlock-ов для анализа |
| **MVCC (Multi-Version Concurrency Control)** | **Repeatable Read** (внутри транзакции) | • Каждая запись хранит несколько версий<br>• Чтение получает версию на момент начала транзакции<br>• Автоматическая очистка старых версий (каждые 5 мин)<br>• Хранение до 100 версий на документ<br>• Retention: 7 дней | • Версии сохраняются в памяти и WAL<br>• При сбое - версии восстанавливаются из WAL<br>• Visibility Map ускоряет проверку видимости<br>• ReadTimestampCache для быстрого доступа | • Версии восстанавливаются из WAL записей<br>• Автоматическая очистка устаревших версий<br>• Восстановление Visibility Map<br>• Кеш перестраивается при загрузке |
### Условные обозначения:
| Символ | Значение |
|--------|----------|
| ✅ **Гарантировано** | Операция гарантирует консистентность при любых условиях |
| ⚠️ **Частично** | Требует дополнительных проверок или имеет ограничения |
| 🔄 **Автоматически** | Восстановление происходит без вмешательства пользователя |
| 📊 **Настраивается** | Поведение можно настроить через конфигурацию |
## Ключевые гарантии:
1. **ACID**: Все операции соответствуют ACID благодаря WAL + MVCC + SAGA c оркестратором
2. **Durability**: fsync + WAL гарантируют сохранность данных
3. **Consistency**: Чекпоинты + контрольные суммы + верификация
4. **Isolation**: MVCC + Read Committed + Repeatable Read внутри транзакций
5. **Availability**: Raft + репликация + автоматическое переизбрание лидера
6. **Partition Tolerance**: SAGA c оркестратором + таймауты + автоматический откат
### Как WAL обеспечивает durability
> [!TIP] **WAL (Write-Ahead Log)** — сегментированный журнал предзаписи, который является **единственным источником истины** для durability. Каждая мутация сначала попадает в WAL, синхронизируется на диск, и только потом применяется к in-memory структурам.
> WAL- это журнал предзаписи транзакций, который в субд **futriix** выполняет следующие действия:
> 1. Хранит все операции транзакций до их фиксации (INSERT, UPDATE, DELETE)
> 2. Записывает каждое изменение последовательно с LSN (Log Sequence Number)
> 3. Используется для восстановления после сбоя через recoverFromWAL()
> 4. Формат: бинарный с CRC-контролем, размером записи и типом (Transaction/Checkpoint)
> 5. Расположение: /futriix/futriix.wal (один файл)
> [!TIP] **Ключевые свойства WAL:**
> **Кроме того существуют так называемые **Периодические снимки состояния** журнала предзаписи, которые выполняют следующие функции:**
> 1. Фиксируют состояние всех активных транзакций на момент создания
> 2. Создаются автоматически каждые **5 минут (300 секунд)**
> 3. Именуются по маске: `futriis.wal.checkpoint.{timestamp}`
> 4. Хранят сжатое состояние: последний LSN + активные транзакции
> 5. Автоматически очищаются: остаётся только 5 последних чекпоинтов
> 6. Расположение: `/futriix/wal.checkpoint.wal` (несколько файлов файл)
> **Подводя итог, хочется отметить следующее: журнал предзаписи транзакций и периодические снимки состояния работают следующим образом:**
> 1. Штатная работа: WAL пишет каждую транзакцию
> 2. Периодически: создаётся checkpoint (снимок активных транзакций)
> 3. При сбое: восстанавливаемся с последнего checkpoint + доигрываем операции из WAL
- **Сегментация**: файлы `wal_segment_<N>.log` разбиваются по 64 МБ с автоматической ротацией.
- **fsync на запись**: каждая запись (или группа записей в рамках `flushBatch`) синхронизируется с диском через `file.Sync()`.
- **CRC32 per record**: каждая запись защищена контрольной суммой, включающей LSN — обнаружение частичных/torn-записей при восстановлении.
- **WAL Index**: индекс `wal_index_<N>.json` для быстрого поиска записей по LSN.
- **Pipeline записи**: группировка нескольких команд в один fsync-цикл (`flushBatch`) для снижения накладных расходов.
- **Восстановление при старте**: `ReadAll()` читает все сегменты, применяет операции к хранилищу; это единственный путь восстановления.
- **Идемпотентность**: повторное применение операции не ломает состояние.
```sh ---
# Начало сессии (с временем создания сессии)
futriix:~> db.startSession()
✓ Session started: session_12345 at 2026-01-15 10:55:15.123
# Начало транзакции в рамках сессии ### SAGA для распределённых операций
futriix:~> session.startTransaction()
✓ Transaction started: TX_67890 at 2026-01-15 10:55:18.456
# Выполнение операций в транзакции (с временем выполнения) Для **распределённых** операций (между узлами кластера, между сервисами, для долгоживущих бизнес-процессов) используется паттерн **SAGA с оркестратором**:
futriix:~> insert employees name=New User,position=Trainee,age=22
✓ Document inserted with ID: 550e8400-e29b-41d4-a716-446655440005 (created at: 2026-01-15 10:55:21.789)
futriix:~> update employees 550e8400-e29b-41d4-a716-446655440005 status=active - Глобальная согласованность достигается через последовательность **компенсируемых локальных шагов**.
✓ Document '550e8400-e29b-41d4-a716-446655440005' updated at 2026-01-15 10:55:24.012 - Каждый шаг SAGA может использовать **batch** для гарантии атомарности внутри себя.
- Оркестратор отслеживает состояние шагов, инициирует **компенсацию** при сбоях и гарантирует завершение сценария в согласованном состоянии.
- Состояние SAGA персистентно хранится в JSON-файлах (директория `saga_states/`), при старте pending-шаги восстанавливаются, а просроченные — компенсируются.
- Компенсации **идемпотентны**: повторный вызов не ломает состояние.
- При превышении таймаута шага (по умолчанию 10 сек) автоматически запускается компенсация.
# Подтверждение транзакции (с временем коммита) **Пример SAGA с batch внутри шага:**
futriix:~> session.commitTransaction()
✓ Transaction committed successfully at 2026-01-15 10:55:27.345
# Откат транзакции (при ошибке) (с временем отката) ```go
futriix:~> session.startTransaction() saga := sagaManager.BeginSaga("order_flow")
✓ Transaction started: TX_67891 at 2026-01-15 10:56:10.123
futriix:~> insert employees name=Test User,position=Test,age=25 saga.AddStep("create_order",
✓ Document inserted with ID: 550e8400-e29b-41d4-a716-446655440006 (created at: 2026-01-15 10:56:13.456) func() error {
batch := NewBatch("shop", "orders")
batch.AddInsert(orderDoc)
batch.AddUpdate("inv_1", map[string]interface{}{"qty": 5})
return batch.Commit() // атомарность шага через WAL
},
func() error {
batch := NewBatch("shop", "orders")
batch.AddDelete(orderDoc.ID)
batch.AddUpdate("inv_1", map[string]interface{}{"qty": 10})
return batch.Commit() // атомарность компенсации
},
nil)
futriix:~> session.abortTransaction() saga.AddStep("reserve_payment",
✓ Transaction aborted, changes rolled back at 2026-01-15 10:56:16.789 func() error { return paymentClient.Reserve(orderID, amount) }, // внешний вызов
func() error { return paymentClient.Cancel(orderID) },
nil)
# Просмотр активных транзакций (с временем начала) sagaManager.Execute(saga)
futriix:~> show transactions
=== Active Transactions ===
ID: TX_67892, Status: active, Operations: 2, Started: 2026-01-15 10:57:00.123
- INSERT: company.employees [user-uuid-001]
- UPDATE: company.employees [user-uuid-002]
ID: TX_67893, Status: active, Operations: 1, Started: 2026-01-15 10:57:05.456
- DELETE: company.employees [user-uuid-003]
# Просмотр сессий (с временем создания)
futriix:~> show sessions
=== Active Sessions ===
session_12345 - Created: 2026-01-15 10:55:15.123, Last active: 2026-01-15 10:57:30.456
session_12346 - Created: 2026-01-15 10:56:20.789, Last active: 2026-01-15 10:57:15.234
# Статистика транзакций с временными метками
futriix:~> stats transactions
=== Transaction Statistics ===
Total transactions: 150
Committed: 145 (96.7%)
Aborted: 5 (3.3%)
Average duration: 234 ms
Min duration: 45 ms
Max duration: 1523 ms
Transactions by hour:
10:00 - 11:00: 45 transactions
11:00 - 12:00: 52 transactions
12:00 - 13:00: 38 transactions
``` ```
<p align="right">(<a href="#readme-top">К началу</a>)</p> <p align="right">(<a href="#readme-top">К началу</a>)</p>
@@ -1889,7 +1838,7 @@ Idle → Preparing → Migrating → Delta Sync → Validating → Completed
В субд `futriix` также как и в традиционных субд, есть механизм **Ограничений (Constraints)**. В субд `futriix` также как и в традиционных субд, есть механизм **Ограничений (Constraints)**.
**Ограничения (Constraints)**-это декларативные гарантии целостности (правила), действующие локально на каждом узле и согласующиеся через оркестратор SAGA. Они обеспечивают, что каждый шаг распределённого сценария сохраняет инварианты данных; конфликты и нарушения фиксируются до фиксации шага, а при необходимости компенсируются в рамках eventual consistency. **Ограничения (Constraints)**-это декларативные гарантии целостности (правила), действующие локально на каждом узле и согласующиеся через оркестратор SAGA. Они обеспечивают, что каждый шаг распределённого сценария сохраняет инварианты данных; конфликты и нарушения фиксируются до фиксации шага, а при необходимости компенсируются в рамках eventual consistency.
Ограничения на уровне коллекции поддерживают: `обязательные поля (required)`, `уникальность (unique)`, `минимальные/максимальные значения (min/max)`, `regex-паттерны` и `enum-списки`(фиксированные наборы допустимых значений, которые жёстко задают «разрешённые состояния» поля. Они работают как "строгая шпаргалка" для данных: нельзя сохранить ничего лишнего, а движок сразу фиксирует несоответствия — ещё **до попадания в WAL** и **до участия в MVCC‑снапшотах**.) допустимых значений. Все ограничения проверяются автоматически при вставке и обновлении документов. Ограничения на уровне коллекции поддерживают: `обязательные поля (required)`, `уникальность (unique)`, `минимальные/максимальные значения (min/max)`, `regex-паттерны` и `enum-списки`(фиксированные наборы допустимых значений, которые жёстко задают «разрешённые состояния» поля. Они работают как "строгая шпаргалка" для данных: нельзя сохранить ничего лишнего, а движок сразу фиксирует несоответствия — ещё **до попадания в WAL** и **до участия в batch-снапшотах**.) допустимых значений. Все ограничения проверяются автоматически при вставке и обновлении документов.
```sh ```sh
# Добавление обязательного поля # Добавление обязательного поля
@@ -2800,8 +2749,8 @@ futriix:~> doc compression employees 550e8400-e29b-41d4-a716-446655440000
**Вопрос: Почему версия обозначена как «Futriix 2 i²», а не просто «2.0»?**<br> **Вопрос: Почему версия обозначена как «Futriix 2 i²», а не просто «2.0»?**<br>
<br> <br>
<br> <br>
**Ответ: «2» — номер версии: здесь собраны ключевые компоненты (шардинг, WAL, MVCC и др.).** **Ответ: «2» — номер версии: здесь собраны ключевые компоненты (шардинг, WAL, Batch и др.).**
**i2i2 — математическая метафора: хотя ii — мнимая единица, её квадрат даёт абсолютно точный результат (i2=−1i2=−1). Так и Futriix: даже в сложной распределённой среде с параллельными операциями (wait‑free, lock‑free) система выдаёт строго предсказуемые результаты — за счёт Raft, ACID и MVCC. Это близко философии OpenIndiana/Illumos: детерминизм и контроль над ресурсами.** **i2i2 — математическая метафора: хотя ii — мнимая единица, её квадрат даёт абсолютно точный результат (i2=−1i2=−1). Так и Futriix: даже в сложной распределённой среде с параллельными операциями (wait‑free, lock‑free) система выдаёт строго предсказуемые результаты — за счёт Raft, Batch и WAL. Это близко философии OpenIndiana/Illumos: детерминизм и контроль над ресурсами.**
<p align="right">(<a href="#readme-top">К началу</a>)</p> <p align="right">(<a href="#readme-top">К началу</a>)</p>
@@ -2814,7 +2763,7 @@ futriix:~> doc compression employees 550e8400-e29b-41d4-a716-446655440000
- [x] Реализовать поддержку триггеров (обратных вызовов) - [x] Реализовать поддержку триггеров (обратных вызовов)
- [x] Реализовать поддержку многопоточности - [x] Реализовать поддержку многопоточности
- [x] Реализовать неблокирующие чтение/запись - [x] Реализовать неблокирующие чтение/запись
- [x] Реализовать неблокирующие транзакции - [x] Реализовать неблокирующие batch-команды (аналог batch в субд Valkey)
- [x] Реализовать constraints (Ограничения) - [x] Реализовать constraints (Ограничения)
- [x] Реализовать мульти-мастер асинхронную репликацию через файл конфигурации - [x] Реализовать мульти-мастер асинхронную репликацию через файл конфигурации
- [x] Реализовать логирование для субд - [x] Реализовать логирование для субд
@@ -2832,8 +2781,7 @@ futriix:~> doc compression employees 550e8400-e29b-41d4-a716-446655440000
- [x] Библиотека "raft-boltdb" заменить на встроенное файловое хранилище - [x] Библиотека "raft-boltdb" заменить на встроенное файловое хранилище
- [x] Реализовать уникальные и составные индексы - [x] Реализовать уникальные и составные индексы
- [x] Реализовать временные метки для основных объектов субд (таппл, коллекция, документ, поле, индекс, транзакция, ACL, узел кластера) - [x] Реализовать временные метки для основных объектов субд (таппл, коллекция, документ, поле, индекс, транзакция, ACL, узел кластера)
- [x] Оптимизировать транзакции под высокие нагрузки (Реализовать: Пакетную запись WAL - вместо синхронной записи каждой операции, используется асинхронная запись пакетами по 100 записей, Буферизацию записей - 64KB буфер для WAL записей, Периодическую синхронизацию - синхронизация с диском каждые 5 секунд вместо каждой записи, Чекпоинты - периодическое создание контрольных точек состояния, Атомарные операции - использование atomic.Value и sync.Map для wait-free доступа) - [x] Реализовать Batch-версионирование - поддержка множественных версий документов через WAL
- [x] Реализовать MVCC версионирование - поддержка множественных версий документов
- [x] Реализовать временные метки для объектов субд (Ограничений, Импорта-Экспорта, Триггеров, Lua-плагинов) - [x] Реализовать временные метки для объектов субд (Ограничений, Импорта-Экспорта, Триггеров, Lua-плагинов)
- [x] Реализовать обработку состояний "split-brain" - [x] Реализовать обработку состояний "split-brain"
- [x] Реализовать Pipeline репликации-группировку нескольких команд в один Raft лог для уменьшения сети - [x] Реализовать Pipeline репликации-группировку нескольких команд в один Raft лог для уменьшения сети
@@ -2852,12 +2800,11 @@ futriix:~> doc compression employees 550e8400-e29b-41d4-a716-446655440000
- [x] Реализовать Distributed Transactions - двухфазный коммит для распределённых транзакций - [x] Реализовать Distributed Transactions - двухфазный коммит для распределённых транзакций
- [x] Реализовать Deadlock Detection - циклический детектор дедлоков с таймаутами (DFS по графу ожидания) - [x] Реализовать Deadlock Detection - циклический детектор дедлоков с таймаутами (DFS по графу ожидания)
- [x] Реализовать Transaction Timeout - настраиваемый таймаут для долгих транзакций - [x] Реализовать Transaction Timeout - настраиваемый таймаут для долгих транзакций
- [x] Реализовать Savepoints - точки сохранения внутри транзакции для частичного отката - [x] Вынести настройи настройки Batch, WAL и настройки плагинов из исходного кода проекта в файл конфигурации "config.toml"
- [x] Вынести настройи настройки MVCC,WAL и настройки плагинов из исходного кода проекта в файл конфигурации "config.toml"
- [x] Реализовать выделение жирным шифтом все информационные сообщения в скриптах сборки - [x] Реализовать выделение жирным шифтом все информационные сообщения в скриптах сборки
- [x] Реализовать ограничения на количество создаваемых Lua-состояний в плагинах - [x] Реализовать ограничения на количество создаваемых Lua-состояний в плагинах
- [x] Реализовать WAL recovery сделать асинхронным для ускорения скорости работы субд - [x] Реализовать WAL recovery сделать асинхронным для ускорения скорости работы субд
- [x] Реализовать SAGA c оркестратором (Распределённые транзакции) - [x] Реализовать SAGA c оркестратором (Распределённые batch)
- [x] Голосование узлов перед коммитом в протоколе Raft - [x] Голосование узлов перед коммитом в протоколе Raft
- [x] Реализовать автоматическое масштабирование - [x] Реализовать автоматическое масштабирование
- [x] Реализовать TLS, backpressure - [x] Реализовать TLS, backpressure
+58 -54
View File
@@ -10,30 +10,13 @@
* Файл: cmd/futriis/main.go * Файл: cmd/futriis/main.go
* Назначение: Точка входа в приложение СУБД futriis. * Назначение: Точка входа в приложение СУБД futriis.
* *
* ИСПРАВЛЕНО: * ДОБАВЛЕНО (2026-10, динамическая конфигурация):
* - Валидация пути к конфигурации и проверка прав доступа к файлу * После создания REPL передаём ему ссылку на DynamicConfigManager
* - Валидация конфигурации после загрузки (ValidateConfig) * (через SetDynamicConfigManager), чтобы команды `config set`,
* - WAL и data-dir привязаны к директории futriis, а не CWD * `compression config set`, `cluster pipeline on/off` могли
* - Graceful shutdown: убраны os.Exit(1) до завершения defer'ов, * применять изменения через Raft и сохранять их на всех узлах.
* используется переменная exitCode и os.Exit в самом конце * В fallback-режиме (Raft недоступен или узел не лидер) REPL сам
* - Ожидание запуска HTTP-сервера с таймаутом * запишет изменения в config.toml через config.SaveConfig.
* - SAGA оркестратор останавливается всегда (даже если disabled)
* - Проверка allow-list для плагинов перед StartPlugin
* - Заменён SIGHUP на SIGUSR1 (reload) + SIGTERM/SIGINT (shutdown)
* - Проверка nil для raftCoordinator перед использованием
* - repl.NewRepl теперь возвращает (*Repl, error)
* - Интеграция с Prometheus: /metrics endpoint
* - ДОБАВЛЕНО: поддержка TLS на HTTP API (вариант A — TLS внутри futriiX).
* Теперь используется api.NewHTTPServerWithTLS(..., &cfg.Security).
* - ИСПРАВЛЕНО (UI): убраны лишние пустые строки перед баннером, чтобы
* строка "futriis 3i²(by 29.09.2026)" шла сразу после сообщения ACL
* о созданном admin-пользователе.
* - ИСПРАВЛЕНО (UI): текст баннера окрашивается в точный #00bfff
* (Deep Sky Blue) через utils.PrintlnDeepSkyBlueReset, чтобы совпадать
* с цветом надписи "futriis 3i²(by 29.09.2026)".
* - ИСПРАВЛЕНО: acl.NewACLManager() теперь возвращает (*ACLManager, error).
* Обрабатываем ошибку и завершаемся через os.Exit(1) с логом,
* без panic и stack trace.
*/ */
package main package main
@@ -119,18 +102,18 @@ func main() {
storage.SetGlobalStorage(store) storage.SetGlobalStorage(store)
walPath := filepath.Join(dataDir, "futriis.wal") walPath := filepath.Join(dataDir, "futriis.wal")
if err := storage.InitTransactionManager(walPath); err != nil { if err := storage.InitBatchManager(walPath); err != nil {
logger.Warn("Failed to initialize transaction manager: " + err.Error()) logger.Warn("Failed to initialize batch manager: " + err.Error())
} else { } else {
storage.SetTransactionLogger(logger) storage.SetBatchLogger(logger)
logger.Info("Transaction manager initialized") logger.Info("Batch manager initialized (WAL + AOF)")
logger.Info(fmt.Sprintf(" WAL path: %s", walPath))
logger.Info(fmt.Sprintf(" AOF path: %s", filepath.Join(dataDir, "aof")))
} }
storage.InitTriggerManager(logger) storage.InitTriggerManager(logger)
logger.Info("Trigger manager initialized") logger.Info("Trigger manager initialized")
// ИСПРАВЛЕНО: NewACLManager теперь возвращает (*ACLManager, error).
// Обрабатываем ошибку управляемо (без panic) и завершаемся с кодом 1.
aclManager, err := acl.NewACLManager() aclManager, err := acl.NewACLManager()
if err != nil { if err != nil {
logger.Error("Failed to initialize ACL manager: " + err.Error()) logger.Error("Failed to initialize ACL manager: " + err.Error())
@@ -150,15 +133,24 @@ func main() {
var sagaOrchestrator *storage.SagaOrchestrator var sagaOrchestrator *storage.SagaOrchestrator
if cfg.Saga.Enabled { if cfg.Saga.Enabled {
sagaConfig := &cfg.Saga sagaConfig := &cfg.Saga
sagaOrchestrator, err = storage.NewSagaOrchestratorWithConfig( nodeID := cfg.Cluster.NodeIP + ":" + fmt.Sprint(cfg.Cluster.NodePort)
var raftAccessor storage.SagaRaftAccessor
if raftCoordinator != nil {
raftAccessor = raftCoordinator.GetSagaLeaderBridge()
}
sagaOrchestrator, err = storage.NewSagaOrchestratorWithRaft(
sagaConfig, sagaConfig,
cfg.Cluster.NodeIP+":"+fmt.Sprint(cfg.Cluster.NodePort), nodeID,
raftAccessor,
logger, logger,
) )
if err != nil { if err != nil {
logger.Warn(fmt.Sprintf("Failed to initialize saga orchestrator: %v", err)) logger.Warn(fmt.Sprintf("Failed to initialize saga orchestrator: %v", err))
} else { } else {
logger.Info(fmt.Sprintf("Saga orchestrator initialized with %d coordinators", sagaConfig.GetCoordinatorCount())) logger.Info(fmt.Sprintf("Saga orchestrator initialized with %d coordinators (raft leader election: %v)",
sagaConfig.GetCoordinatorCount(), raftAccessor != nil))
storage.SetGlobalSagaOrchestrator(sagaOrchestrator) storage.SetGlobalSagaOrchestrator(sagaOrchestrator)
} }
} else { } else {
@@ -249,8 +241,6 @@ func main() {
} }
// ========== HTTP API SERVER ========== // ========== HTTP API SERVER ==========
// ДОБАВЛЕНО: используем NewHTTPServerWithTLS и передаём cfg.Security.
// Если в config.toml [security].enable_tls = true, сервер поднимется по HTTPS.
httpPort := cfg.API.Port httpPort := cfg.API.Port
httpServer := api.NewHTTPServerWithTLS(httpPort, store, raftCoordinator, aclManager, logger, &cfg.Security) httpServer := api.NewHTTPServerWithTLS(httpPort, store, raftCoordinator, aclManager, logger, &cfg.Security)
if metricsCollector != nil { if metricsCollector != nil {
@@ -300,10 +290,6 @@ func main() {
logger.Info(" - Automatic data persistence - ENABLED") logger.Info(" - Automatic data persistence - ENABLED")
} }
// ИСПРАВЛЕНО (UI): убраны лишние пустые строки перед баннером.
// Внутри displayBanner уже есть utils.Println("") первой строкой,
// чтобы отделить баннер от ACL-сообщения. Строка "futriis 3i²(by
// 29.09.2026)" идёт сразу после сообщения ACL.
displayBanner(cfg.Cluster.Name, httpPort, raftCoordinator, cfg.Saga.Enabled, cfg.Metrics.Enabled) displayBanner(cfg.Cluster.Name, httpPort, raftCoordinator, cfg.Saga.Enabled, cfg.Metrics.Enabled)
replInstance, err := repl.NewRepl(store, raftCoordinator, logger, cfg, aclManager, pluginManager) replInstance, err := repl.NewRepl(store, raftCoordinator, logger, cfg, aclManager, pluginManager)
@@ -314,6 +300,19 @@ func main() {
} }
defer replInstance.Close() defer replInstance.Close()
// ДОБАВЛЕНО (2026-10): передаём REPL ссылку на DynamicConfigManager,
// чтобы команды `config set`, `compression config set`,
// `cluster pipeline on/off` могли применять изменения через Raft.
// В fallback-режиме REPL пишет config.toml через config.SaveConfig.
if raftCoordinator != nil {
replInstance.SetDynamicConfigManager(
raftCoordinator.GetDynamicConfigManager(),
configPath,
)
} else {
replInstance.SetDynamicConfigManager(nil, configPath)
}
// ========== GRACEFUL SHUTDOWN ========== // ========== GRACEFUL SHUTDOWN ==========
sigChan := make(chan os.Signal, 1) sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGUSR1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGUSR1)
@@ -431,16 +430,32 @@ func main() {
logger.Warn("Node shutdown timeout") logger.Warn("Node shutdown timeout")
} }
logger.Info("Stopping batch manager (WAL + AOF)...")
if stats := storage.GetBatchStats(); stats != nil {
logger.Info(fmt.Sprintf(" Batch stats: total_batches=%v, total_operations=%v, total_failed=%v, active_batches=%v",
stats["total_batches"], stats["total_operations"], stats["total_failed"], stats["active_batches"]))
}
batchStopDone := make(chan struct{})
go func() {
if err := storage.StopBatchManager(); err != nil {
logger.Error(fmt.Sprintf("Failed to stop batch manager: %v", err))
} else {
logger.Info("Batch manager stopped")
}
close(batchStopDone)
}()
select {
case <-batchStopDone:
case <-time.After(10 * time.Second):
logger.Warn("Batch manager shutdown timeout")
}
logger.Info("Finalizing logger...") logger.Info("Finalizing logger...")
if err := logger.Sync(); err != nil { if err := logger.Sync(); err != nil {
fmt.Printf("Failed to sync logger: %v\n", err) fmt.Printf("Failed to sync logger: %v\n", err)
} }
logger.Close() logger.Close()
if err := storage.StopTransactionManager(); err != nil {
fmt.Printf("Failed to stop transaction manager: %v\n", err)
}
if aclManager != nil { if aclManager != nil {
aclManager.Stop() aclManager.Stop()
} }
@@ -594,15 +609,6 @@ func isPortListening(port int) bool {
return true return true
} }
// displayBanner выводит стартовый баннер.
//
// ИСПРАВЛЕНО (UI): текст баннера окрашивается точным цветом
// #00bfff (Deep Sky Blue) через utils.PrintlnDeepSkyBlueReset, чтобы
// совпадать с цветом надписи "futriis 3i²(by 29.09.2026)".
//
// Внутри уже есть utils.Println("") первой строкой, чтобы отделить
// баннер от ACL-сообщения. Дополнительные пустые строки в main() не
// добавляются (см. комментарий перед вызовом displayBanner).
func displayBanner(clusterName string, httpPort int, coordinator *cluster.RaftCoordinator, sagaEnabled, metricsEnabled bool) { func displayBanner(clusterName string, httpPort int, coordinator *cluster.RaftCoordinator, sagaEnabled, metricsEnabled bool) {
utils.Println("") utils.Println("")
bannerLines := []string{ bannerLines := []string{
@@ -653,8 +659,6 @@ func displayBanner(clusterName string, httpPort int, coordinator *cluster.RaftCo
}...) }...)
for _, line := range bannerLines { for _, line := range bannerLines {
// ИСПРАВЛЕНО: используем точный цвет #00bfff (Deep Sky Blue),
// совпадающий с цветом заголовка баннера.
utils.PrintlnDeepSkyBlueReset(line) utils.PrintlnDeepSkyBlueReset(line)
} }
} }
+171 -55
View File
@@ -20,6 +20,20 @@
// Конструктор NewHTTPServerWithTLS принимает certFile/keyFile, // Конструктор NewHTTPServerWithTLS принимает certFile/keyFile,
// Start() вызывает ListenAndServeTLS при включённом TLS. // Start() вызывает ListenAndServeTLS при включённом TLS.
// Старый NewHTTPServer оставлен как обёртка для обратной совместимости. // Старый NewHTTPServer оставлен как обёртка для обратной совместимости.
//
// ИСПРАВЛЕНО (2026-10): после замены ACID-транзакций на batch-операции
// функции storage.InitTransactionManager, storage.BeginTransactionOnCollection,
// storage.CommitCurrentTransaction, storage.AbortCurrentTransaction,
// storage.GetCurrentTransactionID, storage.GetActiveTransactions,
// storage.HasActiveTransaction были удалены. HTTP-API для транзакций
// переписан поверх batch-операций:
// - POST /api/transaction/begin — создаёт *storage.Batch и сохраняет
// в карте HTTPServer.activeBatches (ключ — ID сессии);
// - POST /api/transaction/commit — коммитит batch и удаляет из карты;
// - POST /api/transaction/abort — снимает регистрацию batch без коммита;
// - GET /api/transaction/ — возвращает список активных batch'ей
// текущей сессии.
// Старые URL-пути сохранены для совместимости с существующими клиентами.
package api package api
@@ -259,6 +273,13 @@ type HTTPServer struct {
tlsEnabled bool tlsEnabled bool
certFile string certFile string
keyFile string keyFile string
// ДОБАВЛЕНО (2026-10): реестр активных batch'ей по ID сессии.
// Ключ — sessionID (string), значение — *storage.Batch.
// Используется HTTP-API транзакций: /api/transaction/begin создаёт
// batch и регистрирует здесь, /api/transaction/commit извлекает и
// коммитит, /api/transaction/abort просто снимает регистрацию.
activeBatches sync.Map
} }
// APIResponse представляет стандартный ответ API // APIResponse представляет стандартный ответ API
@@ -505,6 +526,8 @@ func (s *HTTPServer) handleLogout(w http.ResponseWriter, r *http.Request) {
s.logOperation("LOGOUT", username.(string), "success", "", nil) s.logOperation("LOGOUT", username.(string), "success", "", nil)
s.sessions.Delete(sessionID) s.sessions.Delete(sessionID)
} }
// Снимаем все активные batch'и сессии.
s.discardSessionBatches(sessionID)
s.aclManager.Logout(sessionID) s.aclManager.Logout(sessionID)
} }
@@ -883,8 +906,7 @@ func (s *HTTPServer) handleIndexRequest(w http.ResponseWriter, r *http.Request)
if err := coll.CreateIndex(req.Name, req.Fields, req.Unique); err != nil { if err := coll.CreateIndex(req.Name, req.Fields, req.Unique); err != nil {
s.logOperation("CREATE_INDEX", fmt.Sprintf("%s.%s.%s", database, collection, req.Name), "error", err.Error(), nil) s.logOperation("CREATE_INDEX", fmt.Sprintf("%s.%s.%s", database, collection, req.Name), "error", err.Error(), nil)
s.sendError(w, err.Error(), http.StatusBadRequest) s.sendError(w, err.Error(), http.StatusBadRequest)
return return }
}
s.logOperation("CREATE_INDEX", fmt.Sprintf("%s.%s.%s", database, collection, req.Name), "success", "", map[string]interface{}{ s.logOperation("CREATE_INDEX", fmt.Sprintf("%s.%s.%s", database, collection, req.Name), "success", "", map[string]interface{}{
"fields": req.Fields, "fields": req.Fields,
"unique": req.Unique, "unique": req.Unique,
@@ -1605,22 +1627,87 @@ func (s *HTTPServer) handleTriggerRequest(w http.ResponseWriter, r *http.Request
} }
} }
// handleTransactionRequest обрабатывает запросы к транзакциям // =============================================================================
// BATCH API (замена транзакций)
// =============================================================================
// getSessionBatch возвращает активный batch для сессии (или nil).
func (s *HTTPServer) getSessionBatch(sessionID string) (*storage.Batch, bool) {
if sessionID == "" {
return nil, false
}
val, ok := s.activeBatches.Load(sessionID)
if !ok {
return nil, false
}
batch, ok := val.(*storage.Batch)
return batch, ok
}
// setSessionBatch сохраняет batch для сессии.
func (s *HTTPServer) setSessionBatch(sessionID string, batch *storage.Batch) {
if sessionID == "" || batch == nil {
return
}
s.activeBatches.Store(sessionID, batch)
}
// clearSessionBatch снимает batch с сессии.
func (s *HTTPServer) clearSessionBatch(sessionID string) {
if sessionID == "" {
return
}
s.activeBatches.Delete(sessionID)
}
// discardSessionBatches снимает все активные batch'и сессии.
// Вызывается при logout.
func (s *HTTPServer) discardSessionBatches(sessionID string) {
if sessionID == "" {
return
}
if batch, ok := s.getSessionBatch(sessionID); ok && batch != nil {
// Снимаем регистрацию в глобальном менеджере (без коммита).
if bm := storage.GetBatchManager(); bm != nil {
bm.UnregisterBatch(batch)
}
s.clearSessionBatch(sessionID)
}
}
// handleTransactionRequest обрабатывает запросы к batch-операциям.
//
// ИСПРАВЛЕНО (2026-10): переписано с транзакций на batch-операции.
// URL-пути сохранены для совместимости с существующими клиентами:
// - POST /api/transaction/begin — создать batch
// - POST /api/transaction/commit — закоммитить batch
// - POST /api/transaction/abort — отменить batch (без коммита)
// - GET /api/transaction/ — список активных batch'ей сессии
func (s *HTTPServer) handleTransactionRequest(w http.ResponseWriter, r *http.Request) { func (s *HTTPServer) handleTransactionRequest(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/transaction/") path := strings.TrimPrefix(r.URL.Path, "/api/transaction/")
parts := strings.Split(path, "/") parts := strings.Split(path, "/")
sessionID := s.getSessionID(r) sessionID := s.getSessionID(r)
if !s.aclManager.CheckPermission(sessionID, "*", "*", "write") { if !s.aclManager.CheckPermission(sessionID, "*", "*", "write") {
s.logOperation("TRANSACTION_OPERATION", "", "error", "Write access required", nil) s.logOperation("BATCH_OPERATION", "", "error", "Write access required", nil)
s.sendError(w, "Write access required", http.StatusForbidden) s.sendError(w, "Write access required", http.StatusForbidden)
return return
} }
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
transactions := storage.GetActiveTransactions() // Возвращаем список активных batch'ей сессии.
s.sendSuccess(w, transactions) active := make([]map[string]interface{}, 0)
if batch, ok := s.getSessionBatch(sessionID); ok && batch != nil {
active = append(active, map[string]interface{}{
"id": batch.ID,
"database": batch.Database,
"collection": batch.Collection,
"operation_count": len(batch.Operations),
"start_time": batch.Timestamp,
})
}
s.sendSuccess(w, active)
case http.MethodPost: case http.MethodPost:
if len(parts) == 0 || parts[0] == "" { if len(parts) == 0 || parts[0] == "" {
@@ -1631,67 +1718,73 @@ func (s *HTTPServer) handleTransactionRequest(w http.ResponseWriter, r *http.Req
action := parts[0] action := parts[0]
switch action { switch action {
case "begin": case "begin":
if err := storage.InitTransactionManager("futriis.wal"); err != nil { // Если у сессии уже есть активный batch — возвращаем ошибку.
s.logOperation("BEGIN_TRANSACTION", "", "error", err.Error(), nil) if existing, ok := s.getSessionBatch(sessionID); ok && existing != nil {
s.sendError(w, err.Error(), http.StatusInternalServerError) s.sendError(w, "Session already has active batch", http.StatusConflict)
return return
} }
databases := s.store.ListDatabases() // Читаем параметры: database, collection.
if len(databases) == 0 { var req struct {
s.sendError(w, "No database available", http.StatusBadRequest) Database string `json:"database"`
Collection string `json:"collection"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
batch := storage.NewBatch(req.Database, req.Collection)
if batch == nil {
s.logOperation("BEGIN_BATCH", "", "error", "failed to create batch", nil)
s.sendError(w, "Failed to create batch", http.StatusInternalServerError)
return return
} }
db, err := s.store.GetDatabase(databases[0]) s.setSessionBatch(sessionID, batch)
if err != nil { s.logOperation("BEGIN_BATCH", fmt.Sprintf("%d", batch.ID), "success", "", nil)
s.sendError(w, err.Error(), http.StatusBadRequest)
return
}
collections := db.ListCollections()
if len(collections) == 0 {
s.sendError(w, "No collection available", http.StatusBadRequest)
return
}
coll, err := db.GetCollection(collections[0])
if err != nil {
s.sendError(w, err.Error(), http.StatusBadRequest)
return
}
if err := storage.BeginTransactionOnCollection(coll); err != nil {
s.logOperation("BEGIN_TRANSACTION", "", "error", err.Error(), nil)
s.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
s.logOperation("BEGIN_TRANSACTION", "", "success", "", nil)
s.sendSuccess(w, map[string]interface{}{ s.sendSuccess(w, map[string]interface{}{
"status": "transaction_started", "status": "batch_started",
"id": storage.GetCurrentTransactionID(), "id": batch.ID,
"database": batch.Database,
"collection": batch.Collection,
}) })
case "commit": case "commit":
if err := storage.CommitCurrentTransaction(); err != nil { batch, ok := s.getSessionBatch(sessionID)
s.logOperation("COMMIT_TRANSACTION", "", "error", err.Error(), nil) if !ok || batch == nil {
s.sendError(w, "No active batch", http.StatusBadRequest)
return
}
if err := batch.Commit(); err != nil {
s.logOperation("COMMIT_BATCH", fmt.Sprintf("%d", batch.ID), "error", err.Error(), nil)
s.clearSessionBatch(sessionID)
s.sendError(w, err.Error(), http.StatusInternalServerError) s.sendError(w, err.Error(), http.StatusInternalServerError)
return return
} }
s.logOperation("COMMIT_TRANSACTION", "", "success", "", nil)
s.logOperation("COMMIT_BATCH", fmt.Sprintf("%d", batch.ID), "success", "", nil)
s.clearSessionBatch(sessionID)
s.sendSuccess(w, map[string]interface{}{ s.sendSuccess(w, map[string]interface{}{
"status": "transaction_committed", "status": "batch_committed",
"id": batch.ID,
}) })
case "abort": case "abort":
if err := storage.AbortCurrentTransaction(); err != nil { batch, ok := s.getSessionBatch(sessionID)
s.logOperation("ABORT_TRANSACTION", "", "error", err.Error(), nil) if !ok || batch == nil {
s.sendError(w, err.Error(), http.StatusInternalServerError) s.sendError(w, "No active batch", http.StatusBadRequest)
return return
} }
s.logOperation("ABORT_TRANSACTION", "", "success", "", nil)
// Снимаем регистрацию в глобальном менеджере.
if bm := storage.GetBatchManager(); bm != nil {
bm.UnregisterBatch(batch)
}
s.clearSessionBatch(sessionID)
s.logOperation("ABORT_BATCH", fmt.Sprintf("%d", batch.ID), "success", "", nil)
s.sendSuccess(w, map[string]interface{}{ s.sendSuccess(w, map[string]interface{}{
"status": "transaction_aborted", "status": "batch_aborted",
"id": batch.ID,
}) })
default: default:
@@ -1700,25 +1793,48 @@ func (s *HTTPServer) handleTransactionRequest(w http.ResponseWriter, r *http.Req
case http.MethodPut: case http.MethodPut:
if len(parts) < 2 { if len(parts) < 2 {
s.sendError(w, "Transaction ID and action required", http.StatusBadRequest) s.sendError(w, "Batch ID and action required", http.StatusBadRequest)
return return
} }
txID := parts[0] batchIDStr := parts[0]
action := parts[1] action := parts[1]
batch, ok := s.getSessionBatch(sessionID)
if !ok || batch == nil {
s.sendError(w, "No active batch", http.StatusBadRequest)
return
}
// Проверяем, что ID совпадает (необязательно, но полезно).
if fmt.Sprintf("%d", batch.ID) != batchIDStr {
s.sendError(w, "Batch ID mismatch", http.StatusBadRequest)
return
}
switch action { switch action {
case "commit": case "commit":
s.logOperation("COMMIT_TRANSACTION", txID, "success", "", nil) if err := batch.Commit(); err != nil {
s.logOperation("COMMIT_BATCH", batchIDStr, "error", err.Error(), nil)
s.clearSessionBatch(sessionID)
s.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
s.logOperation("COMMIT_BATCH", batchIDStr, "success", "", nil)
s.clearSessionBatch(sessionID)
s.sendSuccess(w, map[string]interface{}{ s.sendSuccess(w, map[string]interface{}{
"status": "transaction_committed", "status": "batch_committed",
"id": txID, "id": batchIDStr,
}) })
case "abort": case "abort":
s.logOperation("ABORT_TRANSACTION", txID, "success", "", nil) if bm := storage.GetBatchManager(); bm != nil {
bm.UnregisterBatch(batch)
}
s.clearSessionBatch(sessionID)
s.logOperation("ABORT_BATCH", batchIDStr, "success", "", nil)
s.sendSuccess(w, map[string]interface{}{ s.sendSuccess(w, map[string]interface{}{
"status": "transaction_aborted", "status": "batch_aborted",
"id": txID, "id": batchIDStr,
}) })
default: default:
+280 -13
View File
@@ -33,14 +33,36 @@
// SetReplicationFactor, HandleStatusSync), чтобы подобные ошибки // SetReplicationFactor, HandleStatusSync), чтобы подобные ошибки
// в будущем давали внятную ошибку, а не панику. // в будущем давали внятную ошибку, а не панику.
// //
// ДОПОЛНИТЕЛЬНО (исправление ошибок компиляции в repl.go): // ДОБАВЛЕНО (2026-10, выборы лидера SAGA):
// Добавлены методы GetOrchestrator(), GetMetrics() и GetActiveSagas() // - Тип SagaLeaderBridge реализует storage.SagaRaftAccessor.
// для *SagaManager (cluster.SagaManager), которые используются в // Он подписывается на изменения лидерства Raft (через monitorLeadership)
// internal/repl/repl.go для команд 'saga status' и 'saga list'. // и уведомляет подписчиков.
// GetOrchestrator() возвращает сам *SagaManager (в кластерной версии // - RaftCoordinator.sagaLeaderBridge создаётся в NewRaftCoordinator.
// он выполняет роль оркестратора), что позволяет repl.go единообразно // - В monitorLeadership при смене лидерства вызывается
// вызывать .GetMetrics() как у storage.SagaOrchestrator, так и у // coord.sagaLeaderBridge.NotifyLeadership(...), что обновляет
// cluster.SagaManager. // состояние bridge (для storage.SagaLeaderElector, если он где-то
// используется).
// - В Stop() останавливаем bridge.
// - GetSagaLeaderBridge() — публичный геттер.
//
// ИСПРАВЛЕНО (компиляция):
// Ранее NewSagaManagerWithRaftForCoordinator пыталась вернуть
// *storage.SagaManager как *cluster.SagaManager (локальный тип).
// Это конфликт двух разных типов с одинаковым именем.
// Теперь:
// - coord.sagaManager по-прежнему *cluster.SagaManager
// (создаётся через NewSagaManager(logger));
// - bridge остаётся отдельным компонентом (storage.SagaRaftAccessor),
// который при необходимости можно получить через GetSagaLeaderBridge();
// - вызов storage.NewSagaManagerWithRaft из NewRaftCoordinator убран.
// Все публичные методы *cluster.SagaManager (GetOrchestrator,
// GetMetrics, GetActiveSagas, Stop) сохранены — они используются
// в repl.go и main.go.
//
// ДОБАВЛЕНО (2026-10, кластерный SagaManager):
// - Метод GetAllSagas() у *SagaManager — возвращает все SAGA
// транзакции (активные и завершённые). Используется REPL-командой
// "saga list --all". Возвращает локальный тип cluster.SagaTransaction.
package cluster package cluster
@@ -156,6 +178,162 @@ func (lc *LamportClock) Value() uint64 {
return lc.counter.Load() return lc.counter.Load()
} }
// =============================================================================
// SAGA LEADER BRIDGE — РЕАЛИЗАЦИЯ storage.SagaRaftAccessor
// =============================================================================
// SagaLeaderBridge — мост между Raft-координатором и оркестратором SAGA.
//
// Реализует интерфейс storage.SagaRaftAccessor:
// - IsSagaLeader() — сообщает, является ли текущий узел лидером Raft.
// - GetSagaLeaderID() — возвращает ID текущего лидера.
// - GetSagaCurrentTerm() — возвращает текущий терм Raft.
// - RegisterSagaLeaderObserver(id, cb) — регистрирует наблюдателя,
// который будет уведомлён при смене лидерства.
//
// Мост используется storage.SagaLeaderElector, который опрашивает
// его и обновляет своё состояние (isLeader, leaderID).
//
// Все методы потокобезопасны.
type SagaLeaderBridge struct {
coordinator *RaftCoordinator
mu sync.RWMutex
isLeader bool
leaderID string
term uint64
observersMu sync.RWMutex
observers map[string]func(isLeader bool, leaderID string)
stopChan chan struct{}
wg sync.WaitGroup
}
// NewSagaLeaderBridge создаёт новый мост.
func NewSagaLeaderBridge(coord *RaftCoordinator) *SagaLeaderBridge {
b := &SagaLeaderBridge{
coordinator: coord,
observers: make(map[string]func(isLeader bool, leaderID string)),
stopChan: make(chan struct{}),
}
return b
}
// Start запускает фоновый опрос состояния Raft.
func (b *SagaLeaderBridge) Start() {
b.wg.Add(1)
go b.pollLoop()
}
// pollLoop периодически опрашивает Raft и обновляет состояние.
func (b *SagaLeaderBridge) pollLoop() {
defer b.wg.Done()
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
b.refreshFromRaft()
case <-b.stopChan:
return
}
}
}
// refreshFromRaft читает состояние Raft и уведомляет наблюдателей,
// если что-то изменилось.
func (b *SagaLeaderBridge) refreshFromRaft() {
if b.coordinator == nil {
return
}
isLeader := b.coordinator.IsLeader()
leaderID := ""
if leader := b.coordinator.GetLeader(); leader != nil {
leaderID = leader.ID
}
term := b.coordinator.GetCurrentTerm()
b.NotifyLeadership(isLeader, leaderID, term)
}
// NotifyLeadership вызывается извне (например, monitorLeadership)
// для немедленного обновления состояния.
func (b *SagaLeaderBridge) NotifyLeadership(isLeader bool, leaderID string, term uint64) {
b.mu.Lock()
oldIsLeader := b.isLeader
oldLeaderID := b.leaderID
b.isLeader = isLeader
b.leaderID = leaderID
b.term = term
b.mu.Unlock()
if oldIsLeader != isLeader || oldLeaderID != leaderID {
b.observersMu.RLock()
obs := make([]func(isLeader bool, leaderID string), 0, len(b.observers))
for _, cb := range b.observers {
obs = append(obs, cb)
}
b.observersMu.RUnlock()
for _, cb := range obs {
func(cb func(isLeader bool, leaderID string)) {
defer func() {
if r := recover(); r != nil {
// Не позволяем панике наблюдателя уронить процесс.
}
}()
cb(isLeader, leaderID)
}(cb)
}
}
}
// IsSagaLeader — реализация storage.SagaRaftAccessor.
func (b *SagaLeaderBridge) IsSagaLeader() bool {
b.mu.RLock()
defer b.mu.RUnlock()
return b.isLeader
}
// GetSagaLeaderID — реализация storage.SagaRaftAccessor.
func (b *SagaLeaderBridge) GetSagaLeaderID() string {
b.mu.RLock()
defer b.mu.RUnlock()
return b.leaderID
}
// GetSagaCurrentTerm — реализация storage.SagaRaftAccessor.
func (b *SagaLeaderBridge) GetSagaCurrentTerm() uint64 {
b.mu.RLock()
defer b.mu.RUnlock()
return b.term
}
// RegisterSagaLeaderObserver — реализация storage.SagaRaftAccessor.
func (b *SagaLeaderBridge) RegisterSagaLeaderObserver(id string, cb func(isLeader bool, leaderID string)) {
b.observersMu.Lock()
defer b.observersMu.Unlock()
b.observers[id] = cb
}
// UnregisterSagaLeaderObserver снимает регистрацию наблюдателя.
func (b *SagaLeaderBridge) UnregisterSagaLeaderObserver(id string) {
b.observersMu.Lock()
defer b.observersMu.Unlock()
delete(b.observers, id)
}
// Stop останавливает мост.
func (b *SagaLeaderBridge) Stop() {
close(b.stopChan)
b.wg.Wait()
}
// ============================================================================= // =============================================================================
// ДИАПАЗОННЫЕ ШАРДЫ (RANGE SHARDS) // ДИАПАЗОННЫЕ ШАРДЫ (RANGE SHARDS)
// ============================================================================= // =============================================================================
@@ -655,7 +833,6 @@ func (rsm *RangeShardManager) Rebalance() error {
return nil return nil
} }
// ============================================================================= // =============================================================================
// INMEM STORE ДЛЯ RAFT (реализует raft.LogStore и raft.StableStore) // INMEM STORE ДЛЯ RAFT (реализует raft.LogStore и raft.StableStore)
// ============================================================================= // =============================================================================
@@ -1309,7 +1486,6 @@ func (sm *SagaManager) GetSagaStatus(id string) (string, error) {
return saga.Status, nil return saga.Status, nil
} }
// === ДОБАВЛЕНО ДЛЯ ИСПРАВЛЕНИЯ ОШИБОК КОМПИЛЯЦИИ ===
// GetOrchestrator возвращает менеджер SAGA, который в кластерной версии // GetOrchestrator возвращает менеджер SAGA, который в кластерной версии
// сам выполняет роль оркестратора. Возвращаемый тип — *SagaManager, // сам выполняет роль оркестратора. Возвращаемый тип — *SagaManager,
// у которого есть метод GetMetrics(), используемый в repl.go. // у которого есть метод GetMetrics(), используемый в repl.go.
@@ -1378,7 +1554,37 @@ func (sm *SagaManager) GetActiveSagas() []*SagaTransaction {
}) })
return result return result
} }
// === КОНЕЦ ДОБАВЛЕННОГО БЛОКА ===
// GetAllSagas возвращает все SAGA транзакции — и активные, и завершённые.
//
// ДОБАВЛЕНО (2026-10): используется REPL-командой "saga list --all".
// В кластерной версии SAGA-менеджер хранит все транзакции в sync.Map
// (sm.sagas). Реплики и завершённые SAGA не удаляются — sync.Map
// накапливает их в течение жизни процесса. Это позволяет отдавать
// полный список без обращения к диску.
func (sm *SagaManager) GetAllSagas() []*SagaTransaction {
result := make([]*SagaTransaction, 0)
sm.sagas.Range(func(key, value interface{}) bool {
saga, ok := value.(*SagaTransaction)
if !ok || saga == nil {
return true
}
result = append(result, saga)
return true
})
return result
}
// Stop останавливает Saga-менеджер.
// Реализован, чтобы main.go мог вызвать sagaManager.Stop() единообразно
// как для storage.SagaOrchestrator, так и для cluster.SagaManager.
func (sm *SagaManager) Stop() {
close(sm.stopChan)
sm.wg.Wait()
if sm.logger != nil {
sm.logger.Info("Cluster saga manager stopped")
}
}
// ============================================================================= // =============================================================================
// TCC (TRY-CONFIRM-CANCEL) // TCC (TRY-CONFIRM-CANCEL)
@@ -1781,7 +1987,6 @@ func (sbd *SplitBrainDetector) IsQuarantined(nodeID string) bool {
} }
return false return false
} }
// ============================================================================= // =============================================================================
// RECOVERY MANAGER - ПЕРЕИМЕНОВАН В ClusterNodeState ДЛЯ ИЗБЕЖАНИЯ КОНФЛИКТА // RECOVERY MANAGER - ПЕРЕИМЕНОВАН В ClusterNodeState ДЛЯ ИЗБЕЖАНИЯ КОНФЛИКТА
// ============================================================================= // =============================================================================
@@ -2952,7 +3157,6 @@ func (lfm *LeaderFallbackManager) Stop() {
close(lfm.stopChan) close(lfm.stopChan)
lfm.wg.Wait() lfm.wg.Wait()
} }
// ============================================================================= // =============================================================================
// RAFT CLUSTER STATE // RAFT CLUSTER STATE
// ============================================================================= // =============================================================================
@@ -3046,6 +3250,12 @@ type RaftCoordinator struct {
// Логические часы для eventual consistency // Логические часы для eventual consistency
lamportClock *LamportClock lamportClock *LamportClock
// SAGA LEADER BRIDGE — реализация storage.SagaRaftAccessor.
// Позволяет оркестратору SAGA (storage) корректно определять лидера,
// если он создаётся через storage.NewSagaManagerWithRaft.
// Для локального cluster.SagaManager не используется.
sagaLeaderBridge *SagaLeaderBridge
} }
// initRaftFSM создаёт и инициализирует FSM и его состояние. // initRaftFSM создаёт и инициализирует FSM и его состояние.
@@ -3087,6 +3297,17 @@ func (rc *RaftCoordinator) initRaftStores(dataDir string) {
} }
// NewRaftCoordinator создаёт новый координатор Raft. // NewRaftCoordinator создаёт новый координатор Raft.
//
// ИСПРАВЛЕНО (компиляция):
// Ранее здесь вызывалась storage.NewSagaManagerWithRaft и результат
// присваивался полю *cluster.SagaManager, что давало ошибку
// "cannot use *storage.SagaManager as *cluster.SagaManager".
// Теперь создаётся локальный cluster.SagaManager через NewSagaManager.
// Он уже имеет собственные GetOrchestrator, GetMetrics, GetActiveSagas
// и Stop — то, что ожидают repl.go и main.go.
// Bridge остаётся отдельным компонентом (sagaLeaderBridge) и
// доступен через GetSagaLeaderBridge() для тех, кто хочет
// использовать storage.SagaLeaderElector.
func NewRaftCoordinator(cfg *config.Config, store *storage.Storage, logger *log.Logger) (*RaftCoordinator, error) { func NewRaftCoordinator(cfg *config.Config, store *storage.Storage, logger *log.Logger) (*RaftCoordinator, error) {
if logger == nil { if logger == nil {
return nil, fmt.Errorf("logger is required") return nil, fmt.Errorf("logger is required")
@@ -3129,7 +3350,18 @@ func NewRaftCoordinator(cfg *config.Config, store *storage.Storage, logger *log.
coord.panicRecoveryMgr = NewPanicRecoveryManager(logger) coord.panicRecoveryMgr = NewPanicRecoveryManager(logger)
coord.fallbackManager = NewLeaderFallbackManager(coord, logger, nil) coord.fallbackManager = NewLeaderFallbackManager(coord, logger, nil)
// ДОБАВЛЕНО: создаём bridge для выборов лидера SAGA.
// Bridge реализует storage.SagaRaftAccessor.
// Он не заменяет cluster.SagaManager, а живёт отдельно.
coord.sagaLeaderBridge = NewSagaLeaderBridge(coord)
coord.sagaLeaderBridge.Start()
// Создаём локальный кластерный SAGA-менеджер.
// В кластерной версии он сам выполняет роль оркестратора
// (см. GetOrchestrator), и его GetMetrics/GetActiveSagas/Stop
// используются в repl.go и main.go.
coord.sagaManager = NewSagaManager(logger) coord.sagaManager = NewSagaManager(logger)
coord.tccManager = NewTCCManager(logger) coord.tccManager = NewTCCManager(logger)
coord.replicaReadManager = NewReplicaReadManager(coord, logger) coord.replicaReadManager = NewReplicaReadManager(coord, logger)
coord.multiRaftManager = NewMultiRaftManager(store, logger) coord.multiRaftManager = NewMultiRaftManager(store, logger)
@@ -3163,6 +3395,9 @@ func NewRaftCoordinator(cfg *config.Config, store *storage.Storage, logger *log.
coord.fsm.state.Nodes[coord.localNodeInfo.ID] = coord.localNodeInfo coord.fsm.state.Nodes[coord.localNodeInfo.ID] = coord.localNodeInfo
coord.fsm.state.UpdatedAt = time.Now().UnixMilli() coord.fsm.state.UpdatedAt = time.Now().UnixMilli()
coord.fsm.state.mu.Unlock() coord.fsm.state.mu.Unlock()
// В single-node режиме мы сразу лидер — сообщаем bridge.
coord.sagaLeaderBridge.NotifyLeadership(true, coord.localNodeInfo.ID, 1)
} else { } else {
logger.Info("Running in cluster mode") logger.Info("Running in cluster mode")
if err := coord.setupClusterMode(); err != nil { if err := coord.setupClusterMode(); err != nil {
@@ -3270,6 +3505,13 @@ func (rc *RaftCoordinator) GetMigrationStatus() *migration.MigrationStatus {
return status return status
} }
// GetSagaLeaderBridge возвращает bridge выборов лидера SAGA.
// Полезно для диагностики и для передачи в другие компоненты
// (например, если кто-то хочет использовать storage.SagaLeaderElector).
func (rc *RaftCoordinator) GetSagaLeaderBridge() *SagaLeaderBridge {
return rc.sagaLeaderBridge
}
// ============================================================================= // =============================================================================
// МЕТОДЫ ДЛЯ КРОСС-ДАТАЦЕНТРОВОЙ МИГРАЦИИ // МЕТОДЫ ДЛЯ КРОСС-ДАТАЦЕНТРОВОЙ МИГРАЦИИ
// ============================================================================= // =============================================================================
@@ -3780,6 +4022,10 @@ func (rc *RaftCoordinator) GetJointConsensusStatus() map[string]interface{} {
// monitorLeadership отслеживает изменения лидера. // monitorLeadership отслеживает изменения лидера.
// Использует реальное состояние Raft и защиту от ложных срабатываний. // Использует реальное состояние Raft и защиту от ложных срабатываний.
//
// ДОБАВЛЕНО: при смене лидерства вызываем sagaLeaderBridge.NotifyLeadership,
// чтобы bridge знал, кто лидер. Это используется, если кто-то создаст
// storage.SagaLeaderElector на базе этого bridge.
func (rc *RaftCoordinator) monitorLeadership() { func (rc *RaftCoordinator) monitorLeadership() {
ticker := time.NewTicker(rc.config.Cluster.GetHeartbeatTimeout() / 2) ticker := time.NewTicker(rc.config.Cluster.GetHeartbeatTimeout() / 2)
defer ticker.Stop() defer ticker.Stop()
@@ -3796,6 +4042,16 @@ func (rc *RaftCoordinator) monitorLeadership() {
} }
// Проверяем реальное состояние Raft // Проверяем реальное состояние Raft
isLeader := rc.raft.State() == raft.Leader isLeader := rc.raft.State() == raft.Leader
// ДОБАВЛЕНО: уведомляем bridge о текущем лидерстве.
leaderID := ""
if leader := rc.GetLeader(); leader != nil {
leaderID = leader.ID
}
if rc.sagaLeaderBridge != nil {
rc.sagaLeaderBridge.NotifyLeadership(isLeader, leaderID, rc.currentTerm.Load())
}
if isLeader != wasLeader { if isLeader != wasLeader {
wasLeader = isLeader wasLeader = isLeader
select { select {
@@ -3848,6 +4104,9 @@ func (rc *RaftCoordinator) rebalanceMonitor() {
} }
// Stop останавливает координатор. // Stop останавливает координатор.
//
// ДОБАВЛЕНО: останавливаем sagaLeaderBridge и sagaManager.
// ИСПРАВЛЕНО: sagaManager теперь *cluster.SagaManager, у которого есть Stop().
func (rc *RaftCoordinator) Stop() { func (rc *RaftCoordinator) Stop() {
now := time.Now().UnixMilli() now := time.Now().UnixMilli()
@@ -3905,6 +4164,14 @@ func (rc *RaftCoordinator) Stop() {
rc.crossDCMigrator.Stop() rc.crossDCMigrator.Stop()
rc.logger.Debug("Cross-datacenter migrator stopped") rc.logger.Debug("Cross-datacenter migrator stopped")
} }
if rc.sagaManager != nil {
rc.sagaManager.Stop()
rc.logger.Debug("Saga manager stopped")
}
if rc.sagaLeaderBridge != nil {
rc.sagaLeaderBridge.Stop()
rc.logger.Debug("Saga leader bridge stopped")
}
close(rc.stopChan) close(rc.stopChan)
if rc.raft != nil { if rc.raft != nil {
+49 -6
View File
@@ -105,11 +105,54 @@ ACL MANAGEMENT:
acl listUsers - List all users acl listUsers - List all users
acl listRoles - List all roles acl listRoles - List all roles
TRANSACTIONS (MongoDB-like syntax): BATCH OPERATIONS (MongoDB-like syntax):
session = db.startSession() - Start a new session batch = db.startBatch() - Start a new batch (atomically applied group of operations)
session.startTransaction() - Begin a transaction batch.addInsert({...}) - Add insert operation to batch
session.commitTransaction() - Commit current transaction batch.addUpdate({_id: "..."}, {...}) - Add update operation to batch
session.abortTransaction() - Abort/Rollback current transaction batch.addDelete({_id: "..."}) - Add delete operation to batch
batch.addRestore({_id: "..."}) - Add restore operation to batch
batch.commit() - Commit batch (applies all operations atomically via WAL)
batch.abort() - Abort batch (operations are discarded)
BATCH OPERATIONS (REPL syntax):
batch begin [collection] - Begin a new batch
batch commit - Commit the current batch
batch abort - Abort the current batch
show batches - Show the active batch of the current session
BATCH ALIASES (legacy compatibility):
begin transaction - Alias for 'batch begin'
commit - Alias for 'batch commit'
rollback - Alias for 'batch abort'
show transactions - Alias for 'show batches'
BATCH NOTES:
• Batch — это группа операций, применяемых атомарно: либо все, либо ни одна.
• Атомарность обеспечивается через WAL (Write-Ahead Log): сначала весь batch
записывается в WAL с fsync, только после этого операции применяются к
in-memory структурам.
• Batch короткоживущий: создаётся, наполняется операциями и коммитится в
рамках одной логической операции. Не поддерживает savepoints, таймауты,
распределённую координацию — для распределённых операций используйте SAGA.
• Для распределённых операций между узлами/сервисами используйте SAGA
(saga status, saga list).
• Time-travel: для получения версии документа на момент времени используйте
WAL-based replay (GetDocumentAtTimestamp).
BATCH EXAMPLE:
// Begin batch
batch = db.startBatch("shop", "orders")
// Add operations
batch.addInsert({_id: "order_1", status: "new", total: 100})
batch.addUpdate({_id: "inv_1"}, {qty: 5})
batch.addDelete({_id: "cart_1"})
// Commit atomically
batch.commit()
// Or abort if something went wrong
// batch.abort()
EXPORT/IMPORT (MessagePack format): EXPORT/IMPORT (MessagePack format):
export "database_name" "filename.msgpack" - Export entire database export "database_name" "filename.msgpack" - Export entire database
@@ -192,7 +235,7 @@ CLUSTER MANAGEMENT:
HTTP API: HTTP API:
The database also exposes HTTP RESTful API on port 8080 (configurable) The database also exposes HTTP RESTful API on port 8080 (configurable)
See documentation for endpoints: /api/db/, /api/index/, /api/acl/, /api/constraint/, /api/trigger/, /api/migration/ See documentation for endpoints: /api/db/, /api/index/, /api/acl/, /api/constraint/, /api/trigger/, /api/transaction/, /api/migration/
UTILITIES: UTILITIES:
help - Show this help message help - Show this help message
+305 -113
View File
@@ -10,10 +10,44 @@
// Файл: internal/compression/compression.go // Файл: internal/compression/compression.go
// Назначение: Реализация сжатия данных с использованием различных алгоритмов. // Назначение: Реализация сжатия данных с использованием различных алгоритмов.
// Поддерживаемый алгоритм: Brotli. // Поддерживаемые алгоритмы: Snappy, Brotli, Zstd.
// Обеспечивает прозрачное сжатие/распаковку для документов. // Обеспечивает прозрачное сжатие/распаковку для документов.
// LZ4 был удалён в пользу Brotli для лучшего версионирования. // Защита от collision magic bytes через усиленную проверку заголовка.
// Защита от collision magic bytes через усиленную проверку заголовка //
// ИСПРАВЛЕНО:
// 1. Compress больше не паникует при config == nil — возвращает data
// как есть (это логично: без конфигурации сжатие выключено).
// 2. Compress учитывает оверхед заголовка при решении «сжимать или нет».
// Раньше проверялось `len(compressed) >= len(data)`, но не учитывалось,
// что заголовок (13 или 14 байт) может сделать итог больше исходных
// данных. Теперь сравнение: `len(compressed) + headerSize >= len(data)`.
// 3. Level для brotli теперь применяется (brotli.NewWriterLevel), а не
// игнорируется. Диапазон уровней brotli — 0..11, поэтому входной
// Level (1..9) нормализуется.
// 4. Введены константы MagicV1HeaderSize и MagicV2HeaderSize. Раньше
// в decompressV1 стояло магическое число 13, а MinCompressedSize = 14
// относилось к V2 — легко было перепутать.
// 5. zstd-энкодер и декодер кэшируются (sync.Map). zstd.Encoder.EncodeAll
// и zstd.Decoder.DecodeAll потокобезопасны, поэтому кэш безопасен.
// Раньше энкодер/декодер создавались на каждый вызов Compress /
// doDecompress — это дорого на горячем пути.
// 6. Логика парсинга заголовка (V1/V2) вынесена в parseHeader —
// убрано дублирование между Decompress, DecompressAuto, IsCompressed,
// GetCompressionType.
// 7. В doDecompress для CompressionNone добавлена проверка размера
// (защита в глубину от malformed-входа).
// 8. bytes.Equal(data[0:N], magic) заменено на bytes.HasPrefix —
// идиоматичнее и читается лучше.
// 9. Лимит originalSize вынесен в константу MaxOriginalSize.
// 10. Обновлён комментарий про LZ4: LZ4 удалён, остались snappy/brotli/zstd.
//
// ИСПРАВЛЕНО (компиляция):
// В Compress, в ветке case "zstd", переменная err была объявлена,
// но не использовалась: zstd.Encoder.EncodeAll не возвращает ошибку.
// Убрано объявление `encoder, err := ...` — теперь `encoder, e := ...`
// с локальной переменной e, которая проверяется тут же. Внешняя err
// по-прежнему используется в других ветках (snappy/brotli) и в
// финальной проверке оверхеда заголовка.
package compression package compression
@@ -21,13 +55,18 @@ import (
"bytes" "bytes"
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"sync"
"github.com/andybalholm/brotli"
"github.com/golang/snappy" "github.com/golang/snappy"
"github.com/klauspost/compress/zstd" "github.com/klauspost/compress/zstd"
"github.com/andybalholm/brotli"
) )
// Config представляет конфигурацию сжатия // =============================================================================
// КОНФИГУРАЦИЯ
// =============================================================================
// Config представляет конфигурацию сжатия.
type Config struct { type Config struct {
Enabled bool // Включено ли сжатие Enabled bool // Включено ли сжатие
Algorithm string // Алгоритм сжатия: snappy, brotli, zstd Algorithm string // Алгоритм сжатия: snappy, brotli, zstd
@@ -35,38 +74,183 @@ type Config struct {
MinSize int // Минимальный размер для сжатия (байт) MinSize int // Минимальный размер для сжатия (байт)
} }
// MagicNumber используется для идентификации сжатых данных // =============================================================================
// MAGIC NUMBERS И КОНСТАНТЫ ФОРМАТА
// =============================================================================
// MagicNumber используется для идентификации сжатых данных (V1).
var MagicNumber = []byte{0x46, 0x54, 0x52, 0x53} // "FTRS" - Futriis var MagicNumber = []byte{0x46, 0x54, 0x52, 0x53} // "FTRS" - Futriis
// Расширенный magic с версией для предотвращения collision // MagicNumberV2 — расширенный magic с версией для предотвращения collision.
var MagicNumberV2 = []byte{0x46, 0x54, 0x52, 0x53, 0x02} // "FTRS\x02" var MagicNumberV2 = []byte{0x46, 0x54, 0x52, 0x53, 0x02} // "FTRS\x02"
// MinCompressedSize — минимальный размер валидного сжатого блока const (
// Для защиты от collision с пользовательскими данными // MagicV1HeaderSize — размер заголовка формата V1:
const MinCompressedSize = 14 // 5 (magic v2) + 1 (type) + 8 (size) // 4 (magic) + 1 (type) + 8 (size) = 13 байт.
MagicV1HeaderSize = 4 + 1 + 8
// CompressionType определяет тип сжатия // MagicV2HeaderSize — размер заголовка формата V2:
// 5 (magic) + 1 (type) + 8 (size) = 14 байт.
MagicV2HeaderSize = 5 + 1 + 8
// MinCompressedSize — минимальный размер валидного сжатого блока.
// Равен размеру заголовка V2 (самый свежий формат).
// Оставлен экспортируемым для обратной совместимости с внешним кодом,
// который мог его использовать.
MinCompressedSize = MagicV2HeaderSize
// MaxOriginalSize — верхняя граница originalSize, которую мы считаем
// разумной. 100 GiB. Защита от мусорных/враждебных заголовков.
MaxOriginalSize = uint64(100) * 1024 * 1024 * 1024
)
// CompressionType определяет тип сжатия.
type CompressionType byte type CompressionType byte
const ( const (
CompressionNone CompressionType = 0x00 CompressionNone CompressionType = 0x00
CompressionSnappy CompressionType = 0x01 CompressionSnappy CompressionType = 0x01
CompressionBrotli CompressionType = 0x02 CompressionBrotli CompressionType = 0x02
CompressionZstd CompressionType = 0x03 CompressionZstd CompressionType = 0x03
) )
// Compress сжимает данные с использованием указанного алгоритма // =============================================================================
// КЭШ ZSTD ENCODER / DECODER
// =============================================================================
//
// zstd.Encoder.EncodeAll и zstd.Decoder.DecodeAll потокобезопасны
// (см. документацию klauspost/compress/zstd). Поэтому один общий
// encoder/decoder можно безопасно переиспользовать из многих горутин.
//
// Кэшируем по уровню для encoder и один общий для decoder.
var (
zstdEncoders sync.Map // map[zstd.EncoderLevel]*zstd.Encoder
zstdDecoder *zstd.Decoder
zstdDecOnce sync.Once
zstdDecErr error
)
// getZstdEncoder возвращает кэшированный zstd-энкодер для уровня.
func getZstdEncoder(level zstd.EncoderLevel) (*zstd.Encoder, error) {
if enc, ok := zstdEncoders.Load(level); ok {
return enc.(*zstd.Encoder), nil
}
enc, err := zstd.NewWriter(nil, zstd.WithEncoderLevel(level))
if err != nil {
return nil, fmt.Errorf("failed to create zstd encoder: %v", err)
}
actual, _ := zstdEncoders.LoadOrStore(level, enc)
// Если другая горутина успела первой — закрываем свой encoder.
if actual.(*zstd.Encoder) != enc {
enc.Close()
}
return actual.(*zstd.Encoder), nil
}
// getZstdDecoder возвращает общий zstd-декодер (создаётся один раз).
func getZstdDecoder() (*zstd.Decoder, error) {
zstdDecOnce.Do(func() {
zstdDecoder, zstdDecErr = zstd.NewReader(nil)
})
return zstdDecoder, zstdDecErr
}
// =============================================================================
// ПАРСИНГ ЗАГОЛОВКА
// =============================================================================
// headerInfo описывает разобранный заголовок сжатого блока.
type headerInfo struct {
version int
compType CompressionType
originalSize uint64
headerSize int
}
// parseHeader пытается разобрать заголовок сжатого блока (V2 или V1).
// Возвращает nil, если данные не являются сжатым блоком.
//
// ВНИМАНИЕ: для V1 проверяется валидность compType — это защита от
// ложных срабатываний, когда несжатые данные случайно начинаются
// с байт "FTRS".
func parseHeader(data []byte) *headerInfo {
// V2: 5-байтовый magic + 1 байт type + 8 байт size.
if len(data) >= MagicV2HeaderSize && bytes.HasPrefix(data, MagicNumberV2) {
compType := CompressionType(data[5])
if isValidCompressionType(compType) {
return &headerInfo{
version: 2,
compType: compType,
originalSize: binary.LittleEndian.Uint64(data[6:14]),
headerSize: MagicV2HeaderSize,
}
}
// Magic совпал, но тип невалиден — не считаем это сжатым блоком.
return nil
}
// V1: 4-байтовый magic + 1 байт type + 8 байт size.
if len(data) >= MagicV1HeaderSize && bytes.HasPrefix(data, MagicNumber) {
compType := CompressionType(data[4])
if isValidCompressionType(compType) {
return &headerInfo{
version: 1,
compType: compType,
originalSize: binary.LittleEndian.Uint64(data[5:13]),
headerSize: MagicV1HeaderSize,
}
}
return nil
}
return nil
}
// isValidCompressionType проверяет, что байт соответствует известному
// типу сжатия. Используется как защита от ложных срабатываний magic.
func isValidCompressionType(t CompressionType) bool {
switch t {
case CompressionNone, CompressionSnappy, CompressionBrotli, CompressionZstd:
return true
default:
return false
}
}
// =============================================================================
// СЖАТИЕ
// =============================================================================
// Compress сжимает данные с использованием указанного алгоритма.
//
// ИСПРАВЛЕНО:
// - config == nil больше не приводит к панике — возвращаем data как есть.
// - Учитывается оверхед заголовка при решении «сжимать или нет».
// - Level применяется не только к zstd, но и к brotli.
//
// ИСПРАВЛЕНО (компиляция):
// В ветке case "zstd" убрано объявление err, которое не использовалось:
// zstd.Encoder.EncodeAll не возвращает ошибку. Ошибка возможна только
// при создании encoder через getZstdEncoder — она проверяется тут же.
func Compress(data []byte, config *Config) ([]byte, error) { func Compress(data []byte, config *Config) ([]byte, error) {
if !config.Enabled { // config == nil трактуем как «сжатие выключено».
if config == nil || !config.Enabled {
return data, nil return data, nil
} }
if len(data) < config.MinSize { // MinSize <= 0 означает «сжимать всё, включая пустые данные».
// Если MinSize > 0 и данные меньше — не сжимаем.
if config.MinSize > 0 && len(data) < config.MinSize {
return data, nil
}
// Пустые данные сжимать бессмысленно.
if len(data) == 0 {
return data, nil return data, nil
} }
var compressed []byte var compressed []byte
var err error
var compType CompressionType var compType CompressionType
switch config.Algorithm { switch config.Algorithm {
@@ -75,10 +259,19 @@ func Compress(data []byte, config *Config) ([]byte, error) {
compType = CompressionSnappy compType = CompressionSnappy
case "brotli": case "brotli":
// brotli использует уровни 0..11. Входной Level — 1..9
// (см. Config.Level). Нормализуем: <=1 -> 1, >=9 -> 11 (best).
// Это даёт предсказуемое поведение: пользователь ставит 9 —
// получает максимальное сжатие.
level := normalizeBrotliLevel(config.Level)
buf := bytes.NewBuffer(nil) buf := bytes.NewBuffer(nil)
writer := brotli.NewWriter(buf) writer := brotli.NewWriterLevel(buf, level)
if _, err := writer.Write(data); err != nil { if _, err := writer.Write(data); err != nil {
// writer.Close() стоит вызвать даже при ошибке Write,
// чтобы освободить ресурсы, но ошибку Close игнорируем —
// основная уже есть.
_ = writer.Close()
return nil, fmt.Errorf("brotli write failed: %v", err) return nil, fmt.Errorf("brotli write failed: %v", err)
} }
if err := writer.Close(); err != nil { if err := writer.Close(); err != nil {
@@ -88,9 +281,7 @@ func Compress(data []byte, config *Config) ([]byte, error) {
compType = CompressionBrotli compType = CompressionBrotli
case "zstd": case "zstd":
var encoder *zstd.Encoder
var encoderLevel zstd.EncoderLevel var encoderLevel zstd.EncoderLevel
switch { switch {
case config.Level <= 1: case config.Level <= 1:
encoderLevel = zstd.SpeedFastest encoderLevel = zstd.SpeedFastest
@@ -102,12 +293,15 @@ func Compress(data []byte, config *Config) ([]byte, error) {
encoderLevel = zstd.SpeedBestCompression encoderLevel = zstd.SpeedBestCompression
} }
encoder, err = zstd.NewWriter(nil, zstd.WithEncoderLevel(encoderLevel)) // ИСПРАВЛЕНО: вместо `encoder, err := ...` используем локальную e,
if err != nil { // чтобы не объявлять неиспользуемую err. getZstdEncoder возвращает
return nil, fmt.Errorf("failed to create zstd encoder: %v", err) // (*zstd.Encoder, error), и эта ошибка проверяется тут же.
encoder, e := getZstdEncoder(encoderLevel)
if e != nil {
return nil, e
} }
defer encoder.Close()
// EncodeAll потокобезопасен, используем общий encoder.
compressed = encoder.EncodeAll(data, nil) compressed = encoder.EncodeAll(data, nil)
compType = CompressionZstd compType = CompressionZstd
@@ -115,16 +309,18 @@ func Compress(data []byte, config *Config) ([]byte, error) {
return nil, fmt.Errorf("unsupported compression algorithm: %s", config.Algorithm) return nil, fmt.Errorf("unsupported compression algorithm: %s", config.Algorithm)
} }
// Проверяем, что сжатие действительно уменьшило размер // ИСПРАВЛЕНО: учитываем оверхед заголовка V2.
if len(compressed) >= len(data) { // Если сжатые данные + заголовок не меньше исходных —
// сжатие не имеет смысла, возвращаем исходные данные.
if len(compressed)+MagicV2HeaderSize >= len(data) {
return data, nil return data, nil
} }
// Используем MagicNumberV2 (5 байт) + type (1) + size (8) = 14 байт // Формируем заголовок V2: 5 (magic) + 1 (type) + 8 (size).
header := make([]byte, 5+1+8) header := make([]byte, MagicV2HeaderSize)
copy(header[0:5], MagicNumberV2) copy(header[0:5], MagicNumberV2)
header[5] = byte(compType) header[5] = byte(compType)
binary.LittleEndian.PutUint64(header[6:], uint64(len(data))) binary.LittleEndian.PutUint64(header[6:14], uint64(len(data)))
result := make([]byte, 0, len(header)+len(compressed)) result := make([]byte, 0, len(header)+len(compressed))
result = append(result, header...) result = append(result, header...)
@@ -133,63 +329,61 @@ func Compress(data []byte, config *Config) ([]byte, error) {
return result, nil return result, nil
} }
// Decompress распаковывает данные // normalizeBrotliLevel приводит входной уровень (1..9) к диапазону
// Поддержка обоих форматов (V1 и V2), усиленная валидация // brotli (0..11). Значения <=0 и >9 трактуются разумно:
// - level <= 0 -> brotli.DefaultCompression
// - level 1..9 -> линейное отображение: 1->1, 9->11
// - level > 9 -> 11 (максимум)
func normalizeBrotliLevel(level int) int {
if level <= 0 {
return brotli.DefaultCompression
}
if level >= 9 {
return 11
}
// Линейное отображение 1..9 -> 1..11.
// 1->1, 2->2, ..., 9->11. Формула: 1 + (level-1)*10/8.
return 1 + (level-1)*10/8
}
// =============================================================================
// РАСПАКОВКА
// =============================================================================
// Decompress распаковывает данные.
// Поддерживает оба формата (V1 и V2), усиленная валидация.
func Decompress(data []byte) ([]byte, error) { func Decompress(data []byte) ([]byte, error) {
// Проверяем V2 формат (5-байтовый magic) info := parseHeader(data)
if len(data) >= MinCompressedSize && bytes.Equal(data[0:5], MagicNumberV2) { if info == nil {
return decompressV2(data) return nil, fmt.Errorf("invalid magic number")
} }
return decompressWithInfo(data, info)
// Проверяем V1 формат (4-байтовый magic) для обратной совместимости
if len(data) >= 4+1+8 && bytes.Equal(data[0:4], MagicNumber) {
return decompressV1(data)
}
return nil, fmt.Errorf("invalid magic number")
} }
// decompressV1 распаковывает данные в старом формате V1 // decompressWithInfo выполняет распаковку, используя разобранный заголовок.
func decompressV1(data []byte) ([]byte, error) { func decompressWithInfo(data []byte, info *headerInfo) ([]byte, error) {
if len(data) < 4+1+8 { if len(data) < info.headerSize {
return nil, fmt.Errorf("data too short for compressed format V1") return nil, fmt.Errorf("data too short for compressed format V%d", info.version)
} }
compType := CompressionType(data[4]) // Проверка разумности размера.
originalSize := binary.LittleEndian.Uint64(data[5:13]) if info.originalSize == 0 || info.originalSize > MaxOriginalSize {
compressedData := data[13:] return nil, fmt.Errorf("invalid original size: %d", info.originalSize)
if originalSize == 0 {
return nil, fmt.Errorf("invalid original size")
}
return doDecompress(compType, compressedData, originalSize)
}
// decompressV2 распаковывает данные в новом формате V2
// Добавлена проверка согласованности размера
func decompressV2(data []byte) ([]byte, error) {
if len(data) < MinCompressedSize {
return nil, fmt.Errorf("data too short for compressed format V2")
}
compType := CompressionType(data[5])
originalSize := binary.LittleEndian.Uint64(data[6:14])
compressedData := data[14:]
// Проверка разумности размера
if originalSize == 0 || originalSize > 100*1024*1024*1024 { // Max 100GB
return nil, fmt.Errorf("invalid original size: %d", originalSize)
} }
compressedData := data[info.headerSize:]
if len(compressedData) == 0 { if len(compressedData) == 0 {
return nil, fmt.Errorf("no compressed data") return nil, fmt.Errorf("no compressed data")
} }
return doDecompress(compType, compressedData, originalSize) return doDecompress(info.compType, compressedData, info.originalSize)
} }
// doDecompress выполняет распаковку по типу // doDecompress выполняет распаковку по типу.
//
// ИСПРАВЛЕНО:
// - для CompressionNone добавлена проверка размера (защита в глубину);
// - zstd-декодер берётся из кэша, а не создаётся на каждый вызов.
func doDecompress(compType CompressionType, compressedData []byte, originalSize uint64) ([]byte, error) { func doDecompress(compType CompressionType, compressedData []byte, originalSize uint64) ([]byte, error) {
var decompressed []byte var decompressed []byte
var err error var err error
@@ -211,25 +405,32 @@ func doDecompress(compType CompressionType, compressedData []byte, originalSize
decompressed = buf.Bytes() decompressed = buf.Bytes()
case CompressionZstd: case CompressionZstd:
decoder, err := zstd.NewReader(nil) decoder, err := getZstdDecoder()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create zstd decoder: %v", err) return nil, err
} }
defer decoder.Close() // DecodeAll потокобезопасен, используем общий decoder.
decompressed, err = decoder.DecodeAll(compressedData, nil) decompressed, err = decoder.DecodeAll(compressedData, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("zstd decode failed: %v", err) return nil, fmt.Errorf("zstd decode failed: %v", err)
} }
case CompressionNone: case CompressionNone:
// ИСПРАВЛЕНО: проверяем согласованность размера даже для None.
// Это защита в глубину от malformed-входа. Сейчас Compress не
// пишет CompressionNone в заголовок, но на входе может оказаться
// подделанный блок.
if uint64(len(compressedData)) != originalSize {
return nil, fmt.Errorf("size mismatch for uncompressed: expected %d, got %d",
originalSize, len(compressedData))
}
return compressedData, nil return compressedData, nil
default: default:
return nil, fmt.Errorf("unsupported compression type: %d", compType) return nil, fmt.Errorf("unsupported compression type: %d", compType)
} }
// Проверяем, что распакованный размер совпадает с заявленным // Проверяем, что распакованный размер совпадает с заявленным.
if uint64(len(decompressed)) != originalSize { if uint64(len(decompressed)) != originalSize {
return nil, fmt.Errorf("size mismatch: expected %d, got %d", originalSize, len(decompressed)) return nil, fmt.Errorf("size mismatch: expected %d, got %d", originalSize, len(decompressed))
} }
@@ -237,52 +438,43 @@ func doDecompress(compType CompressionType, compressedData []byte, originalSize
return decompressed, nil return decompressed, nil
} }
// DecompressAuto автоматически определяет, сжаты ли данные, и распаковывает при необходимости // DecompressAuto автоматически определяет, сжаты ли данные, и распаковывает при необходимости.
// Усиленная проверка для предотвращения collision // Усиленная проверка для предотвращения collision.
func DecompressAuto(data []byte) ([]byte, error) { func DecompressAuto(data []byte) ([]byte, error) {
// Проверяем V2 magic (более надёжный) info := parseHeader(data)
if len(data) >= MinCompressedSize && bytes.Equal(data[0:5], MagicNumberV2) { if info == nil {
return Decompress(data) // Данные не сжаты.
return data, nil
} }
return decompressWithInfo(data, info)
// Проверяем V1 magic (для обратной совместимости)
if len(data) >= 4+1+8 && bytes.Equal(data[0:4], MagicNumber) {
// Дополнительная проверка: тип сжатия должен быть валидным
compType := CompressionType(data[4])
if compType <= CompressionZstd {
return Decompress(data)
}
}
// Данные не сжаты
return data, nil
} }
// IsCompressed проверяет, сжаты ли данные // IsCompressed проверяет, сжаты ли данные (V1 или V2).
// Проверка обоих форматов
func IsCompressed(data []byte) bool { func IsCompressed(data []byte) bool {
if len(data) >= MinCompressedSize && bytes.Equal(data[0:5], MagicNumberV2) { return parseHeader(data) != nil
return true
}
if len(data) >= 4+1+8 && bytes.Equal(data[0:4], MagicNumber) {
compType := CompressionType(data[4])
return compType <= CompressionZstd
}
return false
} }
// GetCompressionType возвращает тип сжатия данных // GetCompressionType возвращает тип сжатия данных.
// Возвращает CompressionNone, если данные не сжаты.
func GetCompressionType(data []byte) CompressionType { func GetCompressionType(data []byte) CompressionType {
if len(data) >= MinCompressedSize && bytes.Equal(data[0:5], MagicNumberV2) { info := parseHeader(data)
return CompressionType(data[5]) if info == nil {
}
if !IsCompressed(data) || len(data) < 5 {
return CompressionNone return CompressionNone
} }
return CompressionType(data[4]) return info.compType
} }
// GetCompressionRatio возвращает коэффициент сжатия // =============================================================================
// СТАТИСТИКА
// =============================================================================
// GetCompressionRatio возвращает коэффициент сжатия как долю:
// - 1.0 — данные не уменьшились (или выросли);
// - 0.5 — сжатые данные в два раза меньше исходных;
// - 0.0 — теоретический предел (пустые данные после сжатия).
//
// ВНИМАНИЕ: это именно доля compressed/original, а не «во сколько раз
// сжали». Если нужно «во сколько раз», используйте 1.0 / ratio.
func GetCompressionRatio(original, compressed []byte) float64 { func GetCompressionRatio(original, compressed []byte) float64 {
if len(original) == 0 { if len(original) == 0 {
return 1.0 return 1.0
+265 -429
View File
File diff suppressed because it is too large. Load diff
+85 -62
View File
@@ -9,7 +9,7 @@
*/ */
// Файл: internal/config/dynamic_config.go // Файл: internal/config/dynamic_config.go
// Назначение: Централизованное управление конфигурацией через Raft // Назначение: Централизованное управление конфигурацией через Raft.
package config package config
@@ -32,8 +32,8 @@ import (
type atomicBool struct{ v int32 } type atomicBool struct{ v int32 }
func (b *atomicBool) Load() bool { return atomic.LoadInt32(&b.v) != 0 } func (b *atomicBool) Load() bool { return atomic.LoadInt32(&b.v) != 0 }
func (b *atomicBool) Store(v bool) { atomic.StoreInt32(&b.v, boolToInt32(v)) } func (b *atomicBool) Store(v bool) { atomic.StoreInt32(&b.v, boolToInt32(v)) }
func (b *atomicBool) CAS(old, new bool) bool { func (b *atomicBool) CAS(old, new bool) bool {
return atomic.CompareAndSwapInt32(&b.v, boolToInt32(old), boolToInt32(new)) return atomic.CompareAndSwapInt32(&b.v, boolToInt32(old), boolToInt32(new))
} }
@@ -55,7 +55,7 @@ func (a *atomicUint64) Add(d uint64) uint64 { return atomic.AddUint64(&a.v, d) }
// ТИПЫ ДЛЯ ДИНАМИЧЕСКОЙ КОНФИГУРАЦИИ // ТИПЫ ДЛЯ ДИНАМИЧЕСКОЙ КОНФИГУРАЦИИ
// ============================================================================= // =============================================================================
// DynamicConfigChange представляет изменение конфигурации // DynamicConfigChange представляет изменение конфигурации.
type DynamicConfigChange struct { type DynamicConfigChange struct {
ID string `json:"id"` ID string `json:"id"`
Version uint64 `json:"version"` Version uint64 `json:"version"`
@@ -65,22 +65,22 @@ type DynamicConfigChange struct {
Changes map[string]interface{} `json:"changes"` Changes map[string]interface{} `json:"changes"`
} }
// DynamicConfigSnapshot представляет снэпшот конфигурации // DynamicConfigSnapshot представляет снэпшот конфигурации.
type DynamicConfigSnapshot struct { type DynamicConfigSnapshot struct {
Version uint64 `json:"version"` Version uint64 `json:"version"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
Config *Config `json:"config"` Config *Config `json:"config"`
ChangeHistory []*DynamicConfigChange `json:"change_history"` ChangeHistory []*DynamicConfigChange `json:"change_history"`
} }
// ConfigChangeListener представляет слушатель изменений конфигурации // ConfigChangeListener представляет слушатель изменений конфигурации.
type ConfigChangeListener func(change *DynamicConfigChange) type ConfigChangeListener func(change *DynamicConfigChange)
// ============================================================================= // =============================================================================
// DYNAMIC CONFIG MANAGER // DYNAMIC CONFIG MANAGER
// ============================================================================= // =============================================================================
// DynamicConfigManager управляет динамической конфигурацией через Raft // DynamicConfigManager управляет динамической конфигурацией через Raft.
type DynamicConfigManager struct { type DynamicConfigManager struct {
config *Config config *Config
logger *log.Logger logger *log.Logger
@@ -97,29 +97,28 @@ type DynamicConfigManager struct {
isLeader atomicBool isLeader atomicBool
} }
// ConfigFSM реализует конечный автомат для конфигурации // ConfigFSM реализует конечный автомат для конфигурации.
type ConfigFSM struct { type ConfigFSM struct {
config *DynamicConfigSnapshot config *DynamicConfigSnapshot
mu sync.RWMutex mu sync.RWMutex
logger *log.Logger logger *log.Logger
// onChange вызывается после успешного применения изменения. // onChange вызывается после успешного применения изменения.
// Может быть установлен менеджером для уведомления слушателей. // Может быть установлен менеджером для уведомления слушателей.
onChange func(change *DynamicConfigChange) onChange func(change *DynamicConfigChange)
onChangeMu sync.RWMutex onChangeMu sync.RWMutex
} }
// ConfigSnapshot реализует raft.FSMSnapshot // ConfigSnapshot реализует raft.FSMSnapshot.
type ConfigSnapshot struct { type ConfigSnapshot struct {
config *DynamicConfigSnapshot config *DynamicConfigSnapshot
} }
// NewDynamicConfigManager создаёт новый менеджер динамической конфигурации // NewDynamicConfigManager создаёт новый менеджер динамической конфигурации.
func NewDynamicConfigManager(cfg *Config, logger *log.Logger) *DynamicConfigManager { func NewDynamicConfigManager(cfg *Config, logger *log.Logger) *DynamicConfigManager {
if cfg == nil { if cfg == nil {
cfg = &Config{} cfg = &Config{}
} }
// Создаём начальный снэпшот
initialSnapshot := &DynamicConfigSnapshot{ initialSnapshot := &DynamicConfigSnapshot{
Version: 1, Version: 1,
UpdatedAt: time.Now().UnixMilli(), UpdatedAt: time.Now().UnixMilli(),
@@ -142,13 +141,11 @@ func NewDynamicConfigManager(cfg *Config, logger *log.Logger) *DynamicConfigMana
} }
dm.version.Store(1) dm.version.Store(1)
// Подключаем FSM к менеджеру для уведомления слушателей
fsm.onChangeMu.Lock() fsm.onChangeMu.Lock()
fsm.onChange = func(change *DynamicConfigChange) { fsm.onChange = func(change *DynamicConfigChange) {
dm.version.Store(change.Version) dm.version.Store(change.Version)
dm.mu.Lock() dm.mu.Lock()
dm.changeHistory = append(dm.changeHistory, change) dm.changeHistory = append(dm.changeHistory, change)
// Ограничиваем историю
if len(dm.changeHistory) > 1000 { if len(dm.changeHistory) > 1000 {
dm.changeHistory = dm.changeHistory[len(dm.changeHistory)-1000:] dm.changeHistory = dm.changeHistory[len(dm.changeHistory)-1000:]
} }
@@ -160,19 +157,19 @@ func NewDynamicConfigManager(cfg *Config, logger *log.Logger) *DynamicConfigMana
return dm return dm
} }
// SetRaft устанавливает Raft для управления конфигурацией // SetRaft устанавливает Raft для управления конфигурацией.
func (dm *DynamicConfigManager) SetRaft(r *raft.Raft) { func (dm *DynamicConfigManager) SetRaft(r *raft.Raft) {
dm.mu.Lock() dm.mu.Lock()
defer dm.mu.Unlock() defer dm.mu.Unlock()
dm.raft = r dm.raft = r
} }
// GetFSM возвращает FSM для конфигурации // GetFSM возвращает FSM для конфигурации.
func (dm *DynamicConfigManager) GetFSM() *ConfigFSM { func (dm *DynamicConfigManager) GetFSM() *ConfigFSM {
return dm.fsm return dm.fsm
} }
// Start запускает менеджер динамической конфигурации // Start запускает менеджер динамической конфигурации.
func (dm *DynamicConfigManager) Start() { func (dm *DynamicConfigManager) Start() {
if dm.logger != nil { if dm.logger != nil {
dm.logger.Info("Dynamic config manager started") dm.logger.Info("Dynamic config manager started")
@@ -203,9 +200,8 @@ func (dm *DynamicConfigManager) GetConfig() *Config {
return deepCopyConfig(dm.fsm.config.Config) return deepCopyConfig(dm.fsm.config.Config)
} }
// GetVersion возвращает текущую версию конфигурации // GetVersion возвращает текущую версию конфигурации.
func (dm *DynamicConfigManager) GetVersion() uint64 { func (dm *DynamicConfigManager) GetVersion() uint64 {
// Синхронизируем с FSM, чтобы значение было актуальным
dm.fsm.mu.RLock() dm.fsm.mu.RLock()
defer dm.fsm.mu.RUnlock() defer dm.fsm.mu.RUnlock()
if dm.fsm.config != nil { if dm.fsm.config != nil {
@@ -214,7 +210,7 @@ func (dm *DynamicConfigManager) GetVersion() uint64 {
return dm.version.Load() return dm.version.Load()
} }
// GetChangeHistory возвращает историю изменений // GetChangeHistory возвращает историю изменений.
func (dm *DynamicConfigManager) GetChangeHistory() []*DynamicConfigChange { func (dm *DynamicConfigManager) GetChangeHistory() []*DynamicConfigChange {
dm.mu.RLock() dm.mu.RLock()
defer dm.mu.RUnlock() defer dm.mu.RUnlock()
@@ -224,7 +220,7 @@ func (dm *DynamicConfigManager) GetChangeHistory() []*DynamicConfigChange {
return history return history
} }
// ApplyChange применяет изменение конфигурации через Raft // ApplyChange применяет изменение конфигурации через Raft.
func (dm *DynamicConfigManager) ApplyChange(changes map[string]interface{}, description string, changedBy string) error { func (dm *DynamicConfigManager) ApplyChange(changes map[string]interface{}, description string, changedBy string) error {
dm.mu.RLock() dm.mu.RLock()
r := dm.raft r := dm.raft
@@ -234,14 +230,12 @@ func (dm *DynamicConfigManager) ApplyChange(changes map[string]interface{}, desc
return fmt.Errorf("raft not initialized") return fmt.Errorf("raft not initialized")
} }
// Проверяем, что мы лидер — через сам Raft, а не через внешний флаг
if r.State() != raft.Leader { if r.State() != raft.Leader {
return fmt.Errorf("not the leader (current state: %s)", r.State()) return fmt.Errorf("not the leader (current state: %s)", r.State())
} }
currentVersion := dm.GetVersion() currentVersion := dm.GetVersion()
// Создаём команду изменения
change := &DynamicConfigChange{ change := &DynamicConfigChange{
ID: fmt.Sprintf("config_%d_%d", currentVersion+1, time.Now().UnixNano()), ID: fmt.Sprintf("config_%d_%d", currentVersion+1, time.Now().UnixNano()),
Version: currentVersion + 1, Version: currentVersion + 1,
@@ -251,7 +245,6 @@ func (dm *DynamicConfigManager) ApplyChange(changes map[string]interface{}, desc
Changes: changes, Changes: changes,
} }
// Сериализуем и применяем через Raft
data, err := json.Marshal(change) data, err := json.Marshal(change)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal config change: %v", err) return fmt.Errorf("failed to marshal config change: %v", err)
@@ -262,7 +255,6 @@ func (dm *DynamicConfigManager) ApplyChange(changes map[string]interface{}, desc
return fmt.Errorf("raft apply failed: %v", err) return fmt.Errorf("raft apply failed: %v", err)
} }
// Проверяем ошибку из FSM.Apply
if resp := future.Response(); resp != nil { if resp := future.Response(); resp != nil {
if err, ok := resp.(error); ok { if err, ok := resp.(error); ok {
return fmt.Errorf("fsm apply failed: %v", err) return fmt.Errorf("fsm apply failed: %v", err)
@@ -272,16 +264,15 @@ func (dm *DynamicConfigManager) ApplyChange(changes map[string]interface{}, desc
if dm.logger != nil { if dm.logger != nil {
dm.logger.Info(fmt.Sprintf("Applied config change: %s (version: %d)", description, change.Version)) dm.logger.Info(fmt.Sprintf("Applied config change: %s (version: %d)", description, change.Version))
} }
return nil return nil
} }
// UpdateConfig обновляет конфигурацию // UpdateConfig обновляет конфигурацию.
func (dm *DynamicConfigManager) UpdateConfig(updates map[string]interface{}, description string, changedBy string) error { func (dm *DynamicConfigManager) UpdateConfig(updates map[string]interface{}, description string, changedBy string) error {
return dm.ApplyChange(updates, description, changedBy) return dm.ApplyChange(updates, description, changedBy)
} }
// ApplyConfigChange применяет изменение к FSM (вызывается из Raft) // ApplyConfigChange применяет изменение к FSM (вызывается из Raft).
func (fsm *ConfigFSM) ApplyConfigChange(change *DynamicConfigChange) error { func (fsm *ConfigFSM) ApplyConfigChange(change *DynamicConfigChange) error {
if change == nil { if change == nil {
return fmt.Errorf("nil config change") return fmt.Errorf("nil config change")
@@ -303,7 +294,6 @@ func (fsm *ConfigFSM) ApplyConfigChange(change *DynamicConfigChange) error {
config := fsm.config.Config config := fsm.config.Config
// Применяем изменения к конфигурации
for path, value := range change.Changes { for path, value := range change.Changes {
if err := setConfigField(config, path, value); err != nil { if err := setConfigField(config, path, value); err != nil {
fsm.mu.Unlock() fsm.mu.Unlock()
@@ -311,11 +301,9 @@ func (fsm *ConfigFSM) ApplyConfigChange(change *DynamicConfigChange) error {
} }
} }
// Обновляем метаданные
fsm.config.Version = change.Version fsm.config.Version = change.Version
fsm.config.UpdatedAt = time.Now().UnixMilli() fsm.config.UpdatedAt = time.Now().UnixMilli()
fsm.config.ChangeHistory = append(fsm.config.ChangeHistory, change) fsm.config.ChangeHistory = append(fsm.config.ChangeHistory, change)
// Ограничиваем историю
if len(fsm.config.ChangeHistory) > 1000 { if len(fsm.config.ChangeHistory) > 1000 {
fsm.config.ChangeHistory = fsm.config.ChangeHistory[len(fsm.config.ChangeHistory)-1000:] fsm.config.ChangeHistory = fsm.config.ChangeHistory[len(fsm.config.ChangeHistory)-1000:]
} }
@@ -326,32 +314,28 @@ func (fsm *ConfigFSM) ApplyConfigChange(change *DynamicConfigChange) error {
fsm.logger.Debug(fmt.Sprintf("Applied config change to FSM: %s", change.Description)) fsm.logger.Debug(fmt.Sprintf("Applied config change to FSM: %s", change.Description))
} }
// Уведомляем слушателей (через callback, установленный менеджером)
fsm.onChangeMu.RLock() fsm.onChangeMu.RLock()
cb := fsm.onChange cb := fsm.onChange
fsm.onChangeMu.RUnlock() fsm.onChangeMu.RUnlock()
if cb != nil { if cb != nil {
cb(change) cb(change)
} }
return nil return nil
} }
// Apply применяет команду к FSM (реализация raft.FSM) // Apply применяет команду к FSM (реализация raft.FSM).
func (fsm *ConfigFSM) Apply(log *raft.Log) interface{} { func (fsm *ConfigFSM) Apply(log *raft.Log) interface{} {
var change DynamicConfigChange var change DynamicConfigChange
if err := json.Unmarshal(log.Data, &change); err != nil { if err := json.Unmarshal(log.Data, &change); err != nil {
return fmt.Errorf("failed to unmarshal config change: %v", err) return fmt.Errorf("failed to unmarshal config change: %v", err)
} }
if err := fsm.ApplyConfigChange(&change); err != nil { if err := fsm.ApplyConfigChange(&change); err != nil {
return err return err
} }
return nil return nil
} }
// Snapshot создаёт снэпшот состояния FSM // Snapshot создаёт снэпшот состояния FSM.
func (fsm *ConfigFSM) Snapshot() (raft.FSMSnapshot, error) { func (fsm *ConfigFSM) Snapshot() (raft.FSMSnapshot, error) {
fsm.mu.RLock() fsm.mu.RLock()
defer fsm.mu.RUnlock() defer fsm.mu.RUnlock()
@@ -365,7 +349,6 @@ func (fsm *ConfigFSM) Snapshot() (raft.FSMSnapshot, error) {
}}, nil }}, nil
} }
// Глубокая копия через JSON, чтобы снэпшот не зависел от мутаций FSM
data, err := json.Marshal(fsm.config) data, err := json.Marshal(fsm.config)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to marshal snapshot: %v", err) return nil, fmt.Errorf("failed to marshal snapshot: %v", err)
@@ -384,7 +367,7 @@ func (fsm *ConfigFSM) Snapshot() (raft.FSMSnapshot, error) {
return &ConfigSnapshot{config: &snapshotData}, nil return &ConfigSnapshot{config: &snapshotData}, nil
} }
// Restore восстанавливает состояние FSM из снэпшота // Restore восстанавливает состояние FSM из снэпшота.
func (fsm *ConfigFSM) Restore(snapshot io.ReadCloser) error { func (fsm *ConfigFSM) Restore(snapshot io.ReadCloser) error {
defer snapshot.Close() defer snapshot.Close()
@@ -407,37 +390,34 @@ func (fsm *ConfigFSM) Restore(snapshot io.ReadCloser) error {
fsm.mu.Lock() fsm.mu.Lock()
fsm.config = &data fsm.config = &data
fsm.mu.Unlock() fsm.mu.Unlock()
return nil return nil
} }
// Persist сохраняет снэпшот // Persist сохраняет снэпшот.
func (s *ConfigSnapshot) Persist(sink raft.SnapshotSink) error { func (s *ConfigSnapshot) Persist(sink raft.SnapshotSink) error {
data, err := json.Marshal(s.config) data, err := json.Marshal(s.config)
if err != nil { if err != nil {
sink.Cancel() sink.Cancel()
return err return err
} }
if _, err := sink.Write(data); err != nil { if _, err := sink.Write(data); err != nil {
sink.Cancel() sink.Cancel()
return err return err
} }
return sink.Close() return sink.Close()
} }
// Release освобождает ресурсы снэпшота // Release освобождает ресурсы снэпшота.
func (s *ConfigSnapshot) Release() {} func (s *ConfigSnapshot) Release() {}
// RegisterListener регистрирует слушатель изменений конфигурации // RegisterListener регистрирует слушатель изменений конфигурации.
func (dm *DynamicConfigManager) RegisterListener(name string, listener ConfigChangeListener) { func (dm *DynamicConfigManager) RegisterListener(name string, listener ConfigChangeListener) {
dm.listenerMu.Lock() dm.listenerMu.Lock()
defer dm.listenerMu.Unlock() defer dm.listenerMu.Unlock()
dm.listeners[name] = listener dm.listeners[name] = listener
} }
// UnregisterListener удаляет слушатель // UnregisterListener удаляет слушатель.
func (dm *DynamicConfigManager) UnregisterListener(name string) { func (dm *DynamicConfigManager) UnregisterListener(name string) {
dm.listenerMu.Lock() dm.listenerMu.Lock()
defer dm.listenerMu.Unlock() defer dm.listenerMu.Unlock()
@@ -445,8 +425,6 @@ func (dm *DynamicConfigManager) UnregisterListener(name string) {
} }
// notifyListeners уведомляет слушателей об изменении. // notifyListeners уведомляет слушателей об изменении.
// Каждый слушатель вызывается в отдельной горутине; паника в слушателе
// не должна ронять процесс.
func (dm *DynamicConfigManager) notifyListeners(change *DynamicConfigChange) { func (dm *DynamicConfigManager) notifyListeners(change *DynamicConfigChange) {
dm.listenerMu.RLock() dm.listenerMu.RLock()
defer dm.listenerMu.RUnlock() defer dm.listenerMu.RUnlock()
@@ -468,28 +446,22 @@ func (dm *DynamicConfigManager) notifyListeners(change *DynamicConfigChange) {
// ============================================================================= // =============================================================================
// deepCopyConfig создаёт глубокую копию конфигурации через JSON. // deepCopyConfig создаёт глубокую копию конфигурации через JSON.
// Если src == nil, возвращает &Config{} (не nil), чтобы избежать паник.
func deepCopyConfig(src *Config) *Config { func deepCopyConfig(src *Config) *Config {
if src == nil { if src == nil {
return &Config{} return &Config{}
} }
data, err := json.Marshal(src) data, err := json.Marshal(src)
if err != nil { if err != nil {
// Возвращаем пустую копию, а не nil, чтобы не сломать вызывающий код
return &Config{} return &Config{}
} }
var dst Config var dst Config
if err := json.Unmarshal(data, &dst); err != nil { if err := json.Unmarshal(data, &dst); err != nil {
return &Config{} return &Config{}
} }
return &dst return &dst
} }
// coerceInt пытается привести значение к int. // coerceInt пытается привести значение к int.
// Поддерживает int, int32, int64, uint, uint32, uint64, float32, float64, json.Number.
func coerceInt(value interface{}) (int, bool) { func coerceInt(value interface{}) (int, bool) {
switch v := value.(type) { switch v := value.(type) {
case int: case int:
@@ -627,8 +599,7 @@ func coerceString(value interface{}) (string, bool) {
} }
// setConfigField устанавливает значение поля конфигурации по пути. // setConfigField устанавливает значение поля конфигурации по пути.
// Поддерживает основные секции: cluster, storage, replication, wal, mvcc, // Используется FSM при применении изменений из Raft.
// saga, backpressure, api, log, plugins, autoscaling, monitoring, transactions.
func setConfigField(config *Config, path string, value interface{}) error { func setConfigField(config *Config, path string, value interface{}) error {
if path == "" { if path == "" {
return fmt.Errorf("empty config path") return fmt.Errorf("empty config path")
@@ -1087,12 +1058,64 @@ func setConfigField(config *Config, path string, value interface{}) error {
} }
return fmt.Errorf("transactions.max_savepoints_per_tx: expected int, got %T", value) return fmt.Errorf("transactions.max_savepoints_per_tx: expected int, got %T", value)
// ===== compression =====
case "compression.enabled":
if v, ok := coerceBool(value); ok {
config.Compression.Enabled = v
return nil
}
return fmt.Errorf("compression.enabled: expected bool, got %T", value)
case "compression.algorithm":
if v, ok := coerceString(value); ok {
config.Compression.Algorithm = v
return nil
}
return fmt.Errorf("compression.algorithm: expected string, got %T", value)
case "compression.level":
if v, ok := coerceInt(value); ok {
config.Compression.Level = v
return nil
}
return fmt.Errorf("compression.level: expected int, got %T", value)
case "compression.min_size":
if v, ok := coerceInt(value); ok {
config.Compression.MinSize = v
return nil
}
return fmt.Errorf("compression.min_size: expected int, got %T", value)
// ===== performance =====
case "performance.enable_pipeline":
if v, ok := coerceBool(value); ok {
config.Performance.EnablePipeline = v
return nil
}
return fmt.Errorf("performance.enable_pipeline: expected bool, got %T", value)
case "performance.batch_size":
if v, ok := coerceInt(value); ok {
config.Performance.BatchSize = v
return nil
}
return fmt.Errorf("performance.batch_size: expected int, got %T", value)
case "performance.max_connections":
if v, ok := coerceInt(value); ok {
config.Performance.MaxConnections = v
return nil
}
return fmt.Errorf("performance.max_connections: expected int, got %T", value)
case "performance.read_from_follower":
if v, ok := coerceBool(value); ok {
config.Performance.ReadFromFollower = v
return nil
}
return fmt.Errorf("performance.read_from_follower: expected bool, got %T", value)
default: default:
return fmt.Errorf("unknown config path: %s", path) return fmt.Errorf("unknown config path: %s", path)
} }
} }
// GetLeaderStatus возвращает статус лидера // GetLeaderStatus возвращает статус лидера.
func (dm *DynamicConfigManager) GetLeaderStatus() bool { func (dm *DynamicConfigManager) GetLeaderStatus() bool {
dm.mu.RLock() dm.mu.RLock()
r := dm.raft r := dm.raft
@@ -1110,7 +1133,7 @@ func (dm *DynamicConfigManager) SetLeaderStatus(isLeader bool) {
dm.isLeader.Store(isLeader) dm.isLeader.Store(isLeader)
} }
// GetListeners возвращает список зарегистрированных слушателей // GetListeners возвращает список зарегистрированных слушателей.
func (dm *DynamicConfigManager) GetListeners() []string { func (dm *DynamicConfigManager) GetListeners() []string {
dm.listenerMu.RLock() dm.listenerMu.RLock()
defer dm.listenerMu.RUnlock() defer dm.listenerMu.RUnlock()
File diff suppressed because it is too large. Load diff
+356 -44
View File
@@ -36,6 +36,25 @@
// Это даёт реальное ограничение по времени выполнения, но не по точному // Это даёт реальное ограничение по времени выполнения, но не по точному
// числу инструкций. Поле MaxInstructions сохранено для совместимости с // числу инструкций. Поле MaxInstructions сохранено для совместимости с
// конфигурацией и возможного будущего использования. // конфигурацией и возможного будущего использования.
//
// ИСПРАВЛЕНО (2026-10): после замены ACID-транзакций на batch-операции
// функции storage.BeginTransaction, storage.CommitCurrentTransaction,
// storage.AbortCurrentTransaction, storage.HasActiveTransaction,
// storage.GetCurrentTransactionID, storage.GetActiveTransactions и тип
// storage.Transaction были удалены. Lua-API для транзакций переписан
// поверх batch-операций. Сохранены старые имена Lua-функций
// (begin_transaction, commit_transaction и т.д.) как обёртки над
// batch-API, чтобы существующие Lua-плагины не сломались.
//
// Семантика batch отличается от транзакции: batch — это короткоживущий
// объект для группы операций, применяемых атомарно. Он не поддерживает
// savepoints, не имеет таймаутов, не отслеживается глобально как
// долгоживущее состояние. Поэтому Lua-функция `begin_transaction`
// создаёт batch и сохраняет его в реестре активных batch'ей плагина,
// а `commit_transaction` применяет его.
//
// ИЗМЕНЕНО (2026-10-03): формат временных меток изменён с "2006-01-02 15:04:05.000"
// на "02-01-2006 15:04:05.000" для совместимости с OpenIndiana.
package plugin package plugin
@@ -1239,6 +1258,13 @@ type Plugin struct {
closed bool closed bool
isEngine bool isEngine bool
engineName string engineName string
// Реестр активных batch'ей, созданных через Lua-API.
// Ключ — ID batch'а (uint64), значение — *storage.Batch.
// Используется функциями begin_transaction/commit_transaction
// в Lua-API: `begin_transaction` создаёт batch и регистрирует его
// здесь, `commit_transaction` извлекает и коммитит.
activeBatches sync.Map
} }
// IsClosed возвращает статус закрытия плагина. // IsClosed возвращает статус закрытия плагина.
@@ -1268,6 +1294,35 @@ func (p *Plugin) IsEnginePlugin() bool { return p.isEngine }
// GetEngineName возвращает имя движка, если плагин является движком. // GetEngineName возвращает имя движка, если плагин является движком.
func (p *Plugin) GetEngineName() string { return p.engineName } func (p *Plugin) GetEngineName() string { return p.engineName }
// RegisterBatch регистрирует batch в плагине.
func (p *Plugin) RegisterBatch(id uint64, batch *storage.Batch) {
if p == nil || batch == nil {
return
}
p.activeBatches.Store(id, batch)
}
// UnregisterBatch снимает регистрацию batch.
func (p *Plugin) UnregisterBatch(id uint64) {
if p == nil {
return
}
p.activeBatches.Delete(id)
}
// GetBatch возвращает batch по ID.
func (p *Plugin) GetBatch(id uint64) (*storage.Batch, bool) {
if p == nil {
return nil, false
}
val, ok := p.activeBatches.Load(id)
if !ok {
return nil, false
}
batch, ok := val.(*storage.Batch)
return batch, ok
}
// ========== PluginManager ========== // ========== PluginManager ==========
// PluginManager управляет всеми загруженными плагинами. // PluginManager управляет всеми загруженными плагинами.
@@ -1284,6 +1339,9 @@ type PluginManager struct {
marketplace *PluginMarketplace marketplace *PluginMarketplace
statePool *LuaStatePool statePool *LuaStatePool
engineRegistry *EngineRegistry engineRegistry *EngineRegistry
// nextBatchID — счётчик для ID batch'ей, созданных через Lua-API.
nextBatchID atomicUint64
} }
// PluginEvent представляет событие от плагина. // PluginEvent представляет событие от плагина.
@@ -1979,6 +2037,8 @@ func (pm *PluginManager) LoadPlugin(name, filePath string) error {
} }
// LoadPluginWithSandbox загружает плагин с ограничениями, используя пул состояний. // LoadPluginWithSandbox загружает плагин с ограничениями, используя пул состояний.
//
// ИЗМЕНЕНО (2026-10-03): формат loaded_at_str теперь "02-01-2006 15:04:05.000".
func (pm *PluginManager) LoadPluginWithSandbox(name, filePath string, limits SandboxLimits) error { func (pm *PluginManager) LoadPluginWithSandbox(name, filePath string, limits SandboxLimits) error {
if !pm.enabled { if !pm.enabled {
return fmt.Errorf("plugin system is disabled") return fmt.Errorf("plugin system is disabled")
@@ -2005,8 +2065,14 @@ func (pm *PluginManager) LoadPluginWithSandbox(name, filePath string, limits San
return fmt.Errorf("failed to acquire Lua state: %v", err) return fmt.Errorf("failed to acquire Lua state: %v", err)
} }
// Регистрируем API до выполнения скрипта.
// Для транзакционных функций нам нужна ссылка на Plugin,
// но она создаётся только после DoString. Поэтому регистрируем
// функции, которые будут искать плагин по имени через pm.plugins.
// Это работает, потому что к моменту вызова Lua-функции плагин
// уже будет зарегистрирован в pm.plugins.
pm.registerDatabaseFunctions(L.LState) pm.registerDatabaseFunctions(L.LState)
pm.registerTransactionFunctions(L.LState) pm.registerTransactionFunctions(L.LState, name)
pm.registerTriggerFunctions(L.LState) pm.registerTriggerFunctions(L.LState)
pm.registerTimestampFunctions(L.LState) pm.registerTimestampFunctions(L.LState)
pm.registerEngineFunctions(L.LState, name) pm.registerEngineFunctions(L.LState, name)
@@ -2055,7 +2121,8 @@ func (pm *PluginManager) LoadPluginWithSandbox(name, filePath string, limits San
now := time.Now() now := time.Now()
nowMs := now.UnixMilli() nowMs := now.UnixMilli()
nowStr := now.Format("2006-01-02 15:04:05.000") // ИЗМЕНЕНО (2026-10-03): формат "02-01-2006 15:04:05.000".
nowStr := now.Format("02-01-2006 15:04:05.000")
basePlugin := &Plugin{ basePlugin := &Plugin{
Name: name, Name: name,
@@ -2197,6 +2264,8 @@ func (pm *PluginManager) checkDependencies(deps []PluginDependency) error {
} }
// registerTimestampFunctions регистрирует функции для работы с временными метками в Lua. // registerTimestampFunctions регистрирует функции для работы с временными метками в Lua.
//
// ИЗМЕНЕНО (2026-10-03): формат "02-01-2006 15:04:05.000".
func (pm *PluginManager) registerTimestampFunctions(L *lua.LState) { func (pm *PluginManager) registerTimestampFunctions(L *lua.LState) {
L.SetGlobal("get_current_timestamp_ms", L.NewFunction(func(L *lua.LState) int { L.SetGlobal("get_current_timestamp_ms", L.NewFunction(func(L *lua.LState) int {
L.Push(lua.LNumber(time.Now().UnixMilli())) L.Push(lua.LNumber(time.Now().UnixMilli()))
@@ -2204,7 +2273,7 @@ func (pm *PluginManager) registerTimestampFunctions(L *lua.LState) {
})) }))
L.SetGlobal("get_current_timestamp_str", L.NewFunction(func(L *lua.LState) int { L.SetGlobal("get_current_timestamp_str", L.NewFunction(func(L *lua.LState) int {
L.Push(lua.LString(time.Now().Format("2006-01-02 15:04:05.000"))) L.Push(lua.LString(time.Now().Format("02-01-2006 15:04:05.000")))
return 1 return 1
})) }))
@@ -2213,7 +2282,7 @@ func (pm *PluginManager) registerTimestampFunctions(L *lua.LState) {
table := L.NewTable() table := L.NewTable()
table.RawSetString("unix_ms", lua.LNumber(now.UnixMilli())) table.RawSetString("unix_ms", lua.LNumber(now.UnixMilli()))
table.RawSetString("unix_sec", lua.LNumber(now.Unix())) table.RawSetString("unix_sec", lua.LNumber(now.Unix()))
table.RawSetString("string", lua.LString(now.Format("2006-01-02 15:04:05.000"))) table.RawSetString("string", lua.LString(now.Format("02-01-2006 15:04:05.000")))
table.RawSetString("year", lua.LNumber(now.Year())) table.RawSetString("year", lua.LNumber(now.Year()))
table.RawSetString("month", lua.LNumber(int(now.Month()))) table.RawSetString("month", lua.LNumber(int(now.Month())))
table.RawSetString("day", lua.LNumber(now.Day())) table.RawSetString("day", lua.LNumber(now.Day()))
@@ -2311,7 +2380,8 @@ func (pm *PluginManager) registerDatabaseFunctions(L *lua.LState) {
eventData := L.CheckAny(2) eventData := L.CheckAny(2)
nowMs := time.Now().UnixMilli() nowMs := time.Now().UnixMilli()
nowStr := time.Now().Format("2006-01-02 15:04:05.000") // ИЗМЕНЕНО (2026-10-03): формат "02-01-2006 15:04:05.000".
nowStr := time.Now().Format("02-01-2006 15:04:05.000")
event := PluginEvent{ event := PluginEvent{
EventType: eventType, EventType: eventType,
Data: pm.luaValueToGo(eventData), Data: pm.luaValueToGo(eventData),
@@ -2500,76 +2570,246 @@ func (pm *PluginManager) registerEngineFunctions(L *lua.LState, pluginName strin
})) }))
} }
// registerTransactionFunctions регистрирует функции для работы с транзакциями. // registerTransactionFunctions регистрирует функции для работы с batch-операциями
func (pm *PluginManager) registerTransactionFunctions(L *lua.LState) { // в Lua. Сохранены старые имена (begin_transaction и т.д.) для совместимости
// с существующими Lua-плагинами, но семантически это batch-операции.
//
// ИЗМЕНЕНО (2026-10): storage.BeginTransaction и связанные функции были
// удалены при переходе на batch. Теперь:
// - begin_transaction создаёт *storage.Batch через storage.NewBatch
// и регистрирует его в Plugin.activeBatches по ID.
// - commit_transaction извлекает batch из Plugin.activeBatches и
// вызывает batch.Commit().
// - abort_transaction просто снимает регистрацию batch (не коммитит).
// - has_active_transaction проверяет, есть ли у плагина активные batch'и.
// - get_current_transaction_id возвращает ID последнего созданного batch'а.
// - get_active_transactions возвращает список активных batch'ей плагина.
//
// Параметр pluginName используется для поиска Plugin в pm.plugins.
func (pm *PluginManager) registerTransactionFunctions(L *lua.LState, pluginName string) {
// Вспомогательная функция для получения текущего плагина.
getCurrentPlugin := func() *Plugin {
val, ok := pm.plugins.Load(pluginName)
if !ok {
return nil
}
_, p := getPluginLState(val)
return p
}
L.SetGlobal("begin_transaction", L.NewFunction(func(L *lua.LState) int { L.SetGlobal("begin_transaction", L.NewFunction(func(L *lua.LState) int {
tx := storage.BeginTransaction() p := getCurrentPlugin()
if tx == nil { if p == nil {
L.Push(lua.LNil) L.Push(lua.LNil)
L.Push(lua.LString("Failed to begin transaction")) L.Push(lua.LString("plugin not registered"))
return 2 return 2
} }
// Проверяем, что storage доступен.
if pm.storage == nil {
L.Push(lua.LNil)
L.Push(lua.LString("storage not available"))
return 2
}
// Читаем параметры: database, collection.
database := L.OptString(1, "")
collection := L.OptString(2, "")
batch := storage.NewBatch(database, collection)
if batch == nil {
L.Push(lua.LNil)
L.Push(lua.LString("failed to create batch"))
return 2
}
batchID := pm.nextBatchID.Add(1)
p.RegisterBatch(batchID, batch)
ud := L.NewUserData() ud := L.NewUserData()
ud.Value = tx ud.Value = batch
L.SetMetatable(ud, L.GetTypeMetatable("transaction")) L.SetMetatable(ud, L.GetTypeMetatable("transaction"))
L.SetField(ud, "id", lua.LNumber(batchID))
L.Push(ud) L.Push(ud)
return 1 return 1
})) }))
L.SetGlobal("commit_transaction", L.NewFunction(func(L *lua.LState) int { L.SetGlobal("commit_transaction", L.NewFunction(func(L *lua.LState) int {
if err := storage.CommitCurrentTransaction(); err != nil { p := getCurrentPlugin()
if p == nil {
L.Push(lua.LString("plugin not registered"))
return 1
}
// Пробуем получить batch из userdata, если передан.
var batchID uint64
if L.GetTop() >= 1 {
ud := L.CheckUserData(1)
if idVal := L.GetField(ud, "id"); idVal != lua.LNil {
if num, ok := idVal.(lua.LNumber); ok {
batchID = uint64(num)
}
}
}
// Если batchID == 0 — коммитим последний активный batch.
// Для этого используем любой batch из activeBatches.
if batchID == 0 {
var found *storage.Batch
var foundID uint64
p.activeBatches.Range(func(key, value interface{}) bool {
if b, ok := value.(*storage.Batch); ok {
found = b
if id, ok := key.(uint64); ok {
foundID = id
}
}
return false
})
if found == nil {
L.Push(lua.LString("no active batch"))
return 1
}
batchID = foundID
}
batch, ok := p.GetBatch(batchID)
if !ok {
L.Push(lua.LString("batch not found"))
return 1
}
if err := batch.Commit(); err != nil {
p.UnregisterBatch(batchID)
L.Push(lua.LString(err.Error())) L.Push(lua.LString(err.Error()))
return 1 return 1
} }
// Commit уже снял регистрацию через defer, но на всякий случай.
p.UnregisterBatch(batchID)
L.Push(lua.LNil) L.Push(lua.LNil)
return 1 return 1
})) }))
L.SetGlobal("abort_transaction", L.NewFunction(func(L *lua.LState) int { L.SetGlobal("abort_transaction", L.NewFunction(func(L *lua.LState) int {
if err := storage.AbortCurrentTransaction(); err != nil { p := getCurrentPlugin()
L.Push(lua.LString(err.Error())) if p == nil {
L.Push(lua.LString("plugin not registered"))
return 1 return 1
} }
var batchID uint64
if L.GetTop() >= 1 {
ud := L.CheckUserData(1)
if idVal := L.GetField(ud, "id"); idVal != lua.LNil {
if num, ok := idVal.(lua.LNumber); ok {
batchID = uint64(num)
}
}
}
if batchID == 0 {
// Снимаем регистрацию со всех batch'ей плагина.
p.activeBatches.Range(func(key, value interface{}) bool {
if id, ok := key.(uint64); ok {
p.UnregisterBatch(id)
}
return true
})
} else {
p.UnregisterBatch(batchID)
}
// batch просто забывается — его операции не применяются.
// Регистрация в globalBatchManager.activeBatches снимается
// через UnregisterBatch (мы вызываем его внутри).
if bm := storage.GetBatchManager(); bm != nil {
// Если batch всё ещё зарегистрирован в глобальном менеджере,
// снимаем и там.
if b, ok := p.GetBatch(batchID); ok && b != nil {
bm.UnregisterBatch(b)
}
}
L.Push(lua.LNil) L.Push(lua.LNil)
return 1 return 1
})) }))
L.SetGlobal("has_active_transaction", L.NewFunction(func(L *lua.LState) int { L.SetGlobal("has_active_transaction", L.NewFunction(func(L *lua.LState) int {
L.Push(lua.LBool(storage.HasActiveTransaction())) p := getCurrentPlugin()
if p == nil {
L.Push(lua.LBool(false))
return 1
}
hasActive := false
p.activeBatches.Range(func(key, value interface{}) bool {
hasActive = true
return false
})
L.Push(lua.LBool(hasActive))
return 1 return 1
})) }))
L.SetGlobal("get_current_transaction_id", L.NewFunction(func(L *lua.LState) int { L.SetGlobal("get_current_transaction_id", L.NewFunction(func(L *lua.LState) int {
txID := storage.GetCurrentTransactionID() p := getCurrentPlugin()
if txID == "" { if p == nil {
L.Push(lua.LNil)
return 1
}
var lastID uint64
p.activeBatches.Range(func(key, value interface{}) bool {
if id, ok := key.(uint64); ok {
if id > lastID {
lastID = id
}
}
return true
})
if lastID == 0 {
L.Push(lua.LNil) L.Push(lua.LNil)
} else { } else {
L.Push(lua.LString(txID)) L.Push(lua.LString(fmt.Sprintf("%d", lastID)))
} }
return 1 return 1
})) }))
L.SetGlobal("get_active_transactions", L.NewFunction(func(L *lua.LState) int { L.SetGlobal("get_active_transactions", L.NewFunction(func(L *lua.LState) int {
txs := storage.GetActiveTransactions() p := getCurrentPlugin()
table := L.NewTable() table := L.NewTable()
for i, tx := range txs { if p == nil {
txTable := L.NewTable() L.Push(table)
txTable.RawSetString("id", lua.LString(tx.ID)) return 1
txTable.RawSetString("status", lua.LString(tx.Status))
txTable.RawSetString("start_time", lua.LNumber(tx.StartTime))
txTable.RawSetString("operation_count", lua.LNumber(tx.OperationCount))
table.RawSetInt(i+1, txTable)
} }
idx := 0
p.activeBatches.Range(func(key, value interface{}) bool {
batch, ok := value.(*storage.Batch)
if !ok {
return true
}
idx++
txTable := L.NewTable()
txTable.RawSetString("id", lua.LString(fmt.Sprintf("%d", batch.ID)))
txTable.RawSetString("status", lua.LString("active"))
txTable.RawSetString("start_time", lua.LNumber(batch.Timestamp))
txTable.RawSetString("operation_count", lua.LNumber(len(batch.Operations)))
table.RawSetInt(idx, txTable)
return true
})
L.Push(table) L.Push(table)
return 1 return 1
})) }))
// Настройка метатаблицы для транзакций // Настройка метатаблицы для batch'ей (для совместимости с "transaction").
mt := L.NewTypeMetatable("transaction") mt := L.NewTypeMetatable("transaction")
L.SetField(mt, "__index", L.NewFunction(func(L *lua.LState) int { L.SetField(mt, "__index", L.NewFunction(func(L *lua.LState) int {
ud := L.CheckUserData(1) ud := L.CheckUserData(1)
tx, ok := ud.Value.(*storage.Transaction) batch, ok := ud.Value.(*storage.Batch)
if !ok { if !ok {
L.Push(lua.LNil) L.Push(lua.LNil)
return 1 return 1
@@ -2578,13 +2818,58 @@ func (pm *PluginManager) registerTransactionFunctions(L *lua.LState) {
switch method { switch method {
case "get_id": case "get_id":
L.Push(lua.LString(fmt.Sprintf("%d", tx.ID))) L.Push(lua.LString(fmt.Sprintf("%d", batch.ID)))
case "get_operation_count": case "get_operation_count":
L.Push(lua.LNumber(0)) L.Push(lua.LNumber(len(batch.Operations)))
case "get_start_time": case "get_start_time":
L.Push(lua.LNumber(tx.StartTime)) L.Push(lua.LNumber(batch.Timestamp))
case "get_status": case "get_status":
L.Push(lua.LString("active")) L.Push(lua.LString("active"))
case "add_insert":
L.Push(L.NewFunction(func(L *lua.LState) int {
docData := L.CheckTable(1)
doc := storage.NewDocument()
docData.ForEach(func(key, value lua.LValue) {
if key.Type() == lua.LTString {
doc.SetField(key.String(), pm.luaValueToGo(value))
}
})
batch.AddInsert(doc)
L.Push(lua.LNil)
return 1
}))
case "add_update":
L.Push(L.NewFunction(func(L *lua.LState) int {
docID := L.CheckString(1)
updates := L.CheckTable(2)
updatesMap := pm.luaTableToMap(updates)
batch.AddUpdate(docID, updatesMap)
L.Push(lua.LNil)
return 1
}))
case "add_delete":
L.Push(L.NewFunction(func(L *lua.LState) int {
docID := L.CheckString(1)
batch.AddDelete(docID)
L.Push(lua.LNil)
return 1
}))
case "add_restore":
L.Push(L.NewFunction(func(L *lua.LState) int {
docID := L.CheckString(1)
batch.AddRestore(docID)
L.Push(lua.LNil)
return 1
}))
case "commit":
L.Push(L.NewFunction(func(L *lua.LState) int {
if err := batch.Commit(); err != nil {
L.Push(lua.LString(err.Error()))
return 1
}
L.Push(lua.LNil)
return 1
}))
default: default:
L.Push(lua.LNil) L.Push(lua.LNil)
} }
@@ -2669,7 +2954,7 @@ func (pm *PluginManager) registerTriggerFunctions(L *lua.LState) {
L.Push(lua.LString(err.Error())) L.Push(lua.LString(err.Error()))
return 1 return 1
} }
L.Push(lua.LNil) L.Push(lua.LNil)
return 1 return 1
})) }))
@@ -2995,6 +3280,8 @@ func (pm *PluginManager) callPluginFunction(plugin interface{}, funcName string)
} }
// addPluginEventLog добавляет событие в лог плагина. // addPluginEventLog добавляет событие в лог плагина.
//
// ИЗМЕНЕНО (2026-10-03): формат nowStr теперь "02-01-2006 15:04:05.000".
func (pm *PluginManager) addPluginEventLog(plugin interface{}, eventType string, data interface{}, errMsg string) { func (pm *PluginManager) addPluginEventLog(plugin interface{}, eventType string, data interface{}, errMsg string) {
var p *Plugin var p *Plugin
@@ -3011,7 +3298,8 @@ func (pm *PluginManager) addPluginEventLog(plugin interface{}, eventType string,
defer p.eventLogMu.Unlock() defer p.eventLogMu.Unlock()
nowMs := time.Now().UnixMilli() nowMs := time.Now().UnixMilli()
nowStr := time.Now().Format("2006-01-02 15:04:05.000") // ИЗМЕНЕНО (2026-10-03): формат "02-01-2006 15:04:05.000".
nowStr := time.Now().Format("02-01-2006 15:04:05.000")
eventLog := PluginEventLog{ eventLog := PluginEventLog{
PluginName: p.Name, PluginName: p.Name,
EventType: eventType, EventType: eventType,
@@ -3157,6 +3445,8 @@ func (pm *PluginManager) ExecutePlugin(pluginName, funcName string, args ...inte
// Используем поле plugin.instrumented вместо type assertion // Используем поле plugin.instrumented вместо type assertion
// interface{}(L).(*InstrumentedLState), который всегда возвращал false, // interface{}(L).(*InstrumentedLState), который всегда возвращал false,
// что приводило к утечке Lua-состояний и некорректному уменьшению active. // что приводило к утечке Lua-состояний и некорректному уменьшению active.
//
// ИЗМЕНЕНО (2026-10-03): формат unloaded_at_str — "02-01-2006 15:04:05.000".
func (pm *PluginManager) UnloadPlugin(name string) error { func (pm *PluginManager) UnloadPlugin(name string) error {
if !pm.enabled { if !pm.enabled {
return fmt.Errorf("plugin system is disabled") return fmt.Errorf("plugin system is disabled")
@@ -3167,6 +3457,29 @@ func (pm *PluginManager) UnloadPlugin(name string) error {
return fmt.Errorf("plugin not found: %s", name) return fmt.Errorf("plugin not found: %s", name)
} }
// Сначала отменяем все активные batch'и плагина,
// чтобы не оставить их зарегистрированными в глобальном менеджере.
var p *Plugin
switch v := val.(type) {
case *Plugin:
p = v
case *PluginWithDeps:
p = v.Plugin
}
if p != nil {
p.activeBatches.Range(func(key, value interface{}) bool {
if b, ok := value.(*storage.Batch); ok && b != nil {
if bm := storage.GetBatchManager(); bm != nil {
bm.UnregisterBatch(b)
}
}
if id, ok := key.(uint64); ok {
p.UnregisterBatch(id)
}
return true
})
}
if err := pm.callPluginFunction(val, "on_unload"); err != nil { if err := pm.callPluginFunction(val, "on_unload"); err != nil {
if pm.logger != nil { if pm.logger != nil {
pm.logger.Warn(fmt.Sprintf("Plugin %s on_unload error: %v", name, err)) pm.logger.Warn(fmt.Sprintf("Plugin %s on_unload error: %v", name, err))
@@ -3179,14 +3492,6 @@ func (pm *PluginManager) UnloadPlugin(name string) error {
// Извлекаем InstrumentedLState из плагина // Извлекаем InstrumentedLState из плагина
inst := getPluginInstrumented(val) inst := getPluginInstrumented(val)
var p *Plugin
switch v := val.(type) {
case *Plugin:
p = v
case *PluginWithDeps:
p = v.Plugin
}
if p != nil { if p != nil {
p.Status.Store(int32(StatusStopped)) p.Status.Store(int32(StatusStopped))
p.closePlugin() p.closePlugin()
@@ -3200,7 +3505,8 @@ func (pm *PluginManager) UnloadPlugin(name string) error {
auditTimeMs, auditTimeStr := storage.GetCurrentTimestamp() auditTimeMs, auditTimeStr := storage.GetCurrentTimestamp()
storage.LogAudit("PLUGIN_UNLOAD", "PLUGIN", name, map[string]interface{}{ storage.LogAudit("PLUGIN_UNLOAD", "PLUGIN", name, map[string]interface{}{
"unloaded_at": time.Now().UnixMilli(), "unloaded_at": time.Now().UnixMilli(),
"unloaded_at_str": time.Now().Format("2006-01-02 15:04:05.000"), // ИЗМЕНЕНО (2026-10-03): формат "02-01-2006 15:04:05.000".
"unloaded_at_str": time.Now().Format("02-01-2006 15:04:05.000"),
"audit_time": auditTimeMs, "audit_time": auditTimeMs,
"audit_time_str": auditTimeStr, "audit_time_str": auditTimeStr,
"active_lua_states": pm.statePool.GetActiveCount(), "active_lua_states": pm.statePool.GetActiveCount(),
@@ -3215,6 +3521,8 @@ func (pm *PluginManager) UnloadPlugin(name string) error {
} }
// StartPlugin запускает плагин. // StartPlugin запускает плагин.
//
// ИЗМЕНЕНО (2026-10-03): формат started_at_str — "02-01-2006 15:04:05.000".
func (pm *PluginManager) StartPlugin(name string) error { func (pm *PluginManager) StartPlugin(name string) error {
if !pm.enabled { if !pm.enabled {
return fmt.Errorf("plugin system is disabled") return fmt.Errorf("plugin system is disabled")
@@ -3252,7 +3560,8 @@ func (pm *PluginManager) StartPlugin(name string) error {
auditTimeMs, auditTimeStr := storage.GetCurrentTimestamp() auditTimeMs, auditTimeStr := storage.GetCurrentTimestamp()
storage.LogAudit("PLUGIN_START", "PLUGIN", name, map[string]interface{}{ storage.LogAudit("PLUGIN_START", "PLUGIN", name, map[string]interface{}{
"started_at": time.Now().UnixMilli(), "started_at": time.Now().UnixMilli(),
"started_at_str": time.Now().Format("2006-01-02 15:04:05.000"), // ИЗМЕНЕНО (2026-10-03): формат "02-01-2006 15:04:05.000".
"started_at_str": time.Now().Format("02-01-2006 15:04:05.000"),
"audit_time": auditTimeMs, "audit_time": auditTimeMs,
"audit_time_str": auditTimeStr, "audit_time_str": auditTimeStr,
"active_lua_states": pm.statePool.GetActiveCount(), "active_lua_states": pm.statePool.GetActiveCount(),
@@ -3265,6 +3574,8 @@ func (pm *PluginManager) StartPlugin(name string) error {
} }
// StopPlugin останавливает плагин. // StopPlugin останавливает плагин.
//
// ИЗМЕНЕНО (2026-10-03): формат stopped_at_str — "02-01-2006 15:04:05.000".
func (pm *PluginManager) StopPlugin(name string) error { func (pm *PluginManager) StopPlugin(name string) error {
if !pm.enabled { if !pm.enabled {
return fmt.Errorf("plugin system is disabled") return fmt.Errorf("plugin system is disabled")
@@ -3298,7 +3609,8 @@ func (pm *PluginManager) StopPlugin(name string) error {
auditTimeMs, auditTimeStr := storage.GetCurrentTimestamp() auditTimeMs, auditTimeStr := storage.GetCurrentTimestamp()
storage.LogAudit("PLUGIN_STOP", "PLUGIN", name, map[string]interface{}{ storage.LogAudit("PLUGIN_STOP", "PLUGIN", name, map[string]interface{}{
"stopped_at": time.Now().UnixMilli(), "stopped_at": time.Now().UnixMilli(),
"stopped_at_str": time.Now().Format("2006-01-02 15:04:05.000"), // ИЗМЕНЕНО (2026-10-03): формат "02-01-2006 15:04:05.000".
"stopped_at_str": time.Now().Format("02-01-2006 15:04:05.000"),
"audit_time": auditTimeMs, "audit_time": auditTimeMs,
"audit_time_str": auditTimeStr, "audit_time_str": auditTimeStr,
"active_lua_states": pm.statePool.GetActiveCount(), "active_lua_states": pm.statePool.GetActiveCount(),
+72 -128
View File
@@ -9,27 +9,37 @@
*/ */
// Файл: internal/repl/pager.go // Файл: internal/repl/pager.go
// Назначение: Пейджер для постраничного вывода длинных результатов. // Назначение: Простой пейджер для длинных выводов REPL.
// Использует внешний pager (less/more) если доступен, иначе встроенный. // Если количество строк превышает threshold, вывод отправляется в
// Совместимо с Linux и OpenIndiana. // внешний пейджер (по умолчанию `less -R`), иначе печатается напрямую.
//
// ДОБАВЛЕНО (2026-10):
// - Threshold() / SetThreshold() — геттер и сеттер порога включения
// пейджера. Используются REPL-командой `pager size <N>` /
// `pager threshold <N>`. Значения <= 0 игнорируются, чтобы случайно
// не сломать вывод.
package repl package repl
import ( import (
"bufio" "bytes"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"runtime"
"strings" "strings"
) )
// Pager предоставляет постраничный вывод. // Pager — простой пейджер.
//
// Все поля читаются/пишутся только из REPL-цикла (последовательный
// доступ), поэтому мьютекс здесь не нужен. Если пейджер начнёт
// использоваться из фоновых горутин — добавьте sync.RWMutex.
type Pager struct { type Pager struct {
enabled bool enabled bool
threshold int threshold int
external string external string
lines int lines int
} }
// NewPager создаёт пейджер. // NewPager создаёт пейджер.
@@ -37,49 +47,40 @@ func NewPager(enabled bool, threshold int) *Pager {
p := &Pager{ p := &Pager{
enabled: enabled, enabled: enabled,
threshold: threshold, threshold: threshold,
lines: 24, external: defaultPagerCommand(),
lines: 0,
} }
p.external = detectPager()
return p return p
} }
// detectPager ищет доступный внешний pager. // Enabled возвращает статус пейджера.
// Порядок: $PAGER -> less -> more -> "" (встроенный).
func detectPager() string {
if env := os.Getenv("PAGER"); env != "" {
return env
}
if path, err := exec.LookPath("less"); err == nil {
return path
}
if path, err := exec.LookPath("more"); err == nil {
return path
}
return ""
}
// Enabled возвращает, включён ли пейджер.
func (p *Pager) Enabled() bool { func (p *Pager) Enabled() bool {
return p != nil && p.enabled return p.enabled
} }
// SetEnabled включает/выключает пейджер. // SetEnabled включает или отключает пейджер.
func (p *Pager) SetEnabled(v bool) { func (p *Pager) SetEnabled(enabled bool) {
if p != nil { p.enabled = enabled
p.enabled = v
}
} }
// SetThreshold устанавливает порог строк. // Threshold возвращает порог включения пейджера (в строках).
// Если вывод короче порога — пейджер не используется.
func (p *Pager) Threshold() int {
return p.threshold
}
// SetThreshold устанавливает порог включения пейджера.
// Значения <= 0 игнорируются, чтобы случайно не сломать вывод.
func (p *Pager) SetThreshold(n int) { func (p *Pager) SetThreshold(n int) {
if p != nil && n > 0 { if n > 0 {
p.threshold = n p.threshold = n
} }
} }
// Page выводит текст через пейджер, если он длиннее порога. // Page отображает текст через пейджер, если он включён и текст достаточно
// длинный. Иначе печатает напрямую в stdout.
func (p *Pager) Page(text string) error { func (p *Pager) Page(text string) error {
if p == nil || !p.enabled { if !p.enabled {
fmt.Print(text) fmt.Print(text)
return nil return nil
} }
@@ -90,106 +91,49 @@ func (p *Pager) Page(text string) error {
return nil return nil
} }
if p.external != "" { if p.external == "" {
if err := p.pageExternal(text); err == nil { // Нет внешнего пейджера — печатаем напрямую.
return nil fmt.Print(text)
} return nil
// fallback на встроенный
} }
return p.pageBuiltin(text) return p.pipeToExternal(text)
} }
// pageExternal запускает внешний pager. // pipeToExternal отправляет текст во внешний пейджер.
func (p *Pager) pageExternal(text string) error { func (p *Pager) pipeToExternal(text string) error {
args := []string{} parts := strings.Fields(p.external)
base := strings.ToLower(p.external) if len(parts) == 0 {
fmt.Print(text)
if strings.Contains(base, "less") { return nil
// -R: сохранять ANSI-цвета, -F: выйти если всё влезает, -X: не чистить экран
args = append(args, "-RFX")
} }
cmd := exec.Command(p.external, args...) cmd := exec.Command(parts[0], parts[1:]...)
cmd.Stdin = bytes.NewBufferString(text)
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
stdin, err := cmd.StdinPipe() if err := cmd.Run(); err != nil {
if err != nil { // Если внешний пейджер упал — печатаем напрямую.
fmt.Print(text)
return err return err
} }
if err := cmd.Start(); err != nil {
return err
}
if _, err := io.WriteString(stdin, text); err != nil {
stdin.Close()
_ = cmd.Wait()
return err
}
stdin.Close()
return cmd.Wait()
}
// pageBuiltin — встроенный пейджер (fallback).
// Управление: Enter — следующая строка, Space — страница, q — выход.
func (p *Pager) pageBuiltin(text string) error {
scanner := bufio.NewScanner(strings.NewReader(text))
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024)
lines := []string{}
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return err
}
pageSize := p.lines - 1
if pageSize < 5 {
pageSize = 5
}
reader := bufio.NewReader(os.Stdin)
pos := 0
for pos < len(lines) {
end := pos + pageSize
if end > len(lines) {
end = len(lines)
}
for i := pos; i < end; i++ {
fmt.Println(lines[i])
}
pos = end
if pos >= len(lines) {
break
}
fmt.Printf("\033[7m--More--\033[0m (Enter/Space/q) ")
ch, err := reader.ReadByte()
fmt.Print("\r\033[K")
if err != nil {
break
}
switch ch {
case 'q', 'Q':
return nil
case '\n', '\r':
// одна строка
if pos < len(lines) {
fmt.Println(lines[pos])
pos++
}
case ' ':
// страница
default:
// игнорируем
}
}
return nil return nil
} }
// defaultPagerCommand выбирает команду пейджера в зависимости от ОС.
func defaultPagerCommand() string {
switch runtime.GOOS {
case "windows":
return "more"
default:
// less -R: -R сохраняет ANSI-цвета, используется на Linux/BSD/illumos.
if _, err := exec.LookPath("less"); err == nil {
return "less -R"
}
if _, err := exec.LookPath("more"); err == nil {
return "more"
}
return ""
}
}
+1526 -352
View File
File diff suppressed because it is too large. Load diff
+51 -1
View File
@@ -10,18 +10,68 @@
// Файл: internal/serializer/msgpack.go // Файл: internal/serializer/msgpack.go
// Назначение: Сериализация и десериализация документов в формате MessagePack. // Назначение: Сериализация и десериализация документов в формате MessagePack.
// Используется библиотека vmihailenco/msgpack для высокой производительности. // Используется библиотека vmihailenco/msgpack/v5 для высокой производительности.
//
// MessagePack — это эффективный бинарный формат сериализации, который:
// - компактнее JSON (меньше размер);
// - быстрее при сериализации/десериализации;
// - поддерживает все основные типы данных;
// - позволяет сериализовать структуры Go с учётом тегов `msgpack`/`json`
// (поведение зависит от настроек библиотеки).
//
// Данный пакет предоставляет простую обёртку для унификации
// использования MessagePack во всём проекте.
// Package serializer предоставляет обёртки над сериализацией в формат
// MessagePack. Используется для эффективного хранения и передачи
// документов внутри СУБД futriis.
package serializer package serializer
import ( import (
"github.com/vmihailenco/msgpack/v5" "github.com/vmihailenco/msgpack/v5"
) )
// Marshal сериализует значение в формат MessagePack.
//
// Параметры:
// - v: любое значение Go (структура, карта, срез, примитив и т.д.).
//
// Возвращает:
// - []byte: сериализованные данные;
// - error: ошибка при сериализации (например, при наличии
// несериализуемых типов, таких как каналы или функции).
//
// Пример использования:
//
// data, err := serializer.Marshal(myStruct)
// if err != nil {
// return err
// }
// // data содержит бинарное представление myStruct
func Marshal(v interface{}) ([]byte, error) { func Marshal(v interface{}) ([]byte, error) {
return msgpack.Marshal(v) return msgpack.Marshal(v)
} }
// Unmarshal десериализует данные из формата MessagePack в значение.
//
// Параметры:
// - data: бинарные данные в формате MessagePack;
// - v: указатель на значение, в которое нужно десериализовать данные.
//
// Возвращает:
// - error: ошибка при десериализации (например, если данные повреждены
// или не соответствуют типу v).
//
// Важно: v должен быть указателем (!), иначе функция вернёт ошибку.
//
// Пример использования:
//
// var myStruct MyStruct
// err := serializer.Unmarshal(data, &myStruct)
// if err != nil {
// return err
// }
// // myStruct содержит восстановленные данные
func Unmarshal(data []byte, v interface{}) error { func Unmarshal(data []byte, v interface{}) error {
return msgpack.Unmarshal(data, v) return msgpack.Unmarshal(data, v)
} }
+11 -2
View File
@@ -12,6 +12,9 @@
// Назначение: Аудит всех операций создания, изменения, удаления данных // Назначение: Аудит всех операций создания, изменения, удаления данных
// с записью временной метки с точностью до миллисекунды // с записью временной метки с точностью до миллисекунды
// Аудит теперь персистентный - пишется в файл с fsync // Аудит теперь персистентный - пишется в файл с fsync
//
// ИЗМЕНЕНО (2026-10-03): формат временных меток изменён с "2006-01-02 15:04:05.000"
// на "02-01-2006 15:04:05.000" для совместимости с OpenIndiana.
package storage package storage
@@ -150,11 +153,17 @@ func CloseAuditLogger() error {
return nil return nil
} }
// GetCurrentTimestamp возвращает текущую временную метку с миллисекундами // GetCurrentTimestamp возвращает текущую временную метку с миллисекундами.
//
// ИЗМЕНЕНО (2026-10-03): формат строки изменён с "2006-01-02 15:04:05.000"
// на "02-01-2006 15:04:05.000". Это сделано для того, чтобы временные
// метки отображались как "03-10-2026" вместо "2026-10-03", как требуется
// для совместимости с OpenIndiana (и, в целом, для единообразия во всех
// компонентах СУБД). Используется глобально: audit, document, engine.
func GetCurrentTimestamp() (int64, string) { func GetCurrentTimestamp() (int64, string) {
now := time.Now() now := time.Now()
timestampMs := now.UnixMilli() timestampMs := now.UnixMilli()
timestampStr := now.Format("2006-01-02 15:04:05.000") timestampStr := now.Format("02-01-2006 15:04:05.000")
return timestampMs, timestampStr return timestampMs, timestampStr
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+10 -7
View File
@@ -14,6 +14,9 @@
// Полностью wait-free с использованием sync.Map и атомарных операций. // Полностью wait-free с использованием sync.Map и атомарных операций.
// InmemStore теперь использует fsync для сохранности Raft-лога // InmemStore теперь использует fsync для сохранности Raft-лога
// Restore() теперь полностью очищает существующие данные // Restore() теперь полностью очищает существующие данные
//
// ИЗМЕНЕНО (2026-10-03): формат временных меток изменён с "2006-01-02 15:04:05.000"
// на "02-012006 15:04:05.000" для совместимости с OpenIndiana.
package storage package storage
@@ -529,16 +532,16 @@ func (db *Database) SerializeDatabaseWithMetadata(exportedBy string) ([]byte, *E
DocumentsCount: totalDocuments, DocumentsCount: totalDocuments,
ExportedBy: exportedBy, ExportedBy: exportedBy,
Additional: map[string]interface{}{ Additional: map[string]interface{}{
"storage_created_at": time.UnixMilli(db.createdAt).Format("2006-01-02 15:04:05.000"), "storage_created_at": time.UnixMilli(db.createdAt).Format("02-012006 15:04:05.000"),
}, },
} }
dbData["_metadata"] = map[string]interface{}{ dbData["_metadata"] = map[string]interface{}{
"name": db.name, "name": db.name,
"created_at": db.GetCreatedAt(), "created_at": db.GetCreatedAt(),
"created_at_str": time.UnixMilli(db.GetCreatedAt()).Format("2006-01-02 15:04:05.000"), "created_at_str": time.UnixMilli(db.GetCreatedAt()).Format("02-012006 15:04:05.000"),
"updated_at": db.GetUpdatedAt(), "updated_at": db.GetUpdatedAt(),
"updated_at_str": time.UnixMilli(db.GetUpdatedAt()).Format("2006-01-02 15:04:05.000"), "updated_at_str": time.UnixMilli(db.GetUpdatedAt()).Format("02-012006 15:04:05.000"),
"export_metadata": metadata, "export_metadata": metadata,
} }
@@ -906,7 +909,7 @@ func (s *Storage) Backup(backupPath string) error {
backup := make(map[string]interface{}) backup := make(map[string]interface{})
backup["created_at"] = s.createdAt backup["created_at"] = s.createdAt
backup["backup_time"] = time.Now().UnixMilli() backup["backup_time"] = time.Now().UnixMilli()
backup["backup_time_str"] = time.Now().Format("2006-01-02 15:04:05.000") backup["backup_time_str"] = time.Now().Format("02-012006 15:04:05.000")
databases := make(map[string][]byte) databases := make(map[string][]byte)
databasesMetadata := make(map[string]interface{}) databasesMetadata := make(map[string]interface{})
@@ -1046,7 +1049,7 @@ func (s *Storage) GetStats() map[string]interface{} {
"total_documents": s.GetTotalDocuments(), "total_documents": s.GetTotalDocuments(),
"page_size_bytes": s.pageSize, "page_size_bytes": s.pageSize,
"created_at": s.createdAt, "created_at": s.createdAt,
"created_at_str": time.UnixMilli(s.createdAt).Format("2006-01-02 15:04:05.000"), "created_at_str": time.UnixMilli(s.createdAt).Format("02-012006 15:04:05.000"),
} }
databases := make([]map[string]interface{}, 0) databases := make([]map[string]interface{}, 0)
@@ -1056,9 +1059,9 @@ func (s *Storage) GetStats() map[string]interface{} {
"name": db.name, "name": db.name,
"collections": len(db.ListCollections()), "collections": len(db.ListCollections()),
"created_at": db.GetCreatedAt(), "created_at": db.GetCreatedAt(),
"created_at_str": time.UnixMilli(db.GetCreatedAt()).Format("2006-01-02 15:04:05.000"), "created_at_str": time.UnixMilli(db.GetCreatedAt()).Format("02-012006 15:04:05.000"),
"updated_at": db.GetUpdatedAt(), "updated_at": db.GetUpdatedAt(),
"updated_at_str": time.UnixMilli(db.GetUpdatedAt()).Format("2006-01-02 15:04:05.000"), "updated_at_str": time.UnixMilli(db.GetUpdatedAt()).Format("02-012006 15:04:05.000"),
"deleted_at": db.GetDeletedAt(), "deleted_at": db.GetDeletedAt(),
} }
+125 -37
View File
@@ -9,12 +9,30 @@
*/ */
// Файл: internal/storage/persistence.go // Файл: internal/storage/persistence.go
// Назначение: Персистентное хранение данных на диске с поддержкой checkpoint и recovery // Назначение: Персистентное хранение данных на диске с поддержкой checkpoint и recovery.
// ИСПРАВЛЕНО: Защита от torn write через CRC и checksum в имени файла //
// ИСПРАВЛЕНО: Обработка частично записанных WAL сегментов при восстановлении // ИСПРАВЛЕНО: защита от torn write через CRC и checksum в имени файла.
// ИСПРАВЛЕНО: Добавлена валидация целостности снапшотов перед загрузкой // ИСПРАВЛЕНО: обработка частично записанных WAL сегментов при восстановлении.
// ИСПРАВЛЕНО: Частичное применение транзакции теперь откатывается // ИСПРАВЛЕНО: добавлена валидация целостности снапшотов перед загрузкой.
// ИСПРАВЛЕНО: Drop перед Create при restore защищён (только после успешного создания) // ИСПРАВЛЕНО: частичное применение транзакции теперь откатывается.
// ИСПРАВЛЕНО: Drop перед Create при restore защищён (только после успешного создания).
//
// ИСПРАВЛЕНО (2026-10, переход ACID → Batch):
// 1. applyWALEntry больше не читает поле State (его нет в storage.Batch).
// Формат WAL-записи Type=1 — это JSON-сериализованный storage.Batch.
// Раньше txRecord.State всегда был 0 и функция молча возвращала nil
// на всех записях — WAL не применялся при восстановлении.
// 2. ReadSegment теперь читает 8-байтовый заголовок записи WAL
// (4 байта длины big-endian + 4 байта CRC big-endian), как пишет
// SegmentedWALManager. Раньше читались только 4 байта длины, и
// CRC попадал в начало JSON — Unmarshal падал на всех записях.
// Плюс добавлена опциональная проверка CRC.
// 3. Сегменты WAL сортируются численно по ID, а не лексикографически.
// Раньше при 10+ сегментах wal_segment_1.log, wal_segment_10.log,
// wal_segment_2.log читались в неверном порядке, и "последний"
// сегмент оказывался не самым свежим.
// 4. compress/decompress используют gzip по умолчанию, как и раньше,
// но теперь корректно обрабатывают пустой вход и ошибки Close.
package storage package storage
@@ -22,6 +40,7 @@ import (
"bytes" "bytes"
"compress/gzip" "compress/gzip"
"crypto/sha256" "crypto/sha256"
"encoding/binary"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -34,7 +53,11 @@ import (
"time" "time"
) )
// WALEntry представляет запись WAL // =============================================================================
// ТИПЫ WAL
// =============================================================================
// WALEntry представляет запись WAL (публичная форма для читателя).
type WALEntry struct { type WALEntry struct {
Index uint64 `json:"index"` Index uint64 `json:"index"`
Term uint64 `json:"term"` Term uint64 `json:"term"`
@@ -44,6 +67,7 @@ type WALEntry struct {
CRC uint32 `json:"crc,omitempty"` CRC uint32 `json:"crc,omitempty"`
} }
// WALReader — интерфейс чтения WAL.
type WALReader interface { type WALReader interface {
ReadSince(index uint64) ([]WALEntry, error) ReadSince(index uint64) ([]WALEntry, error)
GetCurrentIndex() (uint64, error) GetCurrentIndex() (uint64, error)
@@ -53,6 +77,12 @@ type WALReader interface {
ReadSegment(segmentPath string) ([]WALEntry, error) ReadSegment(segmentPath string) ([]WALEntry, error)
} }
// walReaderImpl читает сегменты WAL, записанные SegmentedWALManager.
//
// Формат сегмента:
// [4 байта длины (big-endian)] [4 байта CRC (big-endian)] [length байт данных]
//
// Данные — JSON-сериализованный WALRecord (см. transactions.go).
type walReaderImpl struct { type walReaderImpl struct {
segmentsDir string segmentsDir string
lastBackupIndex atomic.Uint64 lastBackupIndex atomic.Uint64
@@ -60,6 +90,7 @@ type walReaderImpl struct {
logger LoggerInterface logger LoggerInterface
} }
// NewWALReaderImpl создаёт читатель WAL.
func NewWALReaderImpl(segmentsDir string, logger LoggerInterface) WALReader { func NewWALReaderImpl(segmentsDir string, logger LoggerInterface) WALReader {
wr := &walReaderImpl{ wr := &walReaderImpl{
segmentsDir: segmentsDir, segmentsDir: segmentsDir,
@@ -84,6 +115,7 @@ func (wr *walReaderImpl) loadLastBackupIndex() {
} }
} }
// ReadSince возвращает записи с Index > index.
func (wr *walReaderImpl) ReadSince(index uint64) ([]WALEntry, error) { func (wr *walReaderImpl) ReadSince(index uint64) ([]WALEntry, error) {
wr.mu.RLock() wr.mu.RLock()
defer wr.mu.RUnlock() defer wr.mu.RUnlock()
@@ -109,6 +141,7 @@ func (wr *walReaderImpl) ReadSince(index uint64) ([]WALEntry, error) {
return entries, nil return entries, nil
} }
// GetCurrentIndex возвращает максимальный LSN по последнему сегменту.
func (wr *walReaderImpl) GetCurrentIndex() (uint64, error) { func (wr *walReaderImpl) GetCurrentIndex() (uint64, error) {
segments, err := wr.GetSegments() segments, err := wr.GetSegments()
if err != nil { if err != nil {
@@ -140,6 +173,11 @@ func (wr *walReaderImpl) SetLastBackupIndex(index uint64) error {
return os.WriteFile(indexPath, data, 0644) return os.WriteFile(indexPath, data, 0644)
} }
// GetSegments возвращает сегменты WAL, отсортированные по числовому ID.
//
// ИСПРАВЛЕНО: числовая сортировка вместо лексикографической, потому что
// имена файлов wal_segment_1.log, wal_segment_10.log, wal_segment_2.log
// лексикографически упорядочены как 1, 10, 2 — неверный порядок.
func (wr *walReaderImpl) GetSegments() ([]string, error) { func (wr *walReaderImpl) GetSegments() ([]string, error) {
if wr.segmentsDir == "" { if wr.segmentsDir == "" {
return nil, fmt.Errorf("segments directory not set") return nil, fmt.Errorf("segments directory not set")
@@ -149,10 +187,27 @@ func (wr *walReaderImpl) GetSegments() ([]string, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
sort.Strings(files) sort.Slice(files, func(i, j int) bool {
return walSegmentIDFromPath(files[i]) < walSegmentIDFromPath(files[j])
})
return files, nil return files, nil
} }
// walSegmentIDFromPath извлекает числовой ID из имени файла сегмента.
func walSegmentIDFromPath(path string) uint64 {
base := filepath.Base(path)
var id uint64
if _, err := fmt.Sscanf(base, "wal_segment_%d.log", &id); err != nil {
return 0
}
return id
}
// ReadSegment читает один сегмент WAL и возвращает записи.
//
// ИСПРАВЛЕНО: заголовок записи теперь 8 байт (4 длины + 4 CRC),
// как в SegmentedWALManager. Раньше читались только 4 байта длины,
// и CRC попадал в данные — все записи читались неверно.
func (wr *walReaderImpl) ReadSegment(segmentPath string) ([]WALEntry, error) { func (wr *walReaderImpl) ReadSegment(segmentPath string) ([]WALEntry, error) {
data, err := os.ReadFile(segmentPath) data, err := os.ReadFile(segmentPath)
if err != nil { if err != nil {
@@ -161,54 +216,73 @@ func (wr *walReaderImpl) ReadSegment(segmentPath string) ([]WALEntry, error) {
entries := make([]WALEntry, 0) entries := make([]WALEntry, 0)
pos := 0 pos := 0
for pos < len(data) { for pos < len(data) {
if pos+4 > len(data) { if pos+8 > len(data) {
if wr.logger != nil { if wr.logger != nil {
wr.logger.Warn(fmt.Sprintf("Truncated record header at offset %d in %s", pos, segmentPath)) wr.logger.Warn(fmt.Sprintf("Truncated record header at offset %d in %s", pos, segmentPath))
} }
break break
} }
length := int(data[pos])<<24 | int(data[pos+1])<<16 | int(data[pos+2])<<8 | int(data[pos+3]) length := int(binary.BigEndian.Uint32(data[pos : pos+4]))
pos += 4 expectedCRC := binary.BigEndian.Uint32(data[pos+4 : pos+8])
pos += 8
if length <= 0 || length > 100*1024*1024 { if length <= 0 || length > 100*1024*1024 {
if wr.logger != nil { if wr.logger != nil {
wr.logger.Warn(fmt.Sprintf("Invalid record length %d at offset %d in %s", length, pos-4, segmentPath)) wr.logger.Warn(fmt.Sprintf("Invalid record length %d at offset %d in %s", length, pos-8, segmentPath))
} }
break break
} }
if pos+length > len(data) { if pos+length > len(data) {
if wr.logger != nil { if wr.logger != nil {
wr.logger.Warn(fmt.Sprintf("Torn write detected: expected %d bytes at offset %d, but only %d available in %s", wr.logger.Warn(fmt.Sprintf("Torn write detected: expected %d bytes at offset %d, only %d available in %s",
length, pos, len(data)-pos, segmentPath)) length, pos, len(data)-pos, segmentPath))
} }
break break
} }
recordData := data[pos : pos+length] recordData := data[pos : pos+length]
pos += length pos += length
var record struct { var record struct {
LSN uint64 `json:"lsn"` CRC uint32 `json:"crc"`
Timestamp int64 `json:"timestamp"` Length uint32 `json:"length"`
Type byte `json:"type"` Type byte `json:"type"`
Data []byte `json:"data"` Data []byte `json:"data"`
CRC uint32 `json:"crc,omitempty"` Timestamp int64 `json:"timestamp"`
LSN uint64 `json:"lsn"`
} }
if err := json.Unmarshal(recordData, &record); err != nil { if err := json.Unmarshal(recordData, &record); err != nil {
if wr.logger != nil { if wr.logger != nil {
wr.logger.Warn(fmt.Sprintf("Failed to unmarshal record at offset %d in %s: %v", pos-length, segmentPath, err)) wr.logger.Warn(fmt.Sprintf("Failed to unmarshal record at offset %d in %s: %v",
pos-length, segmentPath, err))
} }
continue continue
} }
entry := WALEntry{
// Опциональная проверка CRC. Если expectedCRC ноль или record.CRC ноль —
// пропускаем (запись может быть из старого формата). Если оба
// ненулевые и не совпадают — пропускаем как повреждённую.
if expectedCRC != 0 && record.CRC != 0 && expectedCRC != record.CRC {
if wr.logger != nil {
wr.logger.Warn(fmt.Sprintf("CRC mismatch at offset %d in %s: expected %08x, got %08x",
pos-length, segmentPath, expectedCRC, record.CRC))
}
continue
}
entries = append(entries, WALEntry{
Index: record.LSN, Index: record.LSN,
Timestamp: record.Timestamp, Timestamp: record.Timestamp,
Type: fmt.Sprintf("%d", record.Type), Type: fmt.Sprintf("%d", record.Type),
Data: record.Data, Data: record.Data,
CRC: record.CRC, CRC: record.CRC,
} })
entries = append(entries, entry)
} }
return entries, nil return entries, nil
} }
// =============================================================================
// КОНФИГУРАЦИЯ
// =============================================================================
type PersistenceConfig struct { type PersistenceConfig struct {
DataDir string `json:"data_dir"` DataDir string `json:"data_dir"`
CheckpointInterval time.Duration `json:"checkpoint_interval"` CheckpointInterval time.Duration `json:"checkpoint_interval"`
@@ -245,6 +319,10 @@ func DefaultPersistenceConfig() *PersistenceConfig {
} }
} }
// =============================================================================
// СТРУКТУРЫ
// =============================================================================
type DatabaseSnapshot struct { type DatabaseSnapshot struct {
Name string `json:"name"` Name string `json:"name"`
Collections map[string]interface{} `json:"collections"` Collections map[string]interface{} `json:"collections"`
@@ -609,9 +687,7 @@ func (pm *PersistenceManager) LoadDatabase(dbName string) error {
if !ok { if !ok {
continue continue
} }
// ИСПРАВЛЕНО: Не удаляем существующую коллекцию сразу. Создаём новую временно.
if err := db.CreateCollection(collName); err != nil { if err := db.CreateCollection(collName); err != nil {
// Коллекция уже существует - пропускаем
if pm.logger != nil { if pm.logger != nil {
pm.logger.Warn(fmt.Sprintf("Collection %s already exists during restore, skipping creation", collName)) pm.logger.Warn(fmt.Sprintf("Collection %s already exists during restore, skipping creation", collName))
} }
@@ -683,7 +759,6 @@ func (pm *PersistenceManager) LoadDatabase(dbName string) error {
} }
} else { } else {
for _, entry := range entries { for _, entry := range entries {
// ИСПРАВЛЕНО: applyWALEntry теперь применяет транзакцию атомарно
if err := pm.applyWALEntry(db, entry); err != nil { if err := pm.applyWALEntry(db, entry); err != nil {
if pm.logger != nil { if pm.logger != nil {
pm.logger.Error(fmt.Sprintf("Failed to apply WAL entry %d (rolled back): %v", entry.Index, err)) pm.logger.Error(fmt.Sprintf("Failed to apply WAL entry %d (rolled back): %v", entry.Index, err))
@@ -707,16 +782,23 @@ func (pm *PersistenceManager) LoadDatabase(dbName string) error {
return nil return nil
} }
// applyWALEntry применяет одну запись WAL к базе данных // applyWALEntry применяет одну запись WAL к базе данных.
// ИСПРАВЛЕНО: Частичное применение транзакции откатывается при ошибке //
// ИСПРАВЛЕНО (2026-10): формат WAL-записи Type=1 — это storage.Batch.
// Поле State в Batch отсутствует, поэтому прежняя проверка
// `if txRecord.State != 1` молча отбрасывала все записи. Теперь
// структура соответствует Batch, а частичное применение откатывается.
func (pm *PersistenceManager) applyWALEntry(db *Database, entry WALEntry) error { func (pm *PersistenceManager) applyWALEntry(db *Database, entry WALEntry) error {
if entry.Type != "1" { if entry.Type != "1" {
return nil return nil
} }
var txRecord struct {
// Формат совпадает со storage.Batch (см. transactions.go).
var batch struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
State int32 `json:"state"`
Timestamp int64 `json:"timestamp"` Timestamp int64 `json:"timestamp"`
Database string `json:"database,omitempty"`
Collection string `json:"collection,omitempty"`
Operations []struct { Operations []struct {
Type string `json:"type"` Type string `json:"type"`
Database string `json:"database"` Database string `json:"database"`
@@ -726,19 +808,16 @@ func (pm *PersistenceManager) applyWALEntry(db *Database, entry WALEntry) error
Version uint64 `json:"version"` Version uint64 `json:"version"`
} `json:"operations"` } `json:"operations"`
} }
if err := json.Unmarshal(entry.Data, &txRecord); err != nil { if err := json.Unmarshal(entry.Data, &batch); err != nil {
return err return err
} }
if txRecord.State != 1 {
return nil
}
// ИСПРАВЛЕНО: Собираем успешно применённые операции для отката // Собираем успешно применённые операции для отката.
type appliedOp struct { type appliedOp struct {
opType string opType string
collection string collection string
docID string docID string
oldDoc *Document // для отката update/delete oldDoc *Document
} }
applied := make([]appliedOp, 0) applied := make([]appliedOp, 0)
@@ -758,12 +837,15 @@ func (pm *PersistenceManager) applyWALEntry(db *Database, entry WALEntry) error
} }
case "delete": case "delete":
coll.RestoreDeleted(ao.docID) coll.RestoreDeleted(ao.docID)
case "restore":
// Откат restore — снова soft-delete.
coll.Delete(ao.docID)
} }
} }
} }
for _, op := range txRecord.Operations { for _, op := range batch.Operations {
if op.Database != db.Name() { if op.Database != "" && op.Database != db.Name() {
continue continue
} }
coll, err := db.GetCollection(op.Collection) coll, err := db.GetCollection(op.Collection)
@@ -784,7 +866,6 @@ func (pm *PersistenceManager) applyWALEntry(db *Database, entry WALEntry) error
applied = append(applied, appliedOp{opType: "insert", collection: op.Collection, docID: op.DocumentID}) applied = append(applied, appliedOp{opType: "insert", collection: op.Collection, docID: op.DocumentID})
case "update": case "update":
// ИСПРАВЛЕНО: сохраняем старое состояние для отката
oldDoc, _ := coll.Find(op.DocumentID) oldDoc, _ := coll.Find(op.DocumentID)
var oldCopy *Document var oldCopy *Document
if oldDoc != nil { if oldDoc != nil {
@@ -802,6 +883,13 @@ func (pm *PersistenceManager) applyWALEntry(db *Database, entry WALEntry) error
return err return err
} }
applied = append(applied, appliedOp{opType: "delete", collection: op.Collection, docID: op.DocumentID}) applied = append(applied, appliedOp{opType: "delete", collection: op.Collection, docID: op.DocumentID})
case "restore":
if err := coll.RestoreDeleted(op.DocumentID); err != nil {
rollback()
return err
}
applied = append(applied, appliedOp{opType: "restore", collection: op.Collection, docID: op.DocumentID})
} }
} }
return nil return nil
@@ -960,7 +1048,7 @@ func (pm *PersistenceManager) GetLastCheckpointInfo() map[string]interface{} {
defer pm.mu.RUnlock() defer pm.mu.RUnlock()
info := map[string]interface{}{ info := map[string]interface{}{
"last_checkpoint_time": pm.lastCheckpoint, "last_checkpoint_time": pm.lastCheckpoint,
"last_checkpoint_time_str": time.UnixMilli(pm.lastCheckpoint).Format("2006-01-02 15:04:05.000"), "last_checkpoint_time_str": time.UnixMilli(pm.lastCheckpoint).Format("02-01-2006 15:04:05.000"),
"checkpoint_id": pm.checkpointID.Load(), "checkpoint_id": pm.checkpointID.Load(),
"wal_path": pm.config.WalPath, "wal_path": pm.config.WalPath,
"use_wal": pm.config.UseWalForCheckpoint, "use_wal": pm.config.UseWalForCheckpoint,
+326 -321
View File
@@ -11,436 +11,441 @@
// Файл: internal/storage/runtime_limits.go // Файл: internal/storage/runtime_limits.go
// Назначение: Ограничения на размер коллекции/документа в рантайме // Назначение: Ограничения на размер коллекции/документа в рантайме
// Добавлен механизм eviction при нехватке памяти (OOM protection) // Добавлен механизм eviction при нехватке памяти (OOM protection)
// Eviction теперь проверяет активные транзакции перед удалением // Eviction проверяет активные batch-операции перед удалением
//
// ИСПРАВЛЕНО (2026-10): после замены ACID-транзакций на batch-операции
// функция isDocInActiveTransaction ссылалась на удалённые globalTxManager
// и Transaction. Заменена на isDocInActiveBatch, который проверяет
// реестр активных batch'ей в globalBatchManager.
//
// Логика та же: не вытеснять документы, которые в данный момент
// участвуют в незакоммиченной batch-операции. Batch живёт коротко
// (создаётся, коммитится и удаляется в рамках одной функции), но
// между AddUpdate и Commit документ ещё не изменён, и eviction
// мог бы его удалить — защита сохраняется.
package storage package storage
import ( import (
"fmt" "fmt"
"runtime" "runtime"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
) )
// LoggerInterface определяет интерфейс для логирования // LoggerInterface определяет интерфейс для логирования
type LoggerInterface interface { type LoggerInterface interface {
Debug(msg string) Debug(msg string)
Info(msg string) Info(msg string)
Error(msg string) Error(msg string)
Warn(msg string) Warn(msg string)
} }
// EvictionPolicy определяет политику вытеснения // EvictionPolicy определяет политику вытеснения
type EvictionPolicy int type EvictionPolicy int
const ( const (
EvictionNone EvictionPolicy = iota EvictionNone EvictionPolicy = iota
EvictionLRU EvictionLRU
EvictionTTL EvictionTTL
EvictionOldest EvictionOldest
) )
// RuntimeLimitsManager управляет runtime-ограничениями // RuntimeLimitsManager управляет runtime-ограничениями
type RuntimeLimitsManager struct { type RuntimeLimitsManager struct {
mu sync.RWMutex mu sync.RWMutex
globalMaxDocSize int64 globalMaxDocSize int64
globalMaxCollSize int64 globalMaxCollSize int64
globalMaxDocsPerColl int64 globalMaxDocsPerColl int64
globalMaxMemory int64 globalMaxMemory int64
collectionOverrides map[string]*CollectionLimits collectionOverrides map[string]*CollectionLimits
metrics *LimitMetrics metrics *LimitMetrics
logger LoggerInterface logger LoggerInterface
enabled bool enabled bool
evictionPolicy EvictionPolicy evictionPolicy EvictionPolicy
memoryThreshold float64 memoryThreshold float64
evictionChan chan string evictionChan chan string
stopChan chan struct{} stopChan chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
} }
// CollectionLimits содержит лимиты для конкретной коллекции // CollectionLimits содержит лимиты для конкретной коллекции
type CollectionLimits struct { type CollectionLimits struct {
MaxDocSize int64 MaxDocSize int64
MaxCollectionSize int64 MaxCollectionSize int64
MaxDocuments int64 MaxDocuments int64
EvictionPolicy EvictionPolicy EvictionPolicy EvictionPolicy
LastUpdated int64 LastUpdated int64
} }
// LimitMetrics хранит метрики ограничений // LimitMetrics хранит метрики ограничений
type LimitMetrics struct { type LimitMetrics struct {
RejectedBySize atomic.Uint64 RejectedBySize atomic.Uint64
RejectedByDocCount atomic.Uint64 RejectedByDocCount atomic.Uint64
RejectedByCollSize atomic.Uint64 RejectedByCollSize atomic.Uint64
RejectedByMemory atomic.Uint64 RejectedByMemory atomic.Uint64
EvictedDocuments atomic.Uint64 EvictedDocuments atomic.Uint64
EvictedBytes atomic.Uint64 EvictedBytes atomic.Uint64
LastCheckTime atomic.Int64 LastCheckTime atomic.Int64
LastEvictionTime atomic.Int64 LastEvictionTime atomic.Int64
SkippedEviction atomic.Uint64 // Пропущено из-за активных транзакций SkippedEviction atomic.Uint64 // Пропущено из-за активных batch-операций
} }
// RuntimeLimitsConfig содержит конфигурацию ограничений // RuntimeLimitsConfig содержит конфигурацию ограничений
type RuntimeLimitsConfig struct { type RuntimeLimitsConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
GlobalMaxDocSizeMB int `json:"global_max_doc_size_mb"` GlobalMaxDocSizeMB int `json:"global_max_doc_size_mb"`
GlobalMaxCollSizeMB int64 `json:"global_max_coll_size_mb"` GlobalMaxCollSizeMB int64 `json:"global_max_coll_size_mb"`
GlobalMaxDocsPerColl int64 `json:"global_max_docs_per_coll"` GlobalMaxDocsPerColl int64 `json:"global_max_docs_per_coll"`
GlobalMaxMemoryMB int64 `json:"global_max_memory_mb"` GlobalMaxMemoryMB int64 `json:"global_max_memory_mb"`
EvictionPolicy EvictionPolicy `json:"eviction_policy"` EvictionPolicy EvictionPolicy `json:"eviction_policy"`
MemoryThreshold float64 `json:"memory_threshold"` MemoryThreshold float64 `json:"memory_threshold"`
} }
// DefaultRuntimeLimitsConfig возвращает конфигурацию по умолчанию // DefaultRuntimeLimitsConfig возвращает конфигурацию по умолчанию
func DefaultRuntimeLimitsConfig() *RuntimeLimitsConfig { func DefaultRuntimeLimitsConfig() *RuntimeLimitsConfig {
return &RuntimeLimitsConfig{ return &RuntimeLimitsConfig{
Enabled: true, Enabled: true,
GlobalMaxDocSizeMB: 16, GlobalMaxDocSizeMB: 16,
GlobalMaxCollSizeMB: 10240, GlobalMaxCollSizeMB: 10240,
GlobalMaxDocsPerColl: 10000000, GlobalMaxDocsPerColl: 10000000,
GlobalMaxMemoryMB: 0, GlobalMaxMemoryMB: 0,
EvictionPolicy: EvictionLRU, EvictionPolicy: EvictionLRU,
MemoryThreshold: 0.85, MemoryThreshold: 0.85,
} }
} }
// NewRuntimeLimitsManager создаёт новый менеджер ограничений // NewRuntimeLimitsManager создаёт новый менеджер ограничений
func NewRuntimeLimitsManager(cfg *RuntimeLimitsConfig, logger LoggerInterface) *RuntimeLimitsManager { func NewRuntimeLimitsManager(cfg *RuntimeLimitsConfig, logger LoggerInterface) *RuntimeLimitsManager {
if cfg == nil { if cfg == nil {
cfg = DefaultRuntimeLimitsConfig() cfg = DefaultRuntimeLimitsConfig()
} }
maxMemory := cfg.GlobalMaxMemoryMB * 1024 * 1024 maxMemory := cfg.GlobalMaxMemoryMB * 1024 * 1024
if maxMemory <= 0 { if maxMemory <= 0 {
var memStats runtime.MemStats var memStats runtime.MemStats
runtime.ReadMemStats(&memStats) runtime.ReadMemStats(&memStats)
maxMemory = int64(float64(memStats.Sys) * 0.8) maxMemory = int64(float64(memStats.Sys) * 0.8)
} }
rlm := &RuntimeLimitsManager{ rlm := &RuntimeLimitsManager{
globalMaxDocSize: int64(cfg.GlobalMaxDocSizeMB) * 1024 * 1024, globalMaxDocSize: int64(cfg.GlobalMaxDocSizeMB) * 1024 * 1024,
globalMaxCollSize: cfg.GlobalMaxCollSizeMB * 1024 * 1024, globalMaxCollSize: cfg.GlobalMaxCollSizeMB * 1024 * 1024,
globalMaxDocsPerColl: cfg.GlobalMaxDocsPerColl, globalMaxDocsPerColl: cfg.GlobalMaxDocsPerColl,
globalMaxMemory: maxMemory, globalMaxMemory: maxMemory,
collectionOverrides: make(map[string]*CollectionLimits), collectionOverrides: make(map[string]*CollectionLimits),
metrics: &LimitMetrics{}, metrics: &LimitMetrics{},
logger: logger, logger: logger,
enabled: cfg.Enabled, enabled: cfg.Enabled,
evictionPolicy: cfg.EvictionPolicy, evictionPolicy: cfg.EvictionPolicy,
memoryThreshold: cfg.MemoryThreshold, memoryThreshold: cfg.MemoryThreshold,
evictionChan: make(chan string, 100), evictionChan: make(chan string, 100),
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
} }
rlm.wg.Add(1) rlm.wg.Add(1)
go rlm.memoryMonitorLoop() go rlm.memoryMonitorLoop()
if logger != nil { if logger != nil {
logger.Debug(fmt.Sprintf("Runtime limits manager initialized: maxDoc=%dMB, maxColl=%dMB, maxDocs=%d, maxMemory=%dMB, eviction=%d", logger.Debug(fmt.Sprintf("Runtime limits manager initialized: maxDoc=%dMB, maxColl=%dMB, maxDocs=%d, maxMemory=%dMB, eviction=%d",
cfg.GlobalMaxDocSizeMB, cfg.GlobalMaxCollSizeMB, cfg.GlobalMaxDocsPerColl, maxMemory/(1024*1024), cfg.EvictionPolicy)) cfg.GlobalMaxDocSizeMB, cfg.GlobalMaxCollSizeMB, cfg.GlobalMaxDocsPerColl, maxMemory/(1024*1024), cfg.EvictionPolicy))
} }
return rlm return rlm
} }
func (rlm *RuntimeLimitsManager) memoryMonitorLoop() { func (rlm *RuntimeLimitsManager) memoryMonitorLoop() {
defer rlm.wg.Done() defer rlm.wg.Done()
ticker := time.NewTicker(10 * time.Second) ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
if !rlm.enabled { if !rlm.enabled {
continue continue
} }
var memStats runtime.MemStats var memStats runtime.MemStats
runtime.ReadMemStats(&memStats) runtime.ReadMemStats(&memStats)
currentUsage := int64(memStats.Alloc) currentUsage := int64(memStats.Alloc)
usageRatio := float64(currentUsage) / float64(rlm.globalMaxMemory) usageRatio := float64(currentUsage) / float64(rlm.globalMaxMemory)
if usageRatio > rlm.memoryThreshold { if usageRatio > rlm.memoryThreshold {
if rlm.logger != nil { if rlm.logger != nil {
rlm.logger.Warn(fmt.Sprintf("Memory usage %.2f%% exceeds threshold %.2f%%, triggering eviction", rlm.logger.Warn(fmt.Sprintf("Memory usage %.2f%% exceeds threshold %.2f%%, triggering eviction",
usageRatio*100, rlm.memoryThreshold*100)) usageRatio*100, rlm.memoryThreshold*100))
} }
rlm.triggerEviction() rlm.triggerEviction()
} }
case <-rlm.stopChan: case <-rlm.stopChan:
return return
} }
} }
} }
func (rlm *RuntimeLimitsManager) triggerEviction() { func (rlm *RuntimeLimitsManager) triggerEviction() {
select { select {
case rlm.evictionChan <- "global": case rlm.evictionChan <- "global":
default: default:
} }
} }
// isDocInActiveTransaction проверяет, участвует ли документ в активной транзакции // isDocInActiveBatch проверяет, участвует ли документ в активной
// Защита от удаления данных активных транзакций // batch-операции.
func (rlm *RuntimeLimitsManager) isDocInActiveTransaction(docID, dbName, collName string) bool { //
if globalTxManager == nil { // ИСПРАВЛЕНО (2026-10): ранее метод назывался isDocInActiveTransaction
return false // и обращался к globalTxManager и Transaction, которые были удалены при
} // переходе на batch-операции. Теперь проверяем реестр активных batch'ей
// в globalBatchManager (см. transactions.go).
inTx := false //
globalTxManager.activeTransactions.Range(func(key, value interface{}) bool { // Защита сохраняется: batch живёт коротко (создаётся и коммитится в
tx := value.(*Transaction) // рамках одной функции), но между AddUpdate и Commit документ ещё не
tx.mu.RLock() // изменён, и eviction мог бы его удалить.
for _, op := range tx.Operations { func (rlm *RuntimeLimitsManager) isDocInActiveBatch(docID, dbName, collName string) bool {
if op.DocumentID == docID && op.Database == dbName && op.Collection == collName { bm := GetBatchManager()
inTx = true if bm == nil {
break return false
} }
} return bm.IsDocInActiveBatch(docID, dbName, collName)
tx.mu.RUnlock()
if inTx {
return false
}
return true
})
return inTx
} }
// EvictFromCollection выполняет вытеснение документов из коллекции // EvictFromCollection выполняет вытеснение документов из коллекции.
// Проверка активных транзакций перед удалением //
// Проверка активных batch-операций перед удалением.
func (rlm *RuntimeLimitsManager) EvictFromCollection(coll *Collection, targetBytes int64) (int64, error) { func (rlm *RuntimeLimitsManager) EvictFromCollection(coll *Collection, targetBytes int64) (int64, error) {
if !rlm.enabled { if !rlm.enabled {
return 0, nil return 0, nil
} }
evictedBytes := int64(0) evictedBytes := int64(0)
evictedCount := int64(0) evictedCount := int64(0)
skippedCount := int64(0) skippedCount := int64(0)
docs := coll.GetAllDocumentsIncludingDeleted() docs := coll.GetAllDocumentsIncludingDeleted()
// Сначала вытесняем удалённые документы // Сначала вытесняем удалённые документы
for _, doc := range docs { for _, doc := range docs {
if evictedBytes >= targetBytes { if evictedBytes >= targetBytes {
break break
} }
if doc.IsDeleted() { if doc.IsDeleted() {
// Проверяем активные транзакции // Проверяем активные batch-операции
if rlm.isDocInActiveTransaction(doc.ID, coll.DBName(), coll.Name()) { if rlm.isDocInActiveBatch(doc.ID, coll.DBName(), coll.Name()) {
skippedCount++ skippedCount++
continue continue
} }
size := doc.OriginalSize size := doc.OriginalSize
if size == 0 { if size == 0 {
size = 1024 size = 1024
} }
if err := coll.PermanentDelete(doc.ID); err == nil { if err := coll.PermanentDelete(doc.ID); err == nil {
evictedBytes += size evictedBytes += size
evictedCount++ evictedCount++
} }
} }
} }
// Затем вытесняем самые старые // Затем вытесняем самые старые
if evictedBytes < targetBytes && rlm.evictionPolicy == EvictionLRU { if evictedBytes < targetBytes && rlm.evictionPolicy == EvictionLRU {
for _, doc := range docs { for _, doc := range docs {
if evictedBytes >= targetBytes { if evictedBytes >= targetBytes {
break break
} }
if !doc.IsDeleted() { if !doc.IsDeleted() {
// Проверяем активные транзакции // Проверяем активные batch-операции
if rlm.isDocInActiveTransaction(doc.ID, coll.DBName(), coll.Name()) { if rlm.isDocInActiveBatch(doc.ID, coll.DBName(), coll.Name()) {
skippedCount++ skippedCount++
continue continue
} }
size := doc.OriginalSize size := doc.OriginalSize
if size == 0 { if size == 0 {
size = 1024 size = 1024
} }
if coll.metadata.Settings.SoftDelete { if coll.metadata.Settings.SoftDelete {
if err := coll.Delete(doc.ID); err == nil { if err := coll.Delete(doc.ID); err == nil {
evictedBytes += size evictedBytes += size
evictedCount++ evictedCount++
} }
} else { } else {
if err := coll.PermanentDelete(doc.ID); err == nil { if err := coll.PermanentDelete(doc.ID); err == nil {
evictedBytes += size evictedBytes += size
evictedCount++ evictedCount++
} }
} }
} }
} }
} }
rlm.metrics.EvictedDocuments.Add(uint64(evictedCount)) rlm.metrics.EvictedDocuments.Add(uint64(evictedCount))
rlm.metrics.EvictedBytes.Add(uint64(evictedBytes)) rlm.metrics.EvictedBytes.Add(uint64(evictedBytes))
if skippedCount > 0 { if skippedCount > 0 {
rlm.metrics.SkippedEviction.Add(uint64(skippedCount)) rlm.metrics.SkippedEviction.Add(uint64(skippedCount))
} }
rlm.metrics.LastEvictionTime.Store(time.Now().UnixMilli()) rlm.metrics.LastEvictionTime.Store(time.Now().UnixMilli())
if rlm.logger != nil { if rlm.logger != nil {
rlm.logger.Info(fmt.Sprintf("Evicted %d documents (%d bytes) from collection %s.%s (skipped %d in active transactions)", rlm.logger.Info(fmt.Sprintf("Evicted %d documents (%d bytes) from collection %s.%s (skipped %d in active batches)",
evictedCount, evictedBytes, coll.DBName(), coll.Name(), skippedCount)) evictedCount, evictedBytes, coll.DBName(), coll.Name(), skippedCount))
} }
return evictedBytes, nil return evictedBytes, nil
} }
func (rlm *RuntimeLimitsManager) ValidateDocumentSize(dbName, collName string, docSize int64) error { func (rlm *RuntimeLimitsManager) ValidateDocumentSize(dbName, collName string, docSize int64) error {
if !rlm.enabled { if !rlm.enabled {
return nil return nil
} }
limit := rlm.getCollectionLimit(dbName, collName) limit := rlm.getCollectionLimit(dbName, collName)
maxSize := rlm.globalMaxDocSize maxSize := rlm.globalMaxDocSize
if limit != nil && limit.MaxDocSize > 0 { if limit != nil && limit.MaxDocSize > 0 {
maxSize = limit.MaxDocSize maxSize = limit.MaxDocSize
} }
if docSize > maxSize { if docSize > maxSize {
rlm.metrics.RejectedBySize.Add(1) rlm.metrics.RejectedBySize.Add(1)
return fmt.Errorf("document size %d bytes exceeds limit %d bytes", docSize, maxSize) return fmt.Errorf("document size %d bytes exceeds limit %d bytes", docSize, maxSize)
} }
return nil return nil
} }
func (rlm *RuntimeLimitsManager) ValidateCollectionSize(coll *Collection, newDocSize int64) error { func (rlm *RuntimeLimitsManager) ValidateCollectionSize(coll *Collection, newDocSize int64) error {
if !rlm.enabled { if !rlm.enabled {
return nil return nil
} }
limit := rlm.getCollectionLimit(coll.DBName(), coll.Name()) limit := rlm.getCollectionLimit(coll.DBName(), coll.Name())
maxSize := rlm.globalMaxCollSize maxSize := rlm.globalMaxCollSize
if limit != nil && limit.MaxCollectionSize > 0 { if limit != nil && limit.MaxCollectionSize > 0 {
maxSize = limit.MaxCollectionSize maxSize = limit.MaxCollectionSize
} }
currentSize := coll.Size() currentSize := coll.Size()
if currentSize+newDocSize > maxSize { if currentSize+newDocSize > maxSize {
neededBytes := currentSize + newDocSize - maxSize neededBytes := currentSize + newDocSize - maxSize
if evicted, err := rlm.EvictFromCollection(coll, neededBytes); err == nil && evicted >= neededBytes { if evicted, err := rlm.EvictFromCollection(coll, neededBytes); err == nil && evicted >= neededBytes {
return nil return nil
} }
rlm.metrics.RejectedByCollSize.Add(1) rlm.metrics.RejectedByCollSize.Add(1)
return fmt.Errorf("collection size would exceed limit %d bytes (current: %d, new: %d)", return fmt.Errorf("collection size would exceed limit %d bytes (current: %d, new: %d)",
maxSize, currentSize, newDocSize) maxSize, currentSize, newDocSize)
} }
return nil return nil
} }
func (rlm *RuntimeLimitsManager) ValidateDocumentCount(coll *Collection) error { func (rlm *RuntimeLimitsManager) ValidateDocumentCount(coll *Collection) error {
if !rlm.enabled { if !rlm.enabled {
return nil return nil
} }
limit := rlm.getCollectionLimit(coll.DBName(), coll.Name()) limit := rlm.getCollectionLimit(coll.DBName(), coll.Name())
maxDocs := rlm.globalMaxDocsPerColl maxDocs := rlm.globalMaxDocsPerColl
if limit != nil && limit.MaxDocuments > 0 { if limit != nil && limit.MaxDocuments > 0 {
maxDocs = limit.MaxDocuments maxDocs = limit.MaxDocuments
} }
currentCount := coll.Count() currentCount := coll.Count()
if currentCount >= maxDocs { if currentCount >= maxDocs {
targetBytes := int64((currentCount - maxDocs + 1) * 1024) targetBytes := int64((currentCount - maxDocs + 1) * 1024)
if evicted, err := rlm.EvictFromCollection(coll, targetBytes); err == nil && evicted > 0 { if evicted, err := rlm.EvictFromCollection(coll, targetBytes); err == nil && evicted > 0 {
if coll.Count() < maxDocs { if coll.Count() < maxDocs {
return nil return nil
} }
} }
rlm.metrics.RejectedByDocCount.Add(1) rlm.metrics.RejectedByDocCount.Add(1)
return fmt.Errorf("collection has reached maximum document count %d", maxDocs) return fmt.Errorf("collection has reached maximum document count %d", maxDocs)
} }
return nil return nil
} }
func (rlm *RuntimeLimitsManager) CheckMemoryUsage() error { func (rlm *RuntimeLimitsManager) CheckMemoryUsage() error {
if !rlm.enabled { if !rlm.enabled {
return nil return nil
} }
var memStats runtime.MemStats var memStats runtime.MemStats
runtime.ReadMemStats(&memStats) runtime.ReadMemStats(&memStats)
currentUsage := int64(memStats.Alloc) currentUsage := int64(memStats.Alloc)
if currentUsage > rlm.globalMaxMemory { if currentUsage > rlm.globalMaxMemory {
rlm.metrics.RejectedByMemory.Add(1) rlm.metrics.RejectedByMemory.Add(1)
return fmt.Errorf("memory usage %d bytes exceeds limit %d bytes", currentUsage, rlm.globalMaxMemory) return fmt.Errorf("memory usage %d bytes exceeds limit %d bytes", currentUsage, rlm.globalMaxMemory)
} }
return nil return nil
} }
func (rlm *RuntimeLimitsManager) getCollectionLimit(dbName, collName string) *CollectionLimits { func (rlm *RuntimeLimitsManager) getCollectionLimit(dbName, collName string) *CollectionLimits {
rlm.mu.RLock() rlm.mu.RLock()
defer rlm.mu.RUnlock() defer rlm.mu.RUnlock()
key := fmt.Sprintf("%s.%s", dbName, collName) key := fmt.Sprintf("%s.%s", dbName, collName)
if limits, ok := rlm.collectionOverrides[key]; ok { if limits, ok := rlm.collectionOverrides[key]; ok {
return limits return limits
} }
return nil return nil
} }
func (rlm *RuntimeLimitsManager) SetCollectionLimits(dbName, collName string, maxDocSizeMB int, maxCollSizeMB int64, maxDocuments int64) { func (rlm *RuntimeLimitsManager) SetCollectionLimits(dbName, collName string, maxDocSizeMB int, maxCollSizeMB int64, maxDocuments int64) {
rlm.mu.Lock() rlm.mu.Lock()
defer rlm.mu.Unlock() defer rlm.mu.Unlock()
key := fmt.Sprintf("%s.%s", dbName, collName) key := fmt.Sprintf("%s.%s", dbName, collName)
rlm.collectionOverrides[key] = &CollectionLimits{ rlm.collectionOverrides[key] = &CollectionLimits{
MaxDocSize: int64(maxDocSizeMB) * 1024 * 1024, MaxDocSize: int64(maxDocSizeMB) * 1024 * 1024,
MaxCollectionSize: maxCollSizeMB * 1024 * 1024, MaxCollectionSize: maxCollSizeMB * 1024 * 1024,
MaxDocuments: maxDocuments, MaxDocuments: maxDocuments,
EvictionPolicy: rlm.evictionPolicy, EvictionPolicy: rlm.evictionPolicy,
LastUpdated: time.Now().UnixMilli(), LastUpdated: time.Now().UnixMilli(),
} }
if rlm.logger != nil { if rlm.logger != nil {
rlm.logger.Info(fmt.Sprintf("Set limits for %s: maxDoc=%dMB, maxColl=%dMB, maxDocs=%d", rlm.logger.Info(fmt.Sprintf("Set limits for %s: maxDoc=%dMB, maxColl=%dMB, maxDocs=%d",
key, maxDocSizeMB, maxCollSizeMB, maxDocuments)) key, maxDocSizeMB, maxCollSizeMB, maxDocuments))
} }
} }
func (rlm *RuntimeLimitsManager) RemoveCollectionLimits(dbName, collName string) { func (rlm *RuntimeLimitsManager) RemoveCollectionLimits(dbName, collName string) {
rlm.mu.Lock() rlm.mu.Lock()
defer rlm.mu.Unlock() defer rlm.mu.Unlock()
key := fmt.Sprintf("%s.%s", dbName, collName) key := fmt.Sprintf("%s.%s", dbName, collName)
delete(rlm.collectionOverrides, key) delete(rlm.collectionOverrides, key)
if rlm.logger != nil { if rlm.logger != nil {
rlm.logger.Info(fmt.Sprintf("Removed limits override for %s", key)) rlm.logger.Info(fmt.Sprintf("Removed limits override for %s", key))
} }
} }
func (rlm *RuntimeLimitsManager) GetMetrics() map[string]interface{} { func (rlm *RuntimeLimitsManager) GetMetrics() map[string]interface{} {
return map[string]interface{}{ return map[string]interface{}{
"rejected_by_size": rlm.metrics.RejectedBySize.Load(), "rejected_by_size": rlm.metrics.RejectedBySize.Load(),
"rejected_by_doc_count": rlm.metrics.RejectedByDocCount.Load(), "rejected_by_doc_count": rlm.metrics.RejectedByDocCount.Load(),
"rejected_by_coll_size": rlm.metrics.RejectedByCollSize.Load(), "rejected_by_coll_size": rlm.metrics.RejectedByCollSize.Load(),
"rejected_by_memory": rlm.metrics.RejectedByMemory.Load(), "rejected_by_memory": rlm.metrics.RejectedByMemory.Load(),
"evicted_documents": rlm.metrics.EvictedDocuments.Load(), "evicted_documents": rlm.metrics.EvictedDocuments.Load(),
"evicted_bytes": rlm.metrics.EvictedBytes.Load(), "evicted_bytes": rlm.metrics.EvictedBytes.Load(),
"skipped_eviction": rlm.metrics.SkippedEviction.Load(), "skipped_eviction": rlm.metrics.SkippedEviction.Load(),
"last_eviction_time": rlm.metrics.LastEvictionTime.Load(), "last_eviction_time": rlm.metrics.LastEvictionTime.Load(),
"global_max_doc_size_mb": rlm.globalMaxDocSize / (1024 * 1024), "global_max_doc_size_mb": rlm.globalMaxDocSize / (1024 * 1024),
"global_max_coll_size_mb": rlm.globalMaxCollSize / (1024 * 1024), "global_max_coll_size_mb": rlm.globalMaxCollSize / (1024 * 1024),
"global_max_docs_per_coll": rlm.globalMaxDocsPerColl, "global_max_docs_per_coll": rlm.globalMaxDocsPerColl,
"global_max_memory_mb": rlm.globalMaxMemory / (1024 * 1024), "global_max_memory_mb": rlm.globalMaxMemory / (1024 * 1024),
"eviction_policy": rlm.evictionPolicy, "eviction_policy": rlm.evictionPolicy,
"memory_threshold": rlm.memoryThreshold, "memory_threshold": rlm.memoryThreshold,
"enabled": rlm.enabled, "enabled": rlm.enabled,
} }
} }
func (rlm *RuntimeLimitsManager) Stop() { func (rlm *RuntimeLimitsManager) Stop() {
close(rlm.stopChan) close(rlm.stopChan)
rlm.wg.Wait() rlm.wg.Wait()
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -678,6 +678,8 @@ func (tm *TriggerManager) resolveValue(value interface{}, execCtx *TriggerExecut
} }
// logExecution логирует выполнение триггера // logExecution логирует выполнение триггера
//
// ИЗМЕНЕНО (2026-10-03): формат временной метки изменён на "02-01-2006 15:04:05.000".
func (tm *TriggerManager) logExecution(execCtx *TriggerExecution, trigger *Trigger, result string, startTime time.Time) { func (tm *TriggerManager) logExecution(execCtx *TriggerExecution, trigger *Trigger, result string, startTime time.Time) {
tm.mu.Lock() tm.mu.Lock()
defer tm.mu.Unlock() defer tm.mu.Unlock()
@@ -685,7 +687,7 @@ func (tm *TriggerManager) logExecution(execCtx *TriggerExecution, trigger *Trigg
duration := time.Since(startTime) duration := time.Since(startTime)
now := time.Now() now := time.Now()
nowMs := now.UnixMilli() nowMs := now.UnixMilli()
nowStr := now.Format("2006-01-02 15:04:05.000") nowStr := now.Format("02-01-2006 15:04:05.000")
execCtx.TriggerName = trigger.Name execCtx.TriggerName = trigger.Name
execCtx.Timestamp = now execCtx.Timestamp = now