v0.5.0 is the reliability release. It removes the deprecated hexeract-outbox-postgres crate, reshapes the OutboxStore trait and OutboxWorkerConfig for crash-safe retries, and freezes the remaining public enums and error types before 1.0. Most upgrades are mechanical: bump the pins, run cargo update, then apply only the source edits for the APIs you actually use.
If you are still on v0.3.x, apply the v0.3 to v0.4 guide first.
| Area | Change | Action required |
|---|---|---|
| Outbox backend | hexeract-outbox-postgres removed |
Switch to hexeract-outbox-sql (the table schema is unchanged) |
OutboxStore trait |
mark_failed takes retry_in: Duration; new claim and mark_dead_lettered methods |
Update custom implementations (built-in backends already do this) |
| Outbox config | retry_delay removed, replaced by retry_base_delay / retry_max_delay / jitter |
Replace the field at construction |
| Outbox errors | New OutboxError::DispatchTimeout variant |
Add a wildcard arm to exhaustive matches |
| Middleware | TracingMiddleware::with_level is now a chainable method, not a constructor |
Build with new() then chain with_level |
| Core / mediator / bus | HandlerNotFound, HandlerFailed, MediatorBuildError, HandlersVerificationError, AckMode, Dialect, RabbitMqWorkerConfig are now #[non_exhaustive] |
Add wildcard arms; construct configs through builders |
| Bus consumer | RabbitMqWorkerConfig::max_buffered (opt-in) and max_payload_bytes cap |
Set max_buffered under AckMode::Unacknowledged if you rely on it |
| Bus pool | ChannelPool::idle_len is now synchronous |
Drop the .await |
| Outbox events | event_type capped at 64 bytes |
Shorten any EVENT_TYPE over 64 bytes |
hexeract = { version = "0.5", features = ["mediator", "bus-rabbitmq", "outbox-sql-postgres"] }Or, if you depend on the individual crates:
hexeract-core = "0.5"
hexeract-mediator = "0.5"
hexeract-middleware = "0.5"
hexeract-bus = "0.5"
hexeract-bus-rabbitmq = "0.5"
hexeract-outbox = "0.5"
hexeract-outbox-sql = { version = "0.5", features = ["postgres"] }Run cargo update and rebuild.
The deprecated hexeract-outbox-postgres crate (built on deadpool_postgres) is removed in 0.5.0. Move to hexeract-outbox-sql, which is built on sqlx, so the constructors take a sqlx::PgPool rather than a deadpool_postgres::Pool. The PostgreSQL table schema is byte-for-byte identical, so existing tables keep working with no data migration.
Before:
hexeract = { version = "0.4", features = ["outbox-postgres"] }use hexeract::outbox_postgres::{PgOutboxWorkerBuilder, ensure_schema};After:
hexeract = { version = "0.5", features = ["outbox-sql-postgres"] }use hexeract::outbox_sql::PgOutboxWorkerBuilder;
use hexeract::outbox_sql::postgres::ensure_schema;
let pool = sqlx::PgPool::connect(&database_url).await?;
ensure_schema(&pool, "audit_outbox").await?; // POC/tests only; production applies Dialect::schema_ddl via migration tooling
let worker = PgOutboxWorkerBuilder::new(pool)
.register_handler::<MyEvent, _>(my_handler)
.build()?;The same crate also covers MySQL (outbox-sql-mysql) and SQLite (outbox-sql-sqlite) behind their own features. SQLite is single-worker only; for competing-consumers fan-out across many workers, stay on PostgreSQL or MySQL. See SQLite outbox concurrency.
If you implement OutboxStore yourself (most users do not, they use the built-in PostgreSQL, MySQL or SQLite backends), three signatures changed for crash-safe reliability.
mark_failed now receives retry_in: Duration, a delay from now, instead of an absolute application timestamp. The backend adds it to its own database clock when persisting next_retry_at, so retry scheduling is immune to skew between the worker host and the database host.
// Before: an absolute timestamp computed on the application clock
async fn mark_failed<'a>(&self, tx: &mut Self::Tx<'a>, event_id: Uuid, error: &str, next_retry_at: SystemTime) -> Result<(), OutboxError>;
// After: a delay the backend resolves against its own clock
async fn mark_failed<'a>(&self, tx: &mut Self::Tx<'a>, event_id: Uuid, error: &str, retry_in: Duration) -> Result<(), OutboxError>;Two methods were added, both with a default no-op body, so a backend that neither leases nor dead-letters can ignore them:
claim(tx, event_ids, lease_for: Duration)advances the soft lease (next_retry_atset to the database clock pluslease_for) and increments the attempt counter at claim time. Incrementing here, rather than only on failure, is what makes a worker crash between claim and acknowledgement safe: the attempt is already counted, so a poison envelope eventually reaches the dead-letter threshold.mark_dead_lettered(tx, event_id, error)persists an envelope that has exhausted its retry budget, called within the same transaction asmark_failed.
OutboxWorkerConfig::retry_delay is removed. Retries now use bounded exponential backoff: the next retry waits min(retry_max_delay, retry_base_delay × 2^attempts), with optional jitter.
// Before
let config = OutboxWorkerConfig {
retry_delay: Duration::from_secs(5),
..Default::default()
};
// After
let config = OutboxWorkerConfig {
retry_base_delay: Duration::from_secs(1), // default 1 s
retry_max_delay: Duration::from_secs(300), // default 300 s
jitter: true, // default true
..Default::default()
};A fixed delay maps to setting retry_base_delay and a retry_max_delay equal to it with jitter: false.
dispatch_timeout (default 30 s) is now enforced as a hard per-handler deadline: a hung handler is cancelled and the envelope retried instead of stalling the worker. It surfaces through the new OutboxError::DispatchTimeout variant. If you exhaustively match OutboxError, add a wildcard arm:
match err {
OutboxError::MaxRetries => { /* ... */ }
// ...
_ => { /* covers DispatchTimeout and future variants */ }
}with_level is no longer a constructor; it is a chainable consuming method on a built middleware.
// Before
let mw = TracingMiddleware::with_level(Level::DEBUG);
// After
let mw = TracingMiddleware::new().with_level(Level::DEBUG);These types are now #[non_exhaustive] so future variants and fields are not breaking. Exhaustive match arms must add a wildcard _ (or .. for struct variants), and configs must be built through their builders rather than struct literals:
HexeractError::HandlerNotFoundandHexeractError::HandlerFailed: constructHandlerNotFoundthroughHexeractError::handler_not_found(); add..to pattern matches.MediatorBuildErrorandHandlersVerificationError: add a wildcard arm.AckModeandDialect: add a wildcard_arm.RabbitMqWorkerConfig: construct throughRabbitMqWorkerBuilder, not a struct literal.AckMode::default()is stillManual.
The new HexeractError::InputDowncastFailed { expected } variant (with the input_downcast_failed constructor) replaces an opaque failure when a dispatched input does not downcast to the handler's message type; a wildcard arm already covers it.
Two opt-in safety bounds were added on the RabbitMQ worker:
RabbitMqWorkerConfig::max_buffered(builder methodmax_buffered) bounds the number of in-flight deliveries buffered underAckMode::Unacknowledged. It defaults to unbounded for backwards compatibility, but ano_ackconsumer with no bound logs a warning; set it to cap memory.RabbitMqWorkerConfig::max_payload_bytes(defaultDEFAULT_MAX_PAYLOAD_BYTES, 1 MiB) rejects oversized deliveries withBusError::PayloadTooLarge. Raise it through themax_payload_bytesbuilder method if your messages are legitimately larger.
let worker = RabbitMqWorkerBuilder::new(conn)
.ack_mode(AckMode::Unacknowledged)
.max_buffered(1024) // bound the no_ack buffer
.max_payload_bytes(4 * 1024 * 1024) // raise the cap to 4 MiB
.register_handler::<OrderPlaced, _>(Projector)
.build()?;Reporting the idle channel count no longer awaits.
// Before
let n = pool.idle_len().await;
// After
let n = pool.idle_len();event_type is validated to be at most 64 bytes at the envelope boundary, matching the schema-bound limit. An Event::EVENT_TYPE longer than 64 bytes is now rejected rather than silently truncated downstream. Shorten any over-long identifier.
After the bump:
-
cargo build --workspacesucceeds. -
cargo test --workspace --all-featuressucceeds. -
cargo clippy --workspace --all-targets --all-features -- -D warningssucceeds. - No remaining reference to
hexeract-outbox-postgresor theoutbox-postgresfeature inCargo.toml. - Custom
OutboxStore::mark_failedtakesretry_in: Duration;claimandmark_dead_letteredare implemented or left as the default no-op. -
OutboxWorkerConfigusesretry_base_delay/retry_max_delay/jitterinstead ofretry_delay. -
TracingMiddlewareis built withnew().with_level(...). - Exhaustive matches on the frozen enums and errors carry a wildcard arm.
- No
EVENT_TYPEexceeds 64 bytes.