Эта страница переведена с английского языковой моделью. При любом расхождении верен английский оригинал.
Повторы и темы dead-letter
Это руководство повторяет запись Kafka сразу или позже, ограничивает число попыток и переносит то, что раз за разом не обрабатывается, в тему dead-letter, на одной теме или на нескольких.
Kafka не придерживает записи и не считает доставки. И то и другое делает фреймворк: точка монтирования объявляет предел попыток и тему dead-letter, а повтор уходит копией записи с числом попыток в заголовке.
Пример - сервис возвратов, который проводит возвраты через процессинг карт.
Подписчик
Процессинг отказывает в возврате тремя способами, и каждому нужен свой исход.
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)]
struct Refund {
id: u64,
card: String,
amount_cents: u64,
}
#[subscriber(KafkaTopic::new("refunds").commit(Commit::Tracked))]
async fn process_refund(
refund: &Refund,
State(processor): State<Arc<Processor>>,
) -> HandlerOutcome {
settle(&processor, refund).await
}
async fn settle(processor: &Processor, refund: &Refund) -> 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, 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 ProcessRefund;
impl Handle<Refund, (), (), (), Clients> for ProcessRefund {
async fn handle(
&self,
refund: &Refund,
_outs: &(),
ctx: &mut Context<'_, (), Clients>,
) -> Result<(), HandlerOutcome> {
settle(&ctx.state().processor, refund).await
}
}
async fn settle(processor: &Processor, refund: &Refund) -> Result<(), HandlerOutcome> {
match 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_after завершает запись и по истечении задержки публикует её копию обратно в refunds.
Копия несёт заголовок с числом попыток (RETRY_COUNT_HEADER), увеличенным на единицу.
Отложенная копия доставляется at-most-once: процесс, который остановился до конца задержки, её теряет.
Копия - новая запись в конце темы, поэтому записи после возврата её не ждут.
retry при объявленном пределе тоже становится копией, которая публикуется сразу.
Без предела он возвращает запись подписке при Commit::Tracked, как показывает руководство Фиксация и гарантии доставки.
Предел попыток и тема 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(
KafkaBroker::new(["localhost:9092"]).default_group("refunds"),
|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);
// A set of topics names where a copy goes: back to the topic it came from.
b.include(process_regional_refund)
.max_attempts(nonzero!(5u32))
.dead_letter("refunds.dlq")
.out_retry(Publish::default())
.transform(ToSourceTopic);
},
)
}
fn app() -> impl App<State = Clients> {
RustStream::new(AppInfo::new("refunds", "0.1.0"))
.on_startup(async move |()| Ok::<_, Infallible>(Clients::default()))
.with_broker(
KafkaBroker::new(["localhost:9092"]).default_group("refunds"),
|b| {
// Five deliveries of one refund, the first included; then the dead-letter topic.
b.include(
subscriber("refunds", ProcessRefund)
.map_source(|_| KafkaTopic::new("refunds").commit(Commit::Tracked))
.build(),
)
.max_attempts(nonzero!(5u32))
.dead_letter("refunds.dlq");
b.include(
subscriber("refunds.dlq", Review)
.map_source(|_| {
KafkaTopic::new("refunds.dlq")
.group("refunds-review")
.start(StartOffset::Earliest)
})
.build(),
);
// A set of topics names where a copy goes: back to the topic it came from.
b.include(
subscriber("refunds.eu,refunds.us", ProcessRegionalRefund)
.map_source(|_| {
KafkaTopics::new(["refunds.eu", "refunds.us"])
.group("regional-refunds")
.commit(Commit::Tracked)
})
.build(),
)
.max_attempts(nonzero!(5u32))
.dead_letter("refunds.dlq")
.out_retry(Publish::default())
.transform(ToSourceTopic);
},
)
}
fn main() -> ExitCode {
run_main(app)
}
max_attempts считает все доставки одного возврата, включая первую.
Пятая доставка не повторяется: dead_letter публикует её в refunds.dlq с полезной нагрузкой и заголовками в том виде, в каком они пришли.
Отброшенный возврат завершается и никуда не уходит. В теме dead-letter лежат только возвраты, у которых кончились попытки.
Темы для повторов и для dead-letter - инфраструктура сервиса. Создайте их, как любую другую тему, а фреймворк только публикует в них.
Чтение темы dead-letter
Тема dead-letter - обычная тема, и review читает её, как любую другую.
- Макросы
- Вручную
#[subscriber(
KafkaTopic::new("refunds.dlq")
.group("refunds-review")
.start(StartOffset::Earliest)
)]
async fn review(refund: &Refund, ctx: &mut Context<'_>) -> HandlerOutcome {
let deliveries = ctx.headers().get_str(RETRY_COUNT_HEADER).unwrap_or("1");
tracing::error!(refund.id, deliveries, "refund needs a person to look at it");
HandlerOutcome::ack()
}
struct Review;
impl Handle<Refund, (), (), (), Clients> for Review {
fn handle(
&self,
refund: &Refund,
_outs: &(),
ctx: &mut Context<'_, (), Clients>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
let deliveries = ctx.headers().get_str(RETRY_COUNT_HEADER).unwrap_or("1");
tracing::error!(refund.id, deliveries, "refund needs a person to look at it");
ready(Ok(()))
}
}
Заголовок с числом попыток у записи из темы dead-letter хранит число доставок, через которые она прошла, здесь 5.
review читает его через ctx.headers() и пишет возврат в журнал, чтобы его посмотрел человек.
Повторы на нескольких темах
Копия записи публикуется в тему, которую читает подписка.
KafkaTopic читает одну тему, поэтому ей больше ничего не нужно.
KafkaTopics и KafkaPartitions читают набор, до которого не дотянется ни одна публикация. Поэтому их регистрация называет, куда уходит копия, а без этого сервис не запускается.
- Макросы
- Вручную
#[subscriber(
KafkaTopics::new(["refunds.eu", "refunds.us"])
.group("regional-refunds")
.commit(Commit::Tracked)
)]
async fn process_regional_refund(
refund: &Refund,
Ctx(topic): Ctx<Topic>,
State(processor): State<Arc<Processor>>,
) -> HandlerOutcome {
tracing::debug!(refund.id, %topic, "regional refund");
settle(&processor, refund).await
}
struct ProcessRegionalRefund;
impl Handle<Refund, (), (), KafkaContext, Clients> for ProcessRegionalRefund {
async fn handle(
&self,
refund: &Refund,
_outs: &(),
ctx: &mut Context<'_, KafkaContext, Clients>,
) -> Result<(), HandlerOutcome> {
tracing::debug!(refund.id, topic = %ctx.context(Topic), "regional refund");
settle(&ctx.state().processor, refund).await
}
}
Приложение монтирует process_regional_refund с .out_retry(Publish::default()).transform(ToSourceTopic).
ToSourceTopic отправляет каждую копию обратно в тему, из которой пришла её доставка, поэтому повторённый возврат из refunds.us возвращается в refunds.us.
Тему он берёт из контекста Kafka, поэтому обработчик принимает ключ контекста, например Ctx<Topic>. В ручной форме тип его контекста - KafkaContext.
.out_retry(Publish::default()).to("refunds.retry") вместо этого отправляет все копии в одну вашу тему. Добавьте эту тему в подписку, чтобы копии возвращались.
То же правило действует для подписки, которая никогда не повторяет: каждая регистрация на набор тем называет адрес для повторов.
Проверка задержки
Тесты в файле примера запускают приложение внутри процесса с остановленными часами, поэтому tb.advance(BACKOFF) проматывает 30 секунд мгновенно.
Работу с TestApp описывает руководство Тестирование сервиса на Kafka.