feat: backend executor implement
This commit is contained in:
@@ -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, `
|
||||
|
||||
Reference in New Issue
Block a user