feat: transaction implement
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user