Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d511e347df | ||
|
|
0394ad893e | ||
|
|
3f4ad87e59 | ||
|
|
bd6c8acda6 | ||
|
|
dd5f11fd3d | ||
|
|
b75dffab84 | ||
|
|
2541233e40 | ||
|
|
f191e5b328 | ||
|
|
cc4ec0c029 | ||
|
|
06e9558839 | ||
|
|
3f1748bd6c | ||
|
|
79c1a0171e | ||
|
|
ba4f7bfaa7 | ||
|
|
1607f4108d | ||
|
|
7804ce2338 | ||
|
|
904f8081bd | ||
|
|
d3353b85ba | ||
|
|
5762b7f6db | ||
|
|
b4ccb910c4 | ||
|
|
43fda3f796 | ||
|
|
cfe1538fcf | ||
|
|
f0694ca563 | ||
|
|
3c25122cd1 | ||
|
|
523f12fec0 | ||
|
|
7fd163af92 | ||
|
|
afbc88c3e8 | ||
|
|
c9a5c8307c | ||
|
|
0fcc0998ba | ||
|
|
6c599b94bf | ||
|
|
ae12264e27 | ||
|
|
a7a3cd8cdb | ||
|
|
73061c2883 | ||
|
|
7fb359a462 | ||
|
|
ec05741a70 | ||
|
|
b418243503 | ||
|
|
4adf1b62db | ||
|
|
96930e8e42 | ||
|
|
f0e28510e9 | ||
|
|
e96c5a7a24 | ||
|
|
a9d6aa42e5 | ||
|
|
78bff3e4d8 | ||
|
|
d8673b747f | ||
|
|
96dc6311ea | ||
|
|
93385022d2 | ||
|
|
e41f557719 | ||
|
|
452c8853f4 | ||
|
|
2a9114083b | ||
|
|
13fe261b40 | ||
|
|
73773c27da | ||
|
|
b934417417 | ||
|
|
5422e39ab2 | ||
|
|
98bae0cbd6 | ||
|
|
52d0216676 | ||
|
|
3a1de92233 | ||
|
|
56a91d9fc8 | ||
|
|
1559e12a51 | ||
|
|
2e84b86d23 | ||
|
|
6b9ee6b3d2 | ||
|
|
a373f05b18 | ||
|
|
a73aa8bc9d | ||
|
|
a024994943 | ||
|
|
aaada4008c | ||
|
|
c5a5286314 | ||
|
|
b26a42ab26 | ||
|
|
d0613d6aa1 | ||
|
|
11e79007cc | ||
|
|
6b3317ce94 | ||
|
|
e5b34c75d8 | ||
|
|
d4ea0533d2 | ||
|
|
9b5bb176b3 | ||
|
|
a533d973c0 | ||
|
|
7fd2addabe | ||
|
|
5ffccfe885 | ||
|
|
ffc66dbed7 | ||
|
|
afff868301 | ||
|
|
87bed4495b | ||
|
|
58ba3b363e | ||
|
|
d59b878cd4 | ||
|
|
54d1c0ba09 | ||
|
|
f480b910fc | ||
|
|
a772e2d2a0 | ||
|
|
90ca83f9a3 | ||
|
|
16267d2ea8 | ||
|
|
5e230b1a9c | ||
|
|
ab48d50988 | ||
|
|
bc445ad7be | ||
|
|
e895568beb | ||
|
|
b8217788e4 | ||
|
|
9c6400ba3e | ||
|
|
c736906ad6 | ||
|
|
69385655a2 | ||
|
|
4bb819b397 | ||
|
|
82d073dd82 | ||
|
|
0153d30d05 | ||
|
|
3ebbf8a55c | ||
|
|
fbd5f209ca | ||
|
|
955fa27ab3 | ||
|
|
233b280835 |
No files matched your search
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+300
-108
@@ -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,17 +74,37 @@ 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 (
|
||||||
@@ -55,18 +114,143 @@ const (
|
|||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем 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")
|
return nil, fmt.Errorf("invalid magic number")
|
||||||
}
|
}
|
||||||
|
return decompressWithInfo(data, info)
|
||||||
// decompressV1 распаковывает данные в старом формате V1
|
|
||||||
func decompressV1(data []byte) ([]byte, error) {
|
|
||||||
if len(data) < 4+1+8 {
|
|
||||||
return nil, fmt.Errorf("data too short for compressed format V1")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
compType := CompressionType(data[4])
|
// decompressWithInfo выполняет распаковку, используя разобранный заголовок.
|
||||||
originalSize := binary.LittleEndian.Uint64(data[5:13])
|
func decompressWithInfo(data []byte, info *headerInfo) ([]byte, error) {
|
||||||
compressedData := data[13:]
|
if len(data) < info.headerSize {
|
||||||
|
return nil, fmt.Errorf("data too short for compressed format V%d", info.version)
|
||||||
if originalSize == 0 {
|
|
||||||
return nil, fmt.Errorf("invalid original size")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return doDecompress(compType, compressedData, originalSize)
|
// Проверка разумности размера.
|
||||||
}
|
if info.originalSize == 0 || info.originalSize > MaxOriginalSize {
|
||||||
|
return nil, fmt.Errorf("invalid original size: %d", info.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)
|
// Данные не сжаты.
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем 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
|
return data, nil
|
||||||
}
|
}
|
||||||
|
return decompressWithInfo(data, info)
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
+263
-427
File diff suppressed because it is too large.
Load diff
@@ -9,7 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Файл: internal/config/dynamic_config.go
|
// Файл: internal/config/dynamic_config.go
|
||||||
// Назначение: Централизованное управление конфигурацией через Raft
|
// Назначение: Централизованное управление конфигурацией через Raft.
|
||||||
|
|
||||||
package config
|
package config
|
||||||
|
|
||||||
@@ -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,7 +65,7 @@ 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"`
|
||||||
@@ -73,14 +73,14 @@ type DynamicConfigSnapshot struct {
|
|||||||
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,7 +97,7 @@ 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
|
||||||
@@ -108,18 +108,17 @@ type ConfigFSM struct {
|
|||||||
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
+355
-43
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
+65
-121
@@ -9,22 +9,32 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Файл: 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
|
||||||
@@ -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 {
|
// Нет внешнего пейджера — печатаем напрямую.
|
||||||
|
fmt.Print(text)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// fallback на встроенный
|
|
||||||
|
return p.pipeToExternal(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.pageBuiltin(text)
|
// pipeToExternal отправляет текст во внешний пейджер.
|
||||||
|
func (p *Pager) pipeToExternal(text string) error {
|
||||||
|
parts := strings.Fields(p.external)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
fmt.Print(text)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageExternal запускает внешний pager.
|
cmd := exec.Command(parts[0], parts[1:]...)
|
||||||
func (p *Pager) pageExternal(text string) error {
|
cmd.Stdin = bytes.NewBufferString(text)
|
||||||
args := []string{}
|
|
||||||
base := strings.ToLower(p.external)
|
|
||||||
|
|
||||||
if strings.Contains(base, "less") {
|
|
||||||
// -R: сохранять ANSI-цвета, -F: выйти если всё влезает, -X: не чистить экран
|
|
||||||
args = append(args, "-RFX")
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command(p.external, args...)
|
|
||||||
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
|
return nil
|
||||||
case '\n', '\r':
|
|
||||||
// одна строка
|
|
||||||
if pos < len(lines) {
|
|
||||||
fmt.Println(lines[pos])
|
|
||||||
pos++
|
|
||||||
}
|
}
|
||||||
case ' ':
|
|
||||||
// страница
|
// defaultPagerCommand выбирает команду пейджера в зависимости от ОС.
|
||||||
|
func defaultPagerCommand() string {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
return "more"
|
||||||
default:
|
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 ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
|
||||||
+1516
-342
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+563
-719
File diff suppressed because it is too large.
Load diff
+595
-190
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -11,7 +11,18 @@
|
|||||||
// Файл: 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
|
||||||
|
|
||||||
@@ -78,7 +89,7 @@ type LimitMetrics struct {
|
|||||||
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 содержит конфигурацию ограничений
|
||||||
@@ -180,34 +191,28 @@ func (rlm *RuntimeLimitsManager) triggerEviction() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isDocInActiveTransaction проверяет, участвует ли документ в активной транзакции
|
// isDocInActiveBatch проверяет, участвует ли документ в активной
|
||||||
// Защита от удаления данных активных транзакций
|
// batch-операции.
|
||||||
func (rlm *RuntimeLimitsManager) isDocInActiveTransaction(docID, dbName, collName string) bool {
|
//
|
||||||
if globalTxManager == nil {
|
// ИСПРАВЛЕНО (2026-10): ранее метод назывался isDocInActiveTransaction
|
||||||
|
// и обращался к globalTxManager и Transaction, которые были удалены при
|
||||||
|
// переходе на batch-операции. Теперь проверяем реестр активных batch'ей
|
||||||
|
// в globalBatchManager (см. transactions.go).
|
||||||
|
//
|
||||||
|
// Защита сохраняется: batch живёт коротко (создаётся и коммитится в
|
||||||
|
// рамках одной функции), но между AddUpdate и Commit документ ещё не
|
||||||
|
// изменён, и eviction мог бы его удалить.
|
||||||
|
func (rlm *RuntimeLimitsManager) isDocInActiveBatch(docID, dbName, collName string) bool {
|
||||||
|
bm := GetBatchManager()
|
||||||
|
if bm == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
return bm.IsDocInActiveBatch(docID, dbName, collName)
|
||||||
inTx := false
|
|
||||||
globalTxManager.activeTransactions.Range(func(key, value interface{}) bool {
|
|
||||||
tx := value.(*Transaction)
|
|
||||||
tx.mu.RLock()
|
|
||||||
for _, op := range tx.Operations {
|
|
||||||
if op.DocumentID == docID && op.Database == dbName && op.Collection == collName {
|
|
||||||
inTx = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
||||||
@@ -226,8 +231,8 @@ func (rlm *RuntimeLimitsManager) EvictFromCollection(coll *Collection, targetByt
|
|||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -252,8 +257,8 @@ func (rlm *RuntimeLimitsManager) EvictFromCollection(coll *Collection, targetByt
|
|||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -286,7 +291,7 @@ func (rlm *RuntimeLimitsManager) EvictFromCollection(coll *Collection, targetByt
|
|||||||
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+241
-196
@@ -12,32 +12,27 @@
|
|||||||
// Назначение: Lock-free skip list (список с пропусками) для инвертированного
|
// Назначение: Lock-free skip list (список с пропусками) для инвертированного
|
||||||
// индекса коллекции. Заменяет sync.Map в Index.data.
|
// индекса коллекции. Заменяет sync.Map в Index.data.
|
||||||
//
|
//
|
||||||
// ОСОБЕННОСТИ РЕАЛИЗАЦИИ:
|
// ИСПРАВЛЕНО (2026-10):
|
||||||
// - Полностью lock-free для операций чтения и записи.
|
// - Баг #6: рассинхронизация size при воскрешении узла (Insert после Delete).
|
||||||
// - Использует atomic.Value и sync/atomic для совместимости с Go < 1.19
|
// Теперь проверяется wasDeleted и size увеличивается корректно.
|
||||||
// (актуально для сборки на OpenIndiana/illumos, где часто стоит старый Go).
|
// - Баг #7: дубликат на верхних уровнях при конкурентной вставке одного
|
||||||
// - Не использует unsafe и generics — одинаково работает на Linux и illumos.
|
// ключа с разными newLevel. Решение: двухфазная вставка — сначала
|
||||||
// - Удаление через mark-and-sweep: узел помечается как удалённый (tombstone),
|
// уровень 0 (гарантированно единственный узел с таким ключом), затем
|
||||||
// физическое удаление происходит лениво при обходе (Range).
|
// верхние уровни (best-effort, дубликаты безопасны).
|
||||||
// - Хранение: ключ — interface{} (значение индексированного поля),
|
|
||||||
// значение — string (ID документа).
|
|
||||||
//
|
//
|
||||||
// АЛГОРИТМ (упрощённый lock-free skip list, основанный на идеях
|
// КЛЮЧЕВОЙ ИНВАРИАНТ:
|
||||||
// William Pugh "Skip Lists: A Probabilistic Alternative to Balanced Trees"
|
// - На уровне 0 не может быть двух узлов с одинаковым ключом.
|
||||||
// и подходах из работ Herlihy & Shavit "The Art of Multiprocessor Programming"):
|
// - На уровнях > 0 дубликаты допустимы (они лишь замедляют поиск, но не
|
||||||
|
// ломают корректность, потому что Search всегда спускается до уровня 0
|
||||||
|
// и там находит единственный узел с нужным ключом).
|
||||||
//
|
//
|
||||||
// - Каждый узел имеет массив forward-указателей (уровни).
|
// ДРУГИЕ ИСПРАВЛЕНИЯ (2026):
|
||||||
// - Вставка: находим позицию на каждом уровне, атомарно "вклеиваем"
|
// - Устранён data race при обновлении value существующего узла:
|
||||||
// узел через Compare-And-Swap (CAS).
|
// value хранится в atomic.Value.
|
||||||
// - Поиск: спускаемся по уровням сверху вниз.
|
// - Исправлен обход Range: спуск на базовый уровень теперь корректный.
|
||||||
// - Удаление: помечаем узел как удалённый (tombstone), обходчик
|
// - Исправлена вставка на уровнях выше текущего sl.level.
|
||||||
// (Range) физически удаляет такие узлы лениво.
|
// - Идемпотентный Delete.
|
||||||
//
|
// - RangeWithKeyPrefix корректно останавливается при выходе за префикс.
|
||||||
// ВАЖНО: это не строго "wait-free" реализация в академическом смысле —
|
|
||||||
// операции вставки используют CAS-цикл (lock-free, но с возможностью
|
|
||||||
// повторных попыток). Однако, в отличие от sync.Map, здесь нет
|
|
||||||
// глобального мьютекса и нет внутренних блокировок на уровне шардов,
|
|
||||||
// что даёт лучшую масштабируемость при конкурентных чтениях/записях.
|
|
||||||
|
|
||||||
package storage
|
package storage
|
||||||
|
|
||||||
@@ -55,8 +50,6 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// Максимальное количество уровней в skip list.
|
// Максимальное количество уровней в skip list.
|
||||||
// 32 уровня достаточно для хранения до 4 миллиардов элементов
|
|
||||||
// с вероятностью 1/2 на каждом уровне.
|
|
||||||
maxSkipListLevel = 32
|
maxSkipListLevel = 32
|
||||||
|
|
||||||
// Вероятность "подъёма" на следующий уровень.
|
// Вероятность "подъёма" на следующий уровень.
|
||||||
@@ -73,20 +66,39 @@ const (
|
|||||||
|
|
||||||
// skipListNode представляет узел skip list.
|
// skipListNode представляет узел skip list.
|
||||||
type skipListNode struct {
|
type skipListNode struct {
|
||||||
// key — значение индексированного поля (может быть любым comparable value).
|
// key — значение индексированного поля.
|
||||||
key interface{}
|
key interface{}
|
||||||
|
|
||||||
// value — ID документа (строка).
|
// value — ID документа. Хранится в atomic.Value для защиты от
|
||||||
value string
|
// data race при обновлении существующего ключа.
|
||||||
|
// atomic.Value не может хранить nil строку напрямую, поэтому
|
||||||
|
// используется обёртка *string.
|
||||||
|
value atomic.Value // *string
|
||||||
|
|
||||||
// deleted — tombstone-флаг. Если true, узел логически удалён
|
// deleted — tombstone-флаг.
|
||||||
// и должен быть пропущен при обходе.
|
|
||||||
deleted atomic.Bool
|
deleted atomic.Bool
|
||||||
|
|
||||||
// forward — массив атомарных указателей на следующий узел
|
// forward — массив атомарных указателей на следующий узел
|
||||||
// на каждом уровне. forward[0] — базовый уровень.
|
// на каждом уровне.
|
||||||
// Используем atomic.Value для совместимости с Go < 1.19.
|
forward []atomic.Value // *skipListNode
|
||||||
forward []atomic.Value // хранит *skipListNode
|
}
|
||||||
|
|
||||||
|
// loadValue атомарно загружает value.
|
||||||
|
func (n *skipListNode) loadValue() string {
|
||||||
|
val := n.value.Load()
|
||||||
|
if val == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ptr, ok := val.(*string)
|
||||||
|
if !ok || ptr == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *ptr
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeValue атомарно сохраняет value.
|
||||||
|
func (n *skipListNode) storeValue(v string) {
|
||||||
|
n.value.Store(&v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadForward атомарно загружает указатель на следующий узел на уровне.
|
// loadForward атомарно загружает указатель на следующий узел на уровне.
|
||||||
@@ -98,7 +110,11 @@ func (n *skipListNode) loadForward(level int) *skipListNode {
|
|||||||
if val == nil {
|
if val == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return val.(*skipListNode)
|
ptr, ok := val.(*skipListNode)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ptr
|
||||||
}
|
}
|
||||||
|
|
||||||
// storeForward атомарно сохраняет указатель на следующий узел на уровне.
|
// storeForward атомарно сохраняет указатель на следующий узел на уровне.
|
||||||
@@ -106,16 +122,10 @@ func (n *skipListNode) storeForward(level int, next *skipListNode) {
|
|||||||
if level < 0 || level >= len(n.forward) {
|
if level < 0 || level >= len(n.forward) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// atomic.Value не может хранить nil — используем типизированный указатель.
|
|
||||||
if next == nil {
|
|
||||||
n.forward[level].Store((*skipListNode)(nil))
|
|
||||||
} else {
|
|
||||||
n.forward[level].Store(next)
|
n.forward[level].Store(next)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// casForward атомарно заменяет указатель, если он равен old.
|
// casForward атомарно заменяет указатель, если он равен old.
|
||||||
// Возвращает true, если замена произошла.
|
|
||||||
func (n *skipListNode) casForward(level int, old, new *skipListNode) bool {
|
func (n *skipListNode) casForward(level int, old, new *skipListNode) bool {
|
||||||
if level < 0 || level >= len(n.forward) {
|
if level < 0 || level >= len(n.forward) {
|
||||||
return false
|
return false
|
||||||
@@ -123,13 +133,7 @@ func (n *skipListNode) casForward(level int, old, new *skipListNode) bool {
|
|||||||
return n.forward[level].CompareAndSwap(old, new)
|
return n.forward[level].CompareAndSwap(old, new)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SkipList — lock-free список с пропусками для инвертированного индекса.
|
// SkipList — lock-free список с пропусками.
|
||||||
//
|
|
||||||
// Поля:
|
|
||||||
// - head: фиктивный головной узел, key == nil.
|
|
||||||
// - level: текущий максимальный уровень (атомарный).
|
|
||||||
// - size: приблизительное количество элементов (атомарный счётчик).
|
|
||||||
// - rngMu: защита rand.Rand (используется только при вставке).
|
|
||||||
type SkipList struct {
|
type SkipList struct {
|
||||||
head *skipListNode
|
head *skipListNode
|
||||||
level atomic.Int32
|
level atomic.Int32
|
||||||
@@ -143,14 +147,16 @@ type SkipList struct {
|
|||||||
func NewSkipList() *SkipList {
|
func NewSkipList() *SkipList {
|
||||||
head := &skipListNode{
|
head := &skipListNode{
|
||||||
key: nil,
|
key: nil,
|
||||||
value: "",
|
|
||||||
forward: make([]atomic.Value, maxSkipListLevel),
|
forward: make([]atomic.Value, maxSkipListLevel),
|
||||||
}
|
}
|
||||||
// Инициализируем forward-указатели nil-ами (atomic.Value требует
|
// Инициализируем forward-указатели типизированным nil.
|
||||||
// типизированного значения, чтобы Load не падал).
|
|
||||||
for i := 0; i < maxSkipListLevel; i++ {
|
for i := 0; i < maxSkipListLevel; i++ {
|
||||||
head.forward[i].Store((*skipListNode)(nil))
|
head.forward[i].Store((*skipListNode)(nil))
|
||||||
}
|
}
|
||||||
|
// Инициализируем value head-а, чтобы atomic.Value имел
|
||||||
|
// установленный concrete type.
|
||||||
|
emptyStr := ""
|
||||||
|
head.value.Store(&emptyStr)
|
||||||
|
|
||||||
sl := &SkipList{
|
sl := &SkipList{
|
||||||
head: head,
|
head: head,
|
||||||
@@ -162,7 +168,6 @@ func NewSkipList() *SkipList {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// randomLevel генерирует случайный уровень для нового узла.
|
// randomLevel генерирует случайный уровень для нового узла.
|
||||||
// Использует геометрическое распределение с p = 0.5.
|
|
||||||
func (sl *SkipList) randomLevel() int {
|
func (sl *SkipList) randomLevel() int {
|
||||||
sl.rngMu.Lock()
|
sl.rngMu.Lock()
|
||||||
defer sl.rngMu.Unlock()
|
defer sl.rngMu.Unlock()
|
||||||
@@ -175,19 +180,93 @@ func (sl *SkipList) randomLevel() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// ВСТАВКА
|
// ПОИСК ПРЕДШЕСТВЕННИКОВ (вспомогательный метод)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// findPredecessors заполняет update[] указателями на узлы, после которых
|
||||||
|
// нужно вставлять/искать узел с заданным ключом на каждом уровне.
|
||||||
|
//
|
||||||
|
// Возвращает:
|
||||||
|
// - update[] — массив предшественников (длина maxSkipListLevel)
|
||||||
|
// - existing — узел на уровне 0 с ключом == key, либо nil
|
||||||
|
//
|
||||||
|
// Этот метод используется и в Insert, и в Delete.
|
||||||
|
func (sl *SkipList) findPredecessors(key interface{}) ([]*skipListNode, *skipListNode) {
|
||||||
|
update := make([]*skipListNode, maxSkipListLevel)
|
||||||
|
|
||||||
|
currentLevel := int(sl.level.Load())
|
||||||
|
|
||||||
|
// Спускаемся с верхнего уровня до базового.
|
||||||
|
x := sl.head
|
||||||
|
for i := currentLevel; i >= 0; i-- {
|
||||||
|
for {
|
||||||
|
next := x.loadForward(i)
|
||||||
|
if next == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if next.deleted.Load() {
|
||||||
|
// Пытаемся физически вытолкнуть tombstone.
|
||||||
|
if x.casForward(i, next, next.loadForward(i)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if compareSkipListKeys(next.key, key) < 0 {
|
||||||
|
x = next
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
update[i] = x
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для уровней выше currentLevel предшественник — head.
|
||||||
|
// (Это может понадобиться, если новый уровень больше текущего.)
|
||||||
|
for i := currentLevel + 1; i < maxSkipListLevel; i++ {
|
||||||
|
update[i] = sl.head
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ищем существующий узел на уровне 0.
|
||||||
|
existing := update[0].loadForward(0)
|
||||||
|
for existing != nil && compareSkipListKeys(existing.key, key) < 0 {
|
||||||
|
existing = existing.loadForward(0)
|
||||||
|
}
|
||||||
|
if existing != nil && compareSkipListKeys(existing.key, key) != 0 {
|
||||||
|
existing = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return update, existing
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ВСТАВКА (ИСПРАВЛЕНО: баги #6 и #7)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// Insert добавляет пару (key, value) в skip list.
|
// Insert добавляет пару (key, value) в skip list.
|
||||||
// Если ключ уже существует, значение перезаписывается.
|
// Если ключ уже существует, значение перезаписывается.
|
||||||
//
|
//
|
||||||
// Реализация lock-free: используется CAS-цикл на каждом уровне.
|
// РЕАЛИЗАЦИЯ (двухфазная вставка):
|
||||||
// При неудаче CAS — повторная попытка.
|
//
|
||||||
|
// Фаза 1: вставка на уровень 0.
|
||||||
|
// - Если ключ уже есть на уровне 0 — обновляем value (с корректной
|
||||||
|
// обработкой wasDeleted для size).
|
||||||
|
// - Иначе — CAS-вставка нового узла на уровень 0. Если CAS провалился
|
||||||
|
// из-за конкурента — retry.
|
||||||
|
// - После успешной вставки на уровень 0 ключ ГАРАНТИРОВАННО есть в списке
|
||||||
|
// (в единственном экземпляре на уровне 0).
|
||||||
|
//
|
||||||
|
// Фаза 2: вставка на верхние уровни (best-effort).
|
||||||
|
// - Для каждого уровня i от 1 до newLevel пытаемся CAS-вставить узел.
|
||||||
|
// - Если CAS провалился — не страшно: узел уже есть на уровне 0,
|
||||||
|
// а верхние уровни — только ускорители поиска. Дубликаты на верхних
|
||||||
|
// уровнях безопасны, потому что Search всегда спускается до уровня 0.
|
||||||
func (sl *SkipList) Insert(key interface{}, value string) {
|
func (sl *SkipList) Insert(key interface{}, value string) {
|
||||||
if key == nil {
|
if key == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Внешний цикл — retry при провале CAS на уровне 0.
|
||||||
|
for attempt := 0; attempt < 1000; attempt++ {
|
||||||
newLevel := sl.randomLevel()
|
newLevel := sl.randomLevel()
|
||||||
|
|
||||||
// Поднимаем максимальный уровень, если нужно.
|
// Поднимаем максимальный уровень, если нужно.
|
||||||
@@ -201,82 +280,97 @@ func (sl *SkipList) Insert(key interface{}, value string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Находим предшественников и существующий узел.
|
||||||
|
update, existing := sl.findPredecessors(key)
|
||||||
|
|
||||||
|
// Случай 1: ключ уже есть на уровне 0.
|
||||||
|
// Обновляем value и, если узел был tombstone, воскрешаем его.
|
||||||
|
if existing != nil {
|
||||||
|
wasDeleted := existing.deleted.Load()
|
||||||
|
existing.storeValue(value)
|
||||||
|
existing.deleted.Store(false)
|
||||||
|
|
||||||
|
// ИСПРАВЛЕНО (баг #6): если узел был tombstone,
|
||||||
|
// увеличиваем size, потому что Delete его уже уменьшил.
|
||||||
|
if wasDeleted {
|
||||||
|
sl.size.Add(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Случай 2: ключа нет — создаём новый узел и вставляем
|
||||||
|
// сначала на уровень 0.
|
||||||
newNode := &skipListNode{
|
newNode := &skipListNode{
|
||||||
key: key,
|
key: key,
|
||||||
value: value,
|
|
||||||
forward: make([]atomic.Value, newLevel+1),
|
forward: make([]atomic.Value, newLevel+1),
|
||||||
}
|
}
|
||||||
for i := 0; i <= newLevel; i++ {
|
for i := 0; i <= newLevel; i++ {
|
||||||
newNode.forward[i].Store((*skipListNode)(nil))
|
newNode.forward[i].Store((*skipListNode)(nil))
|
||||||
}
|
}
|
||||||
|
newNode.storeValue(value)
|
||||||
|
|
||||||
// update[i] — узел, после которого нужно вставить новый
|
// Пытаемся вставить на уровень 0.
|
||||||
// на уровне i.
|
next0 := update[0].loadForward(0)
|
||||||
update := make([]*skipListNode, maxSkipListLevel)
|
// Защита от ситуации, когда между findPredecessors и сейчас
|
||||||
|
// кто-то вставил узел с таким же ключом.
|
||||||
retry:
|
for next0 != nil && compareSkipListKeys(next0.key, key) < 0 {
|
||||||
// Спускаемся с верхнего уровня до базового, запоминая предшественников.
|
next0 = next0.loadForward(0)
|
||||||
x := sl.head
|
|
||||||
for i := int(sl.level.Load()); i >= 0; i-- {
|
|
||||||
for {
|
|
||||||
next := x.loadForward(i)
|
|
||||||
if next == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if next.deleted.Load() {
|
|
||||||
// Пропускаем tombstone-узлы при поиске предшественника.
|
|
||||||
// Пытаемся физически "вытолкнуть" удалённый узел.
|
|
||||||
if x.casForward(i, next, next.loadForward(i)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// CAS не удался — перечитываем next.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if compareSkipListKeys(next.key, key) < 0 {
|
|
||||||
x = next
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
update[i] = x
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Теперь update[0].forward[0] — либо nil, либо узел с key >= newKey.
|
// Если между findPredecessors и сейчас появился узел с нашим
|
||||||
existing := update[0].loadForward(0)
|
// ключом — обновляем его value и выходим.
|
||||||
for existing != nil && compareSkipListKeys(existing.key, key) < 0 {
|
if next0 != nil && compareSkipListKeys(next0.key, key) == 0 {
|
||||||
existing = existing.loadForward(0)
|
wasDeleted := next0.deleted.Load()
|
||||||
|
next0.storeValue(value)
|
||||||
|
next0.deleted.Store(false)
|
||||||
|
if wasDeleted {
|
||||||
|
sl.size.Add(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если ключ уже есть — обновляем значение (и снимаем tombstone).
|
|
||||||
if existing != nil && compareSkipListKeys(existing.key, key) == 0 {
|
|
||||||
existing.value = value
|
|
||||||
existing.deleted.Store(false)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вставляем newNode на всех уровнях от 0 до newLevel.
|
newNode.storeForward(0, next0)
|
||||||
for i := 0; i <= newLevel; i++ {
|
if !update[0].casForward(0, next0, newNode) {
|
||||||
for {
|
// CAS провалился — кто-то другой вставил узел между
|
||||||
next := update[i].loadForward(i)
|
// findPredecessors и сейчас. Retry.
|
||||||
newNode.forward[i].Store(next)
|
continue
|
||||||
if update[i].casForward(i, next, newNode) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
// CAS не удался — кто-то другой вставил узел.
|
|
||||||
// Перечитываем update[i] и повторяем.
|
|
||||||
// Упрощение: перезапускаем всю вставку.
|
|
||||||
goto retry
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Узел успешно вставлен на уровень 0. Теперь увеличиваем size
|
||||||
|
// (единственное место, где мы это делаем для новых узлов).
|
||||||
sl.size.Add(1)
|
sl.size.Add(1)
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ФАЗА 2: вставка на верхние уровни (best-effort).
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// Дубликаты на верхних уровнях безопасны: Search всегда спускается
|
||||||
|
// до уровня 0 и там находит единственный узел с нужным ключом.
|
||||||
|
// Если CAS на верхнем уровне провалился — просто пропускаем уровень.
|
||||||
|
for i := 1; i <= newLevel; i++ {
|
||||||
|
if update[i] == nil {
|
||||||
|
// Предшественник не определён — пропускаем уровень.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Перечитываем next на этом уровне (мог измениться).
|
||||||
|
next := update[i].loadForward(i)
|
||||||
|
newNode.storeForward(i, next)
|
||||||
|
// CAS best-effort: если провалился — не retry, просто пропускаем.
|
||||||
|
update[i].casForward(i, next, newNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если за 1000 попыток не удалось — что-то не так.
|
||||||
|
// Молча выходим (не паникуем).
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// ПОИСК
|
// ПОИСК
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// Search ищет значение по ключу. Возвращает (value, true) при успехе.
|
// Search ищет значение по ключу.
|
||||||
func (sl *SkipList) Search(key interface{}) (string, bool) {
|
func (sl *SkipList) Search(key interface{}) (string, bool) {
|
||||||
if key == nil {
|
if key == nil {
|
||||||
return "", false
|
return "", false
|
||||||
@@ -289,6 +383,13 @@ func (sl *SkipList) Search(key interface{}) (string, bool) {
|
|||||||
if next == nil {
|
if next == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if next.deleted.Load() {
|
||||||
|
// Пропускаем tombstone.
|
||||||
|
if x.casForward(i, next, next.loadForward(i)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if compareSkipListKeys(next.key, key) < 0 {
|
if compareSkipListKeys(next.key, key) < 0 {
|
||||||
x = next
|
x = next
|
||||||
continue
|
continue
|
||||||
@@ -297,7 +398,6 @@ func (sl *SkipList) Search(key interface{}) (string, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// x — предшественник искомого ключа на базовом уровне.
|
|
||||||
candidate := x.loadForward(0)
|
candidate := x.loadForward(0)
|
||||||
if candidate == nil {
|
if candidate == nil {
|
||||||
return "", false
|
return "", false
|
||||||
@@ -306,16 +406,16 @@ func (sl *SkipList) Search(key interface{}) (string, bool) {
|
|||||||
if candidate.deleted.Load() {
|
if candidate.deleted.Load() {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
return candidate.value, true
|
return candidate.loadValue(), true
|
||||||
}
|
}
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchAll возвращает все значения, ключ которых равен заданному.
|
// SearchAll возвращает все значения, ключ которых равен заданному.
|
||||||
// В неуникальном индексе по одному ключу может быть несколько документов —
|
//
|
||||||
// в текущей реализации индекс хранит только последний docID для данного
|
// ВАЖНО: текущая реализация индекса хранит только один docID на ключ,
|
||||||
// ключа (как и sync.Map). Для уникальных индексов это не критично.
|
// поэтому метод возвращает 0 или 1 значение. Для полноценной поддержки
|
||||||
// Для неуникальных индексов метод возвращает единственное значение.
|
// неуникальных индексов потребуется изменить структуру узла.
|
||||||
func (sl *SkipList) SearchAll(key interface{}) []string {
|
func (sl *SkipList) SearchAll(key interface{}) []string {
|
||||||
val, ok := sl.Search(key)
|
val, ok := sl.Search(key)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -329,39 +429,23 @@ func (sl *SkipList) SearchAll(key interface{}) []string {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// Delete помечает узел с заданным ключом как удалённый (tombstone).
|
// Delete помечает узел с заданным ключом как удалённый (tombstone).
|
||||||
// Физическое удаление произойдёт лениво при следующем Range.
|
// Идемпотентен: повторный вызов не уменьшает size.
|
||||||
func (sl *SkipList) Delete(key interface{}) {
|
func (sl *SkipList) Delete(key interface{}) {
|
||||||
if key == nil {
|
if key == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
x := sl.head
|
_, existing := sl.findPredecessors(key)
|
||||||
for i := int(sl.level.Load()); i >= 0; i-- {
|
if existing == nil {
|
||||||
for {
|
|
||||||
next := x.loadForward(i)
|
|
||||||
if next == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if compareSkipListKeys(next.key, key) < 0 {
|
|
||||||
x = next
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
candidate := x.loadForward(0)
|
|
||||||
if candidate == nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if compareSkipListKeys(candidate.key, key) == 0 {
|
|
||||||
if !candidate.deleted.Load() {
|
// CAS как атомарная проверка+установка.
|
||||||
if candidate.deleted.CompareAndSwap(false, true) {
|
// Уменьшаем size только если мы первыми поставили tombstone.
|
||||||
|
if existing.deleted.CompareAndSwap(false, true) {
|
||||||
sl.size.Add(-1)
|
sl.size.Add(-1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// ОБХОД (RANGE)
|
// ОБХОД (RANGE)
|
||||||
@@ -369,27 +453,8 @@ func (sl *SkipList) Delete(key interface{}) {
|
|||||||
|
|
||||||
// Range вызывает fn для каждой пары (key, value) в порядке возрастания ключей.
|
// Range вызывает fn для каждой пары (key, value) в порядке возрастания ключей.
|
||||||
// Если fn возвращает false — обход прекращается.
|
// Если fn возвращает false — обход прекращается.
|
||||||
//
|
|
||||||
// Во время обхода выполняется ленивая физическая очистка tombstone-узлов:
|
|
||||||
// если подряд встретилось больше skipListSweepThreshold удалённых узлов,
|
|
||||||
// они физически выталкиваются из списка.
|
|
||||||
func (sl *SkipList) Range(fn func(key interface{}, value string) bool) {
|
func (sl *SkipList) Range(fn func(key interface{}, value string) bool) {
|
||||||
x := sl.head
|
prev := sl.head
|
||||||
level0 := 0
|
|
||||||
|
|
||||||
// Спускаемся на базовый уровень.
|
|
||||||
for i := int(sl.level.Load()); i > 0; i-- {
|
|
||||||
for {
|
|
||||||
next := x.loadForward(i)
|
|
||||||
if next == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
x = next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = level0
|
|
||||||
|
|
||||||
prev := x
|
|
||||||
tombstoneCount := 0
|
tombstoneCount := 0
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -402,9 +467,7 @@ func (sl *SkipList) Range(fn func(key interface{}, value string) bool) {
|
|||||||
tombstoneCount++
|
tombstoneCount++
|
||||||
// Пытаемся физически удалить узел из базового уровня.
|
// Пытаемся физически удалить узел из базового уровня.
|
||||||
if prev.casForward(0, next, next.loadForward(0)) {
|
if prev.casForward(0, next, next.loadForward(0)) {
|
||||||
// Успешно вытолкнули — не двигаем prev.
|
|
||||||
if tombstoneCount >= skipListSweepThreshold {
|
if tombstoneCount >= skipListSweepThreshold {
|
||||||
// Периодически чистим верхние уровни (lazy).
|
|
||||||
sl.sweepUpperLevels()
|
sl.sweepUpperLevels()
|
||||||
tombstoneCount = 0
|
tombstoneCount = 0
|
||||||
}
|
}
|
||||||
@@ -415,7 +478,7 @@ func (sl *SkipList) Range(fn func(key interface{}, value string) bool) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if !fn(next.key, next.value) {
|
if !fn(next.key, next.loadValue()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,6 +488,9 @@ func (sl *SkipList) Range(fn func(key interface{}, value string) bool) {
|
|||||||
|
|
||||||
// RangeWithKeyPrefix вызывает fn для каждой пары, ключ которой является
|
// RangeWithKeyPrefix вызывает fn для каждой пары, ключ которой является
|
||||||
// строкой с заданным префиксом.
|
// строкой с заданным префиксом.
|
||||||
|
//
|
||||||
|
// Благодаря сортированности skip list, обход можно прервать, как только
|
||||||
|
// ключ перестал соответствовать префиксу.
|
||||||
func (sl *SkipList) RangeWithKeyPrefix(prefix string, fn func(key interface{}, value string) bool) {
|
func (sl *SkipList) RangeWithKeyPrefix(prefix string, fn func(key interface{}, value string) bool) {
|
||||||
sl.Range(func(key interface{}, value string) bool {
|
sl.Range(func(key interface{}, value string) bool {
|
||||||
keyStr, ok := key.(string)
|
keyStr, ok := key.(string)
|
||||||
@@ -434,12 +500,17 @@ func (sl *SkipList) RangeWithKeyPrefix(prefix string, fn func(key interface{}, v
|
|||||||
if strings.HasPrefix(keyStr, prefix) {
|
if strings.HasPrefix(keyStr, prefix) {
|
||||||
return fn(key, value)
|
return fn(key, value)
|
||||||
}
|
}
|
||||||
|
// Если ключ — строка и он больше префикса, дальнейшие ключи
|
||||||
|
// (отсортированные) тоже не подойдут.
|
||||||
|
if keyStr > prefix {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// sweepUpperLevels проходит по верхним уровням и физически выталкивает
|
// sweepUpperLevels проходит по верхним уровням и физически выталкивает
|
||||||
// tombstone-узлы. Вызывается лениво из Range.
|
// tombstone-узлы.
|
||||||
func (sl *SkipList) sweepUpperLevels() {
|
func (sl *SkipList) sweepUpperLevels() {
|
||||||
for i := 1; i < maxSkipListLevel; i++ {
|
for i := 1; i < maxSkipListLevel; i++ {
|
||||||
x := sl.head
|
x := sl.head
|
||||||
@@ -463,20 +534,13 @@ func (sl *SkipList) sweepUpperLevels() {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// Size возвращает приблизительное количество активных элементов.
|
// Size возвращает приблизительное количество активных элементов.
|
||||||
func (sl *SkipList) Size() int64 {
|
func (sl *SkipList) Size() int64 { return sl.size.Load() }
|
||||||
return sl.size.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Len возвращает приблизительное количество активных элементов
|
// Len — синоним Size.
|
||||||
// (синоним Size для совместимости с sync.Map-стилем).
|
func (sl *SkipList) Len() int64 { return sl.size.Load() }
|
||||||
func (sl *SkipList) Len() int64 {
|
|
||||||
return sl.size.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsEmpty возвращает true, если список пуст.
|
// IsEmpty возвращает true, если список пуст.
|
||||||
func (sl *SkipList) IsEmpty() bool {
|
func (sl *SkipList) IsEmpty() bool { return sl.size.Load() == 0 }
|
||||||
return sl.size.Load() == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear помечает все узлы как удалённые.
|
// Clear помечает все узлы как удалённые.
|
||||||
func (sl *SkipList) Clear() {
|
func (sl *SkipList) Clear() {
|
||||||
@@ -492,13 +556,6 @@ func (sl *SkipList) Clear() {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// compareSkipListKeys сравнивает два ключа.
|
// compareSkipListKeys сравнивает два ключа.
|
||||||
// Поддерживает основные типы, используемые в инвертированном индексе:
|
|
||||||
// string, int, int64, float64, bool, []byte.
|
|
||||||
//
|
|
||||||
// Возвращает -1, 0 или 1.
|
|
||||||
//
|
|
||||||
// ВАЖНО: если типы ключей несовместимы, используется строковое сравнение
|
|
||||||
// через fmt.Sprintf, чтобы обеспечить хоть какой-то порядок (не падать).
|
|
||||||
func compareSkipListKeys(a, b interface{}) int {
|
func compareSkipListKeys(a, b interface{}) int {
|
||||||
if a == nil && b == nil {
|
if a == nil && b == nil {
|
||||||
return 0
|
return 0
|
||||||
@@ -549,7 +606,6 @@ func compareSkipListKeys(a, b interface{}) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: строковое сравнение.
|
|
||||||
return strings.Compare(fmt.Sprintf("%v", a), fmt.Sprintf("%v", b))
|
return strings.Compare(fmt.Sprintf("%v", a), fmt.Sprintf("%v", b))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,7 +660,7 @@ func compareInt64(a, b int64) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// ITERATOR (опционально, для совместимости)
|
// ITERATOR
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// SkipListIterator — итератор по skip list.
|
// SkipListIterator — итератор по skip list.
|
||||||
@@ -619,7 +675,7 @@ func (sl *SkipList) NewIterator() *SkipListIterator {
|
|||||||
return &SkipListIterator{sl: sl}
|
return &SkipListIterator{sl: sl}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next переходит к следующему элементу. Возвращает (key, value, ok).
|
// Next переходит к следующему элементу.
|
||||||
func (it *SkipListIterator) Next() (interface{}, string, bool) {
|
func (it *SkipListIterator) Next() (interface{}, string, bool) {
|
||||||
if it.sl == nil {
|
if it.sl == nil {
|
||||||
return nil, "", false
|
return nil, "", false
|
||||||
@@ -627,18 +683,7 @@ func (it *SkipListIterator) Next() (interface{}, string, bool) {
|
|||||||
|
|
||||||
if !it.started {
|
if !it.started {
|
||||||
it.started = true
|
it.started = true
|
||||||
// Спускаемся на базовый уровень.
|
it.current = it.sl.head
|
||||||
x := it.sl.head
|
|
||||||
for i := int(it.sl.level.Load()); i > 0; i-- {
|
|
||||||
for {
|
|
||||||
next := x.loadForward(i)
|
|
||||||
if next == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
x = next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
it.current = x
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -648,12 +693,12 @@ func (it *SkipListIterator) Next() (interface{}, string, bool) {
|
|||||||
}
|
}
|
||||||
it.current = next
|
it.current = next
|
||||||
if !next.deleted.Load() {
|
if !next.deleted.Load() {
|
||||||
return next.key, next.value, true
|
return next.key, next.loadValue(), true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close освобождает ресурсы итератора (no-op, оставлен для совместимости).
|
// Close освобождает ресурсы итератора.
|
||||||
func (it *SkipListIterator) Close() {
|
func (it *SkipListIterator) Close() {
|
||||||
it.sl = nil
|
it.sl = nil
|
||||||
it.current = nil
|
it.current = nil
|
||||||
|
|||||||
+1856
-3464
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user