Files
orca/internal/transport/idempotency_test.go
T
ciagent fc6a6c07e2 feat(P09): capacity repo, scheduler, peer registry, idempotency, retry
Wave A of P02 (multi-node scheduling & job dispatch).

- internal/store/migrations/0005_node_capacity.sql — node_capacity
  table (node_id PK, cpu_millicores, memory_mib, disk_mib, updated_at).
- internal/store/capacity_repo.go — CRUD for the table; ErrNotFound
  semantics; List ordered by node_id.
- internal/store/capacity_repo_test.go — round-trip coverage.
- internal/engine/peer.go — Peer struct (NodeID, Address, ServerName,
  CAPath, LastSeen, Capacity) and PeerRegistry (in-memory map with
  sync.RWMutex; Add/Remove/Get/All/Len/UpdateLastSeen). All() returns
  a stable-sorted snapshot for deterministic tests.
- internal/engine/scheduler.go — JobSpec {CPU, Mem, Disk}; Fits()
  and Score() helpers; PickNode() does best-fit bin-packing with
  deterministic tie-breaking by NodeID. Ties broken lexicographically.
- internal/engine/scheduler_test.go — best-fit, no-fit, tie-break,
  and Fits() boundary coverage.
- internal/transport/idempotency.go — IdempotencyStore (in-memory,
  TTL=5min); WithIdempotencyKey/IdempotencyKeyFromContext helpers.
  Expired entries auto-evict on Get; Sweep() for bulk cleanup.
- internal/transport/idempotency_test.go — put/get, expiry, ctx.
- internal/transport/retry.go — RetryPolicy (100ms/5s/5attempts);
  IsTransient() with explicit signature list (no net/error dep);
  ErrTransient/ErrPermanent sentinels; Do[T] generic retry loop.
  Auto-retry only when (verb is idempotent) OR (ctx has idempotency
  key); otherwise transient errors bail on first attempt (REQ-037).
  backoff() with 25% jitter, ctx cancellation respected.

---ci---
project: orca
phase: 9
milestone: v0.2
status: execute
---/ci---
2026-06-03 22:45:33 +00:00

134 lines
3.5 KiB
Go

package transport
import (
"context"
"errors"
"testing"
"time"
)
func TestIdempotencyStorePutGet(t *testing.T) {
s := NewIdempotencyStore()
if _, ok := s.Get("missing"); ok {
t.Fatal("expected missing key to return ok=false")
}
s.Put("k1", "job-1")
if jobID, ok := s.Get("k1"); !ok || jobID != "job-1" {
t.Errorf("Get(k1): got (%q, %v), want (job-1, true)", jobID, ok)
}
}
func TestIdempotencyStoreExpiry(t *testing.T) {
s := NewIdempotencyStore()
// Manually insert an expired entry.
s.entries["expired"] = dedupeEntry{
key: "expired",
jobID: "old-job",
expiresAt: time.Now().Add(-1 * time.Minute),
}
if _, ok := s.Get("expired"); ok {
t.Fatal("expected expired entry to return ok=false")
}
if _, exists := s.entries["expired"]; exists {
t.Error("expected expired entry to be removed by Get")
}
}
func TestIdempotencyStoreContext(t *testing.T) {
ctx := WithIdempotencyKey(context.Background(), "key-1")
if got := IdempotencyKeyFromContext(ctx); got != "key-1" {
t.Errorf("IdempotencyKeyFromContext: got %q, want key-1", got)
}
ctx2 := context.Background()
if got := IdempotencyKeyFromContext(ctx2); got != "" {
t.Errorf("IdempotencyKeyFromContext(empty): got %q, want \"\"", got)
}
}
func TestRetrySucceedsAfterTransient(t *testing.T) {
calls := 0
got, err := Do(context.Background(), DefaultRetryPolicy(),
func(_ context.Context, attempt int) (string, bool, error) {
calls++
if attempt < 3 {
return "", true, errors.New("connection refused: try again")
}
return "ok", true, nil
})
if err != nil {
t.Fatalf("Do: %v", err)
}
if got != "ok" {
t.Errorf("Do: got %q, want ok", got)
}
if calls != 3 {
t.Errorf("Do: got %d calls, want 3", calls)
}
}
func TestRetryNoKeyOnTransient(t *testing.T) {
// Without an idempotency key AND a non-idempotent verb, a
// transient error on the first attempt must NOT retry (REQ-037).
calls := 0
_, err := Do(context.Background(), DefaultRetryPolicy(),
func(_ context.Context, _ int) (string, bool, error) {
calls++
return "", false, errors.New("connection refused")
})
if err == nil {
t.Fatal("expected error, got nil")
}
if calls != 1 {
t.Errorf("expected 1 call (no retry without key), got %d", calls)
}
}
func TestRetryPermanentError(t *testing.T) {
calls := 0
_, err := Do(context.Background(), DefaultRetryPolicy(),
func(_ context.Context, _ int) (string, bool, error) {
calls++
return "", true, ErrPermanent
})
if !errors.Is(err, ErrPermanent) {
t.Errorf("expected ErrPermanent, got %v", err)
}
if calls != 1 {
t.Errorf("expected 1 call (permanent = no retry), got %d", calls)
}
}
func TestRetryContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
calls := 0
_, err := Do(ctx, DefaultRetryPolicy(),
func(_ context.Context, _ int) (string, bool, error) {
calls++
return "", true, errors.New("EOF")
})
if !errors.Is(err, context.Canceled) {
t.Errorf("expected context.Canceled, got %v", err)
}
}
func TestIsTransient(t *testing.T) {
cases := []struct {
err error
want bool
}{
{nil, false},
{errors.New("connection refused"), true},
{errors.New("i/o timeout"), true},
{errors.New("EOF"), true},
{errors.New("no such host"), true},
{errors.New("connection reset by peer"), true},
{errors.New("invalid spec"), false},
}
for _, c := range cases {
if got := IsTransient(c.err); got != c.want {
t.Errorf("IsTransient(%v): got %v, want %v", c.err, got, c.want)
}
}
}