Эта страница переведена с английского языковой моделью. При любом расхождении верен английский оригинал.
Повторы и dead-letter
Это руководство повторяет сообщение Pulsar сразу или позже, ограничивает число доставок и переносит то, что раз за разом не обрабатывается, в топик dead-letter, который читает обработчик.
Pulsar сам доставляет сообщение повторно, поэтому повтор ничего не публикует. Точка монтирования объявляет предел и топик dead-letter, а применяет их клиент Pulsar.
Пример - сервис возвратов, который проводит возвраты через процессинг карт.
Processor заменяет клиент процессинга, и приложение открывает его при запуске.
Подписчик
Процессинг отказывает в возврате тремя способами, и каждому нужен свой исход.
Busy значит, что его лимит запросов исчерпан. Повтор прямо сейчас снова получит отказ, поэтому retry_after ждёт 30 секунд.
Unavailable значит, что процессинг не ответил: retry пробует снова сразу.
Declined значит, что карточный счёт закрыт. Новая попытка получит тот же ответ, поэтому возврат отбрасывается.
- Макросы
- Вручную
/// How long a busy processor asks its callers to wait.
const BACKOFF: Duration = Duration::from_secs(30);
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
#[outgoing(name = "refunds")]
struct Refund {
id: u64,
card: String,
amount_cents: u64,
}
/// A refund left unsettled for two minutes comes back; a wait must be shorter than that.
#[subscriber(PulsarSubscription::new("refunds", "refunds").ack_timeout(Duration::from_secs(120)))]
async fn process_refund(
refund: &Refund,
State(processor): State<Arc<Processor>>,
) -> HandlerOutcome {
match processor.refund(refund).await {
Ok(()) => HandlerOutcome::ack(),
// Past the rate limit: a delivery right now would be refused again.
Err(Refusal::Busy) => HandlerOutcome::retry_after(BACKOFF),
// No answer: try again at once, up to the attempt cap.
Err(Refusal::Unavailable) => HandlerOutcome::retry(),
Err(Refusal::Declined) => {
tracing::warn!(refund.id, "card declined, refund dropped");
HandlerOutcome::drop()
}
}
}
/// How long a busy processor asks its callers to wait.
const BACKOFF: Duration = Duration::from_secs(30);
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing, JsonSchema)]
#[outgoing(name = "refunds")]
struct Refund {
id: u64,
card: String,
amount_cents: u64,
}
/// A refund left unsettled for two minutes comes back; a wait must be shorter than that.
fn refunds() -> PulsarSubscription {
PulsarSubscription::new("refunds", "refunds").ack_timeout(Duration::from_secs(120))
}
struct ProcessRefund;
impl Handle<Refund, (), (), (), Clients> for ProcessRefund {
async fn handle(
&self,
refund: &Refund,
_outs: &(),
ctx: &mut Context<'_, (), Clients>,
) -> Result<(), HandlerOutcome> {
match ctx.state().processor.refund(refund).await {
Ok(()) => Ok(()),
// Past the rate limit: a delivery right now would be refused again.
Err(Refusal::Busy) => Err(HandlerOutcome::retry_after(BACKOFF)),
// No answer: try again at once, up to the attempt cap.
Err(Refusal::Unavailable) => Err(HandlerOutcome::retry()),
Err(Refusal::Declined) => {
tracing::warn!(refund.id, "card declined, refund dropped");
Err(HandlerOutcome::drop())
}
}
}
}
retry отрицательно подтверждает возврат, и брокер сразу доставляет его снова.
retry_after держит возврат в сервисе неподтверждённым в течение задержки, а затем отрицательно подтверждает его.
У отрицательного подтверждения Pulsar нет собственной задержки, поэтому ожидание идёт в процессе.
Процесс, который остановился во время ожидания, теряет ожидание, а не возврат: брокер доставит возврат снова после тайм-аута подтверждения или сразу, когда консьюмер отключится.
Задержка должна быть короче тайм-аута подтверждения подписки, иначе тайм-аут вернул бы возврат раньше. Более долгая задержка отвергается, когда обработчик её возвращает, с ошибкой, которая называет оба значения. Подписка без тайм-аута подтверждения принимает любую задержку.
Предел и топик dead-letter
- Макросы
- Вручную
#[ruststream::app]
fn app() -> impl App<State = Clients> {
RustStream::new(AppInfo::new("refunds", "0.1.0"))
.on_startup(async move |()| Ok::<_, Infallible>(Clients::default()))
.with_broker(PulsarBroker::new("pulsar://localhost:6650"), |b| {
// Five deliveries of one refund, the first included; then the dead-letter topic.
b.include(process_refund)
.max_attempts(nonzero!(5u32))
.dead_letter("refunds-dlq");
b.include(review);
})
}
fn app() -> impl App<State = Clients> {
RustStream::new(AppInfo::new("refunds", "0.1.0"))
.on_startup(async move |()| Ok::<_, Infallible>(Clients::default()))
.with_broker(PulsarBroker::new("pulsar://localhost:6650"), |b| {
// Five deliveries of one refund, the first included; then the dead-letter topic.
b.include(subscriber(refunds(), ProcessRefund).build())
.max_attempts(nonzero!(5u32))
.dead_letter("refunds-dlq");
b.include(
subscriber(
PulsarSubscription::new("refunds-dlq", "refunds-review"),
Review,
)
.build(),
);
})
}
fn main() -> ExitCode {
run_main(app)
}
max_attempts считает все доставки одного возврата, включая первую.
dead_letter называет, куда уходит возврат, когда доставки исчерпаны.
Эта пара становится собственной политикой dead-letter консьюмера.
Клиент Pulsar считает повторные доставки, публикует пятый неудачный возврат в refunds-dlq и подтверждает оригинал.
retry, retry_after и истёкший тайм-аут подтверждения засчитываются как повторная доставка.
Отброшенный возврат завершается и никуда не уходит. В топике dead-letter лежат только возвраты, у которых кончились попытки.
Указывайте оба шага или ни одного: предел без адреса или адрес без предела останавливает сервис при запуске, и ошибка называет недостающий шаг.
Брокер считает повторные доставки только на подписках Shared и KeyShared, поэтому предел задаётся на них.
Предел на подписке Exclusive или Failover никогда не был бы достигнут, и такая регистрация останавливает сервис при запуске с ошибкой, которая называет тип.
Обработчик, смонтированный по короткому имени топика, принимает то же объявление на подписке брокера по умолчанию.
Чтение топика dead-letter
Топик dead-letter - обычный топик, и review читает его, как любой другой.
- Макросы
- Вручную
/// Reads the refunds that ran out of attempts.
#[subscriber(PulsarSubscription::new("refunds-dlq", "refunds-review"))]
async fn review(refund: &Refund) -> HandlerOutcome {
tracing::error!(refund.id, "refund needs a person to look at it");
HandlerOutcome::ack()
}
/// Reads the refunds that ran out of attempts.
struct Review;
impl Handle<Refund, (), (), (), Clients> for Review {
fn handle(
&self,
refund: &Refund,
_outs: &(),
_ctx: &mut Context<'_, (), Clients>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
tracing::error!(refund.id, "refund needs a person to look at it");
ready(Ok(()))
}
}
Возврат в топике dead-letter сохраняет полезную нагрузку и заголовки, с которыми пришёл.
Клиент добавляет два свойства: REAL_TOPIC, топик, из которого пришёл возврат, и ORIGIN_MESSAGE_ID, его позицию там.
review пишет возврат в журнал, чтобы его посмотрел человек.
Проверка задержки
Тесты в файле примера запускают приложение внутри процесса с TestApp::with_state, который передаёт обработчикам намеренно отказывающий процессинг.
Тест задержки начинается с остановленными часами, поэтому tb.advance(BACKOFF) проматывает 30 секунд мгновенно.
Pulsar внутри процесса считает доставки и переносит возврат с исчерпанными попытками в refunds-dlq так же, как клиент.
Работу с TestApp описывает руководство Тестирование сервиса на Pulsar.