Files
orca/internal/transport/dispatch.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

263 lines
8.4 KiB
Go

// Package transport — dispatch.go implements the orca.v1.Dispatch
// service: a JSON-over-HTTP interface for cross-node job submission
// and status queries. Routes:
//
// POST /orca.v1.Dispatch/Submit -> SubmitHandler
// POST /orca.v1.Dispatch/Status -> StatusHandler
//
// mTLS is the v0.2 transport (P01). ConnectRPC is NOT used because
// it's not in go.mod (RESEARCH conclusion). The service is mounted on
// the orca daemon's mTLS listener (see internal/daemon/dispatch_handler.go).
package transport
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// SubmitRequest is the body of POST /orca.v1.Dispatch/Submit.
type SubmitRequest struct {
Target string `json:"target"` // optional explicit node id; empty = bin-pack
Spec json.RawMessage `json:"spec"` // HCL/YAML job spec, opaque to the dispatch service
IdempotencyKey string `json:"-"` // set from X-Orca-Idempotency-Key header, not body
}
// SubmitResponse is the body of a Submit reply.
type SubmitResponse struct {
JobID string `json:"job_id"`
NodeID string `json:"node_id"` // node that actually accepted the job (local or peer)
}
// StatusRequest is the body of POST /orca.v1.Dispatch/Status.
type StatusRequest struct {
JobID string `json:"job_id"`
}
// StatusResponse is the body of a Status reply.
type StatusResponse struct {
JobID string `json:"job_id"`
NodeID string `json:"node_id"`
State string `json:"state"` // "pending" | "running" | "complete" | "failed" | "stopped"
}
// Dispatcher is the contract the HTTP layer uses to actually run a
// job on a node. The engine layer implements this; the HTTP layer
// translates between JSON and Dispatcher calls.
type Dispatcher interface {
LocalSubmit(ctx context.Context, spec []byte) (jobID string, err error)
LocalStatus(ctx context.Context, jobID string) (state string, err error)
}
// SubmitHandler is an http.Handler that runs Submit on a local Dispatcher.
// It honors X-Orca-Idempotency-Key for dedupe. Errors are returned
// as JSON with an "error" field and an HTTP status code.
type SubmitHandler struct {
Dispatcher Dispatcher
Dedupe *IdempotencyStore
}
// NewSubmitHandler builds a SubmitHandler.
func NewSubmitHandler(d Dispatcher, dedupe *IdempotencyStore) *SubmitHandler {
if dedupe == nil {
dedupe = NewIdempotencyStore()
}
return &SubmitHandler{Dispatcher: d, Dedupe: dedupe}
}
// ServeHTTP implements http.Handler.
func (h *SubmitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
defer r.Body.Close()
var req SubmitRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "decode body: "+err.Error())
return
}
if len(req.Spec) == 0 {
writeError(w, http.StatusBadRequest, "spec is required")
return
}
req.IdempotencyKey = r.Header.Get(IdempotencyHeader)
// Idempotency check.
if req.IdempotencyKey != "" {
if jobID, ok := h.Dedupe.Get(req.IdempotencyKey); ok {
// Replay the previous response.
writeJSON(w, http.StatusOK, SubmitResponse{JobID: jobID, NodeID: ""})
return
}
}
jobID, err := h.Dispatcher.LocalSubmit(r.Context(), req.Spec)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if req.IdempotencyKey != "" {
h.Dedupe.Put(req.IdempotencyKey, jobID)
}
writeJSON(w, http.StatusOK, SubmitResponse{JobID: jobID, NodeID: "self"})
}
// StatusHandler is an http.Handler that runs Status on a local Dispatcher.
type StatusHandler struct {
Dispatcher Dispatcher
}
// NewStatusHandler builds a StatusHandler.
func NewStatusHandler(d Dispatcher) *StatusHandler {
return &StatusHandler{Dispatcher: d}
}
// ServeHTTP implements http.Handler.
func (h *StatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
defer r.Body.Close()
var req StatusRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "decode body: "+err.Error())
return
}
if req.JobID == "" {
writeError(w, http.StatusBadRequest, "job_id is required")
return
}
state, err := h.Dispatcher.LocalStatus(r.Context(), req.JobID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, StatusResponse{JobID: req.JobID, NodeID: "self", State: state})
}
// DispatchClient is the client-side wrapper that calls Submit/Status
// on a remote peer. It uses mTLS (REQ-011) and the retry helper
// (REQ-037).
type DispatchClient struct {
HTTP *MTLSClient
PeerAddr string // http://host:port or https://host:port
}
// NewDispatchClient builds a DispatchClient for a peer.
func NewDispatchClient(caPath, serverName, peerAddr string) (*DispatchClient, error) {
c, err := NewMTLSClient(caPath, serverName, "", "")
if err != nil {
return nil, fmt.Errorf("NewDispatchClient: %w", err)
}
return &DispatchClient{HTTP: c, PeerAddr: peerAddr}, nil
}
// Submit calls POST /orca.v1.Dispatch/Submit on the peer with the
// given spec and idempotency key. Retries per the default policy.
func (c *DispatchClient) Submit(ctx context.Context, spec []byte, idempotencyKey string) (*SubmitResponse, error) {
if idempotencyKey != "" {
ctx = WithIdempotencyKey(ctx, idempotencyKey)
}
body, _ := json.Marshal(SubmitRequest{Spec: spec})
policy := DefaultRetryPolicy()
for attempt := 1; attempt <= policy.MaxAttempts; attempt++ {
if err := ctx.Err(); err != nil {
return nil, err
}
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.PeerAddr+"/orca.v1.Dispatch/Submit", bytesReader(body))
req.Header.Set("Content-Type", "application/json")
if k := IdempotencyKeyFromContext(ctx); k != "" {
req.Header.Set(IdempotencyHeader, k)
}
r, err := c.HTTP.Do(req)
if err == nil {
defer r.Body.Close()
if r.StatusCode == http.StatusOK {
var resp SubmitResponse
if derr := json.NewDecoder(r.Body).Decode(&resp); derr == nil {
return &resp, nil
} else {
return nil, fmt.Errorf("DispatchClient.Submit: decode: %w", derr)
}
}
err = fmt.Errorf("status %d", r.StatusCode)
err = fmt.Errorf("%w: %v", ErrTransient, err)
} else {
err = fmt.Errorf("%w: %v", ErrTransient, err)
}
// No key, not idempotent: bail on first transient error.
if IdempotencyKeyFromContext(ctx) == "" {
return nil, err
}
if attempt == policy.MaxAttempts {
return nil, err
}
// Wait with backoff, respecting ctx.
wait := backoff(policy.Initial, policy.Max, attempt)
t := time.NewTimer(wait)
select {
case <-ctx.Done():
t.Stop()
return nil, ctx.Err()
case <-t.C:
}
}
return nil, fmt.Errorf("DispatchClient.Submit: exhausted attempts")
}
// Status calls POST /orca.v1.Dispatch/Status on the peer. Status is
// idempotent at the verb level, so retries are always safe.
func (c *DispatchClient) Status(ctx context.Context, jobID string) (*StatusResponse, error) {
body, _ := json.Marshal(StatusRequest{JobID: jobID})
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.PeerAddr+"/orca.v1.Dispatch/Status", bytesReader(body))
req.Header.Set("Content-Type", "application/json")
r, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("DispatchClient.Status: %w", err)
}
defer r.Body.Close()
if r.StatusCode != http.StatusOK {
return nil, fmt.Errorf("DispatchClient.Status: status %d", r.StatusCode)
}
var resp StatusResponse
if err := json.NewDecoder(r.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("DispatchClient.Status: decode: %w", err)
}
return &resp, nil
}
// writeJSON encodes v as JSON and writes it with the given status.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// writeError writes a JSON error response.
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// bytesReader is a small helper to keep this file self-contained.
type bytesReadCloser struct {
b []byte
pos int
}
func bytesReader(b []byte) *bytesReadCloser { return &bytesReadCloser{b: b} }
func (r *bytesReadCloser) Read(p []byte) (int, error) {
if r.pos >= len(r.b) {
return 0, fmt.Errorf("EOF")
}
n := copy(p, r.b[r.pos:])
r.pos += n
return n, nil
}
func (r *bytesReadCloser) Close() error { return nil }