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