fc6a6c07e2
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---
67 lines
2.0 KiB
Go
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)")
|
|
}
|
|
}
|