Эта страница переведена с английского языковой моделью. При любом расхождении верен английский оригинал.
Ретрансляция событий в другой брокер
Это руководство записывает событие в одной транзакции с данными, которые оно описывает, а затем публикует его в другой брокер.
У сервиса, который сохраняет заказ, а затем публикует в Kafka «заказ размещён», две записи, которые могут разойтись. Заказ может зафиксироваться, а публикация вернуть ошибку, или публикация может уйти для заказа, который откатился. Транзакционный outbox объединяет их: событие - это строка, записанная в транзакции заказа, а ретранслятор публикует зафиксированные строки. С этим крейтом таблица событий - это таблица очереди, а ретранслятор - обычная подписка.
Пример - интернет-магазин. Оформление размещает заказ и записывает его событие; ретранслятор публикует каждое событие в шину, здесь брокер в памяти.
Заказ и его событие
const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, total_cents INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS checkout_jobs (
id INTEGER PRIMARY KEY,
locked_until TEXT,
payload BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS order_events (
id INTEGER PRIMARY KEY,
aggregate TEXT NOT NULL,
locked_until TEXT,
payload BLOB NOT NULL
);
";
- Макросы
- Вручную
use ruststream_sqlx::prelude::*;
use sqlx::Connection;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::{Sqlite, SqliteConnection};
/// One row of `order_events`: an event waiting for the relay, keyed by its order.
#[derive(Inbox, sqlx::FromRow)]
#[inbox(table = "order_events")]
struct OrderEvent {
#[field(id, generated)]
id: i64,
#[field(partition_key)]
aggregate: String,
#[field(locked_until)]
locked_until: Option<DateTime<Utc>>,
#[field(payload)]
payload: Vec<u8>,
}
/// The order and its event, in the checkout's transaction: both commit, or neither does.
async fn place(
conn: &mut SqliteConnection,
order: &Order,
) -> Result<(), Box<dyn Error + Send + Sync>> {
sqlx::query("INSERT INTO orders (id, total_cents) VALUES (?, ?)")
.bind(order.id)
.bind(order.total_cents)
.execute(&mut *conn)
.await?;
let placed = OrderPlaced {
order: order.id,
total_cents: order.total_cents,
};
let event = OrderEvent {
id: 0,
aggregate: order.id.to_string(),
locked_until: None,
payload: serde_json::to_vec(&placed)?,
};
event.insert(conn).await?;
Ok(())
}
#[subscriber(InboxQueue::<Checkout>::new("checkouts"))]
async fn checkout(order: &Order, Ctx(mut tx): Ctx<keys::Tx<Sqlite>>) -> HandlerOutcome {
match place(&mut tx, order).await {
Ok(()) => HandlerOutcome::ack(),
Err(error) => {
tracing::warn!(subscription = "checkouts", order.id, %error, "order not placed");
HandlerOutcome::drop()
}
}
}
use ruststream_sqlx::prelude::*;
use sqlx::Connection;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::{Sqlite, SqliteConnection};
/// One row of `order_events`: an event waiting for the relay, keyed by its order.
#[derive(sqlx::FromRow)]
struct OrderEvent {
id: i64,
aggregate: String,
payload: Vec<u8>,
}
impl InboxTable for OrderEvent {
type Id = i64;
type Table = InboxSpec<(Key, Lease<DateTime<Utc>>, Payload)>;
const TABLE: Self::Table = InboxSpec::new("order_events", Column::new("id").generated())
.partition_key(Column::new("aggregate"))
.lease(Column::new("locked_until"))
.payload(Column::new("payload"));
fn id(&self) -> &i64 {
&self.id
}
}
impl KeyRow for OrderEvent {
type Key = String;
fn partition_key(&self) -> &String {
&self.aggregate
}
}
impl PayloadRow for OrderEvent {
type Column = Vec<u8>;
fn payload(&self) -> &[u8] {
&self.payload
}
}
// INSERT INTO `order_events` (`aggregate`, `locked_until`, `payload`) VALUES (?, ?, ?)
const INSERT: Sql<160> = insert::sqlite(&OrderEvent::TABLE.spec());
impl Insert<SqliteConnection> for OrderEvent {
async fn insert(&self, conn: &mut SqliteConnection) -> Result<(), sqlx::Error> {
sqlx::query(INSERT.as_str())
.bind(&self.aggregate)
.bind(None::<DateTime<Utc>>)
.bind(&self.payload)
.execute(conn)
.await?;
Ok(())
}
}
/// The order and its event, in the checkout's transaction: both commit, or neither does.
async fn place(
conn: &mut SqliteConnection,
order: &Order,
) -> Result<(), Box<dyn Error + Send + Sync>> {
sqlx::query("INSERT INTO orders (id, total_cents) VALUES (?, ?)")
.bind(order.id)
.bind(order.total_cents)
.execute(&mut *conn)
.await?;
let placed = OrderPlaced {
order: order.id,
total_cents: order.total_cents,
};
let event = OrderEvent {
id: 0,
aggregate: order.id.to_string(),
payload: serde_json::to_vec(&placed)?,
};
event.insert(conn).await?;
Ok(())
}
struct PlaceOrder;
impl Handle<Order, (), (), TxContext<Sqlite>> for PlaceOrder {
async fn handle(
&self,
order: &Order,
_outs: &(),
ctx: &mut Context<'_, TxContext<Sqlite>>,
) -> Result<(), HandlerOutcome> {
let Ctx(mut tx) = Ctx::<keys::Tx<Sqlite>>::from_context(ctx).await?;
place(&mut tx, order).await.map_err(|error| {
tracing::warn!(subscription = "checkouts", order.id, %error, "order not placed");
HandlerOutcome::drop()
})
}
}
checkout работает в транзакционном режиме: он записывает заказ и событие через транзакцию своей доставки.
Его подтверждение фиксирует обе строки вместе с завершением оформления.
Заказ, вставка которого вернула ошибку, откатывает своё событие, и для него ничего не публикуется.
Код вне обработчика, например конечная точка HTTP, записывает те же две строки в собственной транзакции sqlx, как в руководстве Постановка задач в очередь.
Строка события и в этом случае не будит ни одну подписку: ретранслятор находит её в пределах своего интервала опроса.
Ретранслятор
- Макросы
- Вручную
use ruststream::memory::{MemoryBroker, MemoryPublish};
/// Each event goes out on the bus as it was written; the acknowledgement deletes its row.
#[subscriber(InboxQueue::<OrderEvent>::new("order-events"), reply)]
async fn relay(event: &OrderPlaced) -> OrderPlaced {
event.clone()
}
/// The service on the database `url` names.
fn service(url: &str) -> RustStream {
let schema_url = url.to_owned();
// Nothing connects here: the broker builds its pool when the service starts.
let broker = SqlxBroker::from_url(url, SqlitePoolOptions::new());
// The target broker is wrapped first, so its publish token exists before the scopes.
let bus = MemoryBroker::new().bindable();
let to_bus = bus.bind(MemoryPublish);
RustStream::new(AppInfo::new("shop", "0.1.0"))
// The table, created before the subscription checks it.
.on_startup(async move |()| {
let mut conn = SqliteConnection::connect(&schema_url).await?;
sqlx::raw_sql(SCHEMA).execute(&mut conn).await?;
conn.close().await
})
.with_broker(broker.route::<Checkout>("checkouts"), |b| {
b.include(checkout.transactional());
b.include(relay.workers_by_key(nonzero!(4)))
.out_reply(to_bus);
})
.with_broker(bus, |_b| {})
}
use ruststream::memory::{MemoryBroker, MemoryPublish};
/// Each event goes out on the bus as it was written; the acknowledgement deletes its row.
struct Relay;
impl Handle<OrderPlaced, OrderPlaced> for Relay {
fn handle(
&self,
event: &OrderPlaced,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<OrderPlaced, HandlerOutcome>> {
ready(Ok(event.clone()))
}
}
/// The service on the database `url` names.
fn service(url: &str) -> RustStream {
let schema_url = url.to_owned();
// Nothing connects here: the broker builds its pool when the service starts.
let broker = SqlxBroker::from_url(url, SqlitePoolOptions::new());
// The target broker is wrapped first, so its publish token exists before the scopes.
let bus = MemoryBroker::new().bindable();
let to_bus = bus.bind(MemoryPublish);
RustStream::new(AppInfo::new("shop", "0.1.0"))
// The table, created before the subscription checks it.
.on_startup(async move |()| {
let mut conn = SqliteConnection::connect(&schema_url).await?;
sqlx::raw_sql(SCHEMA).execute(&mut conn).await?;
conn.close().await
})
.with_broker(broker.route::<Checkout>("checkouts"), |b| {
let checkout = subscriber("checkouts", PlaceOrder)
.map_source(|_| InboxQueue::<Checkout>::new("checkouts"))
.transactional();
b.include(checkout.build());
let relay = subscriber("order-events", Relay)
.map_source(|_| InboxQueue::<OrderEvent>::new("order-events"))
.workers_by_key(nonzero!(4))
.reply();
b.include(relay.build()).out_reply(to_bus);
})
.with_broker(bus, |_b| {})
}
relay читает order_events и отвечает на каждое событие им самим; тип ответа называет своё место назначения, orders.
Ответ уходит через шину: bindable() оборачивает целевой брокер, bind(MemoryPublish) выпускает для него токен публикации, а .out_reply(to_bus) отправляет ответы relay через этот токен.
Сервис на Kafka или NATS указывает в тех же двух строках свой брокер и свою политику публикации.
Ответ публикуется до того, как завершится строка события, а подтверждение удаляет строку.
Ретранслятор работает по схеме at-least-once. Ответ, публикация которого вернула ошибку, возвращает событие в очередь, а сбой после публикации и до подтверждения публикует его снова. Получатель, который не должен действовать дважды, хранит идентификатор события, здесь заказ, и пропускает уже виденное.
Порядок по агрегату
aggregate - ключ партиционирования события, id заказа.
workers_by_key(4) ретранслирует четыре заказа одновременно и сохраняет события одного заказа в том порядке, в каком они записаны.
Этот порядок действует внутри одной подписки ретранслятора.
Ретранслятор на нескольких экземплярах, события которого должны сохранять порядок по агрегату на всех них, держит их в группе FIFO или в форме рекомендательной блокировки.
Ретранслятору не нужны ни флаг «отправлено», ни собственный опрос: захват выполняет очередь, задержка после неудачной публикации - это переобработка, а предел с группой dead letter откладывает событие, которое цель раз за разом отклоняет. Отслеживание публикаций - другой вариант: плагин outbox записывает то, что публикует обработчик, и отмечает запись, когда получатель обработал сообщение, но его запись фиксируется сама по себе, вне бизнес-транзакции.
Тестирование
Тесты в файле примера публикуют оформления через обвязку и читают, что дошло до шины, через tb.broker::<MemoryBroker>().published(..).
Один заказ доходит до шины один раз, а строка его события удалена.
Тот же заказ, оформленный дважды, публикует одно событие: второе оформление возвращает ошибку, и его событие откатывается.
Строки событий не будят ни одну подписку, поэтому тесты работают на реальных часах через TestApp::start_live и ждут дольше интервала опроса.