feat: backend executor implement

This commit is contained in:
2026-08-16 17:12:06 +08:00
parent 390e0565d3
commit 7755da72e7
31 changed files with 2762 additions and 184 deletions
+115 -5
View File
@@ -17,7 +17,7 @@ import (
"github.com/ncruces/go-sqlite3/driver"
)
const schemaVersion = 1
const schemaVersion = 2
const schemaV1 = `
CREATE TABLE transactions (
@@ -70,13 +70,27 @@ CREATE INDEX transaction_events_by_transaction
PRAGMA user_version = 1;
`
const schemaV2 = `
CREATE TABLE backend_container_deployment (
singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1),
active_port INTEGER NOT NULL CHECK (active_port IN (8080, 8081)),
container_name TEXT NOT NULL,
image_digest TEXT NOT NULL,
container_id TEXT NOT NULL,
transaction_id TEXT NOT NULL REFERENCES transactions(id),
updated_at TEXT NOT NULL
) STRICT;
PRAGMA user_version = 2;
`
// Store 是服务端 SQLite 事务记录。一个进程只应创建一个 Store。
type Store struct {
db *sql.DB
now func() time.Time
}
// OpenStore 打开本地 SQLite,并强制校验第一版持久化参数。
// OpenStore 打开本地 SQLite,并强制校验持久化参数和 schema 版本
func OpenStore(ctx context.Context, path string) (*Store, error) {
if path == "" {
return nil, errors.New("sqlite path is required")
@@ -168,8 +182,21 @@ func migrate(ctx context.Context, db *sql.DB) error {
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)
for version < schemaVersion {
nextVersion := version + 1
var script string
switch nextVersion {
case 1:
script = schemaV1
case 2:
script = schemaV2
default:
return fmt.Errorf("sqlite migration script is missing for version %d", nextVersion)
}
if _, err := tx.ExecContext(ctx, script); err != nil {
return fmt.Errorf("apply sqlite schema version %d: %w", nextVersion, err)
}
version = nextVersion
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit sqlite migration: %w", err)
@@ -199,7 +226,37 @@ func (s *Store) CreateTransaction(ctx context.Context, request CreateRequest) (r
existing, err := getTransactionByIdempotencyKey(ctx, tx, request.IdempotencyKey)
if err == nil {
return existing, false, nil
if existing.State != StateFailed && existing.State != StateRolledBack {
return existing, false, nil
}
archivedKey := existing.IdempotencyKey + ":terminal:" + existing.ID
result, updateErr := tx.ExecContext(ctx, `
UPDATE transactions
SET idempotency_key = ?, version = version + 1, updated_at = ?
WHERE id = ? AND version = ? AND idempotency_key = ?
AND state IN (?, ?)`,
archivedKey,
formatTime(now),
existing.ID,
existing.Version,
existing.IdempotencyKey,
StateFailed,
StateRolledBack,
)
if updateErr != nil {
return Transaction{}, false, fmt.Errorf("archive terminal transaction idempotency key: %w", updateErr)
}
rows, rowsErr := result.RowsAffected()
if rowsErr != nil {
return Transaction{}, false, fmt.Errorf("read archived transaction rows: %w", rowsErr)
}
if rows != 1 {
return Transaction{}, false, errors.New("terminal transaction changed concurrently")
}
if eventErr := insertEvent(ctx, tx, existing.ID, "", "TRANSACTION_RETRY_RELEASED", existing.State, existing.State, "terminal transaction idempotency key archived for manual retry", now); eventErr != nil {
return Transaction{}, false, eventErr
}
err = ErrNotFound
}
if !errors.Is(err, ErrNotFound) {
return Transaction{}, false, err
@@ -469,6 +526,59 @@ func (s *Store) CompleteStep(ctx context.Context, transactionID, stepKey string,
return record, nil
}
// ReopenFailedStep makes one manually resumed external step pending again.
// The coordinator calls this only after Inspect has established an exact
// APPLIED or NOT_APPLIED state; UNKNOWN never reopens a failed step.
func (s *Store) ReopenFailedStep(ctx context.Context, transactionID, stepKey, message string) (Step, error) {
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return Step{}, fmt.Errorf("begin reopen failed step: %w", err)
}
defer tx.Rollback()
record, err := getStep(ctx, tx, transactionID, stepKey)
if err != nil {
return Step{}, err
}
if record.Status == StepIntentRecorded {
return record, nil
}
if record.Status != StepFailed {
return Step{}, ErrStepNotPending
}
now := s.now().UTC()
result, err := tx.ExecContext(ctx, `
UPDATE transaction_steps
SET status = ?, result_json = NULL, error_message = '', updated_at = ?
WHERE transaction_id = ? AND step_key = ? AND status = ?`,
StepIntentRecorded,
formatTime(now),
transactionID,
stepKey,
StepFailed,
)
if err != nil {
return Step{}, fmt.Errorf("reopen failed step: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return Step{}, fmt.Errorf("read reopened step rows: %w", err)
}
if rows != 1 {
return Step{}, errors.New("failed step changed concurrently")
}
if err := insertEvent(ctx, tx, transactionID, stepKey, "STEP_REOPENED", "", "", message, now); err != nil {
return Step{}, err
}
if err := tx.Commit(); err != nil {
return Step{}, fmt.Errorf("commit reopened step: %w", err)
}
record.Status = StepIntentRecorded
record.Result = nil
record.Error = ""
record.UpdatedAt = now
return record, nil
}
// PendingSteps 返回重启后必须先核对实际外部状态的步骤。
func (s *Store) PendingSteps(ctx context.Context, transactionID string) ([]Step, error) {
rows, err := s.db.QueryContext(ctx, `