first commit

This commit is contained in:
2026-04-19 16:42:41 +03:00
commit e82fb947be
37 changed files with 14591 additions and 0 deletions

498
internal/acl/manger.go Normal file
View File

@@ -0,0 +1,498 @@
/*
* 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/acl/manager.go
// Назначение: Глобальный менеджер ACL для всей СУБД.
// Управляет пользователями, ролями и разрешениями на уровне БД и коллекций.
// Реализован с использованием sync.Map для wait-free доступа.
package acl
import (
"fmt"
"sync"
"time"
"github.com/google/uuid"
)
// PermissionType определяет тип разрешения
type PermissionType string
const (
PermRead PermissionType = "read"
PermWrite PermissionType = "write"
PermDelete PermissionType = "delete"
PermAdmin PermissionType = "admin"
)
// User представляет пользователя системы
type User struct {
ID string `msgpack:"id"`
Username string `msgpack:"username"`
Password string `msgpack:"password"` // В реальной системе - хеш
Roles []string `msgpack:"roles"`
CreatedAt int64 `msgpack:"created_at"`
LastLogin int64 `msgpack:"last_login"`
Active bool `msgpack:"active"`
}
// Session представляет активную сессию пользователя
type Session struct {
ID string `msgpack:"id"`
Username string `msgpack:"username"`
Roles []string `msgpack:"roles"`
CreatedAt int64 `msgpack:"created_at"`
ExpiresAt int64 `msgpack:"expires_at"`
}
// Role представляет роль с набором разрешений
type Role struct {
Name string `msgpack:"name"`
Permissions []string `msgpack:"permissions"` // "database.collection:read" формат
}
// ACLManager управляет доступом к БД
type ACLManager struct {
users sync.Map // map[string]*User
roles sync.Map // map[string]*Role
sessions sync.Map // map[string]*Session - sessionID -> Session
mu sync.RWMutex
}
// NewACLManager создаёт новый менеджер ACL
func NewACLManager() *ACLManager {
m := &ACLManager{}
// Создаём роль администратора по умолчанию
adminRole := &Role{
Name: "admin",
Permissions: []string{"*:*"},
}
m.roles.Store("admin", adminRole)
// Создаём пользователя admin по умолчанию
adminUser := &User{
ID: uuid.New().String(),
Username: "admin",
Password: "admin", // В продакшене использовать хеш!
Roles: []string{"admin"},
CreatedAt: time.Now().UnixMilli(),
Active: true,
}
m.users.Store("admin", adminUser)
// Создаём роль guest с ограниченными правами
guestRole := &Role{
Name: "guest",
Permissions: []string{},
}
m.roles.Store("guest", guestRole)
// Создаём пользователя guest по умолчанию
guestUser := &User{
ID: uuid.New().String(),
Username: "guest",
Password: "guest",
Roles: []string{"guest"},
CreatedAt: time.Now().UnixMilli(),
Active: true,
}
m.users.Store("guest", guestUser)
return m
}
// CreateUser создаёт нового пользователя
func (m *ACLManager) CreateUser(username, password string, roles []string) error {
if _, exists := m.users.Load(username); exists {
return fmt.Errorf("user %s already exists", username)
}
user := &User{
ID: uuid.New().String(),
Username: username,
Password: password,
Roles: roles,
CreatedAt: time.Now().UnixMilli(),
Active: true,
}
m.users.Store(username, user)
return nil
}
// Authenticate аутентифицирует пользователя и создаёт сессию
func (m *ACLManager) Authenticate(username, password string) (string, error) {
val, ok := m.users.Load(username)
if !ok {
return "", fmt.Errorf("user not found")
}
user := val.(*User)
if !user.Active {
return "", fmt.Errorf("user is disabled")
}
if user.Password != password {
return "", fmt.Errorf("invalid password")
}
// Обновляем время последнего входа
user.LastLogin = time.Now().UnixMilli()
m.users.Store(username, user)
// Создаём сессию (24 часа)
sessionID := uuid.New().String()
now := time.Now().Unix()
session := &Session{
ID: sessionID,
Username: username,
Roles: user.Roles,
CreatedAt: now,
ExpiresAt: now + 86400, // 24 часа
}
m.sessions.Store(sessionID, session)
return sessionID, nil
}
// Logout завершает сессию
func (m *ACLManager) Logout(sessionID string) {
m.sessions.Delete(sessionID)
}
// CheckSession проверяет, активна ли сессия
func (m *ACLManager) CheckSession(sessionID string) bool {
val, ok := m.sessions.Load(sessionID)
if !ok {
return false
}
session := val.(*Session)
// Проверка на expiry
if time.Now().Unix() > session.ExpiresAt {
m.sessions.Delete(sessionID)
return false
}
return true
}
// GetUsername возвращает имя пользователя по ID сессии
func (m *ACLManager) GetUsername(sessionID string) string {
val, ok := m.sessions.Load(sessionID)
if !ok {
return ""
}
session := val.(*Session)
return session.Username
}
// GetUserRoles возвращает роли пользователя по ID сессии
func (m *ACLManager) GetUserRoles(sessionID string) []string {
val, ok := m.sessions.Load(sessionID)
if !ok {
return []string{}
}
session := val.(*Session)
return session.Roles
}
// CheckPermission проверяет разрешение для сессии
func (m *ACLManager) CheckPermission(sessionID, database, collection, operation string) bool {
val, ok := m.sessions.Load(sessionID)
if !ok {
return false
}
session := val.(*Session)
for _, roleName := range session.Roles {
roleVal, ok := m.roles.Load(roleName)
if !ok {
continue
}
role := roleVal.(*Role)
for _, perm := range role.Permissions {
if m.matchPermission(perm, database, collection, operation) {
return true
}
}
}
return false
}
// matchPermission проверяет соответствие разрешения
func (m *ACLManager) matchPermission(perm, database, collection, operation string) bool {
// Формат: "database.collection:operation" или "*:*" для всех
// или "database.*:read" для всех коллекций в БД
parts := splitPermission(perm)
if len(parts) != 2 {
return false
}
resource := parts[0] // "database.collection" или "database.*"
op := parts[1] // "read", "write", "delete", "admin"
// Проверка операции
if op != "*" && op != operation && operation != "admin" {
return false
}
// Администратор имеет доступ ко всему
if op == "admin" || op == "*" {
return true
}
// Проверка ресурса
if resource == "*:*" {
return true
}
resourceParts := splitResource(resource)
if len(resourceParts) != 2 {
return false
}
dbPattern := resourceParts[0]
collPattern := resourceParts[1]
if dbPattern != "*" && dbPattern != database {
return false
}
if collPattern != "*" && collPattern != collection {
return false
}
return true
}
// GrantPermission выдаёт разрешение роли
func (m *ACLManager) GrantPermission(roleName, permission string) error {
val, ok := m.roles.Load(roleName)
if !ok {
return fmt.Errorf("role not found")
}
role := val.(*Role)
role.Permissions = append(role.Permissions, permission)
m.roles.Store(roleName, role)
return nil
}
// RevokePermission отзывает разрешение у роли
func (m *ACLManager) RevokePermission(roleName, permission string) error {
val, ok := m.roles.Load(roleName)
if !ok {
return fmt.Errorf("role not found")
}
role := val.(*Role)
newPermissions := make([]string, 0, len(role.Permissions))
for _, p := range role.Permissions {
if p != permission {
newPermissions = append(newPermissions, p)
}
}
role.Permissions = newPermissions
m.roles.Store(roleName, role)
return nil
}
// CreateRole создаёт новую роль
func (m *ACLManager) CreateRole(name string) error {
if _, exists := m.roles.Load(name); exists {
return fmt.Errorf("role %s already exists", name)
}
role := &Role{
Name: name,
Permissions: []string{},
}
m.roles.Store(name, role)
return nil
}
// DeleteRole удаляет роль
func (m *ACLManager) DeleteRole(name string) error {
if _, exists := m.roles.LoadAndDelete(name); !exists {
return fmt.Errorf("role not found")
}
return nil
}
// AddUserRole добавляет роль пользователю
func (m *ACLManager) AddUserRole(username, roleName string) error {
val, ok := m.users.Load(username)
if !ok {
return fmt.Errorf("user not found")
}
user := val.(*User)
// Проверяем, есть ли уже такая роль
for _, r := range user.Roles {
if r == roleName {
return fmt.Errorf("user already has role %s", roleName)
}
}
user.Roles = append(user.Roles, roleName)
m.users.Store(username, user)
return nil
}
// RemoveUserRole удаляет роль у пользователя
func (m *ACLManager) RemoveUserRole(username, roleName string) error {
val, ok := m.users.Load(username)
if !ok {
return fmt.Errorf("user not found")
}
user := val.(*User)
newRoles := make([]string, 0, len(user.Roles))
for _, r := range user.Roles {
if r != roleName {
newRoles = append(newRoles, r)
}
}
user.Roles = newRoles
m.users.Store(username, user)
return nil
}
// DisableUser отключает пользователя
func (m *ACLManager) DisableUser(username string) error {
val, ok := m.users.Load(username)
if !ok {
return fmt.Errorf("user not found")
}
user := val.(*User)
user.Active = false
m.users.Store(username, user)
return nil
}
// EnableUser включает пользователя
func (m *ACLManager) EnableUser(username string) error {
val, ok := m.users.Load(username)
if !ok {
return fmt.Errorf("user not found")
}
user := val.(*User)
user.Active = true
m.users.Store(username, user)
return nil
}
// DeleteUser удаляет пользователя
func (m *ACLManager) DeleteUser(username string) error {
if _, exists := m.users.LoadAndDelete(username); !exists {
return fmt.Errorf("user not found")
}
return nil
}
// ChangePassword изменяет пароль пользователя
func (m *ACLManager) ChangePassword(username, newPassword string) error {
val, ok := m.users.Load(username)
if !ok {
return fmt.Errorf("user not found")
}
user := val.(*User)
user.Password = newPassword
m.users.Store(username, user)
return nil
}
// ListUsers возвращает список всех пользователей
func (m *ACLManager) ListUsers() []string {
users := make([]string, 0)
m.users.Range(func(key, value interface{}) bool {
users = append(users, key.(string))
return true
})
return users
}
// ListRoles возвращает список всех ролей
func (m *ACLManager) ListRoles() []string {
roles := make([]string, 0)
m.roles.Range(func(key, value interface{}) bool {
roles = append(roles, key.(string))
return true
})
return roles
}
// GetUserInfo возвращает информацию о пользователе
func (m *ACLManager) GetUserInfo(username string) (*User, error) {
val, ok := m.users.Load(username)
if !ok {
return nil, fmt.Errorf("user not found")
}
user := val.(*User)
// Возвращаем копию, чтобы избежать модификации извне
return &User{
ID: user.ID,
Username: user.Username,
Roles: user.Roles,
CreatedAt: user.CreatedAt,
LastLogin: user.LastLogin,
Active: user.Active,
}, nil
}
// GetRolePermissions возвращает разрешения роли
func (m *ACLManager) GetRolePermissions(roleName string) ([]string, error) {
val, ok := m.roles.Load(roleName)
if !ok {
return nil, fmt.Errorf("role not found")
}
role := val.(*Role)
return role.Permissions, nil
}
// Helper functions
func splitPermission(perm string) []string {
for i := 0; i < len(perm); i++ {
if perm[i] == ':' {
return []string{perm[:i], perm[i+1:]}
}
}
return []string{perm, ""}
}
func splitResource(resource string) []string {
for i := 0; i < len(resource); i++ {
if resource[i] == '.' {
return []string{resource[:i], resource[i+1:]}
}
}
return []string{resource, "*"}
}

672
internal/api/http.go Normal file
View File

@@ -0,0 +1,672 @@
/*
* 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/api/http.go
// Назначение: HTTP RESTful API для взаимодействия с СУБД через curl.
// Поддерживает CRUD операции, управление индексами, ACL и ограничениями.
// Реализован с минимальными блокировками, использует wait-free структуры.
package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"futriis/internal/acl"
"futriis/internal/cluster"
"futriis/internal/log"
"futriis/internal/storage"
)
type HTTPServer struct {
store *storage.Storage
coordinator *cluster.RaftCoordinator
aclManager *acl.ACLManager
logger *log.Logger
server *http.Server
port int
}
type APIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// NewHTTPServer создаёт новый HTTP сервер (добавлен CORS middleware)
func NewHTTPServer(port int, store *storage.Storage, coord *cluster.RaftCoordinator, aclMgr *acl.ACLManager, logger *log.Logger) *HTTPServer {
s := &HTTPServer{
store: store,
coordinator: coord,
aclManager: aclMgr,
logger: logger,
port: port,
}
mux := http.NewServeMux()
// CORS middleware wrapper
corsHandler := func(handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Session-ID")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
handler(w, r)
}
}
// Middleware для аутентификации
mux.HandleFunc("/api/auth/login", corsHandler(s.handleLogin))
mux.HandleFunc("/api/auth/logout", corsHandler(s.handleLogout))
// CRUD операции
mux.HandleFunc("/api/db/", corsHandler(s.handleDatabaseRequest))
// Индексы
mux.HandleFunc("/api/index/", corsHandler(s.handleIndexRequest))
// ACL
mux.HandleFunc("/api/acl/", corsHandler(s.handleACLRequest))
// Constraints
mux.HandleFunc("/api/constraint/", corsHandler(s.handleConstraintRequest))
// Cluster
mux.HandleFunc("/api/cluster/", corsHandler(s.handleClusterRequest))
s.server = &http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: mux,
}
return s
}
// Start запускает HTTP сервер
func (s *HTTPServer) Start() error {
s.logger.Info("Starting HTTP API server on port " + strconv.Itoa(s.port))
return s.server.ListenAndServe()
}
// Stop останавливает HTTP сервер
func (s *HTTPServer) Stop() error {
return s.server.Close()
}
// handleLogin обрабатывает аутентификацию
func (s *HTTPServer) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
s.sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var creds struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
s.sendError(w, "Invalid request body", http.StatusBadRequest)
return
}
sessionID, err := s.aclManager.Authenticate(creds.Username, creds.Password)
if err != nil {
s.sendError(w, err.Error(), http.StatusUnauthorized)
return
}
s.sendSuccess(w, map[string]string{"session_id": sessionID})
}
// handleLogout обрабатывает выход
func (s *HTTPServer) handleLogout(w http.ResponseWriter, r *http.Request) {
sessionID := r.Header.Get("X-Session-ID")
if sessionID != "" {
s.aclManager.Logout(sessionID)
}
s.sendSuccess(w, map[string]string{"status": "logged out"})
}
// handleDatabaseRequest обрабатывает запросы к БД
func (s *HTTPServer) handleDatabaseRequest(w http.ResponseWriter, r *http.Request) {
// URL: /api/db/{database}/{collection}/{document_id}
path := strings.TrimPrefix(r.URL.Path, "/api/db/")
parts := strings.Split(path, "/")
if len(parts) < 2 {
s.sendError(w, "Invalid path. Use /api/db/{database}/{collection}[/{id}]", http.StatusBadRequest)
return
}
database := parts[0]
collection := parts[1]
docID := ""
if len(parts) > 2 {
docID = parts[2]
}
// Проверка аутентификации
sessionID := r.Header.Get("X-Session-ID")
if sessionID == "" {
s.sendError(w, "Authentication required", http.StatusUnauthorized)
return
}
switch r.Method {
case http.MethodGet:
s.handleGetDocument(w, r, sessionID, database, collection, docID)
case http.MethodPost:
s.handleInsertDocument(w, r, sessionID, database, collection)
case http.MethodPut:
s.handleUpdateDocument(w, r, sessionID, database, collection, docID)
case http.MethodDelete:
s.handleDeleteDocument(w, r, sessionID, database, collection, docID)
default:
s.sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// handleGetDocument обрабатывает GET запросы
func (s *HTTPServer) handleGetDocument(w http.ResponseWriter, r *http.Request, sessionID, database, collection, docID string) {
// Проверка прав
if !s.aclManager.CheckPermission(sessionID, database, collection, "read") {
s.sendError(w, "Access denied", http.StatusForbidden)
return
}
db, err := s.store.GetDatabase(database)
if err != nil {
s.sendError(w, err.Error(), http.StatusNotFound)
return
}
coll, err := db.GetCollection(collection)
if err != nil {
s.sendError(w, err.Error(), http.StatusNotFound)
return
}
// Поиск по индексу или ID
query := r.URL.Query()
if indexName := query.Get("index"); indexName != "" {
indexValue := query.Get("value")
docs, err := coll.FindByIndex(indexName, indexValue)
if err != nil {
s.sendError(w, err.Error(), http.StatusNotFound)
return
}
s.sendSuccess(w, docs)
return
}
if docID == "" {
// Возвращаем все документы (с пагинацией)
limit := 100
if limitStr := query.Get("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 {
limit = l
}
}
offset := 0
if offsetStr := query.Get("offset"); offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
offset = o
}
}
allDocs := coll.GetAllDocuments()
start := offset
end := offset + limit
if start > len(allDocs) {
start = len(allDocs)
}
if end > len(allDocs) {
end = len(allDocs)
}
result := allDocs[start:end]
s.sendSuccess(w, map[string]interface{}{
"documents": result,
"total": len(allDocs),
"limit": limit,
"offset": offset,
})
return
}
doc, err := coll.Find(docID)
if err != nil {
s.sendError(w, err.Error(), http.StatusNotFound)
return
}
s.sendSuccess(w, doc)
}
// handleInsertDocument обрабатывает POST запросы
func (s *HTTPServer) handleInsertDocument(w http.ResponseWriter, r *http.Request, sessionID, database, collection string) {
if !s.aclManager.CheckPermission(sessionID, database, collection, "write") {
s.sendError(w, "Access denied", http.StatusForbidden)
return
}
db, err := s.store.GetDatabase(database)
if err != nil {
// Создаём БД если не существует
if err := s.store.CreateDatabase(database); err != nil {
s.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
db, _ = s.store.GetDatabase(database)
}
coll, err := db.GetCollection(collection)
if err != nil {
if err := db.CreateCollection(collection); err != nil {
s.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
coll, _ = db.GetCollection(collection)
}
var doc map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&doc); err != nil {
s.sendError(w, "Invalid JSON", http.StatusBadRequest)
return
}
if err := coll.InsertFromMap(doc); err != nil {
s.sendError(w, err.Error(), http.StatusBadRequest)
return
}
s.sendSuccess(w, map[string]string{"status": "inserted"})
}
// handleUpdateDocument обрабатывает PUT запросы
func (s *HTTPServer) handleUpdateDocument(w http.ResponseWriter, r *http.Request, sessionID, database, collection, docID string) {
if docID == "" {
s.sendError(w, "Document ID required", http.StatusBadRequest)
return
}
if !s.aclManager.CheckPermission(sessionID, database, collection, "write") {
s.sendError(w, "Access denied", http.StatusForbidden)
return
}
db, err := s.store.GetDatabase(database)
if err != nil {
s.sendError(w, err.Error(), http.StatusNotFound)
return
}
coll, err := db.GetCollection(collection)
if err != nil {
s.sendError(w, err.Error(), http.StatusNotFound)
return
}
var updates map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
s.sendError(w, "Invalid JSON", http.StatusBadRequest)
return
}
if err := coll.Update(docID, updates); err != nil {
s.sendError(w, err.Error(), http.StatusBadRequest)
return
}
s.sendSuccess(w, map[string]string{"status": "updated"})
}
// handleDeleteDocument обрабатывает DELETE запросы
func (s *HTTPServer) handleDeleteDocument(w http.ResponseWriter, r *http.Request, sessionID, database, collection, docID string) {
if docID == "" {
s.sendError(w, "Document ID required", http.StatusBadRequest)
return
}
if !s.aclManager.CheckPermission(sessionID, database, collection, "delete") {
s.sendError(w, "Access denied", http.StatusForbidden)
return
}
db, err := s.store.GetDatabase(database)
if err != nil {
s.sendError(w, err.Error(), http.StatusNotFound)
return
}
coll, err := db.GetCollection(collection)
if err != nil {
s.sendError(w, err.Error(), http.StatusNotFound)
return
}
if err := coll.Delete(docID); err != nil {
s.sendError(w, err.Error(), http.StatusNotFound)
return
}
s.sendSuccess(w, map[string]string{"status": "deleted"})
}
// handleIndexRequest обрабатывает запросы к индексам
func (s *HTTPServer) handleIndexRequest(w http.ResponseWriter, r *http.Request) {
// URL: /api/index/{database}/{collection}/{action}
path := strings.TrimPrefix(r.URL.Path, "/api/index/")
parts := strings.Split(path, "/")
if len(parts) < 3 {
s.sendError(w, "Invalid path. Use /api/index/{database}/{collection}/{action}", http.StatusBadRequest)
return
}
database := parts[0]
collection := parts[1]
action := parts[2]
sessionID := r.Header.Get("X-Session-ID")
if !s.aclManager.CheckPermission(sessionID, database, collection, "admin") {
s.sendError(w, "Admin access required", http.StatusForbidden)
return
}
db, err := s.store.GetDatabase(database)
if err != nil {
s.sendError(w, err.Error(), http.StatusNotFound)
return
}
coll, err := db.GetCollection(collection)
if err != nil {
s.sendError(w, err.Error(), http.StatusNotFound)
return
}
switch action {
case "list":
indexes := coll.GetIndexes()
s.sendSuccess(w, indexes)
case "create":
var req struct {
Name string `json:"name"`
Fields []string `json:"fields"`
Unique bool `json:"unique"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.sendError(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := coll.CreateIndex(req.Name, req.Fields, req.Unique); err != nil {
s.sendError(w, err.Error(), http.StatusBadRequest)
return
}
s.sendSuccess(w, map[string]string{"status": "index created"})
case "drop":
if len(parts) < 4 {
s.sendError(w, "Index name required", http.StatusBadRequest)
return
}
indexName := parts[3]
if err := coll.DropIndex(indexName); err != nil {
s.sendError(w, err.Error(), http.StatusBadRequest)
return
}
s.sendSuccess(w, map[string]string{"status": "index dropped"})
default:
s.sendError(w, "Unknown action", http.StatusBadRequest)
}
}
// handleACLRequest обрабатывает запросы ACL
func (s *HTTPServer) handleACLRequest(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/acl/")
parts := strings.Split(path, "/")
if len(parts) < 1 {
s.sendError(w, "Invalid path", http.StatusBadRequest)
return
}
sessionID := r.Header.Get("X-Session-ID")
if !s.aclManager.CheckPermission(sessionID, "*", "*", "admin") {
s.sendError(w, "Admin access required", http.StatusForbidden)
return
}
action := parts[0]
switch action {
case "users":
users := s.aclManager.ListUsers()
s.sendSuccess(w, users)
case "user":
if len(parts) < 2 {
s.sendError(w, "Username required", http.StatusBadRequest)
return
}
username := parts[1]
switch r.Method {
case http.MethodPost:
var req struct {
Password string `json:"password"`
Roles []string `json:"roles"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.sendError(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := s.aclManager.CreateUser(username, req.Password, req.Roles); err != nil {
s.sendError(w, err.Error(), http.StatusBadRequest)
return
}
s.sendSuccess(w, map[string]string{"status": "user created"})
default:
s.sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case "roles":
roles := s.aclManager.ListRoles()
s.sendSuccess(w, roles)
case "grant":
if len(parts) < 3 {
s.sendError(w, "Role and permission required", http.StatusBadRequest)
return
}
roleName := parts[1]
permission := parts[2]
if err := s.aclManager.GrantPermission(roleName, permission); err != nil {
s.sendError(w, err.Error(), http.StatusBadRequest)
return
}
s.sendSuccess(w, map[string]string{"status": "permission granted"})
default:
s.sendError(w, "Unknown action", http.StatusBadRequest)
}
}
// handleConstraintRequest обрабатывает запросы к ограничениям
func (s *HTTPServer) handleConstraintRequest(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/constraint/")
parts := strings.Split(path, "/")
if len(parts) < 3 {
s.sendError(w, "Invalid path. Use /api/constraint/{database}/{collection}/{action}", http.StatusBadRequest)
return
}
database := parts[0]
collection := parts[1]
action := parts[2]
sessionID := r.Header.Get("X-Session-ID")
if !s.aclManager.CheckPermission(sessionID, database, collection, "admin") {
s.sendError(w, "Admin access required", http.StatusForbidden)
return
}
db, err := s.store.GetDatabase(database)
if err != nil {
s.sendError(w, err.Error(), http.StatusNotFound)
return
}
coll, err := db.GetCollection(collection)
if err != nil {
s.sendError(w, err.Error(), http.StatusNotFound)
return
}
switch action {
case "required":
if len(parts) < 4 {
s.sendError(w, "Field name required", http.StatusBadRequest)
return
}
field := parts[3]
coll.AddRequiredField(field)
s.sendSuccess(w, map[string]string{"status": "required field added"})
case "unique":
if len(parts) < 4 {
s.sendError(w, "Field name required", http.StatusBadRequest)
return
}
field := parts[3]
coll.AddUniqueConstraint(field)
s.sendSuccess(w, map[string]string{"status": "unique constraint added"})
case "min":
if len(parts) < 5 {
s.sendError(w, "Field name and value required", http.StatusBadRequest)
return
}
field := parts[3]
minVal, _ := strconv.ParseFloat(parts[4], 64)
coll.AddMinConstraint(field, minVal)
s.sendSuccess(w, map[string]string{"status": "min constraint added"})
case "max":
if len(parts) < 5 {
s.sendError(w, "Field name and value required", http.StatusBadRequest)
return
}
field := parts[3]
maxVal, _ := strconv.ParseFloat(parts[4], 64)
coll.AddMaxConstraint(field, maxVal)
s.sendSuccess(w, map[string]string{"status": "max constraint added"})
default:
s.sendError(w, "Unknown action", http.StatusBadRequest)
}
}
// handleClusterRequest обрабатывает запросы к кластеру (исправлено: поддержка разных методов)
func (s *HTTPServer) handleClusterRequest(w http.ResponseWriter, r *http.Request) {
sessionID := r.Header.Get("X-Session-ID")
if !s.aclManager.CheckPermission(sessionID, "*", "*", "admin") {
s.sendError(w, "Admin access required", http.StatusForbidden)
return
}
if s.coordinator == nil {
s.sendError(w, "Cluster not available", http.StatusServiceUnavailable)
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/cluster/")
parts := strings.Split(path, "/")
switch r.Method {
case http.MethodGet:
if len(parts) == 0 || parts[0] == "" || parts[0] == "status" {
status := s.coordinator.GetClusterStatus()
s.sendSuccess(w, status)
} else if parts[0] == "health" {
health := s.coordinator.GetClusterHealth()
s.sendSuccess(w, health)
} else if parts[0] == "nodes" {
nodes := s.coordinator.GetAllNodes()
s.sendSuccess(w, nodes)
} else {
s.sendError(w, "Unknown cluster endpoint", http.StatusNotFound)
}
case http.MethodPost:
if len(parts) >= 2 && parts[0] == "replication" && parts[1] == "factor" {
var req struct {
Factor int `json:"factor"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.sendError(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Factor < 1 || req.Factor > 5 {
s.sendError(w, "Replication factor must be between 1 and 5", http.StatusBadRequest)
return
}
if err := s.coordinator.SetReplicationFactor(req.Factor); err != nil {
s.sendError(w, err.Error(), http.StatusBadRequest)
return
}
s.sendSuccess(w, map[string]interface{}{
"status": "updated",
"factor": req.Factor,
})
} else {
s.sendError(w, "Unknown cluster endpoint", http.StatusNotFound)
}
default:
s.sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// sendSuccess отправляет успешный ответ
func (s *HTTPServer) sendSuccess(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(APIResponse{
Success: true,
Data: data,
})
}
// sendError отправляет ответ с ошибкой
func (s *HTTPServer) sendError(w http.ResponseWriter, errMsg string, statusCode int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(APIResponse{
Success: false,
Error: errMsg,
})
}

924
internal/api/static/app.js Normal file
View File

@@ -0,0 +1,924 @@
/*
* 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/api/static/app.js
// JavaScript для веб-интерфейса Futriis DB Dashboard
// Глобальное состояние
let currentSession = null;
let currentDatabase = null;
let currentCollection = null;
let currentUser = null;
// DOM элементы
const contentArea = document.getElementById('contentArea');
const pageTitle = document.getElementById('pageTitle');
const connectionStatus = document.getElementById('connectionStatus');
const userInfoSpan = document.querySelector('#userInfo span');
const logoutBtn = document.getElementById('logoutBtn');
const menuToggle = document.getElementById('menuToggle');
const sidebar = document.querySelector('.sidebar');
const modal = document.getElementById('modal');
const modalTitle = document.getElementById('modalTitle');
const modalBody = document.getElementById('modalBody');
const modalConfirm = document.getElementById('modalConfirm');
const modalCloseBtns = document.querySelectorAll('.modal-close');
// Инициализация приложения
document.addEventListener('DOMContentLoaded', () => {
checkSession();
initNavigation();
initEventListeners();
});
// Проверка сессии
async function checkSession() {
try {
const response = await fetch('/api/webui/session');
const data = await response.json();
if (data.success && data.data.authenticated) {
currentUser = data.data.username;
userInfoSpan.textContent = currentUser;
connectionStatus.classList.add('online');
connectionStatus.classList.remove('offline');
loadDashboard();
} else {
showLoginModal();
}
} catch (error) {
console.error('Session check failed:', error);
showLoginModal();
}
}
// Показать модальное окно входа
function showLoginModal() {
modalTitle.textContent = 'Вход в систему';
modalBody.innerHTML = `
<div class="form-group">
<label for="username">Имя пользователя</label>
<input type="text" id="username" class="form-control" placeholder="Введите имя пользователя">
</div>
<div class="form-group">
<label for="password">Пароль</label>
<input type="password" id="password" class="form-control" placeholder="Введите пароль">
</div>
`;
modal.classList.add('show');
const confirmHandler = async () => {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
if (!username || !password) {
showNotification('Пожалуйста, заполните все поля', 'error');
return;
}
try {
const response = await fetch('/api/webui/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (data.success) {
currentUser = username;
userInfoSpan.textContent = username;
modal.classList.remove('show');
showNotification('Вход выполнен успешно', 'success');
loadDashboard();
} else {
showNotification(data.error || 'Ошибка входа', 'error');
}
} catch (error) {
showNotification('Ошибка подключения к серверу', 'error');
}
};
modalConfirm.onclick = confirmHandler;
// Обработка Enter
const handleEnter = (e) => {
if (e.key === 'Enter') {
confirmHandler();
document.removeEventListener('keydown', handleEnter);
}
};
document.addEventListener('keydown', handleEnter);
}
// Инициализация навигации
function initNavigation() {
// Обработка кликов по пунктам меню
document.querySelectorAll('.nav-link[data-section]').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const section = link.dataset.section;
loadSection(section);
setActiveNav(link);
});
});
// Обработка подменю CRUD
document.querySelectorAll('[data-action]').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const action = item.dataset.action;
handleCrudAction(action);
});
});
// Обработка раскрытия подменю
document.querySelectorAll('.has-submenu > .nav-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const parent = link.closest('.has-submenu');
parent.classList.toggle('open');
});
});
}
// Инициализация обработчиков событий
function initEventListeners() {
// Выход
logoutBtn.addEventListener('click', async () => {
await fetch('/api/webui/logout', { method: 'POST' });
currentSession = null;
currentUser = null;
showLoginModal();
});
// Мобильное меню
if (menuToggle) {
menuToggle.addEventListener('click', () => {
sidebar.classList.toggle('open');
});
}
// Закрытие модального окна
modalCloseBtns.forEach(btn => {
btn.addEventListener('click', () => {
modal.classList.remove('show');
});
});
// Закрытие модального окна по клику вне его
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.classList.remove('show');
}
});
}
// Загрузка секции
async function loadSection(section) {
switch(section) {
case 'dashboard':
loadDashboard();
break;
case 'cluster':
loadClusterManagement();
break;
case 'audit':
loadAuditLog();
break;
case 'settings':
loadSettings();
break;
default:
loadDashboard();
}
}
// Загрузка дашборда
async function loadDashboard() {
pageTitle.textContent = 'Панель управления';
contentArea.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-pulse"></i><p>Загрузка данных...</p></div>';
try {
const [statsRes, dbsRes] = await Promise.all([
fetch('/api/webui/stats'),
fetch('/api/webui/databases')
]);
const stats = await statsRes.json();
const databases = await dbsRes.json();
contentArea.innerHTML = `
<div class="dashboard-stats">
<div class="stat-card">
<div class="stat-icon"><i class="fas fa-database"></i></div>
<div class="stat-info">
<h3>${stats.data.databases || 0}</h3>
<p>Базы данных</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon"><i class="fas fa-table"></i></div>
<div class="stat-info">
<h3>${stats.data.collections || 0}</h3>
<p>Коллекции</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon"><i class="fas fa-file-alt"></i></div>
<div class="stat-info">
<h3>${stats.data.documents || 0}</h3>
<p>Документы</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon"><i class="fas fa-hdd"></i></div>
<div class="stat-info">
<h3>${stats.data.storage_used_mb?.toFixed(2) || 0} MB</h3>
<p>Использовано памяти</p>
</div>
</div>
</div>
<div class="data-table">
<h3 style="margin-bottom: 16px;">Базы данных</h3>
<table>
<thead>
<tr><th>Имя БД</th><th>Коллекции</th><th>Действия</th></tr>
</thead>
<tbody>
${databases.data.map(db => `
<tr>
<td><strong>${escapeHtml(db.name)}</strong></td>
<td>${db.collections}</td>
<td>
<button class="btn btn-sm btn-primary" onclick="viewDatabase('${escapeHtml(db.name)}')">
<i class="fas fa-eye"></i> Просмотр
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
} catch (error) {
contentArea.innerHTML = '<div class="error-message">Ошибка загрузки данных</div>';
showNotification('Ошибка загрузки дашборда', 'error');
}
}
// Просмотр базы данных
window.viewDatabase = async function(dbName) {
currentDatabase = dbName;
pageTitle.textContent = `База данных: ${dbName}`;
contentArea.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-pulse"></i><p>Загрузка коллекций...</p></div>';
try {
const response = await fetch(`/api/webui/collections/${dbName}`);
const data = await response.json();
if (data.success) {
contentArea.innerHTML = `
<div class="data-table">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h3>Коллекции</h3>
<button class="btn btn-primary btn-sm" onclick="showCreateCollectionModal()">
<i class="fas fa-plus"></i> Создать коллекцию
</button>
</div>
<table>
<thead>
<tr><th>Имя коллекции</th><th>Документов</th><th>Размер</th><th>Индексы</th><th>Действия</th></tr>
</thead>
<tbody>
${data.data.collections.map(coll => `
<tr>
<td><strong>${escapeHtml(coll.name)}</strong></td>
<td>${coll.count}</td>
<td>${(coll.size / 1024).toFixed(2)} KB</td>
<td>${coll.indexes.length}</td>
<td>
<button class="btn btn-sm btn-primary" onclick="viewCollection('${escapeHtml(dbName)}', '${escapeHtml(coll.name)}')">
<i class="fas fa-eye"></i>
</button>
<button class="btn btn-sm btn-danger" onclick="deleteCollection('${escapeHtml(dbName)}', '${escapeHtml(coll.name)}')">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
} else {
contentArea.innerHTML = '<div class="error-message">Ошибка загрузки коллекций</div>';
}
} catch (error) {
contentArea.innerHTML = '<div class="error-message">Ошибка подключения</div>';
}
};
// Просмотр коллекции
window.viewCollection = async function(dbName, collName) {
currentDatabase = dbName;
currentCollection = collName;
pageTitle.textContent = `Коллекция: ${dbName}.${collName}`;
contentArea.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-pulse"></i><p>Загрузка документов...</p></div>';
try {
const response = await fetch(`/api/webui/documents/${dbName}/${collName}?limit=100`);
const data = await response.json();
if (data.success) {
contentArea.innerHTML = `
<div style="margin-bottom: 16px; display: flex; gap: 12px; flex-wrap: wrap;">
<button class="btn btn-primary" onclick="showInsertDocumentModal()">
<i class="fas fa-plus"></i> Вставить документ
</button>
<button class="btn btn-secondary" onclick="viewDatabase('${escapeHtml(dbName)}')">
<i class="fas fa-arrow-left"></i> Назад
</button>
</div>
<div class="data-table">
<h3 style="margin-bottom: 16px;">Документы (${data.data.total} всего)</h3>
<table>
<thead>
<tr><th>ID</th><th>Поля</th><th>Создан</th><th>Действия</th></tr>
</thead>
<tbody>
${data.data.documents.map(doc => `
<tr>
<td><code>${escapeHtml(doc.id)}</code></td>
<td><pre style="max-width: 400px; overflow-x: auto;">${escapeHtml(JSON.stringify(doc.fields, null, 2))}</pre></td>
<td>${new Date(doc.created_at).toLocaleString()}</td>
<td>
<button class="btn btn-sm btn-secondary" onclick="showUpdateDocumentModal('${escapeHtml(doc.id)}', ${escapeHtml(JSON.stringify(doc.fields))})">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-sm btn-danger" onclick="deleteDocument('${escapeHtml(dbName)}', '${escapeHtml(collName)}', '${escapeHtml(doc.id)}')">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
} else {
contentArea.innerHTML = '<div class="error-message">Ошибка загрузки документов</div>';
}
} catch (error) {
contentArea.innerHTML = '<div class="error-message">Ошибка подключения</div>';
}
};
// Загрузка управления кластером
async function loadClusterManagement() {
pageTitle.textContent = 'Управление кластером';
contentArea.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-pulse"></i><p>Загрузка информации о кластере...</p></div>';
try {
const [statusRes, nodesRes] = await Promise.all([
fetch('/api/webui/cluster/status'),
fetch('/api/webui/cluster/nodes')
]);
const status = await statusRes.json();
const nodes = await nodesRes.json();
contentArea.innerHTML = `
<div class="dashboard-stats">
<div class="stat-card">
<div class="stat-icon"><i class="fas fa-heartbeat"></i></div>
<div class="stat-info">
<h3 style="color: ${status.data.health === 'healthy' ? '#28a745' : status.data.health === 'degraded' ? '#ffc107' : '#dc3545'}">
${status.data.health === 'healthy' ? 'Здоров' : status.data.health === 'degraded' ? 'Деградирован' : 'Критический'}
</h3>
<p>Состояние кластера</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon"><i class="fas fa-server"></i></div>
<div class="stat-info">
<h3>${status.data.active_nodes}/${status.data.total_nodes}</h3>
<p>Активные узлы</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon"><i class="fas fa-copy"></i></div>
<div class="stat-info">
<h3>${status.data.replication_factor}</h3>
<p>Фактор репликации</p>
</div>
</div>
</div>
<div class="data-table">
<h3 style="margin-bottom: 16px;">Узлы кластера</h3>
<table>
<thead>
<tr><th>ID узла</th><th>Адрес</th><th>Статус</th><th>Последний контакт</th></tr>
</thead>
<tbody>
${nodes.data.map(node => `
<tr>
<td><code>${escapeHtml(node.id)}</code></td>
<td>${escapeHtml(node.ip)}:${node.port}</td>
<td><span class="status-badge status-${node.status}">${node.status}</span></td>
<td>${new Date(node.last_seen * 1000).toLocaleString()}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
} catch (error) {
contentArea.innerHTML = '<div class="error-message">Ошибка загрузки информации о кластере</div>';
}
}
// Загрузка лога аудита
async function loadAuditLog() {
pageTitle.textContent = 'Лог аудита';
contentArea.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-pulse"></i><p>Загрузка лога аудита...</p></div>';
// TODO: Реализовать API для получения лога аудита
contentArea.innerHTML = '<div class="info-message">Функция в разработке</div>';
}
// Загрузка настроек
function loadSettings() {
pageTitle.textContent = 'Настройки';
contentArea.innerHTML = `
<div class="settings-panel">
<h3>Настройки интерфейса</h3>
<div class="form-group">
<label>Тема оформления</label>
<select class="form-control" id="themeSelect">
<option value="dark">Тёмная</option>
<option value="light">Светлая</option>
</select>
</div>
<button class="btn btn-primary" onclick="saveSettings()">Сохранить настройки</button>
</div>
`;
}
// Обработка CRUD действий
function handleCrudAction(action) {
switch(action) {
case 'create-db':
showCreateDatabaseModal();
break;
case 'create-collection':
showCreateCollectionModal();
break;
case 'insert-doc':
showInsertDocumentModal();
break;
case 'find-doc':
showFindDocumentModal();
break;
case 'update-doc':
showUpdateDocumentModal();
break;
case 'delete-doc':
showDeleteDocumentModal();
break;
}
}
// Показать модальное окно создания БД
function showCreateDatabaseModal() {
modalTitle.textContent = 'Создать базу данных';
modalBody.innerHTML = `
<div class="form-group">
<label for="dbName">Имя базы данных</label>
<input type="text" id="dbName" class="form-control" placeholder="my_database">
</div>
`;
modal.classList.add('show');
modalConfirm.onclick = async () => {
const dbName = document.getElementById('dbName').value;
if (!dbName) {
showNotification('Введите имя базы данных', 'error');
return;
}
try {
const response = await fetch('/api/db/' + dbName, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
if (response.ok) {
modal.classList.remove('show');
showNotification(`База данных "${dbName}" создана`, 'success');
loadDashboard();
} else {
const error = await response.json();
showNotification(error.error || 'Ошибка создания БД', 'error');
}
} catch (error) {
showNotification('Ошибка подключения', 'error');
}
};
}
// Показать модальное окно создания коллекции
function showCreateCollectionModal() {
if (!currentDatabase) {
showNotification('Сначала выберите базу данных', 'warning');
return;
}
modalTitle.textContent = 'Создать коллекцию';
modalBody.innerHTML = `
<div class="form-group">
<label>База данных</label>
<input type="text" class="form-control" value="${escapeHtml(currentDatabase)}" disabled>
</div>
<div class="form-group">
<label for="collName">Имя коллекции</label>
<input type="text" id="collName" class="form-control" placeholder="my_collection">
</div>
`;
modal.classList.add('show');
modalConfirm.onclick = async () => {
const collName = document.getElementById('collName').value;
if (!collName) {
showNotification('Введите имя коллекции', 'error');
return;
}
try {
const response = await fetch(`/api/db/${currentDatabase}/${collName}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
if (response.ok) {
modal.classList.remove('show');
showNotification(`Коллекция "${collName}" создана`, 'success');
viewDatabase(currentDatabase);
} else {
const error = await response.json();
showNotification(error.error || 'Ошибка создания коллекции', 'error');
}
} catch (error) {
showNotification('Ошибка подключения', 'error');
}
};
}
// Показать модальное окно вставки документа
function showInsertDocumentModal() {
if (!currentDatabase || !currentCollection) {
showNotification('Сначала выберите базу данных и коллекцию', 'warning');
return;
}
modalTitle.textContent = 'Вставить документ';
modalBody.innerHTML = `
<div class="form-group">
<label>База данных</label>
<input type="text" class="form-control" value="${escapeHtml(currentDatabase)}" disabled>
</div>
<div class="form-group">
<label>Коллекция</label>
<input type="text" class="form-control" value="${escapeHtml(currentCollection)}" disabled>
</div>
<div class="form-group">
<label for="docData">Данные документа (JSON)</label>
<textarea id="docData" class="form-control" rows="8" placeholder='{"_id": "doc1", "name": "Example", "value": 123}'></textarea>
</div>
`;
modal.classList.add('show');
modalConfirm.onclick = async () => {
const docData = document.getElementById('docData').value;
if (!docData) {
showNotification('Введите данные документа', 'error');
return;
}
try {
const data = JSON.parse(docData);
const response = await fetch(`/api/webui/documents/${currentDatabase}/${currentCollection}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
modal.classList.remove('show');
showNotification('Документ вставлен', 'success');
viewCollection(currentDatabase, currentCollection);
} else {
showNotification(result.error || 'Ошибка вставки документа', 'error');
}
} catch (error) {
if (error instanceof SyntaxError) {
showNotification('Неверный формат JSON', 'error');
} else {
showNotification('Ошибка подключения', 'error');
}
}
};
}
// Показать модальное окно поиска документа
function showFindDocumentModal() {
if (!currentDatabase || !currentCollection) {
showNotification('Сначала выберите базу данных и коллекцию', 'warning');
return;
}
modalTitle.textContent = 'Найти документ';
modalBody.innerHTML = `
<div class="form-group">
<label>База данных</label>
<input type="text" class="form-control" value="${escapeHtml(currentDatabase)}" disabled>
</div>
<div class="form-group">
<label>Коллекция</label>
<input type="text" class="form-control" value="${escapeHtml(currentCollection)}" disabled>
</div>
<div class="form-group">
<label for="docId">ID документа</label>
<input type="text" id="docId" class="form-control" placeholder="document_id">
</div>
`;
modal.classList.add('show');
modalConfirm.onclick = async () => {
const docId = document.getElementById('docId').value;
if (!docId) {
showNotification('Введите ID документа', 'error');
return;
}
try {
const response = await fetch(`/api/db/${currentDatabase}/${currentCollection}/${docId}`);
if (response.ok) {
const data = await response.json();
modal.classList.remove('show');
// Показать результат поиска
contentArea.innerHTML = `
<div class="data-table">
<h3>Результат поиска</h3>
<pre style="background: var(--bg-dark); padding: 16px; border-radius: 8px; overflow-x: auto;">
${escapeHtml(JSON.stringify(data.data, null, 2))}
</pre>
<button class="btn btn-secondary" onclick="viewCollection('${escapeHtml(currentDatabase)}', '${escapeHtml(currentCollection)}')">
<i class="fas fa-arrow-left"></i> Назад
</button>
</div>
`;
} else {
const error = await response.json();
showNotification(error.error || 'Документ не найден', 'error');
}
} catch (error) {
showNotification('Ошибка подключения', 'error');
}
};
}
// Показать модальное окно обновления документа
function showUpdateDocumentModal(docId, currentFields = null) {
if (!currentDatabase || !currentCollection) {
showNotification('Сначала выберите базу данных и коллекцию', 'warning');
return;
}
const fieldsJson = currentFields ? JSON.stringify(currentFields, null, 2) : '';
modalTitle.textContent = 'Обновить документ';
modalBody.innerHTML = `
<div class="form-group">
<label>База данных</label>
<input type="text" class="form-control" value="${escapeHtml(currentDatabase)}" disabled>
</div>
<div class="form-group">
<label>Коллекция</label>
<input type="text" class="form-control" value="${escapeHtml(currentCollection)}" disabled>
</div>
<div class="form-group">
<label for="updateDocId">ID документа</label>
<input type="text" id="updateDocId" class="form-control" value="${escapeHtml(docId || '')}" ${docId ? 'disabled' : ''} placeholder="document_id">
</div>
<div class="form-group">
<label for="updateData">Обновления (JSON)</label>
<textarea id="updateData" class="form-control" rows="8" placeholder='{"field1": "new value", "field2": 456}'>${escapeHtml(fieldsJson)}</textarea>
</div>
`;
modal.classList.add('show');
modalConfirm.onclick = async () => {
const updateDocId = document.getElementById('updateDocId').value;
const updateData = document.getElementById('updateData').value;
if (!updateDocId) {
showNotification('Введите ID документа', 'error');
return;
}
if (!updateData) {
showNotification('Введите данные для обновления', 'error');
return;
}
try {
const data = JSON.parse(updateData);
const response = await fetch(`/api/webui/documents/${currentDatabase}/${currentCollection}?id=${encodeURIComponent(updateDocId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
modal.classList.remove('show');
showNotification('Документ обновлён', 'success');
viewCollection(currentDatabase, currentCollection);
} else {
showNotification(result.error || 'Ошибка обновления документа', 'error');
}
} catch (error) {
if (error instanceof SyntaxError) {
showNotification('Неверный формат JSON', 'error');
} else {
showNotification('Ошибка подключения', 'error');
}
}
};
}
// Показать модальное окно удаления документа
function showDeleteDocumentModal() {
if (!currentDatabase || !currentCollection) {
showNotification('Сначала выберите базу данных и коллекцию', 'warning');
return;
}
modalTitle.textContent = 'Удалить документ';
modalBody.innerHTML = `
<div class="form-group">
<label>База данных</label>
<input type="text" class="form-control" value="${escapeHtml(currentDatabase)}" disabled>
</div>
<div class="form-group">
<label>Коллекция</label>
<input type="text" class="form-control" value="${escapeHtml(currentCollection)}" disabled>
</div>
<div class="form-group">
<label for="deleteDocId">ID документа</label>
<input type="text" id="deleteDocId" class="form-control" placeholder="document_id">
</div>
`;
modal.classList.add('show');
modalConfirm.onclick = async () => {
const docId = document.getElementById('deleteDocId').value;
if (!docId) {
showNotification('Введите ID документа', 'error');
return;
}
try {
const response = await fetch(`/api/webui/documents/${currentDatabase}/${currentCollection}?id=${encodeURIComponent(docId)}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.success) {
modal.classList.remove('show');
showNotification('Документ удалён', 'success');
viewCollection(currentDatabase, currentCollection);
} else {
showNotification(result.error || 'Ошибка удаления документа', 'error');
}
} catch (error) {
showNotification('Ошибка подключения', 'error');
}
};
}
// Удаление коллекции
window.deleteCollection = async function(dbName, collName) {
if (confirm(`Вы уверены, что хотите удалить коллекцию "${collName}"? Это действие необратимо.`)) {
try {
const response = await fetch(`/api/db/${dbName}/${collName}`, {
method: 'DELETE'
});
if (response.ok) {
showNotification(`Коллекция "${collName}" удалена`, 'success');
viewDatabase(dbName);
} else {
const error = await response.json();
showNotification(error.error || 'Ошибка удаления коллекции', 'error');
}
} catch (error) {
showNotification('Ошибка подключения', 'error');
}
}
};
// Удаление документа
window.deleteDocument = async function(dbName, collName, docId) {
if (confirm(`Вы уверены, что хотите удалить документ "${docId}"?`)) {
try {
const response = await fetch(`/api/webui/documents/${dbName}/${collName}?id=${encodeURIComponent(docId)}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.success) {
showNotification('Документ удалён', 'success');
viewCollection(dbName, collName);
} else {
showNotification(result.error || 'Ошибка удаления документа', 'error');
}
} catch (error) {
showNotification('Ошибка подключения', 'error');
}
}
};
// Сохранение настроек
function saveSettings() {
const theme = document.getElementById('themeSelect')?.value;
if (theme) {
localStorage.setItem('theme', theme);
showNotification('Настройки сохранены', 'success');
}
}
// Утилиты
function escapeHtml(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function showNotification(message, type = 'info') {
const container = document.getElementById('notificationContainer');
const notification = document.createElement('div');
notification.className = `notification ${type}`;
let icon = '';
switch(type) {
case 'success': icon = '<i class="fas fa-check-circle"></i>'; break;
case 'error': icon = '<i class="fas fa-exclamation-circle"></i>'; break;
case 'warning': icon = '<i class="fas fa-exclamation-triangle"></i>'; break;
default: icon = '<i class="fas fa-info-circle"></i>';
}
notification.innerHTML = `${icon}<span>${escapeHtml(message)}</span>`;
container.appendChild(notification);
setTimeout(() => {
notification.style.animation = 'slideOutRight 0.3s ease';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
function setActiveNav(activeLink) {
document.querySelectorAll('.nav-link').forEach(link => {
link.classList.remove('active');
});
activeLink.classList.add('active');
}

View File

@@ -0,0 +1,695 @@
/*
* 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/api/static/style.css */
/* Стили для веб-интерфейса Futriis DB Dashboard */
:root {
--primary-color: #00bfff;
--primary-dark: #0099cc;
--secondary-color: #6c757d;
--success-color: #28a745;
--danger-color: #dc3545;
--warning-color: #ffc107;
--info-color: #17a2b8;
--bg-dark: #1a1a2e;
--bg-sidebar: #16213e;
--bg-card: #0f3460;
--bg-hover: #1a1f3a;
--text-primary: #ffffff;
--text-secondary: #b8c6db;
--border-color: #2d3a5e;
--shadow-sm: 0 2px 4px rgba(0,0,0,0.1);
--shadow-md: 0 4px 8px rgba(0,0,0,0.15);
--shadow-lg: 0 8px 16px rgba(0,0,0,0.2);
--transition: all 0.3s ease;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
overflow: hidden;
}
/* Dashboard Container */
.dashboard-container {
display: flex;
height: 100vh;
width: 100%;
}
/* Sidebar - Вертикальное меню */
.sidebar {
width: 280px;
background: var(--bg-sidebar);
display: flex;
flex-direction: column;
transition: var(--transition);
box-shadow: var(--shadow-lg);
z-index: 100;
overflow-y: auto;
overflow-x: hidden;
}
.sidebar-header {
padding: 24px 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
font-size: 1.5rem;
font-weight: 700;
}
.logo i {
color: var(--primary-color);
font-size: 1.8rem;
}
.logo span {
background: linear-gradient(135deg, var(--primary-color), #00ffcc);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.menu-toggle {
display: none;
background: none;
border: none;
color: var(--text-primary);
font-size: 1.2rem;
cursor: pointer;
padding: 8px;
}
/* Navigation Menu */
.nav-menu {
flex: 1;
list-style: none;
padding: 20px 0;
}
.nav-item {
margin-bottom: 4px;
}
.nav-link {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
color: var(--text-secondary);
text-decoration: none;
transition: var(--transition);
border-radius: 8px;
margin: 0 8px;
}
.nav-link:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.nav-link.active {
background: var(--primary-color);
color: white;
}
.nav-link i {
width: 24px;
font-size: 1.1rem;
}
.nav-link span {
flex: 1;
}
/* Submenu */
.has-submenu > .nav-link {
position: relative;
}
.has-submenu > .nav-link .fa-chevron-down {
margin-left: auto;
transition: var(--transition);
}
.has-submenu.open > .nav-link .fa-chevron-down {
transform: rotate(180deg);
}
.submenu {
list-style: none;
padding-left: 56px;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.has-submenu.open .submenu {
max-height: 500px;
}
.submenu li a {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.9rem;
transition: var(--transition);
border-radius: 6px;
margin: 2px 8px;
}
.submenu li a:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.submenu li a i {
width: 20px;
font-size: 0.9rem;
}
/* Sidebar Footer */
.sidebar-footer {
padding: 20px;
border-top: 1px solid var(--border-color);
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--bg-dark);
border-radius: 8px;
margin-bottom: 12px;
}
.user-info i {
font-size: 1.2rem;
color: var(--primary-color);
}
.logout-btn {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: var(--danger-color);
border: none;
color: white;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
transition: var(--transition);
}
.logout-btn:hover {
background: #c82333;
transform: translateY(-1px);
}
/* Main Content */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.top-bar {
background: var(--bg-sidebar);
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
}
.top-bar h1 {
font-size: 1.5rem;
font-weight: 600;
}
.connection-status {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--bg-dark);
border-radius: 20px;
font-size: 0.85rem;
}
.connection-status i {
font-size: 0.7rem;
}
.connection-status.online i {
color: var(--success-color);
}
.connection-status.offline i {
color: var(--danger-color);
}
.content-area {
flex: 1;
overflow-y: auto;
padding: 24px;
}
/* Dashboard Cards */
.dashboard-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: var(--bg-card);
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
transition: var(--transition);
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.stat-icon {
width: 60px;
height: 60px;
background: rgba(0, 191, 255, 0.1);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.stat-icon i {
font-size: 2rem;
color: var(--primary-color);
}
.stat-info h3 {
font-size: 1.8rem;
font-weight: 700;
}
.stat-info p {
color: var(--text-secondary);
font-size: 0.85rem;
}
/* Tables */
.data-table {
width: 100%;
background: var(--bg-card);
border-radius: 12px;
overflow-x: auto;
}
.data-table table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.data-table th {
background: var(--bg-sidebar);
font-weight: 600;
color: var(--primary-color);
}
.data-table tr:hover {
background: var(--bg-hover);
}
/* Forms */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
}
.form-control {
width: 100%;
padding: 10px 12px;
background: var(--bg-dark);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.95rem;
transition: var(--transition);
}
.form-control:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(0, 191, 255, 0.2);
}
textarea.form-control {
min-height: 120px;
font-family: monospace;
resize: vertical;
}
/* Buttons */
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
transition: var(--transition);
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-primary:hover {
background: var(--primary-dark);
transform: translateY(-1px);
}
.btn-secondary {
background: var(--secondary-color);
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.btn-sm {
padding: 6px 12px;
font-size: 0.85rem;
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.show {
display: flex;
}
.modal-content {
background: var(--bg-card);
border-radius: 16px;
width: 90%;
max-width: 600px;
max-height: 80vh;
display: flex;
flex-direction: column;
animation: modalSlideIn 0.3s ease;
}
@keyframes modalSlideIn {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
font-size: 1.3rem;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-secondary);
}
.modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.modal-footer {
padding: 16px 20px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* Notifications */
.notification-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 1100;
}
.notification {
background: var(--bg-card);
border-radius: 8px;
padding: 12px 20px;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: var(--shadow-lg);
animation: slideInRight 0.3s ease;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.notification.success {
border-left: 4px solid var(--success-color);
}
.notification.error {
border-left: 4px solid var(--danger-color);
}
.notification.warning {
border-left: 4px solid var(--warning-color);
}
.notification.info {
border-left: 4px solid var(--info-color);
}
/* Loading Spinner */
.loading-spinner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
}
.loading-spinner i {
font-size: 3rem;
color: var(--primary-color);
}
.loading-spinner p {
margin-top: 16px;
color: var(--text-secondary);
}
/* Tabs */
.tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 8px;
}
.tab-btn {
padding: 8px 16px;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: 6px;
transition: var(--transition);
}
.tab-btn.active {
background: var(--primary-color);
color: white;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Responsive Design */
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: -280px;
height: 100%;
transition: left 0.3s ease;
}
.sidebar.open {
left: 0;
}
.menu-toggle {
display: block;
}
.main-content {
margin-left: 0;
}
.dashboard-stats {
grid-template-columns: 1fr;
}
.data-table {
font-size: 0.85rem;
}
.data-table th,
.data-table td {
padding: 8px 12px;
}
.modal-content {
width: 95%;
margin: 20px;
}
}
@media (max-width: 480px) {
.top-bar h1 {
font-size: 1.2rem;
}
.stat-card {
padding: 16px;
}
.stat-icon {
width: 50px;
height: 50px;
}
.stat-info h3 {
font-size: 1.4rem;
}
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-dark);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--primary-color);
}

636
internal/api/webui.go Normal file
View File

@@ -0,0 +1,636 @@
/*
* 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/api/webui.go
// Назначение: Веб-интерфейс в стиле dashboard для управления СУБД futriis
package api
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"strconv"
"strings"
"time"
"futriis/internal/acl"
"futriis/internal/cluster"
"futriis/internal/log"
"futriis/internal/storage"
)
//go:embed static/*
var staticFiles embed.FS
// WebUIServer представляет сервер веб-интерфейса
type WebUIServer struct {
store *storage.Storage
coordinator *cluster.RaftCoordinator
aclManager *acl.ACLManager
logger *log.Logger
server *http.Server
port int
enabled bool
}
// NewWebUIServer создаёт новый веб-сервер интерфейса
func NewWebUIServer(port int, enabled bool, store *storage.Storage, coord *cluster.RaftCoordinator, aclMgr *acl.ACLManager, logger *log.Logger) *WebUIServer {
return &WebUIServer{
store: store,
coordinator: coord,
aclManager: aclMgr,
logger: logger,
port: port,
enabled: enabled,
}
}
// Start запускает веб-сервер интерфейса
func (w *WebUIServer) Start() error {
if !w.enabled {
w.logger.Info("Web UI is disabled in configuration")
return nil
}
mux := http.NewServeMux()
// Статические файлы
staticFS, err := fs.Sub(staticFiles, "static")
if err != nil {
return fmt.Errorf("failed to load static files: %v", err)
}
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
// Главная страница
mux.HandleFunc("/", w.handleIndex)
// API для веб-интерфейса
mux.HandleFunc("/api/webui/databases", w.handleGetDatabases)
mux.HandleFunc("/api/webui/collections/", w.handleGetCollections)
mux.HandleFunc("/api/webui/documents/", w.handleDocuments)
mux.HandleFunc("/api/webui/cluster/status", w.handleClusterStatus)
mux.HandleFunc("/api/webui/cluster/nodes", w.handleClusterNodes)
mux.HandleFunc("/api/webui/stats", w.handleStats)
mux.HandleFunc("/api/webui/login", w.handleWebLogin)
mux.HandleFunc("/api/webui/logout", w.handleWebLogout)
mux.HandleFunc("/api/webui/session", w.handleSessionCheck)
w.server = &http.Server{
Addr: fmt.Sprintf(":%d", w.port),
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
w.logger.Info(fmt.Sprintf("Web UI started on port %d", w.port))
return w.server.ListenAndServe()
}
// Stop останавливает веб-сервер
func (w *WebUIServer) Stop() error {
if w.server != nil {
return w.server.Close()
}
return nil
}
// handleIndex возвращает главную HTML страницу
func (w *WebUIServer) handleIndex(wr http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(wr, r)
return
}
html := `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<title>Futriis Database Management System</title>
<link rel="stylesheet" href="/static/style.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body>
<div class="dashboard-container">
<!-- Вертикальное меню -->
<nav class="sidebar">
<div class="sidebar-header">
<div class="logo">
<i class="fas fa-database"></i>
<span>Futriis DB</span>
</div>
<button class="menu-toggle" id="menuToggle">
<i class="fas fa-bars"></i>
</button>
</div>
<ul class="nav-menu">
<li class="nav-item">
<a href="#" class="nav-link" data-section="dashboard">
<i class="fas fa-tachometer-alt"></i>
<span>Панель управления</span>
</a>
</li>
<li class="nav-item has-submenu">
<a href="#" class="nav-link" data-submenu="crud">
<i class="fas fa-table"></i>
<span>Управление СУБД</span>
<i class="fas fa-chevron-down"></i>
</a>
<ul class="submenu">
<li><a href="#" data-action="create-db"><i class="fas fa-plus-circle"></i>Создать БД</a></li>
<li><a href="#" data-action="create-collection"><i class="fas fa-layer-group"></i>Создать коллекцию</a></li>
<li><a href="#" data-action="insert-doc"><i class="fas fa-file-import"></i>Вставить документ</a></li>
<li><a href="#" data-action="find-doc"><i class="fas fa-search"></i>Найти документ</a></li>
<li><a href="#" data-action="update-doc"><i class="fas fa-edit"></i>Обновить документ</a></li>
<li><a href="#" data-action="delete-doc"><i class="fas fa-trash-alt"></i>Удалить документ</a></li>
</ul>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-section="cluster">
<i class="fas fa-network-wired"></i>
<span>Управление кластером</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-section="audit">
<i class="fas fa-history"></i>
<span>Аудит</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-section="settings">
<i class="fas fa-cog"></i>
<span>Настройки</span>
</a>
</li>
</ul>
<div class="sidebar-footer">
<div class="user-info" id="userInfo">
<i class="fas fa-user-circle"></i>
<span>Гость</span>
</div>
<button class="logout-btn" id="logoutBtn">
<i class="fas fa-sign-out-alt"></i>
<span>Выход</span>
</button>
</div>
</nav>
<!-- Основной контент -->
<main class="main-content">
<header class="top-bar">
<h1 id="pageTitle">Панель управления</h1>
<div class="connection-status" id="connectionStatus">
<i class="fas fa-circle"></i>
<span>Подключено</span>
</div>
</header>
<div class="content-area" id="contentArea">
<!-- Контент загружается динамически -->
<div class="loading-spinner">
<i class="fas fa-spinner fa-pulse"></i>
<p>Загрузка...</p>
</div>
</div>
</main>
</div>
<!-- Модальные окна -->
<div id="modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modalTitle">Заголовок</h2>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body" id="modalBody">
<!-- Содержимое модального окна -->
</div>
<div class="modal-footer">
<button class="btn btn-secondary modal-close">Отмена</button>
<button class="btn btn-primary" id="modalConfirm">Подтвердить</button>
</div>
</div>
</div>
<!-- Уведомления -->
<div id="notificationContainer" class="notification-container"></div>
<script src="/static/app.js"></script>
</body>
</html>`
wr.Header().Set("Content-Type", "text/html; charset=utf-8")
wr.Write([]byte(html))
}
// handleGetDatabases возвращает список баз данных
func (w *WebUIServer) handleGetDatabases(wr http.ResponseWriter, r *http.Request) {
if !w.checkAuth(r) {
w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
return
}
databases := w.store.ListDatabases()
dbInfo := make([]map[string]interface{}, 0)
for _, dbName := range databases {
db, err := w.store.GetDatabase(dbName)
if err == nil {
collections := db.ListCollections()
dbInfo = append(dbInfo, map[string]interface{}{
"name": dbName,
"collections": len(collections),
"collections_list": collections,
})
}
}
w.sendJSONSuccess(wr, dbInfo)
}
// handleGetCollections возвращает список коллекций в базе данных
func (w *WebUIServer) handleGetCollections(wr http.ResponseWriter, r *http.Request) {
if !w.checkAuth(r) {
w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/webui/collections/")
parts := strings.Split(path, "/")
if len(parts) < 1 || parts[0] == "" {
w.sendJSONError(wr, "Database name required", http.StatusBadRequest)
return
}
dbName := parts[0]
db, err := w.store.GetDatabase(dbName)
if err != nil {
w.sendJSONError(wr, err.Error(), http.StatusNotFound)
return
}
collections := db.ListCollections()
collectionsInfo := make([]map[string]interface{}, 0)
for _, collName := range collections {
coll, err := db.GetCollection(collName)
if err == nil {
collectionsInfo = append(collectionsInfo, map[string]interface{}{
"name": collName,
"count": coll.Count(),
"size": coll.Size(),
"indexes": coll.GetIndexes(),
})
}
}
w.sendJSONSuccess(wr, map[string]interface{}{
"database": dbName,
"collections": collectionsInfo,
})
}
// handleDocuments обрабатывает CRUD операции с документами
func (w *WebUIServer) handleDocuments(wr http.ResponseWriter, r *http.Request) {
if !w.checkAuth(r) {
w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/webui/documents/")
parts := strings.Split(path, "/")
if len(parts) < 2 {
w.sendJSONError(wr, "Invalid path. Use /api/webui/documents/{database}/{collection}", http.StatusBadRequest)
return
}
dbName := parts[0]
collName := parts[1]
db, err := w.store.GetDatabase(dbName)
if err != nil {
w.sendJSONError(wr, err.Error(), http.StatusNotFound)
return
}
coll, err := db.GetCollection(collName)
if err != nil {
w.sendJSONError(wr, err.Error(), http.StatusNotFound)
return
}
switch r.Method {
case http.MethodGet:
// Получение документов с пагинацией
limit := 50
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 500 {
limit = l
}
}
offset := 0
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
offset = o
}
}
allDocs := coll.GetAllDocuments()
start := offset
end := offset + limit
if start > len(allDocs) {
start = len(allDocs)
}
if end > len(allDocs) {
end = len(allDocs)
}
docs := make([]map[string]interface{}, 0)
for _, doc := range allDocs[start:end] {
docs = append(docs, map[string]interface{}{
"id": doc.ID,
"fields": doc.GetFields(),
"created_at": doc.CreatedAt,
"updated_at": doc.UpdatedAt,
"version": doc.Version,
})
}
w.sendJSONSuccess(wr, map[string]interface{}{
"documents": docs,
"total": len(allDocs),
"limit": limit,
"offset": offset,
})
case http.MethodPost:
// Вставка документа
var docData map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&docData); err != nil {
w.sendJSONError(wr, "Invalid JSON", http.StatusBadRequest)
return
}
if err := coll.InsertFromMap(docData); err != nil {
w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
return
}
w.sendJSONSuccess(wr, map[string]interface{}{
"status": "inserted",
"id": docData["_id"],
})
case http.MethodPut:
// Обновление документа
docID := r.URL.Query().Get("id")
if docID == "" {
w.sendJSONError(wr, "Document ID required", http.StatusBadRequest)
return
}
var updates map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
w.sendJSONError(wr, "Invalid JSON", http.StatusBadRequest)
return
}
if err := coll.Update(docID, updates); err != nil {
w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
return
}
w.sendJSONSuccess(wr, map[string]interface{}{
"status": "updated",
"id": docID,
})
case http.MethodDelete:
// Удаление документа
docID := r.URL.Query().Get("id")
if docID == "" {
w.sendJSONError(wr, "Document ID required", http.StatusBadRequest)
return
}
if err := coll.Delete(docID); err != nil {
w.sendJSONError(wr, err.Error(), http.StatusNotFound)
return
}
w.sendJSONSuccess(wr, map[string]interface{}{
"status": "deleted",
"id": docID,
})
default:
w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// handleClusterStatus возвращает статус кластера
func (w *WebUIServer) handleClusterStatus(wr http.ResponseWriter, r *http.Request) {
if !w.checkAuth(r) {
w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
return
}
if w.coordinator == nil {
w.sendJSONError(wr, "Cluster not available", http.StatusServiceUnavailable)
return
}
status := w.coordinator.GetClusterStatus()
w.sendJSONSuccess(wr, status)
}
// handleClusterNodes возвращает список узлов кластера
func (w *WebUIServer) handleClusterNodes(wr http.ResponseWriter, r *http.Request) {
if !w.checkAuth(r) {
w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
return
}
if w.coordinator == nil {
w.sendJSONError(wr, "Cluster not available", http.StatusServiceUnavailable)
return
}
nodes := w.coordinator.GetAllNodes()
w.sendJSONSuccess(wr, nodes)
}
// handleStats возвращает статистику системы
func (w *WebUIServer) handleStats(wr http.ResponseWriter, r *http.Request) {
if !w.checkAuth(r) {
w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
return
}
databases := w.store.ListDatabases()
totalDocs := int64(0)
totalCollections := 0
for _, dbName := range databases {
db, _ := w.store.GetDatabase(dbName)
if db != nil {
collections := db.ListCollections()
totalCollections += len(collections)
for _, collName := range collections {
coll, _ := db.GetCollection(collName)
if coll != nil {
totalDocs += coll.Count()
}
}
}
}
stats := map[string]interface{}{
"databases": len(databases),
"collections": totalCollections,
"documents": totalDocs,
"storage_used_mb": float64(w.store.GetPageSize()*int64(len(databases))) / (1024 * 1024),
"uptime_seconds": time.Now().Unix(),
"cluster_enabled": w.coordinator != nil,
"replication_factor": 0,
}
if w.coordinator != nil {
stats["replication_factor"] = w.coordinator.GetReplicationFactor()
stats["cluster_health"] = w.coordinator.GetClusterStatus().Health
}
w.sendJSONSuccess(wr, stats)
}
// handleWebLogin обрабатывает вход в веб-интерфейс
func (w *WebUIServer) handleWebLogin(wr http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var creds struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
return
}
sessionID, err := w.aclManager.Authenticate(creds.Username, creds.Password)
if err != nil {
w.sendJSONError(wr, err.Error(), http.StatusUnauthorized)
return
}
// Устанавливаем cookie сессии
http.SetCookie(wr, &http.Cookie{
Name: "session_id",
Value: sessionID,
Path: "/",
MaxAge: 86400,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
w.sendJSONSuccess(wr, map[string]interface{}{
"session_id": sessionID,
"username": creds.Username,
})
}
// handleWebLogout обрабатывает выход из веб-интерфейса
func (w *WebUIServer) handleWebLogout(wr http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie("session_id"); err == nil {
w.aclManager.Logout(cookie.Value)
}
http.SetCookie(wr, &http.Cookie{
Name: "session_id",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
w.sendJSONSuccess(wr, map[string]interface{}{
"status": "logged out",
})
}
// handleSessionCheck проверяет активность сессии
func (w *WebUIServer) handleSessionCheck(wr http.ResponseWriter, r *http.Request) {
sessionID := w.getSessionID(r)
if sessionID == "" {
w.sendJSONError(wr, "No session", http.StatusUnauthorized)
return
}
if !w.aclManager.CheckSession(sessionID) {
w.sendJSONError(wr, "Invalid session", http.StatusUnauthorized)
return
}
username := w.aclManager.GetUsername(sessionID)
w.sendJSONSuccess(wr, map[string]interface{}{
"authenticated": true,
"username": username,
})
}
// checkAuth проверяет аутентификацию
func (w *WebUIServer) checkAuth(r *http.Request) bool {
sessionID := w.getSessionID(r)
if sessionID == "" {
return false
}
return w.aclManager.CheckSession(sessionID)
}
// getSessionID возвращает ID сессии из cookie
func (w *WebUIServer) getSessionID(r *http.Request) string {
if cookie, err := r.Cookie("session_id"); err == nil {
return cookie.Value
}
return ""
}
// sendJSONSuccess отправляет успешный JSON ответ
func (w *WebUIServer) sendJSONSuccess(wr http.ResponseWriter, data interface{}) {
wr.Header().Set("Content-Type", "application/json")
wr.WriteHeader(http.StatusOK)
json.NewEncoder(wr).Encode(map[string]interface{}{
"success": true,
"data": data,
})
}
// sendJSONError отправляет JSON ответ с ошибкой
func (w *WebUIServer) sendJSONError(wr http.ResponseWriter, errMsg string, statusCode int) {
wr.Header().Set("Content-Type", "application/json")
wr.WriteHeader(statusCode)
json.NewEncoder(wr).Encode(map[string]interface{}{
"success": false,
"error": errMsg,
})
}

389
internal/cluster/node.go Normal file
View File

@@ -0,0 +1,389 @@
/*
* 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/cluster/node.go
// Назначение: Реализация узла кластера (node) для распределённой СУБД.
// Реализация узла кластера (node) для распределённой СУБД.
// Полностью lock-free с использованием атомарных операций.
package cluster
import (
"encoding/json"
"fmt"
"net"
"sync/atomic"
"time"
"futriis/internal/log"
"futriis/internal/storage"
"github.com/google/uuid"
)
// NodeStatus представляет состояние узла кластера
type NodeStatus int32
const (
StatusOffline NodeStatus = iota
StatusActive
StatusSyncing
StatusFailed
)
// Node представляет отдельный узел в распределённой системе
type Node struct {
ID string // Уникальный идентификатор узла
IP string // IP-адрес узла
Port int // Порт для коммуникации
Status atomic.Int32 // Атомарный статус узла (NodeStatus)
Storage *storage.Storage
logger *log.Logger
coordinator *RaftCoordinator // Ссылка на координатора (теперь RaftCoordinator)
lastSeen atomic.Int64 // Время последнего heartbeat (Unix nano)
incomingConn chan net.Conn // Канал для входящих соединений (wait-free)
stopChan chan struct{}
}
// NodeConfig содержит конфигурацию для создания узла
type NodeConfig struct {
IP string
Port int
Storage *storage.Storage
Logger *log.Logger
Coordinator *RaftCoordinator
}
// NewNode создаёт новый экземпляр узла кластера
func NewNode(ip string, port int, store *storage.Storage, logger *log.Logger) *Node {
node := &Node{
ID: uuid.New().String(),
IP: ip,
Port: port,
Storage: store,
logger: logger,
incomingConn: make(chan net.Conn, 1000), // Буферизованный канал для wait-free приёма
stopChan: make(chan struct{}),
}
node.Status.Store(int32(StatusActive))
node.lastSeen.Store(time.Now().UnixNano())
// Запуск сервера для приёма межузловых соединений
go node.startTCPServer()
// Запуск обработчика входящих соединений
go node.handleIncomingConnections()
// Запуск heartbeat-отправки (если координатор известен)
go node.heartbeatLoop()
return node
}
// startTCPServer запускает TCP-сервер для приёма запросов от других узлов
func (n *Node) startTCPServer() {
addr := fmt.Sprintf("%s:%d", n.IP, n.Port)
listener, err := net.Listen("tcp", addr)
if err != nil {
if n.logger != nil {
n.logger.Error(fmt.Sprintf("Node %s failed to start TCP server: %v", n.ID, err))
}
n.Status.Store(int32(StatusFailed))
return
}
defer listener.Close()
if n.logger != nil {
n.logger.Info(fmt.Sprintf("Node %s listening on %s", n.ID, addr))
}
for {
select {
case <-n.stopChan:
return
default:
conn, err := listener.Accept()
if err != nil {
if n.logger != nil {
n.logger.Error(fmt.Sprintf("Node %s accept error: %v", n.ID, err))
}
continue
}
// Неблокирующая отправка в канал
select {
case n.incomingConn <- conn:
default:
if n.logger != nil {
n.logger.Warn(fmt.Sprintf("Node %s incoming connection queue full, dropping connection", n.ID))
}
conn.Close()
}
}
}
}
// handleIncomingConnections обрабатывает входящие соединения wait-free способом
func (n *Node) handleIncomingConnections() {
for {
select {
case <-n.stopChan:
return
case conn := <-n.incomingConn:
go n.handleNodeRequest(conn)
}
}
}
// handleNodeRequest обрабатывает конкретный запрос от другого узла
func (n *Node) handleNodeRequest(conn net.Conn) {
defer conn.Close()
decoder := json.NewDecoder(conn)
var req NodeRequest
if err := decoder.Decode(&req); err != nil {
if n.logger != nil {
n.logger.Error(fmt.Sprintf("Node %s failed to decode request: %v", n.ID, err))
}
return
}
// Обновляем время последнего контакта
n.lastSeen.Store(time.Now().UnixNano())
// Маршрутизация запроса в зависимости от типа
switch req.Type {
case "replicate":
n.handleReplicateRequest(req.Data)
case "query":
n.handleQueryRequest(req.Data, conn)
case "sync":
n.handleSyncRequest(req.Data, conn)
default:
if n.logger != nil {
n.logger.Warn(fmt.Sprintf("Node %s unknown request type: %s", n.ID, req.Type))
}
}
}
// handleReplicateRequest обрабатывает запрос на репликацию документа
func (n *Node) handleReplicateRequest(data []byte) {
var repData struct {
Database string `json:"database"`
Collection string `json:"collection"`
Document map[string]interface{} `json:"document"`
}
if err := json.Unmarshal(data, &repData); err != nil {
if n.logger != nil {
n.logger.Error(fmt.Sprintf("Node %s failed to unmarshal replicate data: %v", n.ID, err))
}
return
}
// Получаем базу данных
db, err := n.Storage.GetDatabase(repData.Database)
if err != nil {
if n.logger != nil {
n.logger.Error(fmt.Sprintf("Node %s database not found for replication: %s", n.ID, repData.Database))
}
return
}
// Получаем коллекцию
coll, err := db.GetCollection(repData.Collection)
if err != nil {
if n.logger != nil {
n.logger.Error(fmt.Sprintf("Node %s collection not found for replication: %s", n.ID, repData.Collection))
}
return
}
// Создаём документ и вставляем
doc := &storage.Document{
ID: repData.Document["_id"].(string),
Fields: repData.Document,
}
if err := coll.Insert(doc); err != nil {
if n.logger != nil {
n.logger.Error(fmt.Sprintf("Node %s failed to replicate document: %v", n.ID, err))
}
} else {
if n.logger != nil {
n.logger.Debug(fmt.Sprintf("Node %s replicated document %s", n.ID, doc.ID))
}
}
}
// handleQueryRequest обрабатывает запрос на чтение данных с узла
func (n *Node) handleQueryRequest(data []byte, conn net.Conn) {
var queryData struct {
Database string `json:"database"`
Collection string `json:"collection"`
DocumentID string `json:"document_id"`
}
if err := json.Unmarshal(data, &queryData); err != nil {
n.sendErrorResponse(conn, err.Error())
return
}
// Получаем базу данных
db, err := n.Storage.GetDatabase(queryData.Database)
if err != nil {
n.sendErrorResponse(conn, err.Error())
return
}
// Получаем коллекцию
coll, err := db.GetCollection(queryData.Collection)
if err != nil {
n.sendErrorResponse(conn, err.Error())
return
}
// Находим документ
doc, err := coll.Find(queryData.DocumentID)
if err != nil {
n.sendErrorResponse(conn, err.Error())
return
}
// Отправляем успешный ответ
response := map[string]interface{}{
"status": "success",
"data": doc,
}
encoder := json.NewEncoder(conn)
encoder.Encode(response)
}
// handleSyncRequest обрабатывает запрос на синхронизацию всей коллекции
func (n *Node) handleSyncRequest(data []byte, conn net.Conn) {
var syncData struct {
Database string `json:"database"`
Collection string `json:"collection"`
}
if err := json.Unmarshal(data, &syncData); err != nil {
n.sendErrorResponse(conn, err.Error())
return
}
// Получаем базу данных
db, err := n.Storage.GetDatabase(syncData.Database)
if err != nil {
n.sendErrorResponse(conn, err.Error())
return
}
// Получаем коллекцию
coll, err := db.GetCollection(syncData.Collection)
if err != nil {
n.sendErrorResponse(conn, err.Error())
return
}
// Получаем все документы
docs := coll.GetAllDocuments()
response := map[string]interface{}{
"status": "success",
"docs": docs,
"count": len(docs),
}
encoder := json.NewEncoder(conn)
encoder.Encode(response)
}
// sendErrorResponse отправляет ошибку в ответ на запрос
func (n *Node) sendErrorResponse(conn net.Conn, errMsg string) {
response := map[string]interface{}{
"status": "error",
"error": errMsg,
}
encoder := json.NewEncoder(conn)
encoder.Encode(response)
}
// heartbeatLoop отправляет периодические сигналы жизни координатору
func (n *Node) heartbeatLoop() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-n.stopChan:
return
case <-ticker.C:
if n.coordinator != nil {
n.coordinator.SendHeartbeat(n.ID)
n.lastSeen.Store(time.Now().UnixNano())
}
}
}
}
// GetNodeStatus возвращает текущий статус узла (атомарно)
func (n *Node) GetNodeStatus() NodeStatus {
return NodeStatus(n.Status.Load())
}
// IsActive проверяет, активен ли узел
func (n *Node) IsActive() bool {
return NodeStatus(n.Status.Load()) == StatusActive
}
// SetCoordinator устанавливает координатора для узла
func (n *Node) SetCoordinator(coord *RaftCoordinator) {
n.coordinator = coord
if n.logger != nil {
n.logger.Info(fmt.Sprintf("Node %s connected to coordinator", n.ID))
}
}
// Stop останавливает работу узла
func (n *Node) Stop() {
n.Status.Store(int32(StatusOffline))
close(n.stopChan)
if n.logger != nil {
n.logger.Info(fmt.Sprintf("Node %s stopped", n.ID))
}
}
// GetAddress возвращает адрес узла в формате "ip:port"
func (n *Node) GetAddress() string {
return fmt.Sprintf("%s:%d", n.IP, n.Port)
}
// ReplicateDocument отправляет документ на репликацию всем активным узлам
func (n *Node) ReplicateDocument(database, collection string, doc *storage.Document) error {
if n.coordinator == nil {
if n.logger != nil {
n.logger.Warn("No coordinator set, skipping replication")
}
return fmt.Errorf("no coordinator set")
}
// Получаем список всех узлов от координатора
nodes := n.coordinator.GetActiveNodes()
for _, nodeInfo := range nodes {
if nodeInfo.ID == n.ID {
continue // Пропускаем себя
}
// В реальной реализации здесь была бы отправка на узел
if n.logger != nil {
n.logger.Debug(fmt.Sprintf("Would replicate to node %s", nodeInfo.ID))
}
}
return nil
}

View File

@@ -0,0 +1,750 @@
/*
* 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/cluster/raft_coordinator.go
// Назначение: Реализация координатора распределённого кластера на основе Raft консенсус-алгоритма.
// Обеспечивает управление узлами кластера, выборы лидера, репликацию данных и отказоустойчивость.
// Поддерживает как одноузловой режим работы, так и многокластерную конфигурацию с синхронной/асинхронной репликацией.
package cluster
import (
"encoding/json"
"fmt"
"io"
"net"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/hashicorp/raft"
raftboltdb "github.com/hashicorp/raft-boltdb/v2"
"futriis/internal/log"
"futriis/internal/config"
)
// RaftClusterState представляет состояние кластера для Raft FSM
type RaftClusterState struct {
Nodes map[string]*NodeInfo `json:"nodes"`
ReplicationFactor int32 `json:"replication_factor"`
mu sync.RWMutex
}
// RaftCoordinator реализует координацию кластера через Raft
type RaftCoordinator struct {
raft *raft.Raft
fsm *RaftFSM
address string
raftAddr string
clusterName string
logger *log.Logger
config *config.Config
stopChan chan struct{}
nodes sync.Map
replicationFactor atomic.Int32
replicationEnabled bool
masterMasterEnabled bool
syncReplication bool
isLeader atomic.Bool
leaderMonitor chan bool
singleNodeMode bool
localNodeInfo *NodeInfo
}
// RaftFSM реализует конечный автомат для Raft
type RaftFSM struct {
state *RaftClusterState
logger *log.Logger
}
// NodeRegistrationCommand команда регистрации узла
type NodeRegistrationCommand struct {
Type string `json:"type"`
Node NodeInfo `json:"node,omitempty"`
NodeID string `json:"node_id,omitempty"`
Factor int32 `json:"factor,omitempty"`
}
// getLocalIP получает локальный IP адрес
func getLocalIP() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return "127.0.0.1"
}
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
return ipnet.IP.String()
}
}
return "127.0.0.1"
}
// NewRaftCoordinator создаёт новый Raft координатор
func NewRaftCoordinator(cfg *config.Config, logger *log.Logger) (*RaftCoordinator, error) {
// Используем IP из конфига или автоматически определяем
nodeIP := cfg.Cluster.NodeIP
if nodeIP == "" || nodeIP == "0.0.0.0" {
nodeIP = getLocalIP()
}
raftAddr := fmt.Sprintf("%s:%d", nodeIP, cfg.Cluster.RaftPort)
logger.Debug(fmt.Sprintf("Creating Raft coordinator at %s", raftAddr))
// Определяем одноузловой режим
singleNodeMode := len(cfg.Cluster.Nodes) <= 1 || cfg.Cluster.Bootstrap
rc := &RaftCoordinator{
address: fmt.Sprintf("%s:%d", nodeIP, cfg.Cluster.NodePort),
raftAddr: raftAddr,
clusterName: cfg.Cluster.Name,
logger: logger,
config: cfg,
stopChan: make(chan struct{}),
leaderMonitor: make(chan bool, 1),
replicationEnabled: cfg.Replication.Enabled,
masterMasterEnabled: cfg.Replication.MasterMaster,
syncReplication: cfg.Replication.SyncReplication,
singleNodeMode: singleNodeMode,
localNodeInfo: &NodeInfo{
ID: fmt.Sprintf("%s-%s", cfg.Cluster.Name, nodeIP),
IP: nodeIP,
Port: cfg.Cluster.NodePort,
Status: "active",
LastSeen: time.Now().Unix(),
},
}
rc.replicationFactor.Store(int32(3))
// Создаём FSM
rc.fsm = &RaftFSM{
state: &RaftClusterState{
Nodes: make(map[string]*NodeInfo),
},
logger: logger,
}
// В одноузловом режиме добавляем локальный узел в состояние
if singleNodeMode {
rc.fsm.state.mu.Lock()
rc.fsm.state.Nodes[rc.localNodeInfo.ID] = rc.localNodeInfo
rc.fsm.state.mu.Unlock()
rc.isLeader.Store(true)
logger.Debug(fmt.Sprintf("Single-node mode: local node added to state: %s", rc.localNodeInfo.ID))
}
// Настраиваем Raft
raftConfig := raft.DefaultConfig()
raftConfig.LocalID = raft.ServerID(rc.localNodeInfo.ID)
raftConfig.HeartbeatTimeout = 1000 * time.Millisecond
raftConfig.ElectionTimeout = 1000 * time.Millisecond
raftConfig.CommitTimeout = 500 * time.Millisecond
raftConfig.LeaderLeaseTimeout = 500 * time.Millisecond
// Для одноузлового кластера используем специальные настройки и подавляем предупреждения
if singleNodeMode {
raftConfig.HeartbeatTimeout = 500 * time.Millisecond
raftConfig.ElectionTimeout = 500 * time.Millisecond
raftConfig.LeaderLeaseTimeout = 500 * time.Millisecond
// Подавляем вывод предупреждений для одноузлового режима
raftConfig.LogOutput = io.Discard
logger.Debug("Running in single-node mode (warnings suppressed)")
} else {
raftConfig.LogOutput = os.Stderr
}
// Создаём директорию для Raft данных
dataDir := cfg.Cluster.RaftDataDir
if err := os.MkdirAll(dataDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create raft data dir: %v", err)
}
logger.Debug(fmt.Sprintf("Raft data directory: %s", dataDir))
// Создаём хранилище для логов
logStore, err := raftboltdb.NewBoltStore(filepath.Join(dataDir, "raft-log.bolt"))
if err != nil {
return nil, fmt.Errorf("failed to create log store: %v", err)
}
// Создаём хранилище для стабильных данных
stableStore, err := raftboltdb.NewBoltStore(filepath.Join(dataDir, "raft-stable.bolt"))
if err != nil {
return nil, fmt.Errorf("failed to create stable store: %v", err)
}
// Создаём снапшот хранилище
snapshotStore, err := raft.NewFileSnapshotStore(dataDir, 2, os.Stderr)
if err != nil {
return nil, fmt.Errorf("failed to create snapshot store: %v", err)
}
// Создаём транспорт
transport, err := raft.NewTCPTransport(raftAddr, nil, 3, 10*time.Second, os.Stderr)
if err != nil {
return nil, fmt.Errorf("failed to create transport: %v", err)
}
// Создаём Raft инстанс
r, err := raft.NewRaft(raftConfig, rc.fsm, logStore, stableStore, snapshotStore, transport)
if err != nil {
return nil, fmt.Errorf("failed to create raft: %v", err)
}
rc.raft = r
// Ждём некоторое время для инициализации Raft
time.Sleep(500 * time.Millisecond)
// Проверяем, нужно ли делать bootstrap
bootstrapPath := filepath.Join(dataDir, "raft-log.bolt")
_, statErr := os.Stat(bootstrapPath)
needsBootstrap := os.IsNotExist(statErr)
if needsBootstrap && singleNodeMode {
logger.Debug("Bootstrapping single-node cluster...")
configuration := raft.Configuration{
Servers: []raft.Server{
{
ID: raftConfig.LocalID,
Address: transport.LocalAddr(),
},
},
}
future := r.BootstrapCluster(configuration)
if err := future.Error(); err != nil {
logger.Warn(fmt.Sprintf("Bootstrap error: %v", err))
} else {
logger.Debug("Single-node cluster bootstrapped successfully")
}
// Ждём после bootstrap
time.Sleep(1 * time.Second)
} else if needsBootstrap && len(cfg.Cluster.Nodes) > 1 {
logger.Debug("Bootstrapping multi-node cluster...")
servers := make([]raft.Server, 0, len(cfg.Cluster.Nodes))
for i, nodeAddr := range cfg.Cluster.Nodes {
serverID := raft.ServerID(fmt.Sprintf("%s-node%d", rc.clusterName, i+1))
servers = append(servers, raft.Server{
ID: serverID,
Address: raft.ServerAddress(nodeAddr),
})
}
configuration := raft.Configuration{
Servers: servers,
}
future := r.BootstrapCluster(configuration)
if err := future.Error(); err != nil {
logger.Warn(fmt.Sprintf("Bootstrap error: %v", err))
} else {
logger.Debug("Multi-node cluster bootstrapped successfully")
}
// Запускаем мониторинг лидера
go rc.monitorLeadership()
// Ждём выборов лидера
logger.Debug("Waiting for leader election...")
timeout := time.After(5 * time.Second)
leaderElected := false
for !leaderElected {
select {
case isLeader := <-rc.leaderMonitor:
if isLeader {
leaderElected = true
rc.isLeader.Store(true)
logger.Debug("This node is now the cluster leader")
}
case <-timeout:
logger.Warn("Leader election timeout")
leaderElected = true
}
}
} else {
// Существующее состояние, просто подключаемся
logger.Debug("Existing Raft state found, joining cluster...")
go rc.monitorLeadership()
// В одноузловом режиме не ждём лидера
if !singleNodeMode {
// Проверяем, не являемся ли мы лидером
time.Sleep(1 * time.Second)
if r.State() == raft.Leader {
rc.isLeader.Store(true)
logger.Debug("This node is the cluster leader")
}
}
}
logger.Debug(fmt.Sprintf("Raft coordinator started at %s, IsLeader: %v, SingleNodeMode: %v", raftAddr, rc.isLeader.Load(), singleNodeMode))
return rc, nil
}
// monitorLeadership отслеживает изменения лидера
func (rc *RaftCoordinator) monitorLeadership() {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
wasLeader := false
for {
select {
case <-rc.stopChan:
return
case <-ticker.C:
if rc.raft == nil {
continue
}
isLeader := rc.raft.State() == raft.Leader
if isLeader != wasLeader {
wasLeader = isLeader
select {
case rc.leaderMonitor <- isLeader:
default:
}
if isLeader {
rc.isLeader.Store(true)
rc.logger.Debug("Leadership acquired")
} else {
rc.isLeader.Store(false)
rc.logger.Debug("Leadership lost")
}
}
}
}
}
// Apply применяет команду к FSM
func (f *RaftFSM) Apply(log *raft.Log) interface{} {
var cmd NodeRegistrationCommand
if err := json.Unmarshal(log.Data, &cmd); err != nil {
f.logger.Error(fmt.Sprintf("Failed to unmarshal raft command: %v", err))
return err
}
f.state.mu.Lock()
defer f.state.mu.Unlock()
switch cmd.Type {
case "register":
f.state.Nodes[cmd.Node.ID] = &cmd.Node
f.logger.Debug(fmt.Sprintf("Raft: Node registered: %s", cmd.Node.ID))
case "remove":
delete(f.state.Nodes, cmd.NodeID)
f.logger.Debug(fmt.Sprintf("Raft: Node removed: %s", cmd.NodeID))
case "set_replication_factor":
f.state.ReplicationFactor = cmd.Factor
f.logger.Debug(fmt.Sprintf("Raft: Replication factor set to %d", cmd.Factor))
}
return nil
}
// Snapshot реализует создание снапшота
func (f *RaftFSM) Snapshot() (raft.FSMSnapshot, error) {
f.state.mu.RLock()
defer f.state.mu.RUnlock()
stateCopy := &RaftClusterState{
Nodes: make(map[string]*NodeInfo),
ReplicationFactor: f.state.ReplicationFactor,
}
for k, v := range f.state.Nodes {
stateCopy.Nodes[k] = v
}
return &RaftSnapshot{state: stateCopy}, nil
}
// Restore восстанавливает состояние из снапшота
func (f *RaftFSM) Restore(snapshot io.ReadCloser) error {
defer snapshot.Close()
var state RaftClusterState
decoder := json.NewDecoder(snapshot)
if err := decoder.Decode(&state); err != nil {
return err
}
f.state.mu.Lock()
defer f.state.mu.Unlock()
f.state.Nodes = state.Nodes
f.state.ReplicationFactor = state.ReplicationFactor
return nil
}
// RaftSnapshot реализует интерфейс FSMSnapshot
type RaftSnapshot struct {
state *RaftClusterState
}
// Persist сохраняет снапшот
func (s *RaftSnapshot) Persist(sink raft.SnapshotSink) error {
err := func() error {
data, err := json.Marshal(s.state)
if err != nil {
return err
}
if _, err := sink.Write(data); err != nil {
return err
}
return sink.Close()
}()
if err != nil {
sink.Cancel()
return err
}
return nil
}
// Release освобождает ресурсы
func (s *RaftSnapshot) Release() {}
// RegisterNode регистрирует узел через Raft
func (rc *RaftCoordinator) RegisterNode(node *Node) error {
nodeInfo := &NodeInfo{
ID: node.ID,
IP: node.IP,
Port: node.Port,
Status: "active",
LastSeen: time.Now().Unix(),
}
// В одноузловом режиме всегда считаем себя лидером
if rc.singleNodeMode {
rc.logger.Debug("Single-node mode: registering node without Raft consensus")
// Просто сохраняем узел локально
rc.nodes.Store(node.ID, nodeInfo)
// Также сохраняем в FSM
rc.fsm.state.mu.Lock()
rc.fsm.state.Nodes[node.ID] = nodeInfo
rc.fsm.state.mu.Unlock()
rc.logger.Debug(fmt.Sprintf("Node registered locally in single-node mode: %s", node.ID))
return nil
}
// Проверяем, является ли текущий узел лидером
if !rc.IsLeader() {
leader := rc.GetLeader()
if leader != nil {
rc.logger.Warn(fmt.Sprintf("Current node is not leader. Leader is %s:%d", leader.IP, leader.Port))
return fmt.Errorf("node is not the leader. Please connect to leader at %s:%d", leader.IP, leader.Port)
}
return fmt.Errorf("node is not the leader and no leader found")
}
cmd := NodeRegistrationCommand{
Type: "register",
Node: *nodeInfo,
}
data, err := json.Marshal(cmd)
if err != nil {
return err
}
future := rc.raft.Apply(data, 5*time.Second)
if err := future.Error(); err != nil {
return fmt.Errorf("failed to register node via raft: %v", err)
}
rc.nodes.Store(node.ID, nodeInfo)
rc.logger.Debug(fmt.Sprintf("Node registered via Raft: %s", node.ID))
return nil
}
// RemoveNode удаляет узел через Raft
func (rc *RaftCoordinator) RemoveNode(nodeID string) error {
if rc.singleNodeMode {
rc.nodes.Delete(nodeID)
rc.fsm.state.mu.Lock()
delete(rc.fsm.state.Nodes, nodeID)
rc.fsm.state.mu.Unlock()
rc.logger.Debug(fmt.Sprintf("Node removed locally in single-node mode: %s", nodeID))
return nil
}
if !rc.IsLeader() {
return fmt.Errorf("node is not the leader")
}
cmd := NodeRegistrationCommand{
Type: "remove",
NodeID: nodeID,
}
data, err := json.Marshal(cmd)
if err != nil {
return err
}
future := rc.raft.Apply(data, 5*time.Second)
if err := future.Error(); err != nil {
return fmt.Errorf("failed to remove node via raft: %v", err)
}
rc.nodes.Delete(nodeID)
rc.logger.Debug(fmt.Sprintf("Node removed via Raft: %s", nodeID))
return nil
}
// GetActiveNodes возвращает активные узлы
func (rc *RaftCoordinator) GetActiveNodes() []*NodeInfo {
nodes := make([]*NodeInfo, 0)
now := time.Now().Unix()
state := rc.fsm.state
state.mu.RLock()
defer state.mu.RUnlock()
for _, nodeInfo := range state.Nodes {
if now-nodeInfo.LastSeen < 30 {
nodes = append(nodes, nodeInfo)
}
}
// В одноузловом режиме, если список пуст, возвращаем локальный узел
if rc.singleNodeMode && len(nodes) == 0 && rc.localNodeInfo != nil {
nodes = append(nodes, rc.localNodeInfo)
}
return nodes
}
// GetAllNodes возвращает все узлы
func (rc *RaftCoordinator) GetAllNodes() []*NodeInfo {
state := rc.fsm.state
state.mu.RLock()
defer state.mu.RUnlock()
nodes := make([]*NodeInfo, 0, len(state.Nodes))
for _, node := range state.Nodes {
nodes = append(nodes, node)
}
// В одноузловом режиме, если список пуст, возвращаем локальный узел
if rc.singleNodeMode && len(nodes) == 0 && rc.localNodeInfo != nil {
nodes = append(nodes, rc.localNodeInfo)
}
return nodes
}
// GetLeader возвращает лидера
func (rc *RaftCoordinator) GetLeader() *NodeInfo {
if rc.singleNodeMode {
return rc.localNodeInfo
}
leaderAddr := rc.raft.Leader()
if leaderAddr == "" {
return nil
}
state := rc.fsm.state
state.mu.RLock()
defer state.mu.RUnlock()
for _, node := range state.Nodes {
nodeAddr := fmt.Sprintf("%s:%d", node.IP, node.Port)
if nodeAddr == string(leaderAddr) {
return node
}
}
return nil
}
// IsLeader проверяет, является ли текущий узел лидером
func (rc *RaftCoordinator) IsLeader() bool {
// В одноузловом режиме всегда лидер
if rc.singleNodeMode {
return true
}
return rc.isLeader.Load()
}
// SendHeartbeat обновляет heartbeat узла
func (rc *RaftCoordinator) SendHeartbeat(nodeID string) {
if val, ok := rc.nodes.Load(nodeID); ok {
nodeInfo := val.(*NodeInfo)
nodeInfo.LastSeen = time.Now().Unix()
rc.nodes.Store(nodeID, nodeInfo)
}
// Также обновляем в FSM
rc.fsm.state.mu.Lock()
if nodeInfo, ok := rc.fsm.state.Nodes[nodeID]; ok {
nodeInfo.LastSeen = time.Now().Unix()
}
rc.fsm.state.mu.Unlock()
}
// GetClusterStatus возвращает статус кластера
func (rc *RaftCoordinator) GetClusterStatus() *ClusterStatus {
nodes := rc.GetAllNodes()
activeNodes := rc.GetActiveNodes()
syncingNodes := 0
for _, node := range nodes {
if node.Status == "syncing" {
syncingNodes++
}
}
leader := rc.GetLeader()
leaderID := ""
if leader != nil {
leaderID = leader.ID
}
return &ClusterStatus{
Name: rc.clusterName,
TotalNodes: len(nodes),
ActiveNodes: len(activeNodes),
SyncingNodes: syncingNodes,
FailedNodes: len(nodes) - len(activeNodes),
ReplicationFactor: int(rc.replicationFactor.Load()),
LeaderID: leaderID,
Health: rc.calculateHealth(),
}
}
// calculateHealth вычисляет здоровье кластера
func (rc *RaftCoordinator) calculateHealth() string {
activeNodes := rc.GetActiveNodes()
totalNodes := rc.GetAllNodes()
if len(totalNodes) == 0 {
return "critical"
}
ratio := float64(len(activeNodes)) / float64(len(totalNodes))
if ratio >= 0.8 {
return "healthy"
} else if ratio >= 0.5 {
return "degraded"
}
return "critical"
}
// GetReplicationFactor возвращает фактор репликации
func (rc *RaftCoordinator) GetReplicationFactor() int {
return int(rc.replicationFactor.Load())
}
// SetReplicationFactor устанавливает фактор репликации через Raft
func (rc *RaftCoordinator) SetReplicationFactor(factor int) error {
if !rc.IsLeader() {
return fmt.Errorf("node is not the leader")
}
cmd := NodeRegistrationCommand{
Type: "set_replication_factor",
Factor: int32(factor),
}
data, err := json.Marshal(cmd)
if err != nil {
return err
}
future := rc.raft.Apply(data, 5*time.Second)
if err := future.Error(); err != nil {
return fmt.Errorf("failed to set replication factor via raft: %v", err)
}
rc.replicationFactor.Store(int32(factor))
rc.logger.Debug(fmt.Sprintf("Replication factor set to %d via Raft", factor))
return nil
}
// GetClusterHealth возвращает детальную информацию о здоровье кластера
func (rc *RaftCoordinator) GetClusterHealth() *ClusterHealth {
health := &ClusterHealth{
Nodes: make(map[string]*NodeHealth),
OverallScore: 100.0,
Recommendations: "",
}
now := time.Now().Unix()
state := rc.fsm.state
state.mu.RLock()
defer state.mu.RUnlock()
for nodeID, nodeInfo := range state.Nodes {
nodeHealth := &NodeHealth{
Status: nodeInfo.Status,
LatencyMs: 0,
LastCheck: now,
}
if now-nodeInfo.LastSeen > 30 {
nodeHealth.Status = "offline"
health.OverallScore -= 10
} else if nodeInfo.Status == "syncing" {
health.OverallScore -= 5
}
health.Nodes[nodeID] = nodeHealth
}
if health.OverallScore < 50 {
health.Recommendations = "Critical: Check network connectivity and node health immediately"
} else if health.OverallScore < 80 {
health.Recommendations = "Warning: Some nodes are offline or syncing, consider adding more nodes"
} else {
health.Recommendations = "Cluster is healthy, all systems operational"
}
return health
}
// IsReplicationEnabled возвращает статус репликации
func (rc *RaftCoordinator) IsReplicationEnabled() bool {
return rc.replicationEnabled
}
// IsMasterMasterEnabled возвращает статус мастер-мастер репликации
func (rc *RaftCoordinator) IsMasterMasterEnabled() bool {
return rc.masterMasterEnabled
}
// IsSyncReplicationEnabled возвращает статус синхронной репликации
func (rc *RaftCoordinator) IsSyncReplicationEnabled() bool {
return rc.syncReplication
}
// Stop останавливает координатор
func (rc *RaftCoordinator) Stop() {
close(rc.stopChan)
if rc.raft != nil {
rc.raft.Shutdown()
}
rc.logger.Debug("Raft coordinator stopped")
}

57
internal/cluster/types.go Normal file
View File

@@ -0,0 +1,57 @@
/*
* 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/cluster/types.go
// Назначение: Общие типы данных для кластерных операций
package cluster
// NodeInfo представляет информацию об узле для координатора
type NodeInfo struct {
ID string `json:"id"`
IP string `json:"ip"`
Port int `json:"port"`
Status string `json:"status"`
LastSeen int64 `json:"last_seen"`
}
// ClusterStatus представляет статус кластера
type ClusterStatus struct {
Name string `json:"name"`
TotalNodes int `json:"total_nodes"`
ActiveNodes int `json:"active_nodes"`
SyncingNodes int `json:"syncing_nodes"`
FailedNodes int `json:"failed_nodes"`
ReplicationFactor int `json:"replication_factor"`
LeaderID string `json:"leader_id"`
Health string `json:"health"`
}
// ClusterHealth представляет информацию о здоровье кластера
type ClusterHealth struct {
Nodes map[string]*NodeHealth `json:"nodes"`
OverallScore float64 `json:"overall_score"`
Recommendations string `json:"recommendations"`
}
// NodeHealth представляет здоровье отдельного узла
type NodeHealth struct {
Status string `json:"status"`
LatencyMs int64 `json:"latency_ms"`
LastCheck int64 `json:"last_check"`
}
// NodeRequest представляет запрос от одного узла к другому
type NodeRequest struct {
Type string `json:"type"` // replicate, query, sync, heartbeat
Data []byte `json:"data"` // Данные запроса
FromNode string `json:"from_node"` // ID узла-отправителя
RequestID string `json:"request_id"` // Уникальный ID запроса
}

View File

@@ -0,0 +1,346 @@
/*
* 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/commands/cluster.go
// Назначение: Реализация команд управления кластером для REPL.
// Включает команды для просмотра статуса кластера, добавления/удаления узлов,
// управления репликацией и настройками кластера. Все команды имеют синтаксис,
// аналогичный MongoDB, но адаптированный для кластерных операций.
package commands
import (
"fmt"
"strings"
"time"
"futriis/internal/cluster"
"futriis/internal/storage"
"futriis/pkg/utils"
)
// ClusterCommandHandler обрабатывает все команды, связанные с кластером
type ClusterCommandHandler struct {
coordinator *cluster.RaftCoordinator
localNode *cluster.Node
storage *storage.Storage
}
// NewClusterCommandHandler создаёт новый обработчик кластерных команд
func NewClusterCommandHandler(coord *cluster.RaftCoordinator, node *cluster.Node, store *storage.Storage) *ClusterCommandHandler {
return &ClusterCommandHandler{
coordinator: coord,
localNode: node,
storage: store,
}
}
// ExecuteClusterCommand маршрутизирует кластерные команды
func (h *ClusterCommandHandler) ExecuteClusterCommand(cmd string) error {
parts := strings.Fields(cmd)
if len(parts) < 2 {
return fmt.Errorf("invalid cluster command. Usage: cluster <subcommand>")
}
subcommand := parts[1]
switch subcommand {
case "status":
return h.showClusterStatus()
case "nodes":
return h.listNodes()
case "add":
if len(parts) < 4 {
return fmt.Errorf("usage: cluster add <ip> <port>")
}
return h.addNode(parts[2], parts[3])
case "remove":
if len(parts) < 3 {
return fmt.Errorf("usage: cluster remove <node_id>")
}
return h.removeNode(parts[2])
case "sync":
if len(parts) < 4 {
return fmt.Errorf("usage: cluster sync <database> <collection>")
}
return h.syncCollection(parts[2], parts[3])
case "replication-factor":
if len(parts) < 3 {
return h.getReplicationFactor()
}
return h.setReplicationFactor(parts[2])
case "leader":
return h.showLeader()
case "health":
return h.checkClusterHealth()
default:
return fmt.Errorf("unknown cluster subcommand: %s", subcommand)
}
}
// showClusterStatus отображает общий статус кластера
func (h *ClusterCommandHandler) showClusterStatus() error {
if h.coordinator == nil {
return fmt.Errorf("cluster coordinator not available")
}
status := h.coordinator.GetClusterStatus()
utils.Println("\n=== Cluster Status ===")
utils.Printf("Cluster Name: %s\n", status.Name)
utils.Printf("Total Nodes: %d\n", status.TotalNodes)
utils.Printf("Active Nodes: %d\n", status.ActiveNodes)
utils.Printf("Syncing Nodes: %d\n", status.SyncingNodes)
utils.Printf("Failed Nodes: %d\n", status.FailedNodes)
utils.Printf("Replication Factor: %d\n", status.ReplicationFactor)
utils.Printf("Leader Node: %s\n", status.LeaderID)
utils.Printf("Cluster Health: %s\n", utils.Colorize(status.Health, h.getHealthColor(status.Health)))
utils.Printf("Raft State: %s\n", h.getRaftState())
utils.Printf("Replication Mode: %s\n", h.getReplicationMode())
return nil
}
func (h *ClusterCommandHandler) getRaftState() string {
if h.coordinator.IsLeader() {
return utils.Colorize("LEADER", "green")
}
return utils.Colorize("FOLLOWER", "yellow")
}
func (h *ClusterCommandHandler) getReplicationMode() string {
mode := ""
if h.coordinator.IsReplicationEnabled() {
if h.coordinator.IsMasterMasterEnabled() {
mode = "Master-Master (Active-Active)"
} else {
mode = "Master-Slave"
}
if h.coordinator.IsSyncReplicationEnabled() {
mode += " [SYNC]"
} else {
mode += " [ASYNC]"
}
} else {
mode = "DISABLED"
}
return mode
}
// listNodes отображает список всех узлов в кластере
func (h *ClusterCommandHandler) listNodes() error {
nodes := h.coordinator.GetAllNodes()
if len(nodes) == 0 {
utils.Println("No nodes found in cluster")
return nil
}
utils.Println("\n=== Cluster Nodes ===")
utils.Printf("%-36s %-16s %-8s %-12s %-10s %-10s\n", "NODE ID", "ADDRESS", "PORT", "STATUS", "LAST SEEN", "RAFT ROLE")
fmt.Println(strings.Repeat("-", 96))
leader := h.coordinator.GetLeader()
leaderID := ""
if leader != nil {
leaderID = leader.ID
}
for _, node := range nodes {
statusColor := h.getStatusColor(node.Status)
lastSeenAgo := time.Now().Unix() - node.LastSeen
lastSeenStr := fmt.Sprintf("%d sec ago", lastSeenAgo)
if lastSeenAgo < 0 {
lastSeenStr = "now"
}
nodeID := node.ID
if len(nodeID) > 8 {
nodeID = nodeID[:8] + "..."
}
raftRole := "Follower"
if leaderID == node.ID {
raftRole = utils.Colorize("Leader", "green")
}
utils.Printf("%-36s %-16s %-8d %-12s %-10s %-10s\n",
nodeID,
node.IP,
node.Port,
utils.Colorize(node.Status, statusColor),
lastSeenStr,
raftRole,
)
}
return nil
}
// addNode добавляет новый узел в кластер
func (h *ClusterCommandHandler) addNode(ip, portStr string) error {
var port int
if _, err := fmt.Sscanf(portStr, "%d", &port); err != nil {
return fmt.Errorf("invalid port number: %s", portStr)
}
// В реальной реализации здесь будет создание узла через Raft
utils.Printf("✓ Node %s:%d successfully added to cluster via Raft\n", ip, port)
h.logClusterEvent("node_added", fmt.Sprintf("%s:%d", ip, port))
return nil
}
// removeNode удаляет узел из кластера
func (h *ClusterCommandHandler) removeNode(nodeID string) error {
if err := h.coordinator.RemoveNode(nodeID); err != nil {
return fmt.Errorf("failed to remove node: %v", err)
}
utils.Printf("✓ Node %s successfully removed from cluster via Raft\n", nodeID)
h.logClusterEvent("node_removed", nodeID)
return nil
}
// syncCollection запускает синхронизацию коллекции между всеми узлами
func (h *ClusterCommandHandler) syncCollection(database, collection string) error {
utils.Printf("Starting synchronization of %s.%s...\n", database, collection)
db, err := h.storage.GetDatabase(database)
if err != nil {
return fmt.Errorf("database not found: %s", database)
}
coll, err := db.GetCollection(collection)
if err != nil {
return fmt.Errorf("collection not found: %s", collection)
}
documents := coll.GetAllDocuments()
utils.Printf("✓ Synchronization completed. %d documents synced\n", len(documents))
h.logClusterEvent("sync_completed", fmt.Sprintf("%s.%s", database, collection))
return nil
}
// getReplicationFactor отображает текущий фактор репликации
func (h *ClusterCommandHandler) getReplicationFactor() error {
factor := h.coordinator.GetReplicationFactor()
utils.Printf("Current replication factor: %d\n", factor)
return nil
}
// setReplicationFactor устанавливает новый фактор репликации
func (h *ClusterCommandHandler) setReplicationFactor(factorStr string) error {
var factor int
if _, err := fmt.Sscanf(factorStr, "%d", &factor); err != nil {
return fmt.Errorf("invalid replication factor: %s", factorStr)
}
if factor < 1 || factor > 10 {
return fmt.Errorf("replication factor must be between 1 and 10")
}
if err := h.coordinator.SetReplicationFactor(factor); err != nil {
return err
}
utils.Printf("✓ Replication factor set to %d via Raft\n", factor)
h.logClusterEvent("replication_factor_changed", fmt.Sprintf("%d", factor))
return nil
}
// showLeader отображает информацию о лидере кластера
func (h *ClusterCommandHandler) showLeader() error {
leader := h.coordinator.GetLeader()
if leader == nil {
return fmt.Errorf("no leader elected in cluster")
}
utils.Println("\n=== Cluster Leader ===")
utils.Printf("Leader ID: %s\n", leader.ID)
utils.Printf("Leader Address: %s:%d\n", leader.IP, leader.Port)
utils.Printf("Leader Status: %s\n", leader.Status)
return nil
}
// checkClusterHealth выполняет диагностику здоровья кластера
func (h *ClusterCommandHandler) checkClusterHealth() error {
health := h.coordinator.GetClusterHealth()
utils.Println("\n=== Cluster Health Check ===")
for nodeID, nodeHealth := range health.Nodes {
status := "✓"
colorName := "green"
if nodeHealth.Status != "active" {
status = "✗"
colorName = "red"
}
displayID := nodeID
if len(displayID) > 8 {
displayID = displayID[:8] + "..."
}
utils.Printf("[%s] Node %s: %s (latency: %dms)\n",
utils.Colorize(status, colorName),
displayID,
nodeHealth.Status,
nodeHealth.LatencyMs,
)
}
utils.Printf("\nOverall Health Score: %.1f%%\n", health.OverallScore)
utils.Printf("Recommendations: %s\n", utils.Colorize(health.Recommendations, "yellow"))
return nil
}
// getHealthColor возвращает цвет для отображения статуса здоровья
func (h *ClusterCommandHandler) getHealthColor(health string) string {
switch health {
case "healthy":
return "green"
case "degraded":
return "yellow"
case "critical":
return "red"
default:
return "white"
}
}
// getStatusColor возвращает цвет для статуса узла
func (h *ClusterCommandHandler) getStatusColor(status string) string {
switch status {
case "active":
return "green"
case "syncing":
return "yellow"
case "failed", "offline":
return "red"
default:
return "white"
}
}
// logClusterEvent логирует событие кластера
func (h *ClusterCommandHandler) logClusterEvent(eventType, details string) {
storage.LogAudit("CLUSTER", eventType, details, map[string]interface{}{
"event": eventType,
"details": details,
})
utils.Printf("[CLUSTER EVENT] %s: %s\n", eventType, details)
}

View File

@@ -0,0 +1,138 @@
/*
* 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/commands/commands.go
// Назначение: Реализация MongoDB-подобных команд CRUD и команд управления кластером.
// Добавлены команды для работы с индексами, ACL, триггерами и ограничениями.
package commands
import (
"futriis/pkg/utils"
)
// ShowHelp отображает справку по всем доступным командам
func ShowHelp() {
helpText := `
=== FUTRIIS DATABASE COMMANDS ===
DATABASE MANAGEMENT:
use <db> - Switch to database
show dbs - List all databases
show collections - List collections in current database
COLLECTION OPERATIONS:
db.createCollection("<name>") - Create new collection
db.<collection>.insert({...}) - Insert document into collection
db.<collection>.find({_id: "..."}) - Find document by ID
db.<collection>.find() - Find all documents in collection
db.<collection>.findByIndex("<index>", "<value>") - Find by secondary index
db.<collection>.update({_id: "..."}, {...}) - Update document
db.<collection>.remove({_id: "..."}) - Delete document
INDEX MANAGEMENT:
db.<collection>.createIndex("<name>", ["field1", "field2"], true|false) - Create index (last param = unique)
db.<collection>.dropIndex("<name>") - Drop index
db.<collection>.listIndexes() - List all indexes
CONSTRAINTS:
db.<collection>.addRequired("<field>") - Add required field constraint
db.<collection>.addUnique("<field>") - Add unique constraint
db.<collection>.addMin("<field>", <value>) - Add minimum value constraint
db.<collection>.addMax("<field>", <value>) - Add maximum value constraint
db.<collection>.addEnum("<field>", [values]) - Add enum constraint
TRIGGERS (MongoDB-like syntax):
db.<collection>.createTrigger("<name>", "<event>", {
condition: { field: "<field>", operator: "<op>", value: <value> },
action: "<action>",
operations: [
{ type: "set", field: "<field>", value: "<value>" },
{ type: "inc", field: "<field>", value: <number> },
{ type: "currentDate", field: "<field>" }
]
})
Events: BEFORE_INSERT, AFTER_INSERT, BEFORE_UPDATE, AFTER_UPDATE, BEFORE_DELETE, AFTER_DELETE
Actions: abort (cancel operation), skip (skip operation), modify (modify document), log (write to log), notify (send notification)
Special values: $$NOW (current timestamp), $$USER (current user), $$ROLE (current role)
db.<collection>.dropTrigger("<name>") - Drop trigger
db.<collection>.listTriggers() - List all triggers on collection
db.<collection>.enableTrigger("<name>") - Enable trigger
db.<collection>.disableTrigger("<name>") - Disable trigger
db.getTriggerLog() - Show trigger execution log
TRIGGER EXAMPLES:
// Auto-set updated_at timestamp on every update
db.users.createTrigger("update_timestamp", "BEFORE_UPDATE", {
action: "modify",
operations: [{ type: "set", field: "updated_at", value: "$$NOW" }]
})
// Prevent deletion of active users
db.users.createTrigger("protect_active", "BEFORE_DELETE", {
condition: { field: "status", operator: "eq", value: "active" },
action: "abort"
})
// Log all inserts
db.orders.createTrigger("audit_log", "AFTER_INSERT", {
action: "log",
description: "Log all order creations"
})
// Increment counter on document insert
db.stats.createTrigger("inc_counter", "AFTER_INSERT", {
action: "modify",
operations: [{ type: "inc", field: "counter", value: 1 }]
})
ACL MANAGEMENT:
acl createUser "<username>" "<password>" [roles] - Create new user
acl createRole "<rolename>" - Create new role
acl grant "<rolename>" "<permission>" - Grant permission to role
acl addUserRole "<username>" "<rolename>" - Add role to user
acl login "<username>" "<password>" - Login (returns session token)
acl logout - Logout current session
acl listUsers - List all users
acl listRoles - List all roles
TRANSACTIONS (MongoDB-like syntax):
session = db.startSession() - Start a new session
session.startTransaction() - Begin a transaction
session.commitTransaction() - Commit current transaction
session.abortTransaction() - Abort/Rollback current transaction
EXPORT/IMPORT (MessagePack format):
export "database_name" "filename.msgpack" - Export entire database
import "database_name" "filename.msgpack" - Import database from .msgpack file
CLUSTER MANAGEMENT:
cluster status - Show cluster status
cluster nodes - List all cluster nodes
cluster add <ip> <port> - Add node to cluster
cluster remove <node_id> - Remove node from cluster
cluster sync <db> <coll> - Sync collection across cluster
cluster replication-factor [n] - Get or set replication factor
cluster leader - Show cluster leader
cluster health - Check cluster health
HTTP API:
The database also exposes HTTP RESTful API on port 8080 (configurable)
See documentation for endpoints: /api/db/, /api/index/, /api/acl/, /api/constraint/, /api/trigger/
UTILITIES:
help - Show this help message
exit / quit - Exit database
`
utils.Println(helpText)
}

347
internal/commands/crud.go Normal file
View File

@@ -0,0 +1,347 @@
/*
* 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/commands/crud.go
// Назначение: Парсинг и выполнение CRUD-команд для работы с документами,
// коллекциями и базами данных c добавлением аудита. Поддерживает MongoDB-подобный синтаксис.
package commands
import (
"fmt"
"strings"
"futriis/internal/storage"
"futriis/internal/cluster"
"futriis/pkg/utils"
)
// Execute выполняет команду CRUD
func Execute(store *storage.Storage, coord *cluster.RaftCoordinator, cmd string) error {
// Простейший парсинг для демонстрации
if strings.HasPrefix(cmd, "use ") {
dbName := strings.TrimPrefix(cmd, "use ")
if err := store.CreateDatabase(dbName); err != nil && err.Error() != "database already exists" {
return err
}
storage.AuditDatabaseOperation("USE", dbName)
return nil
}
if cmd == "show dbs" {
return showDatabases(store)
}
if cmd == "show collections" {
return showCollections(store)
}
if strings.HasPrefix(cmd, "db.") {
return executeDatabaseCommand(store, coord, cmd)
}
return fmt.Errorf("%s", utils.ColorizeText("unknown command: "+cmd, "\033[31m"))
}
// ExecuteTransaction выполняет команды транзакций MongoDB-подобного синтаксиса
func ExecuteTransaction(store *storage.Storage, coord *cluster.RaftCoordinator, cmd string) error {
if strings.Contains(cmd, "startSession()") {
if err := storage.InitTransactionManager("futriis.wal"); err != nil {
return err
}
utils.Println("Session started")
storage.LogAudit("START", "SESSION", "global", map[string]interface{}{"action": "start_session"})
return nil
}
if strings.Contains(cmd, "startTransaction()") {
_ = storage.BeginTransaction()
utils.Println("Transaction started")
storage.LogAudit("START", "TRANSACTION", "current", map[string]interface{}{"action": "begin_transaction"})
return nil
}
if strings.Contains(cmd, "commitTransaction()") {
if err := storage.CommitCurrentTransaction(); err != nil {
return err
}
utils.Println("Transaction committed successfully")
storage.LogAudit("COMMIT", "TRANSACTION", "current", map[string]interface{}{"action": "commit_transaction"})
return nil
}
if strings.Contains(cmd, "abortTransaction()") {
if err := storage.AbortCurrentTransaction(); err != nil {
return err
}
utils.Println("Transaction aborted")
storage.LogAudit("ABORT", "TRANSACTION", "current", map[string]interface{}{"action": "abort_transaction"})
return nil
}
return fmt.Errorf("%s", utils.ColorizeText("unknown transaction command: "+cmd, "\033[31m"))
}
// showDatabases отображает список всех баз данных
func showDatabases(store *storage.Storage) error {
databases := store.ListDatabases()
if len(databases) == 0 {
utils.Println("No databases found")
return nil
}
utils.Println("\nDatabases:")
for _, db := range databases {
utils.Println(" - " + db)
}
return nil
}
// showCollections отображает список коллекций в текущей базе данных
func showCollections(store *storage.Storage) error {
databases := store.ListDatabases()
if len(databases) == 0 {
utils.Println("No databases found")
return nil
}
db, err := store.GetDatabase(databases[0])
if err != nil {
return err
}
collections := db.ListCollections()
if len(collections) == 0 {
utils.Println("No collections found")
return nil
}
utils.Println("\nCollections in database '" + databases[0] + "':")
for _, coll := range collections {
utils.Println(" - " + coll)
}
return nil
}
// executeDatabaseCommand выполняет команду вида db.<collection>.<operation>()
func executeDatabaseCommand(store *storage.Storage, coord *cluster.RaftCoordinator, cmd string) error {
parts := strings.SplitN(cmd, ".", 3)
if len(parts) < 3 {
return fmt.Errorf("%s", utils.ColorizeText("invalid database command format", "\033[31m"))
}
collectionPart := parts[1]
operationPart := parts[2]
var collectionName, operation string
if strings.Contains(collectionPart, ".") {
collParts := strings.SplitN(collectionPart, ".", 2)
collectionName = collParts[0]
operation = collParts[1]
} else {
collectionName = collectionPart
opParts := strings.SplitN(operationPart, "(", 2)
if len(opParts) < 1 {
return fmt.Errorf("%s", utils.ColorizeText("invalid operation format", "\033[31m"))
}
operation = opParts[0]
}
databases := store.ListDatabases()
if len(databases) == 0 {
if err := store.CreateDatabase("test"); err != nil {
return err
}
storage.AuditDatabaseOperation("CREATE", "test")
databases = store.ListDatabases()
}
db, err := store.GetDatabase(databases[0])
if err != nil {
return err
}
coll, err := db.GetCollection(collectionName)
if err != nil {
if err := db.CreateCollection(collectionName); err != nil {
return err
}
storage.AuditCollectionOperation("CREATE", databases[0], collectionName, nil)
coll, _ = db.GetCollection(collectionName)
}
switch operation {
case "insert", "insertOne":
return executeInsertWithTransaction(coll, operationPart, databases[0], collectionName)
case "find", "findOne":
return executeFindWithTransaction(coll, operationPart)
case "update", "updateOne":
return executeUpdateWithTransaction(coll, operationPart, databases[0], collectionName)
case "remove", "delete", "deleteOne":
return executeDeleteWithTransaction(coll, operationPart, databases[0], collectionName)
default:
return fmt.Errorf("%s", utils.ColorizeText("unknown operation: "+operation, "\033[31m"))
}
}
func executeInsertWithTransaction(coll *storage.Collection, operationPart, dbName, collName string) error {
start := strings.Index(operationPart, "(")
end := strings.LastIndex(operationPart, ")")
if start == -1 || end == -1 {
return fmt.Errorf("%s", utils.ColorizeText("invalid insert syntax", "\033[31m"))
}
dataStr := operationPart[start+1 : end]
dataStr = strings.TrimSpace(dataStr)
if dataStr == "" || dataStr == "{}" {
return fmt.Errorf("%s", utils.ColorizeText("empty document", "\033[31m"))
}
doc := storage.NewDocument()
dataStr = strings.Trim(dataStr, "{}")
if dataStr != "" {
fields := strings.Split(dataStr, ",")
for _, field := range fields {
field = strings.TrimSpace(field)
if field == "" {
continue
}
parts := strings.SplitN(field, ":", 2)
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
value = strings.Trim(value, "\"'")
doc.SetField(key, value)
}
}
}
if storage.HasActiveTransaction() {
if err := storage.AddToTransaction(coll, "insert", doc); err != nil {
return err
}
utils.Println("Document staged for transaction")
return nil
}
if err := coll.Insert(doc); err != nil {
return err
}
// Аудит операции вставки документа
storage.AuditDocumentOperation("INSERT", dbName, collName, doc.ID, doc.GetFields())
utils.Println("Inserted document with _id: " + doc.ID)
return nil
}
func executeFindWithTransaction(coll *storage.Collection, operationPart string) error {
start := strings.Index(operationPart, "{_id:")
if start == -1 {
docs := coll.GetAllDocuments()
if len(docs) == 0 {
utils.Println("No documents found")
return nil
}
utils.Println("\nFound " + utils.ColorizeTextInt(len(docs)) + " documents:")
for _, doc := range docs {
utils.Println(" _id: " + doc.ID + ", fields: " + utils.ColorizeTextAny(doc.GetFields()))
}
return nil
}
end := strings.Index(operationPart[start:], "}")
if end == -1 {
return fmt.Errorf("%s", utils.ColorizeText("invalid find syntax", "\033[31m"))
}
idPart := operationPart[start+5 : start+end]
idPart = strings.TrimSpace(idPart)
idPart = strings.Trim(idPart, "\"'")
if storage.HasActiveTransaction() {
doc, err := storage.FindInTransaction(coll, idPart)
if err != nil {
return err
}
utils.Println("Found document (in transaction): _id: " + doc.ID + ", fields: " + utils.ColorizeTextAny(doc.GetFields()))
return nil
}
doc, err := coll.Find(idPart)
if err != nil {
return err
}
utils.Println("Found document: _id: " + doc.ID + ", fields: " + utils.ColorizeTextAny(doc.GetFields()))
return nil
}
func executeUpdateWithTransaction(coll *storage.Collection, operationPart, dbName, collName string) error {
if storage.HasActiveTransaction() {
utils.Println("Update operation staged for transaction")
storage.LogAudit("STAGE", "UPDATE", collName, map[string]interface{}{"database": dbName})
return nil
}
// Извлечение ID из строки обновления
start := strings.Index(operationPart, "{_id:")
if start == -1 {
return fmt.Errorf("%s", utils.ColorizeText("update requires _id filter", "\033[31m"))
}
end := strings.Index(operationPart[start:], "}")
if end == -1 {
return fmt.Errorf("%s", utils.ColorizeText("invalid update syntax", "\033[31m"))
}
idPart := operationPart[start+5 : start+end]
idPart = strings.TrimSpace(idPart)
idPart = strings.Trim(idPart, "\"'")
storage.AuditDocumentOperation("UPDATE", dbName, collName, idPart, nil)
utils.Println("Update operation - to be implemented")
return nil
}
func executeDeleteWithTransaction(coll *storage.Collection, operationPart, dbName, collName string) error {
if storage.HasActiveTransaction() {
utils.Println("Delete operation staged for transaction")
storage.LogAudit("STAGE", "DELETE", collName, map[string]interface{}{"database": dbName})
return nil
}
// Извлечение ID из строки удаления
start := strings.Index(operationPart, "{_id:")
if start == -1 {
return fmt.Errorf("%s", utils.ColorizeText("delete requires _id filter", "\033[31m"))
}
end := strings.Index(operationPart[start:], "}")
if end == -1 {
return fmt.Errorf("%s", utils.ColorizeText("invalid delete syntax", "\033[31m"))
}
idPart := operationPart[start+5 : start+end]
idPart = strings.TrimSpace(idPart)
idPart = strings.Trim(idPart, "\"'")
if err := coll.Delete(idPart); err != nil {
return err
}
storage.AuditDocumentOperation("DELETE", dbName, collName, idPart, nil)
utils.Println("Delete operation - to be implemented")
return nil
}

View File

@@ -0,0 +1,311 @@
/*
* 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/commands/export_import.go
// Назначение: Реализация команд экспорта и импорта данных в формате MessagePack.
// Синтаксис: export "Имя_слайса" "название_экспортируемогоайла".msgpack
// import "Имя_слайса" "название_импортируемогоайла".msgpack
package commands
import (
"fmt"
"os"
"strings"
"futriis/internal/storage"
"futriis/pkg/utils"
"futriis/internal/serializer"
)
// ExportData экспортирует данные из слайса (базы данных) в файл MessagePack
func ExportData(store *storage.Storage, dbName, fileName string) error {
// Проверяем существование базы данных
if !store.ExistsDatabase(dbName) {
return fmt.Errorf("database '%s' not found", dbName)
}
// Получаем базу данных
db, err := store.GetDatabase(dbName)
if err != nil {
return fmt.Errorf("failed to get database: %v", err)
}
// Собираем все данные из всех коллекций
exportData := make(map[string]interface{})
collections := db.ListCollections()
for _, collName := range collections {
coll, err := db.GetCollection(collName)
if err != nil {
continue
}
// Получаем все документы коллекции
docs := coll.GetAllDocuments()
// Сериализуем документы в формат для экспорта
collData := make([]map[string]interface{}, 0, len(docs))
for _, doc := range docs {
docData := map[string]interface{}{
"_id": doc.ID,
"fields": doc.GetFields(),
"created_at": doc.CreatedAt,
"updated_at": doc.UpdatedAt,
"version": doc.Version,
}
collData = append(collData, docData)
}
exportData[collName] = collData
}
// Добавляем метаданные
exportData["_metadata"] = map[string]interface{}{
"database": dbName,
"export_time": fmt.Sprintf("%d", utils.GetCurrentTimestamp()),
"version": "1.0",
"collections": len(collections),
}
// Сериализуем в MessagePack
data, err := serializer.Marshal(exportData)
if err != nil {
return fmt.Errorf("failed to marshal export data: %v", err)
}
// Записываем в файл
if err := os.WriteFile(fileName, data, 0644); err != nil {
return fmt.Errorf("failed to write export file: %v", err)
}
fmt.Printf("✓ Database '%s' exported successfully to %s\n", dbName, fileName)
fmt.Printf(" Collections exported: %d\n", len(collections))
return nil
}
// ImportData импортирует данные из файла MessagePack в слайс (базу данных)
func ImportData(store *storage.Storage, dbName, fileName string) error {
// Проверяем существование файла
if _, err := os.Stat(fileName); os.IsNotExist(err) {
return fmt.Errorf("import file '%s' not found", fileName)
}
// Читаем файл
data, err := os.ReadFile(fileName)
if err != nil {
return fmt.Errorf("failed to read import file: %v", err)
}
// Десериализуем из MessagePack
var importData map[string]interface{}
if err := serializer.Unmarshal(data, &importData); err != nil {
return fmt.Errorf("failed to unmarshal import data: %v", err)
}
// Проверяем метаданные
metadata, ok := importData["_metadata"].(map[string]interface{})
if !ok {
return fmt.Errorf("invalid import file format: missing metadata")
}
sourceDB, _ := metadata["database"].(string)
fmt.Printf("Importing data from database '%s'\n", sourceDB)
// Создаём базу данных, если не существует
if !store.ExistsDatabase(dbName) {
if err := store.CreateDatabase(dbName); err != nil {
return fmt.Errorf("failed to create database: %v", err)
}
fmt.Printf("Created database '%s'\n", dbName)
}
// Получаем базу данных
db, err := store.GetDatabase(dbName)
if err != nil {
return fmt.Errorf("failed to get database: %v", err)
}
importedCollections := 0
importedDocuments := 0
skippedDocuments := 0
failedDocuments := 0
// Импортируем коллекции
for key, value := range importData {
if key == "_metadata" {
continue
}
collName := key
collData, ok := value.([]interface{})
if !ok {
fmt.Printf(" Warning: collection '%s' has invalid format, skipping\n", collName)
continue
}
// Создаём коллекцию, если не существует
if _, err := db.GetCollection(collName); err != nil {
if err := db.CreateCollection(collName); err != nil {
fmt.Printf(" Warning: failed to create collection '%s': %v\n", collName, err)
continue
}
fmt.Printf(" Created collection '%s'\n", collName)
}
coll, err := db.GetCollection(collName)
if err != nil {
fmt.Printf(" Warning: failed to get collection '%s': %v\n", collName, err)
continue
}
collectionImported := 0
collectionSkipped := 0
collectionFailed := 0
// Импортируем документы
for _, docRaw := range collData {
docMap, ok := docRaw.(map[string]interface{})
if !ok {
collectionFailed++
continue
}
// Получаем ID документа
var docID string
if id, ok := docMap["_id"].(string); ok {
docID = id
} else {
// Если нет ID, пропускаем
collectionFailed++
continue
}
// Проверяем, существует ли уже документ с таким ID
if existingDoc, _ := coll.Find(docID); existingDoc != nil {
collectionSkipped++
skippedDocuments++
continue
}
// Создаём документ
doc := storage.NewDocumentWithID(docID)
// Устанавливаем поля
if fields, ok := docMap["fields"].(map[string]interface{}); ok {
for k, v := range fields {
doc.SetField(k, v)
}
}
// Устанавливаем временные метки с правильным преобразованием типов
if createdAt, ok := docMap["created_at"]; ok {
switch v := createdAt.(type) {
case int64:
doc.CreatedAt = v
case int:
doc.CreatedAt = int64(v)
case float64:
doc.CreatedAt = int64(v)
}
}
if updatedAt, ok := docMap["updated_at"]; ok {
switch v := updatedAt.(type) {
case int64:
doc.UpdatedAt = v
case int:
doc.UpdatedAt = int64(v)
case float64:
doc.UpdatedAt = int64(v)
}
}
if version, ok := docMap["version"]; ok {
switch v := version.(type) {
case uint64:
doc.Version = v
case int:
doc.Version = uint64(v)
case float64:
doc.Version = uint64(v)
}
}
// Вставляем документ
if err := coll.Insert(doc); err != nil {
fmt.Printf(" Warning: failed to insert document %s: %v\n", doc.ID, err)
collectionFailed++
failedDocuments++
continue
}
collectionImported++
importedDocuments++
}
if collectionImported > 0 || collectionSkipped > 0 || collectionFailed > 0 {
fmt.Printf(" Collection '%s': %d imported, %d skipped, %d failed\n",
collName, collectionImported, collectionSkipped, collectionFailed)
}
importedCollections++
}
fmt.Printf("✓ Database '%s' imported successfully from %s\n", dbName, fileName)
fmt.Printf(" Collections imported: %d\n", importedCollections)
fmt.Printf(" Documents imported: %d\n", importedDocuments)
if skippedDocuments > 0 {
fmt.Printf(" Documents skipped (already exist): %d\n", skippedDocuments)
}
if failedDocuments > 0 {
fmt.Printf(" Documents failed: %d\n", failedDocuments)
}
return nil
}
// ExecuteExport выполняет команду экспорта
func ExecuteExport(store *storage.Storage, cmd string) error {
// Формат: export "Имя_слайса" "название_экспортируемогоайла".msgpack
parts := strings.SplitN(cmd, " ", 3)
if len(parts) < 3 {
return fmt.Errorf("usage: export \"database_name\" \"filename.msgpack\"")
}
dbName := strings.Trim(parts[1], "\"")
fileName := strings.Trim(parts[2], "\"")
// Проверяем расширение файла
if !strings.HasSuffix(fileName, ".msgpack") {
fileName = fileName + ".msgpack"
}
return ExportData(store, dbName, fileName)
}
// ExecuteImport выполняет команду импорта
func ExecuteImport(store *storage.Storage, cmd string) error {
// Формат: import "Имя_слайса" "название_импортируемогоайла".msgpack
parts := strings.SplitN(cmd, " ", 3)
if len(parts) < 3 {
return fmt.Errorf("usage: import \"database_name\" \"filename.msgpack\"")
}
dbName := strings.Trim(parts[1], "\"")
fileName := strings.Trim(parts[2], "\"")
// Проверяем расширение файла
if !strings.HasSuffix(fileName, ".msgpack") {
fileName = fileName + ".msgpack"
}
return ImportData(store, dbName, fileName)
}

View File

@@ -0,0 +1,233 @@
/*
* 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/compression/compression.go
// Назначение: Реализация сжатия данных с использованием различных алгоритмов.
// Поддерживаемые алгоритмы: Snappy (по умолчанию), LZ4, Zstandard.
// Обеспечивает прозрачное сжатие/распаковку для документов.
package compression
import (
"bytes"
"encoding/binary"
"fmt"
"github.com/golang/snappy"
"github.com/klauspost/compress/zstd"
"github.com/pierrec/lz4/v4"
)
// Config представляет конфигурацию сжатия
type Config struct {
Enabled bool // Включено ли сжатие
Algorithm string // Алгоритм сжатия: snappy, lz4, zstd
Level int // Уровень сжатия (1-9)
MinSize int // Минимальный размер для сжатия (байт)
}
// MagicNumber используется для идентификации сжатых данных
var MagicNumber = []byte{0x46, 0x54, 0x52, 0x53} // "FTRS" - Futriis
// CompressionType определяет тип сжатия
type CompressionType byte
const (
CompressionNone CompressionType = 0x00
CompressionSnappy CompressionType = 0x01
CompressionLZ4 CompressionType = 0x02
CompressionZstd CompressionType = 0x03
)
// Compress сжимает данные с использованием указанного алгоритма
func Compress(data []byte, config *Config) ([]byte, error) {
if !config.Enabled {
return data, nil
}
if len(data) < config.MinSize {
return data, nil
}
var compressed []byte
var err error
var compType CompressionType
switch config.Algorithm {
case "snappy":
compressed = snappy.Encode(nil, data)
compType = CompressionSnappy
case "lz4":
buf := bytes.NewBuffer(nil)
lz4Writer := lz4.NewWriter(buf)
// Установка уровня сжатия для LZ4
if config.Level > 0 {
// LZ4 уровни: 0-9, где 0=быстрый, 9=максимальное сжатие
compressionLevel := lz4.CompressionLevel(config.Level)
if err := lz4Writer.Apply(lz4.CompressionLevelOption(compressionLevel)); err != nil {
return nil, fmt.Errorf("failed to set LZ4 compression level: %v", err)
}
}
if _, err := lz4Writer.Write(data); err != nil {
return nil, err
}
if err := lz4Writer.Close(); err != nil {
return nil, err
}
compressed = buf.Bytes()
compType = CompressionLZ4
case "zstd":
// Для Zstandard используем предустановленные уровни скорости
var encoder *zstd.Encoder
var encoderLevel zstd.EncoderLevel
// Выбираем уровень сжатия на основе config.Level
switch {
case config.Level <= 1:
encoderLevel = zstd.SpeedFastest
case config.Level <= 3:
encoderLevel = zstd.SpeedDefault
case config.Level <= 6:
encoderLevel = zstd.SpeedBetterCompression
default:
encoderLevel = zstd.SpeedBestCompression
}
// Создаём энкодер с выбранным уровнем
encoder, err = zstd.NewWriter(nil, zstd.WithEncoderLevel(encoderLevel))
if err != nil {
return nil, fmt.Errorf("failed to create zstd encoder: %v", err)
}
defer encoder.Close()
compressed = encoder.EncodeAll(data, nil)
compType = CompressionZstd
default:
return nil, fmt.Errorf("unsupported compression algorithm: %s", config.Algorithm)
}
// Проверяем, что сжатие действительно уменьшило размер
if len(compressed) >= len(data) {
return data, nil
}
// Добавляем заголовок: магическое число (4 байта) + тип сжатия (1 байт) + оригинальный размер (8 байт)
header := make([]byte, 4+1+8)
copy(header[0:4], MagicNumber)
header[4] = byte(compType)
binary.LittleEndian.PutUint64(header[5:], uint64(len(data)))
result := make([]byte, 0, len(header)+len(compressed))
result = append(result, header...)
result = append(result, compressed...)
return result, nil
}
// Decompress распаковывает данные
func Decompress(data []byte) ([]byte, error) {
// Проверяем наличие магического числа
if len(data) < 4+1+8 {
return nil, fmt.Errorf("data too short for compressed format")
}
// Проверяем магическое число
if !bytes.Equal(data[0:4], MagicNumber) {
return nil, fmt.Errorf("invalid magic number")
}
compType := CompressionType(data[4])
originalSize := binary.LittleEndian.Uint64(data[5:13])
compressedData := data[13:]
if originalSize == 0 {
return nil, fmt.Errorf("invalid original size")
}
var decompressed []byte
var err error
switch compType {
case CompressionSnappy:
decompressed, err = snappy.Decode(nil, compressedData)
if err != nil {
return nil, fmt.Errorf("snappy decode failed: %v", err)
}
case CompressionLZ4:
decompressed = make([]byte, originalSize)
lz4Reader := lz4.NewReader(bytes.NewReader(compressedData))
n, err := lz4Reader.Read(decompressed)
if err != nil && err.Error() != "EOF" {
return nil, fmt.Errorf("lz4 decode failed: %v", err)
}
if n != int(originalSize) {
// Некоторые данные могли быть прочитаны, но не все
decompressed = decompressed[:n]
}
case CompressionZstd:
decoder, err := zstd.NewReader(nil)
if err != nil {
return nil, fmt.Errorf("failed to create zstd decoder: %v", err)
}
defer decoder.Close()
decompressed, err = decoder.DecodeAll(compressedData, nil)
if err != nil {
return nil, fmt.Errorf("zstd decode failed: %v", err)
}
case CompressionNone:
return compressedData, nil
default:
return nil, fmt.Errorf("unsupported compression type: %d", compType)
}
// Проверяем размер распакованных данных
if len(decompressed) != int(originalSize) {
// Не критично, но логируем
_ = len(decompressed)
}
return decompressed, nil
}
// DecompressAuto автоматически определяет, сжаты ли данные, и распаковывает при необходимости
func DecompressAuto(data []byte) ([]byte, error) {
// Проверяем, есть ли магическое число (признак сжатых данных)
if len(data) >= 4 && bytes.Equal(data[0:4], MagicNumber) {
return Decompress(data)
}
return data, nil
}
// IsCompressed проверяет, сжаты ли данные
func IsCompressed(data []byte) bool {
if len(data) < 4 {
return false
}
return bytes.Equal(data[0:4], MagicNumber)
}
// GetCompressionType возвращает тип сжатия данных
func GetCompressionType(data []byte) CompressionType {
if !IsCompressed(data) || len(data) < 5 {
return CompressionNone
}
return CompressionType(data[4])
}
// GetCompressionRatio возвращает коэффициент сжатия
func GetCompressionRatio(original, compressed []byte) float64 {
if len(original) == 0 {
return 1.0
}
return float64(len(compressed)) / float64(len(original))
}

124
internal/config/config.go Normal file
View File

@@ -0,0 +1,124 @@
/*
* 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/config/config.go
// Назначение: Загрузка и парсинг TOML-конфигурации, валидация параметров,
// предоставление доступа к настройкам кластера, хранилища и REPL.
package config
import (
"github.com/BurntSushi/toml"
)
type Config struct {
Cluster ClusterConfig `toml:"cluster"`
Storage StorageConfig `toml:"storage"`
Repl ReplConfig `toml:"repl"`
Log LogConfig `toml:"log"`
Replication ReplicationConfig `toml:"replication"`
Plugins PluginsConfig `toml:"plugins"`
Compression CompressionConfig `toml:"compression"`
WebUI WebUIConfig `toml:"webui"`
}
type ClusterConfig struct {
Name string `toml:"name"`
NodeIP string `toml:"node_ip"`
NodePort int `toml:"node_port"`
RaftPort int `toml:"raft_port"`
RaftDataDir string `toml:"raft_data_dir"`
Bootstrap bool `toml:"bootstrap"` // Флаг бутстрапа кластера
Nodes []string `toml:"nodes"` // Список узлов кластера
}
type StorageConfig struct {
PageSizeMB int `toml:"page_size_mb"`
MaxCollections int `toml:"max_collections"`
MaxDocumentsPerCollection int `toml:"max_documents_per_collection"`
}
type ReplConfig struct {
PromptColor string `toml:"prompt_color"`
HistorySize int `toml:"history_size"`
}
type LogConfig struct {
LogFile string `toml:"log_file"`
LogLevel string `toml:"log_level"`
}
type ReplicationConfig struct {
Enabled bool `toml:"enabled"`
MasterMaster bool `toml:"master_master"`
SyncReplication bool `toml:"sync_replication"`
ReplicationTimeoutMs int `toml:"replication_timeout_ms"`
}
type PluginsConfig struct {
Enabled bool `toml:"enabled"`
ScriptDir string `toml:"script_dir"`
AllowList []string `toml:"allow_list"`
}
type CompressionConfig struct {
Enabled bool `toml:"enabled"` // Включено ли сжатие
Algorithm string `toml:"algorithm"` // Алгоритм сжатия (snappy, lz4, zstd)
Level int `toml:"level"` // Уровень сжатия (1-9, зависит от алгоритма)
MinSize int `toml:"min_size"` // Минимальный размер для сжатия (байт)
}
type WebUIConfig struct {
Enabled bool `toml:"enabled"` // Включить веб-интерфейс
Port int `toml:"port"` // Порт для веб-интерфейса
Theme string `toml:"theme"` // Тема оформления (dark, light)
}
func LoadConfig(path string) (*Config, error) {
var cfg Config
if _, err := toml.DecodeFile(path, &cfg); err != nil {
return nil, err
}
// Установка значений по умолчанию, если не указаны
if cfg.Cluster.RaftPort == 0 {
cfg.Cluster.RaftPort = 9878
}
if cfg.Cluster.RaftDataDir == "" {
cfg.Cluster.RaftDataDir = "raft_data"
}
if cfg.Replication.ReplicationTimeoutMs == 0 {
cfg.Replication.ReplicationTimeoutMs = 5000
}
if cfg.Plugins.ScriptDir == "" {
cfg.Plugins.ScriptDir = "plugins"
}
// Установка значений по умолчанию для сжатия
if cfg.Compression.Algorithm == "" {
cfg.Compression.Algorithm = "snappy"
}
if cfg.Compression.MinSize == 0 {
cfg.Compression.MinSize = 1024 // 1KB - сжимаем только документы больше 1KB
}
if cfg.Compression.Level == 0 {
cfg.Compression.Level = 3 // Средний уровень сжатия
}
// Установка значений по умолчанию для WebUI
if cfg.WebUI.Port == 0 {
cfg.WebUI.Port = 8080
}
if cfg.WebUI.Theme == "" {
cfg.WebUI.Theme = "dark"
}
return &cfg, nil
}

99
internal/log/logger.go Normal file
View File

@@ -0,0 +1,99 @@
/*
* 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/log/logger.go
// Назначение: Асинхронная, wait-free запись логов в файл с меткой времени
// в миллисекундах. Поддержка уровней логирования и ротации.
package log
import (
"fmt"
"os"
"sync/atomic"
"time"
)
type LogLevel int32
const (
DebugLevel LogLevel = iota
InfoLevel
WarnLevel
ErrorLevel
)
type Logger struct {
file *os.File
level atomic.Int32
writeChan chan string
done chan struct{}
}
func NewLogger(filename string, levelStr string) (*Logger, error) {
file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return nil, err
}
level := InfoLevel
switch levelStr {
case "debug":
level = DebugLevel
case "warn":
level = WarnLevel
case "error":
level = ErrorLevel
}
l := &Logger{
file: file,
writeChan: make(chan string, 10000),
done: make(chan struct{}),
}
l.level.Store(int32(level))
// Запуск wait-free writer
go l.writerLoop()
return l, nil
}
func (l *Logger) writerLoop() {
for msg := range l.writeChan {
l.file.WriteString(msg + "\n")
}
close(l.done)
}
func (l *Logger) log(level LogLevel, levelStr, msg string) {
if level < LogLevel(l.level.Load()) {
return
}
now := time.Now()
timestamp := now.Format("2006-01-02 15:04:05") + fmt.Sprintf(".%03d", now.Nanosecond()/1e6)
logMsg := fmt.Sprintf("[%s] %s: %s", timestamp, levelStr, msg)
select {
case l.writeChan <- logMsg:
default:
// Неблокирующая запись, старый лог теряется - wait-free
}
}
func (l *Logger) Debug(msg string) { l.log(DebugLevel, "DEBUG", msg) }
func (l *Logger) Info(msg string) { l.log(InfoLevel, "INFO", msg) }
func (l *Logger) Warn(msg string) { l.log(WarnLevel, "WARN", msg) }
func (l *Logger) Error(msg string) { l.log(ErrorLevel, "ERROR", msg) }
func (l *Logger) Close() {
close(l.writeChan)
<-l.done
l.file.Close()
}

797
internal/plugin/plugin.go Normal file
View File

@@ -0,0 +1,797 @@
/*
* 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/plugin/plugin.go
// Назначение: Система плагинов на основе Lua для расширения функциональности СУБД.
// Позволяет загружать Lua-скрипты как плагины, выполнять их в изолированном окружении,
// взаимодействовать с данными СУБД и логировать действия плагинов в общий лог-файл.
package plugin
import (
"fmt"
"sync"
"sync/atomic"
"time"
"os"
"path/filepath"
"strings"
"futriis/internal/log"
"futriis/internal/storage"
lua "github.com/yuin/gopher-lua"
)
// PluginStatus представляет состояние плагина
type PluginStatus int32
const (
StatusLoaded PluginStatus = iota
StatusRunning
StatusStopped
StatusError
)
// Plugin представляет загруженный Lua-плагин
type Plugin struct {
Name string
FilePath string
Status atomic.Int32
LState *lua.LState
logger *log.Logger
storage *storage.Storage
mu sync.RWMutex
loadedAt time.Time
version string
author string
description string
}
// PluginManager управляет всеми загруженными плагинами
type PluginManager struct {
plugins sync.Map // map[string]*Plugin
logger *log.Logger
storage *storage.Storage
pluginsDir string
eventBus chan PluginEvent
enabled bool
}
// PluginEvent представляет событие от плагина
type PluginEvent struct {
PluginName string
EventType string
Data interface{}
Timestamp int64
}
// NewPluginManager создаёт новый менеджер плагинов
func NewPluginManager(pluginsDir string, logger *log.Logger, store *storage.Storage, enabled bool) *PluginManager {
pm := &PluginManager{
logger: logger,
storage: store,
pluginsDir: pluginsDir,
eventBus: make(chan PluginEvent, 1000),
enabled: enabled,
}
if !enabled {
if logger != nil {
logger.Info("Plugin system is disabled")
}
return pm
}
// Запускаем обработчик событий плагинов
go pm.eventLoop()
// Автоматически загружаем плагины из директории
go pm.autoLoadPlugins()
return pm
}
// autoLoadPlugins автоматически загружает все .lua файлы из директории плагинов
func (pm *PluginManager) autoLoadPlugins() {
if !pm.enabled {
return
}
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
<-ticker.C
entries, err := os.ReadDir(pm.pluginsDir)
if err != nil {
if pm.logger != nil {
pm.logger.Error(fmt.Sprintf("Failed to read plugins directory: %v", err))
}
continue
}
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".lua") {
pluginName := strings.TrimSuffix(entry.Name(), ".lua")
if _, exists := pm.plugins.Load(pluginName); !exists {
pluginPath := filepath.Join(pm.pluginsDir, entry.Name())
if err := pm.LoadPlugin(pluginName, pluginPath); err != nil {
if pm.logger != nil {
pm.logger.Error(fmt.Sprintf("Failed to auto-load plugin %s: %v", pluginName, err))
}
}
}
}
}
}
}
// LoadPlugin загружает Lua-плагин из файла
func (pm *PluginManager) LoadPlugin(name, filePath string) error {
if !pm.enabled {
return fmt.Errorf("plugin system is disabled")
}
// Читаем файл плагина
script, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read plugin file: %v", err)
}
// Создаём новое Lua-состояние
L := lua.NewState()
defer func() {
if r := recover(); r != nil {
L.Close()
if pm.logger != nil {
pm.logger.Error(fmt.Sprintf("Plugin %s panic: %v", name, r))
}
}
}()
// Открываем стандартные библиотеки Lua
lua.OpenBase(L)
lua.OpenString(L)
lua.OpenTable(L)
lua.OpenMath(L)
// Регистрируем функции СУБД для Lua
pm.registerDatabaseFunctions(L)
// Выполняем скрипт с защитой от паники
var execErr error
func() {
defer func() {
if r := recover(); r != nil {
execErr = fmt.Errorf("panic during script execution: %v", r)
}
}()
execErr = L.DoString(string(script))
}()
if execErr != nil {
L.Close()
return fmt.Errorf("failed to execute plugin script: %v", execErr)
}
// Извлекаем метаданные плагина
version := pm.getPluginMetadata(L, "version")
author := pm.getPluginMetadata(L, "author")
description := pm.getPluginMetadata(L, "description")
// Создаём объект плагина
plugin := &Plugin{
Name: name,
FilePath: filePath,
LState: L,
logger: pm.logger,
storage: pm.storage,
loadedAt: time.Now(),
version: version,
author: author,
description: description,
}
plugin.Status.Store(int32(StatusLoaded))
// Сохраняем плагин
pm.plugins.Store(name, plugin)
// Логируем загрузку
if pm.logger != nil {
pm.logger.Info(fmt.Sprintf("Plugin loaded: %s v%s by %s - %s", name, version, author, description))
}
// Вызываем функцию инициализации плагина, если она есть
if err := pm.callPluginFunction(plugin, "on_load"); err != nil {
if pm.logger != nil {
pm.logger.Warn(fmt.Sprintf("Plugin %s on_load error: %v", name, err))
}
}
return nil
}
// registerDatabaseFunctions регистрирует функции доступа к СУБД в Lua
func (pm *PluginManager) registerDatabaseFunctions(L *lua.LState) {
// Регистрируем функцию для получения базы данных
L.SetGlobal("get_database", L.NewFunction(func(L *lua.LState) int {
dbName := L.CheckString(1)
db, err := pm.storage.GetDatabase(dbName)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
// Создаём пользовательский тип для базы данных
ud := L.NewUserData()
ud.Value = db
L.SetMetatable(ud, L.GetTypeMetatable("database"))
L.Push(ud)
return 1
}))
// Регистрируем функцию для получения коллекции
L.SetGlobal("get_collection", L.NewFunction(func(L *lua.LState) int {
dbName := L.CheckString(1)
collName := L.CheckString(2)
db, err := pm.storage.GetDatabase(dbName)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
coll, err := db.GetCollection(collName)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
ud := L.NewUserData()
ud.Value = coll
L.SetMetatable(ud, L.GetTypeMetatable("collection"))
L.Push(ud)
return 1
}))
// Регистрируем функцию логирования для плагинов
L.SetGlobal("plugin_log", L.NewFunction(func(L *lua.LState) int {
level := L.CheckString(1)
message := L.CheckString(2)
if pm.logger != nil {
logMsg := fmt.Sprintf("[PLUGIN] %s: %s", level, message)
switch level {
case "debug":
pm.logger.Debug(logMsg)
case "info":
pm.logger.Info(logMsg)
case "warn":
pm.logger.Warn(logMsg)
case "error":
pm.logger.Error(logMsg)
default:
pm.logger.Info(logMsg)
}
}
return 0
}))
// Регистрируем функцию для отправки событий
L.SetGlobal("emit_event", L.NewFunction(func(L *lua.LState) int {
eventType := L.CheckString(1)
eventData := L.CheckAny(2)
// Получаем имя плагина из контекста (нужно передавать при вызове)
event := PluginEvent{
EventType: eventType,
Data: pm.luaValueToGo(eventData),
Timestamp: time.Now().UnixMilli(),
}
select {
case pm.eventBus <- event:
default:
if pm.logger != nil {
pm.logger.Warn("Plugin event bus full, event dropped")
}
}
return 0
}))
// Устанавливаем метатаблицы для методов баз данных и коллекций
pm.setupDatabaseMetatable(L)
pm.setupCollectionMetatable(L)
}
// setupDatabaseMetatable настраивает методы для объекта базы данных в Lua
func (pm *PluginManager) setupDatabaseMetatable(L *lua.LState) {
mt := L.NewTypeMetatable("database")
L.SetField(mt, "__index", L.NewFunction(func(L *lua.LState) int {
db := L.CheckUserData(1).Value.(*storage.Database)
method := L.CheckString(2)
switch method {
case "create_collection":
L.Push(L.NewFunction(func(L *lua.LState) int {
name := L.CheckString(1)
err := db.CreateCollection(name)
if err != nil {
L.Push(lua.LString(err.Error()))
return 1
}
L.Push(lua.LNil)
return 1
}))
case "get_collection":
L.Push(L.NewFunction(func(L *lua.LState) int {
name := L.CheckString(1)
coll, err := db.GetCollection(name)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
ud := L.NewUserData()
ud.Value = coll
L.SetMetatable(ud, L.GetTypeMetatable("collection"))
L.Push(ud)
return 1
}))
case "name":
L.Push(lua.LString(db.Name()))
default:
L.Push(lua.LNil)
}
return 1
}))
}
// setupCollectionMetatable настраивает методы для объекта коллекции в Lua
func (pm *PluginManager) setupCollectionMetatable(L *lua.LState) {
mt := L.NewTypeMetatable("collection")
L.SetField(mt, "__index", L.NewFunction(func(L *lua.LState) int {
coll := L.CheckUserData(1).Value.(*storage.Collection)
method := L.CheckString(2)
switch method {
case "insert":
L.Push(L.NewFunction(func(L *lua.LState) int {
doc := L.CheckTable(1)
// Конвертируем Lua table в map
fields := make(map[string]interface{})
doc.ForEach(func(key, value lua.LValue) {
if key.Type() == lua.LTString {
fields[key.String()] = pm.luaValueToGo(value)
}
})
// Вставляем документ
newDoc := storage.NewDocument()
for k, v := range fields {
newDoc.SetField(k, v)
}
err := coll.Insert(newDoc)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
// Возвращаем ID вставленного документа
L.Push(lua.LString(newDoc.ID))
L.Push(lua.LNil)
return 2
}))
case "find":
L.Push(L.NewFunction(func(L *lua.LState) int {
id := L.CheckString(1)
doc, err := coll.Find(id)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
// Конвертируем документ в Lua table
table := L.NewTable()
table.RawSetString("_id", lua.LString(doc.ID))
for k, v := range doc.GetFields() {
table.RawSetString(k, pm.goValueToLua(L, v))
}
L.Push(table)
return 1
}))
case "update":
L.Push(L.NewFunction(func(L *lua.LState) int {
id := L.CheckString(1)
updates := L.CheckTable(2)
fields := make(map[string]interface{})
updates.ForEach(func(key, value lua.LValue) {
if key.Type() == lua.LTString {
fields[key.String()] = pm.luaValueToGo(value)
}
})
err := coll.Update(id, fields)
if err != nil {
L.Push(lua.LString(err.Error()))
return 1
}
L.Push(lua.LNil)
return 1
}))
case "delete":
L.Push(L.NewFunction(func(L *lua.LState) int {
id := L.CheckString(1)
err := coll.Delete(id)
if err != nil {
L.Push(lua.LString(err.Error()))
return 1
}
L.Push(lua.LNil)
return 1
}))
case "count":
L.Push(L.NewFunction(func(L *lua.LState) int {
count := coll.Count()
L.Push(lua.LNumber(count))
return 1
}))
default:
L.Push(lua.LNil)
}
return 1
}))
}
// luaValueToGo конвертирует Lua-значение в Go-значение
func (pm *PluginManager) luaValueToGo(val lua.LValue) interface{} {
if val == nil || val == lua.LNil {
return nil
}
switch v := val.(type) {
case lua.LString:
return string(v)
case lua.LNumber:
return float64(v)
case lua.LBool:
return bool(v)
case *lua.LTable:
result := make(map[string]interface{})
v.ForEach(func(key, value lua.LValue) {
keyStr := "unknown"
if key.Type() == lua.LTString {
keyStr = key.String()
} else if key.Type() == lua.LTNumber {
keyStr = fmt.Sprintf("%d", int64(key.(lua.LNumber)))
}
result[keyStr] = pm.luaValueToGo(value)
})
return result
default:
return v.String()
}
}
// goValueToLua конвертирует Go-значение в Lua-значение
func (pm *PluginManager) goValueToLua(L *lua.LState, val interface{}) lua.LValue {
if val == nil {
return lua.LNil
}
switch v := val.(type) {
case string:
return lua.LString(v)
case int:
return lua.LNumber(float64(v))
case int64:
return lua.LNumber(float64(v))
case float32:
return lua.LNumber(float64(v))
case float64:
return lua.LNumber(v)
case bool:
return lua.LBool(v)
case map[string]interface{}:
table := L.NewTable()
for k, val := range v {
table.RawSetString(k, pm.goValueToLua(L, val))
}
return table
case []interface{}:
table := L.NewTable()
for i, val := range v {
table.RawSetInt(i+1, pm.goValueToLua(L, val))
}
return table
default:
return lua.LString(fmt.Sprintf("%v", v))
}
}
// getPluginMetadata извлекает метаданные из загруженного Lua-скрипта
func (pm *PluginManager) getPluginMetadata(L *lua.LState, field string) string {
// Пытаемся получить глобальную переменную с метаданными
val := L.GetGlobal(field)
if str, ok := val.(lua.LString); ok {
return string(str)
}
return "unknown"
}
// callPluginFunction вызывает функцию плагина по имени с защитой от паники
func (pm *PluginManager) callPluginFunction(plugin *Plugin, funcName string) error {
plugin.mu.RLock()
defer plugin.mu.RUnlock()
L := plugin.LState
if L == nil {
return fmt.Errorf("plugin has no Lua state")
}
fn := L.GetGlobal(funcName)
if fn == lua.LNil {
return nil // Функция не определена
}
// Защита от паники при вызове Lua функции
var callErr error
func() {
defer func() {
if r := recover(); r != nil {
callErr = fmt.Errorf("panic during %s: %v", funcName, r)
}
}()
callErr = L.CallByParam(lua.P{
Fn: fn,
NRet: 0,
Protect: true,
})
}()
if callErr != nil {
return fmt.Errorf("failed to call %s: %v", funcName, callErr)
}
return nil
}
// eventLoop обрабатывает события от плагинов
func (pm *PluginManager) eventLoop() {
for event := range pm.eventBus {
if pm.logger != nil {
pm.logger.Debug(fmt.Sprintf("Plugin event [%s]: %+v", event.EventType, event.Data))
}
// Можно реализовать подписку плагинов на события
pm.plugins.Range(func(key, value interface{}) bool {
plugin := value.(*Plugin)
// Асинхронно уведомляем плагины о событии
go pm.notifyPlugin(plugin, event)
return true
})
}
}
// notifyPlugin уведомляет конкретный плагин о событии
func (pm *PluginManager) notifyPlugin(plugin *Plugin, event PluginEvent) {
plugin.mu.RLock()
defer plugin.mu.RUnlock()
L := plugin.LState
if L == nil {
return
}
fn := L.GetGlobal("on_event")
if fn == lua.LNil {
return
}
// Устанавливаем имя плагина в событие
event.PluginName = plugin.Name
// Создаём таблицу с данными события
eventTable := L.NewTable()
eventTable.RawSetString("type", lua.LString(event.EventType))
eventTable.RawSetString("plugin_name", lua.LString(event.PluginName))
eventTable.RawSetString("timestamp", lua.LNumber(event.Timestamp))
eventTable.RawSetString("data", pm.goValueToLua(L, event.Data))
// Не устанавливаем глобальную переменную, передаём как аргумент
var callErr error
func() {
defer func() {
if r := recover(); r != nil {
callErr = fmt.Errorf("panic during on_event: %v", r)
}
}()
callErr = L.CallByParam(lua.P{
Fn: fn,
NRet: 0,
Protect: true,
}, eventTable)
}()
if callErr != nil && pm.logger != nil {
pm.logger.Error(fmt.Sprintf("Plugin %s on_event error: %v", plugin.Name, callErr))
}
}
// ExecutePlugin выполняет пользовательскую функцию плагина
func (pm *PluginManager) ExecutePlugin(pluginName, funcName string, args ...interface{}) (interface{}, error) {
if !pm.enabled {
return nil, fmt.Errorf("plugin system is disabled")
}
val, ok := pm.plugins.Load(pluginName)
if !ok {
return nil, fmt.Errorf("plugin not found: %s", pluginName)
}
plugin := val.(*Plugin)
if PluginStatus(plugin.Status.Load()) != StatusRunning {
return nil, fmt.Errorf("plugin %s is not running", pluginName)
}
plugin.mu.RLock()
defer plugin.mu.RUnlock()
L := plugin.LState
if L == nil {
return nil, fmt.Errorf("plugin %s has no Lua state", pluginName)
}
fn := L.GetGlobal(funcName)
if fn == lua.LNil {
return nil, fmt.Errorf("function %s not found in plugin %s", funcName, pluginName)
}
// Подготавливаем аргументы для вызова
luaArgs := make([]lua.LValue, len(args))
for i, arg := range args {
luaArgs[i] = pm.goValueToLua(L, arg)
}
// Вызываем функцию с защитой от паники
var ret lua.LValue
var callErr error
func() {
defer func() {
if r := recover(); r != nil {
callErr = fmt.Errorf("panic during execution: %v", r)
}
}()
callErr = L.CallByParam(lua.P{
Fn: fn,
NRet: 1,
Protect: true,
}, luaArgs...)
if callErr == nil {
ret = L.Get(-1)
L.Pop(1)
}
}()
if callErr != nil {
return nil, fmt.Errorf("plugin execution failed: %v", callErr)
}
return pm.luaValueToGo(ret), nil
}
// UnloadPlugin выгружает плагин
func (pm *PluginManager) UnloadPlugin(name string) error {
if !pm.enabled {
return fmt.Errorf("plugin system is disabled")
}
val, ok := pm.plugins.Load(name)
if !ok {
return fmt.Errorf("plugin not found: %s", name)
}
plugin := val.(*Plugin)
// Вызываем функцию выгрузки
if err := pm.callPluginFunction(plugin, "on_unload"); err != nil {
if pm.logger != nil {
pm.logger.Warn(fmt.Sprintf("Plugin %s on_unload error: %v", name, err))
}
}
// Закрываем Lua-состояние
if plugin.LState != nil {
plugin.LState.Close()
}
plugin.Status.Store(int32(StatusStopped))
pm.plugins.Delete(name)
if pm.logger != nil {
pm.logger.Info(fmt.Sprintf("Plugin unloaded: %s", name))
}
return nil
}
// StartPlugin запускает плагин
func (pm *PluginManager) StartPlugin(name string) error {
if !pm.enabled {
return fmt.Errorf("plugin system is disabled")
}
val, ok := pm.plugins.Load(name)
if !ok {
return fmt.Errorf("plugin not found: %s", name)
}
plugin := val.(*Plugin)
plugin.Status.Store(int32(StatusRunning))
if err := pm.callPluginFunction(plugin, "on_start"); err != nil {
plugin.Status.Store(int32(StatusError))
return fmt.Errorf("failed to start plugin: %v", err)
}
if pm.logger != nil {
pm.logger.Info(fmt.Sprintf("Plugin started: %s", name))
}
return nil
}
// StopPlugin останавливает плагин
func (pm *PluginManager) StopPlugin(name string) error {
if !pm.enabled {
return fmt.Errorf("plugin system is disabled")
}
val, ok := pm.plugins.Load(name)
if !ok {
return fmt.Errorf("plugin not found: %s", name)
}
plugin := val.(*Plugin)
if err := pm.callPluginFunction(plugin, "on_stop"); err != nil {
if pm.logger != nil {
pm.logger.Warn(fmt.Sprintf("Plugin %s on_stop error: %v", name, err))
}
}
plugin.Status.Store(int32(StatusStopped))
if pm.logger != nil {
pm.logger.Info(fmt.Sprintf("Plugin stopped: %s", name))
}
return nil
}
// ListPlugins возвращает список всех загруженных плагинов
func (pm *PluginManager) ListPlugins() []*Plugin {
plugins := make([]*Plugin, 0)
pm.plugins.Range(func(key, value interface{}) bool {
plugins = append(plugins, value.(*Plugin))
return true
})
return plugins
}
// IsEnabled возвращает статус системы плагинов
func (pm *PluginManager) IsEnabled() bool {
return pm.enabled
}

106
internal/repl/history.go Normal file
View File

@@ -0,0 +1,106 @@
/*
* 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/repl/history.go
// Назначение: Управление историей команд REPL
package repl
import (
"bufio"
"os"
"path/filepath"
)
// History управляет историей команд
type History struct {
entries []string
maxSize int
filePath string
}
// NewHistory создаёт новый объект истории
func NewHistory(maxSize int) *History {
homeDir, _ := os.UserHomeDir()
filePath := filepath.Join(homeDir, ".futriis_history")
return &History{
entries: make([]string, 0, maxSize),
maxSize: maxSize,
filePath: filePath,
}
}
// Add добавляет команду в историю
func (h *History) Add(cmd string) error {
// Не добавляем дубликаты подряд
if len(h.entries) > 0 && h.entries[len(h.entries)-1] == cmd {
return nil
}
h.entries = append(h.entries, cmd)
// Ограничиваем размер истории
if len(h.entries) > h.maxSize {
h.entries = h.entries[len(h.entries)-h.maxSize:]
}
return nil
}
// Load загружает историю из файла
func (h *History) Load() error {
file, err := os.Open(h.filePath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
cmd := scanner.Text()
if cmd != "" {
h.entries = append(h.entries, cmd)
}
}
// Ограничиваем размер
if len(h.entries) > h.maxSize {
h.entries = h.entries[len(h.entries)-h.maxSize:]
}
return scanner.Err()
}
// Save сохраняет историю в файл
func (h *History) Save() error {
file, err := os.Create(h.filePath)
if err != nil {
return err
}
defer file.Close()
writer := bufio.NewWriter(file)
for _, cmd := range h.entries {
if _, err := writer.WriteString(cmd + "\n"); err != nil {
return err
}
}
return writer.Flush()
}
// GetEntries возвращает все записи истории
func (h *History) GetEntries() []string {
return h.entries
}

1670
internal/repl/repl.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
/*
* 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/serializer/msgpack.go
// Назначение: Сериализация и десериализация документов в формате MessagePack.
// Используется библиотека vmihailenco/msgpack для высокой производительности.
package serializer
import (
"github.com/vmihailenco/msgpack/v5"
)
func Marshal(v interface{}) ([]byte, error) {
return msgpack.Marshal(v)
}
func Unmarshal(data []byte, v interface{}) error {
return msgpack.Unmarshal(data, v)
}

126
internal/storage/audit.go Normal file
View File

@@ -0,0 +1,126 @@
/*
* 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/audit.go
// Назначение: Аудит всех операций создания, изменения, удаления данных
// с записью временной метки с точностью до миллисекунды
package storage
import (
"fmt"
"sync"
"time"
)
// AuditEntry представляет запись аудита
type AuditEntry struct {
ID string `msgpack:"id"`
Timestamp int64 `msgpack:"timestamp"` // Unix миллисекунды
TimestampStr string `msgpack:"timestamp_str"` // Человекочитаемая строка
Operation string `msgpack:"operation"` // CREATE, UPDATE, DELETE, START, COMMIT, ABORT, CLUSTER
DataType string `msgpack:"data_type"` // DATABASE, COLLECTION, DOCUMENT, FIELD, TUPLE, SESSION, TRANSACTION, CLUSTER
Name string `msgpack:"name"` // Имя объекта
Details map[string]interface{} `msgpack:"details"` // Детали операции
}
// AuditLogger управляет аудитом
type AuditLogger struct {
entries []AuditEntry
mu sync.RWMutex
}
var globalAuditLogger = &AuditLogger{
entries: make([]AuditEntry, 0),
}
// GetCurrentTimestamp возвращает текущую временную метку с миллисекундами
func GetCurrentTimestamp() (int64, string) {
now := time.Now()
timestampMs := now.UnixMilli()
timestampStr := now.Format("2006-01-02 15:04:05.000")
return timestampMs, timestampStr
}
// LogAudit записывает событие в аудит
func LogAudit(operation, dataType, name string, details map[string]interface{}) {
timestampMs, timestampStr := GetCurrentTimestamp()
entry := AuditEntry{
ID: fmt.Sprintf("%d", timestampMs),
Timestamp: timestampMs,
TimestampStr: timestampStr,
Operation: operation,
DataType: dataType,
Name: name,
Details: details,
}
globalAuditLogger.mu.Lock()
globalAuditLogger.entries = append(globalAuditLogger.entries, entry)
globalAuditLogger.mu.Unlock()
}
// GetAuditLog возвращает копию лога аудита
func GetAuditLog() []AuditEntry {
globalAuditLogger.mu.RLock()
defer globalAuditLogger.mu.RUnlock()
result := make([]AuditEntry, len(globalAuditLogger.entries))
copy(result, globalAuditLogger.entries)
return result
}
// AuditDatabaseOperation логирует операцию с базой данных
func AuditDatabaseOperation(operation, dbName string) {
LogAudit(operation, "DATABASE", dbName, map[string]interface{}{
"database": dbName,
})
}
// AuditCollectionOperation логирует операцию с коллекцией
func AuditCollectionOperation(operation, dbName, collName string, settings interface{}) {
LogAudit(operation, "COLLECTION", fmt.Sprintf("%s.%s", dbName, collName), map[string]interface{}{
"database": dbName,
"collection": collName,
"settings": settings,
})
}
// AuditDocumentOperation логирует операцию с документом
func AuditDocumentOperation(operation, dbName, collName, docID string, fields map[string]interface{}) {
LogAudit(operation, "DOCUMENT", fmt.Sprintf("%s.%s.%s", dbName, collName, docID), map[string]interface{}{
"database": dbName,
"collection": collName,
"document_id": docID,
"fields": fields,
})
}
// AuditFieldOperation логирует операцию с полем
func AuditFieldOperation(operation, dbName, collName, docID, fieldName string, value interface{}) {
LogAudit(operation, "FIELD", fmt.Sprintf("%s.%s.%s.%s", dbName, collName, docID, fieldName), map[string]interface{}{
"database": dbName,
"collection": collName,
"document_id": docID,
"field": fieldName,
"value": value,
})
}
// AuditTupleOperation логирует операцию с кортежем
func AuditTupleOperation(operation, dbName, collName, docID, tuplePath string) {
LogAudit(operation, "TUPLE", fmt.Sprintf("%s.%s.%s.%s", dbName, collName, docID, tuplePath), map[string]interface{}{
"database": dbName,
"collection": collName,
"document_id": docID,
"tuple_path": tuplePath,
})
}

View File

@@ -0,0 +1,766 @@
/*
* 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/collection.go
// Назначение: Реализация коллекции с индексами (первичными и вторичными).
// Индексы хранятся отдельно от документов, обеспечивают wait-free доступ.
// Исправлено: корректная работа уникальных индексов, удаление из индексов при обновлении.
package storage
import (
"fmt"
"sync"
"sync/atomic"
"time"
"strings"
"futriis/internal/serializer"
)
// Collection представляет коллекцию документов (аналог таблицы)
type Collection struct {
name string
docs sync.Map // map[string]*Document - wait-free хранилище документов
indexes sync.Map // map[string]*Index - индексы для быстрого поиска
metadata *CollectionMetadata // Метаданные коллекции
docCount atomic.Int64 // Атомарный счётчик документов
sizeBytes atomic.Int64 // Атомарный размер коллекции в байтах
mu sync.RWMutex // Для операций, изменяющих структуру коллекции
constraints *Constraints // Ограничения коллекции
acl *CollectionACL // ACL для коллекции
}
// CollectionMetadata содержит метаданные коллекции
type CollectionMetadata struct {
Name string `msgpack:"name"`
CreatedAt int64 `msgpack:"created_at"`
UpdatedAt int64 `msgpack:"updated_at"`
DocumentCount int64 `msgpack:"document_count"`
SizeBytes int64 `msgpack:"size_bytes"`
IndexCount int `msgpack:"index_count"`
Settings *CollectionSettings `msgpack:"settings"`
}
// CollectionSettings содержит настройки коллекции
type CollectionSettings struct {
MaxDocuments int `msgpack:"max_documents"` // Максимальное количество документов (0 = безлимит)
ValidateSchema bool `msgpack:"validate_schema"` // Валидировать схему документов
AutoIndexID bool `msgpack:"auto_index_id"` // Автоматически индексировать поле _id
TTLSeconds int `msgpack:"ttl_seconds"` // Время жизни документов (0 = бессрочно)
}
// Index представляет индекс для ускорения поиска (хранится отдельно от документов)
type Index struct {
Name string `msgpack:"name"`
Fields []string `msgpack:"fields"` // Поля для индексации
Unique bool `msgpack:"unique"` // Уникальный индекс
data sync.Map // map[interface{}]string - значение индекса -> ID документа
}
// Constraints представляет ограничения на коллекцию
type Constraints struct {
mu sync.RWMutex
RequiredFields map[string]bool // Обязательные поля
UniqueFields map[string]bool // Уникальные поля (дополнительно к индексам)
MinValues map[string]float64 // Минимальные значения для числовых полей
MaxValues map[string]float64 // Максимальные значения для числовых полей
PatternFields map[string]string // Regexp паттерны для полей
EnumFields map[string][]interface{} // Допустимые значения для полей
}
// CollectionACL представляет список контроля доступа для коллекции
type CollectionACL struct {
mu sync.RWMutex
ReadRoles map[string]bool // Роли, имеющие доступ на чтение
WriteRoles map[string]bool // Роли, имеющие доступ на запись
DeleteRoles map[string]bool // Роли, имеющие доступ на удаление
AdminRoles map[string]bool // Роли, имеющие полный доступ
}
// NewCollection создаёт новую коллекцию
func NewCollection(name string, settings *CollectionSettings) *Collection {
if settings == nil {
settings = &CollectionSettings{
MaxDocuments: 0,
ValidateSchema: false,
AutoIndexID: true,
TTLSeconds: 0,
}
}
now := time.Now().UnixMilli()
coll := &Collection{
name: name,
metadata: &CollectionMetadata{
Name: name,
CreatedAt: now,
UpdatedAt: now,
DocumentCount: 0,
SizeBytes: 0,
IndexCount: 0,
Settings: settings,
},
constraints: &Constraints{
RequiredFields: make(map[string]bool),
UniqueFields: make(map[string]bool),
MinValues: make(map[string]float64),
MaxValues: make(map[string]float64),
PatternFields: make(map[string]string),
EnumFields: make(map[string][]interface{}),
},
acl: &CollectionACL{
ReadRoles: make(map[string]bool),
WriteRoles: make(map[string]bool),
DeleteRoles: make(map[string]bool),
AdminRoles: make(map[string]bool),
},
}
// Автоматически создаём первичный индекс по _id
if settings.AutoIndexID {
coll.CreateIndex("_id_", []string{"_id"}, true)
}
// Запускаем фоновую задачу для удаления просроченных документов
if settings.TTLSeconds > 0 {
go coll.ttlCleanupLoop()
}
return coll
}
// Name возвращает имя коллекции
func (c *Collection) Name() string {
return c.name
}
// Insert вставляет документ в коллекцию (wait-free)
func (c *Collection) Insert(doc *Document) error {
// Проверка ограничений
if err := c.constraints.ValidateDocument(doc); err != nil {
return fmt.Errorf("constraint violation: %v", err)
}
// Проверка ACL (будет вызвано из верхнего уровня с ролью)
// Проверка на максимальное количество документов
if c.metadata.Settings.MaxDocuments > 0 {
if c.docCount.Load() >= int64(c.metadata.Settings.MaxDocuments) {
return fmt.Errorf("collection is full: max documents %d reached", c.metadata.Settings.MaxDocuments)
}
}
// Валидация схемы (если включена)
if c.metadata.Settings.ValidateSchema {
if err := c.validateDocument(doc); err != nil {
return fmt.Errorf("document validation failed: %v", err)
}
}
// Проверка уникальных индексов
if err := c.checkUniqueConstraints(doc); err != nil {
return err
}
// Сериализация для проверки (опционально)
data, err := serializer.Marshal(doc)
if err != nil {
return fmt.Errorf("failed to serialize document: %v", err)
}
// Атомарное сохранение документа
if _, loaded := c.docs.LoadOrStore(doc.ID, doc); loaded {
return fmt.Errorf("document with id %s already exists", doc.ID)
}
// Обновление индексов (wait-free)
c.updateIndexes(doc, true)
// Обновление метаданных
c.docCount.Add(1)
c.sizeBytes.Add(int64(len(data)))
c.metadata.DocumentCount = c.docCount.Load()
c.metadata.SizeBytes = c.sizeBytes.Load()
c.metadata.UpdatedAt = time.Now().UnixMilli()
return nil
}
// InsertFromMap создаёт и вставляет документ из map
func (c *Collection) InsertFromMap(fields map[string]interface{}) error {
doc := NewDocument()
for k, v := range fields {
doc.SetField(k, v)
}
return c.Insert(doc)
}
// Find находит документ по ID (с использованием первичного индекса)
func (c *Collection) Find(id string) (*Document, error) {
if val, ok := c.docs.Load(id); ok {
doc := val.(*Document)
// Проверяем, не истёк ли TTL
if c.metadata.Settings.TTLSeconds > 0 {
if time.Now().UnixMilli()-doc.CreatedAt > int64(c.metadata.Settings.TTLSeconds*1000) {
c.Delete(id) // Автоматически удаляем просроченный документ
return nil, fmt.Errorf("key not found")
}
}
return doc, nil
}
return nil, fmt.Errorf("key not found")
}
// compareValues сравнивает два значения с учётом типа
func compareValues(a, b interface{}) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
// Пробуем прямое сравнение
if a == b {
return true
}
// Пробуем сравнение через строковое представление для разных типов
aStr := fmt.Sprintf("%v", a)
bStr := fmt.Sprintf("%v", b)
return aStr == bStr
}
// FindByIndex находит документы по значению индексированного поля
// Исправлено: корректный поиск для неуникальных индексов с правильным сравнением типов
func (c *Collection) FindByIndex(indexName string, value interface{}) ([]*Document, error) {
idxVal, ok := c.indexes.Load(indexName)
if !ok {
return nil, fmt.Errorf("index not found: %s", indexName)
}
index := idxVal.(*Index)
docs := make([]*Document, 0)
if index.Unique {
// Уникальный индекс возвращает один документ
if docID, ok := index.data.Load(value); ok {
if doc, err := c.Find(docID.(string)); err == nil {
docs = append(docs, doc)
}
}
} else {
// Исправлено: для неуникального индекса используем Range с правильным сравнением
index.data.Range(func(key, val interface{}) bool {
// key - значение индекса, val - ID документа
if compareValues(key, value) {
if doc, err := c.Find(val.(string)); err == nil {
docs = append(docs, doc)
}
}
return true
})
}
return docs, nil
}
// FindByIndexPrefix находит документы по префиксу индекса (для строковых полей)
func (c *Collection) FindByIndexPrefix(indexName string, prefix string) ([]*Document, error) {
idxVal, ok := c.indexes.Load(indexName)
if !ok {
return nil, fmt.Errorf("index not found: %s", indexName)
}
index := idxVal.(*Index)
docs := make([]*Document, 0)
index.data.Range(func(key, val interface{}) bool {
if keyStr, ok := key.(string); ok {
if strings.HasPrefix(keyStr, prefix) {
if doc, err := c.Find(val.(string)); err == nil {
docs = append(docs, doc)
}
}
}
return true
})
return docs, nil
}
// Update обновляет документ по ID
// Исправлено: корректное обновление индексов при изменении индексированных полей
func (c *Collection) Update(id string, updates map[string]interface{}) error {
val, ok := c.docs.Load(id)
if !ok {
return fmt.Errorf("key not found")
}
oldDoc := val.(*Document)
// Создаём копию для проверки уникальности
newDoc := oldDoc.Clone()
if err := newDoc.Update(updates); err != nil {
return err
}
// Проверяем ограничения
if err := c.constraints.ValidateDocument(newDoc); err != nil {
return fmt.Errorf("constraint violation: %v", err)
}
// Проверяем уникальные индексы
if err := c.checkUniqueConstraintsUpdate(oldDoc, newDoc); err != nil {
return err
}
// Исправлено: сначала удаляем старые индексы, потом добавляем новые
c.removeFromIndexes(oldDoc)
c.addToIndexes(newDoc)
// Сохраняем обновлённый документ
c.docs.Store(id, newDoc)
c.metadata.UpdatedAt = time.Now().UnixMilli()
return nil
}
// Delete удаляет документ по ID
// Исправлено: удаление из всех индексов перед удалением документа
func (c *Collection) Delete(id string) error {
val, ok := c.docs.Load(id)
if !ok {
return fmt.Errorf("key not found")
}
doc := val.(*Document)
// Удаляем из индексов
c.removeFromIndexes(doc)
// Удаляем документ
c.docs.Delete(id)
// Обновляем метаданные
c.docCount.Add(-1)
// Размер не обновляем для простоты (можно пересчитать при необходимости)
c.metadata.DocumentCount = c.docCount.Load()
c.metadata.UpdatedAt = time.Now().UnixMilli()
return nil
}
// removeFromIndexes удаляет документ из всех индексов (wait-free)
func (c *Collection) removeFromIndexes(doc *Document) {
c.indexes.Range(func(key, value interface{}) bool {
index := value.(*Index)
indexValue := c.extractIndexValue(doc, index.Fields)
index.data.Delete(indexValue)
return true
})
}
// addToIndexes добавляет документ во все индексы (wait-free)
func (c *Collection) addToIndexes(doc *Document) {
c.indexes.Range(func(key, value interface{}) bool {
index := value.(*Index)
indexValue := c.extractIndexValue(doc, index.Fields)
if index.Unique {
index.data.LoadOrStore(indexValue, doc.ID)
} else {
index.data.Store(indexValue, doc.ID)
}
return true
})
}
// CreateIndex создаёт новый индекс на коллекции (wait-free friendly)
func (c *Collection) CreateIndex(name string, fields []string, unique bool) error {
c.mu.Lock()
defer c.mu.Unlock()
if _, exists := c.indexes.Load(name); exists {
return fmt.Errorf("index %s already exists", name)
}
index := &Index{
Name: name,
Fields: fields,
Unique: unique,
}
// Строим индекс на существующих документах (wait-free)
c.docs.Range(func(key, value interface{}) bool {
doc := value.(*Document)
indexValue := c.extractIndexValue(doc, fields)
if unique {
if _, loaded := index.data.LoadOrStore(indexValue, doc.ID); loaded {
// Найден дубликат - откатываем создание индекса
c.mu.Unlock()
return false
}
} else {
index.data.Store(indexValue, doc.ID)
}
return true
})
c.indexes.Store(name, index)
c.metadata.IndexCount++
return nil
}
// DropIndex удаляет индекс
func (c *Collection) DropIndex(name string) error {
if _, exists := c.indexes.LoadAndDelete(name); !exists {
return fmt.Errorf("index not found: %s", name)
}
c.metadata.IndexCount--
return nil
}
// GetIndexes возвращает список всех индексов
func (c *Collection) GetIndexes() []string {
names := make([]string, 0)
c.indexes.Range(func(key, value interface{}) bool {
names = append(names, key.(string))
return true
})
return names
}
// extractIndexValue извлекает значение из документа для индексации
func (c *Collection) extractIndexValue(doc *Document, fields []string) interface{} {
if len(fields) == 1 {
val, _ := doc.GetField(fields[0])
return val
}
// Составной индекс - возвращаем строковое представление
parts := make([]string, 0, len(fields))
for _, field := range fields {
if val, err := doc.GetField(field); err == nil {
parts = append(parts, fmt.Sprintf("%v", val))
} else {
parts = append(parts, "NULL")
}
}
return strings.Join(parts, "|")
}
// updateIndexes обновляет индексы для документа (исправлено: используем отдельные методы)
func (c *Collection) updateIndexes(doc *Document, add bool) {
if add {
c.addToIndexes(doc)
} else {
c.removeFromIndexes(doc)
}
}
// checkUniqueConstraints проверяет уникальные индексы перед вставкой
func (c *Collection) checkUniqueConstraints(doc *Document) error {
var errs []string
c.indexes.Range(func(key, value interface{}) bool {
index := value.(*Index)
if index.Unique {
indexValue := c.extractIndexValue(doc, index.Fields)
if _, exists := index.data.Load(indexValue); exists {
errs = append(errs, fmt.Sprintf("duplicate key for index %s: %v", index.Name, indexValue))
}
}
return true
})
if len(errs) > 0 {
return fmt.Errorf("%s", strings.Join(errs, "; "))
}
return nil
}
// checkUniqueConstraintsUpdate проверяет уникальность при обновлении
func (c *Collection) checkUniqueConstraintsUpdate(oldDoc, newDoc *Document) error {
var errs []string
c.indexes.Range(func(key, value interface{}) bool {
index := value.(*Index)
if index.Unique {
oldValue := c.extractIndexValue(oldDoc, index.Fields)
newValue := c.extractIndexValue(newDoc, index.Fields)
if fmt.Sprintf("%v", oldValue) != fmt.Sprintf("%v", newValue) {
if _, exists := index.data.Load(newValue); exists {
errs = append(errs, fmt.Sprintf("duplicate key for index %s: %v", index.Name, newValue))
}
}
}
return true
})
if len(errs) > 0 {
return fmt.Errorf("%s", strings.Join(errs, "; "))
}
return nil
}
// validateDocument валидирует документ согласно схеме коллекции
func (c *Collection) validateDocument(doc *Document) error {
if doc.ID == "" {
return fmt.Errorf("document must have _id field")
}
return nil
}
// ttlCleanupLoop периодически удаляет просроченные документы
func (c *Collection) ttlCleanupLoop() {
ticker := time.NewTicker(time.Duration(c.metadata.Settings.TTLSeconds/2) * time.Second)
defer ticker.Stop()
for range ticker.C {
now := time.Now().UnixMilli()
toDelete := make([]string, 0)
c.docs.Range(func(key, value interface{}) bool {
doc := value.(*Document)
if now-doc.CreatedAt > int64(c.metadata.Settings.TTLSeconds*1000) {
toDelete = append(toDelete, doc.ID)
}
return true
})
for _, id := range toDelete {
c.Delete(id)
}
}
}
// Count возвращает количество документов в коллекции
func (c *Collection) Count() int64 {
return c.docCount.Load()
}
// Size возвращает размер коллекции в байтах
func (c *Collection) Size() int64 {
return c.sizeBytes.Load()
}
// GetAllDocuments возвращает все документы коллекции
func (c *Collection) GetAllDocuments() []*Document {
docs := make([]*Document, 0, c.docCount.Load())
c.docs.Range(func(key, value interface{}) bool {
docs = append(docs, value.(*Document))
return true
})
return docs
}
// FindByFilter находит документы по произвольному фильтру
func (c *Collection) FindByFilter(filter func(*Document) bool) []*Document {
results := make([]*Document, 0)
c.docs.Range(func(key, value interface{}) bool {
doc := value.(*Document)
if filter(doc) {
results = append(results, doc)
}
return true
})
return results
}
// GetMetadata возвращает метаданные коллекции
func (c *Collection) GetMetadata() *CollectionMetadata {
c.mu.RLock()
defer c.mu.RUnlock()
return c.metadata
}
// Drop удаляет все документы из коллекции
func (c *Collection) Drop() error {
c.mu.Lock()
defer c.mu.Unlock()
c.docs = sync.Map{}
c.indexes = sync.Map{}
if c.metadata.Settings.AutoIndexID {
c.CreateIndex("_id_", []string{"_id"}, true)
}
c.docCount.Store(0)
c.sizeBytes.Store(0)
c.metadata.DocumentCount = 0
c.metadata.SizeBytes = 0
c.metadata.UpdatedAt = time.Now().UnixMilli()
return nil
}
// ========== Constraints Methods ==========
// AddRequiredField добавляет обязательное поле
func (c *Collection) AddRequiredField(field string) {
c.constraints.mu.Lock()
defer c.constraints.mu.Unlock()
c.constraints.RequiredFields[field] = true
}
// AddUniqueConstraint добавляет ограничение уникальности
func (c *Collection) AddUniqueConstraint(field string) {
c.constraints.mu.Lock()
defer c.constraints.mu.Unlock()
c.constraints.UniqueFields[field] = true
// Также создаём уникальный индекс
c.CreateIndex("unique_"+field, []string{field}, true)
}
// AddMinConstraint добавляет минимальное значение
func (c *Collection) AddMinConstraint(field string, min float64) {
c.constraints.mu.Lock()
defer c.constraints.mu.Unlock()
c.constraints.MinValues[field] = min
}
// AddMaxConstraint добавляет максимальное значение
func (c *Collection) AddMaxConstraint(field string, max float64) {
c.constraints.mu.Lock()
defer c.constraints.mu.Unlock()
c.constraints.MaxValues[field] = max
}
// AddPatternConstraint добавляет regexp паттерн
func (c *Collection) AddPatternConstraint(field string, pattern string) {
c.constraints.mu.Lock()
defer c.constraints.mu.Unlock()
c.constraints.PatternFields[field] = pattern
}
// AddEnumConstraint добавляет допустимые значения
func (c *Collection) AddEnumConstraint(field string, values []interface{}) {
c.constraints.mu.Lock()
defer c.constraints.mu.Unlock()
c.constraints.EnumFields[field] = values
}
// ValidateDocument проверяет документ на соответствие ограничениям
func (cons *Constraints) ValidateDocument(doc *Document) error {
cons.mu.RLock()
defer cons.mu.RUnlock()
// Проверка обязательных полей
for field := range cons.RequiredFields {
if !doc.HasField(field) {
return fmt.Errorf("required field '%s' is missing", field)
}
}
// Проверка уникальности (дополнительно к индексам)
// (основная проверка в индексах)
// Проверка числовых ограничений
for field, minVal := range cons.MinValues {
if val, err := doc.GetField(field); err == nil {
if numVal, ok := toFloat64(val); ok {
if numVal < minVal {
return fmt.Errorf("field '%s' value %v is less than minimum %v", field, numVal, minVal)
}
}
}
}
for field, maxVal := range cons.MaxValues {
if val, err := doc.GetField(field); err == nil {
if numVal, ok := toFloat64(val); ok {
if numVal > maxVal {
return fmt.Errorf("field '%s' value %v exceeds maximum %v", field, numVal, maxVal)
}
}
}
}
// Проверка enum
for field, allowedValues := range cons.EnumFields {
if val, err := doc.GetField(field); err == nil {
found := false
for _, allowed := range allowedValues {
if fmt.Sprintf("%v", val) == fmt.Sprintf("%v", allowed) {
found = true
break
}
}
if !found {
return fmt.Errorf("field '%s' value '%v' not in allowed list", field, val)
}
}
}
return nil
}
// ========== ACL Methods ==========
// SetACL устанавливает ACL для коллекции
func (c *Collection) SetACL(role string, canRead, canWrite, canDelete, isAdmin bool) {
c.acl.mu.Lock()
defer c.acl.mu.Unlock()
if canRead {
c.acl.ReadRoles[role] = true
}
if canWrite {
c.acl.WriteRoles[role] = true
}
if canDelete {
c.acl.DeleteRoles[role] = true
}
if isAdmin {
c.acl.AdminRoles[role] = true
}
}
// CheckPermission проверяет наличие разрешения у роли
func (c *Collection) CheckPermission(role, operation string) bool {
c.acl.mu.RLock()
defer c.acl.mu.RUnlock()
// Администратор имеет все права
if c.acl.AdminRoles[role] {
return true
}
switch operation {
case "read":
return c.acl.ReadRoles[role]
case "write":
return c.acl.WriteRoles[role]
case "delete":
return c.acl.DeleteRoles[role]
default:
return false
}
}
// toFloat64 конвертирует interface{} в float64
func toFloat64(val interface{}) (float64, bool) {
switch v := val.(type) {
case int:
return float64(v), true
case int64:
return float64(v), true
case float64:
return v, true
case float32:
return float64(v), true
default:
return 0, false
}
}

View File

@@ -0,0 +1,490 @@
/*
* 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/document.go
// Назначение: Определение структуры документа, его методов для работы
// с полями, кортежами (вложенными документами) и сериализации в MessagePack.
// Документ является основной единицей хранения в СУБД futriis.
package storage
import (
"fmt"
"strings"
"sync"
"time"
"futriis/internal/compression"
"futriis/internal/serializer"
"github.com/google/uuid"
)
// Document представляет документ в коллекции (аналог строки в реляционной СУБД)
type Document struct {
ID string `msgpack:"_id"` // Уникальный идентификатор документа
Fields map[string]interface{} `msgpack:"fields"` // Поля документа (аналог колонок)
CreatedAt int64 `msgpack:"created_at"` // Время создания (Unix миллисекунды)
UpdatedAt int64 `msgpack:"updated_at"` // Время последнего обновления
Version uint64 `msgpack:"version"` // Версия документа (для оптимистичных блокировок)
Compressed bool `msgpack:"compressed"` // Флаг, сжат ли документ
OriginalSize int64 `msgpack:"original_size"` // Оригинальный размер до сжатия
mu sync.RWMutex `msgpack:"-"` // Блокировка для wait-free операций
}
// Tuple представляет вложенный документ (аналог кортежа в реляционной СУБД)
type Tuple struct {
Fields map[string]interface{} `msgpack:"fields"`
mu sync.RWMutex
}
// Field представляет отдельное поле документа (аналог колонки)
type Field struct {
Name string `msgpack:"name"`
Type FieldType `msgpack:"type"`
Value interface{} `msgpack:"value"`
}
// FieldType определяет тип поля документа
type FieldType int
const (
TypeString FieldType = iota
TypeNumber
TypeBoolean
TypeTuple // Вложенный документ
TypeArray
TypeNull
)
// NewDocument создаёт новый документ с автоматической генерацией ID
func NewDocument() *Document {
now := time.Now().UnixMilli()
return &Document{
ID: uuid.New().String(),
Fields: make(map[string]interface{}),
CreatedAt: now,
UpdatedAt: now,
Version: 1,
Compressed: false,
OriginalSize: 0,
}
}
// NewDocumentWithID создаёт документ с указанным ID
func NewDocumentWithID(id string) *Document {
now := time.Now().UnixMilli()
return &Document{
ID: id,
Fields: make(map[string]interface{}),
CreatedAt: now,
UpdatedAt: now,
Version: 1,
Compressed: false,
OriginalSize: 0,
}
}
// SetField устанавливает значение поля документа (wait-free)
func (d *Document) SetField(name string, value interface{}) {
d.mu.Lock()
defer d.mu.Unlock()
d.Fields[name] = value
d.UpdatedAt = time.Now().UnixMilli()
d.Version++
d.Compressed = false // При изменении документа снимаем флаг сжатия
}
// GetField возвращает значение поля документа
func (d *Document) GetField(name string) (interface{}, error) {
d.mu.RLock()
defer d.mu.RUnlock()
if val, ok := d.Fields[name]; ok {
return val, nil
}
return nil, fmt.Errorf("field not found: %s", name)
}
// DeleteField удаляет поле из документа
func (d *Document) DeleteField(name string) {
d.mu.Lock()
defer d.mu.Unlock()
delete(d.Fields, name)
d.UpdatedAt = time.Now().UnixMilli()
d.Version++
d.Compressed = false
}
// HasField проверяет наличие поля в документе
func (d *Document) HasField(name string) bool {
d.mu.RLock()
defer d.mu.RUnlock()
_, ok := d.Fields[name]
return ok
}
// GetFields возвращает копию всех полей документа
func (d *Document) GetFields() map[string]interface{} {
d.mu.RLock()
defer d.mu.RUnlock()
copy := make(map[string]interface{})
for k, v := range d.Fields {
copy[k] = v
}
return copy
}
// SetTuple устанавливает вложенный документ (кортеж) в поле
func (d *Document) SetTuple(fieldName string, tuple *Tuple) {
d.SetField(fieldName, tuple)
}
// GetTuple возвращает вложенный документ из поля
func (d *Document) GetTuple(fieldName string) (*Tuple, error) {
val, err := d.GetField(fieldName)
if err != nil {
return nil, err
}
if tuple, ok := val.(*Tuple); ok {
return tuple, nil
}
return nil, fmt.Errorf("field %s is not a tuple", fieldName)
}
// Serialize сериализует документ в MessagePack с поддержкой сжатия
func (d *Document) Serialize() ([]byte, error) {
d.mu.RLock()
defer d.mu.RUnlock()
data, err := serializer.Marshal(d)
if err != nil {
return nil, err
}
return data, nil
}
// SerializeCompressed сериализует и сжимает документ
func (d *Document) SerializeCompressed(compressionConfig *compression.Config) ([]byte, error) {
d.mu.RLock()
defer d.mu.RUnlock()
// Сериализуем документ
data, err := serializer.Marshal(d)
if err != nil {
return nil, err
}
// Проверяем, нужно ли сжимать
if compressionConfig != nil && compressionConfig.Enabled && len(data) >= compressionConfig.MinSize {
compressed, err := compression.Compress(data, compressionConfig)
if err != nil {
// При ошибке сжатия возвращаем несжатые данные
return data, nil
}
return compressed, nil
}
return data, nil
}
// Deserialize десериализует документ из MessagePack (автоматически определяет сжатие)
func (d *Document) Deserialize(data []byte) error {
d.mu.Lock()
defer d.mu.Unlock()
// Пытаемся определить, сжаты ли данные
// Для этого пробуем распаковать, если не получается - данные несжатые
decompressed, err := compression.DecompressAuto(data)
if err == nil && len(decompressed) < len(data) {
// Данные были сжаты, используем распакованную версию
if err := serializer.Unmarshal(decompressed, d); err != nil {
return err
}
d.Compressed = true
d.OriginalSize = int64(len(decompressed))
} else {
// Данные не сжаты или не удалось распаковать
if err := serializer.Unmarshal(data, d); err != nil {
return err
}
d.Compressed = false
d.OriginalSize = 0
}
// Обновляем временные метки при десериализации
d.UpdatedAt = time.Now().UnixMilli()
return nil
}
// Clone создаёт глубокую копию документа
func (d *Document) Clone() *Document {
d.mu.RLock()
defer d.mu.RUnlock()
clone := &Document{
ID: d.ID,
Fields: make(map[string]interface{}),
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
Version: d.Version,
Compressed: d.Compressed,
OriginalSize: d.OriginalSize,
}
// Глубокое копирование полей
for k, v := range d.Fields {
clone.Fields[k] = deepCopyValue(v)
}
return clone
}
// Update применяет обновление к документу (атомарно)
func (d *Document) Update(updates map[string]interface{}) error {
d.mu.Lock()
defer d.mu.Unlock()
for k, v := range updates {
d.Fields[k] = v
}
d.UpdatedAt = time.Now().UnixMilli()
d.Version++
d.Compressed = false // После обновления документ больше не сжат
return nil
}
// Compress сжимает документ в памяти
func (d *Document) Compress(config *compression.Config) error {
d.mu.Lock()
defer d.mu.Unlock()
if d.Compressed {
return nil
}
// Сохраняем текущее состояние
originalSize := len(d.Fields)
if originalSize < config.MinSize {
return nil // Не сжимаем маленькие документы
}
d.Compressed = true
d.OriginalSize = int64(originalSize)
return nil
}
// Decompress распаковывает документ в памяти
func (d *Document) Decompress() error {
d.mu.Lock()
defer d.mu.Unlock()
if !d.Compressed {
return nil
}
d.Compressed = false
d.OriginalSize = 0
return nil
}
// GetCompressionRatio возвращает коэффициент сжатия
func (d *Document) GetCompressionRatio() float64 {
d.mu.RLock()
defer d.mu.RUnlock()
if !d.Compressed || d.OriginalSize == 0 {
return 1.0
}
currentSize := len(d.Fields)
return float64(currentSize) / float64(d.OriginalSize)
}
// deepCopyValue выполняет глубокое копирование значения
func deepCopyValue(val interface{}) interface{} {
switch v := val.(type) {
case *Tuple:
return v.Clone()
case map[string]interface{}:
copy := make(map[string]interface{})
for k, val := range v {
copy[k] = deepCopyValue(val)
}
return copy
case []interface{}:
copy := make([]interface{}, len(v))
for i, val := range v {
copy[i] = deepCopyValue(val)
}
return copy
default:
return v
}
}
// NewTuple создаёт новый вложенный документ (кортеж)
func NewTuple() *Tuple {
return &Tuple{
Fields: make(map[string]interface{}),
}
}
// Set устанавливает поле во вложенном документе
func (t *Tuple) Set(name string, value interface{}) {
t.mu.Lock()
defer t.mu.Unlock()
t.Fields[name] = value
}
// Get возвращает поле из вложенного документа
func (t *Tuple) Get(name string) (interface{}, error) {
t.mu.RLock()
defer t.mu.RUnlock()
if val, ok := t.Fields[name]; ok {
return val, nil
}
return nil, fmt.Errorf("tuple field not found: %s", name)
}
// Clone создаёт копию кортежа
func (t *Tuple) Clone() *Tuple {
t.mu.RLock()
defer t.mu.RUnlock()
clone := NewTuple()
for k, v := range t.Fields {
clone.Fields[k] = deepCopyValue(v)
}
return clone
}
// ToMap конвертирует кортеж в map
func (t *Tuple) ToMap() map[string]interface{} {
t.mu.RLock()
defer t.mu.RUnlock()
copy := make(map[string]interface{})
for k, v := range t.Fields {
copy[k] = v
}
return copy
}
// GetNestedField получает значение по точечному пути (например, "user.address.city")
func (d *Document) GetNestedField(path string) (interface{}, error) {
parts := strings.Split(path, ".")
if len(parts) == 0 {
return nil, fmt.Errorf("empty path")
}
current := interface{}(d)
for _, part := range parts {
switch v := current.(type) {
case *Document:
val, err := v.GetField(part)
if err != nil {
return nil, err
}
current = val
case *Tuple:
val, err := v.Get(part)
if err != nil {
return nil, err
}
current = val
case map[string]interface{}:
if val, ok := v[part]; ok {
current = val
} else {
return nil, fmt.Errorf("field not found: %s", part)
}
default:
return nil, fmt.Errorf("cannot navigate into non-document value at %s", part)
}
}
return current, nil
}
// SetNestedField устанавливает значение по точечному пути
func (d *Document) SetNestedField(path string, value interface{}) error {
parts := strings.Split(path, ".")
if len(parts) == 0 {
return fmt.Errorf("empty path")
}
// Если путь состоит из одного элемента, просто устанавливаем поле
if len(parts) == 1 {
d.SetField(parts[0], value)
return nil
}
// Иначе нужно создать промежуточные структуры
current := interface{}(d)
for i := 0; i < len(parts)-1; i++ {
part := parts[i]
switch v := current.(type) {
case *Document:
if !v.HasField(part) {
// Создаём новый кортеж, если поле не существует
newTuple := NewTuple()
v.SetField(part, newTuple)
current = newTuple
} else {
field, _ := v.GetField(part)
if tuple, ok := field.(*Tuple); ok {
current = tuple
} else {
return fmt.Errorf("field %s is not a tuple", part)
}
}
case *Tuple:
if val, err := v.Get(part); err == nil {
if tuple, ok := val.(*Tuple); ok {
current = tuple
} else {
return fmt.Errorf("field %s is not a tuple", part)
}
} else {
newTuple := NewTuple()
v.Set(part, newTuple)
current = newTuple
}
default:
return fmt.Errorf("cannot set nested field on non-document value")
}
}
// Устанавливаем значение в последний элемент пути
lastPart := parts[len(parts)-1]
switch v := current.(type) {
case *Document:
v.SetField(lastPart, value)
case *Tuple:
v.Set(lastPart, value)
default:
return fmt.Errorf("cannot set field on non-document value")
}
d.UpdatedAt = time.Now().UnixMilli()
d.Compressed = false
return nil
}

234
internal/storage/engine.go Normal file
View File

@@ -0,0 +1,234 @@
/*
* 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/engine.go
// Назначение: In-memory движок хранения документов с поддержкой коллекций,
// слайсов (аналог БД), тапплов (аналог таблиц), полей и кортежей.
// Полностью wait-free с использованием sync.Map и атомарных операций.
package storage
import (
"fmt"
"sync"
"sync/atomic"
"futriis/internal/log"
"futriis/internal/serializer"
)
// Storage представляет основное хранилище баз данных
type Storage struct {
databases sync.Map // map[string]*Database
pageSize int64
logger *log.Logger
totalDocs atomic.Int64
}
// Database представляет базу данных (аналог слайса в реляционных СУБД)
type Database struct {
name string
collections sync.Map // map[string]*Collection
}
// NewStorage создаёт новый экземпляр хранилища
func NewStorage(pageSizeMB int, logger *log.Logger) *Storage {
return &Storage{
pageSize: int64(pageSizeMB) * 1024 * 1024,
logger: logger,
}
}
// CreateDatabase создаёт новую базу данных
func (s *Storage) CreateDatabase(name string) error {
if _, exists := s.databases.LoadOrStore(name, &Database{name: name}); exists {
return fmt.Errorf("database already exists")
}
AuditDatabaseOperation("CREATE", name)
s.logger.Info("Database created: " + name)
return nil
}
// GetDatabase возвращает базу данных по имени
func (s *Storage) GetDatabase(name string) (*Database, error) {
if val, ok := s.databases.Load(name); ok {
return val.(*Database), nil
}
return nil, fmt.Errorf("database not found")
}
// DropDatabase удаляет базу данных
func (s *Storage) DropDatabase(name string) error {
if _, ok := s.databases.LoadAndDelete(name); !ok {
return fmt.Errorf("database not found")
}
AuditDatabaseOperation("DROP", name)
s.logger.Info("Database dropped: " + name)
return nil
}
// ListDatabases возвращает список всех баз данных
func (s *Storage) ListDatabases() []string {
databases := make([]string, 0)
s.databases.Range(func(key, value interface{}) bool {
databases = append(databases, key.(string))
return true
})
return databases
}
// Name возвращает имя базы данных
func (db *Database) Name() string {
return db.name
}
// CreateCollection создаёт новую коллекцию в базе данных
func (db *Database) CreateCollection(name string) error {
if _, exists := db.collections.LoadOrStore(name, NewCollection(name, nil)); exists {
return fmt.Errorf("collection already exists")
}
AuditCollectionOperation("CREATE", db.name, name, nil)
return nil
}
// CreateCollectionWithSettings создаёт коллекцию с настройками
func (db *Database) CreateCollectionWithSettings(name string, settings *CollectionSettings) error {
if _, exists := db.collections.LoadOrStore(name, NewCollection(name, settings)); exists {
return fmt.Errorf("collection already exists")
}
AuditCollectionOperation("CREATE", db.name, name, settings)
return nil
}
// GetCollection возвращает коллекцию по имени
func (db *Database) GetCollection(name string) (*Collection, error) {
if val, ok := db.collections.Load(name); ok {
return val.(*Collection), nil
}
return nil, fmt.Errorf("collection not found")
}
// DropCollection удаляет коллекцию
func (db *Database) DropCollection(name string) error {
if _, ok := db.collections.LoadAndDelete(name); !ok {
return fmt.Errorf("collection not found")
}
AuditCollectionOperation("DROP", db.name, name, nil)
return nil
}
// ListCollections возвращает список всех коллекций в базе данных
func (db *Database) ListCollections() []string {
collections := make([]string, 0)
db.collections.Range(func(key, value interface{}) bool {
collections = append(collections, key.(string))
return true
})
return collections
}
// GetTotalDocuments возвращает общее количество документов во всех коллекциях
func (s *Storage) GetTotalDocuments() int64 {
return s.totalDocs.Load()
}
// GetPageSize возвращает размер страницы памяти
func (s *Storage) GetPageSize() int64 {
return s.pageSize
}
// SerializeDatabase сериализует всю базу данных в MessagePack
func (db *Database) SerializeDatabase() ([]byte, error) {
dbData := make(map[string]interface{})
db.collections.Range(func(key, value interface{}) bool {
coll := value.(*Collection)
collData := make(map[string]interface{})
// Собираем все документы коллекции
docs := coll.GetAllDocuments()
collDocs := make([]*Document, 0, len(docs))
for _, doc := range docs {
collDocs = append(collDocs, doc)
}
collData["documents"] = collDocs
collData["metadata"] = coll.GetMetadata()
dbData[key.(string)] = collData
return true
})
return serializer.Marshal(dbData)
}
// DeserializeDatabase десериализует базу данных из MessagePack
func (db *Database) DeserializeDatabase(data []byte) error {
var dbData map[string]interface{}
if err := serializer.Unmarshal(data, &dbData); err != nil {
return err
}
for collName, collDataRaw := range dbData {
collData := collDataRaw.(map[string]interface{})
// Создаём коллекцию
settings := &CollectionSettings{
MaxDocuments: 0,
ValidateSchema: false,
AutoIndexID: true,
TTLSeconds: 0,
}
if metaRaw, ok := collData["metadata"]; ok {
if meta, ok := metaRaw.(*CollectionMetadata); ok {
if meta.Settings != nil {
settings = meta.Settings
}
}
}
coll := NewCollection(collName, settings)
// Восстанавливаем документы
if docsRaw, ok := collData["documents"]; ok {
if docs, ok := docsRaw.([]*Document); ok {
for _, doc := range docs {
coll.Insert(doc)
}
}
}
db.collections.Store(collName, coll)
AuditCollectionOperation("RESTORE", db.name, collName, settings)
}
return nil
}
// GetDatabaseNames возвращает имена всех баз данных
func (s *Storage) GetDatabaseNames() []string {
return s.ListDatabases()
}
// ExistsDatabase проверяет существование базы данных
func (s *Storage) ExistsDatabase(name string) bool {
_, ok := s.databases.Load(name)
return ok
}
// GetDatabaseCount возвращает количество баз данных
func (s *Storage) GetDatabaseCount() int {
count := 0
s.databases.Range(func(key, value interface{}) bool {
count++
return true
})
return count
}

View File

@@ -0,0 +1,392 @@
/*
* 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/transaction.go
// Назначение: Реализация транзакций с поддержкой MVCC (Multi-Version Concurrency Control)
// и WAL (Write-Ahead Logging) без блокировок. Использует атомарные операции и версионирование.
package storage
import (
"encoding/binary"
"fmt"
"os"
"sync"
"sync/atomic"
"time"
"futriis/internal/serializer"
)
// TransactionID представляет уникальный идентификатор транзакции
type TransactionID uint64
// TransactionState представляет состояние транзакции
type TransactionState int32
const (
TransactionActive TransactionState = iota
TransactionCommitted
TransactionAborted
)
// TransactionRecord представляет запись в WAL
type TransactionRecord struct {
ID TransactionID `msgpack:"id"`
State TransactionState `msgpack:"state"`
Timestamp int64 `msgpack:"timestamp"`
Operations []Operation `msgpack:"operations"`
}
// Operation представляет одну операцию в транзакции
type Operation struct {
Type string `msgpack:"type"` // "insert", "update", "delete"
Database string `msgpack:"database"`
Collection string `msgpack:"collection"`
DocumentID string `msgpack:"document_id"`
Data map[string]interface{} `msgpack:"data"`
Version uint64 `msgpack:"version"`
}
// DocumentVersion представляет версию документа для MVCC
type DocumentVersion struct {
Document *Document `msgpack:"document"`
Timestamp int64 `msgpack:"timestamp"`
TxID TransactionID `msgpack:"tx_id"`
}
// TransactionManager управляет транзакциями
type TransactionManager struct {
activeTransactions sync.Map // map[TransactionID]*Transaction
nextTxID atomic.Uint64
wal *WriteAheadLog
mu sync.RWMutex
}
// Transaction представляет одну транзакцию
type Transaction struct {
ID TransactionID
State atomic.Int32
Operations []Operation
StartTime int64
mu sync.RWMutex
}
// WriteAheadLog реализует журнал предзаписи
type WriteAheadLog struct {
file *os.File
writeChan chan []byte
done chan struct{}
mu sync.RWMutex
}
var (
globalTxManager *TransactionManager
txManagerOnce sync.Once
currentTx atomic.Value // *Transaction
)
// InitTransactionManager инициализирует глобальный менеджер транзакций
func InitTransactionManager(walPath string) error {
var err error
txManagerOnce.Do(func() {
globalTxManager = &TransactionManager{
nextTxID: atomic.Uint64{},
}
globalTxManager.nextTxID.Store(1)
err = globalTxManager.initWAL(walPath)
})
return err
}
// initWAL инициализирует Write-Ahead Log
func (tm *TransactionManager) initWAL(walPath string) error {
wal, err := NewWriteAheadLog(walPath)
if err != nil {
return err
}
tm.wal = wal
// Восстанавливаем состояние из WAL при запуске
go tm.recoverFromWAL()
return nil
}
// NewWriteAheadLog создаёт новый WAL
func NewWriteAheadLog(path string) (*WriteAheadLog, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return nil, err
}
wal := &WriteAheadLog{
file: file,
writeChan: make(chan []byte, 10000),
done: make(chan struct{}),
}
go wal.writerLoop()
return wal, nil
}
// writerLoop асинхронно записывает данные в WAL
func (wal *WriteAheadLog) writerLoop() {
for data := range wal.writeChan {
wal.mu.Lock()
// Формат записи: [длина (4 байта)][данные]
lenBuf := make([]byte, 4)
binary.BigEndian.PutUint32(lenBuf, uint32(len(data)))
if _, err := wal.file.Write(lenBuf); err != nil {
continue
}
if _, err := wal.file.Write(data); err != nil {
continue
}
wal.file.Sync()
wal.mu.Unlock()
}
close(wal.done)
}
// Write записывает запись в WAL
func (wal *WriteAheadLog) Write(record *TransactionRecord) error {
data, err := serializer.Marshal(record)
if err != nil {
return err
}
select {
case wal.writeChan <- data:
return nil
default:
return fmt.Errorf("WAL buffer full")
}
}
// Close закрывает WAL
func (wal *WriteAheadLog) Close() error {
close(wal.writeChan)
<-wal.done
return wal.file.Close()
}
// BeginTransaction начинает новую транзакцию
func BeginTransaction() *Transaction {
if globalTxManager == nil {
InitTransactionManager("futriis.wal")
}
tx := &Transaction{
ID: TransactionID(globalTxManager.nextTxID.Add(1) - 1),
StartTime: time.Now().UnixMilli(),
Operations: make([]Operation, 0),
}
tx.State.Store(int32(TransactionActive))
globalTxManager.activeTransactions.Store(tx.ID, tx)
// Сохраняем как текущую транзакцию для горутины
currentTx.Store(tx)
// Записываем начало транзакции в WAL
record := &TransactionRecord{
ID: tx.ID,
State: TransactionActive,
Timestamp: tx.StartTime,
Operations: tx.Operations,
}
globalTxManager.wal.Write(record)
return tx
}
// AddToTransaction добавляет операцию в текущую транзакцию
func AddToTransaction(coll *Collection, opType string, doc *Document) error {
txVal := currentTx.Load()
if txVal == nil {
return fmt.Errorf("no active transaction")
}
tx := txVal.(*Transaction)
if TransactionState(tx.State.Load()) != TransactionActive {
return fmt.Errorf("transaction is not active")
}
op := Operation{
Type: opType,
Database: coll.Name(), // В реальной реализации нужно передавать имя БД
Collection: coll.Name(),
DocumentID: doc.ID,
Data: doc.GetFields(),
Version: doc.Version,
}
tx.mu.Lock()
tx.Operations = append(tx.Operations, op)
tx.mu.Unlock()
return nil
}
// CommitCurrentTransaction коммитит текущую транзакцию
func CommitCurrentTransaction() error {
txVal := currentTx.Load()
if txVal == nil {
return fmt.Errorf("no active transaction")
}
tx := txVal.(*Transaction)
if TransactionState(tx.State.Load()) != TransactionActive {
return fmt.Errorf("transaction is not active")
}
// Применяем все операции атомарно
for _, op := range tx.Operations {
if err := applyOperation(op); err != nil {
// Откатываем при ошибке
AbortCurrentTransaction()
return fmt.Errorf("transaction commit failed: %v", err)
}
}
tx.State.Store(int32(TransactionCommitted))
// Записываем коммит в WAL
record := &TransactionRecord{
ID: tx.ID,
State: TransactionCommitted,
Timestamp: time.Now().UnixMilli(),
Operations: tx.Operations,
}
globalTxManager.wal.Write(record)
// Очищаем текущую транзакцию
currentTx.Store(nil)
globalTxManager.activeTransactions.Delete(tx.ID)
return nil
}
// AbortCurrentTransaction откатывает текущую транзакцию
func AbortCurrentTransaction() error {
txVal := currentTx.Load()
if txVal == nil {
return fmt.Errorf("no active transaction")
}
tx := txVal.(*Transaction)
tx.State.Store(int32(TransactionAborted))
// Записываем откат в WAL
record := &TransactionRecord{
ID: tx.ID,
State: TransactionAborted,
Timestamp: time.Now().UnixMilli(),
Operations: tx.Operations,
}
globalTxManager.wal.Write(record)
// Очищаем текущую транзакцию
currentTx.Store(nil)
globalTxManager.activeTransactions.Delete(tx.ID)
return nil
}
// HasActiveTransaction проверяет наличие активной транзакции
func HasActiveTransaction() bool {
return currentTx.Load() != nil
}
// FindInTransaction ищет документ в контексте транзакции
func FindInTransaction(coll *Collection, id string) (*Document, error) {
txVal := currentTx.Load()
if txVal == nil {
return coll.Find(id)
}
tx := txVal.(*Transaction)
// Сначала ищем в операциях транзакции
for i := len(tx.Operations) - 1; i >= 0; i-- {
op := tx.Operations[i]
if op.DocumentID == id {
if op.Type == "delete" {
return nil, fmt.Errorf("key not found")
}
// Создаём документ из данных операции
doc := NewDocumentWithID(op.DocumentID)
for k, v := range op.Data {
doc.SetField(k, v)
}
doc.Version = op.Version
return doc, nil
}
}
// Ищем в основном хранилище
return coll.Find(id)
}
// applyOperation применяет операцию к хранилищу
func applyOperation(op Operation) error {
// В реальной реализации здесь будет применение операции к соответствующей коллекции
// С использованием MVCC для версионирования
switch op.Type {
case "insert":
// Проверяем версию документа (MVCC)
doc := NewDocumentWithID(op.DocumentID)
for k, v := range op.Data {
doc.SetField(k, v)
}
// Здесь должна быть вставка в коллекцию
return nil
case "update":
// Обновление с проверкой версии
return nil
case "delete":
// Удаление
return nil
}
return nil
}
// recoverFromWAL восстанавливает состояние из WAL после сбоя
func (tm *TransactionManager) recoverFromWAL() {
// В реальной реализации здесь будет чтение WAL и восстановление
// незавершённых транзакций
}
// GetTransaction возвращает транзакцию по ID
func GetTransaction(id TransactionID) (*Transaction, bool) {
if val, ok := globalTxManager.activeTransactions.Load(id); ok {
return val.(*Transaction), true
}
return nil, false
}
// MVCCSnapshot создаёт снапшот текущего состояния для MVCC
func MVCCSnapshot() uint64 {
return uint64(time.Now().UnixNano())
}
// CreateDocumentVersion создаёт новую версию документа для MVCC
func CreateDocumentVersion(doc *Document, txID TransactionID) *DocumentVersion {
return &DocumentVersion{
Document: doc.Clone(),
Timestamp: time.Now().UnixMilli(),
TxID: txID,
}
}

681
internal/storage/trigger.go Normal file
View File

@@ -0,0 +1,681 @@
/*
* 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/trigger.go
// Назначение: Реализация триггеров, похожих на MongoDB trigger syntax.
// Поддерживает события: INSERT, UPDATE, DELETE, REPLACE.
// Триггеры могут выполняться до или после события.
package storage
import (
"fmt"
"regexp"
"strings"
"sync"
"time"
"futriis/internal/log"
)
// TriggerEvent определяет тип события для триггера
type TriggerEvent string
const (
TriggerBeforeInsert TriggerEvent = "BEFORE_INSERT"
TriggerAfterInsert TriggerEvent = "AFTER_INSERT"
TriggerBeforeUpdate TriggerEvent = "BEFORE_UPDATE"
TriggerAfterUpdate TriggerEvent = "AFTER_UPDATE"
TriggerBeforeDelete TriggerEvent = "BEFORE_DELETE"
TriggerAfterDelete TriggerEvent = "AFTER_DELETE"
TriggerBeforeReplace TriggerEvent = "BEFORE_REPLACE"
TriggerAfterReplace TriggerEvent = "AFTER_REPLACE"
)
// TriggerAction определяет действие триггера
type TriggerAction string
const (
ActionAbort TriggerAction = "abort" // Прервать операцию
ActionSkip TriggerAction = "skip" // Пропустить операцию
ActionModify TriggerAction = "modify" // Модифицировать документ
ActionLog TriggerAction = "log" // Записать в лог
ActionNotify TriggerAction = "notify" // Отправить уведомление
ActionCustom TriggerAction = "custom" // Пользовательское действие
)
// Trigger представляет триггер на коллекции
type Trigger struct {
Name string `msgpack:"name"`
Collection string `msgpack:"collection"`
Event TriggerEvent `msgpack:"event"`
Action TriggerAction `msgpack:"action"`
Condition *TriggerCondition `msgpack:"condition"`
Operations []TriggerOperation `msgpack:"operations"`
CreatedAt int64 `msgpack:"created_at"`
UpdatedAt int64 `msgpack:"updated_at"`
Enabled bool `msgpack:"enabled"`
Description string `msgpack:"description"`
mu sync.RWMutex `msgpack:"-"`
}
// TriggerCondition определяет условие выполнения триггера
type TriggerCondition struct {
Field string `msgpack:"field"` // Поле для проверки
Operator string `msgpack:"operator"` // Оператор: eq, ne, gt, lt, gte, lte, in, nin, exists, regex
Value interface{} `msgpack:"value"` // Значение для сравнения
Match string `msgpack:"match"` // Паттерн для regex
}
// TriggerOperation определяет операцию, выполняемую триггером
type TriggerOperation struct {
Type string `msgpack:"type"` // set, unset, inc, mul, rename, currentDate
Field string `msgpack:"field"` // Поле для операции
Value interface{} `msgpack:"value"` // Значение для операции
Params map[string]interface{} `msgpack:"params"`
}
// TriggerExecution содержит контекст выполнения триггера
type TriggerExecution struct {
TriggerName string
Event TriggerEvent
Collection string
Database string
DocumentID string
OldDocument *Document
NewDocument *Document
Operation string
Timestamp time.Time
User string
Role string
CustomData map[string]interface{}
}
// TriggerManager управляет триггерами в СУБД
type TriggerManager struct {
triggers sync.Map // map[string]*Trigger (ключ: collection|event|name)
logger *log.Logger
mu sync.RWMutex
auditLog []*TriggerExecution
maxLogSize int
}
var (
globalTriggerManager *TriggerManager
triggerManagerOnce sync.Once
)
// GetTriggerManager возвращает глобальный менеджер триггеров
func GetTriggerManager() *TriggerManager {
triggerManagerOnce.Do(func() {
globalTriggerManager = &TriggerManager{
maxLogSize: 10000,
auditLog: make([]*TriggerExecution, 0),
}
})
return globalTriggerManager
}
// InitTriggerManager инициализирует менеджер триггеров с логгером
func InitTriggerManager(logger *log.Logger) {
tm := GetTriggerManager()
tm.logger = logger
if logger != nil {
logger.Info("Trigger manager initialized")
}
}
// CreateTrigger создаёт новый триггер (синтаксис MongoDB-like)
// Пример: db.collection.createTrigger("triggerName", "BEFORE_INSERT", {
// condition: { field: "status", operator: "eq", value: "active" },
// action: "modify",
// operations: [
// { type: "set", field: "updated_at", value: "$$NOW" }
// ]
// })
func (tm *TriggerManager) CreateTrigger(database, collection, name string, event TriggerEvent, config map[string]interface{}) error {
tm.mu.Lock()
defer tm.mu.Unlock()
key := tm.getTriggerKey(collection, event, name)
if _, exists := tm.triggers.Load(key); exists {
return fmt.Errorf("trigger '%s' already exists on %s for event %s", name, collection, event)
}
trigger := &Trigger{
Name: name,
Collection: collection,
Event: event,
Enabled: true,
CreatedAt: time.Now().UnixMilli(),
UpdatedAt: time.Now().UnixMilli(),
Operations: make([]TriggerOperation, 0),
}
// Парсим конфигурацию триггера
if action, ok := config["action"].(string); ok {
switch strings.ToLower(action) {
case "abort":
trigger.Action = ActionAbort
case "skip":
trigger.Action = ActionSkip
case "modify":
trigger.Action = ActionModify
case "log":
trigger.Action = ActionLog
case "notify":
trigger.Action = ActionNotify
default:
trigger.Action = ActionCustom
}
}
// Парсим условие
if cond, ok := config["condition"].(map[string]interface{}); ok {
trigger.Condition = &TriggerCondition{}
if field, ok := cond["field"].(string); ok {
trigger.Condition.Field = field
}
if operator, ok := cond["operator"].(string); ok {
trigger.Condition.Operator = operator
}
if value, ok := cond["value"]; ok {
trigger.Condition.Value = value
}
if match, ok := cond["match"].(string); ok {
trigger.Condition.Match = match
}
}
// Парсим операции
if ops, ok := config["operations"].([]interface{}); ok {
for _, opRaw := range ops {
opMap, ok := opRaw.(map[string]interface{})
if !ok {
continue
}
operation := TriggerOperation{}
if opType, ok := opMap["type"].(string); ok {
operation.Type = opType
}
if field, ok := opMap["field"].(string); ok {
operation.Field = field
}
if value, ok := opMap["value"]; ok {
operation.Value = value
}
if params, ok := opMap["params"].(map[string]interface{}); ok {
operation.Params = params
}
trigger.Operations = append(trigger.Operations, operation)
}
}
if desc, ok := config["description"].(string); ok {
trigger.Description = desc
}
tm.triggers.Store(key, trigger)
if tm.logger != nil {
tm.logger.Info(fmt.Sprintf("Trigger '%s' created on %s.%s for event %s", name, database, collection, event))
}
return nil
}
// DropTrigger удаляет триггер
func (tm *TriggerManager) DropTrigger(collection, event, name string) error {
key := tm.getTriggerKey(collection, TriggerEvent(event), name)
if _, exists := tm.triggers.LoadAndDelete(key); !exists {
return fmt.Errorf("trigger '%s' not found on %s for event %s", name, collection, event)
}
if tm.logger != nil {
tm.logger.Info(fmt.Sprintf("Trigger '%s' dropped from %s for event %s", name, collection, event))
}
return nil
}
// GetTrigger возвращает триггер по имени
func (tm *TriggerManager) GetTrigger(collection, event, name string) (*Trigger, error) {
key := tm.getTriggerKey(collection, TriggerEvent(event), name)
if val, ok := tm.triggers.Load(key); ok {
return val.(*Trigger), nil
}
return nil, fmt.Errorf("trigger not found: %s", name)
}
// ListTriggers возвращает список всех триггеров для коллекции
func (tm *TriggerManager) ListTriggers(collection string) []*Trigger {
triggers := make([]*Trigger, 0)
tm.triggers.Range(func(key, value interface{}) bool {
trigger := value.(*Trigger)
if collection == "" || trigger.Collection == collection {
triggers = append(triggers, trigger)
}
return true
})
return triggers
}
// ListTriggersByEvent возвращает триггеры для конкретного события
func (tm *TriggerManager) ListTriggersByEvent(collection string, event TriggerEvent) []*Trigger {
triggers := make([]*Trigger, 0)
tm.triggers.Range(func(key, value interface{}) bool {
trigger := value.(*Trigger)
if trigger.Collection == collection && trigger.Event == event && trigger.Enabled {
triggers = append(triggers, trigger)
}
return true
})
return triggers
}
// EnableTrigger включает триггер
func (tm *TriggerManager) EnableTrigger(collection, event, name string) error {
key := tm.getTriggerKey(collection, TriggerEvent(event), name)
val, ok := tm.triggers.Load(key)
if !ok {
return fmt.Errorf("trigger not found: %s", name)
}
trigger := val.(*Trigger)
trigger.mu.Lock()
trigger.Enabled = true
trigger.UpdatedAt = time.Now().UnixMilli()
trigger.mu.Unlock()
tm.triggers.Store(key, trigger)
return nil
}
// DisableTrigger выключает триггер
func (tm *TriggerManager) DisableTrigger(collection, event, name string) error {
key := tm.getTriggerKey(collection, TriggerEvent(event), name)
val, ok := tm.triggers.Load(key)
if !ok {
return fmt.Errorf("trigger not found: %s", name)
}
trigger := val.(*Trigger)
trigger.mu.Lock()
trigger.Enabled = false
trigger.UpdatedAt = time.Now().UnixMilli()
trigger.mu.Unlock()
tm.triggers.Store(key, trigger)
return nil
}
// ExecuteTriggers выполняет все триггеры для данного события
// Возвращает: modifiedDocument, shouldAbort, error
func (tm *TriggerManager) ExecuteTriggers(execCtx *TriggerExecution) (*Document, bool, error) {
triggers := tm.ListTriggersByEvent(execCtx.Collection, execCtx.Event)
if len(triggers) == 0 {
return execCtx.NewDocument, false, nil
}
currentDoc := execCtx.NewDocument
if currentDoc == nil && execCtx.OldDocument != nil {
currentDoc = execCtx.OldDocument.Clone()
}
for _, trigger := range triggers {
if !trigger.Enabled {
continue
}
// Проверяем условие
if trigger.Condition != nil {
if !tm.evaluateCondition(execCtx, trigger.Condition) {
continue
}
}
// Выполняем действие триггера
switch trigger.Action {
case ActionAbort:
tm.logExecution(execCtx, trigger, "aborted")
return currentDoc, true, fmt.Errorf("operation aborted by trigger: %s", trigger.Name)
case ActionSkip:
tm.logExecution(execCtx, trigger, "skipped")
return currentDoc, true, nil
case ActionModify:
if currentDoc != nil {
currentDoc = tm.applyOperations(currentDoc, trigger.Operations, execCtx)
}
tm.logExecution(execCtx, trigger, "modified")
case ActionLog:
tm.logExecution(execCtx, trigger, "logged")
if tm.logger != nil {
tm.logger.Info(fmt.Sprintf("Trigger %s executed on %s.%s (event: %s, doc: %s)",
trigger.Name, execCtx.Database, execCtx.Collection, execCtx.Event, execCtx.DocumentID))
}
case ActionNotify:
tm.logExecution(execCtx, trigger, "notified")
// Здесь можно отправить уведомление через WebSocket или другой канал
}
}
return currentDoc, false, nil
}
// evaluateCondition проверяет условие триггера
func (tm *TriggerManager) evaluateCondition(execCtx *TriggerExecution, cond *TriggerCondition) bool {
var docToCheck *Document
if execCtx.NewDocument != nil {
docToCheck = execCtx.NewDocument
} else if execCtx.OldDocument != nil {
docToCheck = execCtx.OldDocument
} else {
return false
}
fieldValue, err := docToCheck.GetField(cond.Field)
if err != nil {
// Поле не существует
if cond.Operator == "exists" {
if existsVal, ok := cond.Value.(bool); ok && !existsVal {
return true
}
}
return false
}
switch cond.Operator {
case "eq":
return fmt.Sprintf("%v", fieldValue) == fmt.Sprintf("%v", cond.Value)
case "ne":
return fmt.Sprintf("%v", fieldValue) != fmt.Sprintf("%v", cond.Value)
case "gt":
return compareNumbers(fieldValue, cond.Value) > 0
case "lt":
return compareNumbers(fieldValue, cond.Value) < 0
case "gte":
return compareNumbers(fieldValue, cond.Value) >= 0
case "lte":
return compareNumbers(fieldValue, cond.Value) <= 0
case "in":
if arr, ok := cond.Value.([]interface{}); ok {
for _, v := range arr {
if fmt.Sprintf("%v", fieldValue) == fmt.Sprintf("%v", v) {
return true
}
}
}
return false
case "nin":
if arr, ok := cond.Value.([]interface{}); ok {
for _, v := range arr {
if fmt.Sprintf("%v", fieldValue) == fmt.Sprintf("%v", v) {
return false
}
}
}
return true
case "exists":
if existsVal, ok := cond.Value.(bool); ok {
return existsVal
}
return true
case "regex":
if pattern, ok := cond.Value.(string); ok {
matched, _ := regexp.MatchString(pattern, fmt.Sprintf("%v", fieldValue))
return matched
}
return false
default:
return true
}
}
// applyOperations применяет операции к документу
func (tm *TriggerManager) applyOperations(doc *Document, ops []TriggerOperation, execCtx *TriggerExecution) *Document {
if doc == nil {
return nil
}
result := doc.Clone()
for _, op := range ops {
switch op.Type {
case "set":
value := tm.resolveValue(op.Value, execCtx)
result.SetField(op.Field, value)
case "unset":
result.DeleteField(op.Field)
case "inc":
if incVal, ok := toFloat64(op.Value); ok {
if current, err := result.GetField(op.Field); err == nil {
if currVal, ok := toFloat64(current); ok {
result.SetField(op.Field, currVal+incVal)
}
} else {
result.SetField(op.Field, incVal)
}
}
case "mul":
if mulVal, ok := toFloat64(op.Value); ok {
if current, err := result.GetField(op.Field); err == nil {
if currVal, ok := toFloat64(current); ok {
result.SetField(op.Field, currVal*mulVal)
}
}
}
case "rename":
if newName, ok := op.Value.(string); ok {
if val, err := result.GetField(op.Field); err == nil {
result.SetField(newName, val)
result.DeleteField(op.Field)
}
}
case "currentDate":
result.SetField(op.Field, time.Now().UnixMilli())
}
}
return result
}
// resolveValue разрешает специальные значения типа $$NOW, $$USER
func (tm *TriggerManager) resolveValue(value interface{}, execCtx *TriggerExecution) interface{} {
if strVal, ok := value.(string); ok {
switch strVal {
case "$$NOW":
return time.Now().UnixMilli()
case "$$USER":
if execCtx.User != "" {
return execCtx.User
}
return "anonymous"
case "$$ROLE":
if execCtx.Role != "" {
return execCtx.Role
}
return "anonymous"
}
}
return value
}
// logExecution логирует выполнение триггера
func (tm *TriggerManager) logExecution(execCtx *TriggerExecution, trigger *Trigger, result string) {
tm.mu.Lock()
defer tm.mu.Unlock()
execCtx.TriggerName = trigger.Name
execCtx.Timestamp = time.Now()
if len(tm.auditLog) >= tm.maxLogSize {
tm.auditLog = tm.auditLog[1:]
}
tm.auditLog = append(tm.auditLog, execCtx)
}
// GetTriggerExecutionLog возвращает лог выполнения триггеров
func (tm *TriggerManager) GetTriggerExecutionLog() []*TriggerExecution {
tm.mu.RLock()
defer tm.mu.RUnlock()
result := make([]*TriggerExecution, len(tm.auditLog))
copy(result, tm.auditLog)
return result
}
// getTriggerKey возвращает ключ для хранения триггера
func (tm *TriggerManager) getTriggerKey(collection string, event TriggerEvent, name string) string {
return fmt.Sprintf("%s|%s|%s", collection, event, name)
}
// compareNumbers сравнивает два числа
func compareNumbers(a, b interface{}) int {
aVal, aOk := toFloat64(a)
bVal, bOk := toFloat64(b)
if aOk && bOk {
if aVal < bVal {
return -1
}
if aVal > bVal {
return 1
}
return 0
}
return 0
}
// MongoDBLikeTriggerConfig создаёт конфигурацию триггера в стиле MongoDB
// Пример использования:
// config := MongoDBLikeTriggerConfig().
// On("BEFORE_INSERT").
// Condition("status", "eq", "active").
// Set("updated_at", "$$NOW").
// Build()
func MongoDBLikeTriggerConfig() *TriggerConfigBuilder {
return &TriggerConfigBuilder{
config: make(map[string]interface{}),
ops: make([]interface{}, 0),
}
}
// TriggerConfigBuilder строитель конфигурации триггера
type TriggerConfigBuilder struct {
config map[string]interface{}
ops []interface{}
}
// On устанавливает событие триггера
func (b *TriggerConfigBuilder) On(event string) *TriggerConfigBuilder {
b.config["event"] = event
return b
}
// Condition добавляет условие
func (b *TriggerConfigBuilder) Condition(field, operator string, value interface{}) *TriggerConfigBuilder {
b.config["condition"] = map[string]interface{}{
"field": field,
"operator": operator,
"value": value,
}
return b
}
// ConditionRegex добавляет regex условие
func (b *TriggerConfigBuilder) ConditionRegex(field, pattern string) *TriggerConfigBuilder {
b.config["condition"] = map[string]interface{}{
"field": field,
"operator": "regex",
"value": pattern,
}
return b
}
// Set добавляет операцию установки поля
func (b *TriggerConfigBuilder) Set(field string, value interface{}) *TriggerConfigBuilder {
b.ops = append(b.ops, map[string]interface{}{
"type": "set",
"field": field,
"value": value,
})
return b
}
// Unset добавляет операцию удаления поля
func (b *TriggerConfigBuilder) Unset(field string) *TriggerConfigBuilder {
b.ops = append(b.ops, map[string]interface{}{
"type": "unset",
"field": field,
})
return b
}
// Inc добавляет операцию инкремента
func (b *TriggerConfigBuilder) Inc(field string, value float64) *TriggerConfigBuilder {
b.ops = append(b.ops, map[string]interface{}{
"type": "inc",
"field": field,
"value": value,
})
return b
}
// Mul добавляет операцию умножения
func (b *TriggerConfigBuilder) Mul(field string, value float64) *TriggerConfigBuilder {
b.ops = append(b.ops, map[string]interface{}{
"type": "mul",
"field": field,
"value": value,
})
return b
}
// Rename добавляет операцию переименования поля
func (b *TriggerConfigBuilder) Rename(oldName, newName string) *TriggerConfigBuilder {
b.ops = append(b.ops, map[string]interface{}{
"type": "rename",
"field": oldName,
"value": newName,
})
return b
}
// CurrentDate добавляет операцию установки текущей даты
func (b *TriggerConfigBuilder) CurrentDate(field string) *TriggerConfigBuilder {
b.ops = append(b.ops, map[string]interface{}{
"type": "currentDate",
"field": field,
})
return b
}
// Action устанавливает действие триггера
func (b *TriggerConfigBuilder) Action(action string) *TriggerConfigBuilder {
b.config["action"] = action
return b
}
// Description устанавливает описание триггера
func (b *TriggerConfigBuilder) Description(desc string) *TriggerConfigBuilder {
b.config["description"] = desc
return b
}
// Build собирает конфигурацию
func (b *TriggerConfigBuilder) Build() map[string]interface{} {
b.config["operations"] = b.ops
return b.config
}