Skip to content

Caliper emission worker host — a long-lived Bun loop, not a cron

ADR-0041 built the Caliper Sensor rail (readers → outbox → transport) but deliberately left the host undecided: runEmissionPass is “the unit of work the host invokes — once per tick from a scheduler, or in a loop from a long-lived worker.” That host is the last thing standing between the rail and live emission, and it is what CAL-SND-1 (real TLS on the wire) is gated on. Product also wants emission running so there is something to dogfood.

The work is intrinsically Bun + Drizzle + fetch: the readers walk canonical state with keyset SQL built in Drizzle (over bun-sql), enqueue to the Postgres outbox, and the transport POSTs batched Envelopes. The outbox is a clean seam — a Drizzle-heavy projection half and a small, stable, egress-shaped transport half — and the transport (CAL-SND-2..6) is already conformance-proven in Bun by the in-process interop lane.

  1. Bun, not Spin/WASM. The worker is a Bun process. (Spin was evaluated first — see below.)
  2. A long-lived, single-replica Deployment with a self-pacing loop — NOT an external cron. The loop runs an emission pass, then sleeps a short active interval if it did work or backs off to a longer idle interval if it didn’t (near-real-time when learners are active, near-zero DB load when quiet). Cron is the wrong shape for a frequent drain: both pg_cron and k8s CronJob have a 1-minute floor and pay full pod/process setup every tick.
  3. A dedicated worker, not in-process in apps/api. Clean separation, single-flight by construction, independent restart/limits, and the seed of a general job-runner later. (apps/api is not even deployed as a service yet.)
  4. At-most-one via strategy: Recreate. claimPending is a plain SELECT (no FOR UPDATE SKIP LOCKED), so two concurrent loops would double-POST. One replica + Recreate (old pod terminated before the new one starts) guarantees single-flight. Making the claim lock-based is the documented prerequisite to ever running >1 replica — deferred, not built.
  5. Dogfood our own in-cluster LRS Caliper Endpoint first (http://emergent-lrs…/caliper). A complete, watchable Sensor→Endpoint loop with no external dependency. An external analytics endpoint is a later env swap (the worker takes {endpointUrl, bearerToken} + governance from the environment).
  6. Periods are env-configurable (CALIPER_WORKER_{ACTIVE,IDLE,AGGREGATE}_INTERVAL_MS) so cadence is tuned per environment without a rebuild.
  7. A heartbeat health server. A loop-only process has no request surface, so it publishes a heartbeat each iteration and serves /health (liveness — fails only when a beat once existed and went stale, a genuinely wedged loop) and /ready (ready after the first fresh iteration). This also fits the chart, which models every workload as an httpGet-probed service.
  • Spin / SpinKube (explored first, shelved). The appeal was Spin’s cron trigger, but that trigger does not run under SpinKube — on Kubernetes the idiomatic pattern is a native CronJob (or Job) whose pod sets runtimeClassName: wasmtime-spin-v2 and runs a run-to-completion command-trigger WASM component (the spintainer executor can host the literal cron trigger, but SpinKube’s docs call it a workaround). More decisively, the telemetry worker is the worst fit for WASM: porting it would mean rewriting ~15 Drizzle keyset readers as raw SQL on the Spin JS SDK and re-implementing the conformance-proven transport in WASM — forking both the data stack and the CAL-SND-2..6 conformance evidence (we’d prove code we don’t ship). Spin remains the platform’s intended functions lane (already scaffolded in the chart as functions.spinApps, executor: containerd-shim-spin, disabled) for green-field jobs that don’t reuse Bun-coupled code.
  • pg_cron / Supabase cron. A 1-minute floor and, worse, it forces the outbound HTTP POST from inside Postgres (via pg_net/http) — coupling the database to network egress and making delivery hard to observe and retry. Rejected (and previously tried — it “wasn’t a good solution”).
  • k8s-native CronJob. Fixes the egress-from-the-DB problem but keeps the 1-minute floor, a per-tick pod cold start (fresh Bun process + DB pool every fire), and blind wall-clock firing. Reserved for genuinely infrequent future jobs, not this frequent drain.
  • In-process in apps/api. No new deployment, but api isn’t deployed; web servers shouldn’t host background drains; and at >1 api replica every replica would loop and double-POST without leader election.
  • CAL-SND-1 becomes flippable. The host that was gating it now exists; it flips to done under a deployed-endpoint TLS smoke check once an external https endpoint is configured (the in-cluster dogfood endpoint is internal, so it legitimately uses http per the governance gate).
  • At-most-one is a deployment property, not a code invariant — guarded by single replica + Recreate. Scaling out requires FOR UPDATE SKIP LOCKED on the claim first.
  • Aggregate measures become a time series. Periodic snapshots use a per-run outbox identity (<iri>#<runId>) while the wire AggregateMeasure.id stays the stable metric IRI, so the Endpoint receives sendTime-differentiated re-describes and the counts actually update (refines ADR-0048, whose IRI-keyed idempotency would otherwise emit each measure exactly once).
  • First long-lived background job. Its shape (self-pacing loop + heartbeat health + env config) is the template for a general job-runner; truly-infrequent jobs would go to CronJob.
  • Dogfood prerequisite: a seeded caliper_endpoint_credential row whose token_sha256 matches the worker’s CALIPER_ENDPOINT_TOKEN — a DB write, owned by the operator.