Эта страница переведена с английского языковой моделью. При любом расхождении верен английский оригинал.
Транзакции и exactly-once
Это руководство публикует несколько записей одной транзакцией Kafka, запускает транзакции при пуле воркеров и делает обработчик по схеме «прочитать, преобразовать, записать» exactly-once.
Транзакция Kafka делает набор записей видимым целиком.
Читатель с уровнем изоляции read_committed, который в librdkafka стоит по умолчанию, видит их все после фиксации транзакции и не видит ни одной после прерывания.
В RustStream транзакция - свойство политики публикации в точке монтирования, а обработчик называет только ту совместимость, которой пользуется.
Всё или ничего из одного обработчика
ship превращает оплаченный заказ в отгрузочную строку на каждую позицию.
Заказ с пустой строкой отбрасывается, и ни одна его строка не должна попасть на склад.
- Макросы
- Вручную
#[derive(Debug, PartialEq, Deserialize)]
struct Order {
id: u64,
items: Vec<Item>,
}
#[derive(Debug, PartialEq, Deserialize)]
struct Item {
sku: String,
quantity: u32,
}
/// One shipment line per item of a paid order.
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
#[outgoing(name = "shipment-lines")]
struct ShipmentLine {
order_id: u64,
sku: String,
quantity: u32,
}
#[derive(OutSlot)]
#[publishes(ShipmentLine)]
struct Shipments;
#[subscriber(KafkaTopic::new("paid-orders").group("shipping").commit(Commit::Tracked))]
async fn ship(
order: &Order,
Out(shipments): Out<impl TransactionalPublisher, Shipments>,
) -> HandlerOutcome {
if shipments.begin_transaction().await.is_err() {
return HandlerOutcome::retry();
}
for item in &order.items {
if item.quantity == 0 {
tracing::warn!(order.id, sku = %item.sku, "order with an empty line dropped");
return match shipments.abort().await {
Ok(()) => HandlerOutcome::drop(),
Err(_) => HandlerOutcome::retry(),
};
}
let line = ShipmentLine {
order_id: order.id,
sku: item.sku.clone(),
quantity: item.quantity,
};
if shipments.message(&line).publish().await.is_err() {
shipments.abort().await.ok();
return HandlerOutcome::retry();
}
}
if shipments.commit().await.is_err() {
return HandlerOutcome::retry();
}
tracing::info!(order.id, lines = order.items.len(), "order shipped");
HandlerOutcome::ack()
}
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Order {
id: u64,
items: Vec<Item>,
}
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Item {
sku: String,
quantity: u32,
}
/// One shipment line per item of a paid order.
#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct ShipmentLine {
order_id: u64,
sku: String,
quantity: u32,
}
// `#[derive(Outgoing)]` with `#[outgoing(name = "shipment-lines")]`, by hand.
impl OutgoingDestination for ShipmentLine {
type Form = FixedName;
const DESTINATION: &'static str = "shipment-lines";
}
impl MessageHeaders for ShipmentLine {
type Contract = NoHeaders;
}
impl MessageInfo for ShipmentLine {
const NAME: &'static str = "ShipmentLine";
const DESCRIPTION: Option<&'static str> = None;
}
/// The slot the shipment lines leave through.
struct Shipments;
impl OutSlot for Shipments {
const NAME: &'static str = "Shipments";
// `ShipmentLine` fixes its destination, so no transform on this slot may name one.
type Destination = Reads;
}
impl PublishedThrough<Shipments> for ShipmentLine {}
struct Ship;
impl<S> Handle<Order, (), Outs<(S,)>> for Ship
where
S: OutEntry<Shipments, Wire: TransactionalPublisher>,
{
async fn handle(
&self,
order: &Order,
outs: &Outs<(S,)>,
_ctx: &mut Context<'_>,
) -> Result<(), HandlerOutcome> {
let shipments = outs.get(Shipments);
if shipments.begin_transaction().await.is_err() {
return Err(HandlerOutcome::retry());
}
for item in &order.items {
if item.quantity == 0 {
tracing::warn!(order.id, sku = %item.sku, "order with an empty line dropped");
return match shipments.abort().await {
Ok(()) => Err(HandlerOutcome::drop()),
Err(_) => Err(HandlerOutcome::retry()),
};
}
let line = ShipmentLine {
order_id: order.id,
sku: item.sku.clone(),
quantity: item.quantity,
};
if shipments.message(&line).publish().await.is_err() {
shipments.abort().await.ok();
return Err(HandlerOutcome::retry());
}
}
if shipments.commit().await.is_err() {
return Err(HandlerOutcome::retry());
}
tracing::info!(order.id, lines = order.items.len(), "order shipped");
Ok(())
}
}
Обработчик принимает свой слот как Out<impl TransactionalPublisher, Shipments>: он называет совместимость ядра TransactionalPublisher и ничего из Kafka.
Записи, опубликованные между begin_transaction и commit, становятся видимыми вместе.
abort отменяет их, поэтому первая строка заказа с пустой строкой так и не доходит до читателя.
Вне открытой транзакции слот публикует как обычный.
Второй begin_transaction при открытой транзакции сообщает KafkaError::TransactionBusy, а commit или abort, когда открытой транзакции нет, сообщает KafkaError::NoTransaction.
Транзакция охватывает опубликованные строки, но не прочитанный заказ.
Commit::Tracked фиксирует смещение заказа после того, как обработчик его подтвердил.
Если процесс упадёт между этими двумя шагами, заказ будет доставлен снова, и его строки уйдут во второй раз.
Этот разрыв закрывает конвейер exactly-once, о котором речь ниже.
Транзакции при пуле воркеров
Один продюсер Kafka ведёт одну транзакцию за раз, поэтому обработчики пула воркеров не могут делить одного транзакционного издателя.
invoice запускает четыре партиции воркеров и даёт каждой исходной партиции собственную транзакцию.
- Макросы
- Вручную
/// One invoice line per item of a billed order.
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
#[outgoing(name = "invoice-items")]
struct InvoiceItem {
order_id: u64,
sku: String,
quantity: u32,
}
#[subscriber(
KafkaTopic::new("billable-orders").group("invoicing").commit(Commit::Tracked),
workers(4, by_key)
)]
async fn invoice(
order: &Order,
Ctx(partition): Ctx<Partition>,
Out(lanes): Out<impl PartitionLanes>,
) -> HandlerOutcome {
let Ok(items) = lanes.for_partition(partition).await else {
return HandlerOutcome::retry();
};
if items.begin_transaction().await.is_err() {
return HandlerOutcome::retry();
}
for item in &order.items {
let line = InvoiceItem {
order_id: order.id,
sku: item.sku.clone(),
quantity: item.quantity,
};
if items.message(&line).publish().await.is_err() {
items.abort().await.ok();
return HandlerOutcome::retry();
}
}
if items.commit().await.is_err() {
return HandlerOutcome::retry();
}
tracing::info!(order.id, partition, "order invoiced");
HandlerOutcome::ack()
}
/// One invoice line per item of a billed order.
#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct InvoiceItem {
order_id: u64,
sku: String,
quantity: u32,
}
impl OutgoingDestination for InvoiceItem {
type Form = FixedName;
const DESTINATION: &'static str = "invoice-items";
}
impl MessageHeaders for InvoiceItem {
type Contract = NoHeaders;
}
impl MessageInfo for InvoiceItem {
const NAME: &'static str = "InvoiceItem";
const DESCRIPTION: Option<&'static str> = None;
}
/// Declaring `KafkaContext` is what gives the body the delivery's partition.
struct Invoice;
impl<L> Handle<Order, (), Outs<(L,)>, KafkaContext> for Invoice
where
L: OutEntry<DefaultSlot, Wire: PartitionLanes>,
{
async fn handle(
&self,
order: &Order,
outs: &Outs<(L,)>,
ctx: &mut Context<'_, KafkaContext>,
) -> Result<(), HandlerOutcome> {
let partition = ctx.context(Partition);
let Ok(items) = outs.get(DefaultSlot).for_partition(partition).await else {
return Err(HandlerOutcome::retry());
};
if items.begin_transaction().await.is_err() {
return Err(HandlerOutcome::retry());
}
for item in &order.items {
let line = InvoiceItem {
order_id: order.id,
sku: item.sku.clone(),
quantity: item.quantity,
};
if items.message(&line).publish().await.is_err() {
items.abort().await.ok();
return Err(HandlerOutcome::retry());
}
}
if items.commit().await.is_err() {
return Err(HandlerOutcome::retry());
}
tracing::info!(order.id, partition, "order invoiced");
Ok(())
}
}
workers(4, by_key) выбирает партицию воркеров по партиции записи, поэтому одна партиция обрабатывается по порядку на одной партиции воркеров.
Слот - PartitionLanes: for_partition возвращает транзакционного издателя партиции доставки и создаёт его при первом обращении.
Партиции воркеров, которые обрабатывают разные партиции, ведут свои транзакции одновременно.
Транзакционные идентификаторы следуют за партициями темы, а не за числом воркеров, поэтому изменение workers(n) не меняет ни одного идентификатора.
Этой схеме нужен ключ партиционирования по умолчанию.
LaneKey::RecordKey раскладывает одну партицию по нескольким партициям воркеров. Тогда они делили бы издателя одной партиции и сталкивались бы на его единственной транзакции.
Конвейер exactly-once
enrich читает размещённый заказ и отвечает итогами, по которым планирует склад.
Каждый ответ публикуется ровно один раз, даже если сервис упадёт и заказ будет прочитан снова.
- Макросы
- Вручную
/// A placed order with the totals the warehouse plans with.
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
#[outgoing(name = "enriched-orders")]
struct EnrichedOrder {
id: u64,
lines: usize,
units: u32,
}
#[subscriber(
KafkaTopic::new("placed-orders")
.group("enrichment")
.commit(Commit::Transactional("enrichment-1".into())),
reply,
workers(4, by_key)
)]
async fn enrich(order: &Order) -> EnrichedOrder {
let units = order.items.iter().map(|item| item.quantity).sum();
tracing::info!(order.id, units, "order enriched");
EnrichedOrder {
id: order.id,
lines: order.items.len(),
units,
}
}
/// A placed order with the totals the warehouse plans with.
#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct EnrichedOrder {
id: u64,
lines: usize,
units: u32,
}
impl OutgoingDestination for EnrichedOrder {
type Form = FixedName;
const DESTINATION: &'static str = "enriched-orders";
}
impl MessageHeaders for EnrichedOrder {
type Contract = NoHeaders;
}
impl MessageInfo for EnrichedOrder {
const NAME: &'static str = "EnrichedOrder";
const DESCRIPTION: Option<&'static str> = None;
}
struct Enrich;
impl Handle<Order, EnrichedOrder> for Enrich {
fn handle(
&self,
order: &Order,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<EnrichedOrder, HandlerOutcome>> {
let units = order.items.iter().map(|item| item.quantity).sum();
tracing::info!(order.id, units, "order enriched");
ready(Ok(EnrichedOrder {
id: order.id,
lines: order.items.len(),
units,
}))
}
}
Обработчик только возвращает ответ, кода транзакций в нём нет.
Commit::Transactional("enrichment-1") запрещает подписке фиксировать смещения самой.
Вместо неё их фиксирует конвейер с тем же идентификатором, внутри транзакции, которая несёт ответы.
Поэтому прочитанная позиция и опубликованные записи сдвигаются вместе: фиксируются либо оба, либо ни одно.
Точка монтирования
Точка монтирования называет политику за каждым слотом и ответом.
- Макросы
- Вручную
fn service(broker: KafkaBroker) -> impl App {
RustStream::new(AppInfo::new("fulfilment", "0.1.0")).with_broker(broker, |b| {
// One transactional id per service instance.
b.include(ship)
.out(Shipments, Publish::default().transactional_id("shipping-1"))
.build();
// One transactional id per source partition: "invoicing-1-p0", "invoicing-1-p1", ...
b.include(invoice)
.out(
DefaultSlot,
Publish::default()
.transactional_id("invoicing-1")
.per_partition(),
)
.build();
// The pipeline's id is the one the subscription's commit mode names.
b.include(enrich)
.out_reply(EosPublish::new("enrichment-1"))
.transform(EosReplies);
})
}
#[ruststream::app]
fn app() -> impl App {
service(KafkaBroker::new(["localhost:9092"]))
}
fn service(broker: KafkaBroker) -> impl App {
RustStream::new(AppInfo::new("fulfilment", "0.1.0")).with_broker(broker, |b| {
// One transactional id per service instance.
let paid = subscriber("paid-orders", Ship).map_source(|_| {
KafkaTopic::new("paid-orders")
.group("shipping")
.commit(Commit::Tracked)
});
b.include(paid.build())
.out(Shipments, Publish::default().transactional_id("shipping-1"))
.build();
// One transactional id per source partition: "invoicing-1-p0", "invoicing-1-p1", ...
let billable = subscriber("billable-orders", Invoice)
.map_source(|_| {
KafkaTopic::new("billable-orders")
.group("invoicing")
.commit(Commit::Tracked)
})
.workers_by_key(nonzero!(4));
b.include(billable.build())
.out(
DefaultSlot,
Publish::default()
.transactional_id("invoicing-1")
.per_partition(),
)
.build();
// The pipeline's id is the one the subscription's commit mode names.
let placed = subscriber("placed-orders", Enrich)
.map_source(|_| {
KafkaTopic::new("placed-orders")
.group("enrichment")
.commit(Commit::Transactional("enrichment-1".into()))
})
.reply()
.workers_by_key(nonzero!(4));
b.include(placed.build())
.out_reply(EosPublish::new("enrichment-1"))
.transform(EosReplies);
})
}
fn app() -> impl App {
service(KafkaBroker::new(["localhost:9092"]))
}
fn main() -> ExitCode {
run_main(app)
}
transactional_id превращает обычную политику в транзакционную.
Один транзакционный идентификатор принадлежит одному живому продюсеру: второй продюсер, запущенный с тем же идентификатором, отсекает первого, и тот больше не может фиксировать.
Дайте каждому запущенному экземпляру сервиса свой идентификатор, например по порядковому номеру пода.
per_partition делает идентификатор основой: издатели invoice получают идентификаторы invoicing-1-p0, invoicing-1-p1 и так далее.
EosPublish::new("enrichment-1") - это конвейер, и его идентификатор тот же, что называет режим фиксации подписки.
EosReplies переносит исходную позицию каждой доставки на её ответ. По ней конвейер и сопоставляет одно с другим.
Без него первый ответ завершается ошибкой, а не уходит мимо транзакции.
Окно фиксации
Конвейер собирает ответы в окно и фиксирует его каждые commit_interval (по умолчанию 100 мс).
В конце окна он ждёт, пока завершится каждая доставка, которая в него публиковала, добавляет их позиции и метаданные группы в транзакцию и фиксирует её.
Поэтому ответ становится видимым не раньше чем через один интервал фиксации после того, как обработчик его вернул.
Неудачная публикация, неудачная фиксация или доставка, которая не завершилась вовремя, прерывают окно. Тогда консьюмеры перематываются к последним зафиксированным смещениям, и всё окно обрабатывается заново в новой транзакции. В зафиксированном выходе каждый ответ всё равно встречается один раз.
Конвейер ограничивает, что обработчики могут возвращать и как они читают:
retry()из обработчика держит окно открытым до истечения срока транзакции, 30 секунд, еслиtransaction_timeoutне задаёт другой, а затем окно читается заново.retry_afterне место в обработчике конвейера: его отложенная копия публикуется вне окна и нарушает соответствие записей и смещений.KafkaPartitionsне сочетается сCommit::Transactional, и такая подписка не запускается: конвейер фиксирует через группу консьюмеров, а читатель закреплённых партиций в группе не участвует.