本页译自英文,由模型翻译。若与英文原文存在差异,以英文原文为准。
重试与死信流
本指南立即或稍后重试一条记录,限定尝试次数,并把一直失败的记录转入死信流。
Kinesis 既不扣留记录,也不统计投递次数,也没有死信队列。这三件事都由框架来做:挂载点声明上限和死信流,重试以记录副本的形式传递,消息头里带着重试次数。
示例是一个退款服务,它通过发卡处理商发出退款。
订阅者
处理商有三种拒绝退款的方式,每种需要不同的结果。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,
}
#[subscriber(KinesisStream::new("refunds").create_if_missing(1))]
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, JsonSchema)]
struct Refund {
id: u64,
card: String,
amount_cents: u64,
}
impl OutgoingDestination for Refund {
type Form = FixedName;
const DESTINATION: &'static str = "refunds";
}
impl MessageHeaders for Refund {
type Contract = NoHeaders;
}
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_after 结算这条记录,并在延迟过去后把一份副本发布回 refunds。副本带着重试次数消息头(RETRY_COUNT_HEADER),其值加一。副本在服务的内存中等待,所以它是至多一次的:在延迟结束前停止的进程会丢失它,而原记录已经位于检查点之后。
在声明了上限时,retry 也会变成一份副本,并立即发布。没有上限时,它让分片的检查点停在这条记录前面,检查点与投递保证展示了这一点。
上限与死信流
- 宏
- 手写
fn service(broker: KinesisBroker) -> impl App<State = Clients> {
RustStream::new(AppInfo::new("refunds", "0.1.0"))
.on_startup(async move |()| Ok::<_, Infallible>(Clients::default()))
.with_broker(broker, |b| {
// Five deliveries of one refund, the first included; then the dead-letter stream.
b.include(process_refund)
.max_attempts(nonzero!(5u32))
.dead_letter("refunds.review");
b.include(review);
})
}
#[ruststream::app]
fn app() -> impl App<State = Clients> {
service(
KinesisBroker::new()
.endpoint("http://localhost:4566")
.region("us-east-1")
.test_credentials(),
)
}
fn service(broker: KinesisBroker) -> impl App<State = Clients> {
RustStream::new(AppInfo::new("refunds", "0.1.0"))
.on_startup(async move |()| Ok::<_, Infallible>(Clients::default()))
.with_broker(broker, |b| {
// Five deliveries of one refund, the first included; then the dead-letter stream.
b.include(
subscriber(
KinesisStream::new("refunds").create_if_missing(1),
ProcessRefund,
)
.build(),
)
.max_attempts(nonzero!(5u32))
.dead_letter("refunds.review");
b.include(
subscriber(
KinesisStream::new("refunds.review").create_if_missing(1),
Review,
)
.build(),
);
})
}
fn main() -> ExitCode {
run_main(|| {
service(
KinesisBroker::new()
.endpoint("http://localhost:4566")
.region("us-east-1")
.test_credentials(),
)
})
}
max_attempts 统计一笔退款的每次投递,第一次也算在内。第五次投递不再重试:dead_letter 把它发布到 refunds.review,载荷和消息头保持到达时的样子。设置了上限却没有死信流时,检查点会像 drop 那样越过用尽尝试的记录。被丢弃的退款完成结算,不会去任何地方;死信流只存放用完了尝试次数的退款。这些流属于服务的基础设施;示例为本地的 LocalStack 用 create_if_missing 创建它们。
读取死信流
死信流是一个普通的流,review 像读取其他流一样读取它。
- 宏
- 手写
#[subscriber(KinesisStream::new("refunds.review").create_if_missing(1))]
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(()))
}
}
转入死信的记录,其重试次数消息头保存它经历的投递次数,这里是 5。review 用 ctx.headers() 读取它,并把这笔退款记入日志,留给人来查看。
副本与顺序
副本回到订阅读取的流,所以 Kinesis 上的注册不需要自己的重试目的地。副本保留记录的分区键,所以它落在该键所在的分片上。不过它落在这个分片的最新处,排在它等待期间发布的记录之后。因此等待 30 秒的退款,会在同一张卡后来到达的退款之后才被处理。
在键的顺序比继续向前更重要的地方,不要声明上限,让 retry 挡住分片:检查点停在这条记录上,下次获取该分片的租约时,从这条记录重新读取分片。out_retry 替换副本发出所经由的发布者,因此转换可以在每份副本发出时给它打上标记。
测试延迟
示例文件中的测试在暂停的时钟上于进程内运行应用,所以 tb.advance(BACKOFF) 让 30 秒瞬间过去。TestApp::with_state 启动应用,配上一个繁忙或不应答的替身处理商。测试 Kinesis 服务讲解了测试工具。