Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ce6c907dd | |||
| f7c78d3d7c | |||
| d1aadacfdc | |||
| a8ca85d311 | |||
| 4e5a8837ba | |||
| 04104e07d7 | |||
| 43c870a92e | |||
| c252f462e7 | |||
| 755919fd29 | |||
| 601af066c1 | |||
| 7edeab3b10 | |||
| c0f6146308 | |||
| d46c3fa730 | |||
| 46aa587a10 | |||
| 49daa6aa76 | |||
| b12689bea0 | |||
| 2982bec7ae | |||
| 362f3d6d71 | |||
| 51c5119194 | |||
| 2f3d1450d8 | |||
| dd9b9353f1 | |||
| a769f72993 | |||
| bc10d7af53 | |||
| 6b30d43a45 | |||
| 4e053c1dfc |
36
README.md
36
README.md
@@ -31,6 +31,7 @@
|
|||||||
<li><a href="#о-проекте">О проекте</a></li>
|
<li><a href="#о-проекте">О проекте</a></li>
|
||||||
<li><a href="#лицензия">Лицензия</a></li>
|
<li><a href="#лицензия">Лицензия</a></li>
|
||||||
<li><a href="#глоссарий">Глоссарий</a></li>
|
<li><a href="#глоссарий">Глоссарий</a></li>
|
||||||
|
<li><a href="#почему-illumos-openindiana">Почему Illumos OpenIndiana?</a></li>
|
||||||
<li><a href="#архитектурные-примечания-и-предложения-безопасности">Архитектурные примечания и предложения безопасности</a></li>
|
<li><a href="#архитектурные-примечания-и-предложения-безопасности">Архитектурные примечания и предложения безопасности</a></li>
|
||||||
<li><a href="#алгоритмы-и-структуры-данных">Алгоритмы и структуры данных</a></li>
|
<li><a href="#алгоритмы-и-структуры-данных">Алгоритмы и структуры данных</a></li>
|
||||||
<li><a href="#системные-требования">Системные требования</a></li>
|
<li><a href="#системные-требования">Системные требования</a></li>
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
<li><a href="#триггеры">Триггеры</a></li>
|
<li><a href="#триггеры">Триггеры</a></li>
|
||||||
<li><a href="#сжатие-данных">Сжатие данных</a></li>
|
<li><a href="#сжатие-данных">Сжатие данных</a></li>
|
||||||
<li><a href="#графический-интерфейс">Графический интерфейс</a></li>
|
<li><a href="#графический-интерфейс">Графический интерфейс</a></li>
|
||||||
|
<li><a href="#чаво">ЧАВО</a></li>
|
||||||
<li><a href="#план-развития">План развития</a></li>
|
<li><a href="#план-развития">План развития</a></li>
|
||||||
<li><a href="#контакты">Контакты</a></li>
|
<li><a href="#контакты">Контакты</a></li>
|
||||||
</ol>
|
</ol>
|
||||||
@@ -173,6 +175,24 @@ futriix — это лёгкая распределённая NoSQL‑СУБД н
|
|||||||
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
|
||||||
|
## Почему Illumos OpenIndiana?
|
||||||
|
|
||||||
|
Несмотря на то, что `futriix` сохраняет кроссплатформенную совместимость и полностью поддерживает Linux, в качестве основной целевой платформы выбрана операционная система **Illumos (OpenIndiana)**. Для резидентных (in-memory) NoSQL-СУБД, эксплуатируемых в Замкнутых Программных Средах (ЗПС), ОС общего назначения (такие как Linux) часто вносят недетерминированные задержки (ресурсный джиттер) и обладают избыточным архитектурным оверхедом. Родословная Solaris в Illumos обеспечивает критически важный фундамент, построенный на строгом детерминизме и эталонном системном проектировании.
|
||||||
|
|
||||||
|
**Ключевые архитектурные причины приоритета ядра Illumos:**
|
||||||
|
|
||||||
|
* **Строгий детерминизм ядра и планирование реального времени:** В отличие от планировщика Linux (CFS/EEVDF), оптимизированного под компромисс между пропускной способностью и интерактивностью, диспетчер задач Illumos гарантирует жесткое и предсказуемое распределение ресурсов процессора. Это исключает внезапные задержки (jitter), критические для wait-free/lock-free алгоритмов обработки транзакций в памяти.
|
||||||
|
* **Масштабируемые порты событий (`port_create`):** Для организации асинхронного высоконагруженного ввода-вывода `futriix` задействует системный интерфейс Event Ports вместо Linux `epoll`. Архитектурное преимущество портов событий заключается в нативном устранении проблемы «эффекта проснувшегося стада» (thundering herd) на уровне ядра и лучшей локальности процессорного кэша при работе с пулами потоков.
|
||||||
|
* **Нативная архитектура управления сбоями (FMA):** Эксплуатация СУБД типа in-memory сопряжена с рисками аппаратных сбоев памяти. Подсистема Illumos FMA непрерывно анализирует телеметрию оборудования: если планка оперативной памяти начинает деградировать, FMA изолирует сбойные страницы «на лету» *до* того, как произойдет Kernel Panic, обеспечивая непрерывную доступность СУБД.
|
||||||
|
* **Эталонная целостность WAL через нативную ZFS:** Сегментированный лог упреждающей записи (WAL) требует абсолютной надежности хранения. В Illumos файловая система ZFS является родной и глубоко интегрирована в слой виртуальной памяти ядра. Она верифицирует блоки данных с помощью криптографических контрольных сумм и автоматически исправляет скрытые повреждения бит (*silent data corruption*), гарантируя надежность (Durability) без потери производительности.
|
||||||
|
* **Жесткая изоляция на уровне ОС (Zones):** Технология контейнеризации Zones «вшита» непосредственно в ядро системы, в отличие от контейнеров Linux, реализованных в виде надстройки через пространства имен (namespaces) и cgroups. Это обеспечивает чистую, математически строгую логическую и физическую изоляцию распределенных узлов СУБД, запущенных на общем оборудовании.
|
||||||
|
* **Лицензия CDDL для применения в ЗПС:** Лицензия Common Development and Distribution License (CDDL) позволяет модифицировать и усиливать безопасность ядра ОС под требования отечественных регуляторов без необходимости раскрывать производный код (в отличие от жестких копилефт-требований GPL в Linux). Это радикально упрощает создание доверенных, сертифицированных программно-аппаратных комплексов.
|
||||||
|
|
||||||
|
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Архитектурные примечания и предложения безопасности
|
## Архитектурные примечания и предложения безопасности
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
@@ -215,7 +235,7 @@ futriix — это лёгкая распределённая NoSQL‑СУБД н
|
|||||||
|----------|----------|----------|
|
|----------|----------|----------|
|
||||||
| **Хранилище (Storage)** | `sync.Map` | Конкурентная хэш‑таблица для хранения баз данных, обеспечивает wait‑free чтение и запись |
|
| **Хранилище (Storage)** | `sync.Map` | Конкурентная хэш‑таблица для хранения баз данных, обеспечивает wait‑free чтение и запись |
|
||||||
| | Атомарные счётчики (`atomic.Int64`) | Для отслеживания общего количества документов без блокировок |
|
| | Атомарные счётчики (`atomic.Int64`) | Для отслеживания общего количества документов без блокировок |
|
||||||
| **База данных (Database)** | `sync.Map` коллекций | Аналог слайса в реляционных СУБД |
|
| **База данных (Database)** | `sync.Map` коллекций | Синоним термнина "база данных" в реляционных СУБД |
|
||||||
| | Мьютексы (`sync.RWMutex`) | Для операций изменения структуры (создание/удаление коллекций) |
|
| | Мьютексы (`sync.RWMutex`) | Для операций изменения структуры (создание/удаление коллекций) |
|
||||||
| **Коллекция (Collection)** | `sync.Map` документов | Ключ: ID документа, значение: указатель на `Document` |
|
| **Коллекция (Collection)** | `sync.Map` документов | Ключ: ID документа, значение: указатель на `Document` |
|
||||||
| | `sync.Map` индексов | Отдельное хранилище для инвертированных индексов |
|
| | `sync.Map` индексов | Отдельное хранилище для инвертированных индексов |
|
||||||
@@ -2510,10 +2530,24 @@ futriix:~> doc compression employees 550e8400-e29b-41d4-a716-446655440000
|
|||||||
<img src="wui.png" height=400 weight=400 alt="wui.png"></img>
|
<img src="wui.png" height=400 weight=400 alt="wui.png"></img>
|
||||||
|
|
||||||
|
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
|
||||||
|
## ЧАВО
|
||||||
|
|
||||||
|
В данном разделе- **ЧАВО (Часто задаваемые Вопросы и Ответы)** собраны ответы на самые частые вопросы — чтобы вы быстро разобрались в работе проекта и не тратили время на поиск очевидных вещей. Если не нашли нужного ответа — напишите в `issues`: обратная связь помогает нам расставлять приоритеты.<br> Дальше — раздел «План развития»: там показаны, какие улучшения уже реализованы, а каких ждать в будущем.
|
||||||
|
|
||||||
|
**Вопрос: Почему версия обозначена как «Futriix 2 i²», а не просто «2.0»?**<br>
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
**Ответ: «2» — номер версии: здесь собраны ключевые компоненты (шардинг, WAL, MVCC и др.).**
|
||||||
|
**i2i2 — математическая метафора: хотя ii — мнимая единица, её квадрат даёт абсолютно точный результат (i2=−1i2=−1). Так и Futriix: даже в сложной распределённой среде с параллельными операциями (wait‑free, lock‑free) система выдаёт строго предсказуемые результаты — за счёт Raft, ACID и MVCC. Это близко философии OpenIndiana/Illumos: детерминизм и контроль над ресурсами.**
|
||||||
|
|
||||||
|
|
||||||
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## План развития
|
## План развития
|
||||||
|
|
||||||
- [x] Реализовать поддержку хранимых процедур
|
- [x] Реализовать поддержку хранимых процедур
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func main() {
|
|||||||
aclManager := acl.NewACLManager()
|
aclManager := acl.NewACLManager()
|
||||||
logger.Info("ACL manager initialized")
|
logger.Info("ACL manager initialized")
|
||||||
|
|
||||||
// Исправлено: передаём store как второй аргумент
|
// Передаём store как второй аргумент
|
||||||
raftCoordinator, err := cluster.NewRaftCoordinator(cfg, store, logger)
|
raftCoordinator, err := cluster.NewRaftCoordinator(cfg, store, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Failed to start Raft coordinator: " + err.Error())
|
logger.Error("Failed to start Raft coordinator: " + err.Error())
|
||||||
@@ -185,7 +185,7 @@ func main() {
|
|||||||
|
|
||||||
node := cluster.NewNode(cfg.Cluster.NodeIP, cfg.Cluster.NodePort, store, logger)
|
node := cluster.NewNode(cfg.Cluster.NodeIP, cfg.Cluster.NodePort, store, logger)
|
||||||
|
|
||||||
// Исправлено: объявляем переменную maxRetries здесь, чтобы она была доступна
|
// Объявляем переменную maxRetries здесь, чтобы она была доступна
|
||||||
maxRetries := 5
|
maxRetries := 5
|
||||||
var registerErr error
|
var registerErr error
|
||||||
for i := 0; i < maxRetries; i++ {
|
for i := 0; i < maxRetries; i++ {
|
||||||
@@ -205,7 +205,7 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Исправлено: передаём отдельные параметры из конфигурации плагинов
|
// Передаём отдельные параметры из конфигурации плагинов
|
||||||
pluginManager := plugin.NewPluginManager(
|
pluginManager := plugin.NewPluginManager(
|
||||||
cfg.Plugins.ScriptDir,
|
cfg.Plugins.ScriptDir,
|
||||||
logger,
|
logger,
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ func (sm *SchemaMigrator) createDefaultSchema() {
|
|||||||
UpdatedAt: time.Now().UnixMilli(),
|
UpdatedAt: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sm.currentVersion = "1.0.0"
|
||||||
sm.saveSchema()
|
sm.saveSchema()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +389,13 @@ func (sm *SchemaMigrator) MigrateSchema(targetVersion string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Начинаем транзакцию для миграции
|
// Начинаем транзакцию для миграции
|
||||||
|
// Используем storage.BeginTransaction() из пакета storage
|
||||||
tx := storage.BeginTransaction()
|
tx := storage.BeginTransaction()
|
||||||
|
if tx == nil {
|
||||||
|
return fmt.Errorf("failed to begin transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка паники и откат транзакции
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
storage.AbortCurrentTransaction()
|
storage.AbortCurrentTransaction()
|
||||||
@@ -437,12 +444,17 @@ func (sm *SchemaMigrator) MigrateSchema(targetVersion string) error {
|
|||||||
return fmt.Errorf("failed to save schema: %v", err)
|
return fmt.Errorf("failed to save schema: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сохраняем применённые миграции
|
||||||
|
if err := sm.saveAppliedMigrations(); err != nil {
|
||||||
|
storage.AbortCurrentTransaction()
|
||||||
|
return fmt.Errorf("failed to save applied migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Коммитим транзакцию
|
// Коммитим транзакцию
|
||||||
if err := storage.CommitCurrentTransaction(); err != nil {
|
if err := storage.CommitCurrentTransaction(); err != nil {
|
||||||
return fmt.Errorf("failed to commit schema migration: %v", err)
|
return fmt.Errorf("failed to commit schema migration: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sm.saveAppliedMigrations()
|
|
||||||
sm.currentVersion = targetVersion
|
sm.currentVersion = targetVersion
|
||||||
|
|
||||||
if sm.logger != nil {
|
if sm.logger != nil {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
// Файл: internal/storage/fsync.go
|
// Файл: internal/storage/fsync.go
|
||||||
// Назначение: Реальная синхронизация с диском (fsync) для WAL
|
// Назначение: Реальная синхронизация с диском (fsync) для WAL
|
||||||
|
// Поддерживает все платформы: Linux, macOS, BSD, Windows, Solaris/Illumos (OpenIndiana)
|
||||||
|
|
||||||
package storage
|
package storage
|
||||||
|
|
||||||
@@ -17,21 +18,29 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RealFsync выполняет реальный fsync на файле
|
// RealFsync выполняет реальный fsync на файле.
|
||||||
|
// Использует стандартный метод Sync() для максимальной совместимости
|
||||||
|
// со всеми платформами включая OpenIndiana (Solaris/Illumos).
|
||||||
func RealFsync(file *os.File) error {
|
func RealFsync(file *os.File) error {
|
||||||
if file == nil {
|
if file == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вызываем системный fsync
|
// Вызываем системный fsync через стандартный метод Sync
|
||||||
|
// Это работает на всех платформах: Linux, macOS, BSD, Windows, Solaris/Illumos
|
||||||
return file.Sync()
|
return file.Sync()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RealFsyncWithRetry выполняет fsync с повторными попытками
|
// RealFsyncWithRetry выполняет fsync с повторными попытками.
|
||||||
|
// Параметры:
|
||||||
|
// - file: файл для синхронизации
|
||||||
|
// - maxRetries: максимальное количество попыток
|
||||||
|
// - retryDelay: задержка между попытками
|
||||||
|
//
|
||||||
|
// Возвращает ошибку, если все попытки не удались.
|
||||||
func RealFsyncWithRetry(file *os.File, maxRetries int, retryDelay time.Duration) error {
|
func RealFsyncWithRetry(file *os.File, maxRetries int, retryDelay time.Duration) error {
|
||||||
if file == nil {
|
if file == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -52,7 +61,8 @@ func RealFsyncWithRetry(file *os.File, maxRetries int, retryDelay time.Duration)
|
|||||||
return lastErr
|
return lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// FsyncDir синхронизирует директорию (для гарантии, что создание файла записано на диск)
|
// FsyncDir синхронизирует директорию (для гарантии, что создание файла записано на диск).
|
||||||
|
// На некоторых платформах (Windows, Solaris/Illumos) fsync для директорий не поддерживается.
|
||||||
func FsyncDir(dirPath string) error {
|
func FsyncDir(dirPath string) error {
|
||||||
dir, err := os.Open(dirPath)
|
dir, err := os.Open(dirPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -60,24 +70,84 @@ func FsyncDir(dirPath string) error {
|
|||||||
}
|
}
|
||||||
defer dir.Close()
|
defer dir.Close()
|
||||||
|
|
||||||
if runtime.GOOS == "windows" {
|
// На Windows и Solaris/Illumos нет прямой поддержки fsync для директорий
|
||||||
// На Windows нет прямой поддержки fsync для директорий
|
// Используем стандартный Sync если доступен
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows", "solaris", "illumos", "openindiana":
|
||||||
|
// На этих платформах fsync для директорий не поддерживается или не нужен
|
||||||
|
// Возвращаем nil, так как данные уже записаны через fsync файлов
|
||||||
return nil
|
return nil
|
||||||
|
default:
|
||||||
|
// Для Linux, BSD, macOS используем Sync
|
||||||
|
return dir.Sync()
|
||||||
}
|
}
|
||||||
|
|
||||||
return dir.Sync()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FsyncDataOnly выполняет fsync только для данных (не метаданных)
|
// FsyncDataOnly выполняет fsync только для данных (не метаданных).
|
||||||
|
// Использует стандартный Sync для всех платформ.
|
||||||
|
// Это обеспечивает запись данных на диск без синхронизации метаданных.
|
||||||
func FsyncDataOnly(file *os.File) error {
|
func FsyncDataOnly(file *os.File) error {
|
||||||
if file == nil {
|
if file == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fd := int(file.Fd())
|
// Используем стандартный Sync для всех платформ
|
||||||
_, _, err := syscall.Syscall(syscall.SYS_FSYNC, uintptr(fd), 0, 0)
|
// Это обеспечит запись данных на диск
|
||||||
if err != 0 {
|
return file.Sync()
|
||||||
return fmt.Errorf("fsync failed: %v", err)
|
}
|
||||||
}
|
|
||||||
return nil
|
// FsyncWithTimeout выполняет fsync с таймаутом.
|
||||||
|
// Если операция не завершается за указанное время, возвращается ошибка.
|
||||||
|
func FsyncWithTimeout(file *os.File, timeout time.Duration) error {
|
||||||
|
if file == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём канал для результата
|
||||||
|
result := make(chan error, 1)
|
||||||
|
|
||||||
|
// Запускаем fsync в отдельной горутине
|
||||||
|
go func() {
|
||||||
|
result <- file.Sync()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Ожидаем результат или таймаут
|
||||||
|
select {
|
||||||
|
case err := <-result:
|
||||||
|
return err
|
||||||
|
case <-time.After(timeout):
|
||||||
|
return fmt.Errorf("fsync timeout after %v", timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustFsync выполняет fsync и паникует при ошибке.
|
||||||
|
// Используется только в критических секциях, где ошибка синхронизации фатальна.
|
||||||
|
func MustFsync(file *os.File) {
|
||||||
|
if file == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := file.Sync(); err != nil {
|
||||||
|
panic(fmt.Sprintf("Fatal: fsync failed: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsFsyncSupported возвращает true если платформа поддерживает fsync.
|
||||||
|
// Все платформы поддерживают fsync через file.Sync().
|
||||||
|
func IsFsyncSupported() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFsyncPlatformInfo возвращает информацию о платформе для диагностики.
|
||||||
|
func GetFsyncPlatformInfo() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"os": runtime.GOOS,
|
||||||
|
"arch": runtime.GOARCH,
|
||||||
|
"fsync_supported": IsFsyncSupported(),
|
||||||
|
"dir_fsync_supported": !(runtime.GOOS == "windows" ||
|
||||||
|
runtime.GOOS == "solaris" ||
|
||||||
|
runtime.GOOS == "illumos" ||
|
||||||
|
runtime.GOOS == "openindiana"),
|
||||||
|
"method": "file.Sync()",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -857,6 +857,7 @@ func (pm *PersistenceManager) LoadDatabase(dbName string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// applyWALEntry применяет одну запись WAL к базе данных
|
// applyWALEntry применяет одну запись WAL к базе данных
|
||||||
|
// TransactionCommitted = 1 (определено в transactions.go)
|
||||||
func (pm *PersistenceManager) applyWALEntry(db *Database, entry WALEntry) error {
|
func (pm *PersistenceManager) applyWALEntry(db *Database, entry WALEntry) error {
|
||||||
// Проверяем тип записи (1 = Transaction)
|
// Проверяем тип записи (1 = Transaction)
|
||||||
if entry.Type != "1" {
|
if entry.Type != "1" {
|
||||||
@@ -882,12 +883,13 @@ func (pm *PersistenceManager) applyWALEntry(db *Database, entry WALEntry) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Применяем только закоммиченные транзакции (state = 1)
|
// Применяем только закоммиченные транзакции (state = 1)
|
||||||
if txRecord.State != 1 { // TransactionCommitted
|
// TransactionCommitted = 1 (определено в transactions.go через iota)
|
||||||
|
if txRecord.State != 1 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, op := range txRecord.Operations {
|
for _, op := range txRecord.Operations {
|
||||||
if op.Database != db.name {
|
if op.Database != db.Name() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user