proxy-pool/internal/domain/workerruntime/contracttest/contract.go
youfak 6766097ea7
Some checks are pending
ci / proto (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
ci / race (push) Waiting to run
ci / integration (push) Waiting to run
feat: report gateway proxy outcomes
2026-07-31 17:36:17 +08:00

211 lines
8.7 KiB
Go

package contracttest
import (
"context"
"crypto/sha256"
"errors"
"testing"
"time"
"proxy-pool/internal/domain/workerruntime"
)
type Fixture struct {
Store workerruntime.ControlStore
Reader workerruntime.RuntimeReader
Outcomes workerruntime.OutcomeWriter
TTL time.Duration
Advance func(time.Duration)
}
type Factory func(*testing.T) Fixture
// Run exercises the public control-store behavior shared by Memory and Redis.
func Run(t *testing.T, factory Factory) {
t.Helper()
t.Run("acknowledged runtime lifecycle", func(t *testing.T) { runLifecycle(t, newFixture(t, factory)) })
t.Run("negative acknowledgement fences runtime", func(t *testing.T) { runNegativeAck(t, newFixture(t, factory)) })
t.Run("session replacement fences issued snapshots", func(t *testing.T) { runSessionReplacementFence(t, newFixture(t, factory)) })
t.Run("outcome sequence fences retries", func(t *testing.T) { runOutcomeFence(t, newFixture(t, factory)) })
t.Run("outcome invalid call is a store error", func(t *testing.T) { runOutcomeInvalidCall(t, newFixture(t, factory)) })
}
func runLifecycle(t *testing.T, fixture Fixture) {
t.Helper()
ctx := context.Background()
open(t, fixture.Store, fixture.TTL)
epoch, err := fixture.Store.CurrentOwnershipEpoch(ctx)
if err != nil {
t.Fatalf("CurrentOwnershipEpoch(): %v", err)
}
reference := snapshot(7, epoch, "snapshot-7")
report := runtimeReport(1, 7, epoch)
if err := fixture.Store.ReplaceRuntime(ctx, report, fixture.TTL); !errors.Is(err, workerruntime.ErrSnapshotMismatch) {
t.Fatalf("ReplaceRuntime(before ACK) error = %v", err)
}
if err := fixture.Store.RecordIssuedSnapshot(ctx, "session-a", reference, fixture.TTL); err != nil {
t.Fatalf("RecordIssuedSnapshot(): %v", err)
}
ack := workerruntime.SnapshotAcknowledgement{WorkerID: "worker-a", SessionID: "session-a", Reference: reference, Applied: true}
if err := fixture.Store.AcknowledgeSnapshot(ctx, ack, fixture.TTL); err != nil {
t.Fatalf("AcknowledgeSnapshot(): %v", err)
}
if err := fixture.Store.ReplaceRuntime(ctx, report, fixture.TTL); err != nil {
t.Fatalf("ReplaceRuntime(after ACK): %v", err)
}
assertFresh(t, fixture.Reader, epoch, true)
if err := fixture.Store.AcknowledgeSnapshot(ctx, ack, fixture.TTL); err != nil {
t.Fatalf("AcknowledgeSnapshot(replay): %v", err)
}
if err := fixture.Store.ReplaceRuntime(ctx, runtimeReport(0, 7, epoch), fixture.TTL); !errors.Is(err, workerruntime.ErrInvalidReport) {
t.Fatalf("ReplaceRuntime(invalid sequence) error = %v", err)
}
fixture.Advance(2 * fixture.TTL)
assertFresh(t, fixture.Reader, epoch, false)
}
func runNegativeAck(t *testing.T, fixture Fixture) {
t.Helper()
ctx := context.Background()
open(t, fixture.Store, fixture.TTL)
epoch, err := fixture.Store.CurrentOwnershipEpoch(ctx)
if err != nil {
t.Fatalf("CurrentOwnershipEpoch(): %v", err)
}
first := snapshot(7, epoch, "snapshot-7")
if err := fixture.Store.RecordIssuedSnapshot(ctx, "session-a", first, fixture.TTL); err != nil {
t.Fatalf("RecordIssuedSnapshot(first): %v", err)
}
if err := fixture.Store.AcknowledgeSnapshot(ctx, workerruntime.SnapshotAcknowledgement{WorkerID: "worker-a", SessionID: "session-a", Reference: first, Applied: true}, fixture.TTL); err != nil {
t.Fatalf("AcknowledgeSnapshot(first): %v", err)
}
second := snapshot(8, epoch, "snapshot-8")
if err := fixture.Store.RecordIssuedSnapshot(ctx, "session-a", second, fixture.TTL); err != nil {
t.Fatalf("RecordIssuedSnapshot(second): %v", err)
}
if err := fixture.Store.AcknowledgeSnapshot(ctx, workerruntime.SnapshotAcknowledgement{
WorkerID: "worker-a", SessionID: "session-a", Reference: second, ErrorCode: "apply_failed",
}, fixture.TTL); err != nil {
t.Fatalf("AcknowledgeSnapshot(negative): %v", err)
}
if err := fixture.Store.ReplaceRuntime(ctx, runtimeReport(1, 7, epoch), fixture.TTL); !errors.Is(err, workerruntime.ErrSnapshotMismatch) {
t.Fatalf("ReplaceRuntime(delayed): %v", err)
}
}
func runSessionReplacementFence(t *testing.T, fixture Fixture) {
t.Helper()
ctx := context.Background()
open(t, fixture.Store, fixture.TTL)
epoch, err := fixture.Store.CurrentOwnershipEpoch(ctx)
if err != nil {
t.Fatalf("CurrentOwnershipEpoch(): %v", err)
}
if err := fixture.Store.RecordIssuedSnapshot(ctx, "session-a", snapshot(3, epoch, "old"), fixture.TTL); err != nil {
t.Fatalf("RecordIssuedSnapshot(old): %v", err)
}
if err := fixture.Store.OpenSession(ctx, workerruntime.Session{
WorkerID: "worker-a", InstanceID: "instance-b", SessionID: "session-b", Zone: "zone-a", ProtocolVersion: 1,
}, fixture.TTL); err != nil {
t.Fatalf("OpenSession(replacement): %v", err)
}
if err := fixture.Store.ValidateSession(ctx, "worker-a", "session-a"); !errors.Is(err, workerruntime.ErrStaleSession) {
t.Fatalf("ValidateSession(old): %v, want ErrStaleSession", err)
}
if err := fixture.Store.RecordIssuedSnapshot(ctx, "session-a", snapshot(4, epoch, "stale"), fixture.TTL); !errors.Is(err, workerruntime.ErrStaleSession) {
t.Fatalf("RecordIssuedSnapshot(stale): %v, want ErrStaleSession", err)
}
if err := fixture.Store.RecordIssuedSnapshot(ctx, "session-b", snapshot(1, epoch, "new"), fixture.TTL); err != nil {
t.Fatalf("RecordIssuedSnapshot(new): %v", err)
}
}
func runOutcomeFence(t *testing.T, fixture Fixture) {
t.Helper()
ctx := context.Background()
open(t, fixture.Store, fixture.TTL)
first := workerruntime.OutcomeReference{
WorkerID: "worker-a", SessionID: "session-a", Sequence: 1,
Digest: sha256.Sum256([]byte("outcome-1")),
}
accepted, err := fixture.Outcomes.RecordOutcomes(ctx, first)
if err != nil || accepted != first.Sequence {
t.Fatalf("RecordOutcomes(first) = %d, %v; want 1, nil", accepted, err)
}
accepted, err = fixture.Outcomes.RecordOutcomes(ctx, first)
if err != nil || accepted != first.Sequence {
t.Fatalf("RecordOutcomes(replay) = %d, %v; want 1, nil", accepted, err)
}
conflicting := first
conflicting.Digest = sha256.Sum256([]byte("outcome-conflict"))
if _, err := fixture.Outcomes.RecordOutcomes(ctx, conflicting); !errors.Is(err, workerruntime.ErrConflictingOutcome) {
t.Fatalf("RecordOutcomes(conflict) = %v, want ErrConflictingOutcome", err)
}
next := first
next.Sequence = 2
next.Digest = sha256.Sum256([]byte("outcome-2"))
if accepted, err := fixture.Outcomes.RecordOutcomes(ctx, next); err != nil || accepted != next.Sequence {
t.Fatalf("RecordOutcomes(next) = %d, %v; want 2, nil", accepted, err)
}
if _, err := fixture.Outcomes.RecordOutcomes(ctx, first); !errors.Is(err, workerruntime.ErrStaleOutcome) {
t.Fatalf("RecordOutcomes(stale) = %v, want ErrStaleOutcome", err)
}
if err := fixture.Store.OpenSession(ctx, workerruntime.Session{
WorkerID: "worker-a", InstanceID: "instance-b", SessionID: "session-b", Zone: "zone-a", ProtocolVersion: 1,
}, fixture.TTL); err != nil {
t.Fatalf("OpenSession(replacement): %v", err)
}
if _, err := fixture.Outcomes.RecordOutcomes(ctx, first); !errors.Is(err, workerruntime.ErrStaleSession) {
t.Fatalf("RecordOutcomes(old session) = %v, want ErrStaleSession", err)
}
}
func runOutcomeInvalidCall(t *testing.T, fixture Fixture) {
t.Helper()
if _, err := fixture.Outcomes.RecordOutcomes(nil, workerruntime.OutcomeReference{}); !errors.Is(err, workerruntime.ErrInvalidStore) {
t.Fatalf("RecordOutcomes(nil) = %v, want ErrInvalidStore", err)
}
}
func newFixture(t *testing.T, factory Factory) Fixture {
t.Helper()
fixture := factory(t)
if fixture.Store == nil || fixture.Reader == nil || fixture.Outcomes == nil || fixture.TTL <= 0 || fixture.Advance == nil {
t.Fatal("contract fixture is incomplete")
}
return fixture
}
func open(t *testing.T, store workerruntime.ControlStore, ttl time.Duration) {
t.Helper()
err := store.OpenSession(context.Background(), workerruntime.Session{
WorkerID: "worker-a", InstanceID: "instance-a", SessionID: "session-a", Zone: "zone-a", ProtocolVersion: 1,
}, ttl)
if err != nil {
t.Fatalf("OpenSession(): %v", err)
}
}
func snapshot(version, epoch uint64, value string) workerruntime.SnapshotReference {
return workerruntime.SnapshotReference{
WorkerID: "worker-a", Version: version, OwnershipEpoch: epoch, Checksum: sha256.Sum256([]byte(value)),
}
}
func runtimeReport(sequence, version, epoch uint64) workerruntime.Report {
return workerruntime.Report{
WorkerID: "worker-a", SessionID: "session-a", Sequence: sequence, SnapshotVersion: version,
OwnershipEpoch: epoch, ObservedAt: time.Date(2026, 7, 31, 9, 0, 0, 0, time.UTC),
}
}
func assertFresh(t *testing.T, reader workerruntime.RuntimeReader, epoch uint64, want bool) {
t.Helper()
snapshots, err := reader.ReadRuntime(context.Background(), []workerruntime.OwnedProxy{{
ProxyID: "proxy-a", WorkerID: "worker-a", OwnershipEpoch: epoch,
}})
if err != nil || len(snapshots) != 1 || snapshots[0].Fresh != want {
t.Fatalf("ReadRuntime() = %+v, %v; want Fresh=%t", snapshots, err, want)
}
}