Transactions on streams
This guide publishes groups of messages to Redis streams all or nothing.
The example is an order service. It confirms orders in batches and ships each packed parcel as one shipment per item.
A batch's replies in one block
- Macros
- Manual
/// Confirms a batch of orders. The confirmations of one batch are published together or not at
/// all.
#[subscriber(RedisStream::new("orders").group("confirmations"), reply)]
async fn confirm(orders: &[Order]) -> Result<Vec<Confirmation>, HandlerOutcome> {
if orders.iter().any(|order| order.items.is_empty()) {
tracing::warn!(
orders = orders.len(),
"a batch with an empty order is dropped"
);
return Err(HandlerOutcome::drop());
}
Ok(orders
.iter()
.map(|order| Confirmation { order_id: order.id })
.collect())
}
/// Confirms a batch of orders. The confirmations of one batch are published together or not at
/// all.
struct Confirm;
impl Handle<[Order], Vec<Confirmation>> for Confirm {
fn handle(
&self,
orders: &[Order],
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<Vec<Confirmation>, Vec<HandlerOutcome>>> {
if orders.iter().any(|order| order.items.is_empty()) {
tracing::warn!(
orders = orders.len(),
"a batch with an empty order is dropped"
);
return ready(Err(orders.iter().map(|_| HandlerOutcome::drop()).collect()));
}
ready(Ok(orders
.iter()
.map(|order| Confirmation { order_id: order.id })
.collect()))
}
}
A transaction sends its publishes as one MULTI and EXEC block, in publish order.
A reader of confirmations sees every confirmation of a batch, or none of them.
confirm is a batch handler: it takes a slice and returns one confirmation per order.
An Err publishes nothing and settles the whole batch with its outcome.
The mount
- Macros
- Manual
fn service(broker: RedisBroker) -> impl App {
RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(broker, |b| {
b.include(confirm.batch(nonzero!(32)))
.out_reply(TransactionalPublish)
.transactional();
b.include(ship).out(Shipments, TransactionalPublish).build();
})
}
fn service(broker: RedisBroker) -> impl App {
RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(broker, |b| {
let confirm = subscriber(RedisStream::new("orders").group("confirmations"), Confirm);
b.include(confirm.batch(nonzero!(32)).reply().build())
.out_reply(TransactionalPublish)
.transactional();
let ship = subscriber(RedisStream::new("packed").group("shipping"), Ship);
b.include(ship.build())
.out(Shipments, TransactionalPublish)
.build();
})
}
.out_reply(TransactionalPublish).transactional() asks for the borrowed kind.
The runtime opens a transaction on the reply publisher, publishes the batch's replies into it and commits.
TransactionalPublish is RedisTransactionalPublish under the stream prelude's name.
It sends with XADD, like Publish, and runs only where transactions run.
The size of the batch is the COUNT of the read that fetches it, so one transaction holds at most 32 confirmations here.
A transaction the handler owns
- Macros
- Manual
/// Publishes one shipment per item of a parcel, committed as one block.
#[subscriber(RedisStream::new("packed").group("shipping"))]
async fn ship(
parcel: &Parcel,
Out(shipments): Out<impl OwnedTransactions, Shipments>,
) -> HandlerOutcome {
let Ok(mut transaction) = shipments.transaction().await else {
return HandlerOutcome::retry();
};
for item in &parcel.items {
if item.is_empty() {
tracing::warn!(parcel.order_id, "a nameless item, the parcel ships nothing");
// The abort discards the shipments buffered so far.
return match transaction.abort().await {
Ok(()) => HandlerOutcome::drop(),
Err(_) => HandlerOutcome::retry(),
};
}
let shipment = Shipment {
order_id: parcel.order_id,
item: item.clone(),
};
if transaction.message(&shipment).publish().await.is_err() {
return HandlerOutcome::retry();
}
}
match transaction.commit().await {
Ok(()) => HandlerOutcome::ack(),
Err(_) => HandlerOutcome::retry(),
}
}
/// Publishes one shipment per item of a parcel, committed as one block.
struct Ship;
impl<Slot> Handle<Parcel, (), Outs<(Slot,)>> for Ship
where
Slot: OutEntry<Shipments, Wire: OwnedTransactions>,
{
async fn handle(
&self,
parcel: &Parcel,
outs: &Outs<(Slot,)>,
_ctx: &mut Context<'_>,
) -> Result<(), HandlerOutcome> {
let mut transaction = outs
.get(Shipments)
.transaction()
.await
.map_err(|_| HandlerOutcome::retry())?;
for item in &parcel.items {
if item.is_empty() {
tracing::warn!(parcel.order_id, "a nameless item, the parcel ships nothing");
// The abort discards the shipments buffered so far.
transaction
.abort()
.await
.map_err(|_| HandlerOutcome::retry())?;
return Err(HandlerOutcome::drop());
}
let shipment = Shipment {
order_id: parcel.order_id,
item: item.clone(),
};
transaction
.message(&shipment)
.publish()
.await
.map_err(|_| HandlerOutcome::retry())?;
}
transaction
.commit()
.await
.map_err(|_| HandlerOutcome::retry())
}
}
transaction() on an Out slot returns a value that owns its buffer.
Its publishes go into the buffer, commit sends the buffer as one block, and abort discards it.
commit and abort consume the value, so a second commit or a publish after the end does not compile.
Several owned transactions can be open on one publisher at once, and settling one never touches another.
A transaction dropped without either is discarded like an abort, with a warning in the log.
owned_transaction() is the same over values: each publish takes a destination and a value and encodes it with the default codec.
It suits a startup hook, such as after_startup, that seeds a stream.
What "no rollback" means
Redis queues the commands of a block and runs them together; it does not undo them.
A command the server refuses to queue, such as one with a wrong number of arguments, discards the whole block, and commit returns an error.
A command that fails while the block runs leaves the commands before it applied.
For a group of XADDs that happens when the server runs out of memory or a key holds another type.
A failed commit has consumed its transaction, and the buffer is gone.
The handler returns retry, and the delivery comes again and builds a new transaction.
Where transactions run
Transactions run on a standalone server and on a sentinel deployment.
A MULTI block cannot span hash slots, so on a cluster a service that mounts TransactionalPublish does not start.
The list and Pub/Sub publishers have no transactions.
A transaction covers publishes.
On the consuming side, .pipeline().atomic() puts a handler's own Redis commands and the acknowledgement of its delivery in one block.
Testing
The tests in the example files run in process, where the in-process Redis runs MULTI and EXEC.
They check that a batch's confirmations and a parcel's shipments arrive together, and that a dropped batch and an aborted parcel publish nothing.
One more test starts the same service with a cluster broker and checks that it does not start.