Caliper emission worker host — a long-lived Bun loop, not a cron
Context
Section titled “Context”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.
Decision
Section titled “Decision”- Bun, not Spin/WASM. The worker is a Bun process. (Spin was evaluated first — see below.)
- 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
activeinterval if it did work or backs off to a longeridleinterval 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 k8sCronJobhave a 1-minute floor and pay full pod/process setup every tick. - 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/apiis not even deployed as a service yet.) - At-most-one via
strategy: Recreate.claimPendingis a plainSELECT(noFOR 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. - 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). - Periods are env-configurable (
CALIPER_WORKER_{ACTIVE,IDLE,AGGREGATE}_INTERVAL_MS) so cadence is tuned per environment without a rebuild. - 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.
Considered options
Section titled “Considered options”- 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(orJob) whose pod setsruntimeClassName: wasmtime-spin-v2and runs a run-to-completion command-trigger WASM component (thespintainerexecutor 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 theCAL-SND-2..6conformance evidence (we’d prove code we don’t ship). Spin remains the platform’s intendedfunctionslane (already scaffolded in the chart asfunctions.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, butapiisn’t deployed; web servers shouldn’t host background drains; and at >1 api replica every replica would loop and double-POST without leader election.
Consequences
Section titled “Consequences”CAL-SND-1becomes flippable. The host that was gating it now exists; it flips todoneunder a deployed-endpoint TLS smoke check once an external https endpoint is configured (the in-cluster dogfood endpoint isinternal, 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 requiresFOR UPDATE SKIP LOCKEDon the claim first. - Aggregate measures become a time series. Periodic snapshots use a per-run outbox identity
(
<iri>#<runId>) while the wireAggregateMeasure.idstays the stable metric IRI, so the Endpoint receivessendTime-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_credentialrow whosetoken_sha256matches the worker’sCALIPER_ENDPOINT_TOKEN— a DB write, owned by the operator.