Details (original message): Add multi-instance groundwork: blobstore seam for artifacts, PG LISTEN/NOTIFY event fanout, and per-tenant rate limiting with native Redis backend
149 lines
5.1 KiB
Go
149 lines
5.1 KiB
Go
// 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"
|
||
"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")
|
||
|
||
// OwnerDefault 是单租户默认 owner(嵌入式档 / 未启用认证时的隐式身份)。
|
||
const OwnerDefault = "local"
|
||
|
||
type Store struct {
|
||
db *sql.DB
|
||
dialect dialect
|
||
dataDir string // 工件对象目录(备份路径等诊断用)
|
||
blobs blobstore.Store // 工件对象存储(本地档;多实例切共享卷/对象存储)
|
||
}
|
||
|
||
// OpenSQLite 打开(必要时创建)嵌入式存储并执行迁移。
|
||
func OpenSQLite(path string) (*Store, error) {
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
return nil, err
|
||
}
|
||
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)", path)
|
||
db, err := sql.Open("sqlite", dsn)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// SQLite 单写者:串行化连接,语句级原子 + 版本化迁移事务。
|
||
db.SetMaxOpenConns(1)
|
||
return finishOpen(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil)
|
||
}
|
||
|
||
// OpenPostgres 打开 PostgreSQL 档存储并执行迁移。
|
||
// DSN 例:postgres://user:pass@host:5432/agent_system?sslmode=disable
|
||
// artifactsDir 为工件对象目录(多实例部署时指向共享卷;单实例默认本地)。
|
||
func OpenPostgres(dsn, artifactsDir string) (*Store, error) {
|
||
db, err := sql.Open("pgx", dsn)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
db.SetMaxOpenConns(16)
|
||
db.SetMaxIdleConns(8)
|
||
db.SetConnMaxIdleTime(5 * time.Minute)
|
||
return finishOpen(db, postgresDialect{}, artifactsDir, nil)
|
||
}
|
||
|
||
// OpenPostgresWithBlobs 是注入自定义对象存储的 PG 档入口(多实例共享卷/对象存储)。
|
||
func OpenPostgresWithBlobs(dsn, artifactsDir string, blobs blobstore.Store) (*Store, error) {
|
||
db, err := sql.Open("pgx", dsn)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
db.SetMaxOpenConns(16)
|
||
db.SetMaxIdleConns(8)
|
||
db.SetConnMaxIdleTime(5 * time.Minute)
|
||
return finishOpen(db, postgresDialect{}, artifactsDir, blobs)
|
||
}
|
||
|
||
func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store) (*Store, error) {
|
||
if blobs == nil {
|
||
var err error
|
||
blobs, err = blobstore.NewLocal(artifactsDir)
|
||
if err != nil {
|
||
db.Close()
|
||
return nil, err
|
||
}
|
||
}
|
||
s := &Store{db: db, dialect: d, dataDir: artifactsDir, blobs: blobs}
|
||
if err := s.migrate(); err != nil {
|
||
db.Close()
|
||
return nil, err
|
||
}
|
||
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() }
|
||
|
||
// Dialect 返回当前存储档名(sqlite | postgres;诊断/健康检查用)。
|
||
func (s *Store) Dialect() string { return s.dialect.name() }
|
||
|
||
// 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
|
||
}
|