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 |
No files matched your search
@@ -75,7 +75,8 @@ futriix — это лёгкая распределённая NoSQL‑СУБД н
|
|||||||
Ключевые механизмы надёжности и масштабируемости:
|
Ключевые механизмы надёжности и масштабируемости:
|
||||||
|
|
||||||
* WAL — устойчивость к сбоям
|
* WAL — устойчивость к сбоям
|
||||||
* SAGA- для внешних batch
|
* Batch — атомарные группы операций
|
||||||
|
* SAGA — для внешних batch
|
||||||
* Горизонтальное масштабирование
|
* Горизонтальное масштабирование
|
||||||
|
|
||||||
|
|
||||||
@@ -227,7 +228,7 @@ futriix — это лёгкая распределённая NoSQL‑СУБД н
|
|||||||
| **Документ (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,
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|
||||||
@@ -944,19 +946,17 @@ futriix:~> db.startSession()
|
|||||||
|
|
||||||
## Batch
|
## Batch
|
||||||
|
|
||||||
В СУБД **futriix** для обеспечения надёжности (durability), атомарности и быстрого восстановления базы данных реализована двухуровневая система журналирования:
|
В СУБД **futriix** для обеспечения надёжности (durability), атомарности и быстрого восстановления базы данных реализована система журналирования на основе **WAL (Write-Ahead Log)**.
|
||||||
|
|
||||||
1. **WAL (Write-Ahead Log)** — сегментированный журнал предзаписи для **batch-операций** (атомарных групп изменений). По умолчанию хранится в каталоге `futriix/wal_segments/`.
|
Все мутации — как групповые (batch), так и одиночные (прямые вызовы `Insert`, `Update`, `Delete`, `Restore` из REPL, HTTP-API и Lua-плагинов) — проходят через WAL. Это даёт:
|
||||||
2. **AOF (Append-Only File)** — журнал дозаписи для **одиночных мутаций**, выполненных вне batch (прямые вызовы `Insert`, `Update`, `Delete`, `Restore`, а также операции из REPL, HTTP-API и Lua-плагинов). По умолчанию хранится в каталоге `futriix/aof/`.
|
|
||||||
|
|
||||||
Такое разделение даёт:
|
|
||||||
- **Атомарность** для сложных составных операций (batch).
|
- **Атомарность** для сложных составных операций (batch).
|
||||||
- **Гарантированную сохранность** каждой отдельной мутации (AOF).
|
- **Гарантированную сохранность** каждой отдельной мутации.
|
||||||
- **Быстрое восстановление** через воспроизведение обоих журналов при старте.
|
- **Быстрое восстановление** через воспроизведение WAL при старте.
|
||||||
- **Разделение нагрузки**: batch-операции пишутся в WAL сегментами по 64 МБ, одиночные мутации — в AOF с ротацией по 64 МБ.
|
- **Единый источник истины** — один журнал, один fsync-путь, один формат хранения.
|
||||||
|
|
||||||
|
|
||||||
##№ Что такое Batch
|
### Что такое Batch
|
||||||
|
|
||||||
**Batch** — это группа операций, применяемых **атомарно**: либо все операции из группы применяются, либо ни одна. Batch заменяет полноценные ACID-транзакции в локальном узле и работает по принципу «всё или ничего» через WAL.
|
**Batch** — это группа операций, применяемых **атомарно**: либо все операции из группы применяются, либо ни одна. Batch заменяет полноценные ACID-транзакции в локальном узле и работает по принципу «всё или ничего» через WAL.
|
||||||
|
|
||||||
@@ -966,7 +966,6 @@ futriix:~> db.startSession()
|
|||||||
- **Откат при ошибке применения**: если операция падает на середине применения, уже применённые операции откатываются (best-effort) — полная атомарность гарантируется на уровне WAL при восстановлении.
|
- **Откат при ошибке применения**: если операция падает на середине применения, уже применённые операции откатываются (best-effort) — полная атомарность гарантируется на уровне WAL при восстановлении.
|
||||||
- **Короткое время жизни**: batch создаётся, наполняется операциями и коммитится в рамках одной логической операции. Не поддерживает savepoints, таймауты, распределённую координацию.
|
- **Короткое время жизни**: batch создаётся, наполняется операциями и коммитится в рамках одной логической операции. Не поддерживает savepoints, таймауты, распределённую координацию.
|
||||||
- **Реестр активных batch'ей**: `BatchManager.activeBatches` (см. `runtime_limits.go`) защищает документы, участвующие в незакоммиченном batch, от вытеснения (eviction) при нехватке памяти.
|
- **Реестр активных batch'ей**: `BatchManager.activeBatches` (см. `runtime_limits.go`) защищает документы, участвующие в незакоммиченном batch, от вытеснения (eviction) при нехватке памяти.
|
||||||
- **AOF-дублирование**: после коммита batch также пишется в AOF для ускорения восстановления.
|
|
||||||
|
|
||||||
**Операции batch:**
|
**Операции batch:**
|
||||||
|
|
||||||
@@ -980,27 +979,19 @@ futriix:~> db.startSession()
|
|||||||
| `batch.Commit()` | Применить batch атомарно через WAL |
|
| `batch.Commit()` | Применить batch атомарно через WAL |
|
||||||
|
|
||||||
|
|
||||||
### Что такое AOF
|
### Как WAL обеспечивает durability
|
||||||
|
|
||||||
**AOF (Append-Only File)** — журнал дозаписи, который логирует **каждую отдельную мутацию**, выполненную вне batch. Это нужно, потому что прямые вызовы `coll.Insert()`, `coll.Update()`, `coll.Delete()` идут мимо WAL, и без AOF такие изменения могли бы потеряться при сбое.
|
**WAL (Write-Ahead Log)** — сегментированный журнал предзаписи, который является **единственным источником истины** для durability. Каждая мутация сначала попадает в WAL, синхронизируется на диск, и только потом применяется к in-memory структурам.
|
||||||
|
|
||||||
**Ключевые свойства AOF:**
|
**Ключевые свойства WAL:**
|
||||||
|
|
||||||
- **Формат**: JSON-lines (по одной JSON-записи на строку) с CRC32 для верификации целостности.
|
- **Сегментация**: файлы `wal_segment_<N>.log` разбиваются по 64 МБ с автоматической ротацией.
|
||||||
- **Ротация**: файлы разбиваются по размеру 64 МБ, имена вида `aof_<N>.log`.
|
- **fsync на запись**: каждая запись (или группа записей в рамках `flushBatch`) синхронизируется с диском через `file.Sync()`.
|
||||||
- **fsync**: каждая запись синхронизируется с диском через `file.Sync()` для гарантии сохранности.
|
- **CRC32 per record**: каждая запись защищена контрольной суммой, включающей LSN — обнаружение частичных/torn-записей при восстановлении.
|
||||||
- **Восстановление при старте**: `ReplayAOF(storage)` читает все файлы и применяет операции к хранилищу.
|
- **WAL Index**: индекс `wal_index_<N>.json` для быстрого поиска записей по LSN.
|
||||||
- **Идемпотентность**: повторное применение операции не ломает состояние (insert/update/delete идемпотентны по ключу).
|
- **Pipeline записи**: группировка нескольких команд в один fsync-цикл (`flushBatch`) для снижения накладных расходов.
|
||||||
- **Автоматическое логирование**: методы `Collection.Insert`, `Update`, `Delete`, `PermanentDelete`, `RestoreDeleted` вызывают `LogAOFInsert`, `LogAOFUpdate`, `LogAOFDelete`, `LogAOFRestore` соответственно.
|
- **Восстановление при старте**: `ReadAll()` читает все сегменты, применяет операции к хранилищу; это единственный путь восстановления.
|
||||||
|
- **Идемпотентность**: повторное применение операции не ломает состояние.
|
||||||
**Типы записей AOF:**
|
|
||||||
|
|
||||||
| Тип | Описание |
|
|
||||||
|-----|----------|
|
|
||||||
| `insert` | Вставка документа |
|
|
||||||
| `update` | Обновление документа |
|
|
||||||
| `delete` | Удаление документа (soft или permanent) |
|
|
||||||
| `restore` | Восстановление мягко удалённого документа |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1041,6 +1032,7 @@ saga.AddStep("reserve_payment",
|
|||||||
nil)
|
nil)
|
||||||
|
|
||||||
sagaManager.Execute(saga)
|
sagaManager.Execute(saga)
|
||||||
|
```
|
||||||
|
|
||||||
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
@@ -1846,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
|
||||||
# Добавление обязательного поля
|
# Добавление обязательного поля
|
||||||
@@ -2757,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>
|
||||||
@@ -2789,7 +2781,7 @@ futriix:~> doc compression employees 550e8400-e29b-41d4-a716-446655440000
|
|||||||
- [x] Библиотека "raft-boltdb" заменить на встроенное файловое хранилище
|
- [x] Библиотека "raft-boltdb" заменить на встроенное файловое хранилище
|
||||||
- [x] Реализовать уникальные и составные индексы
|
- [x] Реализовать уникальные и составные индексы
|
||||||
- [x] Реализовать временные метки для основных объектов субд (таппл, коллекция, документ, поле, индекс, транзакция, ACL, узел кластера)
|
- [x] Реализовать временные метки для основных объектов субд (таппл, коллекция, документ, поле, индекс, транзакция, ACL, узел кластера)
|
||||||
- [x] Реализовать MVCC версионирование - поддержка множественных версий документов
|
- [x] Реализовать Batch-версионирование - поддержка множественных версий документов через WAL
|
||||||
- [x] Реализовать временные метки для объектов субд (Ограничений, Импорта-Экспорта, Триггеров, Lua-плагинов)
|
- [x] Реализовать временные метки для объектов субд (Ограничений, Импорта-Экспорта, Триггеров, Lua-плагинов)
|
||||||
- [x] Реализовать обработку состояний "split-brain"
|
- [x] Реализовать обработку состояний "split-brain"
|
||||||
- [x] Реализовать Pipeline репликации-группировку нескольких команд в один Raft лог для уменьшения сети
|
- [x] Реализовать Pipeline репликации-группировку нескольких команд в один Raft лог для уменьшения сети
|
||||||
@@ -2808,7 +2800,7 @@ 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] Вынести настройи настройки MVCC,WAL и настройки плагинов из исходного кода проекта в файл конфигурации "config.toml"
|
- [x] Вынести настройи настройки Batch, WAL и настройки плагинов из исходного кода проекта в файл конфигурации "config.toml"
|
||||||
- [x] Реализовать выделение жирным шифтом все информационные сообщения в скриптах сборки
|
- [x] Реализовать выделение жирным шифтом все информационные сообщения в скриптах сборки
|
||||||
- [x] Реализовать ограничения на количество создаваемых Lua-состояний в плагинах
|
- [x] Реализовать ограничения на количество создаваемых Lua-состояний в плагинах
|
||||||
- [x] Реализовать WAL recovery сделать асинхронным для ускорения скорости работы субд
|
- [x] Реализовать WAL recovery сделать асинхронным для ускорения скорости работы субд
|
||||||
@@ -2879,4 +2871,4 @@ futriix:~> doc compression employees 550e8400-e29b-41d4-a716-446655440000
|
|||||||
**Григорий Сафронов** - [E-mail](gvsafronov@yandex.ru) <br>
|
**Григорий Сафронов** - [E-mail](gvsafronov@yandex.ru) <br>
|
||||||
**Сообщество** - [Сообщество Futriix](https://source.futriix.ru/gvsafronov/futriix/issues/new/choose)
|
**Сообщество** - [Сообщество Futriix](https://source.futriix.ru/gvsafronov/futriix/issues/new/choose)
|
||||||
|
|
||||||
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
+21
-102
@@ -10,58 +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.
|
|
||||||
*
|
|
||||||
* ИСПРАВЛЕНО (2026-10): после замены ACID-транзакций на batch-операции
|
|
||||||
* функции storage.InitTransactionManager, storage.SetTransactionLogger,
|
|
||||||
* storage.StopTransactionManager были удалены. Заменены на:
|
|
||||||
* - storage.InitBatchManager(walPath) — инициализирует batch-менеджер,
|
|
||||||
* WAL и AOF (append-only file для мутаций вне batch);
|
|
||||||
* - storage.SetBatchLogger(logger) — устанавливает логгер;
|
|
||||||
* - storage.StopBatchManager() — останавливает batch-менеджер,
|
|
||||||
* WAL и AOF.
|
|
||||||
* Дополнительно: при shutdown логируется состояние batch-статистики
|
|
||||||
* (количество выполненных batch'ей, операций, ошибок) для диагностики.
|
|
||||||
*
|
|
||||||
* ДОБАВЛЕНО (2026-10, выборы лидера SAGA через Raft):
|
|
||||||
* - SAGA-оркестратор теперь создаётся через
|
|
||||||
* storage.NewSagaOrchestratorWithRaft и получает bridge из
|
|
||||||
* raftCoordinator (raftCoordinator.GetSagaLeaderBridge()).
|
|
||||||
* Это позволяет в кластерном режиме выбирать SAGA-лидера через Raft
|
|
||||||
* и выполнять компенсации только на одном узле.
|
|
||||||
* - В single-node режиме bridge также возвращает корректные значения
|
|
||||||
* (IsLeader() == true), так что поведение не меняется.
|
|
||||||
* - Раньше оркестратор создавался через NewSagaOrchestratorWithConfig
|
|
||||||
* с raftAccessor == nil, что в кластерном режиме приводило к тому,
|
|
||||||
* что каждый узел считал себя SAGA-лидером. Идемпотентность шагов
|
|
||||||
* через saga_executed_keys.json защищала от дублей операций, но не
|
|
||||||
* от одновременной компенсации одной SAGA с двух узлов.
|
|
||||||
* - Порядок shutdown безопасен: sagaOrchestrator.Stop() вызывается
|
|
||||||
* раньше raftCoordinator.Stop(), поэтому bridge ещё жив, когда
|
|
||||||
* оркестратор отписывается от него.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package main
|
package main
|
||||||
@@ -146,13 +101,6 @@ func main() {
|
|||||||
store := storage.NewStorage(cfg.Storage.PageSizeMB, logger)
|
store := storage.NewStorage(cfg.Storage.PageSizeMB, logger)
|
||||||
storage.SetGlobalStorage(store)
|
storage.SetGlobalStorage(store)
|
||||||
|
|
||||||
// ========== BATCH MANAGER (замена TransactionManager) ==========
|
|
||||||
//
|
|
||||||
// ИСПРАВЛЕНО (2026-10): InitTransactionManager заменён на InitBatchManager.
|
|
||||||
// InitBatchManager инициализирует:
|
|
||||||
// - WAL (сегментированный журнал для batch-операций);
|
|
||||||
// - AOF (append-only file для мутаций вне batch).
|
|
||||||
// Директория AOF создаётся автоматически как filepath.Join(filepath.Dir(walPath), "aof").
|
|
||||||
walPath := filepath.Join(dataDir, "futriis.wal")
|
walPath := filepath.Join(dataDir, "futriis.wal")
|
||||||
if err := storage.InitBatchManager(walPath); err != nil {
|
if err := storage.InitBatchManager(walPath); err != nil {
|
||||||
logger.Warn("Failed to initialize batch manager: " + err.Error())
|
logger.Warn("Failed to initialize batch manager: " + err.Error())
|
||||||
@@ -166,8 +114,6 @@ func main() {
|
|||||||
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())
|
||||||
@@ -184,27 +130,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========== SAGA ORCHESTRATOR ==========
|
// ========== SAGA ORCHESTRATOR ==========
|
||||||
//
|
|
||||||
// ДОБАВЛЕНО (2026-10): используем NewSagaOrchestratorWithRaft и
|
|
||||||
// передаём bridge из raftCoordinator. Это позволяет в кластерном
|
|
||||||
// режиме выбирать SAGA-лидера через Raft, чтобы компенсации
|
|
||||||
// выполнялись только на одном узле.
|
|
||||||
//
|
|
||||||
// В single-node режиме bridge возвращает IsLeader() == true, так что
|
|
||||||
// поведение не меняется. В кластерном режиме только лидер Raft
|
|
||||||
// будет IsSagaLeader() == true, и только он будет выполнять SAGA.
|
|
||||||
//
|
|
||||||
// Если raftCoordinator == nil (не должно случаться — выше os.Exit(1)
|
|
||||||
// при ошибке), создаём оркестратор без Raft (single-node fallback).
|
|
||||||
var sagaOrchestrator *storage.SagaOrchestrator
|
var sagaOrchestrator *storage.SagaOrchestrator
|
||||||
if cfg.Saga.Enabled {
|
if cfg.Saga.Enabled {
|
||||||
sagaConfig := &cfg.Saga
|
sagaConfig := &cfg.Saga
|
||||||
nodeID := cfg.Cluster.NodeIP + ":" + fmt.Sprint(cfg.Cluster.NodePort)
|
nodeID := cfg.Cluster.NodeIP + ":" + fmt.Sprint(cfg.Cluster.NodePort)
|
||||||
|
|
||||||
// Передаём bridge из Raft в качестве storage.SagaRaftAccessor.
|
|
||||||
// Тип bridge реализует интерфейс storage.SagaRaftAccessor
|
|
||||||
// (IsSagaLeader, GetSagaLeaderID, GetSagaCurrentTerm,
|
|
||||||
// RegisterSagaLeaderObserver).
|
|
||||||
var raftAccessor storage.SagaRaftAccessor
|
var raftAccessor storage.SagaRaftAccessor
|
||||||
if raftCoordinator != nil {
|
if raftCoordinator != nil {
|
||||||
raftAccessor = raftCoordinator.GetSagaLeaderBridge()
|
raftAccessor = raftCoordinator.GetSagaLeaderBridge()
|
||||||
@@ -311,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 {
|
||||||
@@ -362,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)
|
||||||
@@ -376,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,10 +368,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ВАЖНО: sagaOrchestrator останавливается ДО raftCoordinator.
|
|
||||||
// Это гарантирует, что оркестратор отпишется от sagaLeaderBridge
|
|
||||||
// (и остановит leaderElector) до того, как bridge будет остановлен
|
|
||||||
// вместе с координатором. Двойной остановки bridge не будет.
|
|
||||||
if sagaOrchestrator != nil {
|
if sagaOrchestrator != nil {
|
||||||
logger.Info("Stopping SAGA orchestrator...")
|
logger.Info("Stopping SAGA orchestrator...")
|
||||||
sagaStopDone := make(chan struct{})
|
sagaStopDone := make(chan struct{})
|
||||||
@@ -497,8 +430,6 @@ func main() {
|
|||||||
logger.Warn("Node shutdown timeout")
|
logger.Warn("Node shutdown timeout")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ИСПРАВЛЕНО (2026-10): StopTransactionManager заменён на StopBatchManager.
|
|
||||||
// StopBatchManager синхронизирует WAL, закрывает его и закрывает AOF.
|
|
||||||
logger.Info("Stopping batch manager (WAL + AOF)...")
|
logger.Info("Stopping batch manager (WAL + AOF)...")
|
||||||
if stats := storage.GetBatchStats(); stats != nil {
|
if stats := storage.GetBatchStats(); stats != nil {
|
||||||
logger.Info(fmt.Sprintf(" Batch stats: total_batches=%v, total_operations=%v, total_failed=%v, active_batches=%v",
|
logger.Info(fmt.Sprintf(" Batch stats: total_batches=%v, total_operations=%v, total_failed=%v, active_batches=%v",
|
||||||
@@ -515,7 +446,6 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
select {
|
select {
|
||||||
case <-batchStopDone:
|
case <-batchStopDone:
|
||||||
// уже залогировано внутри горутины
|
|
||||||
case <-time.After(10 * time.Second):
|
case <-time.After(10 * time.Second):
|
||||||
logger.Warn("Batch manager shutdown timeout")
|
logger.Warn("Batch manager shutdown timeout")
|
||||||
}
|
}
|
||||||
@@ -679,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{
|
||||||
@@ -738,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -751,4 +670,4 @@ func printJSON(data interface{}) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Println(string(jsonData))
|
fmt.Println(string(jsonData))
|
||||||
}
|
}
|
||||||
@@ -1381,4 +1381,4 @@ func (bs *BackupScheduler) GetStats() map[string]interface{} {
|
|||||||
"retention_days": bs.config.RetentionDays,
|
"retention_days": bs.config.RetentionDays,
|
||||||
"max_concurrent": bs.config.MaxConcurrent,
|
"max_concurrent": bs.config.MaxConcurrent,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,6 +58,11 @@
|
|||||||
// Все публичные методы *cluster.SagaManager (GetOrchestrator,
|
// Все публичные методы *cluster.SagaManager (GetOrchestrator,
|
||||||
// GetMetrics, GetActiveSagas, Stop) сохранены — они используются
|
// GetMetrics, GetActiveSagas, Stop) сохранены — они используются
|
||||||
// в repl.go и main.go.
|
// в repl.go и main.go.
|
||||||
|
//
|
||||||
|
// ДОБАВЛЕНО (2026-10, кластерный SagaManager):
|
||||||
|
// - Метод GetAllSagas() у *SagaManager — возвращает все SAGA
|
||||||
|
// транзакции (активные и завершённые). Используется REPL-командой
|
||||||
|
// "saga list --all". Возвращает локальный тип cluster.SagaTransaction.
|
||||||
|
|
||||||
package cluster
|
package cluster
|
||||||
|
|
||||||
@@ -828,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)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -1551,6 +1555,26 @@ 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-менеджер.
|
// Stop останавливает Saga-менеджер.
|
||||||
// Реализован, чтобы main.go мог вызвать sagaManager.Stop() единообразно
|
// Реализован, чтобы main.go мог вызвать sagaManager.Stop() единообразно
|
||||||
// как для storage.SagaOrchestrator, так и для cluster.SagaManager.
|
// как для storage.SagaOrchestrator, так и для cluster.SagaManager.
|
||||||
@@ -1963,7 +1987,6 @@ func (sbd *SplitBrainDetector) IsQuarantined(nodeID string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// RECOVERY MANAGER - ПЕРЕИМЕНОВАН В ClusterNodeState ДЛЯ ИЗБЕЖАНИЯ КОНФЛИКТА
|
// RECOVERY MANAGER - ПЕРЕИМЕНОВАН В ClusterNodeState ДЛЯ ИЗБЕЖАНИЯ КОНФЛИКТА
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -3134,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
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -4405,4 +4427,4 @@ type ClusterStatus struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Гарантируем, что runtime импортирован для кроссплатформенности
|
// Гарантируем, что runtime импортирован для кроссплатформенности
|
||||||
var _ = runtime.GOOS
|
var _ = runtime.GOOS
|
||||||
+329
-137
@@ -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,50 +74,204 @@ type Config struct {
|
|||||||
MinSize int // Минимальный размер для сжатия (байт)
|
MinSize int // Минимальный размер для сжатия (байт)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MagicNumber используется для идентификации сжатых данных
|
// =============================================================================
|
||||||
|
// MAGIC NUMBERS И КОНСТАНТЫ ФОРМАТА
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// MagicNumber используется для идентификации сжатых данных (V1).
|
||||||
var MagicNumber = []byte{0x46, 0x54, 0x52, 0x53} // "FTRS" - Futriis
|
var MagicNumber = []byte{0x46, 0x54, 0x52, 0x53} // "FTRS" - Futriis
|
||||||
|
|
||||||
// Расширенный magic с версией для предотвращения collision
|
// MagicNumberV2 — расширенный magic с версией для предотвращения collision.
|
||||||
var MagicNumberV2 = []byte{0x46, 0x54, 0x52, 0x53, 0x02} // "FTRS\x02"
|
var MagicNumberV2 = []byte{0x46, 0x54, 0x52, 0x53, 0x02} // "FTRS\x02"
|
||||||
|
|
||||||
// MinCompressedSize — минимальный размер валидного сжатого блока
|
const (
|
||||||
// Для защиты от collision с пользовательскими данными
|
// MagicV1HeaderSize — размер заголовка формата V1:
|
||||||
const MinCompressedSize = 14 // 5 (magic v2) + 1 (type) + 8 (size)
|
// 4 (magic) + 1 (type) + 8 (size) = 13 байт.
|
||||||
|
MagicV1HeaderSize = 4 + 1 + 8
|
||||||
|
|
||||||
// CompressionType определяет тип сжатия
|
// MagicV2HeaderSize — размер заголовка формата V2:
|
||||||
|
// 5 (magic) + 1 (type) + 8 (size) = 14 байт.
|
||||||
|
MagicV2HeaderSize = 5 + 1 + 8
|
||||||
|
|
||||||
|
// MinCompressedSize — минимальный размер валидного сжатого блока.
|
||||||
|
// Равен размеру заголовка V2 (самый свежий формат).
|
||||||
|
// Оставлен экспортируемым для обратной совместимости с внешним кодом,
|
||||||
|
// который мог его использовать.
|
||||||
|
MinCompressedSize = MagicV2HeaderSize
|
||||||
|
|
||||||
|
// MaxOriginalSize — верхняя граница originalSize, которую мы считаем
|
||||||
|
// разумной. 100 GiB. Защита от мусорных/враждебных заголовков.
|
||||||
|
MaxOriginalSize = uint64(100) * 1024 * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
// CompressionType определяет тип сжатия.
|
||||||
type CompressionType byte
|
type CompressionType byte
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CompressionNone CompressionType = 0x00
|
CompressionNone CompressionType = 0x00
|
||||||
CompressionSnappy CompressionType = 0x01
|
CompressionSnappy CompressionType = 0x01
|
||||||
CompressionBrotli CompressionType = 0x02
|
CompressionBrotli CompressionType = 0x02
|
||||||
CompressionZstd CompressionType = 0x03
|
CompressionZstd CompressionType = 0x03
|
||||||
)
|
)
|
||||||
|
|
||||||
// Compress сжимает данные с использованием указанного алгоритма
|
// =============================================================================
|
||||||
|
// КЭШ ZSTD ENCODER / DECODER
|
||||||
|
// =============================================================================
|
||||||
|
//
|
||||||
|
// zstd.Encoder.EncodeAll и zstd.Decoder.DecodeAll потокобезопасны
|
||||||
|
// (см. документацию klauspost/compress/zstd). Поэтому один общий
|
||||||
|
// encoder/decoder можно безопасно переиспользовать из многих горутин.
|
||||||
|
//
|
||||||
|
// Кэшируем по уровню для encoder и один общий для decoder.
|
||||||
|
|
||||||
|
var (
|
||||||
|
zstdEncoders sync.Map // map[zstd.EncoderLevel]*zstd.Encoder
|
||||||
|
zstdDecoder *zstd.Decoder
|
||||||
|
zstdDecOnce sync.Once
|
||||||
|
zstdDecErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
// getZstdEncoder возвращает кэшированный zstd-энкодер для уровня.
|
||||||
|
func getZstdEncoder(level zstd.EncoderLevel) (*zstd.Encoder, error) {
|
||||||
|
if enc, ok := zstdEncoders.Load(level); ok {
|
||||||
|
return enc.(*zstd.Encoder), nil
|
||||||
|
}
|
||||||
|
enc, err := zstd.NewWriter(nil, zstd.WithEncoderLevel(level))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create zstd encoder: %v", err)
|
||||||
|
}
|
||||||
|
actual, _ := zstdEncoders.LoadOrStore(level, enc)
|
||||||
|
// Если другая горутина успела первой — закрываем свой encoder.
|
||||||
|
if actual.(*zstd.Encoder) != enc {
|
||||||
|
enc.Close()
|
||||||
|
}
|
||||||
|
return actual.(*zstd.Encoder), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getZstdDecoder возвращает общий zstd-декодер (создаётся один раз).
|
||||||
|
func getZstdDecoder() (*zstd.Decoder, error) {
|
||||||
|
zstdDecOnce.Do(func() {
|
||||||
|
zstdDecoder, zstdDecErr = zstd.NewReader(nil)
|
||||||
|
})
|
||||||
|
return zstdDecoder, zstdDecErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ПАРСИНГ ЗАГОЛОВКА
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// headerInfo описывает разобранный заголовок сжатого блока.
|
||||||
|
type headerInfo struct {
|
||||||
|
version int
|
||||||
|
compType CompressionType
|
||||||
|
originalSize uint64
|
||||||
|
headerSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHeader пытается разобрать заголовок сжатого блока (V2 или V1).
|
||||||
|
// Возвращает nil, если данные не являются сжатым блоком.
|
||||||
|
//
|
||||||
|
// ВНИМАНИЕ: для V1 проверяется валидность compType — это защита от
|
||||||
|
// ложных срабатываний, когда несжатые данные случайно начинаются
|
||||||
|
// с байт "FTRS".
|
||||||
|
func parseHeader(data []byte) *headerInfo {
|
||||||
|
// V2: 5-байтовый magic + 1 байт type + 8 байт size.
|
||||||
|
if len(data) >= MagicV2HeaderSize && bytes.HasPrefix(data, MagicNumberV2) {
|
||||||
|
compType := CompressionType(data[5])
|
||||||
|
if isValidCompressionType(compType) {
|
||||||
|
return &headerInfo{
|
||||||
|
version: 2,
|
||||||
|
compType: compType,
|
||||||
|
originalSize: binary.LittleEndian.Uint64(data[6:14]),
|
||||||
|
headerSize: MagicV2HeaderSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Magic совпал, но тип невалиден — не считаем это сжатым блоком.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// V1: 4-байтовый magic + 1 байт type + 8 байт size.
|
||||||
|
if len(data) >= MagicV1HeaderSize && bytes.HasPrefix(data, MagicNumber) {
|
||||||
|
compType := CompressionType(data[4])
|
||||||
|
if isValidCompressionType(compType) {
|
||||||
|
return &headerInfo{
|
||||||
|
version: 1,
|
||||||
|
compType: compType,
|
||||||
|
originalSize: binary.LittleEndian.Uint64(data[5:13]),
|
||||||
|
headerSize: MagicV1HeaderSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidCompressionType проверяет, что байт соответствует известному
|
||||||
|
// типу сжатия. Используется как защита от ложных срабатываний magic.
|
||||||
|
func isValidCompressionType(t CompressionType) bool {
|
||||||
|
switch t {
|
||||||
|
case CompressionNone, CompressionSnappy, CompressionBrotli, CompressionZstd:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// СЖАТИЕ
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Compress сжимает данные с использованием указанного алгоритма.
|
||||||
|
//
|
||||||
|
// ИСПРАВЛЕНО:
|
||||||
|
// - config == nil больше не приводит к панике — возвращаем data как есть.
|
||||||
|
// - Учитывается оверхед заголовка при решении «сжимать или нет».
|
||||||
|
// - Level применяется не только к zstd, но и к brotli.
|
||||||
|
//
|
||||||
|
// ИСПРАВЛЕНО (компиляция):
|
||||||
|
// В ветке case "zstd" убрано объявление err, которое не использовалось:
|
||||||
|
// zstd.Encoder.EncodeAll не возвращает ошибку. Ошибка возможна только
|
||||||
|
// при создании encoder через getZstdEncoder — она проверяется тут же.
|
||||||
func Compress(data []byte, config *Config) ([]byte, error) {
|
func Compress(data []byte, config *Config) ([]byte, error) {
|
||||||
if !config.Enabled {
|
// config == nil трактуем как «сжатие выключено».
|
||||||
|
if config == nil || !config.Enabled {
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(data) < config.MinSize {
|
// MinSize <= 0 означает «сжимать всё, включая пустые данные».
|
||||||
|
// Если MinSize > 0 и данные меньше — не сжимаем.
|
||||||
|
if config.MinSize > 0 && len(data) < config.MinSize {
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Пустые данные сжимать бессмысленно.
|
||||||
|
if len(data) == 0 {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
var compressed []byte
|
var compressed []byte
|
||||||
var err error
|
|
||||||
var compType CompressionType
|
var compType CompressionType
|
||||||
|
|
||||||
switch config.Algorithm {
|
switch config.Algorithm {
|
||||||
case "snappy":
|
case "snappy":
|
||||||
compressed = snappy.Encode(nil, data)
|
compressed = snappy.Encode(nil, data)
|
||||||
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 {
|
||||||
@@ -86,11 +279,9 @@ func Compress(data []byte, config *Config) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
compressed = buf.Bytes()
|
compressed = buf.Bytes()
|
||||||
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
|
||||||
@@ -101,106 +292,109 @@ func Compress(data []byte, config *Config) ([]byte, error) {
|
|||||||
default:
|
default:
|
||||||
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
|
||||||
|
|
||||||
default:
|
default:
|
||||||
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...)
|
||||||
result = append(result, compressed...)
|
result = append(result, compressed...)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decompress распаковывает данные
|
// normalizeBrotliLevel приводит входной уровень (1..9) к диапазону
|
||||||
// Поддержка обоих форматов (V1 и V2), усиленная валидация
|
// brotli (0..11). Значения <=0 и >9 трактуются разумно:
|
||||||
|
// - level <= 0 -> brotli.DefaultCompression
|
||||||
|
// - level 1..9 -> линейное отображение: 1->1, 9->11
|
||||||
|
// - level > 9 -> 11 (максимум)
|
||||||
|
func normalizeBrotliLevel(level int) int {
|
||||||
|
if level <= 0 {
|
||||||
|
return brotli.DefaultCompression
|
||||||
|
}
|
||||||
|
if level >= 9 {
|
||||||
|
return 11
|
||||||
|
}
|
||||||
|
// Линейное отображение 1..9 -> 1..11.
|
||||||
|
// 1->1, 2->2, ..., 9->11. Формула: 1 + (level-1)*10/8.
|
||||||
|
return 1 + (level-1)*10/8
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// РАСПАКОВКА
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Decompress распаковывает данные.
|
||||||
|
// Поддерживает оба формата (V1 и V2), усиленная валидация.
|
||||||
func Decompress(data []byte) ([]byte, error) {
|
func Decompress(data []byte) ([]byte, error) {
|
||||||
// Проверяем V2 формат (5-байтовый magic)
|
info := parseHeader(data)
|
||||||
if len(data) >= MinCompressedSize && bytes.Equal(data[0:5], MagicNumberV2) {
|
if info == nil {
|
||||||
return decompressV2(data)
|
return nil, fmt.Errorf("invalid magic number")
|
||||||
}
|
}
|
||||||
|
return decompressWithInfo(data, info)
|
||||||
// Проверяем V1 формат (4-байтовый magic) для обратной совместимости
|
|
||||||
if len(data) >= 4+1+8 && bytes.Equal(data[0:4], MagicNumber) {
|
|
||||||
return decompressV1(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("invalid magic number")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// decompressV1 распаковывает данные в старом формате V1
|
// decompressWithInfo выполняет распаковку, используя разобранный заголовок.
|
||||||
func decompressV1(data []byte) ([]byte, error) {
|
func decompressWithInfo(data []byte, info *headerInfo) ([]byte, error) {
|
||||||
if len(data) < 4+1+8 {
|
if len(data) < info.headerSize {
|
||||||
return nil, fmt.Errorf("data too short for compressed format V1")
|
return nil, fmt.Errorf("data too short for compressed format V%d", info.version)
|
||||||
}
|
}
|
||||||
|
|
||||||
compType := CompressionType(data[4])
|
|
||||||
originalSize := binary.LittleEndian.Uint64(data[5:13])
|
|
||||||
compressedData := data[13:]
|
|
||||||
|
|
||||||
if originalSize == 0 {
|
|
||||||
return nil, fmt.Errorf("invalid original size")
|
|
||||||
}
|
|
||||||
|
|
||||||
return doDecompress(compType, compressedData, originalSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
// decompressV2 распаковывает данные в новом формате V2
|
// Проверка разумности размера.
|
||||||
// Добавлена проверка согласованности размера
|
if info.originalSize == 0 || info.originalSize > MaxOriginalSize {
|
||||||
func decompressV2(data []byte) ([]byte, error) {
|
return nil, fmt.Errorf("invalid original size: %d", info.originalSize)
|
||||||
if len(data) < MinCompressedSize {
|
|
||||||
return nil, fmt.Errorf("data too short for compressed format V2")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
compType := CompressionType(data[5])
|
compressedData := data[info.headerSize:]
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
switch compType {
|
switch compType {
|
||||||
case CompressionSnappy:
|
case CompressionSnappy:
|
||||||
decompressed, err = snappy.Decode(nil, compressedData)
|
decompressed, err = snappy.Decode(nil, compressedData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("snappy decode failed: %v", err)
|
return nil, fmt.Errorf("snappy decode failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
case CompressionBrotli:
|
case CompressionBrotli:
|
||||||
reader := brotli.NewReader(bytes.NewReader(compressedData))
|
reader := brotli.NewReader(bytes.NewReader(compressedData))
|
||||||
buf := bytes.NewBuffer(nil)
|
buf := bytes.NewBuffer(nil)
|
||||||
@@ -209,83 +403,81 @@ func doDecompress(compType CompressionType, compressedData []byte, originalSize
|
|||||||
return nil, fmt.Errorf("brotli decode failed: %v", err)
|
return nil, fmt.Errorf("brotli decode failed: %v", err)
|
||||||
}
|
}
|
||||||
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
return decompressed, nil
|
return decompressed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DecompressAuto автоматически определяет, сжаты ли данные, и распаковывает при необходимости
|
// DecompressAuto автоматически определяет, сжаты ли данные, и распаковывает при необходимости.
|
||||||
// Усиленная проверка для предотвращения collision
|
// Усиленная проверка для предотвращения collision.
|
||||||
func DecompressAuto(data []byte) ([]byte, error) {
|
func DecompressAuto(data []byte) ([]byte, error) {
|
||||||
// Проверяем V2 magic (более надёжный)
|
info := parseHeader(data)
|
||||||
if len(data) >= MinCompressedSize && bytes.Equal(data[0:5], MagicNumberV2) {
|
if info == nil {
|
||||||
return Decompress(data)
|
// Данные не сжаты.
|
||||||
|
return data, nil
|
||||||
}
|
}
|
||||||
|
return decompressWithInfo(data, info)
|
||||||
// Проверяем V1 magic (для обратной совместимости)
|
|
||||||
if len(data) >= 4+1+8 && bytes.Equal(data[0:4], MagicNumber) {
|
|
||||||
// Дополнительная проверка: тип сжатия должен быть валидным
|
|
||||||
compType := CompressionType(data[4])
|
|
||||||
if compType <= CompressionZstd {
|
|
||||||
return Decompress(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Данные не сжаты
|
|
||||||
return data, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsCompressed проверяет, сжаты ли данные
|
// IsCompressed проверяет, сжаты ли данные (V1 или V2).
|
||||||
// Проверка обоих форматов
|
|
||||||
func IsCompressed(data []byte) bool {
|
func IsCompressed(data []byte) bool {
|
||||||
if len(data) >= MinCompressedSize && bytes.Equal(data[0:5], MagicNumberV2) {
|
return parseHeader(data) != nil
|
||||||
return true
|
|
||||||
}
|
|
||||||
if len(data) >= 4+1+8 && bytes.Equal(data[0:4], MagicNumber) {
|
|
||||||
compType := CompressionType(data[4])
|
|
||||||
return compType <= CompressionZstd
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCompressionType возвращает тип сжатия данных
|
// GetCompressionType возвращает тип сжатия данных.
|
||||||
|
// Возвращает CompressionNone, если данные не сжаты.
|
||||||
func GetCompressionType(data []byte) CompressionType {
|
func GetCompressionType(data []byte) CompressionType {
|
||||||
if len(data) >= MinCompressedSize && bytes.Equal(data[0:5], MagicNumberV2) {
|
info := parseHeader(data)
|
||||||
return CompressionType(data[5])
|
if info == nil {
|
||||||
}
|
|
||||||
if !IsCompressed(data) || len(data) < 5 {
|
|
||||||
return CompressionNone
|
return CompressionNone
|
||||||
}
|
}
|
||||||
return CompressionType(data[4])
|
return info.compType
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCompressionRatio возвращает коэффициент сжатия
|
// =============================================================================
|
||||||
|
// СТАТИСТИКА
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// GetCompressionRatio возвращает коэффициент сжатия как долю:
|
||||||
|
// - 1.0 — данные не уменьшились (или выросли);
|
||||||
|
// - 0.5 — сжатые данные в два раза меньше исходных;
|
||||||
|
// - 0.0 — теоретический предел (пустые данные после сжатия).
|
||||||
|
//
|
||||||
|
// ВНИМАНИЕ: это именно доля compressed/original, а не «во сколько раз
|
||||||
|
// сжали». Если нужно «во сколько раз», используйте 1.0 / ratio.
|
||||||
func GetCompressionRatio(original, compressed []byte) float64 {
|
func GetCompressionRatio(original, compressed []byte) float64 {
|
||||||
if len(original) == 0 {
|
if len(original) == 0 {
|
||||||
return 1.0
|
return 1.0
|
||||||
}
|
}
|
||||||
return float64(len(compressed)) / float64(len(original))
|
return float64(len(compressed)) / float64(len(original))
|
||||||
}
|
}
|
||||||
+265
-429
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
|
||||||
|
|
||||||
@@ -32,8 +32,8 @@ import (
|
|||||||
|
|
||||||
type atomicBool struct{ v int32 }
|
type atomicBool struct{ v int32 }
|
||||||
|
|
||||||
func (b *atomicBool) Load() bool { return atomic.LoadInt32(&b.v) != 0 }
|
func (b *atomicBool) Load() bool { return atomic.LoadInt32(&b.v) != 0 }
|
||||||
func (b *atomicBool) Store(v bool) { atomic.StoreInt32(&b.v, boolToInt32(v)) }
|
func (b *atomicBool) Store(v bool) { atomic.StoreInt32(&b.v, boolToInt32(v)) }
|
||||||
func (b *atomicBool) CAS(old, new bool) bool {
|
func (b *atomicBool) CAS(old, new bool) bool {
|
||||||
return atomic.CompareAndSwapInt32(&b.v, boolToInt32(old), boolToInt32(new))
|
return atomic.CompareAndSwapInt32(&b.v, boolToInt32(old), boolToInt32(new))
|
||||||
}
|
}
|
||||||
@@ -55,7 +55,7 @@ func (a *atomicUint64) Add(d uint64) uint64 { return atomic.AddUint64(&a.v, d) }
|
|||||||
// ТИПЫ ДЛЯ ДИНАМИЧЕСКОЙ КОНФИГУРАЦИИ
|
// ТИПЫ ДЛЯ ДИНАМИЧЕСКОЙ КОНФИГУРАЦИИ
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// DynamicConfigChange представляет изменение конфигурации
|
// DynamicConfigChange представляет изменение конфигурации.
|
||||||
type DynamicConfigChange struct {
|
type DynamicConfigChange struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Version uint64 `json:"version"`
|
Version uint64 `json:"version"`
|
||||||
@@ -65,22 +65,22 @@ type DynamicConfigChange struct {
|
|||||||
Changes map[string]interface{} `json:"changes"`
|
Changes map[string]interface{} `json:"changes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DynamicConfigSnapshot представляет снэпшот конфигурации
|
// DynamicConfigSnapshot представляет снэпшот конфигурации.
|
||||||
type DynamicConfigSnapshot struct {
|
type DynamicConfigSnapshot struct {
|
||||||
Version uint64 `json:"version"`
|
Version uint64 `json:"version"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
Config *Config `json:"config"`
|
Config *Config `json:"config"`
|
||||||
ChangeHistory []*DynamicConfigChange `json:"change_history"`
|
ChangeHistory []*DynamicConfigChange `json:"change_history"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigChangeListener представляет слушатель изменений конфигурации
|
// ConfigChangeListener представляет слушатель изменений конфигурации.
|
||||||
type ConfigChangeListener func(change *DynamicConfigChange)
|
type ConfigChangeListener func(change *DynamicConfigChange)
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// DYNAMIC CONFIG MANAGER
|
// DYNAMIC CONFIG MANAGER
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// DynamicConfigManager управляет динамической конфигурацией через Raft
|
// DynamicConfigManager управляет динамической конфигурацией через Raft.
|
||||||
type DynamicConfigManager struct {
|
type DynamicConfigManager struct {
|
||||||
config *Config
|
config *Config
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
@@ -97,29 +97,28 @@ type DynamicConfigManager struct {
|
|||||||
isLeader atomicBool
|
isLeader atomicBool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigFSM реализует конечный автомат для конфигурации
|
// ConfigFSM реализует конечный автомат для конфигурации.
|
||||||
type ConfigFSM struct {
|
type ConfigFSM struct {
|
||||||
config *DynamicConfigSnapshot
|
config *DynamicConfigSnapshot
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
// onChange вызывается после успешного применения изменения.
|
// onChange вызывается после успешного применения изменения.
|
||||||
// Может быть установлен менеджером для уведомления слушателей.
|
// Может быть установлен менеджером для уведомления слушателей.
|
||||||
onChange func(change *DynamicConfigChange)
|
onChange func(change *DynamicConfigChange)
|
||||||
onChangeMu sync.RWMutex
|
onChangeMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigSnapshot реализует raft.FSMSnapshot
|
// ConfigSnapshot реализует raft.FSMSnapshot.
|
||||||
type ConfigSnapshot struct {
|
type ConfigSnapshot struct {
|
||||||
config *DynamicConfigSnapshot
|
config *DynamicConfigSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDynamicConfigManager создаёт новый менеджер динамической конфигурации
|
// NewDynamicConfigManager создаёт новый менеджер динамической конфигурации.
|
||||||
func NewDynamicConfigManager(cfg *Config, logger *log.Logger) *DynamicConfigManager {
|
func NewDynamicConfigManager(cfg *Config, logger *log.Logger) *DynamicConfigManager {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
cfg = &Config{}
|
cfg = &Config{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаём начальный снэпшот
|
|
||||||
initialSnapshot := &DynamicConfigSnapshot{
|
initialSnapshot := &DynamicConfigSnapshot{
|
||||||
Version: 1,
|
Version: 1,
|
||||||
UpdatedAt: time.Now().UnixMilli(),
|
UpdatedAt: time.Now().UnixMilli(),
|
||||||
@@ -142,13 +141,11 @@ func NewDynamicConfigManager(cfg *Config, logger *log.Logger) *DynamicConfigMana
|
|||||||
}
|
}
|
||||||
dm.version.Store(1)
|
dm.version.Store(1)
|
||||||
|
|
||||||
// Подключаем FSM к менеджеру для уведомления слушателей
|
|
||||||
fsm.onChangeMu.Lock()
|
fsm.onChangeMu.Lock()
|
||||||
fsm.onChange = func(change *DynamicConfigChange) {
|
fsm.onChange = func(change *DynamicConfigChange) {
|
||||||
dm.version.Store(change.Version)
|
dm.version.Store(change.Version)
|
||||||
dm.mu.Lock()
|
dm.mu.Lock()
|
||||||
dm.changeHistory = append(dm.changeHistory, change)
|
dm.changeHistory = append(dm.changeHistory, change)
|
||||||
// Ограничиваем историю
|
|
||||||
if len(dm.changeHistory) > 1000 {
|
if len(dm.changeHistory) > 1000 {
|
||||||
dm.changeHistory = dm.changeHistory[len(dm.changeHistory)-1000:]
|
dm.changeHistory = dm.changeHistory[len(dm.changeHistory)-1000:]
|
||||||
}
|
}
|
||||||
@@ -160,19 +157,19 @@ func NewDynamicConfigManager(cfg *Config, logger *log.Logger) *DynamicConfigMana
|
|||||||
return dm
|
return dm
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRaft устанавливает Raft для управления конфигурацией
|
// SetRaft устанавливает Raft для управления конфигурацией.
|
||||||
func (dm *DynamicConfigManager) SetRaft(r *raft.Raft) {
|
func (dm *DynamicConfigManager) SetRaft(r *raft.Raft) {
|
||||||
dm.mu.Lock()
|
dm.mu.Lock()
|
||||||
defer dm.mu.Unlock()
|
defer dm.mu.Unlock()
|
||||||
dm.raft = r
|
dm.raft = r
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFSM возвращает FSM для конфигурации
|
// GetFSM возвращает FSM для конфигурации.
|
||||||
func (dm *DynamicConfigManager) GetFSM() *ConfigFSM {
|
func (dm *DynamicConfigManager) GetFSM() *ConfigFSM {
|
||||||
return dm.fsm
|
return dm.fsm
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start запускает менеджер динамической конфигурации
|
// Start запускает менеджер динамической конфигурации.
|
||||||
func (dm *DynamicConfigManager) Start() {
|
func (dm *DynamicConfigManager) Start() {
|
||||||
if dm.logger != nil {
|
if dm.logger != nil {
|
||||||
dm.logger.Info("Dynamic config manager started")
|
dm.logger.Info("Dynamic config manager started")
|
||||||
@@ -203,9 +200,8 @@ func (dm *DynamicConfigManager) GetConfig() *Config {
|
|||||||
return deepCopyConfig(dm.fsm.config.Config)
|
return deepCopyConfig(dm.fsm.config.Config)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetVersion возвращает текущую версию конфигурации
|
// GetVersion возвращает текущую версию конфигурации.
|
||||||
func (dm *DynamicConfigManager) GetVersion() uint64 {
|
func (dm *DynamicConfigManager) GetVersion() uint64 {
|
||||||
// Синхронизируем с FSM, чтобы значение было актуальным
|
|
||||||
dm.fsm.mu.RLock()
|
dm.fsm.mu.RLock()
|
||||||
defer dm.fsm.mu.RUnlock()
|
defer dm.fsm.mu.RUnlock()
|
||||||
if dm.fsm.config != nil {
|
if dm.fsm.config != nil {
|
||||||
@@ -214,7 +210,7 @@ func (dm *DynamicConfigManager) GetVersion() uint64 {
|
|||||||
return dm.version.Load()
|
return dm.version.Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetChangeHistory возвращает историю изменений
|
// GetChangeHistory возвращает историю изменений.
|
||||||
func (dm *DynamicConfigManager) GetChangeHistory() []*DynamicConfigChange {
|
func (dm *DynamicConfigManager) GetChangeHistory() []*DynamicConfigChange {
|
||||||
dm.mu.RLock()
|
dm.mu.RLock()
|
||||||
defer dm.mu.RUnlock()
|
defer dm.mu.RUnlock()
|
||||||
@@ -224,7 +220,7 @@ func (dm *DynamicConfigManager) GetChangeHistory() []*DynamicConfigChange {
|
|||||||
return history
|
return history
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyChange применяет изменение конфигурации через Raft
|
// ApplyChange применяет изменение конфигурации через Raft.
|
||||||
func (dm *DynamicConfigManager) ApplyChange(changes map[string]interface{}, description string, changedBy string) error {
|
func (dm *DynamicConfigManager) ApplyChange(changes map[string]interface{}, description string, changedBy string) error {
|
||||||
dm.mu.RLock()
|
dm.mu.RLock()
|
||||||
r := dm.raft
|
r := dm.raft
|
||||||
@@ -234,14 +230,12 @@ func (dm *DynamicConfigManager) ApplyChange(changes map[string]interface{}, desc
|
|||||||
return fmt.Errorf("raft not initialized")
|
return fmt.Errorf("raft not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем, что мы лидер — через сам Raft, а не через внешний флаг
|
|
||||||
if r.State() != raft.Leader {
|
if r.State() != raft.Leader {
|
||||||
return fmt.Errorf("not the leader (current state: %s)", r.State())
|
return fmt.Errorf("not the leader (current state: %s)", r.State())
|
||||||
}
|
}
|
||||||
|
|
||||||
currentVersion := dm.GetVersion()
|
currentVersion := dm.GetVersion()
|
||||||
|
|
||||||
// Создаём команду изменения
|
|
||||||
change := &DynamicConfigChange{
|
change := &DynamicConfigChange{
|
||||||
ID: fmt.Sprintf("config_%d_%d", currentVersion+1, time.Now().UnixNano()),
|
ID: fmt.Sprintf("config_%d_%d", currentVersion+1, time.Now().UnixNano()),
|
||||||
Version: currentVersion + 1,
|
Version: currentVersion + 1,
|
||||||
@@ -251,7 +245,6 @@ func (dm *DynamicConfigManager) ApplyChange(changes map[string]interface{}, desc
|
|||||||
Changes: changes,
|
Changes: changes,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сериализуем и применяем через Raft
|
|
||||||
data, err := json.Marshal(change)
|
data, err := json.Marshal(change)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to marshal config change: %v", err)
|
return fmt.Errorf("failed to marshal config change: %v", err)
|
||||||
@@ -262,7 +255,6 @@ func (dm *DynamicConfigManager) ApplyChange(changes map[string]interface{}, desc
|
|||||||
return fmt.Errorf("raft apply failed: %v", err)
|
return fmt.Errorf("raft apply failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем ошибку из FSM.Apply
|
|
||||||
if resp := future.Response(); resp != nil {
|
if resp := future.Response(); resp != nil {
|
||||||
if err, ok := resp.(error); ok {
|
if err, ok := resp.(error); ok {
|
||||||
return fmt.Errorf("fsm apply failed: %v", err)
|
return fmt.Errorf("fsm apply failed: %v", err)
|
||||||
@@ -272,16 +264,15 @@ func (dm *DynamicConfigManager) ApplyChange(changes map[string]interface{}, desc
|
|||||||
if dm.logger != nil {
|
if dm.logger != nil {
|
||||||
dm.logger.Info(fmt.Sprintf("Applied config change: %s (version: %d)", description, change.Version))
|
dm.logger.Info(fmt.Sprintf("Applied config change: %s (version: %d)", description, change.Version))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateConfig обновляет конфигурацию
|
// UpdateConfig обновляет конфигурацию.
|
||||||
func (dm *DynamicConfigManager) UpdateConfig(updates map[string]interface{}, description string, changedBy string) error {
|
func (dm *DynamicConfigManager) UpdateConfig(updates map[string]interface{}, description string, changedBy string) error {
|
||||||
return dm.ApplyChange(updates, description, changedBy)
|
return dm.ApplyChange(updates, description, changedBy)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyConfigChange применяет изменение к FSM (вызывается из Raft)
|
// ApplyConfigChange применяет изменение к FSM (вызывается из Raft).
|
||||||
func (fsm *ConfigFSM) ApplyConfigChange(change *DynamicConfigChange) error {
|
func (fsm *ConfigFSM) ApplyConfigChange(change *DynamicConfigChange) error {
|
||||||
if change == nil {
|
if change == nil {
|
||||||
return fmt.Errorf("nil config change")
|
return fmt.Errorf("nil config change")
|
||||||
@@ -303,7 +294,6 @@ func (fsm *ConfigFSM) ApplyConfigChange(change *DynamicConfigChange) error {
|
|||||||
|
|
||||||
config := fsm.config.Config
|
config := fsm.config.Config
|
||||||
|
|
||||||
// Применяем изменения к конфигурации
|
|
||||||
for path, value := range change.Changes {
|
for path, value := range change.Changes {
|
||||||
if err := setConfigField(config, path, value); err != nil {
|
if err := setConfigField(config, path, value); err != nil {
|
||||||
fsm.mu.Unlock()
|
fsm.mu.Unlock()
|
||||||
@@ -311,11 +301,9 @@ func (fsm *ConfigFSM) ApplyConfigChange(change *DynamicConfigChange) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем метаданные
|
|
||||||
fsm.config.Version = change.Version
|
fsm.config.Version = change.Version
|
||||||
fsm.config.UpdatedAt = time.Now().UnixMilli()
|
fsm.config.UpdatedAt = time.Now().UnixMilli()
|
||||||
fsm.config.ChangeHistory = append(fsm.config.ChangeHistory, change)
|
fsm.config.ChangeHistory = append(fsm.config.ChangeHistory, change)
|
||||||
// Ограничиваем историю
|
|
||||||
if len(fsm.config.ChangeHistory) > 1000 {
|
if len(fsm.config.ChangeHistory) > 1000 {
|
||||||
fsm.config.ChangeHistory = fsm.config.ChangeHistory[len(fsm.config.ChangeHistory)-1000:]
|
fsm.config.ChangeHistory = fsm.config.ChangeHistory[len(fsm.config.ChangeHistory)-1000:]
|
||||||
}
|
}
|
||||||
@@ -326,32 +314,28 @@ func (fsm *ConfigFSM) ApplyConfigChange(change *DynamicConfigChange) error {
|
|||||||
fsm.logger.Debug(fmt.Sprintf("Applied config change to FSM: %s", change.Description))
|
fsm.logger.Debug(fmt.Sprintf("Applied config change to FSM: %s", change.Description))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Уведомляем слушателей (через callback, установленный менеджером)
|
|
||||||
fsm.onChangeMu.RLock()
|
fsm.onChangeMu.RLock()
|
||||||
cb := fsm.onChange
|
cb := fsm.onChange
|
||||||
fsm.onChangeMu.RUnlock()
|
fsm.onChangeMu.RUnlock()
|
||||||
if cb != nil {
|
if cb != nil {
|
||||||
cb(change)
|
cb(change)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply применяет команду к FSM (реализация raft.FSM)
|
// Apply применяет команду к FSM (реализация raft.FSM).
|
||||||
func (fsm *ConfigFSM) Apply(log *raft.Log) interface{} {
|
func (fsm *ConfigFSM) Apply(log *raft.Log) interface{} {
|
||||||
var change DynamicConfigChange
|
var change DynamicConfigChange
|
||||||
if err := json.Unmarshal(log.Data, &change); err != nil {
|
if err := json.Unmarshal(log.Data, &change); err != nil {
|
||||||
return fmt.Errorf("failed to unmarshal config change: %v", err)
|
return fmt.Errorf("failed to unmarshal config change: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := fsm.ApplyConfigChange(&change); err != nil {
|
if err := fsm.ApplyConfigChange(&change); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot создаёт снэпшот состояния FSM
|
// Snapshot создаёт снэпшот состояния FSM.
|
||||||
func (fsm *ConfigFSM) Snapshot() (raft.FSMSnapshot, error) {
|
func (fsm *ConfigFSM) Snapshot() (raft.FSMSnapshot, error) {
|
||||||
fsm.mu.RLock()
|
fsm.mu.RLock()
|
||||||
defer fsm.mu.RUnlock()
|
defer fsm.mu.RUnlock()
|
||||||
@@ -365,7 +349,6 @@ func (fsm *ConfigFSM) Snapshot() (raft.FSMSnapshot, error) {
|
|||||||
}}, nil
|
}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Глубокая копия через JSON, чтобы снэпшот не зависел от мутаций FSM
|
|
||||||
data, err := json.Marshal(fsm.config)
|
data, err := json.Marshal(fsm.config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to marshal snapshot: %v", err)
|
return nil, fmt.Errorf("failed to marshal snapshot: %v", err)
|
||||||
@@ -384,7 +367,7 @@ func (fsm *ConfigFSM) Snapshot() (raft.FSMSnapshot, error) {
|
|||||||
return &ConfigSnapshot{config: &snapshotData}, nil
|
return &ConfigSnapshot{config: &snapshotData}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore восстанавливает состояние FSM из снэпшота
|
// Restore восстанавливает состояние FSM из снэпшота.
|
||||||
func (fsm *ConfigFSM) Restore(snapshot io.ReadCloser) error {
|
func (fsm *ConfigFSM) Restore(snapshot io.ReadCloser) error {
|
||||||
defer snapshot.Close()
|
defer snapshot.Close()
|
||||||
|
|
||||||
@@ -407,37 +390,34 @@ func (fsm *ConfigFSM) Restore(snapshot io.ReadCloser) error {
|
|||||||
fsm.mu.Lock()
|
fsm.mu.Lock()
|
||||||
fsm.config = &data
|
fsm.config = &data
|
||||||
fsm.mu.Unlock()
|
fsm.mu.Unlock()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist сохраняет снэпшот
|
// Persist сохраняет снэпшот.
|
||||||
func (s *ConfigSnapshot) Persist(sink raft.SnapshotSink) error {
|
func (s *ConfigSnapshot) Persist(sink raft.SnapshotSink) error {
|
||||||
data, err := json.Marshal(s.config)
|
data, err := json.Marshal(s.config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sink.Cancel()
|
sink.Cancel()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := sink.Write(data); err != nil {
|
if _, err := sink.Write(data); err != nil {
|
||||||
sink.Cancel()
|
sink.Cancel()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return sink.Close()
|
return sink.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release освобождает ресурсы снэпшота
|
// Release освобождает ресурсы снэпшота.
|
||||||
func (s *ConfigSnapshot) Release() {}
|
func (s *ConfigSnapshot) Release() {}
|
||||||
|
|
||||||
// RegisterListener регистрирует слушатель изменений конфигурации
|
// RegisterListener регистрирует слушатель изменений конфигурации.
|
||||||
func (dm *DynamicConfigManager) RegisterListener(name string, listener ConfigChangeListener) {
|
func (dm *DynamicConfigManager) RegisterListener(name string, listener ConfigChangeListener) {
|
||||||
dm.listenerMu.Lock()
|
dm.listenerMu.Lock()
|
||||||
defer dm.listenerMu.Unlock()
|
defer dm.listenerMu.Unlock()
|
||||||
dm.listeners[name] = listener
|
dm.listeners[name] = listener
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnregisterListener удаляет слушатель
|
// UnregisterListener удаляет слушатель.
|
||||||
func (dm *DynamicConfigManager) UnregisterListener(name string) {
|
func (dm *DynamicConfigManager) UnregisterListener(name string) {
|
||||||
dm.listenerMu.Lock()
|
dm.listenerMu.Lock()
|
||||||
defer dm.listenerMu.Unlock()
|
defer dm.listenerMu.Unlock()
|
||||||
@@ -445,8 +425,6 @@ func (dm *DynamicConfigManager) UnregisterListener(name string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// notifyListeners уведомляет слушателей об изменении.
|
// notifyListeners уведомляет слушателей об изменении.
|
||||||
// Каждый слушатель вызывается в отдельной горутине; паника в слушателе
|
|
||||||
// не должна ронять процесс.
|
|
||||||
func (dm *DynamicConfigManager) notifyListeners(change *DynamicConfigChange) {
|
func (dm *DynamicConfigManager) notifyListeners(change *DynamicConfigChange) {
|
||||||
dm.listenerMu.RLock()
|
dm.listenerMu.RLock()
|
||||||
defer dm.listenerMu.RUnlock()
|
defer dm.listenerMu.RUnlock()
|
||||||
@@ -468,28 +446,22 @@ func (dm *DynamicConfigManager) notifyListeners(change *DynamicConfigChange) {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// deepCopyConfig создаёт глубокую копию конфигурации через JSON.
|
// deepCopyConfig создаёт глубокую копию конфигурации через JSON.
|
||||||
// Если src == nil, возвращает &Config{} (не nil), чтобы избежать паник.
|
|
||||||
func deepCopyConfig(src *Config) *Config {
|
func deepCopyConfig(src *Config) *Config {
|
||||||
if src == nil {
|
if src == nil {
|
||||||
return &Config{}
|
return &Config{}
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := json.Marshal(src)
|
data, err := json.Marshal(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Возвращаем пустую копию, а не nil, чтобы не сломать вызывающий код
|
|
||||||
return &Config{}
|
return &Config{}
|
||||||
}
|
}
|
||||||
|
|
||||||
var dst Config
|
var dst Config
|
||||||
if err := json.Unmarshal(data, &dst); err != nil {
|
if err := json.Unmarshal(data, &dst); err != nil {
|
||||||
return &Config{}
|
return &Config{}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &dst
|
return &dst
|
||||||
}
|
}
|
||||||
|
|
||||||
// coerceInt пытается привести значение к int.
|
// coerceInt пытается привести значение к int.
|
||||||
// Поддерживает int, int32, int64, uint, uint32, uint64, float32, float64, json.Number.
|
|
||||||
func coerceInt(value interface{}) (int, bool) {
|
func coerceInt(value interface{}) (int, bool) {
|
||||||
switch v := value.(type) {
|
switch v := value.(type) {
|
||||||
case int:
|
case int:
|
||||||
@@ -627,8 +599,7 @@ func coerceString(value interface{}) (string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// setConfigField устанавливает значение поля конфигурации по пути.
|
// setConfigField устанавливает значение поля конфигурации по пути.
|
||||||
// Поддерживает основные секции: cluster, storage, replication, wal, mvcc,
|
// Используется FSM при применении изменений из Raft.
|
||||||
// saga, backpressure, api, log, plugins, autoscaling, monitoring, transactions.
|
|
||||||
func setConfigField(config *Config, path string, value interface{}) error {
|
func setConfigField(config *Config, path string, value interface{}) error {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return fmt.Errorf("empty config path")
|
return fmt.Errorf("empty config path")
|
||||||
@@ -1087,12 +1058,64 @@ func setConfigField(config *Config, path string, value interface{}) error {
|
|||||||
}
|
}
|
||||||
return fmt.Errorf("transactions.max_savepoints_per_tx: expected int, got %T", value)
|
return fmt.Errorf("transactions.max_savepoints_per_tx: expected int, got %T", value)
|
||||||
|
|
||||||
|
// ===== compression =====
|
||||||
|
case "compression.enabled":
|
||||||
|
if v, ok := coerceBool(value); ok {
|
||||||
|
config.Compression.Enabled = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("compression.enabled: expected bool, got %T", value)
|
||||||
|
case "compression.algorithm":
|
||||||
|
if v, ok := coerceString(value); ok {
|
||||||
|
config.Compression.Algorithm = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("compression.algorithm: expected string, got %T", value)
|
||||||
|
case "compression.level":
|
||||||
|
if v, ok := coerceInt(value); ok {
|
||||||
|
config.Compression.Level = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("compression.level: expected int, got %T", value)
|
||||||
|
case "compression.min_size":
|
||||||
|
if v, ok := coerceInt(value); ok {
|
||||||
|
config.Compression.MinSize = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("compression.min_size: expected int, got %T", value)
|
||||||
|
|
||||||
|
// ===== performance =====
|
||||||
|
case "performance.enable_pipeline":
|
||||||
|
if v, ok := coerceBool(value); ok {
|
||||||
|
config.Performance.EnablePipeline = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("performance.enable_pipeline: expected bool, got %T", value)
|
||||||
|
case "performance.batch_size":
|
||||||
|
if v, ok := coerceInt(value); ok {
|
||||||
|
config.Performance.BatchSize = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("performance.batch_size: expected int, got %T", value)
|
||||||
|
case "performance.max_connections":
|
||||||
|
if v, ok := coerceInt(value); ok {
|
||||||
|
config.Performance.MaxConnections = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("performance.max_connections: expected int, got %T", value)
|
||||||
|
case "performance.read_from_follower":
|
||||||
|
if v, ok := coerceBool(value); ok {
|
||||||
|
config.Performance.ReadFromFollower = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("performance.read_from_follower: expected bool, got %T", value)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown config path: %s", path)
|
return fmt.Errorf("unknown config path: %s", path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLeaderStatus возвращает статус лидера
|
// GetLeaderStatus возвращает статус лидера.
|
||||||
func (dm *DynamicConfigManager) GetLeaderStatus() bool {
|
func (dm *DynamicConfigManager) GetLeaderStatus() bool {
|
||||||
dm.mu.RLock()
|
dm.mu.RLock()
|
||||||
r := dm.raft
|
r := dm.raft
|
||||||
@@ -1110,7 +1133,7 @@ func (dm *DynamicConfigManager) SetLeaderStatus(isLeader bool) {
|
|||||||
dm.isLeader.Store(isLeader)
|
dm.isLeader.Store(isLeader)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetListeners возвращает список зарегистрированных слушателей
|
// GetListeners возвращает список зарегистрированных слушателей.
|
||||||
func (dm *DynamicConfigManager) GetListeners() []string {
|
func (dm *DynamicConfigManager) GetListeners() []string {
|
||||||
dm.listenerMu.RLock()
|
dm.listenerMu.RLock()
|
||||||
defer dm.listenerMu.RUnlock()
|
defer dm.listenerMu.RUnlock()
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+72
-128
@@ -9,27 +9,37 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Файл: internal/repl/pager.go
|
// Файл: internal/repl/pager.go
|
||||||
// Назначение: Пейджер для постраничного вывода длинных результатов.
|
// Назначение: Простой пейджер для длинных выводов REPL.
|
||||||
// Использует внешний pager (less/more) если доступен, иначе встроенный.
|
// Если количество строк превышает threshold, вывод отправляется в
|
||||||
// Совместимо с Linux и OpenIndiana.
|
// внешний пейджер (по умолчанию `less -R`), иначе печатается напрямую.
|
||||||
|
//
|
||||||
|
// ДОБАВЛЕНО (2026-10):
|
||||||
|
// - Threshold() / SetThreshold() — геттер и сеттер порога включения
|
||||||
|
// пейджера. Используются REPL-командой `pager size <N>` /
|
||||||
|
// `pager threshold <N>`. Значения <= 0 игнорируются, чтобы случайно
|
||||||
|
// не сломать вывод.
|
||||||
|
|
||||||
package repl
|
package repl
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pager предоставляет постраничный вывод.
|
// Pager — простой пейджер.
|
||||||
|
//
|
||||||
|
// Все поля читаются/пишутся только из REPL-цикла (последовательный
|
||||||
|
// доступ), поэтому мьютекс здесь не нужен. Если пейджер начнёт
|
||||||
|
// использоваться из фоновых горутин — добавьте sync.RWMutex.
|
||||||
type Pager struct {
|
type Pager struct {
|
||||||
enabled bool
|
enabled bool
|
||||||
threshold int
|
threshold int
|
||||||
external string
|
external string
|
||||||
lines int
|
lines int
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPager создаёт пейджер.
|
// NewPager создаёт пейджер.
|
||||||
@@ -37,49 +47,40 @@ func NewPager(enabled bool, threshold int) *Pager {
|
|||||||
p := &Pager{
|
p := &Pager{
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
threshold: threshold,
|
threshold: threshold,
|
||||||
lines: 24,
|
external: defaultPagerCommand(),
|
||||||
|
lines: 0,
|
||||||
}
|
}
|
||||||
p.external = detectPager()
|
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectPager ищет доступный внешний pager.
|
// Enabled возвращает статус пейджера.
|
||||||
// Порядок: $PAGER -> less -> more -> "" (встроенный).
|
|
||||||
func detectPager() string {
|
|
||||||
if env := os.Getenv("PAGER"); env != "" {
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
if path, err := exec.LookPath("less"); err == nil {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
if path, err := exec.LookPath("more"); err == nil {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enabled возвращает, включён ли пейджер.
|
|
||||||
func (p *Pager) Enabled() bool {
|
func (p *Pager) Enabled() bool {
|
||||||
return p != nil && p.enabled
|
return p.enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetEnabled включает/выключает пейджер.
|
// SetEnabled включает или отключает пейджер.
|
||||||
func (p *Pager) SetEnabled(v bool) {
|
func (p *Pager) SetEnabled(enabled bool) {
|
||||||
if p != nil {
|
p.enabled = enabled
|
||||||
p.enabled = v
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetThreshold устанавливает порог строк.
|
// Threshold возвращает порог включения пейджера (в строках).
|
||||||
|
// Если вывод короче порога — пейджер не используется.
|
||||||
|
func (p *Pager) Threshold() int {
|
||||||
|
return p.threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetThreshold устанавливает порог включения пейджера.
|
||||||
|
// Значения <= 0 игнорируются, чтобы случайно не сломать вывод.
|
||||||
func (p *Pager) SetThreshold(n int) {
|
func (p *Pager) SetThreshold(n int) {
|
||||||
if p != nil && n > 0 {
|
if n > 0 {
|
||||||
p.threshold = n
|
p.threshold = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Page выводит текст через пейджер, если он длиннее порога.
|
// Page отображает текст через пейджер, если он включён и текст достаточно
|
||||||
|
// длинный. Иначе печатает напрямую в stdout.
|
||||||
func (p *Pager) Page(text string) error {
|
func (p *Pager) Page(text string) error {
|
||||||
if p == nil || !p.enabled {
|
if !p.enabled {
|
||||||
fmt.Print(text)
|
fmt.Print(text)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -90,106 +91,49 @@ func (p *Pager) Page(text string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.external != "" {
|
if p.external == "" {
|
||||||
if err := p.pageExternal(text); err == nil {
|
// Нет внешнего пейджера — печатаем напрямую.
|
||||||
return nil
|
fmt.Print(text)
|
||||||
}
|
return nil
|
||||||
// fallback на встроенный
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.pageBuiltin(text)
|
return p.pipeToExternal(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageExternal запускает внешний pager.
|
// pipeToExternal отправляет текст во внешний пейджер.
|
||||||
func (p *Pager) pageExternal(text string) error {
|
func (p *Pager) pipeToExternal(text string) error {
|
||||||
args := []string{}
|
parts := strings.Fields(p.external)
|
||||||
base := strings.ToLower(p.external)
|
if len(parts) == 0 {
|
||||||
|
fmt.Print(text)
|
||||||
if strings.Contains(base, "less") {
|
return nil
|
||||||
// -R: сохранять ANSI-цвета, -F: выйти если всё влезает, -X: не чистить экран
|
|
||||||
args = append(args, "-RFX")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(p.external, args...)
|
cmd := exec.Command(parts[0], parts[1:]...)
|
||||||
|
cmd.Stdin = bytes.NewBufferString(text)
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
stdin, err := cmd.StdinPipe()
|
if err := cmd.Run(); err != nil {
|
||||||
if err != nil {
|
// Если внешний пейджер упал — печатаем напрямую.
|
||||||
|
fmt.Print(text)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := io.WriteString(stdin, text); err != nil {
|
|
||||||
stdin.Close()
|
|
||||||
_ = cmd.Wait()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
stdin.Close()
|
|
||||||
|
|
||||||
return cmd.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
// pageBuiltin — встроенный пейджер (fallback).
|
|
||||||
// Управление: Enter — следующая строка, Space — страница, q — выход.
|
|
||||||
func (p *Pager) pageBuiltin(text string) error {
|
|
||||||
scanner := bufio.NewScanner(strings.NewReader(text))
|
|
||||||
buf := make([]byte, 0, 64*1024)
|
|
||||||
scanner.Buffer(buf, 1024*1024)
|
|
||||||
|
|
||||||
lines := []string{}
|
|
||||||
for scanner.Scan() {
|
|
||||||
lines = append(lines, scanner.Text())
|
|
||||||
}
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
pageSize := p.lines - 1
|
|
||||||
if pageSize < 5 {
|
|
||||||
pageSize = 5
|
|
||||||
}
|
|
||||||
|
|
||||||
reader := bufio.NewReader(os.Stdin)
|
|
||||||
pos := 0
|
|
||||||
|
|
||||||
for pos < len(lines) {
|
|
||||||
end := pos + pageSize
|
|
||||||
if end > len(lines) {
|
|
||||||
end = len(lines)
|
|
||||||
}
|
|
||||||
for i := pos; i < end; i++ {
|
|
||||||
fmt.Println(lines[i])
|
|
||||||
}
|
|
||||||
pos = end
|
|
||||||
|
|
||||||
if pos >= len(lines) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\033[7m--More--\033[0m (Enter/Space/q) ")
|
|
||||||
ch, err := reader.ReadByte()
|
|
||||||
fmt.Print("\r\033[K")
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
switch ch {
|
|
||||||
case 'q', 'Q':
|
|
||||||
return nil
|
|
||||||
case '\n', '\r':
|
|
||||||
// одна строка
|
|
||||||
if pos < len(lines) {
|
|
||||||
fmt.Println(lines[pos])
|
|
||||||
pos++
|
|
||||||
}
|
|
||||||
case ' ':
|
|
||||||
// страница
|
|
||||||
default:
|
|
||||||
// игнорируем
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultPagerCommand выбирает команду пейджера в зависимости от ОС.
|
||||||
|
func defaultPagerCommand() string {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
return "more"
|
||||||
|
default:
|
||||||
|
// less -R: -R сохраняет ANSI-цвета, используется на Linux/BSD/illumos.
|
||||||
|
if _, err := exec.LookPath("less"); err == nil {
|
||||||
|
return "less -R"
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("more"); err == nil {
|
||||||
|
return "more"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
+1393
-371
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)
|
||||||
}
|
}
|
||||||
@@ -1,481 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2026 Safronov Grigorii
|
|
||||||
*
|
|
||||||
* Licensed under the CDDL, Version 1.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
*
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
* https://opensource.org/licenses/CDDL-1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Файл: internal/storage/aof.go
|
|
||||||
// Назначение: Append-Only File (AOF) для мутаций, выполненных вне batch.
|
|
||||||
//
|
|
||||||
// ЗАЧЕМ НУЖЕН AOF:
|
|
||||||
// WAL покрывает только batch-операции (групповые мутации).
|
|
||||||
// Прямые вызовы coll.Insert / coll.Update / coll.Delete идут мимо WAL.
|
|
||||||
// AOF логирует каждую отдельную мутацию и позволяет:
|
|
||||||
// 1. Восстановить состояние после краха без потери данных.
|
|
||||||
// 2. Вести полный audit trail.
|
|
||||||
// 3. Использовать для репликации.
|
|
||||||
//
|
|
||||||
// ФОРМАТ:
|
|
||||||
// Каждая запись — JSON-строка, заканчивающаяся '\n'.
|
|
||||||
// Поля: type, database, collection, document_id, data, timestamp, crc.
|
|
||||||
// Файлы разбиваются по размеру (по умолчанию 64 МБ).
|
|
||||||
// При старте AOFManager сканирует директорию и загружает все записи.
|
|
||||||
|
|
||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// КОНСТАНТЫ
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const (
|
|
||||||
// Максимальный размер одного AOF-файла.
|
|
||||||
AOFMaxFileSize = 64 * 1024 * 1024
|
|
||||||
|
|
||||||
// Префикс и суффикс AOF-файлов.
|
|
||||||
AOFFilePrefix = "aof_"
|
|
||||||
AOFFileSuffix = ".log"
|
|
||||||
|
|
||||||
// Размер буфера для bufio.Writer.
|
|
||||||
AOFBufferSize = 64 * 1024
|
|
||||||
)
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// ТИПЫ
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
// AOFOperation — одна операция в AOF.
|
|
||||||
type AOFOperation struct {
|
|
||||||
Type string `json:"type"` // insert, update, delete, restore
|
|
||||||
Database string `json:"database"`
|
|
||||||
Collection string `json:"collection"`
|
|
||||||
DocumentID string `json:"document_id"`
|
|
||||||
Data map[string]interface{} `json:"data,omitempty"`
|
|
||||||
Timestamp int64 `json:"timestamp"`
|
|
||||||
CRC uint32 `json:"crc,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AOFManager управляет append-only file.
|
|
||||||
type AOFManager struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
dir string
|
|
||||||
currentFile *os.File
|
|
||||||
writer *bufio.Writer
|
|
||||||
currentSize int64
|
|
||||||
fileIndex uint32
|
|
||||||
fsyncEnabled bool
|
|
||||||
logger LoggerInterface
|
|
||||||
closed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAOFManager создаёт AOF-менеджер.
|
|
||||||
func NewAOFManager(dir string, fsyncEnabled bool, logger LoggerInterface) (*AOFManager, error) {
|
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create AOF directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
aof := &AOFManager{
|
|
||||||
dir: dir,
|
|
||||||
fsyncEnabled: fsyncEnabled,
|
|
||||||
logger: logger,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Находим максимальный индекс файла.
|
|
||||||
files, err := aof.listFiles()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(files) > 0 {
|
|
||||||
lastFile := files[len(files)-1]
|
|
||||||
var idx uint32
|
|
||||||
if _, err := fmt.Sscanf(filepath.Base(lastFile), AOFFilePrefix+"%d"+AOFFileSuffix, &idx); err == nil {
|
|
||||||
aof.fileIndex = idx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Открываем текущий файл на дозапись.
|
|
||||||
if err := aof.openCurrentFile(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return aof, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetLogger устанавливает логгер.
|
|
||||||
func (aof *AOFManager) SetLogger(logger LoggerInterface) {
|
|
||||||
aof.mu.Lock()
|
|
||||||
defer aof.mu.Unlock()
|
|
||||||
aof.logger = logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// listFiles возвращает отсортированный список AOF-файлов.
|
|
||||||
func (aof *AOFManager) listFiles() ([]string, error) {
|
|
||||||
pattern := filepath.Join(aof.dir, AOFFilePrefix+"*"+AOFFileSuffix)
|
|
||||||
files, err := filepath.Glob(pattern)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sort.Strings(files)
|
|
||||||
return files, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// openCurrentFile открывает текущий файл для дозаписи.
|
|
||||||
func (aof *AOFManager) openCurrentFile() error {
|
|
||||||
path := filepath.Join(aof.dir, fmt.Sprintf("%s%d%s", AOFFilePrefix, aof.fileIndex, AOFFileSuffix))
|
|
||||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_RDWR, 0644)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to open AOF file: %v", err)
|
|
||||||
}
|
|
||||||
stat, err := f.Stat()
|
|
||||||
if err != nil {
|
|
||||||
f.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
aof.currentFile = f
|
|
||||||
aof.writer = bufio.NewWriterSize(f, AOFBufferSize)
|
|
||||||
aof.currentSize = stat.Size()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// rotateIfNeeded проверяет размер и ротирует файл.
|
|
||||||
func (aof *AOFManager) rotateIfNeeded() error {
|
|
||||||
if aof.currentSize < AOFMaxFileSize {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := aof.writer.Flush(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if aof.fsyncEnabled {
|
|
||||||
RealFsync(aof.currentFile)
|
|
||||||
}
|
|
||||||
if err := aof.currentFile.Close(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
aof.fileIndex++
|
|
||||||
return aof.openCurrentFile()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append записывает одну операцию в AOF.
|
|
||||||
func (aof *AOFManager) Append(op *AOFOperation) error {
|
|
||||||
aof.mu.Lock()
|
|
||||||
defer aof.mu.Unlock()
|
|
||||||
|
|
||||||
if aof.closed {
|
|
||||||
return fmt.Errorf("AOF is closed")
|
|
||||||
}
|
|
||||||
|
|
||||||
op.Timestamp = time.Now().UnixMilli()
|
|
||||||
|
|
||||||
// Вычисляем CRC.
|
|
||||||
data, err := json.Marshal(op)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
op.CRC = crc32(data)
|
|
||||||
data, err = json.Marshal(op)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data = append(data, '\n')
|
|
||||||
|
|
||||||
if _, err := aof.writer.Write(data); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
aof.currentSize += int64(len(data))
|
|
||||||
|
|
||||||
if err := aof.writer.Flush(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if aof.fsyncEnabled {
|
|
||||||
RealFsync(aof.currentFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
return aof.rotateIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppendBatch записывает все операции batch в AOF.
|
|
||||||
func (aof *AOFManager) AppendBatch(batch *Batch) error {
|
|
||||||
for _, op := range batch.Operations {
|
|
||||||
aofOp := &AOFOperation{
|
|
||||||
Type: op.Type,
|
|
||||||
Database: op.Database,
|
|
||||||
Collection: op.Collection,
|
|
||||||
DocumentID: op.DocumentID,
|
|
||||||
Data: op.Data,
|
|
||||||
}
|
|
||||||
if err := aof.Append(aofOp); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadAll читает все операции из всех AOF-файлов.
|
|
||||||
func (aof *AOFManager) ReadAll() ([]*AOFOperation, error) {
|
|
||||||
aof.mu.Lock()
|
|
||||||
defer aof.mu.Unlock()
|
|
||||||
|
|
||||||
files, err := aof.listFiles()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ops := make([]*AOFOperation, 0)
|
|
||||||
for _, file := range files {
|
|
||||||
fileOps, err := aof.readFile(file)
|
|
||||||
if err != nil {
|
|
||||||
if aof.logger != nil {
|
|
||||||
aof.logger.Warn(fmt.Sprintf("Failed to read AOF file %s: %v", file, err))
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ops = append(ops, fileOps...)
|
|
||||||
}
|
|
||||||
return ops, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// readFile читает операции из одного файла.
|
|
||||||
func (aof *AOFManager) readFile(path string) ([]*AOFOperation, error) {
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
ops := make([]*AOFOperation, 0)
|
|
||||||
scanner := bufio.NewScanner(f)
|
|
||||||
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Bytes()
|
|
||||||
if len(line) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var op AOFOperation
|
|
||||||
if err := json.Unmarshal(line, &op); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Проверяем CRC.
|
|
||||||
expectedCRC := op.CRC
|
|
||||||
op.CRC = 0
|
|
||||||
data, _ := json.Marshal(&op)
|
|
||||||
calculatedCRC := crc32(data)
|
|
||||||
op.CRC = expectedCRC
|
|
||||||
if calculatedCRC != expectedCRC {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ops = append(ops, &op)
|
|
||||||
}
|
|
||||||
return ops, scanner.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replay воспроизводит все операции AOF, применяя их к хранилищу.
|
|
||||||
//
|
|
||||||
// Используется при старте для восстановления состояния после краха.
|
|
||||||
func (aof *AOFManager) Replay(storage *Storage) error {
|
|
||||||
ops, err := aof.ReadAll()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(ops) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if aof.logger != nil {
|
|
||||||
aof.logger.Info(fmt.Sprintf("Replaying %d AOF operations", len(ops)))
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, op := range ops {
|
|
||||||
db, err := storage.GetDatabase(op.Database)
|
|
||||||
if err != nil {
|
|
||||||
// База данных могла быть создана позже — пропускаем.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
coll, err := db.GetCollection(op.Collection)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
switch op.Type {
|
|
||||||
case "insert":
|
|
||||||
doc := NewDocumentWithID(op.DocumentID)
|
|
||||||
for k, v := range op.Data {
|
|
||||||
doc.SetField(k, v)
|
|
||||||
}
|
|
||||||
doc.CreatedAt = op.Timestamp
|
|
||||||
doc.UpdatedAt = op.Timestamp
|
|
||||||
coll.Insert(doc)
|
|
||||||
case "update":
|
|
||||||
coll.Update(op.DocumentID, op.Data)
|
|
||||||
case "delete":
|
|
||||||
coll.Delete(op.DocumentID)
|
|
||||||
case "restore":
|
|
||||||
coll.RestoreDeleted(op.DocumentID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close закрывает AOF-менеджер.
|
|
||||||
func (aof *AOFManager) Close() error {
|
|
||||||
aof.mu.Lock()
|
|
||||||
defer aof.mu.Unlock()
|
|
||||||
|
|
||||||
if aof.closed {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
aof.closed = true
|
|
||||||
|
|
||||||
if aof.writer != nil {
|
|
||||||
if err := aof.writer.Flush(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if aof.currentFile != nil {
|
|
||||||
if aof.fsyncEnabled {
|
|
||||||
RealFsync(aof.currentFile)
|
|
||||||
}
|
|
||||||
return aof.currentFile.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStats возвращает статистику AOF.
|
|
||||||
func (aof *AOFManager) GetStats() map[string]interface{} {
|
|
||||||
aof.mu.Lock()
|
|
||||||
defer aof.mu.Unlock()
|
|
||||||
|
|
||||||
files, _ := aof.listFiles()
|
|
||||||
var totalSize int64
|
|
||||||
for _, f := range files {
|
|
||||||
if stat, err := os.Stat(f); err == nil {
|
|
||||||
totalSize += stat.Size()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return map[string]interface{}{
|
|
||||||
"file_count": len(files),
|
|
||||||
"total_size": totalSize,
|
|
||||||
"current_file": fmt.Sprintf("%s%d%s", AOFFilePrefix, aof.fileIndex, AOFFileSuffix),
|
|
||||||
"current_size": aof.currentSize,
|
|
||||||
"fsync_enabled": aof.fsyncEnabled,
|
|
||||||
"dir": aof.dir,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// ИНТЕГРАЦИЯ С COLLECTION
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
// LogAOFInsert логирует вставку документа.
|
|
||||||
func LogAOFInsert(dbName, collName string, doc *Document) {
|
|
||||||
if globalBatchManager == nil || globalBatchManager.aof == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
op := &AOFOperation{
|
|
||||||
Type: "insert",
|
|
||||||
Database: dbName,
|
|
||||||
Collection: collName,
|
|
||||||
DocumentID: doc.ID,
|
|
||||||
Data: doc.GetFields(),
|
|
||||||
}
|
|
||||||
if err := globalBatchManager.aof.Append(op); err != nil {
|
|
||||||
if globalBatchManager.logger != nil {
|
|
||||||
globalBatchManager.logger.Warn(fmt.Sprintf("AOF insert failed: %v", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogAOFUpdate логирует обновление документа.
|
|
||||||
func LogAOFUpdate(dbName, collName, docID string, updates map[string]interface{}) {
|
|
||||||
if globalBatchManager == nil || globalBatchManager.aof == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
op := &AOFOperation{
|
|
||||||
Type: "update",
|
|
||||||
Database: dbName,
|
|
||||||
Collection: collName,
|
|
||||||
DocumentID: docID,
|
|
||||||
Data: updates,
|
|
||||||
}
|
|
||||||
if err := globalBatchManager.aof.Append(op); err != nil {
|
|
||||||
if globalBatchManager.logger != nil {
|
|
||||||
globalBatchManager.logger.Warn(fmt.Sprintf("AOF update failed: %v", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogAOFDelete логирует удаление документа.
|
|
||||||
func LogAOFDelete(dbName, collName, docID string) {
|
|
||||||
if globalBatchManager == nil || globalBatchManager.aof == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
op := &AOFOperation{
|
|
||||||
Type: "delete",
|
|
||||||
Database: dbName,
|
|
||||||
Collection: collName,
|
|
||||||
DocumentID: docID,
|
|
||||||
}
|
|
||||||
if err := globalBatchManager.aof.Append(op); err != nil {
|
|
||||||
if globalBatchManager.logger != nil {
|
|
||||||
globalBatchManager.logger.Warn(fmt.Sprintf("AOF delete failed: %v", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogAOFRestore логирует восстановление документа.
|
|
||||||
func LogAOFRestore(dbName, collName, docID string) {
|
|
||||||
if globalBatchManager == nil || globalBatchManager.aof == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
op := &AOFOperation{
|
|
||||||
Type: "restore",
|
|
||||||
Database: dbName,
|
|
||||||
Collection: collName,
|
|
||||||
DocumentID: docID,
|
|
||||||
}
|
|
||||||
if err := globalBatchManager.aof.Append(op); err != nil {
|
|
||||||
if globalBatchManager.logger != nil {
|
|
||||||
globalBatchManager.logger.Warn(fmt.Sprintf("AOF restore failed: %v", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAOFStats возвращает статистику AOF.
|
|
||||||
func GetAOFStats() map[string]interface{} {
|
|
||||||
if globalBatchManager == nil || globalBatchManager.aof == nil {
|
|
||||||
return map[string]interface{}{"error": "AOF not initialized"}
|
|
||||||
}
|
|
||||||
return globalBatchManager.aof.GetStats()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReplayAOF воспроизводит AOF при старте.
|
|
||||||
func ReplayAOF(storage *Storage) error {
|
|
||||||
if globalBatchManager == nil || globalBatchManager.aof == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return globalBatchManager.aof.Replay(storage)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// ВСПОМОГАТЕЛЬНЫЕ
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
// trimAOFExtension убирает суффикс из имени файла.
|
|
||||||
func trimAOFExtension(name string) string {
|
|
||||||
return strings.TrimSuffix(name, AOFFileSuffix)
|
|
||||||
}
|
|
||||||
+1303
-1516
File diff suppressed because it is too large.
Load diff
+519
-168
File diff suppressed because it is too large.
Load diff
+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,
|
||||||
|
|||||||
@@ -33,11 +33,11 @@
|
|||||||
// Transaction — теперь эти ссылки заменены на GetBatchManager() и *Batch.
|
// Transaction — теперь эти ссылки заменены на GetBatchManager() и *Batch.
|
||||||
//
|
//
|
||||||
// ДОБАВЛЕНО (2026-10, идемпотентность SAGA + выборы лидера):
|
// ДОБАВЛЕНО (2026-10, идемпотентность SAGA + выборы лидера):
|
||||||
// - SagaPersistentStorage теперь ведёт персистентную таблицу
|
// - SagaPersistentStorage ведёт персистентную таблицу выполненных
|
||||||
// выполненных ExecutionID (saga_executed_keys.json). Перед выполнением
|
// ExecutionID (saga_executed_keys.json). Перед выполнением шага
|
||||||
// шага и перед его компенсацией оркестратор проверяет эту таблицу,
|
// и перед его компенсацией оркестратор проверяет эту таблицу,
|
||||||
// чтобы повторный запуск SAGA после падения не приводил к дублированию
|
// чтобы повторный запуск SAGA после падения не приводил к
|
||||||
// операций на удалённых узлах.
|
// дублированию операций на удалённых узлах.
|
||||||
// - SagaOrchestrator получил SagaLeaderElector, который берёт лидера
|
// - SagaOrchestrator получил SagaLeaderElector, который берёт лидера
|
||||||
// из Raft (через SagaRaftAccessor). Если Raft недоступен —
|
// из Raft (через SagaRaftAccessor). Если Raft недоступен —
|
||||||
// используется резервный режим «единственный узел — лидер».
|
// используется резервный режим «единственный узел — лидер».
|
||||||
@@ -48,8 +48,15 @@
|
|||||||
// узел знал, был ли он лидером, и корректно себя вёл до первых
|
// узел знал, был ли он лидером, и корректно себя вёл до первых
|
||||||
// выборов.
|
// выборов.
|
||||||
// - Добавлен интерфейс SagaRaftAccessor, чтобы storage не зависел
|
// - Добавлен интерфейс SagaRaftAccessor, чтобы storage не зависел
|
||||||
// напрямую от hashicorp/raft (это позволяет избежать циклических
|
// напрямую от hashicorp/raft.
|
||||||
// импортов и упрощает тестирование).
|
//
|
||||||
|
// ДОБАВЛЕНО (2026-10, ревизия):
|
||||||
|
// - GetAllSagas у SagaManager — читает все SAGA (включая завершённые)
|
||||||
|
// с диска через SagaPersistentStorage.ListAll(). Используется
|
||||||
|
// REPL-командой "saga list --all".
|
||||||
|
//
|
||||||
|
// ИЗМЕНЕНО (2026-10): удалён AOF. BatchManager больше не содержит
|
||||||
|
// поле aof, и Commit не пишет в AOF. Единственный источник истины — WAL.
|
||||||
|
|
||||||
package storage
|
package storage
|
||||||
|
|
||||||
@@ -127,14 +134,12 @@ const (
|
|||||||
SagaCleanupInterval = 1 * time.Minute
|
SagaCleanupInterval = 1 * time.Minute
|
||||||
|
|
||||||
// SagaExecutedKeysFile — персистентная таблица выполненных ExecutionID.
|
// SagaExecutedKeysFile — персистентная таблица выполненных ExecutionID.
|
||||||
// Нужна для идемпотентности шагов SAGA при повторе после падения.
|
|
||||||
SagaExecutedKeysFile = "saga_executed_keys.json"
|
SagaExecutedKeysFile = "saga_executed_keys.json"
|
||||||
|
|
||||||
// SagaLeaderStateFile — персистентное состояние лидера SAGA.
|
// SagaLeaderStateFile — персистентное состояние лидера SAGA.
|
||||||
SagaLeaderStateFile = "saga_leader_state.json"
|
SagaLeaderStateFile = "saga_leader_state.json"
|
||||||
|
|
||||||
// SagaLeaderElectionInterval — как часто опрашиваем Raft на предмет
|
// SagaLeaderElectionInterval — как часто опрашиваем Raft на предмет лидерства.
|
||||||
// лидерства. Меньше — быстрее реакция, но больше нагрузка.
|
|
||||||
SagaLeaderElectionInterval = 2 * time.Second
|
SagaLeaderElectionInterval = 2 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -197,7 +202,7 @@ func crc32(data []byte) uint32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// BATCH MANAGER - замена TransactionManager
|
// BATCH MANAGER — замена TransactionManager
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// BatchManager управляет batch-операциями и WAL.
|
// BatchManager управляет batch-операциями и WAL.
|
||||||
@@ -207,12 +212,8 @@ type BatchManager struct {
|
|||||||
logger LoggerInterface
|
logger LoggerInterface
|
||||||
walPath string
|
walPath string
|
||||||
stats *BatchStats
|
stats *BatchStats
|
||||||
aof *AOFManager // append-only file для мутаций вне batch
|
|
||||||
|
|
||||||
// activeBatches — реестр активных (незакоммиченных) batch'ей.
|
// activeBatches — реестр активных (незакоммиченных) batch'ей.
|
||||||
// Ключ — Batch.ID, значение — *Batch.
|
|
||||||
// Используется eviction-механизмом (runtime_limits.go), чтобы не
|
|
||||||
// вытеснять документы, участвующие в незакоммиченной batch-операции.
|
|
||||||
activeBatches sync.Map
|
activeBatches sync.Map
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,14 +263,6 @@ func InitBatchManagerWithConfig(walPath string, config map[string]interface{}) e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инициализируем AOF для мутаций вне batch.
|
|
||||||
globalBatchManager.aof, err = NewAOFManager(
|
|
||||||
filepath.Join(filepath.Dir(walPath), "aof"),
|
|
||||||
fsyncEnabled, nil)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -284,9 +277,6 @@ func SetBatchLogger(logger LoggerInterface) {
|
|||||||
if globalBatchManager.wal != nil {
|
if globalBatchManager.wal != nil {
|
||||||
globalBatchManager.wal.logger = logger
|
globalBatchManager.wal.logger = logger
|
||||||
}
|
}
|
||||||
if globalBatchManager.aof != nil {
|
|
||||||
globalBatchManager.aof.SetLogger(logger)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +291,6 @@ func GetGlobalStorage() *Storage { return globalStorage }
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// RegisterBatch регистрирует batch как активный.
|
// RegisterBatch регистрирует batch как активный.
|
||||||
// Вызывается в NewBatch.
|
|
||||||
func (bm *BatchManager) RegisterBatch(b *Batch) {
|
func (bm *BatchManager) RegisterBatch(b *Batch) {
|
||||||
if bm == nil || b == nil {
|
if bm == nil || b == nil {
|
||||||
return
|
return
|
||||||
@@ -310,7 +299,6 @@ func (bm *BatchManager) RegisterBatch(b *Batch) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UnregisterBatch снимает регистрацию batch.
|
// UnregisterBatch снимает регистрацию batch.
|
||||||
// Вызывается в Commit (успешном или неуспешном).
|
|
||||||
func (bm *BatchManager) UnregisterBatch(b *Batch) {
|
func (bm *BatchManager) UnregisterBatch(b *Batch) {
|
||||||
if bm == nil || b == nil {
|
if bm == nil || b == nil {
|
||||||
return
|
return
|
||||||
@@ -318,11 +306,7 @@ func (bm *BatchManager) UnregisterBatch(b *Batch) {
|
|||||||
bm.activeBatches.Delete(b.ID)
|
bm.activeBatches.Delete(b.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsDocInActiveBatch проверяет, участвует ли документ в активной
|
// IsDocInActiveBatch проверяет, участвует ли документ в активной batch-операции.
|
||||||
// (незакоммиченной) batch-операции.
|
|
||||||
//
|
|
||||||
// Используется в runtime_limits.go (eviction), чтобы не вытеснять
|
|
||||||
// документы, которые в данный момент участвуют в batch.
|
|
||||||
func (bm *BatchManager) IsDocInActiveBatch(docID, dbName, collName string) bool {
|
func (bm *BatchManager) IsDocInActiveBatch(docID, dbName, collName string) bool {
|
||||||
if bm == nil {
|
if bm == nil {
|
||||||
return false
|
return false
|
||||||
@@ -347,7 +331,6 @@ func (bm *BatchManager) IsDocInActiveBatch(docID, dbName, collName string) bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetActiveBatchCount возвращает количество активных batch'ей.
|
// GetActiveBatchCount возвращает количество активных batch'ей.
|
||||||
// Полезно для метрик и отладки.
|
|
||||||
func (bm *BatchManager) GetActiveBatchCount() int {
|
func (bm *BatchManager) GetActiveBatchCount() int {
|
||||||
if bm == nil {
|
if bm == nil {
|
||||||
return 0
|
return 0
|
||||||
@@ -376,8 +359,6 @@ func NewBatch(database, collection string) *Batch {
|
|||||||
Database: database,
|
Database: database,
|
||||||
Collection: collection,
|
Collection: collection,
|
||||||
}
|
}
|
||||||
// Регистрируем batch как активный, чтобы eviction не вытеснил
|
|
||||||
// документы, участвующие в нём.
|
|
||||||
globalBatchManager.RegisterBatch(b)
|
globalBatchManager.RegisterBatch(b)
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
@@ -426,29 +407,17 @@ func (b *Batch) AddRestore(docID string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Commit применяет batch атомарно.
|
// Commit применяет batch атомарно.
|
||||||
//
|
|
||||||
// Гарантия атомарности:
|
|
||||||
// 1. Сериализуем весь batch и пишем в WAL (fsync).
|
|
||||||
// 2. Только после успешной записи в WAL применяем операции.
|
|
||||||
// 3. Если применение падает на середине — мы можем восстановить
|
|
||||||
// состояние, проиграв WAL (операции идемпотентны или
|
|
||||||
// откатываются вручную).
|
|
||||||
//
|
|
||||||
// В любом случае (успех или ошибка) batch снимается с регистрации
|
|
||||||
// в activeBatches.
|
|
||||||
func (b *Batch) Commit() error {
|
func (b *Batch) Commit() error {
|
||||||
if globalBatchManager == nil {
|
if globalBatchManager == nil {
|
||||||
return fmt.Errorf("batch manager not initialized")
|
return fmt.Errorf("batch manager not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Снимаем регистрацию в любом случае — batch короткоживущий.
|
|
||||||
defer globalBatchManager.UnregisterBatch(b)
|
defer globalBatchManager.UnregisterBatch(b)
|
||||||
|
|
||||||
if len(b.Operations) == 0 {
|
if len(b.Operations) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Пишем batch в WAL.
|
|
||||||
data, err := json.Marshal(b)
|
data, err := json.Marshal(b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to marshal batch: %v", err)
|
return fmt.Errorf("failed to marshal batch: %v", err)
|
||||||
@@ -460,37 +429,22 @@ func (b *Batch) Commit() error {
|
|||||||
return fmt.Errorf("failed to write batch to WAL: %v", err)
|
return fmt.Errorf("failed to write batch to WAL: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Применяем операции.
|
|
||||||
if err := b.apply(); err != nil {
|
if err := b.apply(); err != nil {
|
||||||
globalBatchManager.stats.TotalFailed.Add(1)
|
globalBatchManager.stats.TotalFailed.Add(1)
|
||||||
return fmt.Errorf("batch apply failed: %v", err)
|
return fmt.Errorf("batch apply failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Также пишем в AOF для быстрого восстановления (опционально).
|
|
||||||
if globalBatchManager.aof != nil {
|
|
||||||
if err := globalBatchManager.aof.AppendBatch(b); err != nil {
|
|
||||||
if globalBatchManager.logger != nil {
|
|
||||||
globalBatchManager.logger.Warn(fmt.Sprintf("AOF append failed: %v", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
globalBatchManager.stats.TotalBatches.Add(1)
|
globalBatchManager.stats.TotalBatches.Add(1)
|
||||||
globalBatchManager.stats.TotalOperations.Add(uint64(len(b.Operations)))
|
globalBatchManager.stats.TotalOperations.Add(uint64(len(b.Operations)))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// apply применяет все операции batch.
|
// apply применяет все операции batch.
|
||||||
//
|
|
||||||
// Если операция падает, мы пытаемся откатить уже применённые
|
|
||||||
// (best-effort). Полная атомарность гарантируется только на уровне
|
|
||||||
// WAL: при восстановлении batch либо весь применится, либо нет.
|
|
||||||
func (b *Batch) apply() error {
|
func (b *Batch) apply() error {
|
||||||
if globalStorage == nil {
|
if globalStorage == nil {
|
||||||
return fmt.Errorf("storage not initialized")
|
return fmt.Errorf("storage not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Собираем откат для уже применённых операций.
|
|
||||||
type appliedOp struct {
|
type appliedOp struct {
|
||||||
opType string
|
opType string
|
||||||
db string
|
db string
|
||||||
@@ -592,11 +546,7 @@ func (b *Batch) apply() error {
|
|||||||
// TIME-TRAVEL QUERIES (WAL-based)
|
// TIME-TRAVEL QUERIES (WAL-based)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// GetDocumentAtTimestamp возвращает документ на указанный момент времени,
|
// GetDocumentAtTimestamp возвращает документ на указанный момент времени.
|
||||||
// проигрывая WAL с начала до нужного LSN.
|
|
||||||
//
|
|
||||||
// Это O(N) по количеству записей WAL — для in-memory СУБД с небольшим WAL
|
|
||||||
// это приемлемо. Для больших WAL нужен индекс по времени.
|
|
||||||
func GetDocumentAtTimestamp(docID string, timestamp int64) (*Document, error) {
|
func GetDocumentAtTimestamp(docID string, timestamp int64) (*Document, error) {
|
||||||
if globalBatchManager == nil {
|
if globalBatchManager == nil {
|
||||||
return nil, fmt.Errorf("batch manager not initialized")
|
return nil, fmt.Errorf("batch manager not initialized")
|
||||||
@@ -661,8 +611,7 @@ func GetDocumentAtTimestamp(docID string, timestamp int64) (*Document, error) {
|
|||||||
return current, nil
|
return current, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDocumentVersionsSince возвращает все версии документа,
|
// GetDocumentVersionsSince возвращает все версии документа после указанного времени.
|
||||||
// созданные после указанного времени.
|
|
||||||
func GetDocumentVersionsSince(docID string, since int64) ([]*Document, error) {
|
func GetDocumentVersionsSince(docID string, since int64) ([]*Document, error) {
|
||||||
if globalBatchManager == nil || globalBatchManager.wal == nil {
|
if globalBatchManager == nil || globalBatchManager.wal == nil {
|
||||||
return nil, fmt.Errorf("batch manager not initialized")
|
return nil, fmt.Errorf("batch manager not initialized")
|
||||||
@@ -747,8 +696,6 @@ type SegmentedWALManager struct {
|
|||||||
logger LoggerInterface
|
logger LoggerInterface
|
||||||
fsyncEnabled bool
|
fsyncEnabled bool
|
||||||
|
|
||||||
// ackMap — map LSN -> chan struct{} для синхронного подтверждения.
|
|
||||||
// Ключ — LSN записи. После записи в WAL закрываем канал.
|
|
||||||
ackMu sync.Mutex
|
ackMu sync.Mutex
|
||||||
ackMap map[uint64]chan struct{}
|
ackMap map[uint64]chan struct{}
|
||||||
}
|
}
|
||||||
@@ -871,13 +818,9 @@ func (wm *SegmentedWALManager) Write(record *WALRecord) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WriteSync — синхронная запись с гарантией fsync.
|
// WriteSync — синхронная запись с гарантией fsync.
|
||||||
//
|
|
||||||
// Используем ackMap по LSN, чтобы не было race condition
|
|
||||||
// с чужими подтверждениями.
|
|
||||||
func (wm *SegmentedWALManager) WriteSync(record *WALRecord) error {
|
func (wm *SegmentedWALManager) WriteSync(record *WALRecord) error {
|
||||||
record.Timestamp = time.Now().UnixMilli()
|
record.Timestamp = time.Now().UnixMilli()
|
||||||
|
|
||||||
// Присваиваем LSN под защитой mu.
|
|
||||||
wm.mu.Lock()
|
wm.mu.Lock()
|
||||||
lsn := wm.getCurrentLSNLocked() + 1
|
lsn := wm.getCurrentLSNLocked() + 1
|
||||||
record.LSN = lsn
|
record.LSN = lsn
|
||||||
@@ -981,7 +924,6 @@ func (wm *SegmentedWALManager) flushBatch(batch []*WALRecord) {
|
|||||||
|
|
||||||
wm.mu.Unlock()
|
wm.mu.Unlock()
|
||||||
|
|
||||||
// Подтверждаем все записи из batch.
|
|
||||||
wm.ackMu.Lock()
|
wm.ackMu.Lock()
|
||||||
for _, record := range batch {
|
for _, record := range batch {
|
||||||
if ch, ok := wm.ackMap[record.LSN]; ok {
|
if ch, ok := wm.ackMap[record.LSN]; ok {
|
||||||
@@ -1150,14 +1092,14 @@ func (im *WALIndexManager) load() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// SAGA — ИНТЕРФЕЙСЫ ДЛЯ RAFT (без прямого импорта hashicorp/raft)
|
// SAGA — ИНТЕРФЕЙСЫ ДЛЯ RAFT
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// SagaRaftAccessor — минимальный интерфейс к Raft, необходимый
|
// SagaRaftAccessor — минимальный интерфейс к Raft, необходимый
|
||||||
// оркестратору SAGA для выборов лидера.
|
// оркестратору SAGA для выборов лидера.
|
||||||
//
|
//
|
||||||
// Реализуется в internal/cluster (RaftCoordinator), чтобы избежать
|
// Реализуется в internal/cluster (SagaLeaderBridge), чтобы storage
|
||||||
// циклических импортов и позволить storage не зависеть от raft напрямую.
|
// не зависел напрямую от hashicorp/raft.
|
||||||
type SagaRaftAccessor interface {
|
type SagaRaftAccessor interface {
|
||||||
// IsSagaLeader возвращает true, если текущий узел является лидером
|
// IsSagaLeader возвращает true, если текущий узел является лидером
|
||||||
// в Raft-группе.
|
// в Raft-группе.
|
||||||
@@ -1224,13 +1166,8 @@ type SagaStepState struct {
|
|||||||
// SagaPersistentStorage — персистентное хранилище SAGA.
|
// SagaPersistentStorage — персистентное хранилище SAGA.
|
||||||
//
|
//
|
||||||
// ДОБАВЛЕНО: executedKeys — персистентная таблица уже выполненных
|
// ДОБАВЛЕНО: executedKeys — персистентная таблица уже выполненных
|
||||||
// ExecutionID. Заполняется после успешного выполнения шага
|
// ExecutionID. Заполняется после успешного выполнения шага и после
|
||||||
// (в executeSagaInternal) и после успешной компенсации
|
// успешной компенсации. Проверяется перед выполнением/компенсацией.
|
||||||
// (в executeCompensation). Проверяется перед выполнением/компенсацией.
|
|
||||||
//
|
|
||||||
// Формат файла — JSON: { "execution_id": timestamp_ms, ... }.
|
|
||||||
// Это позволяет при рестарте быстро понять, какие шаги уже выполнены,
|
|
||||||
// и не дублировать их на удалённых узлах.
|
|
||||||
type SagaPersistentStorage struct {
|
type SagaPersistentStorage struct {
|
||||||
baseDir string
|
baseDir string
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -1241,16 +1178,11 @@ type SagaPersistentStorage struct {
|
|||||||
fsyncMaxRetries int
|
fsyncMaxRetries int
|
||||||
fsyncRetryDelay time.Duration
|
fsyncRetryDelay time.Duration
|
||||||
|
|
||||||
// executedKeys — таблица выполненных ExecutionID.
|
|
||||||
// Ключ — ExecutionID, значение — UnixMilli, когда он был выполнен.
|
|
||||||
// Хранится в памяти + на диске (saga_executed_keys.json).
|
|
||||||
executedMu sync.RWMutex
|
executedMu sync.RWMutex
|
||||||
executedKeys map[string]int64
|
executedKeys map[string]int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSagaPersistentStorageWithConfig создаёт хранилище SAGA.
|
// NewSagaPersistentStorageWithConfig создаёт хранилище SAGA.
|
||||||
//
|
|
||||||
// ДОБАВЛЕНО: при создании загружаем executedKeys с диска.
|
|
||||||
func NewSagaPersistentStorageWithConfig(cfg *config.SagaConfig, logger LoggerInterface) (*SagaPersistentStorage, error) {
|
func NewSagaPersistentStorageWithConfig(cfg *config.SagaConfig, logger LoggerInterface) (*SagaPersistentStorage, error) {
|
||||||
baseDir := SagaStateDir
|
baseDir := SagaStateDir
|
||||||
maxCache := 10000
|
maxCache := 10000
|
||||||
@@ -1282,7 +1214,6 @@ func NewSagaPersistentStorageWithConfig(cfg *config.SagaConfig, logger LoggerInt
|
|||||||
executedKeys: make(map[string]int64),
|
executedKeys: make(map[string]int64),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загружаем таблицу выполненных ключей.
|
|
||||||
if err := sps.loadExecutedKeys(); err != nil {
|
if err := sps.loadExecutedKeys(); err != nil {
|
||||||
if logger != nil {
|
if logger != nil {
|
||||||
logger.Warn(fmt.Sprintf("Failed to load saga executed keys: %v", err))
|
logger.Warn(fmt.Sprintf("Failed to load saga executed keys: %v", err))
|
||||||
@@ -1325,7 +1256,6 @@ func (sps *SagaPersistentStorage) loadExecutedKeys() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// saveExecutedKeys сохраняет таблицу выполненных ключей на диск.
|
// saveExecutedKeys сохраняет таблицу выполненных ключей на диск.
|
||||||
// Вызывается под executedMu.RLock или без удержания блокировки.
|
|
||||||
func (sps *SagaPersistentStorage) saveExecutedKeys() error {
|
func (sps *SagaPersistentStorage) saveExecutedKeys() error {
|
||||||
sps.executedMu.RLock()
|
sps.executedMu.RLock()
|
||||||
data, err := json.Marshal(sps.executedKeys)
|
data, err := json.Marshal(sps.executedKeys)
|
||||||
@@ -1364,8 +1294,7 @@ func (sps *SagaPersistentStorage) IsExecuted(executionID string) bool {
|
|||||||
// MarkExecuted атомарно отмечает ExecutionID как выполненный
|
// MarkExecuted атомарно отмечает ExecutionID как выполненный
|
||||||
// и сохраняет таблицу на диск.
|
// и сохраняет таблицу на диск.
|
||||||
//
|
//
|
||||||
// Возвращает true, если ключ был добавлен впервые (т.е. операция
|
// Возвращает true, если ключ был добавлен впервые.
|
||||||
// действительно новая), и false, если ключ уже был (повтор).
|
|
||||||
func (sps *SagaPersistentStorage) MarkExecuted(executionID string) bool {
|
func (sps *SagaPersistentStorage) MarkExecuted(executionID string) bool {
|
||||||
sps.executedMu.Lock()
|
sps.executedMu.Lock()
|
||||||
if _, exists := sps.executedKeys[executionID]; exists {
|
if _, exists := sps.executedKeys[executionID]; exists {
|
||||||
@@ -1561,21 +1490,13 @@ func (sps *SagaPersistentStorage) LoadLeaderState() (*SagaLeaderState, error) {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// SagaLeaderElector — выборы лидера SAGA через Raft.
|
// SagaLeaderElector — выборы лидера SAGA через Raft.
|
||||||
//
|
|
||||||
// Если Raft доступен, IsLeader() опрашивает RaftAccessor.
|
|
||||||
// Если Raft недоступен, используется резервный режим: узел считается
|
|
||||||
// лидером, только если он один в кластере (single-node mode).
|
|
||||||
//
|
|
||||||
// Все изменения лидерства сохраняются в SagaPersistentStorage
|
|
||||||
// (файл saga_leader_state.json), чтобы после рестарта узел знал,
|
|
||||||
// был ли он лидером.
|
|
||||||
type SagaLeaderElector struct {
|
type SagaLeaderElector struct {
|
||||||
nodeID string
|
nodeID string
|
||||||
raft SagaRaftAccessor
|
raft SagaRaftAccessor
|
||||||
storage *SagaPersistentStorage
|
storage *SagaPersistentStorage
|
||||||
logger LoggerInterface
|
logger LoggerInterface
|
||||||
isLeader atomic.Bool
|
isLeader atomic.Bool
|
||||||
leaderID atomic.Value // string
|
leaderID atomic.Value
|
||||||
currentTerm atomic.Uint64
|
currentTerm atomic.Uint64
|
||||||
|
|
||||||
observersMu sync.RWMutex
|
observersMu sync.RWMutex
|
||||||
@@ -1597,7 +1518,6 @@ func NewSagaLeaderElector(nodeID string, raft SagaRaftAccessor, storage *SagaPer
|
|||||||
}
|
}
|
||||||
e.leaderID.Store("")
|
e.leaderID.Store("")
|
||||||
|
|
||||||
// Пытаемся загрузить прошлое состояние лидера.
|
|
||||||
if storage != nil {
|
if storage != nil {
|
||||||
if state, err := storage.LoadLeaderState(); err == nil && state != nil {
|
if state, err := storage.LoadLeaderState(); err == nil && state != nil {
|
||||||
e.isLeader.Store(state.IsLeader)
|
e.isLeader.Store(state.IsLeader)
|
||||||
@@ -1606,15 +1526,12 @@ func NewSagaLeaderElector(nodeID string, raft SagaRaftAccessor, storage *SagaPer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если Raft доступен, регистрируем наблюдателя, чтобы оперативно
|
|
||||||
// получать уведомления о смене лидера.
|
|
||||||
if raft != nil {
|
if raft != nil {
|
||||||
raft.RegisterSagaLeaderObserver(nodeID, func(isLeader bool, leaderID string) {
|
raft.RegisterSagaLeaderObserver(nodeID, func(isLeader bool, leaderID string) {
|
||||||
e.updateLeadership(isLeader, leaderID)
|
e.updateLeadership(isLeader, leaderID)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Запускаем фоновый опрос Raft (на случай, если наблюдатель не сработал).
|
|
||||||
e.wg.Add(1)
|
e.wg.Add(1)
|
||||||
go e.leaderElectionLoop()
|
go e.leaderElectionLoop()
|
||||||
|
|
||||||
@@ -1622,7 +1539,6 @@ func NewSagaLeaderElector(nodeID string, raft SagaRaftAccessor, storage *SagaPer
|
|||||||
}
|
}
|
||||||
|
|
||||||
// updateLeadership — центральная точка обновления лидерства.
|
// updateLeadership — центральная точка обновления лидерства.
|
||||||
// Вызывается и наблюдателем, и фоновым опросом.
|
|
||||||
func (e *SagaLeaderElector) updateLeadership(isLeader bool, leaderID string) {
|
func (e *SagaLeaderElector) updateLeadership(isLeader bool, leaderID string) {
|
||||||
oldLeader := e.isLeader.Load()
|
oldLeader := e.isLeader.Load()
|
||||||
oldLeaderID, _ := e.leaderID.Load().(string)
|
oldLeaderID, _ := e.leaderID.Load().(string)
|
||||||
@@ -1636,7 +1552,6 @@ func (e *SagaLeaderElector) updateLeadership(isLeader bool, leaderID string) {
|
|||||||
}
|
}
|
||||||
e.currentTerm.Store(term)
|
e.currentTerm.Store(term)
|
||||||
|
|
||||||
// Сохраняем на диск.
|
|
||||||
if e.storage != nil {
|
if e.storage != nil {
|
||||||
_ = e.storage.SaveLeaderState(&SagaLeaderState{
|
_ = e.storage.SaveLeaderState(&SagaLeaderState{
|
||||||
LeaderID: leaderID,
|
LeaderID: leaderID,
|
||||||
@@ -1647,7 +1562,6 @@ func (e *SagaLeaderElector) updateLeadership(isLeader bool, leaderID string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если что-то изменилось — уведомляем наблюдателей.
|
|
||||||
if oldLeader != isLeader || oldLeaderID != leaderID {
|
if oldLeader != isLeader || oldLeaderID != leaderID {
|
||||||
e.observersMu.RLock()
|
e.observersMu.RLock()
|
||||||
obs := make([]func(isLeader bool, leaderID string), 0, len(e.observers))
|
obs := make([]func(isLeader bool, leaderID string), 0, len(e.observers))
|
||||||
@@ -1732,28 +1646,24 @@ func (e *SagaLeaderElector) Stop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// SAGA ORCHESTRATOR (упрощённый, без мьютексов на горячем пути)
|
// SAGA ORCHESTRATOR
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// SagaOrchestrator управляет SAGA.
|
// SagaOrchestrator управляет SAGA.
|
||||||
//
|
|
||||||
// ДОБАВЛЕНО: leaderElector — выборы лидера через Raft.
|
|
||||||
// isLeader больше не выставляется безусловно в true: значение
|
|
||||||
// синхронизируется с leaderElector.
|
|
||||||
type SagaOrchestrator struct {
|
type SagaOrchestrator struct {
|
||||||
storage *SagaPersistentStorage
|
storage *SagaPersistentStorage
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
logger LoggerInterface
|
logger LoggerInterface
|
||||||
nodeID string
|
nodeID string
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
isLeader atomic.Bool
|
isLeader atomic.Bool
|
||||||
leaderID string
|
leaderID string
|
||||||
config *config.SagaConfig
|
config *config.SagaConfig
|
||||||
metrics *SagaMetrics
|
metrics *SagaMetrics
|
||||||
activeSagas sync.Map // map[string]*SagaTransaction
|
activeSagas sync.Map
|
||||||
leaderElector *SagaLeaderElector
|
leaderElector *SagaLeaderElector
|
||||||
raftAccessor SagaRaftAccessor
|
raftAccessor SagaRaftAccessor
|
||||||
}
|
}
|
||||||
|
|
||||||
// SagaMetrics — метрики SAGA.
|
// SagaMetrics — метрики SAGA.
|
||||||
@@ -1765,17 +1675,11 @@ type SagaMetrics struct {
|
|||||||
TotalCompensated atomic.Uint64
|
TotalCompensated atomic.Uint64
|
||||||
ActiveCount atomic.Int64
|
ActiveCount atomic.Int64
|
||||||
RecoveryCount atomic.Uint64
|
RecoveryCount atomic.Uint64
|
||||||
IdempotentSkips atomic.Uint64 // сколько шагов было пропущено по идемпотентности
|
IdempotentSkips atomic.Uint64
|
||||||
NotLeaderRejects atomic.Uint64 // сколько SAGA было отклонено из-за отсутствия лидерства
|
NotLeaderRejects atomic.Uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSagaOrchestratorWithConfig создаёт оркестратор.
|
// NewSagaOrchestratorWithConfig создаёт оркестратор без Raft.
|
||||||
//
|
|
||||||
// ДОБАВЛЕНО: второй аргумент raftAccessor может быть nil —
|
|
||||||
// тогда оркестратор работает в single-node режиме и всегда лидер.
|
|
||||||
//
|
|
||||||
// ДОБАВЛЕНО: если raftAccessor != nil, создаётся SagaLeaderElector,
|
|
||||||
// который опрашивает Raft и обновляет isLeader.
|
|
||||||
func NewSagaOrchestratorWithConfig(cfg *config.SagaConfig, nodeID string, logger LoggerInterface) (*SagaOrchestrator, error) {
|
func NewSagaOrchestratorWithConfig(cfg *config.SagaConfig, nodeID string, logger LoggerInterface) (*SagaOrchestrator, error) {
|
||||||
return NewSagaOrchestratorWithRaft(cfg, nodeID, nil, logger)
|
return NewSagaOrchestratorWithRaft(cfg, nodeID, nil, logger)
|
||||||
}
|
}
|
||||||
@@ -1814,11 +1718,8 @@ func NewSagaOrchestratorWithRaft(cfg *config.SagaConfig, nodeID string, raftAcce
|
|||||||
raftAccessor: raftAccessor,
|
raftAccessor: raftAccessor,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаём выборщик лидера.
|
|
||||||
o.leaderElector = NewSagaLeaderElector(nodeID, raftAccessor, storage, logger)
|
o.leaderElector = NewSagaLeaderElector(nodeID, raftAccessor, storage, logger)
|
||||||
|
|
||||||
// Начальное значение isLeader синхронизируем с выборщиком.
|
|
||||||
// Если Raft недоступен (nil), считаем себя лидером — single-node режим.
|
|
||||||
if raftAccessor == nil {
|
if raftAccessor == nil {
|
||||||
o.isLeader.Store(true)
|
o.isLeader.Store(true)
|
||||||
o.leaderID = nodeID
|
o.leaderID = nodeID
|
||||||
@@ -1828,7 +1729,6 @@ func NewSagaOrchestratorWithRaft(cfg *config.SagaConfig, nodeID string, raftAcce
|
|||||||
o.leaderID = o.leaderElector.GetLeaderID()
|
o.leaderID = o.leaderElector.GetLeaderID()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Подписываемся на изменения лидерства.
|
|
||||||
o.leaderElector.RegisterObserver("orchestrator", func(isLeader bool, leaderID string) {
|
o.leaderElector.RegisterObserver("orchestrator", func(isLeader bool, leaderID string) {
|
||||||
o.mu.Lock()
|
o.mu.Lock()
|
||||||
o.leaderID = leaderID
|
o.leaderID = leaderID
|
||||||
@@ -1844,9 +1744,6 @@ func NewSagaOrchestratorWithRaft(cfg *config.SagaConfig, nodeID string, raftAcce
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsLeader — является ли узел лидером.
|
// IsLeader — является ли узел лидером.
|
||||||
//
|
|
||||||
// ДОБАВЛЕНО: теперь значение берётся из leaderElector, а не
|
|
||||||
// из жёстко выставленного флага.
|
|
||||||
func (o *SagaOrchestrator) IsLeader() bool {
|
func (o *SagaOrchestrator) IsLeader() bool {
|
||||||
if o.leaderElector == nil {
|
if o.leaderElector == nil {
|
||||||
return true
|
return true
|
||||||
@@ -1868,7 +1765,6 @@ func (o *SagaOrchestrator) GetLeaderID() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsExecuted проверяет, был ли ExecutionID уже выполнен.
|
// IsExecuted проверяет, был ли ExecutionID уже выполнен.
|
||||||
// Делегируется в storage.
|
|
||||||
func (o *SagaOrchestrator) IsExecuted(executionID string) bool {
|
func (o *SagaOrchestrator) IsExecuted(executionID string) bool {
|
||||||
if o.storage == nil {
|
if o.storage == nil {
|
||||||
return false
|
return false
|
||||||
@@ -1877,7 +1773,6 @@ func (o *SagaOrchestrator) IsExecuted(executionID string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MarkExecuted помечает ExecutionID как выполненный.
|
// MarkExecuted помечает ExecutionID как выполненный.
|
||||||
// Возвращает true, если это первое выполнение.
|
|
||||||
func (o *SagaOrchestrator) MarkExecuted(executionID string) bool {
|
func (o *SagaOrchestrator) MarkExecuted(executionID string) bool {
|
||||||
if o.storage == nil {
|
if o.storage == nil {
|
||||||
return true
|
return true
|
||||||
@@ -1896,7 +1791,6 @@ func (o *SagaOrchestrator) recoveryLoop() {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
// Восстановлением занимается только лидер.
|
|
||||||
if !o.IsLeader() {
|
if !o.IsLeader() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1914,7 +1808,6 @@ func (o *SagaOrchestrator) orphanCleanupLoop() {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
// Очисткой занимается только лидер.
|
|
||||||
if !o.IsLeader() {
|
if !o.IsLeader() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1925,7 +1818,7 @@ func (o *SagaOrchestrator) orphanCleanupLoop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupOrphanSteps — очистка "осиротевших" SAGA.
|
// cleanupOrphanSteps — очистка «осиротевших» SAGA.
|
||||||
func (o *SagaOrchestrator) cleanupOrphanSteps() {
|
func (o *SagaOrchestrator) cleanupOrphanSteps() {
|
||||||
pending, err := o.storage.ListPending()
|
pending, err := o.storage.ListPending()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1948,13 +1841,7 @@ func (o *SagaOrchestrator) cleanupOrphanSteps() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// compensateOrphanSaga — компенсация "осиротевшей" SAGA.
|
// compensateOrphanSaga — компенсация «осиротевшей» SAGA.
|
||||||
//
|
|
||||||
// Проверяем nil на Compensate, сохраняем состояние после
|
|
||||||
// каждой компенсации, обрабатываем compensation_failed.
|
|
||||||
//
|
|
||||||
// ДОБАВЛЕНО: проверяем лидерство — компенсацию выполняет только лидер,
|
|
||||||
// чтобы два узла не компенсировали одну и ту же SAGA.
|
|
||||||
func (o *SagaOrchestrator) compensateOrphanSaga(state *SagaState) {
|
func (o *SagaOrchestrator) compensateOrphanSaga(state *SagaState) {
|
||||||
if !o.IsLeader() {
|
if !o.IsLeader() {
|
||||||
return
|
return
|
||||||
@@ -2015,8 +1902,6 @@ func (o *SagaOrchestrator) stateToTransaction(state *SagaState) *SagaTransaction
|
|||||||
}
|
}
|
||||||
|
|
||||||
// recoverPendingSagas — восстановление незавершённых SAGA.
|
// recoverPendingSagas — восстановление незавершённых SAGA.
|
||||||
//
|
|
||||||
// ДОБАВЛЕНО: выполняется только лидером.
|
|
||||||
func (o *SagaOrchestrator) recoverPendingSagas() {
|
func (o *SagaOrchestrator) recoverPendingSagas() {
|
||||||
if !o.IsLeader() {
|
if !o.IsLeader() {
|
||||||
return
|
return
|
||||||
@@ -2050,9 +1935,6 @@ func (o *SagaOrchestrator) resumeSaga(state *SagaState) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BeginSaga — начать SAGA.
|
// BeginSaga — начать SAGA.
|
||||||
//
|
|
||||||
// ДОБАВЛЕНО: если узел не лидер — возвращаем ошибку, чтобы клиент
|
|
||||||
// перенаправил запрос на лидера.
|
|
||||||
func (o *SagaOrchestrator) BeginSaga(id string) (*SagaTransaction, error) {
|
func (o *SagaOrchestrator) BeginSaga(id string) (*SagaTransaction, error) {
|
||||||
if !o.IsLeader() {
|
if !o.IsLeader() {
|
||||||
o.metrics.NotLeaderRejects.Add(1)
|
o.metrics.NotLeaderRejects.Add(1)
|
||||||
@@ -2098,8 +1980,6 @@ func (o *SagaOrchestrator) BeginSaga(id string) (*SagaTransaction, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute — выполнить SAGA.
|
// Execute — выполнить SAGA.
|
||||||
//
|
|
||||||
// ДОБАВЛЕНО: только лидер выполняет SAGA.
|
|
||||||
func (o *SagaOrchestrator) Execute(saga *SagaTransaction) error {
|
func (o *SagaOrchestrator) Execute(saga *SagaTransaction) error {
|
||||||
if !o.IsLeader() {
|
if !o.IsLeader() {
|
||||||
o.metrics.NotLeaderRejects.Add(1)
|
o.metrics.NotLeaderRejects.Add(1)
|
||||||
@@ -2109,16 +1989,6 @@ func (o *SagaOrchestrator) Execute(saga *SagaTransaction) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// executeSagaInternal — внутренняя логика выполнения SAGA.
|
// executeSagaInternal — внутренняя логика выполнения SAGA.
|
||||||
//
|
|
||||||
// Компенсация идемпотентна и корректно обрабатывает ошибки.
|
|
||||||
//
|
|
||||||
// ДОБАВЛЕНО: идемпотентность через storage.IsExecuted / storage.MarkExecuted.
|
|
||||||
// Перед выполнением шага проверяем, не выполнялся ли он уже. Если да —
|
|
||||||
// пропускаем (IdempotentSkips++). После успешного выполнения отмечаем
|
|
||||||
// ExecutionID как выполненный на диске.
|
|
||||||
//
|
|
||||||
// ДОБАВЛЕНО: if !o.IsLeader() — прерываем выполнение, если узел потерял
|
|
||||||
// лидерство в процессе (защита от двух лидеров).
|
|
||||||
func (o *SagaOrchestrator) executeSagaInternal(saga *SagaTransaction) error {
|
func (o *SagaOrchestrator) executeSagaInternal(saga *SagaTransaction) error {
|
||||||
if !o.IsLeader() {
|
if !o.IsLeader() {
|
||||||
return fmt.Errorf("node is not the saga leader")
|
return fmt.Errorf("node is not the saga leader")
|
||||||
@@ -2138,8 +2008,6 @@ func (o *SagaOrchestrator) executeSagaInternal(saga *SagaTransaction) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i := saga.CurrentStep; i < len(saga.Steps); i++ {
|
for i := saga.CurrentStep; i < len(saga.Steps); i++ {
|
||||||
// Проверяем лидерство на каждой итерации: если потеряли —
|
|
||||||
// прерываемся, чтобы не конфликтовать с новым лидером.
|
|
||||||
if !o.IsLeader() {
|
if !o.IsLeader() {
|
||||||
return fmt.Errorf("lost saga leadership during execution of %s", saga.ID)
|
return fmt.Errorf("lost saga leadership during execution of %s", saga.ID)
|
||||||
}
|
}
|
||||||
@@ -2155,7 +2023,6 @@ func (o *SagaOrchestrator) executeSagaInternal(saga *SagaTransaction) error {
|
|||||||
step.StartedAt = time.Now().UnixMilli()
|
step.StartedAt = time.Now().UnixMilli()
|
||||||
saga.mu.Unlock()
|
saga.mu.Unlock()
|
||||||
|
|
||||||
// ИДЕМПОТЕНТНОСТЬ: если шаг уже выполнялся (по журналу), пропускаем.
|
|
||||||
if o.IsExecuted(step.ExecutionID) || saga.HasExecuted(step.ExecutionID) {
|
if o.IsExecuted(step.ExecutionID) || saga.HasExecuted(step.ExecutionID) {
|
||||||
saga.mu.Lock()
|
saga.mu.Lock()
|
||||||
step.Status = "completed"
|
step.Status = "completed"
|
||||||
@@ -2219,8 +2086,6 @@ func (o *SagaOrchestrator) executeSagaInternal(saga *SagaTransaction) error {
|
|||||||
return fmt.Errorf("saga %s aborted at step %s: %v", saga.ID, step.Name, err)
|
return fmt.Errorf("saga %s aborted at step %s: %v", saga.ID, step.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ИДЕМПОТЕНТНОСТЬ: отмечаем шаг как выполненный на диске,
|
|
||||||
// чтобы при рестарте не выполнить его повторно.
|
|
||||||
o.MarkExecuted(step.ExecutionID)
|
o.MarkExecuted(step.ExecutionID)
|
||||||
|
|
||||||
saga.mu.Lock()
|
saga.mu.Lock()
|
||||||
@@ -2276,14 +2141,6 @@ func (o *SagaOrchestrator) saveSagaState(saga *SagaTransaction) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// executeCompensation — выполнение компенсации.
|
// executeCompensation — выполнение компенсации.
|
||||||
//
|
|
||||||
// Идемпотентность: пропускаем уже скомпенсированные шаги.
|
|
||||||
// Сохраняем состояние после каждой компенсации.
|
|
||||||
// Обрабатываем compensation_failed корректно.
|
|
||||||
//
|
|
||||||
// ДОБАВЛЕНО: проверяем лидерство в начале и на каждой итерации.
|
|
||||||
// ДОБАВЛЕНО: идемпотентность через storage.IsExecuted на
|
|
||||||
// execution_id компенсации (используем суффикс ":compensate").
|
|
||||||
func (o *SagaOrchestrator) executeCompensation(saga *SagaTransaction, failedStep int) error {
|
func (o *SagaOrchestrator) executeCompensation(saga *SagaTransaction, failedStep int) error {
|
||||||
if !o.IsLeader() {
|
if !o.IsLeader() {
|
||||||
return fmt.Errorf("node is not the saga leader")
|
return fmt.Errorf("node is not the saga leader")
|
||||||
@@ -2313,7 +2170,6 @@ func (o *SagaOrchestrator) executeCompensation(saga *SagaTransaction, failedStep
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// ИДЕМПОТЕНТНОСТЬ: проверяем, не компенсировали ли уже.
|
|
||||||
compExecID := step.ExecutionID + ":compensate"
|
compExecID := step.ExecutionID + ":compensate"
|
||||||
if o.IsExecuted(compExecID) {
|
if o.IsExecuted(compExecID) {
|
||||||
saga.mu.Lock()
|
saga.mu.Lock()
|
||||||
@@ -2325,7 +2181,6 @@ func (o *SagaOrchestrator) executeCompensation(saga *SagaTransaction, failedStep
|
|||||||
}
|
}
|
||||||
|
|
||||||
if step.Compensate == nil {
|
if step.Compensate == nil {
|
||||||
// Нет функции компенсации — считаем шаг скомпенсированным.
|
|
||||||
saga.mu.Lock()
|
saga.mu.Lock()
|
||||||
step.Status = "compensated"
|
step.Status = "compensated"
|
||||||
step.CompletedAt = time.Now().UnixMilli()
|
step.CompletedAt = time.Now().UnixMilli()
|
||||||
@@ -2355,7 +2210,6 @@ func (o *SagaOrchestrator) executeCompensation(saga *SagaTransaction, failedStep
|
|||||||
o.saveSagaState(saga)
|
o.saveSagaState(saga)
|
||||||
return fmt.Errorf("compensation for step %s failed: %v", step.Name, err)
|
return fmt.Errorf("compensation for step %s failed: %v", step.Name, err)
|
||||||
}
|
}
|
||||||
// ИДЕМПОТЕНТНОСТЬ: отмечаем компенсацию как выполненную.
|
|
||||||
o.MarkExecuted(compExecID)
|
o.MarkExecuted(compExecID)
|
||||||
saga.mu.Lock()
|
saga.mu.Lock()
|
||||||
step.Status = "compensated"
|
step.Status = "compensated"
|
||||||
@@ -2387,8 +2241,6 @@ func (o *SagaOrchestrator) GetSaga(id string) (*SagaTransaction, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetMetrics — метрики SAGA.
|
// GetMetrics — метрики SAGA.
|
||||||
//
|
|
||||||
// ДОБАВЛЕНО: idempotent_skips, not_leader_rejects, leader_id.
|
|
||||||
func (o *SagaOrchestrator) GetMetrics() map[string]interface{} {
|
func (o *SagaOrchestrator) GetMetrics() map[string]interface{} {
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"total_started": o.metrics.TotalStarted.Load(),
|
"total_started": o.metrics.TotalStarted.Load(),
|
||||||
@@ -2654,6 +2506,30 @@ func (sm *SagaManager) GetActiveSagas() []*SagaTransaction {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllSagas возвращает все SAGA транзакции — и активные, и завершённые.
|
||||||
|
//
|
||||||
|
// ДОБАВЛЕНО (2026-10): используется REPL-командой "saga list --all".
|
||||||
|
// В отличие от GetActiveSagas, читает состояние с диска через
|
||||||
|
// SagaPersistentStorage.ListAll() и конвертирует каждую запись в
|
||||||
|
// SagaTransaction через stateToTransaction.
|
||||||
|
func (sm *SagaManager) GetAllSagas() []*SagaTransaction {
|
||||||
|
result := make([]*SagaTransaction, 0)
|
||||||
|
if sm.orchestrator == nil || sm.orchestrator.storage == nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
states, err := sm.orchestrator.storage.ListAll()
|
||||||
|
if err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
for _, st := range states {
|
||||||
|
if st == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, sm.orchestrator.stateToTransaction(st))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// Stop — остановка.
|
// Stop — остановка.
|
||||||
func (sm *SagaManager) Stop() {
|
func (sm *SagaManager) Stop() {
|
||||||
close(sm.stopChan)
|
close(sm.stopChan)
|
||||||
@@ -2711,9 +2587,6 @@ func StopBatchManager() error {
|
|||||||
globalBatchManager.wal.Sync()
|
globalBatchManager.wal.Sync()
|
||||||
globalBatchManager.wal.Close()
|
globalBatchManager.wal.Close()
|
||||||
}
|
}
|
||||||
if globalBatchManager.aof != nil {
|
|
||||||
globalBatchManager.aof.Close()
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2721,7 +2594,7 @@ func StopBatchManager() error {
|
|||||||
// СОВМЕСТИМОСТЬ: псевдонимы для старого API
|
// СОВМЕСТИМОСТЬ: псевдонимы для старого API
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// GetTransactionStats — псевдоним для GetBatchStats (обратная совместимость).
|
// GetTransactionStats — псевдоним для GetBatchStats.
|
||||||
func GetTransactionStats() map[string]interface{} {
|
func GetTransactionStats() map[string]interface{} {
|
||||||
return GetBatchStats()
|
return GetBatchStats()
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user