Files
Agent-System-Backend/internal/instanceguard/guard.go
T
weichenandCursor 89acd16ca5 Refuse a second SQLite service before migration
Why: Two processes opening the same SQLite file both migrate and reconcile, so the second one seals the first one's live runs. The activity lock is taken before open, and durability settings are read back on the application connection.
Evidence: SELF go test ./internal/instanceguard ./internal/store ./internal/app (filtered T8 cases) on feature/u8-sqlite-instance-guard. PG durability readback is NOT_RUN locally; Dev CI test-backend-pg is the GATE.
Impact: SQLite serve and migrate share one non-blocking flock. A replaced database or rebuilt lock file refuses start unless --adopt-instance-guard. PostgreSQL serve refuses when fsync, full_page_writes, or synchronous_commit is off.
Refs: docs/recovery/u8-sqlite-instance-guard.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 13:53:42 +00:00

149 lines
4.2 KiB
Go

// Package instanceguard 强制同一 SQLite 数据文件只有一个活动服务。
// 锁在打开数据库(迁移发生在打开内部)之前取得。账户 CLI 不使用本包。
package instanceguard
import (
"errors"
"fmt"
"strings"
"agent-system-backend/internal/store"
)
var (
ErrBusy = errors.New("instance guard: sqlite service already running")
ErrHardlink = errors.New("instance guard: sqlite database has a hardlink alias")
ErrDBReplaced = errors.New("instance guard: sqlite database replaced")
ErrDBMissing = errors.New("instance guard: recorded sqlite database is missing")
ErrLockRebuilt = errors.New("instance guard: lock file was rebuilt")
ErrLockCorrupt = errors.New("instance guard: lock record is corrupt")
ErrInstanceMismatch = errors.New("instance guard: instance id mismatch")
ErrUnsupported = errors.New("instance guard: sqlite activity lock is not available on this platform")
)
// Record 是锁文件里记下的数据库身份。没有记录表示锁文件是新建的。
type Record struct {
DBDev uint64
DBIno uint64
InstanceID string
}
// Lease 持有活动服务锁,直到 Release。关闭顺序由调用方保证:先关存储,再 Release。
type Lease struct {
fd int
dbPath string
lockDev uint64
lockIno uint64
dbDev uint64
dbIno uint64
dbExists bool
record *Record
parseErr error
release func()
}
// Release 放开锁并关闭锁文件描述符。锁文件留在磁盘上。
func (l *Lease) Release() {
if l == nil || l.release == nil {
return
}
l.release()
l.release = nil
}
// DBPath 是解析后的数据库路径。符号链接指向真实文件。
func (l *Lease) DBPath() string {
if l == nil {
return ""
}
return l.dbPath
}
// AutomaticRefusal 在打开数据库之前调用。
// 尚无记录时返回 nil(首次建立,含旧库升级)。硬链接在 Acquire 阶段已经拒绝。
func (l *Lease) AutomaticRefusal() error {
if l.parseErr != nil {
return l.parseErr
}
if l.record == nil {
return nil
}
if !l.dbExists {
return ErrDBMissing
}
if l.record.DBDev != l.dbDev || l.record.DBIno != l.dbIno {
return ErrDBReplaced
}
return nil
}
// BindOptions 控制守卫建立后的比对。
type BindOptions struct {
Adopt bool
InstanceID string
}
// Bind 在数据库已经打开并完成迁移之后调用。
// 锁文件 inode 与表不一致则拒绝,除非 Adopt。通过时重写锁文件内容与表。
func Bind(st *store.Store, lease *Lease, opt BindOptions) error {
if lease == nil {
return errors.New("instance guard: nil lease")
}
marker, ok, err := st.LoadInstanceGuard()
if err != nil {
return err
}
if ok && (marker.LockDev != lease.lockDev || marker.LockIno != lease.lockIno) && !opt.Adopt {
return ErrLockRebuilt
}
id := opt.InstanceID
if ok && marker.InstanceID != "" && id != "" && marker.InstanceID != id && !opt.Adopt {
return ErrInstanceMismatch
}
if id == "" && ok {
id = marker.InstanceID
}
dev, ino, exists, err := lease.currentDB()
if err != nil {
return err
}
if !exists {
return ErrDBMissing
}
lease.dbDev, lease.dbIno, lease.dbExists = dev, ino, true
next := store.InstanceGuardMarker{
DBDev: dev, DBIno: ino, LockDev: lease.lockDev, LockIno: lease.lockIno, InstanceID: id,
}
if err := lease.writeRecord(dev, ino, id); err != nil {
return err
}
if !ok || opt.Adopt || marker != next {
if err := st.SaveInstanceGuard(next); err != nil {
return err
}
}
return nil
}
func parseRecord(raw string) (*Record, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
var dev, ino uint64
var id string
if _, err := fmt.Sscanf(raw, "v1\tdb_dev=%d\tdb_ino=%d\tinstance_id=%s", &dev, &ino, &id); err == nil {
want := fmt.Sprintf("v1\tdb_dev=%d\tdb_ino=%d\tinstance_id=%s", dev, ino, id)
if raw == want && !strings.ContainsAny(id, " \t") {
return &Record{DBDev: dev, DBIno: ino, InstanceID: id}, nil
}
}
if _, err := fmt.Sscanf(raw, "v1\tdb_dev=%d\tdb_ino=%d\tinstance_id=", &dev, &ino); err == nil {
want := fmt.Sprintf("v1\tdb_dev=%d\tdb_ino=%d\tinstance_id=", dev, ino)
if raw == want {
return &Record{DBDev: dev, DBIno: ino}, nil
}
}
return nil, ErrLockCorrupt
}