Перейти к основному содержимому
Нормативная версия документации - английская

Эта страница переведена с английского языковой моделью. При любом расхождении верен английский оригинал.

Повторное чтение и перемотка

Это руководство читает поток заново с самой старой записи или с заданного момента и перематывает работающую подписку из её обработчика.

Kinesis хранит запись и после того, как потребитель её прочитал, пока не истечёт срок хранения потока. Сервис может прочитать её снова: чтобы перестроить модель чтения, заполнить данными нового потребителя или пропустить записи, которые ему не годятся. Подписка получает начальную позицию в точке монтирования, а обработчик перематывает свою подписку через контекст доставки.

Модели чтения​

Сервис держит в памяти две модели чтения: баланс каждого счёта и расходы по каждой карте. Каждая проводка содержит ревизию реестра. Исправление прошлых проводок публикуется новой ревизией, и модель чтения, которая её встречает, перестраивается с самой старой записи потока.

src/main.rs
/// One posting of the ledger. `revision` is the ledger's revision: a correction of past
/// postings is published as a new revision, and a read model meeting one rebuilds itself.
#[derive(Debug, PartialEq, Deserialize)]
struct Posting {
id: u64,
account: String,
amount_cents: i64,
revision: u32,
}

/// A read model kept in memory: the balance of every account.
#[derive(Debug, Default)]
struct Projection {
revision: u32,
balances: HashMap<String, i64>,
}

impl Projection {
/// Applies `posting`, or clears the model and answers `false` when the posting belongs to a
/// newer revision, which the model has to be rebuilt for from the start of the log.
fn apply(&mut self, posting: &Posting) -> bool {
if posting.revision > self.revision {
self.revision = posting.revision;
self.balances.clear();
return false;
}
*self.balances.entry(posting.account.clone()).or_default() += posting.amount_cents;
true
}
}

/// The service's state: one read model per subscription that builds one.
#[derive(Clone, Default)]
struct Projections {
balances: Arc<Mutex<Projection>>,
spend: Arc<Mutex<Projection>>,
}

Чтение с самой старой записи​

Балансы хранятся в памяти, поэтому после перезапуска они строятся заново по всему потоку.

src/main.rs
#[subscriber(
KinesisStream::new("ledger").create_if_missing(1),
start_at(KinesisPosition::horizon())
)]
async fn project_balance(
posting: &Posting,
ctx: &mut Context<'_, KinesisContext, Projections>,
Ctx(seeker): Ctx<SeekHandle>,
) -> HandlerOutcome {
let applied = ctx
.state()
.balances
.lock()
.unwrap_or_else(PoisonError::into_inner)
.apply(posting);
if applied {
tracing::info!(posting.id, "balance updated");
return HandlerOutcome::ack();
}
tracing::info!(
posting.id,
posting.revision,
"new revision, rebuilding the balances"
);
if seeker.seek(KinesisPosition::horizon()).await.is_err() {
return HandlerOutcome::retry();
}
HandlerOutcome::ack()
}

start_at(KinesisPosition::horizon()) открывает каждый сегмент с TRIM_HORIZON, самой старой записи, которую поток ещё хранит. Эта позиция действует при каждом запуске, на сегменты, с которыми открывается подписка, и имеет приоритет над любой сохранённой контрольной точкой. Без start_at сегмент продолжает после своей контрольной точки, а если её нет, открывается с конца.

У KinesisPosition четыре позиции:

ПозицияОткуда читает подписка
horizon()с самой старой записи каждого сегмента
latest()с конца каждого сегмента, поэтому читаются только новые записи
timestamp(ms)с первой записи каждого сегмента, поступившей в момент ms или позже
sequence(shard, seq)с одной записи одного сегмента и записей после неё

По умолчанию поток хранит записи 24 часа, а с продлённым сроком хранения - до 365 дней. Записи старше срока хранения удаляются, и TRIM_HORIZON сдвигается вперёд вслед за ними.

Перемотка из обработчика​

Тот же обработчик перестраивает балансы, когда приходит проводка более новой ревизии. Ctx<SeekHandle> даёт обработчику KinesisSeeker его собственной подписки. seek перематывает подписку на самую старую запись, и обработчик читает все проводки заново. Проводка, с которой началась перестройка, тоже возвращается, и на этот раз проекция её применяет.

Перемотка принимает любую из позиций выше. Позиция для всего потока перематывает каждый сегмент, который держит этот экземпляр. Ctx<Position> хранит собственную позицию доставки, позицию sequence: перемотка на неё читает этот сегмент заново с этой записи и не трогает остальные сегменты. Для неё этот экземпляр должен держать сегмент. Перемотка на сегмент, который читает другой экземпляр или который уже прочитан до конца, возвращает ошибку.

Перемотка из пакетного обработчика​

Проводки по картам идут большим объёмом, поэтому расходы строятся пакетами до 500 записей.

src/main.rs
#[subscriber(KinesisStream::new("card-postings").create_if_missing(1))]
async fn project_spend(
postings: &[Posting],
ctx: &mut Context<'_, KinesisBatchContext, Projections>,
) -> HandlerOutcome {
let applied = {
let mut spend = ctx
.state()
.spend
.lock()
.unwrap_or_else(PoisonError::into_inner);
postings.iter().all(|posting| spend.apply(posting))
};
if applied {
tracing::info!(postings = postings.len(), "card spend updated");
return HandlerOutcome::ack();
}
tracing::info!("new revision, rebuilding the card spend");
if ctx
.context(SeekHandle)
.seek(KinesisPosition::horizon())
.await
.is_err()
{
return HandlerOutcome::retry();
}
HandlerOutcome::ack()
}

Пакетный обработчик объявляет KinesisBatchContext вместо контекста одной доставки. В нём тот же ключ SeekHandle и нет позиции, потому что пакет охватывает много записей. Тело, которому нужно место одной записи, читает её заголовки kinesis-shard-id и kinesis-sequence-number.

Чтение с заданного момента​

Проверка выплат при каждом запуске читает выплаты за последние сутки.

src/main.rs
/// A payout as the payments service publishes it.
#[derive(Debug, PartialEq, Deserialize)]
struct Payout {
id: u64,
amount_cents: i64,
}

#[subscriber(KinesisStream::new("payouts").create_if_missing(1))]
async fn review_payout(payout: &Payout) -> HandlerOutcome {
tracing::info!(payout.id, payout.amount_cents, "payout reviewed");
HandlerOutcome::ack()
}

Момент известен только при запуске сервиса, поэтому позицию задаёт точка монтирования, а не атрибут.

src/main.rs
/// Milliseconds since the epoch, the unit of a Kinesis arrival timestamp.
fn epoch_millis(at: SystemTime) -> u64 {
at.duration_since(UNIX_EPOCH).map_or(0, |since| {
u64::try_from(since.as_millis()).unwrap_or(u64::MAX)
})
}

fn service(broker: KinesisBroker, review_from: SystemTime) -> impl App<State = Projections> {
RustStream::new(AppInfo::new("ledger", "0.1.0"))
.on_startup(async move |()| Ok::<_, Infallible>(Projections::default()))
.with_broker(broker, |b| {
b.include(project_balance);
b.include(project_spend.batch(nonzero!(500usize)));
b.include(
review_payout.start_at(KinesisPosition::timestamp(epoch_millis(review_from))),
);
})
}

/// The review reads the payouts of the last day on every start, the stream's default retention.
const REVIEW_WINDOW: Duration = Duration::from_hours(24);

#[ruststream::app]
fn app() -> impl App<State = Projections> {
let review_from = SystemTime::now() - REVIEW_WINDOW;
service(
KinesisBroker::new()
.endpoint("http://localhost:4566")
.region("us-east-1")
.test_credentials(),
review_from,
)
}

KinesisPosition::timestamp принимает миллисекунды от начала эпохи. Kinesis сравнивает это значение с меткой времени поступления, которую он ставит на каждую запись. Эта метка приблизительная, поэтому записи, поступившие примерно в один момент, точно не различаются.

Перемотка и контрольные точки​

Перемотка сбрасывает учёт контрольных точек каждого сегмента, который она переносит. Доставка, прочитанная до перемотки, завершается впустую, поэтому ack проводки, с которой началась перестройка, не двигает контрольную точку. Так следующая контрольная точка никогда не обгоняет запись, которую перемотка прочитала заново и которую ещё не обработал ни один обработчик. Перемотка двигает только этот экземпляр. Остальные экземпляры продолжают читать свои сегменты, а экземпляр, который забирает сегмент, продолжает с его контрольной точки.

Тесты в файле примера запускают приложение внутри процесса. Kinesis внутри процесса хранит весь журнал каждого потока, поэтому перемотка и там читает его заново.