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---
124 lines
3.7 KiB
Go
124 lines
3.7 KiB
Go
// Package transport — idempotency.go implements the X-Orca-Idempotency-Key
|
|
// header for cross-node dispatch (REQ-037). The dedupe store is a
|
|
// in-memory map with a TTL window; persistent dedupe across daemon
|
|
// restarts is out of scope for v0.2 (the bin-packing scheduler is
|
|
// single-daemon for now; the dedupe window just covers in-flight retries).
|
|
package transport
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// IdempotencyHeader is the canonical header name. Casing-insensitive
|
|
// per HTTP spec, but we keep the canonical form for log clarity.
|
|
IdempotencyHeader = "X-Orca-Idempotency-Key"
|
|
// DedupeWindow is how long an idempotency key is honored after
|
|
// first use. Tuned for the in-flight retry window: a transient
|
|
// dispatch error followed by an exponential-backoff retry (max 5
|
|
// attempts with cap 5s) completes well within 60s. The dedupe
|
|
// window is 5 minutes to cover cases where a peer processes a
|
|
// request but the response is lost on the wire.
|
|
DedupeWindow = 5 * time.Minute
|
|
)
|
|
|
|
// dedupeEntry is a single (key -> response) record with expiry.
|
|
type dedupeEntry struct {
|
|
key string
|
|
jobID string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// IdempotencyStore is a thread-safe in-memory dedupe map. Keys are
|
|
// scoped per-process; a restart drops the map. For P02 this is
|
|
// sufficient because the dispatcher is single-instance.
|
|
type IdempotencyStore struct {
|
|
mu sync.Mutex
|
|
entries map[string]dedupeEntry
|
|
}
|
|
|
|
// NewIdempotencyStore returns an empty store.
|
|
func NewIdempotencyStore() *IdempotencyStore {
|
|
return &IdempotencyStore{entries: make(map[string]dedupeEntry)}
|
|
}
|
|
|
|
// Get returns the recorded jobID for key, or "" if no entry is present
|
|
// (or the entry is expired). The second return is true if a live
|
|
// (non-expired) entry was found.
|
|
func (s *IdempotencyStore) Get(key string) (string, bool) {
|
|
if key == "" {
|
|
return "", false
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
e, ok := s.entries[key]
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
if time.Now().After(e.expiresAt) {
|
|
delete(s.entries, key)
|
|
return "", false
|
|
}
|
|
return e.jobID, true
|
|
}
|
|
|
|
// Put records (key -> jobID) with a default expiry of DedupeWindow.
|
|
// Overwrites any prior entry (rare in practice since we check Get first).
|
|
func (s *IdempotencyStore) Put(key, jobID string) {
|
|
if key == "" || jobID == "" {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
s.entries[key] = dedupeEntry{
|
|
key: key,
|
|
jobID: jobID,
|
|
expiresAt: time.Now().Add(DedupeWindow),
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// Sweep removes all expired entries. Called periodically by the dispatch
|
|
// service; safe to call concurrently.
|
|
func (s *IdempotencyStore) Sweep() {
|
|
now := time.Now()
|
|
s.mu.Lock()
|
|
for k, e := range s.entries {
|
|
if now.After(e.expiresAt) {
|
|
delete(s.entries, k)
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// ErrIdempotencyKeyRequired is returned by retry helpers when a
|
|
// non-idempotent call (e.g., POST) is retried without an idempotency
|
|
// key. Matches REQ-037's "absent header + transient error → no retry".
|
|
var ErrIdempotencyKeyRequired = errors.New("retry requires X-Orca-Idempotency-Key header")
|
|
|
|
// HeaderFromContext extracts the X-Orca-Idempotency-Key from a
|
|
// request-scoped context, if any. The dispatcher stores the key on
|
|
// the context via WithIdempotencyKey so downstream layers can read it
|
|
// without parsing headers.
|
|
type idempotencyKey struct{}
|
|
|
|
// WithIdempotencyKey attaches an idempotency key to ctx.
|
|
func WithIdempotencyKey(ctx context.Context, key string) context.Context {
|
|
if key == "" {
|
|
return ctx
|
|
}
|
|
return context.WithValue(ctx, idempotencyKey{}, key)
|
|
}
|
|
|
|
// IdempotencyKeyFromContext returns the key attached to ctx, or "".
|
|
func IdempotencyKeyFromContext(ctx context.Context) string {
|
|
if v := ctx.Value(idempotencyKey{}); v != nil {
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|