Skip to content

ADR-022 — Lambert worker message protocol

Status · Accepted Date · 2026-04-28 Closes · RFC-003 (Lambert worker message protocol) TA anchor · §components/lambert-worker · §contracts/lambert-message

Context

RFC-003 proposed a message protocol for src/workers/lambert.worker.ts so the porkchop computation runs off the main thread (ADR-008). The open questions were:

  1. Cancellation strategy. When the user changes the date range mid-compute, how does the main thread cancel the in-flight request without leaking stale messages?
  2. Progress granularity. How often should the worker post progress updates — every row, every 10 rows, every percent?
  3. Result shape. Is the grid posted as a single result message, or streamed row-by-row?

Closure was originally scheduled for the Slice 3 gate, after the worker had been wired to a real porkchop UI (/plan). We are closing it now as part of slice checkpoint 3a-8 — the worker has shipped (3a-1, bc43585), the /plan route exercises it (3a-7, adda9a3), and the protocol behaved correctly under cancellation and HiDPI rendering loads.

Decision

Protocol (locked)

ts
// Main → Worker
interface LambertRequest {
  id: number;                       // monotonic; main increments on each new request
  depRange: [number, number];       // [start, end] departure days from epoch
  arrRange: [number, number];       // [start, end] time-of-flight in days
  steps: [number, number];          // [width, height] cell counts
}

// Worker → Main (during compute)
interface LambertProgress { id: number; progress: number; }   // 0..1

// Worker → Main (final)
interface LambertGrid {
  id: number;
  grid: number[][];                 // [row][col], row = TOF axis
  depDays: number[];                // size = steps[0]
  arrDays: number[];                // size = steps[1]
}

Cancellation by monotonic id

Main thread increments nextId on each new request and stores currentId. On every received message, it discards anything whose id is not currentId. Worker symmetrically tracks the most recent request id — if a new request arrives mid-compute, the running loop bails on its next row check before posting stale results.

The id is the single source of truth for "is this message still relevant?" — no need for AbortController or worker.terminate() between requests, no need for the worker to drain a queue.

Progress every 10 rows

Posting after every row is wasteful (≈100 unnecessary structured-clone hops for an 11,200-cell grid; the user can't see progress that fine-grained). Posting only on completion gives a frozen UI for 1–2 seconds. Every 10 rows is a happy medium: ~10 progress messages per compute, smooth visual fill, negligible overhead.

Single result message (not streaming)

The grid is small enough (Float64Array would be ~90 KB; we use plain number[][] which is bigger but still well under any reasonable threshold) that one structured-clone of the full result outperforms the bookkeeping for streaming partial rows. Streaming would also force the renderer to keep partial bitmaps in flux, complicating heatmap construction.

Failure sentinel

Worker returns 28 (km/s) for cells where solveLambert returns null (typically: TOF too short for short-way Hohmann, or geometry near-degenerate). 28 clamps into the deepest red of the colour scale, so failed cells render as visually unreachable rather than as gaps. The renderer doesn't need to know about failures — they fall out of the colour mapping.

Rationale

Closing now lets us write lambert.worker.ts and /plan against a final contract instead of leaving the protocol in-flux while RFC-003 was still draft. The id-based cancellation has now been exercised by /plan (changing date range cancels mid-flight, no race on the result handler), and the every-10-row progress cadence rendered smoothly during the 1.4 s compute on test devices.

steps: [112, 100] (the value used by /plan, mandated by Issue #3) is a free parameter at the contract level — the worker handles arbitrary [w, h]. Future routes (e.g. /earth orbit-injection porkchops) can pick their own grid dimensions without protocol changes.

Alternatives considered

  • Cancel via worker.terminate() + spawn fresh. Rejected: terminating a worker discards the import graph; respawn is ~20 ms wasted on every interaction. The id check is ~free.
  • AbortController-style. No native worker support yet; would require extra plumbing for marginal API ergonomics.
  • Stream rows. Rejected: complicates the renderer; structured-clone of the final grid is fast enough that streaming buys nothing visually.

Consequences

Positive: Lambert worker contract locked, surfaced through types in the source so any downstream caller gets compile-time correctness; id-based cancellation enables future date-range editors without race conditions; progress UX is smooth.

Negative: the worker was originally locked to Earth → Mars geometry. Resolved by ADR-026 (v0.1.6, closes RFC-007): the request payload now carries an optional destinationId ('mercury' | 'venus' | 'mars' | 'jupiter' | 'saturn'); the worker reads the destination's heliocentric ephemerides (a, T, L0) from static/data/planets.json via lambert-grid.constants.ts. Backward-compatible — a request without destinationId defaults to Mars and produces byte-identical output to the pre-v0.1.6 worker.

Orrery — architecture documentation · MIT · No tracking