Эта страница переведена с английского языковой моделью. При любом расхождении верен английский оригинал.
Переобработка и недоставленные сообщения
Это руководство переобрабатывает доставку сразу или позже, ограничивает число попыток и сохраняет то, что раз за разом не обрабатывается, в стриме для недоставленных сообщений: на консьюмере стрима, на субъекте Core и на шаблоне.
В NATS нет механизма недоставленных сообщений. JetStream умеет ограничивать число доставок сообщения, но последнюю доставку никуда не отправляет. Поэтому предел и субъект для недоставленных сообщений объявляет точка монтирования, а фреймворк применяет их к обеим моделям.
Пример - платёжный сервис, который проводит платежи и отправляет возвраты через банк. Недоставленные сообщения он хранит в отдельном стриме:
nats stream add PAYMENTS --subjects 'payments.settle' --defaults
nats stream add DEAD --subjects 'dead.*' --defaults
Переобработка на стриме
- Макросы
- Вручную
/// How long a busy bank asks its callers to wait.
const BACKOFF: Duration = Duration::from_secs(30);
/// The outcome a refusal of the bank calls for.
fn outcome(refusal: Refusal) -> HandlerOutcome {
match refusal {
// Past the rate limit: a call right now would be refused again.
Refusal::Busy => HandlerOutcome::retry_after(BACKOFF),
// No answer: try again at once, up to the attempt cap.
Refusal::Unavailable => HandlerOutcome::retry(),
Refusal::Declined => HandlerOutcome::drop(),
}
}
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
#[outgoing(name = "payments.settle")]
struct Payment {
id: u64,
card: String,
amount_cents: u64,
}
#[subscriber(JetStreamSubject::new("payments.settle", "PAYMENTS").durable("settler"))]
async fn settle(payment: &Payment, State(bank): State<Arc<Bank>>) -> HandlerOutcome {
match bank.charge(&payment.card).await {
Ok(()) => HandlerOutcome::ack(),
Err(refusal) => {
tracing::warn!(payment.id, ?refusal, "payment refused by the bank");
outcome(refusal)
}
}
}
/// How long a busy bank asks its callers to wait.
const BACKOFF: Duration = Duration::from_secs(30);
/// The outcome a refusal of the bank calls for.
fn outcome(refusal: Refusal) -> HandlerOutcome {
match refusal {
// Past the rate limit: a call right now would be refused again.
Refusal::Busy => HandlerOutcome::retry_after(BACKOFF),
// No answer: try again at once, up to the attempt cap.
Refusal::Unavailable => HandlerOutcome::retry(),
Refusal::Declined => HandlerOutcome::drop(),
}
}
#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Payment {
id: u64,
card: String,
amount_cents: u64,
}
impl OutgoingDestination for Payment {
type Form = FixedName;
const DESTINATION: &'static str = "payments.settle";
}
impl MessageHeaders for Payment {
type Contract = NoHeaders;
}
impl MessageInfo for Payment {
const NAME: &'static str = "Payment";
}
struct Settle;
impl Handle<Payment, (), (), (), Clients> for Settle {
async fn handle(
&self,
payment: &Payment,
_outs: &(),
ctx: &mut Context<'_, (), Clients>,
) -> Result<(), HandlerOutcome> {
match ctx.state().bank.charge(&payment.card).await {
Ok(()) => Ok(()),
Err(refusal) => {
tracing::warn!(payment.id, ?refusal, "payment refused by the bank");
Err(outcome(refusal))
}
}
}
}
outcome превращает отказ банка в исход обработчика.
Если банк занят, retry_after повторяет обращение через 30 секунд.
На консьюмере JetStream задержка передаётся в отрицательном подтверждении: сервер придерживает платёж и повторно доставляет его тому же консьюмеру с тем же порядковым номером в стриме.
Ничего не публикуется, и процесс, остановленный в промежутке, ничего не теряет.
Если банк молчит, retry повторяет обращение сразу, а платёж по отклонённой карте отбрасывается.
Предел попыток и недоставленные сообщения
- Макросы
- Вручную
#[ruststream::app]
fn app() -> impl App<State = Clients> {
RustStream::new(AppInfo::new("payments", "0.1.0"))
.on_startup(async move |()| Ok::<_, Infallible>(Clients::default()))
.with_broker(NatsBroker::new("nats://localhost:4222"), |b| {
// Five deliveries of one payment, the first included; then the dead-letter subject.
b.include(settle)
.max_attempts(nonzero!(5u32))
.dead_letter("dead.payments");
b.include(review);
// A pattern is no subject to publish a copy to, so the mount site names one.
b.include(send_refund)
.max_attempts(nonzero!(3u32))
.dead_letter("dead.refunds")
.out_retry(Publish)
.to("refunds.retry");
})
}
fn app() -> impl App<State = Clients> {
RustStream::new(AppInfo::new("payments", "0.1.0"))
.on_startup(async move |()| Ok::<_, Infallible>(Clients::default()))
.with_broker(NatsBroker::new("nats://localhost:4222"), |b| {
b.include(
subscriber(
JetStreamSubject::new("payments.settle", "PAYMENTS").durable("settler"),
Settle,
)
.build(),
)
// Five deliveries of one payment, the first included; then the dead-letter subject.
.max_attempts(nonzero!(5u32))
.dead_letter("dead.payments");
b.include(
subscriber(
JetStreamSubject::new("dead.payments", "DEAD").durable("review"),
Review,
)
.build(),
);
// A pattern is no subject to publish a copy to, so the mount site names one.
b.include(subscriber(CoreWildcard::new("refunds.*"), SendRefund).build())
.max_attempts(nonzero!(3u32))
.dead_letter("dead.refunds")
.out_retry(Publish)
.to("refunds.retry");
})
}
fn main() -> ExitCode {
run_main(app)
}
max_attempts считает все доставки одного платежа, включая первую.
На консьюмере JetStream счёт ведёт сам сервер, поэтому истёкший ack_wait тоже учитывается.
Пятая доставка не переобрабатывается: dead_letter публикует её в dead.payments с полезной нагрузкой и заголовками в том виде, в каком они пришли.
Отброшенный платёж завершается и никуда не уходит, поэтому среди недоставленных сообщений лежат только платежи, у которых кончились попытки.
Консьюмер с пределом, но без субъекта для недоставленных сообщений прекращает свою последнюю доставку.
dead.payments - обычная публикация, а Core NATS не хранит то, что никто не читает.
Её сохраняет стрим DEAD, поэтому недоставленное сообщение ждёт, пока его кто-нибудь посмотрит.
Ни один субъект, который читает приложение, не подходит к субъекту для недоставленных сообщений. Иначе исчерпанная доставка вернулась бы к обработчику, который от неё отказался.
Чтение недоставленных сообщений
- Макросы
- Вручную
#[subscriber(JetStreamSubject::new("dead.payments", "DEAD").durable("review"))]
async fn review(payment: &Payment) -> HandlerOutcome {
tracing::error!(payment.id, "payment needs a person to look at it");
HandlerOutcome::ack()
}
struct Review;
impl Handle<Payment, (), (), (), Clients> for Review {
fn handle(
&self,
payment: &Payment,
_outs: &(),
_ctx: &mut Context<'_, (), Clients>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
tracing::error!(payment.id, "payment needs a person to look at it");
ready(Ok(()))
}
}
review читает недоставленные платежи из стрима DEAD, как любой другой стрим, и пишет каждый в журнал, чтобы его посмотрел человек.
Переобработка на субъектах Core
- Макросы
- Вручную
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
struct Refund {
id: u64,
card: String,
amount_cents: u64,
}
#[subscriber(CoreWildcard::new("refunds.*"))]
async fn send_refund(refund: &Refund, State(bank): State<Arc<Bank>>) -> HandlerOutcome {
match bank.charge(&refund.card).await {
Ok(()) => HandlerOutcome::ack(),
Err(refusal) => {
tracing::warn!(refund.id, ?refusal, "refund refused by the bank");
outcome(refusal)
}
}
}
#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Refund {
id: u64,
card: String,
amount_cents: u64,
}
impl OutgoingDestination for Refund {
type Form = CallerName;
}
impl MessageHeaders for Refund {
type Contract = NoHeaders;
}
impl MessageInfo for Refund {
const NAME: &'static str = "Refund";
}
struct SendRefund;
impl Handle<Refund, (), (), (), Clients> for SendRefund {
async fn handle(
&self,
refund: &Refund,
_outs: &(),
ctx: &mut Context<'_, (), Clients>,
) -> Result<(), HandlerOutcome> {
match ctx.state().bank.charge(&refund.card).await {
Ok(()) => Ok(()),
Err(refusal) => {
tracing::warn!(refund.id, ?refusal, "refund refused by the bank");
Err(outcome(refusal))
}
}
}
}
Core NATS не умеет придерживать сообщение.
На подписке Core переобработка - это копия доставки, которую публикует фреймворк: retry публикует её сразу, retry_after - по истечении задержки.
Число доставок копия хранит в заголовке x-ruststream-retry-count, и предел читает его оттуда.
Отложенная копия доставляется at-most-once: процесс, который остановился до конца задержки, её теряет.
CoreSubject публикует свои копии в собственный субъект.
send_refund читает шаблон refunds.*, у которого нет единственного субъекта для публикации.
Поэтому приложение называет его через out_retry(Publish).to("refunds.retry"). Это субъект, к которому подходит шаблон, и копии возвращаются в send_refund.
Регистрация на шаблон без него не запускается, просит её обработчик переобработку или нет.
Третья доставка возврата уходит в dead.refunds с числом доставок в заголовке.
Тесты в файле примера запускают приложение внутри процесса с остановленными часами, поэтому tb.advance(BACKOFF) проматывает 30 секунд мгновенно.
Работу с обвязкой описывает руководство Тестирование сервиса на NATS.