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