Эта страница переведена с английского языковой моделью. При любом расхождении верен английский оригинал.
Повторы и очереди dead-letter
Это руководство повторяет сообщение позже, ограничивает число попыток и переносит то, что не обрабатывается, в очередь dead-letter.
ZeroMQ доставляет сообщение не более одного раза и ничего не хранит. Сообщение, на котором обработчик не справился, пропадает, если сервис не опубликует его снова. Это делает фреймворк: точка монтирования объявляет предел попыток и адрес для копий, а каждая копия хранит число повторов в заголовке.
Пример - сервис расчётов с двумя воркерами. Воркер заданий повторяет неудавшееся задание через собственную очередь. Воркер счетов переносит неудавшийся счёт в отдельную очередь, чтобы его посмотрел человек.
Не более одного раза
ack и nack нечего сообщить сокету: в ZeroMQ нет подтверждения.
Каждый из них отвечает AckError::Unsupported, и рантайм пишет в журнал предупреждение.
Поэтому обычный retry без предела ничего не возвращает, и обработчик второй раз не вызывается.
Повтор, который должен произойти, идёт через копию, как показано ниже.
Воркер заданий
- Макросы
- Вручную
use std::time::Duration;
use ruststream_zeromq::queue::prelude::*;
use serde::Deserialize;
/// How long a failed job waits before its next delivery.
const BACKOFF: Duration = Duration::from_secs(30);
#[derive(Debug, PartialEq, Deserialize)]
struct Job {
id: u64,
}
#[subscriber("jobs")]
async fn work(job: &Job) -> HandlerOutcome {
match run(job) {
Ok(()) => HandlerOutcome::ack(),
Err(err) => {
tracing::warn!(job.id, %err, "the job failed, retrying later");
HandlerOutcome::retry_after(BACKOFF)
}
}
}
use std::time::Duration;
use ruststream_zeromq::queue::prelude::*;
use serde::Deserialize;
/// How long a failed job waits before its next delivery.
const BACKOFF: Duration = Duration::from_secs(30);
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Job {
id: u64,
}
struct Work;
impl Handle<Job> for Work {
fn handle(
&self,
job: &Job,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
ready(run(job).map_err(|err| {
tracing::warn!(job.id, %err, "the job failed, retrying later");
HandlerOutcome::retry_after(BACKOFF)
}))
}
}
Задание, которое не удалось, просит новую попытку через 30 секунд с помощью retry_after.
Рантайм выжидает задержку и публикует копию доставки: её полезную нагрузку и заголовки в том виде, в каком они пришли.
Копия ждёт в памяти сервиса, поэтому процесс, который остановился до конца задержки, её теряет.
Воркер счетов
- Макросы
- Вручную
#[derive(Debug, PartialEq, Deserialize)]
struct Invoice {
id: u64,
amount_cents: u64,
}
#[subscriber("invoices")]
async fn send_invoice(invoice: &Invoice) -> HandlerOutcome {
if invoice.amount_cents == 0 {
tracing::warn!(invoice.id, "an invoice without an amount, parking it");
return HandlerOutcome::retry();
}
tracing::info!(invoice.id, invoice.amount_cents, "invoice sent");
HandlerOutcome::ack()
}
/// Keeps the invoices nobody could send, for a person to look at.
#[subscriber("invoices.dead")]
async fn park(invoice: &Invoice) -> HandlerOutcome {
tracing::error!(invoice.id, "invoice parked");
HandlerOutcome::ack()
}
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Invoice {
id: u64,
amount_cents: u64,
}
struct SendInvoice;
impl Handle<Invoice> for SendInvoice {
fn handle(
&self,
invoice: &Invoice,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
if invoice.amount_cents == 0 {
tracing::warn!(invoice.id, "an invoice without an amount, parking it");
return ready(Err(HandlerOutcome::retry()));
}
tracing::info!(invoice.id, invoice.amount_cents, "invoice sent");
ready(Ok(()))
}
}
/// Keeps the invoices nobody could send, for a person to look at.
struct Park;
impl Handle<Invoice> for Park {
fn handle(
&self,
invoice: &Invoice,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
tracing::error!(invoice.id, "invoice parked");
ready(Ok(()))
}
}
Счёт без суммы отправить нельзя, и повторная отправка ничего не изменит.
send_invoice возвращает retry, а точка монтирования превращает его в dead-letter.
park читает очередь dead-letter и пишет каждое сообщение в журнал.
Точки монтирования
- Макросы
- Вручную
#[ruststream::app]
fn app() -> impl App {
let dead = ZmqQueue::new(ZmqEndpoint::bind("tcp://0.0.0.0:5561")).bindable();
let parked = dead.bind(Publish);
RustStream::new(AppInfo::new("billing", "0.1.0"))
.with_broker_labeled(
"jobs",
ZmqQueue::new(ZmqEndpoint::bind("tcp://0.0.0.0:5555")),
|b| {
// Five deliveries at most, the first one included; the copies return to this queue.
b.include(work)
.max_attempts(nonzero!(5u32))
.out_retry(Publish);
},
)
.with_broker_labeled(
"invoices",
ZmqQueue::new(ZmqEndpoint::bind("tcp://0.0.0.0:5556")),
|b| {
// A failed invoice leaves for the second queue, under the name it is read by.
b.include(send_invoice)
.dead_letter("invoices.dead")
.out_retry(parked);
},
)
.with_broker_labeled("dead", dead, |b| {
b.include(park);
})
}
fn app() -> impl App {
let dead = ZmqQueue::new(ZmqEndpoint::bind("tcp://0.0.0.0:5561")).bindable();
let parked = dead.bind(Publish);
RustStream::new(AppInfo::new("billing", "0.1.0"))
.with_broker_labeled(
"jobs",
ZmqQueue::new(ZmqEndpoint::bind("tcp://0.0.0.0:5555")),
|b| {
// Five deliveries at most, the first one included; the copies return to this queue.
b.include(subscriber("jobs", Work).build())
.max_attempts(nonzero!(5u32))
.out_retry(Publish);
},
)
.with_broker_labeled(
"invoices",
ZmqQueue::new(ZmqEndpoint::bind("tcp://0.0.0.0:5556")),
|b| {
// A failed invoice leaves for the second queue, under the name it is read by.
b.include(subscriber("invoices", SendInvoice).build())
.dead_letter("invoices.dead")
.out_retry(parked);
},
)
.with_broker_labeled("dead", dead, |b| {
b.include(subscriber("invoices.dead", Park).build());
})
}
fn main() -> ExitCode {
run_main(app)
}
max_attempts считает все доставки одного задания, включая первую.
В ZMTP нет счётчика доставок, поэтому фреймворк считает их в своём заголовке x-ruststream-retry-count, и предел соблюдается, куда бы ни уходили копии.
Пятая доставка неудавшегося задания не повторяется. Без адреса dead-letter она отбрасывается с предупреждением, в котором названа подписка.
out_retry выбирает издателя, через которого уходят копии.
Очередь jobs привязывает этот сервис, поэтому .out_retry(Publish) отправляет каждую копию обратно в неё, и копию берёт тот воркер заданий, который свободен.
Регистрация счетов объявляет dead_letter("invoices.dead") без предела, поэтому каждый неудавшийся счёт сразу уходит туда.
Dead-letter - такая же публикация, как любая другая, а привязанная очередь принимает только имя своей подписки.
Поэтому dead-letter уходят через parked, политику на брокере dead, и там их читает park.
Копии для повтора регистрации уходят через того же издателя, поэтому регистрация выбирает между повторами и dead-letter.
Регистрация, которой нужно и то и другое, держит повторы в собственной очереди и обходится без dead-letter.
Воркер, который подключается
Воркер, который подключается к вентилятору, не может отправить копию обратно: вентилятор только отправляет.
Его регистрация называет адрес для копий через .out_retry(policy).to("jobs") над очередью, которую воркер привязывает для себя, и монтирует на этой очереди тот же обработчик.
Регистрацию без адреса старт отвергает, а цепочка Router без адреса не компилируется.
Такую регистрацию показывает страница out_retry, а использует руководство Конвейеры на PUSH/PULL.
Проверка задержек
Тесты запускают приложение во внутрипроцессном режиме.
Тест заданий начинается с остановленными часами, поэтому tb.advance(BACKOFF) проматывает 30 секунд мгновенно.
Он проверяет пять доставок и последнюю копию с числом повторов 4.
Тест счетов проверяет, что счёт дошёл до park после одной доставки.
Обвязку разбирает руководство Тестирование сервиса на ZeroMQ.