feat: add durable client command spool
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
// Package spool owns the durable, command-local state retained by a client
|
||||
// daemon between network sessions and process restarts.
|
||||
package spool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rvbox/rvbox/internal/domain"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnsafeDataDirectory = errors.New("unsafe client spool directory")
|
||||
ErrAlreadyOpen = errors.New("client spool directory is already locked")
|
||||
ErrIdentityCorrupt = errors.New("client instance identity is corrupt")
|
||||
ErrCommandConflict = errors.New("command UUID has different immutable content")
|
||||
ErrAlreadyExecuted = errors.New("command UUID was already executed")
|
||||
ErrUnknownCommand = errors.New("unknown client command")
|
||||
ErrInvalidEventAck = errors.New("event acknowledgement is beyond assigned sequence")
|
||||
ErrEventsPending = errors.New("command has events pending server acknowledgement")
|
||||
)
|
||||
|
||||
const DefaultTombstoneLimit uint64 = 1_000_000
|
||||
|
||||
type Options struct {
|
||||
DataDir string
|
||||
BusyTimeout time.Duration
|
||||
TombstoneLimit uint64
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
unlock func() error
|
||||
dataDir string
|
||||
identity domain.UUID
|
||||
tombstoneLimit uint64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Open recovers an existing spool or creates an empty one. The caller must
|
||||
// surface ErrIdentityCorrupt as dirty health; it is deliberately never healed
|
||||
// by assigning a new client identity.
|
||||
func Open(ctx context.Context, options Options) (*Store, error) {
|
||||
if !filepath.IsAbs(options.DataDir) || filepath.Clean(options.DataDir) == string(filepath.Separator) {
|
||||
return nil, ErrUnsafeDataDirectory
|
||||
}
|
||||
if options.BusyTimeout <= 0 {
|
||||
return nil, errors.New("busy timeout must be positive")
|
||||
}
|
||||
if options.TombstoneLimit == 0 {
|
||||
options.TombstoneLimit = DefaultTombstoneLimit
|
||||
}
|
||||
if err := ensurePrivateDirectory(options.DataDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
unlock, err := acquireInstanceLock(filepath.Join(options.DataDir, "spool.lock"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
identity, err := loadOrCreateIdentity(filepath.Join(options.DataDir, "client-instance-id"), domain.NewUUIDv7)
|
||||
if err != nil {
|
||||
_ = unlock()
|
||||
return nil, err
|
||||
}
|
||||
databasePath := filepath.Join(options.DataDir, "spool.db")
|
||||
if err := ensurePrivateFile(databasePath); err != nil {
|
||||
_ = unlock()
|
||||
return nil, err
|
||||
}
|
||||
query := url.Values{}
|
||||
query.Add("_defensive", "1")
|
||||
query.Add("_pragma", "journal_mode(WAL)")
|
||||
query.Add("_pragma", "foreign_keys(ON)")
|
||||
query.Add("_pragma", "synchronous(FULL)")
|
||||
query.Add("_pragma", "busy_timeout("+strconv.FormatInt(options.BusyTimeout.Milliseconds(), 10)+")")
|
||||
databaseURL := &url.URL{Scheme: "file", Path: filepath.ToSlash(databasePath), RawQuery: query.Encode()}
|
||||
db, err := sql.Open("sqlite", databaseURL.String())
|
||||
if err != nil {
|
||||
_ = unlock()
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
store := &Store{db: db, unlock: unlock, dataDir: options.DataDir, identity: identity, tombstoneLimit: options.TombstoneLimit}
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = store.Close()
|
||||
return nil, fmt.Errorf("open client spool SQLite: %w", err)
|
||||
}
|
||||
if err := applyMigrations(ctx, db); err != nil {
|
||||
_ = store.Close()
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (store *Store) ClientInstanceID() domain.UUID { return store.identity }
|
||||
|
||||
func (store *Store) Close() error {
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
var result error
|
||||
if store.db != nil {
|
||||
result = store.db.Close()
|
||||
store.db = nil
|
||||
}
|
||||
if store.unlock != nil {
|
||||
if err := store.unlock(); result == nil {
|
||||
result = err
|
||||
}
|
||||
store.unlock = nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func ensurePrivateFile(path string) error {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600)
|
||||
if err == nil {
|
||||
return file.Close()
|
||||
}
|
||||
if !errors.Is(err, os.ErrExist) {
|
||||
return err
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("%w: %s is not a regular file", ErrUnsafeDataDirectory, path)
|
||||
}
|
||||
if info.Mode().Perm()&0o077 != 0 {
|
||||
return fmt.Errorf("%w: %s permissions %04o expose private state", ErrUnsafeDataDirectory, path, info.Mode().Perm())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensurePrivateDirectory(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(path, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err = os.Lstat(path)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return fmt.Errorf("%w: %s is not a real directory", ErrUnsafeDataDirectory, path)
|
||||
}
|
||||
if info.Mode().Perm()&0o077 != 0 {
|
||||
return fmt.Errorf("%w: %s permissions %04o expose private state", ErrUnsafeDataDirectory, path, info.Mode().Perm())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func immutableDigest(payload []byte) [32]byte { return sha256.Sum256(payload) }
|
||||
Reference in New Issue
Block a user