В идеальном мире события прибывают в брокер и покидают его строго в том порядке, в котором они были созданы. Часы на всех серверах синхронизированы до наносекунды, сеть никогда не рвётся, а потребители обрабатывают данные мгновенно. Но мы живём в реальности. Сетевые пакеты застревают, серверы перезагружаются, а производители событий временно теряют связь с брокером.
Как гарантировать, что микросервис, обрабатывающий финансовые транзакции или аналитику кликов, выдаст один и тот же результат независимо от того, обрабатывает ли он данные в реальном времени или «переваривает» вчерашний завал? Ответ кроется в концепции детерминированной обработки потоков.
Это шестая глава цикла о событийно-управляемой архитектуре. Мы уже разобрали, почему EDA нужна, как она устроена под капотом, как проектировать контракты, как подключить legacy и как работает потоковая обработка. Теперь — о самом сложном: о времени.
Главная ловушка при переходе от пакетной обработки к потоковой — путаница между двумя типами времени. Чтобы построить надёжную систему, нужно жёстко разграничить:
Момент, когда факт действительно произошёл в реальном мире. Когда пользователь нажал «Оплатить» или датчик IoT зафиксировал температуру. Хранится в payload события или в metadata записи Kafka.
Момент, когда микросервис получил и начал обрабатывать событие. Зависит от задержек сети, очередей, загрузки CPU и сборщика мусора. Нестабильно и недетерминировано.
Момент, когда событие попало в брокер (Kafka timestamp). Промежуточный вариант: детерминирован в рамках одного брокера, но не отражает реальное время события.
Event time — для бизнес-логики. Ingestion time — для брокерной сортировки. Processing time — для метрик мониторинга.
| Свойство | Event Time | Processing Time | Ingestion Time |
|---|---|---|---|
| Детерминированность | Да | Нет | В рамках одного брокера |
| Устойчивость к сбоям | Да | Нет | Частично |
| Точность бизнес-логики | Максимальная | Низкая | Средняя |
| Сложность реализации | Высокая | Низкая | Средняя |
Банковский счёт. В 10:00 клиент вносит $100 (event time). В 10:05 снимает $150. Если из-за сбоя сети событие снятия пришло в систему первым, использование времени обработки приведёт к ошибочному штрафу за овердрафт. Только время события спасает бизнес-логику.
Прежде чем двигаться дальше, нужно ответить на фундаментальный вопрос: откуда система знает event time? Событие — это просто байты. Время нужно откуда-то извлечь.
По умолчанию Kafka Streams использует timestamp записи Kafka — то, что продюсер положил в metadata. Это может быть время отправки (create time) или время приёма брокером (log append time) — зависит от настройки топика message.timestamp.type.
Если timestamp Kafka не подходит (например, событие реплицировано из другой системы, и метка не отражает реальное время), можно задать свой TimestampExtractor:
public class OrderTimestampExtractor implements TimestampExtractor {
@Override
public long extract(ConsumerRecord<Object, Object> record, long partitionTime) {
OrderEvent order = (OrderEvent) record.value();
return order.getOccurredAt().toEpochMilli();
}
}
// В конфигурации:
props.put(StreamsConfig.DEFAULT_TIMESTAMP_EXTRACTOR_CLASS_CONFIG,
OrderTimestampExtractor.class);
Особенно важно: если payload не содержит корректного timestamp, продюсер может положить отрицательное значение. Тогда Kafka Streams выбросит StreamsException. Для graceful-обработки используйте WallclockTimestampExtractor (fallback на текущее время) или FailOnInvalidTimestamp (для строгой валидации).
В Flink event time извлекается через TimestampAssigner, который задаётся на источнике:
DataStream<OrderEvent> orders = env
.addSource(new KafkaSource<>(...))
.assignTimestampsAndWatermarks(
WatermarkStrategy.<OrderEvent>forBoundedOutOfOrderness(
Duration.ofMinutes(2))
.withTimestampAssigner((event, ts) ->
event.getOccurredAt().toEpochMilli()));
Ключевое отличие от Kafka Streams: во Flink timestamp assigner и watermark strategy задаются явно. Это требует больше кода, но даёт полный контроль над моделью времени.
Если payload уже содержит event time — используйте его через TimestampExtractor/TimestampAssigner. Если нет — используйте ingestion time (Kafka timestamp), но помните: он не отражает реальное время события.
Микросервис часто потребляет данные из нескольких партиций и разных потоков одновременно. Если обрабатывать по принципу «что первое пришло, то и крутим», результат зависит от сетевой задержки, а не от бизнес-логики.
Планировщик событий (Event Scheduler) буферизует входящие записи и всегда отдаёт в бизнес-логику событие с наименьшей временной меткой. Это гарантирует, что даже если события перепутались на уровне сети, микросервис восстановит их историческую справедливость.
Цена: буферизация требует памяти. Чем больше окно и чем больше партиций, тем больше буфер. Для session windows с timeout 30 минут и 100 партиций буфер может занимать гигабайты.
Планировщик сортирует события только в пределах одной партиции. Между партициями глобального порядка нет. Если нужно собрать все события одного пользователя — используйте repartitioning (см. главу V).
Если мы буферизуем события для сортировки, возникает вечный вопрос: как долго ждать опоздавших? Бесконечно ждать нельзя — память не резиновая, а бизнесу нужны результаты.
Водяной знак (Watermark) — это монотонно возрастающий timestamp, который объявляет: «Все события с временем T и ранее уже гарантированно прибыли». Если после водяного знака приходит событие со временем T−1, система понимает: это запоздалое событие (Late Event).
Водяной знак вычисляется на основе наблюдаемых временных меток событий. Стратегия генерации определяет, насколько агрессивно система «сдвигает» границу.
Водяной знак генерируется периодически (например, каждые 200 мс), на основе максимального event time, наблюдённого за период, минус допустимая задержка:
watermark = max(eventTime) - allowedLateness
Например, если максимальное наблюдённое event time — 10:05, а allowed lateness — 2 минуты, watermark = 10:03. Все окна до 10:03 можно закрывать.
Водяной знак генерируется при каждом специальном событии (например, маркер конца сессии). Редко используется, подходит для потоков с нерегулярными интервалами.
WatermarkStrategy.<OrderEvent>forBoundedOutOfOrderness(
Duration.ofSeconds(30))
.withTimestampAssigner((event, ts) -> event.getOccurredAt().toEpochMilli())
.withIdleness(Duration.ofMinutes(5));
Здесь forBoundedOutOfOrderness означает: «мы ожидаем, что события опаздывают не более чем на 30 секунд».
Watermark движется по топологии: каждый оператор получает водяные знаки от всех входных потоков и берёт минимум.
Source A: watermark = 10:05
Source B: watermark = 10:02 → Operator: watermark = min(10:05, 10:02) = 10:02
Source C: watermark = 10:07
Это объясняет, почему один медленный или молчащий поток блокирует всю топологию: если один источник не шлёт события, его watermark не двигается, и общий минимум застывает.
В больших топологиях разные источники могут обрабатывать данные с разной скоростью. Если один источник отстаёт на часы, а остальные — на секунды, минимум watermark застывает, и окна не закрываются.
Flink предоставляет watermark alignment: операторы могут «сообщать» upstream, что они отстают, и upstream-операторы притормаживают свои watermarks, чтобы дать отстающему догнать.
WatermarkStrategy.<OrderEvent>forBoundedOutOfOrderness(
Duration.ofSeconds(30))
.withWatermarkAlignment(
"alignment-group-1",
Duration.ofMinutes(5), // максимальное расхождение
Duration.ofSeconds(30)); // интервал проверки
Это более продвинутый механизм, чем withIdleness: он не исключает источник, а временно «придерживает» остальные, чтобы сохранить консистентность.
withIdleness — когда источник может полностью замолчать (ночной трафик). Watermark alignment — когда источники работают с разной скоростью (например, одна партиция читает быстрее другой).
Если одна партиция замолчала (например, ночной трафик по одному региону прекратился), её watermark перестаёт двигаться. Поскольку оператор берёт минимум по всем входам, общая watermark застывает, и окна не закрываются.
Решение в Flink: withIdleness(Duration.ofMinutes(5)) — если источник не получает событий 5 минут, он исключается из расчёта watermark. Окна продолжают закрываться по остальным источникам.
В Kafka Streams этой проблемы нет в явном виде — окна закрываются по wall-clock time, а не по watermark. Но есть другая проблема: если партиция замолчала, окно не получит новых событий и будет закрыто по истечении grace period.
Бизнес-логика редко работает с абстрактным «потоком». Ей нужны агрегаты: «сколько кликов за последний час», «какова сессия пользователя». Для этого используются оконные функции.
| Тип окна | Перекрытие | Привязка к сетке | Событий в нескольких окнах | Где используется |
|---|---|---|---|---|
| Tumbling | Нет | Да | 1 | Периодические отчёты |
| Hopping | Да | Да | N | Rolling-метрики |
| Sliding | Да | Нет (к событиям) | N | JOIN, корреляция |
| Session | Динамическое | Нет | Зависит | Поведение пользователей |
Переворачивающиеся (Tumbling) — фиксированные, непересекающиеся интервалы. Каждое событие попадает ровно в одно окно. Идеально для периодических отчётов: «выручка за каждый час».
Прыгающие (Hopping) — фиксированный размер, но шаг меньше размера. Окно «перекатывается» с заданным интервалом. Одно событие может попасть в несколько окон. Используется для rolling-метрик: «5-минутное скользящее среднее, обновление каждую минуту».
Скользящие (Sliding) — окно привязано не к фиксированной сетке, а к временным меткам самих событий. Два события агрегируются, если их временные метки в пределах размера окна. В Kafka Streams sliding windows используются только для JOIN-операций. Идеально для корреляции: «дважды неудачный логин в течение 30 секунд».
TimeWindows windows = TimeWindows
.ofSizeAndGrace(Duration.ofMinutes(5), Duration.ofMinutes(1));
stream.groupByKey()
.windowedBy(windows)
.count();
Здесь окно — 5 минут, grace period — 1 минута: опоздавшие события в пределах 1 минуты после закрытия окна будут приняты и вызовут корректирующий результат.
Сеансовые окна (Session Windows) — динамические окна, не привязанные к часам. Окно открыто, пока пользователь активен. Пауза (timeout бездействия, например 30 минут) закрывает окно.
Ключевая особенность session windows — слияние (merge). Если новая активность «дотягивается» до предыдущей (gap < timeout), два окна объединяются в одно.
Session 1: [10:00 — 10:05]
Session 2: [10:07 — 10:15]
Gap = 2 минуты < timeout (30 минут)
→ Объединённая сессия: [10:00 — 10:15]
Это основа для анализа пользовательского поведения: клики, просмотры, покупки в рамках одной сессии могут быть «склеены» в единое событие.
В Kafka Streams session windows используются так:
stream.groupByKey()
.windowedBy(SessionWindows.ofInactivityGapWithNoGrace(
Duration.ofMinutes(30)))
.count();
Session windows держат состояние до тех пор, пока сессия активна. Для пользователя, который не закрывает браузер неделю, сессия будет расти. Настройте максимальный размер сессии или используйте grace period для принудительного закрытия.
Не все «неправильные» события одинаковы.
События пришли не в порядке event time, но до watermark. Например, событие 10:03 пришло раньше события 10:01, но watermark ещё 09:55. Планировщик переупорядочит их — это нормальная работа системы.
Событие пришло после watermark. Окно может быть уже закрыто. Это требует стратегии (см. следующий раздел).
Различие критично: out-of-order — штатная ситуация, late — исключительная. Система должна быть спроектирована так, чтобы out-of-order обрабатывались прозрачно, а late — осознанно.
Рано или поздно watermark уйдёт вперёд, и прилетит событие, которое «опоздало». Что с ним делать — это не только технический, но и бизнес-вопрос.
Окно закрыто, результат ушёл. Позднее событие игнорируется. Подходит для телеметрии, где важна актуальность, а не история.
В Kafka Streams — режим по умолчанию при отсутствии grace period. В Flink — события уходят в side output для анализа.
Окно закрыто, но остаётся «приоткрытым» на N минут. Если опоздавшее событие приходит в этот период, агрегат пересчитывается и выпускается корректирующий результат.
Разница между фреймворками:
Позднее событие не отбрасывается молча, а направляется в отдельный поток (side output). Этот поток можно анализировать, репроцессить или применять fallback-логику.
OutputTag<OrderEvent> lateOutputTag = new OutputTag<>("late-orders"){};
SingleOutputStreamOperator<Result> result = stream
.keyBy(OrderEvent::getCustomerId)
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.allowedLateness(Time.minutes(1))
.sideOutputLateData(lateOutputTag)
.aggregate(new OrderAggregator());
DataStream<OrderEvent> lateOrders = result.getSideOutput(lateOutputTag);
| Стратегия | Kafka Streams | Flink | Когда выбирать |
|---|---|---|---|
| Drop | По умолчанию | По умолчанию | Телеметрия, клики, неважные данные |
| Grace / Allowed lateness | TimeWindows.grace(...) |
.allowedLateness(...) |
Финансы, биллинг, инвентаризация |
| Side output | Вручную через branch | Встроено | Аудит поздних данных, fallback-логика |
| Wait (бесконечное ожидание) | Не поддерживается | Не поддерживается | Не рекомендуется — рост состояния |
Grace period — это компромисс между задержкой выдачи результата (latency) и полнотой данных (completeness). Как выбрать?
late-events-rate — доля событий, пришедших после watermark. Если растёт — grace period нужно увеличить.Real-time дашборды: grace 30 секунд. Биллинг и инвентаризация: grace 10 минут. Финансовая отчётность: grace 24 часа.
Фреймворки принципиально расходятся в подходе к времени, и это влияет на выбор под задачу.
| Критерий | Flink | Kafka Streams |
|---|---|---|
| Модель времени | Event-time-first, watermarks | Wall-clock + event time через retention |
| Watermarks | Встроенные, настраиваемые стратегии | Нет в классическом понимании |
| Late events | Allowed lateness + side outputs | Grace period, drop после закрытия |
| Idle sources | withIdleness() |
Нет явной проблемы (wall-clock) |
| Watermark alignment | Да | Нет |
| Timestamp assigner | Явный | Через TimestampExtractor |
| Out-of-order | Планировщик переупорядочивает | Ограничено (только в пределах партиции) |
| Event-time JOIN | Да, через watermark alignment | Да, через windowed join |
| Сложность | Выше (настройка watermarks) | Ниже (wall-clock fallback) |
Flink — когда нужна точная event-time обработка с watermarks, сложная аналитика, multi-source JOIN, ML-пайплайны. Kafka Streams — когда вход и выход в Kafka, нужна простота деплоя и приемлем wall-clock fallback.
Неизменяемые журналы событий дают потрясающую возможность. Если найдена ошибка в бизнес-логике или выпущена новая схема, можно просто перемотать offset на начало и прогнать данные через микросервис заново. Благодаря детерминированной обработке и планировщику событий, переработка исторических данных выдаст точно такой же порядок и результаты, как если бы обработка шла в реальном времени.
Но reprocessing — не «нажать кнопку». Нужно учесть:
public OrderEvent upcast(OrderEventV1 old) {
return new OrderEvent(
old.getId(),
old.getCustomerId(),
old.getAmount(),
null, // paymentMethod — новое поле
old.getOccurredAt()
);
}
Reprocessing 1 ТБ данных через Kafka Streams при 50k msg/sec ≈ 30–60 минут. Но это очень приблизительно. Ключевые факторы:
| Глава | Фокус | Связь с текущей главой |
|---|---|---|
| I. Среда формирует архитектуру | Почему EDA и как это меняет организации | Детерминированность — это контракт между командами |
| II. Анатомия EDA | Топологии, брокеры, идемпотентность | Timestamp в Kafka — основа ingestion time |
| III. Анатомия идеального события | Контракты и Schema Registry | Upcasting при reprocessing — эволюция схем в действии |
| IV. Data Liberation | Legacy-интеграция и событизация | Reprocessing — инструмент для переигрывания legacy-данных |
| V. Механика потоковой обработки | State, windows, EOS | Windowing в этой главе — углубление раздела V |
| VI. Время в потоках | Event time, watermarks, late events | Детерминизм — фундамент надёжной EDA |
late-events-rate добавлена в мониторингwithIdleness во Flink)withIdleness, Kafka Streams — через wall-clock fallback.Детерминизм в потоковой обработке — это не про идеальные условия. Это про создание архитектуры, которая ожидает сбоев, задержек и пересортицы. В событийно-управляемом мире время — это не просто часы на стене. Это данные. И тот, кто умеет с ними обращаться, получает абсолютную власть над историей.
Глава VI цикла «Событийно-управляемая архитектура». Источники: Apache Flink, Confluent, Conduktor, RisingWave, Software Patterns Lexicon, BigDataSchool, Habr, практические кейсы production-систем.
In an ideal world, events arrive at the broker and leave it strictly in the order they were created. Clocks on all servers are synchronized to the nanosecond, the network never breaks, and consumers process data instantly. But we live in reality. Network packets get stuck, servers restart, and event producers temporarily lose connection to the broker.
How do we guarantee that a microservice processing financial transactions or click analytics produces the same result whether it processes data in real time or "digests" yesterday's backlog? The answer lies in the concept of deterministic stream processing.
This is the sixth chapter of the event-driven architecture series. We've covered why EDA is needed, how it works under the hood, how to design contracts, how to connect legacy, and how stream processing works. Now — the hardest part: time.
The main trap when moving from batch to stream processing is confusing two types of time. To build a reliable system, we must strictly distinguish:
The moment the fact actually happened in the real world. When the user clicked "Pay" or the IoT sensor recorded the temperature. Stored in the event payload or in Kafka record metadata.
The moment the microservice received and started processing the event. Depends on network delays, queues, CPU load, and garbage collector. Unstable and non-deterministic.
The moment the event entered the broker (Kafka timestamp). An intermediate option: deterministic within one broker, but doesn't reflect the real time of the event.
Event time — for business logic. Ingestion time — for broker sorting. Processing time — for monitoring metrics.
| Property | Event Time | Processing Time | Ingestion Time |
|---|---|---|---|
| Determinism | Yes | No | Within one broker |
| Fault tolerance | Yes | No | Partial |
| Business logic accuracy | Maximum | Low | Medium |
| Implementation complexity | High | Low | Medium |
A bank account. At 10:00 the client deposits $100 (event time). At 10:05 they withdraw $150. If, due to a network failure, the withdrawal event reaches the system first, using processing time leads to an erroneous overdraft fee. Only event time saves the business logic.
Before going further, we need to answer a fundamental question: where does the system learn event time? An event is just bytes. Time must be extracted from somewhere.
By default, Kafka Streams uses the Kafka record timestamp — what the producer put in the metadata. This can be the send time (create time) or the broker receive time (log append time), depending on the topic setting message.timestamp.type.
If the Kafka timestamp doesn't fit (e.g., the event was replicated from another system and the label doesn't reflect real time), you can set your own TimestampExtractor:
public class OrderTimestampExtractor implements TimestampExtractor {
@Override
public long extract(ConsumerRecord<Object, Object> record, long partitionTime) {
OrderEvent order = (OrderEvent) record.value();
return order.getOccurredAt().toEpochMilli();
}
}
// In configuration:
props.put(StreamsConfig.DEFAULT_TIMESTAMP_EXTRACTOR_CLASS_CONFIG,
OrderTimestampExtractor.class);
Important: if the payload doesn't contain a valid timestamp, the producer may put a negative value. Kafka Streams will throw a StreamsException. For graceful handling, use WallclockTimestampExtractor (fallback to current time) or FailOnInvalidTimestamp (strict validation).
In Flink, event time is extracted via a TimestampAssigner set on the source:
DataStream<OrderEvent> orders = env
.addSource(new KafkaSource<>(...))
.assignTimestampsAndWatermarks(
WatermarkStrategy.<OrderEvent>forBoundedOutOfOrderness(
Duration.ofMinutes(2))
.withTimestampAssigner((event, ts) ->
event.getOccurredAt().toEpochMilli()));
The key difference from Kafka Streams: in Flink, timestamp assigner and watermark strategy are set explicitly. This requires more code but gives full control over the time model.
If the payload already contains event time — use it via TimestampExtractor/TimestampAssigner. If not — use ingestion time (Kafka timestamp), but remember: it doesn't reflect the real time of the event.
A microservice often consumes data from multiple partitions and different streams simultaneously. If we process on a "first come, first served" basis, the result depends on network delay, not business logic.
The Event Scheduler buffers incoming records and always passes to business logic the event with the smallest timestamp. This guarantees that even if events got shuffled at the network level, the microservice restores their historical justice.
Cost: buffering requires memory. The larger the window and the more partitions, the larger the buffer. For session windows with a 30-minute timeout and 100 partitions, the buffer can take gigabytes.
The scheduler sorts events only within one partition. There is no global order between partitions. If you need to gather all events of one user — use repartitioning (see Chapter V).
If we buffer events for sorting, an eternal question arises: how long to wait for latecomers? We can't wait forever — memory is finite, and the business needs results.
A Watermark is a monotonically increasing timestamp that declares: "All events with time T and earlier have definitely arrived." If after the watermark an event with time T−1 arrives, the system understands: this is a Late Event.
The watermark is computed from observed event timestamps. The generation strategy determines how aggressively the system "moves" the boundary.
The watermark is generated periodically (e.g., every 200 ms), based on the maximum event time observed during the period, minus the allowed delay:
watermark = max(eventTime) - allowedLateness
For example, if the maximum observed event time is 10:05 and allowed lateness is 2 minutes, watermark = 10:03. All windows up to 10:03 can be closed.
The watermark is generated on every special event (e.g., end-of-session marker). Rarely used, suitable for streams with irregular intervals.
WatermarkStrategy.<OrderEvent>forBoundedOutOfOrderness(
Duration.ofSeconds(30))
.withTimestampAssigner((event, ts) -> event.getOccurredAt().toEpochMilli())
.withIdleness(Duration.ofMinutes(5));
Here forBoundedOutOfOrderness means: "we expect events to be late by no more than 30 seconds."
The watermark moves through the topology: each operator receives watermarks from all input streams and takes the minimum.
Source A: watermark = 10:05
Source B: watermark = 10:02 → Operator: watermark = min(10:05, 10:02) = 10:02
Source C: watermark = 10:07
This explains why one slow or silent stream blocks the entire topology: if one source doesn't send events, its watermark doesn't move, and the overall minimum freezes.
In large topologies, different sources may process data at different speeds. If one source lags by hours while others lag by seconds, the watermark minimum freezes, and windows don't close.
Flink provides watermark alignment: operators can "report" to upstream that they are lagging, and upstream operators slow down their watermarks to let the laggard catch up.
WatermarkStrategy.<OrderEvent>forBoundedOutOfOrderness(
Duration.ofSeconds(30))
.withWatermarkAlignment(
"alignment-group-1",
Duration.ofMinutes(5), // maximum divergence
Duration.ofSeconds(30)); // check interval
This is a more advanced mechanism than withIdleness: it doesn't exclude the source but temporarily "holds" the others to preserve consistency.
withIdleness — when the source can go completely silent (night traffic). Watermark alignment — when sources run at different speeds (e.g., one partition reads faster than another).
If one partition goes silent (e.g., night traffic in one region stopped), its watermark stops moving. Since the operator takes the minimum across all inputs, the overall watermark freezes, and windows don't close.
Solution in Flink: withIdleness(Duration.ofMinutes(5)) — if the source receives no events for 5 minutes, it is excluded from watermark calculation. Windows continue to close based on the remaining sources.
Kafka Streams doesn't have this problem explicitly — windows close by wall-clock time, not by watermark. But there's another issue: if a partition goes silent, the window won't receive new events and will be closed when the grace period expires.
Business logic rarely works with an abstract "stream." It needs aggregates: "how many clicks in the last hour," "what is the user's session." For this, windowing functions are used.
| Window Type | Overlap | Grid-bound | Events in Multiple Windows | Where Used |
|---|---|---|---|---|
| Tumbling | No | Yes | 1 | Periodic reports |
| Hopping | Yes | Yes | N | Rolling metrics |
| Sliding | Yes | No (event-bound) | N | JOIN, correlation |
| Session | Dynamic | No | Depends | User behavior |
Tumbling — fixed, non-overlapping intervals. Each event lands in exactly one window. Ideal for periodic reports: "revenue per hour."
Hopping — fixed size, but step smaller than size. The window "rolls" with a given interval. One event can land in several windows. Used for rolling metrics: "5-minute moving average, updated every minute."
Sliding — window bound not to a fixed grid but to event timestamps. Two events aggregate if their timestamps are within the window size. In Kafka Streams, sliding windows are used only for JOIN operations. Ideal for correlation: "two failed logins within 30 seconds."
TimeWindows windows = TimeWindows
.ofSizeAndGrace(Duration.ofMinutes(5), Duration.ofMinutes(1));
stream.groupByKey()
.windowedBy(windows)
.count();
Here the window is 5 minutes, grace period — 1 minute: late events within 1 minute after the window closes will be accepted and produce a corrective result.
Session Windows are dynamic windows not bound to a clock. The window is open while the user is active. A pause (idle timeout, e.g., 30 minutes) closes the window.
The key feature of session windows is merge. If new activity "reaches" the previous one (gap < timeout), two windows combine into one.
Session 1: [10:00 — 10:05]
Session 2: [10:07 — 10:15]
Gap = 2 minutes < timeout (30 minutes)
→ Merged session: [10:00 — 10:15]
This is the foundation for user behavior analysis: clicks, views, purchases within one session can be "glued" into a single event.
In Kafka Streams, session windows are used like this:
stream.groupByKey()
.windowedBy(SessionWindows.ofInactivityGapWithNoGrace(
Duration.ofMinutes(30)))
.count();
Session windows hold state as long as the session is active. For a user who doesn't close the browser for a week, the session will grow. Set a maximum session size or use a grace period for forced closure.
Not all "wrong" events are the same.
Events arrived out of event-time order but before the watermark. For example, event 10:03 arrived before event 10:01, but the watermark is still 09:55. The scheduler will reorder them — this is normal operation.
The event arrived after the watermark. The window may already be closed. This requires a strategy (see next section).
The distinction is critical: out-of-order is normal, late is exceptional. The system must be designed so out-of-order events are handled transparently and late — consciously.
Sooner or later the watermark moves forward, and an event arrives that is "late." What to do with it is not only a technical but also a business question.
The window is closed, the result is out. The late event is ignored. Suitable for telemetry, where freshness matters more than history.
The window is closed but stays "ajar" for N minutes. If a late event arrives during this period, the aggregate is recomputed and a corrective result is emitted.
Difference between frameworks:
The late event is not silently dropped but routed to a separate stream (side output). This stream can be analyzed, reprocessed, or used for fallback logic.
OutputTag<OrderEvent> lateOutputTag = new OutputTag<>("late-orders"){};
SingleOutputStreamOperator<Result> result = stream
.keyBy(OrderEvent::getCustomerId)
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.allowedLateness(Time.minutes(1))
.sideOutputLateData(lateOutputTag)
.aggregate(new OrderAggregator());
DataStream<OrderEvent> lateOrders = result.getSideOutput(lateOutputTag);
| Strategy | Kafka Streams | Flink | When to Choose |
|---|---|---|---|
| Drop | Default | Default | Telemetry, clicks, non-critical data |
| Grace / Allowed lateness | TimeWindows.grace(...) |
.allowedLateness(...) |
Finance, billing, inventory |
| Side output | Manual via branch | Built-in | Audit of late data, fallback logic |
| Wait (infinite) | Not supported | Not supported | Not recommended — state growth |
Grace period is a trade-off between result latency and data completeness. How to choose?
late-events-rate — the share of events arriving after the watermark. If it grows — the grace period needs to increase.Real-time dashboards: grace 30 seconds. Billing and inventory: grace 10 minutes. Financial reporting: grace 24 hours.
Frameworks fundamentally diverge in their approach to time, and this affects the choice for the task.
| Criterion | Flink | Kafka Streams |
|---|---|---|
| Time model | Event-time-first, watermarks | Wall-clock + event time via retention |
| Watermarks | Built-in, configurable strategies | None in the classical sense |
| Late events | Allowed lateness + side outputs | Grace period, drop after closure |
| Idle sources | withIdleness() |
No explicit problem (wall-clock) |
| Watermark alignment | Yes | No |
| Timestamp assigner | Explicit | Via TimestampExtractor |
| Out-of-order | Scheduler reorders | Limited (within partition) |
| Event-time JOIN | Yes, via watermark alignment | Yes, via windowed join |
| Complexity | Higher (watermark setup) | Lower (wall-clock fallback) |
Flink — when precise event-time processing with watermarks, complex analytics, multi-source JOIN, ML pipelines are needed. Kafka Streams — when input and output are in Kafka, deployment simplicity is needed, and a wall-clock fallback is acceptable.
Immutable event logs give an amazing capability. If a bug is found in business logic or a new schema is released, you can simply rewind the offset to the beginning and re-run the data through the microservice. Thanks to deterministic processing and the event scheduler, reprocessing historical data produces exactly the same order and results as if processing had been in real time.
But reprocessing is not "press a button." You need to consider:
public OrderEvent upcast(OrderEventV1 old) {
return new OrderEvent(
old.getId(),
old.getCustomerId(),
old.getAmount(),
null, // paymentMethod — new field
old.getOccurredAt()
);
}
Reprocessing 1 TB of data through Kafka Streams at 50k msg/sec ≈ 30–60 minutes. But this is very approximate. Key factors:
| Chapter | Focus | Link to This Chapter |
|---|---|---|
| I. The Medium Shapes the Architecture | Why EDA and how it changes organizations | Determinism is a contract between teams |
| II. The Anatomy of EDA | Topologies, brokers, idempotency | Kafka timestamp is the basis of ingestion time |
| III. The Anatomy of an Ideal Event | Contracts and Schema Registry | Upcasting during reprocessing — schema evolution in action |
| IV. Data Liberation | Legacy integration and eventification | Reprocessing is a tool for replaying legacy data |
| V. The Mechanics of Stream Processing | State, windows, EOS | Windowing here deepens Chapter V |
| VI. Time in Streams | Event time, watermarks, late events | Determinism is the foundation of reliable EDA |
late-events-rate added to monitoringwithIdleness in Flink)withIdleness, Kafka Streams — via wall-clock fallback.Determinism in stream processing is not about ideal conditions. It is about building an architecture that expects failures, delays, and reshuffles. In the event-driven world, time is not just a clock on the wall. It is data. And whoever knows how to handle it gains absolute power over history.
Chapter VI of the "Event-Driven Architecture" series. Sources: Apache Flink, Confluent, Conduktor, RisingWave, Software Patterns Lexicon, BigDataSchool, Habr, production system case studies.