Files
orca/internal/transport/idempotency.go
T
Jon Chery df58bc25a3 docs(milestone): complete scheduling-streaming (v0.3)
---ci---
project: orca
phase: 3
milestone: v0.3
status: complete
requirements:
  covered: [REQ-022, REQ-030, REQ-032]
  partial: []
---/ci---

v0.3 milestone merged to main. Includes all v0.2 work (P08-P10) that
was previously on the milestone branch but not yet merged to main, plus
the v0.3 completion work (iter.Seq streaming + doctor network/db).

v0.2 phases included: P08 (mTLS), P09 (scheduling), P10 (security scan).
v0.3 phases: P0 (pre-execution), P1 (iter.Seq streaming), P2 (doctor),
P3 (final review+ship).

Total: 40 requirements, all complete. No new go.mod dependencies.
Full test suite passes under -race. gofmt + go vet clean.
2026-08-01 20:06:47 +00:00

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 ""
}