Run through this list before letting a Hexeract-powered service answer a real workload. Each item is a one-line check followed by where to read more.
- Schema applied via migration tooling. Generate the canonical DDL with
hexeract outbox patch --table <name>(or programmatically viaDialect::Postgres.schema_ddl("<name>")?) and apply it through your versioned migration tool before deployment.hexeract_outbox_sql::postgres::ensure_schema(&pool, "<name>")is an idempotent helper reserved for POC and integration tests; do not call it at production startup (the runtime role should not hold DDL privileges). See outbox PostgreSQL schema. - Pool sized for both writers and the worker. Each
OutboxWorkerinstance holds one connection per poll cycle; size yoursqlx::PgPoolat leastbusiness_writers + workers + headroom(configure viasqlx::postgres::PgPoolOptions::new().max_connections(n)). - Idempotency wired on the handler side. Handlers can be redelivered. Store a
processed_event_idtable or short-circuit on a deduplication key. - Tuning matches your latency target. Default
poll_interval = 100 msgives a publish-to-dispatch p99 around 200 ms. Drop to20-50 msfor tighter SLOs, scale workers horizontally before lowering further. -
max_attemptsnot silently absorbing bugs. A row pastmax_attemptsstops being polled. Audit pending failures withSELECT event_id, last_error FROM audit_outbox WHERE delivered_at IS NULL AND attempts >= 5. - Backup includes the outbox table. It carries side-effect commitments that have not yet been dispatched.
- Topology declared outside the hot path. Run
hexeract bus declare --topology FILEduring deployment, or callensure_topologyonce at service startup. Do not calldeclare_*helpers on every publish. - Durable queues for at-least-once semantics. Set
durable = trueon every queue that must survive a broker restart, plusauto_delete = false. - Prefetch matched to handler throughput. Default
prefetch = 16is appropriate for most cases; raise for fast, CPU-bound handlers, lower for handlers that block on slow downstream calls. - AckMode chosen consciously. Manual (at-least-once) is the default; only choose a lossy
AckMode(AckOnReceivefor at-most-once,Unacknowledgedfor fire-and-forget) when delivery loss is acceptable. - Publish mode chosen consciously. The transport awaits a publisher confirm by default, so
Okproves the broker stored the message and an unroutable routing key raisesBusError::Unroutable. Only switch a transport tofire_and_forget()when loss is acceptable on the publish side, mirroring the consume-side trade-off above. - Dead-letter routing key configured when at-least-once must not drop on exhaustion. See retry policy.
- Broker reconnect tested.
RabbitMqConnection::connect_with_retryretries on startup, but the running connection does not auto-reconnect mid-session. Wrap your worker spawn in a supervisor that restarts on terminal broker errors. - Metadata limits reviewed against your real headers.
AmqpMetadataLimitsdefaults to 64 headers, 128 key bytes, 8 KiB per value and 32 KiB in total, applied to application and frameworkx-hexeract-*headers together. Count what your deployment actually sends (trace context, tenancy, RPC wire fields, and RabbitMQ's ownx-deathhistory on a retrying queue) and raise or lower the bound deliberately. A publish above the bound fails, and an inbound delivery above it is refused before any handler runs, so a limit set too low is an outage, not a silent truncation. - Metadata limits set identically on every path. Set the same value on the worker (
.metadata_limits(..)), the publisher (RabbitMqTransport::with_metadata_limits) and the request client (RabbitMqRequestClientConfigBuilder::metadata_limits). A single path left on the defaults is the bound that actually applies to an attacker. - Broker-side
max_message_sizeset to the deployment's real ceiling. This is the ingress defense that acts before the client: Hexeract's limits only bound work afterlapinhas already decoded a delivery, so they complement it rather than replace it. Set it to the largest message the application legitimately sends, not to a value chosen to mirror the client limits. -
frame_maxleft at the negotiated default. RabbitMQ recommends retaining the broker/client negotiated value. It is a transport framing parameter, not an application metadata policy; bound metadata withAmqpMetadataLimitsand messages withmax_message_sizeinstead. - Dead-letter consumers tolerate an empty header table. A delivery quarantined for invalid metadata is republished with its field table rebuilt empty, keeping only bounded core properties (
message_id,correlation_id,type,reply_to,timestamp, delivery mode). Anything downstream that routes on a header must handle its absence.
- Schema applied via the CLI, never hand-edited. Generate the DDL with
hexeract scheduler schema --dialect <postgres|my-sql|sqlite>(note: the MySQL dialect token ismy-sql, kebab-cased) and apply it through your versioned migration tool. The CLI is the source of truth for the table shape. - Worker sized for your throughput.
build()enforceslease >= batch_size x dispatch_timeout(rejecting the configuration otherwise, including on overflow), because settling a claimed batch is sequential and a shorter lease could expire before the last occurrence in the batch is even dispatched. Defaults:lease300s,batch_size10,dispatch_timeout30s,poll_interval100ms. If you raisebatch_sizeordispatch_timeout, raiseleaseto match orbuild()will returnSchedulerError::InvalidConfiguration. - Dispatch lag monitored. Track the gap between an occurrence's due time and its actual dispatch time; a growing gap signals an under-provisioned worker pool or a stuck sink.
- Dead-letter alerted and operated. Alert on dead-letter growth, inspect entries with
hexeract scheduler dead-letter list, and replay a schedule withhexeract scheduler dead-letter replay <schedule-id>once the underlying cause is fixed.
- Graceful shutdown propagates the
CancellationToken. SIGTERM, SIGINT and admin-triggered drains all callcancel.cancel()before awaiting the worker join handle. - Worker
JoinHandleawaited and inspected. A panic inside a handler bubbles to the join handle; surface it through structured logging. - Tracing subscriber installed early.
hexeract-bus-rabbitmqandhexeract-outboxemittracing::warnandtracing::errorevents on retries, decode failures and DLR routing. A missing subscriber discards those signals. - No
RUSTFLAGS=-D warningsremoved in production builds. Warnings flag unused futures, unhandled results and lint regressions that often turn into runtime bugs.
- Structured logs.
tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env())is the minimum; pair with a JSON layer when shipping to a log aggregator. - Per-publish
message_idpropagated. Log it on the producer side, log it on the consumer side, correlate across services. BothOutboxEnvelopeandBusEnvelopecarry UUIDv7 identifiers ready to be stitched together. - Correlation chain preserved. Use
publish_with_correlation_idfrom inside handlers to forward the inboundctx.correlation_id. See correlation ID. - Metrics exported. Hexeract does not (yet) expose Prometheus metrics natively; instrument the handler call site and the publish call site with your existing instrumentation crate.
- Connection string out of source control. Use environment variables (
DATABASE_URL,HEXERACT_BUS_URL) or a secret manager. - TLS enabled on broker connections. Use
amqps://instead ofamqp://; the default configuration validates against the platform trust store. For an internal CA or mutual TLS, pass aRabbitMqConnectionConfigcontainingOwnedTLSConfigto every connection constructor, or throughRabbitMqRequestClientConfigBuilder::connection_config; load the CA, client certificate, and its password from the service's secret manager. An internal CA is added to the platform trust store, not substituted for it, so it does not pin trust to your own authority: a certificate issued by any publicly trusted CA for the broker hostname remains acceptable. Rely on mutual TLS and per-service credentials for authentication, not on the private CA alone. - Plaintext restricted to local development.
amqp://is accepted by default only forlocalhost,127.0.0.0/8, and::1; remote plaintext is rejected before connecting. Useamqps://in production, neverallow_insecure_plaintext_transport. The same rule governshexeract bus declare,peekandpurge: no runbook targeting a production broker should carry their--insecure-plaintextflag. - Remote brokers addressed by hostname, not by IPv6 literal.
lapindiscards a bracketed IPv6 literal and dialslocalhostinstead, soamqps://[2001:db8::1]:5671silently targets the local machine. Use a hostname, which is also what certificate validation needs. - TLS material matched to the URI scheme. Configuring a CA or a client identity alongside a plaintext
amqp://URI is refused rather than ignored, because lapin would discard it and connect in cleartext. If a deployment fails to start with that error, fix the scheme rather than reaching forallow_insecure_plaintext_transport, which re-enables the silent downgrade. -
outbox applyandoutbox checkuse TLS by default; scheduler admin commands do not.outbox apply/outbox checkupgrade anysslmodeother than an explicitdisabletorequireand connect viarustlsagainst the operating-system trust store; onlysslmode=disablein the connection string opts into plaintext, and a warning is logged when it does.scheduler list/inspect/dead-letteropen their PostgreSQL pool throughsqlxdirectly, which defaults tosslmode=preferand silently falls back to cleartext if the server declines TLS. For those commands, setsslmode=requireexplicitly inDATABASE_URL. - Credentials scoped per service. A consumer service does not need publish permissions on every exchange; tighten the broker authorisation rules.
- Database role least-privileged. The outbox publisher needs
INSERTon the outbox table; the worker needsSELECT FOR UPDATEandUPDATE. NoDROP, noTRUNCATE.
-
cargo fmt --all -- --check -
cargo clippy --workspace --all-targets --all-features -- -D warnings -
cargo test --workspace --all-features -
cargo deny check(supply-chain audit; see the projectdeny.toml) - Integration tests with
--ignoredagainst real PostgreSQL and RabbitMQ containers on the merge queue.
| Workload shape | Recommendation |
|---|---|
| Bursts up to 100 events/s | Default OutboxWorker config, single worker |
| Sustained 100-500 events/s | Two OutboxWorker instances sharing the table; SELECT ... FOR UPDATE SKIP LOCKED handles the contention |
| > 500 events/s | Horizontal worker pool, per-service outbox table, partition by subject_id if hot rows appear |
| Bursty bus consumer with slow downstream calls | Raise prefetch cautiously, prefer scaling worker instances over inflating prefetch |