feat: transaction implement

This commit is contained in:
2026-08-15 02:30:36 +08:00
parent 6515c93378
commit b4e18c6f0c
21 changed files with 3886 additions and 0 deletions
+1663
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
module yms-daemon
go 1.26
require github.com/ncruces/go-sqlite3 v0.35.3
require (
github.com/ncruces/go-sqlite3-wasm/v3 v3.2.35304 // indirect
github.com/ncruces/julianday v1.0.0 // indirect
golang.org/x/sys v0.47.0 // indirect
)
+10
View File
@@ -0,0 +1,10 @@
github.com/ncruces/go-sqlite3 v0.35.3 h1:Ei07Zv1qfV/vyXzelhFsyS5Oh9TArBZHsmFk14Xv3GY=
github.com/ncruces/go-sqlite3 v0.35.3/go.mod h1:i1rhym/NIiB5xeEfzbN+e24Y+i7NGUpf7C2xZ3Dpwks=
github.com/ncruces/go-sqlite3-wasm/v3 v3.2.35304 h1:5NoQAewtgKNK3G4bjNPxVoGXu6F6NzLXWCTdD5FFAEY=
github.com/ncruces/go-sqlite3-wasm/v3 v3.2.35304/go.mod h1:o8gr9w/50fXA5TDskg6bNUjvqmFfw4KaXth4q+yDSjg=
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
@@ -0,0 +1,182 @@
//go:build linux
package integration_test
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"testing"
"yms-daemon/internal/processlock"
"yms-daemon/internal/transaction"
)
const (
lockHelperModeEnvironment = "YMS_DAEMON_INTEGRATION_LOCK_HELPER_MODE"
lockHelperPathEnvironment = "YMS_DAEMON_INTEGRATION_LOCK_HELPER_PATH"
lockHelperExpectBlocked = "EXPECT_BLOCKED"
lockHelperExpectAcquired = "EXPECT_ACQUIRED"
)
// TestTransactionKernelIntegration 使用真实子进程、flock 和 SQLite 文件验证事务底座。
// 测试期间父进程始终持有 serve 锁,完整事务提交并重开数据库后才释放锁。
func TestTransactionKernelIntegration(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
lockPath := filepath.Join(root, "serve.lock")
databasePath := filepath.Join(root, "transaction.db")
serveLock, err := processlock.Acquire(lockPath)
if err != nil {
t.Fatalf("acquire serve lock: %v", err)
}
t.Cleanup(func() {
if err := serveLock.Close(); err != nil {
t.Errorf("release serve lock during cleanup: %v", err)
}
})
// 同进程重复加锁不足以证明客户服务器上的进程互斥,因此必须启动独立进程核对。
runLockHelper(t, lockHelperExpectBlocked, lockPath)
store, err := transaction.OpenStore(ctx, databasePath)
if err != nil {
t.Fatalf("open transaction store: %v", err)
}
record, created, err := store.CreateTransaction(ctx, transaction.CreateRequest{
ID: "integration-transaction",
IdempotencyKey: "integration-request",
Source: "integration-test",
Service: "all",
})
if err != nil || !created {
t.Fatalf("create transaction: record=%+v created=%v err=%v", record, created, err)
}
transitions := []transaction.State{
transaction.StateValidating,
transaction.StatePrepared,
transaction.StateStarting,
transaction.StateSwitching,
transaction.StateVerifying,
transaction.StateDraining,
transaction.StateCommitted,
}
for index, next := range transitions {
record, err = store.Transition(ctx, record.ID, next, "integration transition")
if err != nil {
t.Fatalf("transition to %s: %v", next, err)
}
expectedVersion := int64(index + 2)
if record.State != next || record.Version != expectedVersion {
t.Fatalf("unexpected transition result: state=%s version=%d, want state=%s version=%d",
record.State, record.Version, next, expectedVersion)
}
}
if err := store.Close(); err != nil {
t.Fatalf("close transaction store: %v", err)
}
// 重新打开同一文件,证明终态和事件不是内存中的临时结果。
reopened, err := transaction.OpenStore(ctx, databasePath)
if err != nil {
t.Fatalf("reopen transaction store: %v", err)
}
t.Cleanup(func() {
if err := reopened.Close(); err != nil {
t.Errorf("close reopened transaction store: %v", err)
}
})
persisted, err := reopened.Transaction(ctx, record.ID)
if err != nil {
t.Fatalf("read committed transaction: %v", err)
}
if persisted.State != transaction.StateCommitted || persisted.Version != 8 {
t.Fatalf("unexpected committed transaction: %+v", persisted)
}
events, err := reopened.EventsAfter(ctx, record.ID, 0, 100)
if err != nil {
t.Fatalf("read transaction events: %v", err)
}
assertStateEvents(t, events, transitions)
if err := serveLock.Close(); err != nil {
t.Fatalf("release serve lock: %v", err)
}
runLockHelper(t, lockHelperExpectAcquired, lockPath)
}
// TestTransactionKernelLockHelper 只由 TestTransactionKernelIntegration 启动。
// 独立测试进程让 flock 的互斥语义得到端到端验证。
func TestTransactionKernelLockHelper(t *testing.T) {
mode, enabled := os.LookupEnv(lockHelperModeEnvironment)
if !enabled {
t.Skip("lock helper process is not enabled")
}
lockPath, present := os.LookupEnv(lockHelperPathEnvironment)
if !present || lockPath == "" {
t.Fatal("lock helper path is missing")
}
lock, err := processlock.Acquire(lockPath)
switch mode {
case lockHelperExpectBlocked:
if lock != nil {
_ = lock.Close()
}
if !errors.Is(err, processlock.ErrAlreadyLocked) {
t.Fatalf("expected kernel lock contention, lock=%v err=%v", lock, err)
}
case lockHelperExpectAcquired:
if err != nil || lock == nil {
t.Fatalf("expected released kernel lock to be acquired, lock=%v err=%v", lock, err)
}
if err := lock.Close(); err != nil {
t.Fatalf("release helper lock: %v", err)
}
default:
t.Fatalf("unknown lock helper mode: %q", mode)
}
}
func runLockHelper(t *testing.T, mode, lockPath string) {
t.Helper()
command := exec.Command(os.Args[0], "-test.run=^TestTransactionKernelLockHelper$", "-test.v")
command.Env = []string{
lockHelperModeEnvironment + "=" + mode,
lockHelperPathEnvironment + "=" + lockPath,
}
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("run lock helper in mode %s: %v\n%s", mode, err, output)
}
t.Logf("lock helper mode %s passed", mode)
}
func assertStateEvents(t *testing.T, events []transaction.Event, transitions []transaction.State) {
t.Helper()
if len(events) != len(transitions)+1 {
t.Fatalf("unexpected event count: got %d, want %d", len(events), len(transitions)+1)
}
if events[0].Kind != "TRANSACTION_CREATED" || events[0].ToState != transaction.StateCreated {
t.Fatalf("unexpected transaction creation event: %+v", events[0])
}
previous := transaction.StateCreated
for index, next := range transitions {
event := events[index+1]
if event.Kind != "TRANSACTION_STATE_CHANGED" || event.FromState != previous || event.ToState != next {
t.Fatalf("unexpected state event at index %d: %+v", index+1, event)
}
if event.Sequence <= events[index].Sequence {
t.Fatalf("event sequence is not increasing at index %d: previous=%d current=%d",
index+1, events[index].Sequence, event.Sequence)
}
previous = next
}
if previous != transaction.StateCommitted {
t.Fatalf("state flow ended at %s", previous)
}
}
+213
View File
@@ -0,0 +1,213 @@
package filestore
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"hash"
"io"
"os"
"path/filepath"
)
var ErrDestinationConflict = errors.New("destination already exists with different content")
// Identity 是不可变文件在进入事务目录前必须满足的身份。
type Identity struct {
Size int64
SHA256 string
}
// File 是一次原子提交的结果。
type File struct {
Path string
Identity Identity
Reused bool
}
// Store 在一个 daemon 独占管理的本地根目录中保存不可变文件。
type Store struct {
root string
}
// New 创建文件存储并固定其规范根目录。
func New(root string) (*Store, error) {
if root == "" {
return nil, errors.New("file store root is required")
}
absRoot, err := filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("resolve file store root: %w", err)
}
if err := os.MkdirAll(absRoot, 0o750); err != nil {
return nil, fmt.Errorf("create file store root: %w", err)
}
resolvedRoot, err := filepath.EvalSymlinks(absRoot)
if err != nil {
return nil, fmt.Errorf("resolve file store symlinks: %w", err)
}
return &Store{root: resolvedRoot}, nil
}
// Commit 把内容写入同目录临时文件,校验后通过硬链接原子创建最终文件。
// 最终文件已经存在且身份相同时按幂等成功处理,内容不同时拒绝覆盖。
func (s *Store) Commit(relativePath string, source io.Reader, expected Identity) (File, error) {
if source == nil {
return File{}, errors.New("file source is required")
}
expectedDigest, err := validateIdentity(expected)
if err != nil {
return File{}, err
}
if !filepath.IsLocal(relativePath) || relativePath == "." {
return File{}, fmt.Errorf("file store path is not a local relative path: %q", relativePath)
}
target := filepath.Join(s.root, filepath.Clean(relativePath))
parent := filepath.Dir(target)
if err := os.MkdirAll(parent, 0o750); err != nil {
return File{}, fmt.Errorf("create destination directory: %w", err)
}
if err := s.verifyParent(parent); err != nil {
return File{}, err
}
if existing, found, err := verifyExisting(target, expected, expectedDigest); err != nil {
return File{}, err
} else if found {
return existing, nil
}
temp, err := os.CreateTemp(parent, ".incoming-*")
if err != nil {
return File{}, fmt.Errorf("create temporary file: %w", err)
}
tempPath := temp.Name()
preserveTemp := false
defer func() {
if !preserveTemp {
_ = os.Remove(tempPath)
}
}()
if err := temp.Chmod(0o640); err != nil {
_ = temp.Close()
return File{}, fmt.Errorf("set temporary file permissions: %w", err)
}
digest := sha256.New()
size, copyErr := io.Copy(io.MultiWriter(temp, digest), source)
if copyErr != nil {
_ = temp.Close()
return File{}, fmt.Errorf("write temporary file: %w", copyErr)
}
if size != expected.Size {
_ = temp.Close()
return File{}, fmt.Errorf("file size mismatch: got %d, want %d", size, expected.Size)
}
if !sameDigest(digest, expectedDigest) {
_ = temp.Close()
return File{}, errors.New("file SHA-256 mismatch")
}
if err := temp.Sync(); err != nil {
_ = temp.Close()
return File{}, fmt.Errorf("flush temporary file: %w", err)
}
if err := temp.Close(); err != nil {
return File{}, fmt.Errorf("close temporary file: %w", err)
}
if err := os.Link(tempPath, target); err != nil {
if existing, found, verifyErr := verifyExisting(target, expected, expectedDigest); verifyErr != nil {
return File{}, verifyErr
} else if found {
return existing, nil
}
return File{}, fmt.Errorf("atomically create destination file: %w", err)
}
if err := syncDirectory(parent); err != nil {
// 最终路径已经可见,保留临时硬链接,避免在目录落盘失败时继续改变现场。
preserveTemp = true
return File{}, err
}
if err := os.Remove(tempPath); err != nil {
preserveTemp = true
return File{}, fmt.Errorf("remove committed temporary link: %w", err)
}
if err := syncDirectory(parent); err != nil {
return File{}, err
}
return File{Path: target, Identity: expected}, nil
}
func (s *Store) verifyParent(parent string) error {
resolvedParent, err := filepath.EvalSymlinks(parent)
if err != nil {
return fmt.Errorf("resolve destination directory symlinks: %w", err)
}
relative, err := filepath.Rel(s.root, resolvedParent)
if err != nil {
return fmt.Errorf("compare destination directory with store root: %w", err)
}
if relative != "." && !filepath.IsLocal(relative) {
return fmt.Errorf("destination directory escapes file store root: %q", parent)
}
return nil
}
func validateIdentity(identity Identity) ([]byte, error) {
if identity.Size < 0 {
return nil, errors.New("expected file size must not be negative")
}
digest, err := hex.DecodeString(identity.SHA256)
if err != nil || len(digest) != sha256.Size {
return nil, errors.New("expected SHA-256 must be a 64-character hexadecimal value")
}
return digest, nil
}
func verifyExisting(path string, expected Identity, expectedDigest []byte) (File, bool, error) {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return File{}, false, nil
}
if err != nil {
return File{}, false, fmt.Errorf("inspect destination file: %w", err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return File{}, false, fmt.Errorf("destination is not a regular file: %s", path)
}
if info.Size() != expected.Size {
return File{}, false, ErrDestinationConflict
}
file, err := os.Open(path)
if err != nil {
return File{}, false, fmt.Errorf("open existing destination file: %w", err)
}
digest := sha256.New()
_, copyErr := io.Copy(digest, file)
closeErr := file.Close()
if err := errors.Join(copyErr, closeErr); err != nil {
return File{}, false, fmt.Errorf("hash existing destination file: %w", err)
}
if !sameDigest(digest, expectedDigest) {
return File{}, false, ErrDestinationConflict
}
return File{Path: path, Identity: expected, Reused: true}, true, nil
}
func sameDigest(actual hash.Hash, expected []byte) bool {
return subtle.ConstantTimeCompare(actual.Sum(nil), expected) == 1
}
func syncDirectory(path string) error {
directory, err := os.Open(path)
if err != nil {
return fmt.Errorf("open directory for flush: %w", err)
}
syncErr := directory.Sync()
closeErr := directory.Close()
if err := errors.Join(syncErr, closeErr); err != nil {
return fmt.Errorf("flush directory: %w", err)
}
return nil
}
+111
View File
@@ -0,0 +1,111 @@
package filestore
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"testing"
)
func TestStoreCommitsAndReusesImmutableFile(t *testing.T) {
t.Parallel()
store, err := New(t.TempDir())
if err != nil {
t.Fatalf("create file store: %v", err)
}
content := []byte("signed update package")
identity := identityOf(content)
committed, err := store.Commit("transactions/one/package.zip", bytes.NewReader(content), identity)
if err != nil {
t.Fatalf("commit file: %v", err)
}
if committed.Reused {
t.Fatal("new file reported as reused")
}
actual, err := os.ReadFile(committed.Path)
if err != nil || !bytes.Equal(actual, content) {
t.Fatalf("read committed file: content=%q err=%v", actual, err)
}
reused, err := store.Commit("transactions/one/package.zip", bytes.NewReader(content), identity)
if err != nil {
t.Fatalf("reuse committed file: %v", err)
}
if !reused.Reused || reused.Path != committed.Path {
t.Fatalf("unexpected reused file: %+v", reused)
}
}
func TestStoreRejectsMismatchAndNeverPublishesInvalidFile(t *testing.T) {
t.Parallel()
root := t.TempDir()
store, err := New(root)
if err != nil {
t.Fatalf("create file store: %v", err)
}
content := []byte("actual")
expected := identityOf([]byte("different"))
expected.Size = int64(len(content))
_, err = store.Commit("package.zip", bytes.NewReader(content), expected)
if err == nil {
t.Fatal("SHA-256 mismatch was accepted")
}
if _, statErr := os.Stat(filepath.Join(root, "package.zip")); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("invalid destination was published: %v", statErr)
}
}
func TestStoreRejectsNilSource(t *testing.T) {
t.Parallel()
store, err := New(t.TempDir())
if err != nil {
t.Fatalf("create file store: %v", err)
}
if _, err := store.Commit("package.zip", nil, identityOf(nil)); err == nil {
t.Fatal("nil file source was accepted")
}
}
func TestStoreRejectsExistingDifferentContentAndPathEscape(t *testing.T) {
t.Parallel()
root := t.TempDir()
store, err := New(root)
if err != nil {
t.Fatalf("create file store: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "package.zip"), []byte("old"), 0o640); err != nil {
t.Fatalf("seed destination: %v", err)
}
_, err = store.Commit("package.zip", bytes.NewReader([]byte("new")), identityOf([]byte("new")))
if !errors.Is(err, ErrDestinationConflict) {
t.Fatalf("expected destination conflict, got %v", err)
}
_, err = store.Commit("../outside", bytes.NewReader(nil), identityOf(nil))
if err == nil {
t.Fatal("path traversal was accepted")
}
}
func TestStoreRejectsSymlinkedParentOutsideRoot(t *testing.T) {
t.Parallel()
root := t.TempDir()
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(root, "linked")); err != nil {
t.Fatalf("create parent symlink: %v", err)
}
store, err := New(root)
if err != nil {
t.Fatalf("create file store: %v", err)
}
_, err = store.Commit("linked/package.zip", bytes.NewReader(nil), identityOf(nil))
if err == nil {
t.Fatal("symlinked parent outside root was accepted")
}
}
func identityOf(content []byte) Identity {
digest := sha256.Sum256(content)
return Identity{Size: int64(len(content)), SHA256: hex.EncodeToString(digest[:])}
}
+52
View File
@@ -0,0 +1,52 @@
package logging
import (
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
)
// New 创建同时写入 stdout 和本地文件的结构化文本日志。
// stdout 由 systemd/journald 收集,本地文件用于现场诊断和受控远程读取。
func New(filePath string) (*slog.Logger, io.Closer, error) {
return NewWithConsole(os.Stdout, filePath)
}
// NewWithConsole 允许调用方指定控制台 writer,主要用于测试和嵌入运行。
func NewWithConsole(console io.Writer, filePath string) (*slog.Logger, io.Closer, error) {
if console == nil {
return nil, nil, errors.New("console writer is required")
}
if filePath == "" {
return nil, nil, errors.New("log file path is required")
}
absPath, err := filepath.Abs(filePath)
if err != nil {
return nil, nil, fmt.Errorf("resolve log file path: %w", err)
}
if err := os.MkdirAll(filepath.Dir(absPath), 0o750); err != nil {
return nil, nil, fmt.Errorf("create log directory: %w", err)
}
file, err := os.OpenFile(absPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o640)
if err != nil {
return nil, nil, fmt.Errorf("open log file: %w", err)
}
handler := slog.NewTextHandler(io.MultiWriter(console, file), &slog.HandlerOptions{Level: slog.LevelInfo})
return slog.New(handler), &syncFileCloser{file: file}, nil
}
type syncFileCloser struct {
file *os.File
}
func (c *syncFileCloser) Close() error {
if c == nil || c.file == nil {
return nil
}
file := c.file
c.file = nil
return errors.Join(file.Sync(), file.Close())
}
+35
View File
@@ -0,0 +1,35 @@
package logging
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoggerWritesConsoleAndFile(t *testing.T) {
t.Parallel()
var console bytes.Buffer
path := filepath.Join(t.TempDir(), "daemon.log")
logger, closer, err := NewWithConsole(&console, path)
if err != nil {
t.Fatalf("create logger: %v", err)
}
logger.Info("transaction created", "transaction_id", "transaction-1")
if err := closer.Close(); err != nil {
t.Fatalf("close logger: %v", err)
}
fileContent, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read log file: %v", err)
}
for name, content := range map[string]string{
"console": console.String(),
"file": string(fileContent),
} {
if !strings.Contains(content, "transaction created") || !strings.Contains(content, "transaction_id=transaction-1") {
t.Fatalf("%s log is missing fields: %q", name, content)
}
}
}
+10
View File
@@ -0,0 +1,10 @@
package processlock
import "errors"
var (
// ErrAlreadyLocked 表示另一个 serve 进程已经持有内核锁。
ErrAlreadyLocked = errors.New("process lock is already held")
// ErrUnsupported 表示当前操作系统不能运行服务端进程锁。
ErrUnsupported = errors.New("process lock is not supported on this operating system")
)
+84
View File
@@ -0,0 +1,84 @@
//go:build linux
package processlock
import (
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"golang.org/x/sys/unix"
)
// Lock 持有与文件描述符绑定的 Linux advisory flock。
// 锁文件可以永久存在;只有内核锁状态表示当前所有权。
type Lock struct {
mu sync.Mutex
file *os.File
}
// Acquire 以非阻塞方式获取排他锁,并把当前 PID 写入文件用于诊断。
func Acquire(path string) (*Lock, error) {
if path == "" {
return nil, errors.New("process lock path is required")
}
absPath, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("resolve process lock path: %w", err)
}
if err := os.MkdirAll(filepath.Dir(absPath), 0o750); err != nil {
return nil, fmt.Errorf("create process lock directory: %w", err)
}
file, err := os.OpenFile(absPath, os.O_CREATE|os.O_RDWR, 0o640)
if err != nil {
return nil, fmt.Errorf("open process lock: %w", err)
}
if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
_ = file.Close()
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
return nil, ErrAlreadyLocked
}
return nil, fmt.Errorf("acquire process lock: %w", err)
}
if err := writeOwnerPID(file); err != nil {
_ = unix.Flock(int(file.Fd()), unix.LOCK_UN)
_ = file.Close()
return nil, err
}
return &Lock{file: file}, nil
}
func writeOwnerPID(file *os.File) error {
if err := file.Truncate(0); err != nil {
return fmt.Errorf("truncate process lock metadata: %w", err)
}
if _, err := file.Seek(0, 0); err != nil {
return fmt.Errorf("seek process lock metadata: %w", err)
}
if _, err := fmt.Fprintf(file, "%d\n", os.Getpid()); err != nil {
return fmt.Errorf("write process lock metadata: %w", err)
}
if err := file.Sync(); err != nil {
return fmt.Errorf("flush process lock metadata: %w", err)
}
return nil
}
// Close 释放内核锁并关闭文件描述符,不删除锁文件。
func (l *Lock) Close() error {
if l == nil {
return nil
}
l.mu.Lock()
defer l.mu.Unlock()
if l.file == nil {
return nil
}
file := l.file
l.file = nil
unlockErr := unix.Flock(int(file.Fd()), unix.LOCK_UN)
closeErr := file.Close()
return errors.Join(unlockErr, closeErr)
}
+40
View File
@@ -0,0 +1,40 @@
//go:build linux
package processlock
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestLockUsesKernelOwnershipAndLeavesFileInPlace(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "serve.lock")
first, err := Acquire(path)
if err != nil {
t.Fatalf("acquire first lock: %v", err)
}
second, err := Acquire(path)
if !errors.Is(err, ErrAlreadyLocked) || second != nil {
t.Fatalf("expected second acquire to fail with lock ownership, lock=%v err=%v", second, err)
}
metadata, err := os.ReadFile(path)
if err != nil || len(metadata) == 0 {
t.Fatalf("read diagnostic PID: data=%q err=%v", metadata, err)
}
if err := first.Close(); err != nil {
t.Fatalf("release first lock: %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("lock file should remain after release: %v", err)
}
third, err := Acquire(path)
if err != nil {
t.Fatalf("reacquire existing lock file: %v", err)
}
if err := third.Close(); err != nil {
t.Fatalf("release reacquired lock: %v", err)
}
}
+16
View File
@@ -0,0 +1,16 @@
//go:build !linux
package processlock
// Lock 仅用于让共享代码在非 Linux client 构建中保持可编译。
type Lock struct{}
// Acquire 明确拒绝在非 Linux 系统启动服务端进程锁。
func Acquire(string) (*Lock, error) {
return nil, ErrUnsupported
}
// Close 对未获取的非 Linux 锁不执行操作。
func (l *Lock) Close() error {
return nil
}
+169
View File
@@ -0,0 +1,169 @@
package transaction
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
)
// InspectionStatus 表示外部系统中某一步的实际结果。
type InspectionStatus string
const (
InspectionApplied InspectionStatus = "APPLIED"
InspectionNotApplied InspectionStatus = "NOT_APPLIED"
InspectionUnknown InspectionStatus = "UNKNOWN"
)
// Inspection 是执行器通过 inspect、摘要、健康检查等方式得到的实际状态。
type Inspection struct {
Status InspectionStatus
Result json.RawMessage
}
// Operation 是一个可核对实际结果的外部副作用。
// Apply 返回成功只代表调用完成;最终成功必须由 Inspect 确认。
type Operation interface {
Apply(context.Context) error
Inspect(context.Context) (Inspection, error)
}
// UncertainStepError 表示当前无法确认外部副作用是否已经发生。
// 这种错误必须保留 INTENT_RECORDED,等待恢复流程再次核对。
type UncertainStepError struct {
TransactionID string
StepKey string
Cause error
}
func (e *UncertainStepError) Error() string {
return fmt.Sprintf("external step result is uncertain: transaction=%s step=%s: %v", e.TransactionID, e.StepKey, e.Cause)
}
func (e *UncertainStepError) Unwrap() error {
return e.Cause
}
// Coordinator 串行化单机更新,并实现“先记录意图、再执行、最后 inspect”的步骤协议。
type Coordinator struct {
store *Store
logger *slog.Logger
permit chan struct{}
}
func NewCoordinator(store *Store, logger *slog.Logger) (*Coordinator, error) {
if store == nil {
return nil, errors.New("transaction store is required")
}
if logger == nil {
logger = slog.Default()
}
permit := make(chan struct{}, 1)
permit <- struct{}{}
return &Coordinator{store: store, logger: logger, permit: permit}, nil
}
// RunExclusive 在一个进程内只允许一个完整更新流程进入执行区。
func (c *Coordinator) RunExclusive(ctx context.Context, run func(context.Context) error) error {
if run == nil {
return errors.New("exclusive update function is required")
}
select {
case <-ctx.Done():
return ctx.Err()
case <-c.permit:
}
defer func() { c.permit <- struct{}{} }()
return run(ctx)
}
// ExecuteStep 执行或恢复一个外部步骤。
// 相同 step key 再次调用时先核对现场,禁止直接重复 Apply。
func (c *Coordinator) ExecuteStep(ctx context.Context, transactionID string, intent StepIntent, operation Operation) (Step, error) {
if operation == nil {
return Step{}, errors.New("external operation is required")
}
step, created, err := c.store.RecordStepIntent(ctx, transactionID, intent)
if err != nil {
return Step{}, err
}
if step.Status == StepSucceeded {
return step, nil
}
if step.Status == StepFailed {
return step, fmt.Errorf("external step already failed: %s", step.Error)
}
if !created {
inspection, err := operation.Inspect(ctx)
if err != nil {
return step, c.uncertain(ctx, transactionID, intent.Key, err)
}
switch inspection.Status {
case InspectionApplied:
return c.completeApplied(ctx, transactionID, intent.Key, inspection)
case InspectionUnknown:
return step, c.uncertain(ctx, transactionID, intent.Key, errors.New("inspect returned UNKNOWN"))
case InspectionNotApplied:
// 现场明确未发生副作用后,才允许恢复流程重新 Apply。
default:
return step, c.uncertain(ctx, transactionID, intent.Key, fmt.Errorf("inspect returned invalid status %q", inspection.Status))
}
}
c.logger.InfoContext(ctx, "external step apply started",
"transaction_id", transactionID,
"step_key", intent.Key,
"step_name", intent.Name,
)
applyErr := operation.Apply(ctx)
inspection, inspectErr := operation.Inspect(ctx)
if inspectErr != nil {
return step, c.uncertain(ctx, transactionID, intent.Key, errors.Join(applyErr, inspectErr))
}
switch inspection.Status {
case InspectionApplied:
completed, err := c.completeApplied(ctx, transactionID, intent.Key, inspection)
if err == nil {
c.logger.InfoContext(ctx, "external step applied",
"transaction_id", transactionID,
"step_key", intent.Key,
)
}
return completed, err
case InspectionUnknown:
return step, c.uncertain(ctx, transactionID, intent.Key, errors.Join(applyErr, errors.New("inspect returned UNKNOWN")))
case InspectionNotApplied:
failure := applyErr
if failure == nil {
failure = errors.New("operation completed without reaching the expected external state")
}
completed, err := c.store.CompleteStep(ctx, transactionID, intent.Key, StepFailed, inspection.Result, failure.Error())
if err != nil {
return Step{}, errors.Join(failure, err)
}
c.logger.ErrorContext(ctx, "external step failed",
"transaction_id", transactionID,
"step_key", intent.Key,
"error", failure,
)
return completed, failure
default:
return step, c.uncertain(ctx, transactionID, intent.Key, fmt.Errorf("inspect returned invalid status %q", inspection.Status))
}
}
func (c *Coordinator) completeApplied(ctx context.Context, transactionID, stepKey string, inspection Inspection) (Step, error) {
return c.store.CompleteStep(ctx, transactionID, stepKey, StepSucceeded, inspection.Result, "")
}
func (c *Coordinator) uncertain(ctx context.Context, transactionID, stepKey string, cause error) error {
c.logger.WarnContext(ctx, "external step result is uncertain",
"transaction_id", transactionID,
"step_key", stepKey,
"error", cause,
)
return &UncertainStepError{TransactionID: transactionID, StepKey: stepKey, Cause: cause}
}
+154
View File
@@ -0,0 +1,154 @@
package transaction
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"sync/atomic"
"testing"
)
func TestCoordinatorRecoversIntentWithoutRepeatingAppliedOperation(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := openTestStore(t)
record := createTestTransaction(t, store, "recover-applied")
intent := StepIntent{Key: "start-green", Name: "start green", Intent: json.RawMessage(`{"slot":"green"}`)}
if _, _, err := store.RecordStepIntent(ctx, record.ID, intent); err != nil {
t.Fatalf("record crash-window intent: %v", err)
}
operation := &fakeOperation{applied: true, result: json.RawMessage(`{"running":true}`)}
coordinator := newTestCoordinator(t, store)
step, err := coordinator.ExecuteStep(ctx, record.ID, intent, operation)
if err != nil {
t.Fatalf("recover applied operation: %v", err)
}
if step.Status != StepSucceeded || operation.applyCalls.Load() != 0 || operation.inspectCalls.Load() != 1 {
t.Fatalf("unexpected recovery result: step=%+v apply=%d inspect=%d", step, operation.applyCalls.Load(), operation.inspectCalls.Load())
}
}
func TestCoordinatorRecoversNotAppliedIntentThenExecutesOnce(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := openTestStore(t)
record := createTestTransaction(t, store, "recover-not-applied")
intent := StepIntent{Key: "write-files", Name: "write files", Intent: json.RawMessage(`{}`)}
if _, _, err := store.RecordStepIntent(ctx, record.ID, intent); err != nil {
t.Fatalf("record crash-window intent: %v", err)
}
operation := &fakeOperation{result: json.RawMessage(`{"written":true}`)}
coordinator := newTestCoordinator(t, store)
step, err := coordinator.ExecuteStep(ctx, record.ID, intent, operation)
if err != nil {
t.Fatalf("recover not-applied operation: %v", err)
}
if step.Status != StepSucceeded || operation.applyCalls.Load() != 1 || operation.inspectCalls.Load() != 2 {
t.Fatalf("unexpected recovery result: step=%+v apply=%d inspect=%d", step, operation.applyCalls.Load(), operation.inspectCalls.Load())
}
}
func TestCoordinatorLeavesIntentPendingWhenInspectionIsUnknown(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := openTestStore(t)
record := createTestTransaction(t, store, "recover-unknown")
intent := StepIntent{Key: "reload-gateway", Name: "reload gateway", Intent: json.RawMessage(`{}`)}
operation := &fakeOperation{inspectErr: errors.New("gateway unavailable")}
coordinator := newTestCoordinator(t, store)
step, err := coordinator.ExecuteStep(ctx, record.ID, intent, operation)
var uncertain *UncertainStepError
if !errors.As(err, &uncertain) {
t.Fatalf("expected uncertain step error, got step=%+v err=%v", step, err)
}
pending, err := store.PendingSteps(ctx, record.ID)
if err != nil {
t.Fatalf("read pending steps: %v", err)
}
if len(pending) != 1 || pending[0].Status != StepIntentRecorded {
t.Fatalf("uncertain step did not remain pending: %+v", pending)
}
}
func TestCoordinatorExclusiveExecutionHonorsContext(t *testing.T) {
t.Parallel()
store := openTestStore(t)
coordinator := newTestCoordinator(t, store)
firstEntered := make(chan struct{})
releaseFirst := make(chan struct{})
firstDone := make(chan error, 1)
go func() {
firstDone <- coordinator.RunExclusive(context.Background(), func(context.Context) error {
close(firstEntered)
<-releaseFirst
return nil
})
}()
<-firstEntered
cancelled, cancel := context.WithCancel(context.Background())
cancel()
err := coordinator.RunExclusive(cancelled, func(context.Context) error {
t.Fatal("cancelled update entered exclusive section")
return nil
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context cancellation, got %v", err)
}
close(releaseFirst)
if err := <-firstDone; err != nil {
t.Fatalf("first exclusive update failed: %v", err)
}
}
type fakeOperation struct {
applied bool
result json.RawMessage
applyErr error
inspectErr error
applyCalls atomic.Int32
inspectCalls atomic.Int32
}
func (o *fakeOperation) Apply(context.Context) error {
o.applyCalls.Add(1)
if o.applyErr == nil {
o.applied = true
}
return o.applyErr
}
func (o *fakeOperation) Inspect(context.Context) (Inspection, error) {
o.inspectCalls.Add(1)
if o.inspectErr != nil {
return Inspection{}, o.inspectErr
}
if o.applied {
return Inspection{Status: InspectionApplied, Result: o.result}, nil
}
return Inspection{Status: InspectionNotApplied}, nil
}
func createTestTransaction(t *testing.T, store *Store, suffix string) Transaction {
t.Helper()
record, _, err := store.CreateTransaction(context.Background(), CreateRequest{
ID: "transaction-" + suffix,
IdempotencyKey: "request-" + suffix,
Source: "test",
Service: "backend",
})
if err != nil {
t.Fatalf("create test transaction: %v", err)
}
return record
}
func newTestCoordinator(t *testing.T, store *Store) *Coordinator {
t.Helper()
coordinator, err := NewCoordinator(store, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("create coordinator: %v", err)
}
return coordinator
}
+26
View File
@@ -0,0 +1,26 @@
package transaction
import (
"errors"
"fmt"
)
var (
ErrNotFound = errors.New("transaction record not found")
ErrActiveExists = errors.New("an unfinished transaction already exists")
ErrStepConflict = errors.New("step key already refers to different intent")
ErrStepNotPending = errors.New("step is not waiting for an execution result")
)
// ActiveTransactionError 告知调用方当前阻塞新请求的事务。
type ActiveTransactionError struct {
TransactionID string
}
func (e *ActiveTransactionError) Error() string {
return fmt.Sprintf("%s: %s", ErrActiveExists, e.TransactionID)
}
func (e *ActiveTransactionError) Unwrap() error {
return ErrActiveExists
}
+69
View File
@@ -0,0 +1,69 @@
package transaction
import (
"encoding/json"
"time"
)
// Transaction 是一次更新请求的服务端持久化快照。
type Transaction struct {
ID string
IdempotencyKey string
Source string
Service string
Request json.RawMessage
State State
Version int64
CreatedAt time.Time
UpdatedAt time.Time
}
// CreateRequest 包含创建事务所需的不可变请求信息。
type CreateRequest struct {
ID string
IdempotencyKey string
Source string
Service string
Request json.RawMessage
}
// StepStatus 是外部步骤的持久化执行状态。
type StepStatus string
const (
StepIntentRecorded StepStatus = "INTENT_RECORDED"
StepSucceeded StepStatus = "SUCCEEDED"
StepFailed StepStatus = "FAILED"
)
// Step 记录一次外部副作用的意图和最终核对结果。
type Step struct {
TransactionID string
Key string
Name string
Status StepStatus
Intent json.RawMessage
Result json.RawMessage
Error string
CreatedAt time.Time
UpdatedAt time.Time
}
// StepIntent 是执行外部操作前必须先持久化的内容。
type StepIntent struct {
Key string
Name string
Intent json.RawMessage
}
// Event 是供查询和 WSS 断联恢复使用的顺序事件。
type Event struct {
Sequence int64
TransactionID string
StepKey string
Kind string
FromState State
ToState State
Message string
CreatedAt time.Time
}
+79
View File
@@ -0,0 +1,79 @@
package transaction
import "fmt"
// State 是服务端更新事务的持久化状态。
type State string
const (
StateCreated State = "CREATED"
StateValidating State = "VALIDATING"
StatePrepared State = "PREPARED"
StateStarting State = "STARTING"
StateSwitching State = "SWITCHING"
StateVerifying State = "VERIFYING"
StateDraining State = "DRAINING"
StateCommitted State = "COMMITTED"
StateRollingBack State = "ROLLING_BACK"
StateRolledBack State = "ROLLED_BACK"
StateFailed State = "FAILED"
)
var forwardTransitions = map[State]State{
StateCreated: StateValidating,
StateValidating: StatePrepared,
StatePrepared: StateStarting,
StateStarting: StateSwitching,
StateSwitching: StateVerifying,
StateVerifying: StateDraining,
StateDraining: StateCommitted,
}
// Valid 报告状态是否属于当前状态机协议。
func (s State) Valid() bool {
switch s {
case StateCreated,
StateValidating,
StatePrepared,
StateStarting,
StateSwitching,
StateVerifying,
StateDraining,
StateCommitted,
StateRollingBack,
StateRolledBack,
StateFailed:
return true
default:
return false
}
}
// Terminal 报告事务是否已经不可再推进。
func (s State) Terminal() bool {
return s == StateCommitted || s == StateRolledBack || s == StateFailed
}
// CanTransitionTo 校验正常推进、回滚和不可恢复失败三类转换。
func (s State) CanTransitionTo(next State) bool {
if !s.Valid() || !next.Valid() || s.Terminal() {
return false
}
if s == StateRollingBack {
return next == StateRolledBack || next == StateFailed
}
if next == StateRollingBack || next == StateFailed {
return true
}
return forwardTransitions[s] == next
}
// TransitionError 表示状态机拒绝了一次转换。
type TransitionError struct {
From State
To State
}
func (e *TransitionError) Error() string {
return fmt.Sprintf("transaction state transition is not allowed: %s -> %s", e.From, e.To)
}
+34
View File
@@ -0,0 +1,34 @@
package transaction
import "testing"
func TestStateTransitions(t *testing.T) {
t.Parallel()
forward := []State{
StateCreated,
StateValidating,
StatePrepared,
StateStarting,
StateSwitching,
StateVerifying,
StateDraining,
StateCommitted,
}
for index := 0; index < len(forward)-1; index++ {
if !forward[index].CanTransitionTo(forward[index+1]) {
t.Fatalf("expected transition %s -> %s", forward[index], forward[index+1])
}
}
if StateCreated.CanTransitionTo(StatePrepared) {
t.Fatal("state machine accepted a skipped forward state")
}
if !StateSwitching.CanTransitionTo(StateRollingBack) {
t.Fatal("state machine rejected rollback")
}
if !StateRollingBack.CanTransitionTo(StateRolledBack) {
t.Fatal("state machine rejected rollback completion")
}
if StateCommitted.CanTransitionTo(StateRollingBack) {
t.Fatal("terminal state accepted another transition")
}
}
+704
View File
@@ -0,0 +1,704 @@
package transaction
import (
"bytes"
"context"
"crypto/rand"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/ncruces/go-sqlite3/driver"
)
const schemaVersion = 1
const schemaV1 = `
CREATE TABLE transactions (
id TEXT PRIMARY KEY,
idempotency_key TEXT NOT NULL UNIQUE,
source TEXT NOT NULL,
service TEXT NOT NULL,
request_json TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN (
'CREATED', 'VALIDATING', 'PREPARED', 'STARTING', 'SWITCHING',
'VERIFYING', 'DRAINING', 'COMMITTED', 'ROLLING_BACK',
'ROLLED_BACK', 'FAILED'
)),
version INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
) STRICT;
CREATE UNIQUE INDEX one_unfinished_transaction
ON transactions ((1))
WHERE state NOT IN ('COMMITTED', 'ROLLED_BACK', 'FAILED');
CREATE TABLE transaction_steps (
transaction_id TEXT NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
step_key TEXT NOT NULL,
name TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('INTENT_RECORDED', 'SUCCEEDED', 'FAILED')),
intent_json TEXT NOT NULL,
result_json TEXT,
error_message TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (transaction_id, step_key)
) STRICT;
CREATE TABLE transaction_events (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
transaction_id TEXT NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
step_key TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL,
from_state TEXT NOT NULL DEFAULT '',
to_state TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
) STRICT;
CREATE INDEX transaction_events_by_transaction
ON transaction_events (transaction_id, sequence);
PRAGMA user_version = 1;
`
// Store 是服务端 SQLite 事务记录。一个进程只应创建一个 Store。
type Store struct {
db *sql.DB
now func() time.Time
}
// OpenStore 打开本地 SQLite,并强制校验第一版持久化参数。
func OpenStore(ctx context.Context, path string) (*Store, error) {
if path == "" {
return nil, errors.New("sqlite path is required")
}
absPath, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("resolve sqlite path: %w", err)
}
if err := os.MkdirAll(filepath.Dir(absPath), 0o750); err != nil {
return nil, fmt.Errorf("create sqlite directory: %w", err)
}
dsn := (&url.URL{
Scheme: "file",
Path: absPath,
RawQuery: url.Values{"_txlock": {"immediate"}}.Encode(),
}).String()
db, err := driver.Open(dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite driver: %w", err)
}
// 单连接是服务端事务串行化的一部分,也保证连接级 PRAGMA 始终生效。
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
closeOnError := func(cause error) (*Store, error) {
_ = db.Close()
return nil, cause
}
if err := db.PingContext(ctx); err != nil {
return closeOnError(fmt.Errorf("ping sqlite: %w", err))
}
if err := configureSQLite(ctx, db); err != nil {
return closeOnError(err)
}
if err := migrate(ctx, db); err != nil {
return closeOnError(err)
}
if err := os.Chmod(absPath, 0o600); err != nil {
return closeOnError(fmt.Errorf("set sqlite permissions: %w", err))
}
return &Store{db: db, now: time.Now}, nil
}
func configureSQLite(ctx context.Context, db *sql.DB) error {
var journalMode string
if err := db.QueryRowContext(ctx, "PRAGMA journal_mode = DELETE").Scan(&journalMode); err != nil {
return fmt.Errorf("set sqlite journal mode: %w", err)
}
if journalMode != "delete" {
return fmt.Errorf("sqlite journal mode mismatch: got %q, want %q", journalMode, "delete")
}
if _, err := db.ExecContext(ctx, "PRAGMA synchronous = EXTRA"); err != nil {
return fmt.Errorf("set sqlite synchronous: %w", err)
}
var synchronous int
if err := db.QueryRowContext(ctx, "PRAGMA synchronous").Scan(&synchronous); err != nil {
return fmt.Errorf("read sqlite synchronous: %w", err)
}
if synchronous != 3 {
return fmt.Errorf("sqlite synchronous mismatch: got %d, want 3 (EXTRA)", synchronous)
}
if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
return fmt.Errorf("enable sqlite foreign keys: %w", err)
}
var foreignKeys int
if err := db.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&foreignKeys); err != nil {
return fmt.Errorf("read sqlite foreign_keys: %w", err)
}
if foreignKeys != 1 {
return errors.New("sqlite foreign_keys is not enabled")
}
return nil
}
func migrate(ctx context.Context, db *sql.DB) error {
var version int
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
return fmt.Errorf("read sqlite schema version: %w", err)
}
if version > schemaVersion {
return fmt.Errorf("sqlite schema version %d is newer than supported version %d", version, schemaVersion)
}
if version == schemaVersion {
return nil
}
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return fmt.Errorf("begin sqlite migration: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, schemaV1); err != nil {
return fmt.Errorf("apply sqlite schema version 1: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit sqlite migration: %w", err)
}
return nil
}
// Close 关闭服务端 SQLite。
func (s *Store) Close() error {
return s.db.Close()
}
// CreateTransaction 原子处理幂等重试和单活动事务约束。created=false 表示返回已有幂等事务。
func (s *Store) CreateTransaction(ctx context.Context, request CreateRequest) (record Transaction, created bool, err error) {
if err := validateCreateRequest(&request); err != nil {
return Transaction{}, false, err
}
if request.ID == "" {
request.ID = rand.Text()
}
now := s.now().UTC()
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return Transaction{}, false, fmt.Errorf("begin create transaction: %w", err)
}
defer tx.Rollback()
existing, err := getTransactionByIdempotencyKey(ctx, tx, request.IdempotencyKey)
if err == nil {
return existing, false, nil
}
if !errors.Is(err, ErrNotFound) {
return Transaction{}, false, err
}
active, err := getActiveTransaction(ctx, tx)
if err == nil {
return Transaction{}, false, &ActiveTransactionError{TransactionID: active.ID}
}
if !errors.Is(err, ErrNotFound) {
return Transaction{}, false, err
}
requestJSON := string(request.Request)
_, err = tx.ExecContext(ctx, `
INSERT INTO transactions (
id, idempotency_key, source, service, request_json,
state, version, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`,
request.ID,
request.IdempotencyKey,
request.Source,
request.Service,
requestJSON,
StateCreated,
formatTime(now),
formatTime(now),
)
if err != nil {
return Transaction{}, false, fmt.Errorf("insert transaction: %w", err)
}
if err := insertEvent(ctx, tx, request.ID, "", "TRANSACTION_CREATED", "", StateCreated, "", now); err != nil {
return Transaction{}, false, err
}
if err := tx.Commit(); err != nil {
return Transaction{}, false, fmt.Errorf("commit create transaction: %w", err)
}
return Transaction{
ID: request.ID,
IdempotencyKey: request.IdempotencyKey,
Source: request.Source,
Service: request.Service,
Request: cloneJSON(request.Request),
State: StateCreated,
Version: 1,
CreatedAt: now,
UpdatedAt: now,
}, true, nil
}
func validateCreateRequest(request *CreateRequest) error {
if strings.TrimSpace(request.IdempotencyKey) == "" {
return errors.New("idempotency key is required")
}
if strings.TrimSpace(request.Source) == "" {
return errors.New("transaction source is required")
}
if strings.TrimSpace(request.Service) == "" {
return errors.New("transaction service is required")
}
if len(request.Request) == 0 {
request.Request = json.RawMessage(`{}`)
}
if !json.Valid(request.Request) {
return errors.New("transaction request is not valid JSON")
}
return nil
}
// Transaction 返回指定事务的最新持久化快照。
func (s *Store) Transaction(ctx context.Context, id string) (Transaction, error) {
return getTransactionByID(ctx, s.db, id)
}
// ActiveTransaction 返回当前唯一未结束事务。
func (s *Store) ActiveTransaction(ctx context.Context) (Transaction, error) {
return getActiveTransaction(ctx, s.db)
}
// Transition 校验并原子提交状态变化及其恢复事件。
func (s *Store) Transition(ctx context.Context, id string, next State, message string) (Transaction, error) {
if !next.Valid() {
return Transaction{}, fmt.Errorf("unknown transaction state: %q", next)
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return Transaction{}, fmt.Errorf("begin state transition: %w", err)
}
defer tx.Rollback()
record, err := getTransactionByID(ctx, tx, id)
if err != nil {
return Transaction{}, err
}
if record.State == next {
return record, nil
}
if !record.State.CanTransitionTo(next) {
return Transaction{}, &TransitionError{From: record.State, To: next}
}
now := s.now().UTC()
result, err := tx.ExecContext(ctx, `
UPDATE transactions
SET state = ?, version = version + 1, updated_at = ?
WHERE id = ? AND version = ?`,
next, formatTime(now), id, record.Version,
)
if err != nil {
return Transaction{}, fmt.Errorf("update transaction state: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return Transaction{}, fmt.Errorf("read updated transaction rows: %w", err)
}
if rows != 1 {
return Transaction{}, errors.New("transaction changed concurrently")
}
if err := insertEvent(ctx, tx, id, "", "TRANSACTION_STATE_CHANGED", record.State, next, message, now); err != nil {
return Transaction{}, err
}
if err := tx.Commit(); err != nil {
return Transaction{}, fmt.Errorf("commit state transition: %w", err)
}
record.State = next
record.Version++
record.UpdatedAt = now
return record, nil
}
// RecordStepIntent 先于外部副作用持久化步骤意图。created=false 表示相同意图已经存在。
func (s *Store) RecordStepIntent(ctx context.Context, transactionID string, intent StepIntent) (record Step, created bool, err error) {
if strings.TrimSpace(intent.Key) == "" {
return Step{}, false, errors.New("step key is required")
}
if strings.TrimSpace(intent.Name) == "" {
return Step{}, false, errors.New("step name is required")
}
if len(intent.Intent) == 0 {
intent.Intent = json.RawMessage(`{}`)
}
if !json.Valid(intent.Intent) {
return Step{}, false, errors.New("step intent is not valid JSON")
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return Step{}, false, fmt.Errorf("begin record step intent: %w", err)
}
defer tx.Rollback()
transactionRecord, err := getTransactionByID(ctx, tx, transactionID)
if err != nil {
return Step{}, false, err
}
if transactionRecord.State.Terminal() {
return Step{}, false, fmt.Errorf("cannot record a step for terminal transaction %s", transactionID)
}
existing, err := getStep(ctx, tx, transactionID, intent.Key)
if err == nil {
if existing.Name != intent.Name || !bytes.Equal(existing.Intent, intent.Intent) {
return Step{}, false, ErrStepConflict
}
return existing, false, nil
}
if !errors.Is(err, ErrNotFound) {
return Step{}, false, err
}
now := s.now().UTC()
_, err = tx.ExecContext(ctx, `
INSERT INTO transaction_steps (
transaction_id, step_key, name, status, intent_json,
result_json, error_message, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, NULL, '', ?, ?)`,
transactionID,
intent.Key,
intent.Name,
StepIntentRecorded,
string(intent.Intent),
formatTime(now),
formatTime(now),
)
if err != nil {
return Step{}, false, fmt.Errorf("insert step intent: %w", err)
}
if err := insertEvent(ctx, tx, transactionID, intent.Key, "STEP_INTENT_RECORDED", "", "", intent.Name, now); err != nil {
return Step{}, false, err
}
if err := tx.Commit(); err != nil {
return Step{}, false, fmt.Errorf("commit step intent: %w", err)
}
return Step{
TransactionID: transactionID,
Key: intent.Key,
Name: intent.Name,
Status: StepIntentRecorded,
Intent: cloneJSON(intent.Intent),
CreatedAt: now,
UpdatedAt: now,
}, true, nil
}
// CompleteStep 原子记录外部状态核对后的最终结果。
func (s *Store) CompleteStep(ctx context.Context, transactionID, stepKey string, status StepStatus, result json.RawMessage, errorMessage string) (Step, error) {
if status != StepSucceeded && status != StepFailed {
return Step{}, fmt.Errorf("invalid final step status: %q", status)
}
if len(result) > 0 && !json.Valid(result) {
return Step{}, errors.New("step result is not valid JSON")
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return Step{}, fmt.Errorf("begin complete step: %w", err)
}
defer tx.Rollback()
record, err := getStep(ctx, tx, transactionID, stepKey)
if err != nil {
return Step{}, err
}
if record.Status == status {
if !bytes.Equal(record.Result, result) || record.Error != errorMessage {
return Step{}, ErrStepConflict
}
return record, nil
}
if record.Status != StepIntentRecorded {
return Step{}, ErrStepNotPending
}
now := s.now().UTC()
var resultValue any
if len(result) > 0 {
resultValue = string(result)
}
updateResult, err := tx.ExecContext(ctx, `
UPDATE transaction_steps
SET status = ?, result_json = ?, error_message = ?, updated_at = ?
WHERE transaction_id = ? AND step_key = ? AND status = ?`,
status,
resultValue,
errorMessage,
formatTime(now),
transactionID,
stepKey,
StepIntentRecorded,
)
if err != nil {
return Step{}, fmt.Errorf("update step result: %w", err)
}
updatedRows, err := updateResult.RowsAffected()
if err != nil {
return Step{}, fmt.Errorf("read updated step rows: %w", err)
}
if updatedRows != 1 {
return Step{}, errors.New("step changed concurrently")
}
eventKind := "STEP_SUCCEEDED"
if status == StepFailed {
eventKind = "STEP_FAILED"
}
if err := insertEvent(ctx, tx, transactionID, stepKey, eventKind, "", "", errorMessage, now); err != nil {
return Step{}, err
}
if err := tx.Commit(); err != nil {
return Step{}, fmt.Errorf("commit step result: %w", err)
}
record.Status = status
record.Result = cloneJSON(result)
record.Error = errorMessage
record.UpdatedAt = now
return record, nil
}
// PendingSteps 返回重启后必须先核对实际外部状态的步骤。
func (s *Store) PendingSteps(ctx context.Context, transactionID string) ([]Step, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT transaction_id, step_key, name, status, intent_json,
result_json, error_message, created_at, updated_at
FROM transaction_steps
WHERE transaction_id = ? AND status = ?
ORDER BY created_at, step_key`, transactionID, StepIntentRecorded)
if err != nil {
return nil, fmt.Errorf("query pending steps: %w", err)
}
defer rows.Close()
var records []Step
for rows.Next() {
record, err := scanStep(rows)
if err != nil {
return nil, err
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate pending steps: %w", err)
}
return records, nil
}
// EventsAfter 返回指定顺序位置之后的事务事件。
func (s *Store) EventsAfter(ctx context.Context, transactionID string, afterSequence int64, limit int) ([]Event, error) {
if afterSequence < 0 {
return nil, errors.New("event sequence must not be negative")
}
if limit < 1 || limit > 1000 {
return nil, errors.New("event limit must be between 1 and 1000")
}
rows, err := s.db.QueryContext(ctx, `
SELECT sequence, transaction_id, step_key, kind,
from_state, to_state, message, created_at
FROM transaction_events
WHERE transaction_id = ? AND sequence > ?
ORDER BY sequence
LIMIT ?`, transactionID, afterSequence, limit)
if err != nil {
return nil, fmt.Errorf("query transaction events: %w", err)
}
defer rows.Close()
var events []Event
for rows.Next() {
var event Event
var fromState, toState, createdAt string
if err := rows.Scan(
&event.Sequence,
&event.TransactionID,
&event.StepKey,
&event.Kind,
&fromState,
&toState,
&event.Message,
&createdAt,
); err != nil {
return nil, fmt.Errorf("scan transaction event: %w", err)
}
event.FromState = State(fromState)
event.ToState = State(toState)
event.CreatedAt, err = parseTime(createdAt)
if err != nil {
return nil, err
}
events = append(events, event)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate transaction events: %w", err)
}
return events, nil
}
type queryRower interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
type rowScanner interface {
Scan(...any) error
}
func getTransactionByID(ctx context.Context, query queryRower, id string) (Transaction, error) {
return scanTransaction(query.QueryRowContext(ctx, `
SELECT id, idempotency_key, source, service, request_json,
state, version, created_at, updated_at
FROM transactions
WHERE id = ?`, id))
}
func getTransactionByIdempotencyKey(ctx context.Context, query queryRower, key string) (Transaction, error) {
return scanTransaction(query.QueryRowContext(ctx, `
SELECT id, idempotency_key, source, service, request_json,
state, version, created_at, updated_at
FROM transactions
WHERE idempotency_key = ?`, key))
}
func getActiveTransaction(ctx context.Context, query queryRower) (Transaction, error) {
return scanTransaction(query.QueryRowContext(ctx, `
SELECT id, idempotency_key, source, service, request_json,
state, version, created_at, updated_at
FROM transactions
WHERE state NOT IN (?, ?, ?)
LIMIT 1`, StateCommitted, StateRolledBack, StateFailed))
}
func scanTransaction(row rowScanner) (Transaction, error) {
var record Transaction
var requestJSON, state, createdAt, updatedAt string
if err := row.Scan(
&record.ID,
&record.IdempotencyKey,
&record.Source,
&record.Service,
&requestJSON,
&state,
&record.Version,
&createdAt,
&updatedAt,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Transaction{}, ErrNotFound
}
return Transaction{}, fmt.Errorf("scan transaction: %w", err)
}
record.Request = json.RawMessage(requestJSON)
record.State = State(state)
var err error
record.CreatedAt, err = parseTime(createdAt)
if err != nil {
return Transaction{}, err
}
record.UpdatedAt, err = parseTime(updatedAt)
if err != nil {
return Transaction{}, err
}
return record, nil
}
func getStep(ctx context.Context, query queryRower, transactionID, stepKey string) (Step, error) {
return scanStep(query.QueryRowContext(ctx, `
SELECT transaction_id, step_key, name, status, intent_json,
result_json, error_message, created_at, updated_at
FROM transaction_steps
WHERE transaction_id = ? AND step_key = ?`, transactionID, stepKey))
}
func scanStep(row rowScanner) (Step, error) {
var record Step
var status, intentJSON, createdAt, updatedAt string
var resultJSON sql.NullString
if err := row.Scan(
&record.TransactionID,
&record.Key,
&record.Name,
&status,
&intentJSON,
&resultJSON,
&record.Error,
&createdAt,
&updatedAt,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Step{}, ErrNotFound
}
return Step{}, fmt.Errorf("scan transaction step: %w", err)
}
record.Status = StepStatus(status)
record.Intent = json.RawMessage(intentJSON)
if resultJSON.Valid {
record.Result = json.RawMessage(resultJSON.String)
}
var err error
record.CreatedAt, err = parseTime(createdAt)
if err != nil {
return Step{}, err
}
record.UpdatedAt, err = parseTime(updatedAt)
if err != nil {
return Step{}, err
}
return record, nil
}
func insertEvent(
ctx context.Context,
tx *sql.Tx,
transactionID string,
stepKey string,
kind string,
fromState State,
toState State,
message string,
createdAt time.Time,
) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO transaction_events (
transaction_id, step_key, kind, from_state, to_state, message, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
transactionID,
stepKey,
kind,
fromState,
toState,
message,
formatTime(createdAt),
)
if err != nil {
return fmt.Errorf("insert transaction event: %w", err)
}
return nil
}
func formatTime(value time.Time) string {
return value.UTC().Format(time.RFC3339Nano)
}
func parseTime(value string) (time.Time, error) {
parsed, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
return time.Time{}, fmt.Errorf("parse persisted time %q: %w", value, err)
}
return parsed, nil
}
func cloneJSON(value json.RawMessage) json.RawMessage {
if len(value) == 0 {
return nil
}
return bytes.Clone(value)
}
+220
View File
@@ -0,0 +1,220 @@
package transaction
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"sync"
"testing"
"time"
)
func TestStoreCreateIsIdempotentAndAllowsOnlyOneActiveTransaction(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := openTestStore(t)
request := CreateRequest{
ID: "transaction-1",
IdempotencyKey: "request-1",
Source: "test",
Service: "backend",
Request: json.RawMessage(`{"service":"backend"}`),
}
created, isNew, err := store.CreateTransaction(ctx, request)
if err != nil {
t.Fatalf("create transaction: %v", err)
}
if !isNew || created.State != StateCreated {
t.Fatalf("unexpected created transaction: %+v new=%v", created, isNew)
}
retried, isNew, err := store.CreateTransaction(ctx, request)
if err != nil {
t.Fatalf("retry transaction: %v", err)
}
if isNew || retried.ID != created.ID {
t.Fatalf("idempotent retry created another transaction: %+v", retried)
}
_, _, err = store.CreateTransaction(ctx, CreateRequest{
ID: "transaction-2",
IdempotencyKey: "request-2",
Source: "test",
Service: "frontend",
})
var activeErr *ActiveTransactionError
if !errors.As(err, &activeErr) || activeErr.TransactionID != created.ID {
t.Fatalf("expected active transaction error, got %v", err)
}
if _, err := store.Transition(ctx, created.ID, StateFailed, "test terminal state"); err != nil {
t.Fatalf("finish first transaction: %v", err)
}
second, isNew, err := store.CreateTransaction(ctx, CreateRequest{
ID: "transaction-2",
IdempotencyKey: "request-2",
Source: "test",
Service: "frontend",
})
if err != nil || !isNew || second.ID != "transaction-2" {
t.Fatalf("create transaction after terminal state: record=%+v new=%v err=%v", second, isNew, err)
}
}
func TestStoreTransitionStepAndEventPersistence(t *testing.T) {
t.Parallel()
ctx := context.Background()
databasePath := filepath.Join(t.TempDir(), "transaction.db")
store, err := OpenStore(ctx, databasePath)
if err != nil {
t.Fatalf("open store: %v", err)
}
fixedTime := time.Date(2026, time.August, 15, 10, 0, 0, 0, time.UTC)
store.now = func() time.Time { return fixedTime }
record, _, err := store.CreateTransaction(ctx, CreateRequest{
ID: "transaction-persisted",
IdempotencyKey: "request-persisted",
Source: "test",
Service: "all",
})
if err != nil {
t.Fatalf("create transaction: %v", err)
}
if _, err := store.Transition(ctx, record.ID, StateValidating, "validation started"); err != nil {
t.Fatalf("transition transaction: %v", err)
}
if _, err := store.Transition(ctx, record.ID, StatePrepared, "validation completed"); err != nil {
t.Fatalf("transition transaction: %v", err)
}
step, isNew, err := store.RecordStepIntent(ctx, record.ID, StepIntent{
Key: "prepare-files",
Name: "prepare immutable files",
Intent: json.RawMessage(`{"sha256":"abc"}`),
})
if err != nil || !isNew || step.Status != StepIntentRecorded {
t.Fatalf("record step intent: step=%+v new=%v err=%v", step, isNew, err)
}
if err := store.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
reopened, err := OpenStore(ctx, databasePath)
if err != nil {
t.Fatalf("reopen store: %v", err)
}
t.Cleanup(func() { _ = reopened.Close() })
persisted, err := reopened.Transaction(ctx, record.ID)
if err != nil {
t.Fatalf("read persisted transaction: %v", err)
}
if persisted.State != StatePrepared || persisted.Version != 3 {
t.Fatalf("unexpected persisted transaction: %+v", persisted)
}
pending, err := reopened.PendingSteps(ctx, record.ID)
if err != nil {
t.Fatalf("read pending steps: %v", err)
}
if len(pending) != 1 || pending[0].Key != step.Key {
t.Fatalf("unexpected pending steps: %+v", pending)
}
completed, err := reopened.CompleteStep(ctx, record.ID, step.Key, StepSucceeded, json.RawMessage(`{"installed":true}`), "")
if err != nil || completed.Status != StepSucceeded {
t.Fatalf("complete step: step=%+v err=%v", completed, err)
}
events, err := reopened.EventsAfter(ctx, record.ID, 0, 100)
if err != nil {
t.Fatalf("read events: %v", err)
}
if len(events) != 5 {
t.Fatalf("unexpected event count: got %d events=%+v", len(events), events)
}
for index := 1; index < len(events); index++ {
if events[index].Sequence <= events[index-1].Sequence {
t.Fatalf("events are not ordered: %+v", events)
}
}
}
func TestStoreRejectsInvalidTransitionAndConflictingStepIntent(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := openTestStore(t)
record, _, err := store.CreateTransaction(ctx, CreateRequest{
ID: "transaction-conflict",
IdempotencyKey: "request-conflict",
Source: "test",
Service: "backend",
})
if err != nil {
t.Fatalf("create transaction: %v", err)
}
_, err = store.Transition(ctx, record.ID, StatePrepared, "skip validation")
var transitionErr *TransitionError
if !errors.As(err, &transitionErr) {
t.Fatalf("expected transition error, got %v", err)
}
intent := StepIntent{Key: "same-key", Name: "first", Intent: json.RawMessage(`{"value":1}`)}
if _, _, err := store.RecordStepIntent(ctx, record.ID, intent); err != nil {
t.Fatalf("record first step intent: %v", err)
}
_, _, err = store.RecordStepIntent(ctx, record.ID, StepIntent{
Key: intent.Key,
Name: "different",
Intent: intent.Intent,
})
if !errors.Is(err, ErrStepConflict) {
t.Fatalf("expected step conflict, got %v", err)
}
}
func TestStoreSerializesConcurrentCreates(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := openTestStore(t)
const workers = 12
var wait sync.WaitGroup
wait.Add(workers)
results := make(chan error, workers)
for index := 0; index < workers; index++ {
go func(index int) {
defer wait.Done()
_, _, err := store.CreateTransaction(ctx, CreateRequest{
IdempotencyKey: "concurrent-" + string(rune('A'+index)),
Source: "test",
Service: "backend",
})
results <- err
}(index)
}
wait.Wait()
close(results)
var created, rejected int
for err := range results {
switch {
case err == nil:
created++
case errors.Is(err, ErrActiveExists):
rejected++
default:
t.Fatalf("unexpected create error: %v", err)
}
}
if created != 1 || rejected != workers-1 {
t.Fatalf("unexpected concurrent result: created=%d rejected=%d", created, rejected)
}
}
func openTestStore(t *testing.T) *Store {
t.Helper()
store, err := OpenStore(context.Background(), filepath.Join(t.TempDir(), "transaction.db"))
if err != nil {
t.Fatalf("open test store: %v", err)
}
t.Cleanup(func() {
if err := store.Close(); err != nil {
t.Errorf("close test store: %v", err)
}
})
return store
}
+4
View File
@@ -0,0 +1,4 @@
package main
// 命令入口会在事务内核稳定后接入。当前提交只构建和验证底层能力。
func main() {}