Files
orca/internal/engine/scheduler_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

67 lines
2.0 KiB
Go

package engine
import (
"testing"
"git.cloudinit.dev/coreci/orca/internal/store"
)
func TestPickNodeBestFit(t *testing.T) {
caps := []*store.NodeCapacity{
{NodeID: "node-b", CPUMillicores: 1000, MemoryMiB: 1024, DiskMiB: 1024},
{NodeID: "node-a", CPUMillicores: 4000, MemoryMiB: 4096, DiskMiB: 4096},
{NodeID: "node-c", CPUMillicores: 500, MemoryMiB: 512, DiskMiB: 512},
}
spec := JobSpec{CPUMillicores: 1000, MemoryMiB: 1024, DiskMiB: 1024}
got, idx, err := PickNode(spec, caps)
if err != nil {
t.Fatalf("PickNode: %v", err)
}
if got.NodeID != "node-a" {
t.Errorf("PickNode: got %s, want node-a (most free capacity)", got.NodeID)
}
if idx != 1 {
t.Errorf("PickNode: got idx %d, want 1", idx)
}
}
func TestPickNodeNoFit(t *testing.T) {
caps := []*store.NodeCapacity{
{NodeID: "node-a", CPUMillicores: 100, MemoryMiB: 100, DiskMiB: 100},
}
spec := JobSpec{CPUMillicores: 1000, MemoryMiB: 1024, DiskMiB: 1024}
_, _, err := PickNode(spec, caps)
if err == nil {
t.Fatal("expected PickNode to fail when no node can fit")
}
}
func TestPickNodeTieDeterministic(t *testing.T) {
// Two nodes with identical free capacity. Tie broken by NodeID
// (lexicographic) for determinism.
caps := []*store.NodeCapacity{
{NodeID: "node-z", CPUMillicores: 4000, MemoryMiB: 4096, DiskMiB: 4096},
{NodeID: "node-a", CPUMillicores: 4000, MemoryMiB: 4096, DiskMiB: 4096},
}
spec := JobSpec{CPUMillicores: 1000, MemoryMiB: 1024, DiskMiB: 1024}
got, _, err := PickNode(spec, caps)
if err != nil {
t.Fatalf("PickNode: %v", err)
}
if got.NodeID != "node-a" {
t.Errorf("PickNode tie-break: got %s, want node-a (lexicographic)", got.NodeID)
}
}
func TestJobSpecFits(t *testing.T) {
spec := JobSpec{CPUMillicores: 1000, MemoryMiB: 1024, DiskMiB: 1024}
c := &store.NodeCapacity{CPUMillicores: 2000, MemoryMiB: 2048, DiskMiB: 2048}
if !spec.Fits(c) {
t.Error("Fits: should fit")
}
c.CPUMillicores = 500
if spec.Fits(c) {
t.Error("Fits: should not fit (CPU too low)")
}
}