mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-19 22:17:42 +02:00
Deep mode fires many create_asset calls at once. UpsertAssets used a DEFERRED transaction (SELECT then INSERT) on a 25-connection pool, so writers hit SQLITE_BUSY immediately and returned "database is locked" in a few milliseconds instead of waiting. Open SQLite with _txlock=immediate and a 30s busy timeout, shrink the pool, and retry locked upserts. Add a 32-way concurrent upsert test.
304 lines
9.5 KiB
Go
304 lines
9.5 KiB
Go
package database
|
||
|
||
import (
|
||
"database/sql"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
_ "github.com/mattn/go-sqlite3"
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
const (
|
||
// WAL 允许并发读,但同一时间仍只有一个写者。连接池过大时,Deep 模式并行
|
||
// create_asset 会各自 Begin+SELECT 再 INSERT,触发 SQLITE_BUSY(database is locked)
|
||
// 且 DEFERRED 事务升级写锁时可能立刻失败、不走 busy_timeout。
|
||
sqliteMaxOpenConns = 8
|
||
sqliteMaxIdleConns = 4
|
||
sqliteBusyTimeoutMS = 30000
|
||
// 以页为单位的自动 checkpoint 触发阈值(默认 1000 页,约 4MB @ 4KB/page)。
|
||
sqliteWALAutoCheckpointPages = 1000
|
||
// 控制 WAL 目标上限,避免异常场景持续膨胀(256MB)。
|
||
sqliteJournalSizeLimitBytes = 256 * 1024 * 1024
|
||
// 定时执行 PASSIVE checkpoint,平滑推进 WAL 回收。
|
||
sqlitePassiveCheckpointInterval = 300 * time.Second
|
||
)
|
||
|
||
func sqliteOpenDSN(path string) string {
|
||
return fmt.Sprintf("%s?_journal_mode=WAL&_foreign_keys=1&_busy_timeout=%d&_synchronous=NORMAL&_txlock=immediate", path, sqliteBusyTimeoutMS)
|
||
}
|
||
|
||
func isSQLiteBusy(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
msg := strings.ToLower(err.Error())
|
||
return strings.Contains(msg, "database is locked") || strings.Contains(msg, "sqlite_busy")
|
||
}
|
||
|
||
func retrySQLiteBusy(attempts int, op func() error) error {
|
||
if attempts < 1 {
|
||
attempts = 1
|
||
}
|
||
var err error
|
||
backoff := 5 * time.Millisecond
|
||
for i := 0; i < attempts; i++ {
|
||
err = op()
|
||
if err == nil || !isSQLiteBusy(err) {
|
||
return err
|
||
}
|
||
time.Sleep(backoff)
|
||
if backoff < 100*time.Millisecond {
|
||
backoff *= 2
|
||
}
|
||
}
|
||
return err
|
||
}
|
||
|
||
// configureDBPool 设置 SQLite 连接池:保留少量连接给 WAL 并发读,避免 25 路写连接互锁。
|
||
func configureDBPool(db *sql.DB) {
|
||
db.SetMaxOpenConns(sqliteMaxOpenConns)
|
||
db.SetMaxIdleConns(sqliteMaxIdleConns)
|
||
db.SetConnMaxLifetime(30 * time.Minute)
|
||
}
|
||
|
||
// configureSQLitePragmas 调整 WAL 回收行为,降低 -wal 文件长期膨胀风险。
|
||
func configureSQLitePragmas(db *sql.DB) error {
|
||
if _, err := db.Exec(fmt.Sprintf("PRAGMA wal_autocheckpoint=%d", sqliteWALAutoCheckpointPages)); err != nil {
|
||
return fmt.Errorf("设置 wal_autocheckpoint 失败: %w", err)
|
||
}
|
||
if _, err := db.Exec(fmt.Sprintf("PRAGMA journal_size_limit=%d", sqliteJournalSizeLimitBytes)); err != nil {
|
||
return fmt.Errorf("设置 journal_size_limit 失败: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DB 数据库连接
|
||
type DB struct {
|
||
*sql.DB
|
||
logger *zap.Logger
|
||
conversationArtifactsDir string
|
||
einoPlantaskBaseDir string // skills_dir + plantask_rel_dir (per-conversation subdirs)
|
||
einoCheckpointBaseDir string // checkpoint_dir root (per-conversation subdirs)
|
||
einoReductionRootDir string // reduction_root_dir or default tmp/reduction (conversations/<id> subdirs)
|
||
einoWorkspaceRootDir string // workspace_root_dir or default tmp/workspace (projects|conversations/<id> subdirs)
|
||
chatUploadsDir string // chat_uploads root (<date>/<conversationID> subdirs)
|
||
checkpointLoopName string
|
||
checkpointStop chan struct{}
|
||
checkpointDone chan struct{}
|
||
closeOnce sync.Once
|
||
closeErr error
|
||
vulnerabilityCreatedHook func(*Vulnerability)
|
||
}
|
||
|
||
// startPassiveCheckpointLoop 启动后台 PASSIVE checkpoint 循环。
|
||
func (db *DB) startPassiveCheckpointLoop(name string) {
|
||
if sqlitePassiveCheckpointInterval <= 0 || db == nil || db.DB == nil {
|
||
return
|
||
}
|
||
db.checkpointLoopName = strings.TrimSpace(name)
|
||
db.checkpointStop = make(chan struct{})
|
||
db.checkpointDone = make(chan struct{})
|
||
|
||
go func() {
|
||
defer close(db.checkpointDone)
|
||
ticker := time.NewTicker(sqlitePassiveCheckpointInterval)
|
||
defer ticker.Stop()
|
||
|
||
// 启动后先尝试一次,尽快回收已有 WAL 堆积。
|
||
db.runPassiveCheckpoint("startup")
|
||
for {
|
||
select {
|
||
case <-db.checkpointStop:
|
||
return
|
||
case <-ticker.C:
|
||
db.runPassiveCheckpoint("ticker")
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
|
||
// runPassiveCheckpoint 执行一次 PRAGMA wal_checkpoint(PASSIVE)。
|
||
func (db *DB) runPassiveCheckpoint(trigger string) {
|
||
if db == nil || db.DB == nil {
|
||
return
|
||
}
|
||
startAt := time.Now()
|
||
var busy, logFrames, checkpointed int
|
||
err := db.QueryRow("PRAGMA wal_checkpoint(PASSIVE)").Scan(&busy, &logFrames, &checkpointed)
|
||
if db.logger == nil {
|
||
return
|
||
}
|
||
fields := []zap.Field{
|
||
zap.String("db", db.checkpointLoopName),
|
||
zap.String("trigger", trigger),
|
||
zap.Int("busy", busy),
|
||
zap.Int("log_frames", logFrames),
|
||
zap.Int("checkpointed_frames", checkpointed),
|
||
zap.Int64("elapsed_ms", time.Since(startAt).Milliseconds()),
|
||
}
|
||
if err != nil {
|
||
db.logger.Warn("SQLite PASSIVE checkpoint 完成(失败)",
|
||
append(fields, zap.Error(err))...,
|
||
)
|
||
return
|
||
}
|
||
if busy > 0 {
|
||
db.logger.Debug("SQLite PASSIVE checkpoint 完成(部分推进)", fields...)
|
||
return
|
||
}
|
||
db.logger.Debug("SQLite PASSIVE checkpoint 完成(成功)", fields...)
|
||
}
|
||
|
||
// NewDB 创建数据库连接
|
||
func NewDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
||
db, err := sql.Open("sqlite3", sqliteOpenDSN(dbPath))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("打开数据库失败: %w", err)
|
||
}
|
||
|
||
configureDBPool(db)
|
||
|
||
if err := db.Ping(); err != nil {
|
||
_ = db.Close()
|
||
return nil, fmt.Errorf("连接数据库失败: %w", err)
|
||
}
|
||
if err := configureSQLitePragmas(db); err != nil {
|
||
_ = db.Close()
|
||
return nil, fmt.Errorf("配置数据库 PRAGMA 失败: %w", err)
|
||
}
|
||
|
||
database := &DB{
|
||
DB: db,
|
||
logger: logger,
|
||
}
|
||
// Keep conversation-scoped artifacts near database files, so cleanup can follow conversation lifecycle.
|
||
baseDir := filepath.Join(filepath.Dir(dbPath), "conversation_artifacts")
|
||
if mkErr := os.MkdirAll(baseDir, 0o755); mkErr == nil {
|
||
database.conversationArtifactsDir = baseDir
|
||
} else if logger != nil {
|
||
logger.Warn("创建 conversation artifacts 目录失败", zap.String("dir", baseDir), zap.Error(mkErr))
|
||
}
|
||
|
||
// 初始化表
|
||
if err := database.initTables(); err != nil {
|
||
_ = db.Close()
|
||
return nil, fmt.Errorf("初始化表失败: %w", err)
|
||
}
|
||
if err := database.migrateLegacyToolGuardBlocks(); err != nil {
|
||
_ = db.Close()
|
||
return nil, fmt.Errorf("迁移历史安全拦截记录失败: %w", err)
|
||
}
|
||
database.startPassiveCheckpointLoop("conversations")
|
||
|
||
return database, nil
|
||
}
|
||
|
||
// SetEinoConversationDirs configures best-effort filesystem cleanup on DeleteConversation.
|
||
// plantaskBase is skills_root/plantask_rel (no conversation id); checkpointBase is checkpoint_dir root.
|
||
// reductionRoot is reduction_root_dir from config; empty uses tmp/reduction (conversation-scoped subdirs only).
|
||
// workspaceRoot is agent.workspace_root_dir from config; empty uses tmp/workspace.
|
||
func (db *DB) SetEinoConversationDirs(plantaskBase, checkpointBase, reductionRoot, workspaceRoot string) {
|
||
if db == nil {
|
||
return
|
||
}
|
||
db.einoPlantaskBaseDir = strings.TrimSpace(plantaskBase)
|
||
db.einoCheckpointBaseDir = strings.TrimSpace(checkpointBase)
|
||
db.einoReductionRootDir = strings.TrimSpace(reductionRoot)
|
||
db.einoWorkspaceRootDir = strings.TrimSpace(workspaceRoot)
|
||
}
|
||
|
||
// SetChatUploadsDir configures the chat_uploads root so DeleteConversation can remove
|
||
// uploaded attachment files. Their chat_upload_artifacts rows already disappear via
|
||
// ON DELETE CASCADE; without this the files themselves would linger forever.
|
||
func (db *DB) SetChatUploadsDir(dir string) {
|
||
if db == nil {
|
||
return
|
||
}
|
||
db.chatUploadsDir = strings.TrimSpace(dir)
|
||
}
|
||
|
||
// initTables 初始化数据库表
|
||
func (db *DB) initTables() error {
|
||
if err := db.reconcileSchema(applicationSchema); err != nil {
|
||
return err
|
||
}
|
||
// Data migrations are separate from schema repair.
|
||
if _, err := db.Exec("UPDATE messages SET updated_at = created_at WHERE updated_at IS NULL OR updated_at = ''"); err != nil {
|
||
return fmt.Errorf("回填消息更新时间失败: %w", err)
|
||
}
|
||
if err := db.migrateVulnerabilitiesConversationFK(); err != nil {
|
||
return fmt.Errorf("迁移漏洞会话外键失败: %w", err)
|
||
}
|
||
if err := db.BackfillModelTokenUsageFromProcessDetails(); err != nil {
|
||
return fmt.Errorf("回填模型Token用量失败: %w", err)
|
||
}
|
||
db.logger.Debug("数据库结构检查及补齐完成")
|
||
return nil
|
||
}
|
||
|
||
// NewKnowledgeDB 创建知识库数据库连接(只包含知识库相关的表)
|
||
func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
||
sqlDB, err := sql.Open("sqlite3", sqliteOpenDSN(dbPath))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("打开知识库数据库失败: %w", err)
|
||
}
|
||
|
||
configureDBPool(sqlDB)
|
||
|
||
if err := sqlDB.Ping(); err != nil {
|
||
_ = sqlDB.Close()
|
||
return nil, fmt.Errorf("连接知识库数据库失败: %w", err)
|
||
}
|
||
if err := configureSQLitePragmas(sqlDB); err != nil {
|
||
_ = sqlDB.Close()
|
||
return nil, fmt.Errorf("配置知识库数据库 PRAGMA 失败: %w", err)
|
||
}
|
||
|
||
database := &DB{
|
||
DB: sqlDB,
|
||
logger: logger,
|
||
}
|
||
|
||
// 初始化知识库表
|
||
if err := database.initKnowledgeTables(); err != nil {
|
||
_ = sqlDB.Close()
|
||
return nil, fmt.Errorf("初始化知识库表失败: %w", err)
|
||
}
|
||
database.startPassiveCheckpointLoop("knowledge")
|
||
|
||
return database, nil
|
||
}
|
||
|
||
// initKnowledgeTables 初始化知识库数据库表(只包含知识库相关的表)
|
||
func (db *DB) initKnowledgeTables() error {
|
||
if err := db.reconcileSchema(knowledgeSchema); err != nil {
|
||
return err
|
||
}
|
||
db.logger.Info("数据库结构检查及补齐完成")
|
||
return nil
|
||
}
|
||
|
||
// Close 关闭数据库连接
|
||
func (db *DB) Close() error {
|
||
if db == nil {
|
||
return nil
|
||
}
|
||
db.closeOnce.Do(func() {
|
||
if db.checkpointStop != nil {
|
||
close(db.checkpointStop)
|
||
if db.checkpointDone != nil {
|
||
<-db.checkpointDone
|
||
}
|
||
}
|
||
if db.DB != nil {
|
||
db.closeErr = db.DB.Close()
|
||
}
|
||
})
|
||
return db.closeErr
|
||
}
|