Files
Agent-System-Backend/internal/store/store.go
T
agent-exec-transport 7b379d28e7
governance / governance (pull_request) Successful in 17s
ci / vet-and-test (pull_request) Successful in 1m6s
agent-exec transport d2c63b4a83821a5716997a28174233b1/d6e403e1e778
2026-10-18 09:20:03 +00:00

392 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package store 是持久化层唯一入口(存储档接口,设计 4.1/D5/D16)。
// 两档实现共用同一方法面:
// - 嵌入式档 SQLite(默认:单文件 + 本地对象目录,零外部服务);
// - PostgreSQL 档(多用户/多实例;DSN 配置,pgx 纯 Go 驱动,静态构建兼容)。
//
// 方言差异(占位符、UPSERT、全文检索、备份、咨询锁)收敛在 dialect 与方言文件;
// 业务层只依赖本包公开方法。schema 演进走版本化迁移(migrate.go,双方言、可回滚)。
// 租户硬边界:租户域查询一律带 owner 过滤,跨租户访问返回 ErrNotFound。
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"agent-system-backend/internal/blobstore"
_ "github.com/jackc/pgx/v5/stdlib" // PG 档驱动(注册名 "pgx")
_ "modernc.org/sqlite" // 嵌入式档驱动(注册名 "sqlite",CGO-free)
)
var ErrNotFound = errors.New("store: not found")
var ErrConflict = errors.New("store: conflict")
// ErrDuplicateRequest 表示同一会话的 request_id 已经落库。
// 调用方应返回已有消息,不要映射成 409:客户端把 409 当成会话忙并重新入队。
var ErrDuplicateRequest = errors.New("store: duplicate request id")
// OwnerDefault 是单租户默认 owner(嵌入式档 / 未启用认证时的隐式身份)。
const OwnerDefault = "local"
// PoolOptions PG 连接预算(协作存储方案 KTD12)。零值走默认(DefaultPGPool);
// 预算应覆盖全部 API/Worker/LISTEN/migrator 连接,100 在线用户 ≠ 100 连接。
type PoolOptions struct {
MaxOpenConn int // 每进程最大打开连接;0=默认 16
MaxIdleConn int // 每进程最大空闲连接;0=默认 8
ConnMaxIdleTimeSec int // 空闲连接回收时长;0=默认 300s
ConnMaxLifetimeSec int // 连接最长生命周期;0=不限
QueryTimeoutSec int // PG statement_timeout(秒);0=不限
}
// DefaultPGPool 与历史行为一致的默认预算(16/8/300s)。
var DefaultPGPool = PoolOptions{MaxOpenConn: 16, MaxIdleConn: 8, ConnMaxIdleTimeSec: 300}
func (p PoolOptions) effective() PoolOptions {
out := p
if out.MaxOpenConn <= 0 {
out.MaxOpenConn = DefaultPGPool.MaxOpenConn
}
if out.MaxIdleConn <= 0 {
out.MaxIdleConn = DefaultPGPool.MaxIdleConn
}
if out.ConnMaxIdleTimeSec <= 0 {
out.ConnMaxIdleTimeSec = DefaultPGPool.ConnMaxIdleTimeSec
}
return out
}
type Store struct {
db *sql.DB
dialect dialect
dataDir string // 工件对象目录(备份路径等诊断用)
blobs blobstore.Store // 工件对象存储(本地档;多实例切共享卷/对象存储)
instanceID string // 本进程标识;CreateRun 写入 runs.instance_id
pool PoolOptions // 生效的连接预算(诊断输出用)
settleMu sync.Mutex
settleFault error // 非空时 SettleRun 在写入前失败,用于终态提交失败测试
migrateMu sync.Mutex
migrateFault error // 非空时 migratePass 在「DDL 已执行、版本未登记」处失败(原子性测试)
accountFault error // 非空时账户事务在提交前失败(半态测试)
vecPGOnce sync.Once // pgvector 列探测(U6-3:一次性缓存)
vecPG bool
}
// OpenSQLite 打开(必要时创建)嵌入式存储并执行迁移。
func OpenSQLite(path string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
return openSQLiteFile(path, true, true)
}
// OpenIdentity 打开既有库做身份 ensure/verify:不创建文件或目录,不执行迁移。
// 缺少 agent_system_identity 表时返回 ErrIdentityTableMissing。
func OpenIdentity(storageDSN, dataDir string) (*Store, error) {
if storageDSN != "" {
return openIdentityPostgres(storageDSN)
}
return openIdentitySQLite(filepath.Join(dataDir, "agent-system.db"))
}
func openIdentitySQLite(path string) (*Store, error) {
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("%w: %s", ErrIdentityDBMissing, path)
}
return nil, err
}
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(30000)&_pragma=foreign_keys(1)&_txlock=immediate", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
if err := requireIdentityTable(db, "sqlite"); err != nil {
db.Close()
return nil, err
}
return &Store{db: db, dialect: sqliteDialect{}}, nil
}
func openIdentityPostgres(dsn string) (*Store, error) {
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(2)
if err := db.Ping(); err != nil {
db.Close()
return nil, err
}
if err := requireIdentityTable(db, "postgres"); err != nil {
db.Close()
return nil, err
}
return &Store{db: db, dialect: postgresDialect{}}, nil
}
func requireIdentityTable(db *sql.DB, kind string) error {
var name string
var err error
switch kind {
case "postgres":
err = db.QueryRow(`SELECT COALESCE(to_regclass('public.agent_system_identity')::text, '')`).Scan(&name)
default:
err = db.QueryRow(`SELECT COALESCE((SELECT name FROM sqlite_master WHERE type='table' AND name='agent_system_identity'), '')`).Scan(&name)
}
if err != nil {
return err
}
if name == "" {
return fmt.Errorf("%w: 先执行受控迁移(服务启动或 migrate),身份命令不建库、不迁移", ErrIdentityTableMissing)
}
return nil
}
// SchemaVersion 读取 schema_migrations 的最大版本。表不存在时返回 0。
func (s *Store) SchemaVersion() (int, error) {
var v sql.NullInt64
err := s.db.QueryRow(`SELECT MAX(version) FROM schema_migrations`).Scan(&v)
if err != nil {
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "no such table") || strings.Contains(msg, "does not exist") {
return 0, nil
}
return 0, err
}
if !v.Valid {
return 0, nil
}
return int(v.Int64), nil
}
// OpenExistingSQLite 打开既有嵌入式库:数据文件不存在则失败,不创建空库或父目录,也不迁移。
// schema 与本二进制不一致时拒绝,并提示使用 migrate 或先启动服务。
func OpenExistingSQLite(path string) (*Store, error) {
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("open existing: sqlite file missing: %s", path)
}
return nil, err
}
return openSQLiteFile(path, false, false)
}
func openSQLiteFile(path string, createArtifacts, migrateSchema bool) (*Store, error) {
// _txlock=immediate:写事务在 BEGIN 即取库写锁(而非首个写语句升级),
// 跨进程迁移/写路径依赖 busy_timeout 排队而不是中途 SQLITE_BUSY(KTD8)。
// busy_timeout 30s:迁移竞争测试在慢速 CI 盘上 4 个迁移者串行全程可超 5s
// (实测 job 2285 SQLITE_BUSY);本地档宁可排队不报错。
// synchronous(FULL) 是恢复方案 KTD14 的可靠档。打开后在同一连接读回,见 verifyDurability。
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(30000)&_pragma=journal_mode(WAL)&_pragma=synchronous(FULL)&_pragma=foreign_keys(1)&_txlock=immediate", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
// SQLite 单写者:串行化连接,语句级原子 + 版本化迁移事务。
db.SetMaxOpenConns(1)
return finishOpenMode(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil, PoolOptions{}, createArtifacts, migrateSchema)
}
// OpenPostgres 打开 PostgreSQL 档存储并执行迁移(默认连接预算)。
// DSN 例:postgres://user:pass@host:5432/agent_system?sslmode=disable
// artifactsDir 为工件对象目录(多实例部署时指向共享卷;单实例默认本地)。
func OpenPostgres(dsn, artifactsDir string) (*Store, error) {
return OpenPostgresWithOptions(dsn, artifactsDir, PoolOptions{}, nil)
}
// OpenPostgresWithBlobs 是注入自定义对象存储的 PG 档入口(多实例共享卷/对象存储)。
func OpenPostgresWithBlobs(dsn, artifactsDir string, blobs blobstore.Store) (*Store, error) {
return OpenPostgresWithOptions(dsn, artifactsDir, PoolOptions{}, blobs)
}
// OpenPostgresWithOptions 按 KTD12 连接预算打开 PG 档;QueryTimeoutSec>0 时
// 以 statement_timeout 运行参数附着到 DSN(对 URL 与键值两种形式均生效)。
// blobs 为 nil(含带类型的空指针,例如未配置端点时的 (*blobstore.S3)(nil))时回落本地目录。
func OpenPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs blobstore.Store) (*Store, error) {
return openPostgresWithOptions(dsn, artifactsDir, pool, blobs, true, true)
}
// OpenExistingPostgres 打开既有 PG 库,不创建 artifacts 目录,也不迁移。
func OpenExistingPostgres(dsn, artifactsDir string) (*Store, error) {
return openPostgresWithOptions(dsn, artifactsDir, PoolOptions{}, nil, false, false)
}
func openPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs blobstore.Store, createArtifacts, migrateSchema bool) (*Store, error) {
pool = pool.effective()
if pool.QueryTimeoutSec > 0 {
dsn = withPGRuntimeParam(dsn, "statement_timeout", strconv.Itoa(pool.QueryTimeoutSec*1000))
}
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(pool.MaxOpenConn)
db.SetMaxIdleConns(pool.MaxIdleConn)
db.SetConnMaxIdleTime(time.Duration(pool.ConnMaxIdleTimeSec) * time.Second)
if pool.ConnMaxLifetimeSec > 0 {
db.SetConnMaxLifetime(time.Duration(pool.ConnMaxLifetimeSec) * time.Second)
}
return finishOpenMode(db, postgresDialect{}, artifactsDir, blobs, pool, createArtifacts, migrateSchema)
}
// withPGRuntimeParam 向 DSN 追加运行时参数(libpq 语义:URL 查询串或键值对)。
// 不改写既有参数;值中不含分隔符时安全(statement_timeout/search_path 均满足)。
func withPGRuntimeParam(dsn, key, value string) string {
return AppendPGRuntimeParam(dsn, key, value)
}
// AppendPGRuntimeParam 导出形态:测试与其他包(如 runmgr 的 schema 隔离)
// 共用同一 DSN 拼接规则,避免各自盲拼 "&key=value" 造出非法库名。
func AppendPGRuntimeParam(dsn, key, value string) string {
kv := key + "=" + value
if !strings.Contains(dsn, "://") {
if strings.TrimSpace(dsn) == "" {
return kv
}
return dsn + " " + kv
}
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
return dsn + sep + kv
}
// Preserve the existing initialization helper contract; maintenance opens select
// nonmigrating mode explicitly through finishOpenMode.
func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions, createArtifacts bool) (*Store, error) {
return finishOpenMode(db, d, artifactsDir, blobs, pool, createArtifacts, true)
}
func finishOpenMode(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions, createArtifacts, migrateSchema bool) (*Store, error) {
if blobstore.IsNil(blobs) {
var err error
if createArtifacts {
blobs, err = blobstore.NewLocal(artifactsDir)
} else {
blobs, err = blobstore.OpenLocalExisting(artifactsDir)
}
if err != nil {
db.Close()
return nil, err
}
}
s := &Store{db: db, dialect: d, dataDir: artifactsDir, blobs: blobs, pool: pool}
if migrateSchema {
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
} else if err := s.requireCurrentSchema(); err != nil {
db.Close()
return nil, err
}
if err := s.verifyDurability(); err != nil {
db.Close()
return nil, err
}
if createArtifacts {
if err := os.MkdirAll(artifactsDir, 0o755); err != nil {
db.Close()
return nil, err
}
}
return s, nil
}
func (s *Store) Close() error { return s.db.Close() }
// SetInstanceID 记录本进程标识。之后 CreateRun 把它写入 runs.instance_id。
// 只在开始接请求前调用一次。
func (s *Store) SetInstanceID(id string) { s.instanceID = id }
// SetSettleFault 让下一次 SettleRun 在写入前失败。nil 表示恢复正常。
func (s *Store) SetSettleFault(err error) {
s.settleMu.Lock()
s.settleFault = err
s.settleMu.Unlock()
}
// SetMigrateFault 让下一次 migratePass 在首个待应用迁移的「DDL 已执行、版本
// 未登记」位置失败一次(nil 恢复)。测试专用:验证 DDL 与账本同事务回滚。
func (s *Store) SetMigrateFault(err error) {
s.migrateMu.Lock()
s.migrateFault = err
s.migrateMu.Unlock()
}
func (s *Store) takeMigrateFault() error {
s.migrateMu.Lock()
defer s.migrateMu.Unlock()
err := s.migrateFault
s.migrateFault = nil
return err
}
// Dialect 返回当前存储档名(sqlite | postgres;诊断/健康检查用)。
func (s *Store) Dialect() string { return s.dialect.name() }
// Pool 返回生效的连接预算(启动诊断;脱敏,不含 DSN)。
func (s *Store) Pool() PoolOptions { return s.pool }
// DB 暴露底层连接(运维工具专用:storage-migrate 的控制表与
// information_schema 查询)。业务代码一律走本包公开方法。
func (s *Store) DB() *sql.DB { return s.db }
// DataDir 返回工件对象目录。
func (s *Store) DataDir() string { return s.dataDir }
// BlobStore 返回工件对象存储(诊断/运维)。
func (s *Store) BlobStore() blobstore.Store { return s.blobs }
// NotifyPG 发送 PostgreSQL NOTIFY(PG 档事件扇出用;SQLite 档返回未执行)。
func (s *Store) NotifyPG(ctx context.Context, channel, payload string) (bool, error) {
if s.dialect.name() != "postgres" {
return false, nil
}
_, err := s.db.ExecContext(ctx, `SELECT pg_notify($1, $2)`, channel, payload)
return true, err
}
// q 按方言重绑占位符(查询统一用 ? 书写,PG 执行前转 $n)。
// 本包 SQL 不含字符串字面量 "?",重绑安全。
func (s *Store) q(query string) string { return s.dialect.rebind(query) }
// Backup 生成一致性备份文件。SQLite 档用 VACUUM INTO(在线安全,含 WAL 内容);
// PG 档备份走 pg_dump / WAL 归档(运维路径,见 usage 指南),此处返回指引错误。
func (s *Store) Backup(destPath string) error {
if s.dialect.name() != "sqlite" {
return errors.New("store: PG 档备份走 pg_dump/WAL 归档(见 docs/guides/usage.md 存储档一节)")
}
if _, err := s.db.Exec(`VACUUM INTO ?`, destPath); err != nil {
return fmt.Errorf("store: backup: %w", err)
}
return nil
}
func nowISO() string { return time.Now().UTC().Format(time.RFC3339Nano) }
func marshalJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
func unmarshalJSON[T any](raw string) (T, error) {
var out T
err := json.Unmarshal([]byte(raw), &out)
return out, err
}