ADR 0006 — RabbitMQ persistence with two-track message policy (CEL, 512KB)¶
Status: Proposed (target architecture)
Date: 2026-06-02
Related: ADR 0005, Architecture, Orchestrator
Context¶
The orchestrator is being shaped more strictly as a service-provider-driven runtime with:
- minimal memory footprint in the runner;
- durable message persistence;
- open-source components that run locally on Kubernetes;
- explicit separation between small and large messages.
CEL was chosen for routing expressions.
RabbitMQ (not Kafka) was chosen for queueing/persistence because the primary use case is command/work queue processing, not large-scale event log replay.
Decision¶
1) Messaging stack (OSS, local K8s)¶
The runtime uses:
- RabbitMQ as the broker for command/work queues;
- PostgreSQL for run/step/state/idempotency persistence;
- MinIO (S3-compatible) for payload offload of large messages.
Everything runs as containers on local Kubernetes (kind/k3d/minikube) and is cloud-neutral.
2) Two-track message policy¶
The orchestrator enforces a hard split on payload size:
- Track small: payload
<= 512KB - Track large: payload
> 512KB
Track small (<= 512KB)¶
- Inline payload in message envelope allowed.
- In-flow transformations allowed (within budget/limits).
- Content-based routing allowed with CEL on headers + payload fields.
Track large (> 512KB)¶
- Payload is handled as a reference (
payload_ref), not inline. - Orchestrator processes envelope + metadata only.
- Transformations happen only via explicit
service_callto provider services. - No inline content transformations in the runner.
3) Envelope contract¶
Every queue message contains at minimum:
message_id(UUID)correlation_idworkflow_nametrack(small|large)headers(technical + business)idempotency_key- exactly one of:
payload_inline(small)payload_ref(bucket,key,version,sha256) (large)
4) RabbitMQ topology¶
Minimal topology:
- Exchange
orchestrator.commands(topic/direct) - Queue
orchestrator.run.small - Queue
orchestrator.run.large - Queue
orchestrator.retry.small - Queue
orchestrator.retry.large - Queue
orchestrator.dlq
Rules:
- retry via delayed backoff pattern (TTL + dead-letter routing);
- poison messages to
orchestrator.dlq; - worker acks only after durable state write.
5) State & idempotency¶
PostgreSQL manages:
- run lifecycle (
accepted,queued,running,succeeded,failed,dead_lettered); - step checkpoints;
- idempotency receipts;
- audit/events.
Processing is at-least-once with idempotent handling via idempotency_key.
6) Memory footprint rules¶
Runner rules:
- keep only envelope + necessary step context in memory;
- do not dump large payloads into trace by default;
- bounded concurrency + prefetch limits;
- fetch/stream large payloads only when a service call needs them.
Consequences¶
Positive¶
- clear, enforceable separation between light and heavy payload processing;
- lower memory pressure on orchestrator workers;
- better reliability through durable queue + persisted state;
- easier to run locally than a Kafka-first setup.
Negative / trade-offs¶
- extra components (RabbitMQ, Postgres, MinIO) increase operational footprint;
- large track requires reference management and hash validation;
- CEL governance needed (limits on expression complexity and evaluation time).
Implementation guardrails¶
- No backward compatibility for legacy inline-invocation paths.
- New workflow functionality must be provider-driven (
service_callfirst). - Inline transformations only for small track and within strict limits.
- Large track policy enforced at API ingress.
Follow-up work items¶
- ~~Publish contracts: queue envelope JSON schema + DB schema.~~ (done)
- ~~Adapt orchestrator API: size-based track selection and payload offload.~~ (done)
- ~~Implement worker split: small/large queue consumers with different budgets.~~ (done)
- Add CEL routing engine for small track.
- Add retry/DLQ policies and operational dashboards.
- ~~PostgreSQL run-state writes in enqueue/worker flow.~~ (done)
- Per-step
workflow_stepspersistence from runner trace (not yet wired).