Wait Click Join в YTsaurus Flow (Java)
Пайплайн выполняет джойн потоков хитов (hit) и действий (action) на временном окне. Для каждого хита система ожидает связанные действия (show, click) в течение заданного временного интервала, после чего генерирует объединенное событие.
Исходный код (Kotlin)
Подробное описание задачи и логики джойна — в описании C++ версии.
Постановка задачи
Есть система с тремя видами событий:
- hit — факт запроса пользователя. Содержит
hit_id,hit_timeиhit_payload. - action show — факт, что пользователь увидел ответ. Содержит
hit_id,hit_time,action_timeиis_click = false. - action click — факт, что пользователь кликнул на ответ. Содержит
hit_id,hit_time,action_timeиis_click = true.
Данные поступают в двух потоках: hit и action.
На выходе строится джойн: к каждому show присоединяется информация о наличии клика и hit_payload с хита. Хиты без показов игнорируются. Система ждёт события action в течение wait_for_actions секунд после hit_time.
Схема потоков данных
JoinProcessFunction
Основная логика пайплайна реализована в JoinProcessFunction, которая обрабатывает два потока — hit и action:
@Override
public void onMessage(ExtendedMessage message, OutputCollector output, RuntimeContext ctx) {
var streamId = message.getStreamId();
String hitId;
Long hitTime;
Hit hit = null;
Action action = null;
if ("hit".equals(streamId)) {
hit = message.getPayload();
hitId = hit.getHitId();
hitTime = hit.getHitTime();
} else if ("action".equals(streamId)) {
action = message.getPayload();
hitId = action.getHitId();
hitTime = action.getHitTime();
} else {
throw new IllegalArgumentException("Unknown streamId: %s".formatted(streamId));
}
log.debug("Process message (Key: {}, HitId: {}, HitTime: {})", message.getKey(), hitId, hitTime);
long waitTime = parseDuration(ctx.getComputationParameters().get("wait_for_actions").stringValue()).toSeconds();
// [BEGIN timer_setup]
long maxTime = hitTime + waitTime;
// All messages out of wait range would be dropped.
if (message.getEventTimestamp() >= maxTime) {
log.warn("Message is out of wait range, skip (HitId: {}, HitTime: {}, MaxTime: {})",
hitId, hitTime, maxTime);
return;
}
// Drop late data.
// [BEGIN late_data_check]
if (message.getEventTimestamp() < ctx.getEpochInputEventWatermark()) {
log.warn("Late message, skip (HitId: {}, EventTimestamp: {}, InputEventWatermark: {})",
hitId, message.getEventTimestamp(), ctx.getEpochInputEventWatermark());
return;
}
// [END late_data_check]
// [BEGIN state_update]
ExternalStateAccessor stateAccessor = ctx.getState(JOINED_ACTION_STATE, message);
PayloadBuilder joinState = stateAccessor.getOrDefault().toBuilder();
if ("hit".equals(streamId)) {
log.debug("Process hit message (HitId: {}, HitPayload: {}, Hit: {})", hitId, hit.getHitPayload(), hit);
joinState.set("hit_payload", hit.getHitPayload());
} else {
log.debug("Process action message (HitId: {}, ActionTime: {}, Action: {})",
hitId, action.getActionTime(), action);
if (Boolean.TRUE.equals(action.isClick())) {
joinState.set("click_time", action.getActionTime());
} else {
joinState.set("show_time", action.getActionTime());
}
}
log.debug("Set state (KeyHitId: {}, State: {})", hitId, joinState);
stateAccessor.set(joinState.finish());
// [END state_update]
log.debug("Add timer for Key (MaxTime: {}, HitTime: {})", maxTime, hitTime);
output.addTimer(maxTime, hitTime);
// [END timer_setup]
}
// [END on_message]
// [BEGIN on_timer]
@Override
public void onTimer(Timer timer, OutputCollector output, RuntimeContext ctx) {
// Key to pojo conversion is not supported yet.
String hitId = timer.getKey().get("hit_id", String.class);
log.debug("Process timer (KeyHitId: {}, Timer: {})", hitId, timer);
ExternalStateAccessor stateAccessor = ctx.getState(JOINED_ACTION_STATE, timer);
Payload joinState = stateAccessor.get();
if (joinState == null) {
throw new IllegalStateException("Empty state for timer: " + timer);
}
log.debug("Process state (KeyHitId: {}, State: {})", hitId, joinState);
if ("timer".equals(timer.getStreamId())) {
// ExternalState to pojo conversion is not supported yet.
if (joinState.get("show_time", Long.class) != null
&& joinState.get("hit_payload", String.class) != null
&& joinState.get("show_time", Long.class) != 0) {
log.debug("Click join: {}", joinState);
JoinedAction joinedAction = new JoinedAction();
long hitTime = timer.getKey().get("hit_time", Long.class);
joinedAction.setHitId(hitId);
joinedAction.setHitTime(hitTime);
joinedAction.setShowTime(joinState.get("show_time", Long.class));
if (joinState.get("click_time", Long.class) != null && joinState.get("click_time", Long.class) != 0) {
joinedAction.setClick(true);
joinedAction.setClickTime(joinState.get("click_time", Long.class));
} else {
joinedAction.setClick(false);
joinedAction.setClickTime(0L);
}
joinedAction.setHitPayload(joinState.get("hit_payload", String.class));
output.addMessage(Message.builder().setStreamId("joined_action").setPayload(joinedAction).build());
}
stateAccessor.clear();
} else {
throw new IllegalArgumentException("Unknown streamId: %s".formatted(timer.getStreamId()));
}
}
override fun onMessage(message: ExtendedMessage, output: OutputCollector, ctx: RuntimeContext) {
val streamId = message.streamId
val hitId: String
val hitTime: Long
var hit: Hit? = null
var action: Action? = null
if ("hit" == streamId) {
hit = message.getPayload<Hit>()
hitId = hit.hitId!!
hitTime = hit.hitTime!!
} else if ("action" == streamId) {
action = message.getPayload<Action>()
hitId = action.hitId!!
hitTime = action.hitTime!!
} else {
throw IllegalArgumentException("Unknown streamId: $streamId")
}
log.debug("Process message (Key: {}, HitId: {}, HitTime: {})", message.key, hitId, hitTime)
val waitTime = parseDuration(
ctx.computationParameters.get("wait_for_actions")!!.stringValue()
).toSeconds()
// [BEGIN timer_setup]
val maxTime = hitTime + waitTime
// All messages out of wait range would be dropped.
if (message.eventTimestamp >= maxTime) {
log.warn(
"Message is out of wait range, skip (HitId: {}, HitTime: {}, MaxTime: {})",
hitId, hitTime, maxTime
)
return
}
// Drop late data.
// [BEGIN late_data_check]
if (message.eventTimestamp < ctx.epochInputEventWatermark) {
log.warn(
"Late message, skip (HitId: {}, EventTimestamp: {}, InputEventWatermark: {})",
hitId, message.eventTimestamp, ctx.epochInputEventWatermark
)
return
}
// [END late_data_check]
// [BEGIN state_update]
val stateAccessor = ctx.getState(JOINED_ACTION_STATE, message)
val joinState = stateAccessor.orDefault.toBuilder()
if ("hit" == streamId) {
log.debug(
"Process hit message (HitId: {}, HitPayload: {}, Hit: {})",
hitId, hit!!.hitPayload, hit
)
joinState.set("hit_payload", hit.hitPayload)
} else {
log.debug(
"Process action message (HitId: {}, ActionTime: {}, Action: {})",
hitId, action!!.actionTime, action
)
if (action.isClick() == true) {
joinState.set("click_time", action.actionTime)
} else {
joinState.set("show_time", action.actionTime)
}
}
log.debug("Set state (KeyHitId: {}, State: {})", hitId, joinState)
stateAccessor.set(joinState.finish())
// [END state_update]
log.debug("Add timer for Key (MaxTime: {}, HitTime: {})", maxTime, hitTime)
output.addTimer(maxTime, hitTime)
// [END timer_setup]
}
// [END on_message]
// [BEGIN on_timer]
override fun onTimer(timer: Timer, output: OutputCollector, ctx: RuntimeContext) {
// Key to pojo conversion is not supported yet.
val hitId = timer.key.get("hit_id", String::class.java)
log.debug("Process timer (KeyHitId: {}, Timer: {})", hitId, timer)
val stateAccessor = ctx.getState(JOINED_ACTION_STATE, timer)
val joinState = stateAccessor.get()
?: throw IllegalStateException("Empty state for timer: $timer")
log.debug("Process state (KeyHitId: {}, State: {})", hitId, joinState)
if ("timer" == timer.streamId) {
// ExternalState to pojo conversion is not supported yet.
if (joinState.get("show_time", Long::class.java) != null &&
joinState.get("hit_payload", String::class.java) != null &&
joinState.get("show_time", Long::class.java) != 0L
) {
log.debug("Click join: {}", joinState)
val joinedAction = JoinedAction()
val hitTime = timer.key.get("hit_time", Long::class.java)
joinedAction.hitId = hitId
joinedAction.hitTime = hitTime
joinedAction.showTime = joinState.get("show_time", Long::class.java)
if (joinState.get("click_time", Long::class.java) != null &&
joinState.get("click_time", Long::class.java) != 0L
) {
joinedAction.isClick = true
joinedAction.clickTime = joinState.get("click_time", Long::class.java)
} else {
joinedAction.isClick = false
joinedAction.clickTime = 0L
}
joinedAction.hitPayload = joinState.get("hit_payload", String::class.java)
output.addMessage(
Message.builder().setStreamId("joined_action").setPayload(joinedAction).build()
)
}
stateAccessor.clear()
} else {
throw IllegalArgumentException("Unknown streamId: ${timer.streamId}")
}
}
Ключевые паттерны
Фильтрация late data
if (message.getEventTimestamp() < ctx.getEpochInputEventWatermark()) {
log.warn("Late message, skip (HitId: {}, EventTimestamp: {}, InputEventWatermark: {})",
hitId, message.getEventTimestamp(), ctx.getEpochInputEventWatermark());
return;
}
if (message.eventTimestamp < ctx.epochInputEventWatermark) {
log.warn(
"Late message, skip (HitId: {}, EventTimestamp: {}, InputEventWatermark: {})",
hitId, message.eventTimestamp, ctx.epochInputEventWatermark
)
return
}
Сообщения, пришедшие с event-временем меньше текущего вотермарка, считаются опоздавшими и игнорируются. Подробнее про вотермарки — в разделе Watermarks & Timers.
Таймеры для закрытия окна
long maxTime = hitTime + waitTime;
// All messages out of wait range would be dropped.
if (message.getEventTimestamp() >= maxTime) {
log.warn("Message is out of wait range, skip (HitId: {}, HitTime: {}, MaxTime: {})",
hitId, hitTime, maxTime);
return;
}
// Drop late data.
// [BEGIN late_data_check]
if (message.getEventTimestamp() < ctx.getEpochInputEventWatermark()) {
log.warn("Late message, skip (HitId: {}, EventTimestamp: {}, InputEventWatermark: {})",
hitId, message.getEventTimestamp(), ctx.getEpochInputEventWatermark());
return;
}
// [END late_data_check]
// [BEGIN state_update]
ExternalStateAccessor stateAccessor = ctx.getState(JOINED_ACTION_STATE, message);
PayloadBuilder joinState = stateAccessor.getOrDefault().toBuilder();
if ("hit".equals(streamId)) {
log.debug("Process hit message (HitId: {}, HitPayload: {}, Hit: {})", hitId, hit.getHitPayload(), hit);
joinState.set("hit_payload", hit.getHitPayload());
} else {
log.debug("Process action message (HitId: {}, ActionTime: {}, Action: {})",
hitId, action.getActionTime(), action);
if (Boolean.TRUE.equals(action.isClick())) {
joinState.set("click_time", action.getActionTime());
} else {
joinState.set("show_time", action.getActionTime());
}
}
log.debug("Set state (KeyHitId: {}, State: {})", hitId, joinState);
stateAccessor.set(joinState.finish());
// [END state_update]
log.debug("Add timer for Key (MaxTime: {}, HitTime: {})", maxTime, hitTime);
output.addTimer(maxTime, hitTime);
val maxTime = hitTime + waitTime
// All messages out of wait range would be dropped.
if (message.eventTimestamp >= maxTime) {
log.warn(
"Message is out of wait range, skip (HitId: {}, HitTime: {}, MaxTime: {})",
hitId, hitTime, maxTime
)
return
}
// Drop late data.
// [BEGIN late_data_check]
if (message.eventTimestamp < ctx.epochInputEventWatermark) {
log.warn(
"Late message, skip (HitId: {}, EventTimestamp: {}, InputEventWatermark: {})",
hitId, message.eventTimestamp, ctx.epochInputEventWatermark
)
return
}
// [END late_data_check]
// [BEGIN state_update]
val stateAccessor = ctx.getState(JOINED_ACTION_STATE, message)
val joinState = stateAccessor.orDefault.toBuilder()
if ("hit" == streamId) {
log.debug(
"Process hit message (HitId: {}, HitPayload: {}, Hit: {})",
hitId, hit!!.hitPayload, hit
)
joinState.set("hit_payload", hit.hitPayload)
} else {
log.debug(
"Process action message (HitId: {}, ActionTime: {}, Action: {})",
hitId, action!!.actionTime, action
)
if (action.isClick() == true) {
joinState.set("click_time", action.actionTime)
} else {
joinState.set("show_time", action.actionTime)
}
}
log.debug("Set state (KeyHitId: {}, State: {})", hitId, joinState)
stateAccessor.set(joinState.finish())
// [END state_update]
log.debug("Add timer for Key (MaxTime: {}, HitTime: {})", maxTime, hitTime)
output.addTimer(maxTime, hitTime)
При получении хита устанавливается таймер на hit_time + wait_for_actions. Таймер сработает, когда вотермарк достигнет maxTime, то есть когда система будет уверена, что все события с event_time < maxTime уже обработаны.
ExternalStateAccessor с PayloadBuilder
Стейт хранится во внешней динамической таблице. PayloadBuilder позволяет обновлять отдельные поля стейта без полной пересериализации:
ExternalStateAccessor stateAccessor = ctx.getState(JOINED_ACTION_STATE, message);
PayloadBuilder joinState = stateAccessor.getOrDefault().toBuilder();
if ("hit".equals(streamId)) {
log.debug("Process hit message (HitId: {}, HitPayload: {}, Hit: {})", hitId, hit.getHitPayload(), hit);
joinState.set("hit_payload", hit.getHitPayload());
} else {
log.debug("Process action message (HitId: {}, ActionTime: {}, Action: {})",
hitId, action.getActionTime(), action);
if (Boolean.TRUE.equals(action.isClick())) {
joinState.set("click_time", action.getActionTime());
} else {
joinState.set("show_time", action.getActionTime());
}
}
log.debug("Set state (KeyHitId: {}, State: {})", hitId, joinState);
stateAccessor.set(joinState.finish());
val stateAccessor = ctx.getState(JOINED_ACTION_STATE, message)
val joinState = stateAccessor.orDefault.toBuilder()
if ("hit" == streamId) {
log.debug(
"Process hit message (HitId: {}, HitPayload: {}, Hit: {})",
hitId, hit!!.hitPayload, hit
)
joinState.set("hit_payload", hit.hitPayload)
} else {
log.debug(
"Process action message (HitId: {}, ActionTime: {}, Action: {})",
hitId, action!!.actionTime, action
)
if (action.isClick() == true) {
joinState.set("click_time", action.actionTime)
} else {
joinState.set("show_time", action.actionTime)
}
}
log.debug("Set state (KeyHitId: {}, State: {})", hitId, joinState)
stateAccessor.set(joinState.finish())
Очистка стейта
После срабатывания таймера и генерации выходного события стейт удаляется через stateAccessor.clear(), чтобы не накапливать устаревшие профили.
Модели данных
Для типизированной работы с сообщениями определены POJO-классы с JPA-аннотациями.
Схемы стримов этого примера выводятся из моделей: в pipeline.yson секции streams нет вовсе — runner подставляет схемы зарегистрированных типизированных стримов сам, как это делают C++ и Go SDK. Порядок полей модели задаёт порядок колонок, а columnDefinition фиксирует тип колонки там, где он отличается от типа по умолчанию для Java-типа: поле String по умолчанию выводится в utf8, поэтому колонки, которые в очередях объявлены как string, помечены columnDefinition = "string".
Если схема стрима всё же задана в спеке, она имеет приоритет — runner ничего не перезаписывает.
Важно
Порядок полей в типизированной модели данных должен совпадать с порядком колонок в определении стрима в статической спеке. Нарушение порядка полей может приводить к сложнодиагностируемым ошибкам.
Если стрим в спеке не описан, порядок колонок задаёт сама модель — тогда следить нужно за совместимостью с очередями и таблицами, в которые пишет пайплайн.
Hit
// The stream schema is derived from this class: the runner writes it into the pipeline spec,
// so "hit" needs no schema of its own there. Field order defines column order, and
// columnDefinition pins a column type where the default for the field type differs.
@Entity
public class Hit {
@Column(name = "hit_id", columnDefinition = "string")
private String hitId;
@Column(name = "hit_time", columnDefinition = "uint64")
private Long hitTime;
@Column(name = "hit_payload", columnDefinition = "string")
private String hitPayload;
public Hit() {
}
public Hit(String hitId, Long hitTime, String hitPayload) {
this.hitId = hitId;
this.hitTime = hitTime;
this.hitPayload = hitPayload;
}
public String getHitId() {
return hitId;
}
public void setHitId(String hitId) {
this.hitId = hitId;
}
public Long getHitTime() {
return hitTime;
}
public void setHitTime(Long hitTime) {
this.hitTime = hitTime;
}
public String getHitPayload() {
return hitPayload;
}
public void setHitPayload(String hitPayload) {
this.hitPayload = hitPayload;
}
@Override
public String toString() {
return "Hit{" +
"hitId='" + hitId + '\'' +
", hitTime=" + hitTime +
", hitPayload='" + hitPayload + '\'' +
'}';
}
}
// The stream schema is derived from this class: the runner writes it into the pipeline spec,
// so "hit" needs no schema of its own there. Field order defines column order, and
// columnDefinition pins a column type where the default for the field type differs.
@Entity
class Hit {
@Column(name = "hit_id", columnDefinition = "string")
var hitId: String? = null
@Column(name = "hit_time", columnDefinition = "uint64")
var hitTime: Long? = null
@Column(name = "hit_payload", columnDefinition = "string")
var hitPayload: String? = null
constructor()
constructor(hitId: String, hitTime: Long, hitPayload: String) {
this.hitId = hitId
this.hitTime = hitTime
this.hitPayload = hitPayload
}
override fun toString(): String {
return "Hit{" +
"hitId='$hitId'" +
", hitTime=$hitTime" +
", hitPayload='$hitPayload'" +
'}'
}
}
Action
// The stream schema is derived from this class: the runner writes it into the pipeline spec,
// so "action" needs no schema of its own there. Field order defines column order, and
// columnDefinition pins a column type where the default for the field type differs.
@Entity
public class Action {
@Column(name = "hit_id", columnDefinition = "string")
private String hitId;
@Column(name = "hit_time", columnDefinition = "uint64")
private Long hitTime;
@Column(name = "is_click")
private Boolean click;
@Column(name = "action_time", columnDefinition = "uint64")
private Long actionTime;
public Action() {
}
public Action(String hitId, Long hitTime, Long actionTime, Boolean isClick) {
this.hitId = hitId;
this.hitTime = hitTime;
this.actionTime = actionTime;
this.click = isClick;
}
public String getHitId() {
return hitId;
}
public void setHitId(String hitId) {
this.hitId = hitId;
}
public Long getHitTime() {
return hitTime;
}
public void setHitTime(Long hitTime) {
this.hitTime = hitTime;
}
public Long getActionTime() {
return actionTime;
}
public void setActionTime(Long actionTime) {
this.actionTime = actionTime;
}
public Boolean isClick() {
return click;
}
public void setIsClick(Boolean click) {
this.click = click;
}
@Override
public String toString() {
return "Action{" +
"hitId='" + hitId + '\'' +
", hitTime=" + hitTime +
", actionTime=" + actionTime +
", click=" + click +
'}';
}
}
// The stream schema is derived from this class: the runner writes it into the pipeline spec,
// so "action" needs no schema of its own there. Field order defines column order, and
// columnDefinition pins a column type where the default for the field type differs.
@Entity
class Action {
@Column(name = "hit_id", columnDefinition = "string")
var hitId: String? = null
@Column(name = "hit_time", columnDefinition = "uint64")
var hitTime: Long? = null
@Column(name = "is_click")
var click: Boolean? = null
@Column(name = "action_time", columnDefinition = "uint64")
var actionTime: Long? = null
constructor()
constructor(hitId: String, hitTime: Long, actionTime: Long, isClick: Boolean) {
this.hitId = hitId
this.hitTime = hitTime
this.actionTime = actionTime
this.click = isClick
}
fun isClick(): Boolean? = click
fun setIsClick(click: Boolean?) {
this.click = click
}
override fun toString(): String {
return "Action{" +
"hitId='$hitId'" +
", hitTime=$hitTime" +
", actionTime=$actionTime" +
", click=$click" +
'}'
}
}
JoinedAction
// The stream schema is derived from this class: the runner writes it into the pipeline spec,
// so "joined_action" needs no schema of its own there. Field order defines column order, and
// columnDefinition pins a column type where the default for the field type differs.
@Entity
public class JoinedAction {
@Column(name = "hit_id", columnDefinition = "string")
private String hitId;
@Column(name = "hit_time", columnDefinition = "uint64")
private Long hitTime;
@Column(name = "is_click")
private Boolean isClick;
@Column(name = "show_time", columnDefinition = "uint64")
private Long showTime;
@Column(name = "click_time", columnDefinition = "uint64")
private Long clickTime;
@Column(name = "hit_payload", columnDefinition = "string")
private String hitPayload;
public JoinedAction() {
}
public JoinedAction(String hitId, Long hitTime, Boolean isClick, Long showTime, Long clickTime, String hitPayload) {
this.hitId = hitId;
this.hitTime = hitTime;
this.isClick = isClick;
this.showTime = showTime;
this.clickTime = clickTime;
this.hitPayload = hitPayload;
}
public String getHitId() {
return hitId;
}
public void setHitId(String hitId) {
this.hitId = hitId;
}
public Long getHitTime() {
return hitTime;
}
public void setHitTime(Long hitTime) {
this.hitTime = hitTime;
}
public Boolean getClick() {
return isClick;
}
public void setClick(Boolean click) {
isClick = click;
}
public Long getShowTime() {
return showTime;
}
public void setShowTime(Long showTime) {
this.showTime = showTime;
}
public Long getClickTime() {
return clickTime;
}
public void setClickTime(Long clickTime) {
this.clickTime = clickTime;
}
public String getHitPayload() {
return hitPayload;
}
public void setHitPayload(String hitPayload) {
this.hitPayload = hitPayload;
}
}
// The stream schema is derived from this class: the runner writes it into the pipeline spec,
// so "joined_action" needs no schema of its own there. Field order defines column order, and
// columnDefinition pins a column type where the default for the field type differs.
@Entity
class JoinedAction {
@Column(name = "hit_id", columnDefinition = "string")
var hitId: String? = null
@Column(name = "hit_time", columnDefinition = "uint64")
var hitTime: Long? = null
@get:JvmName("getClick")
@set:JvmName("setClick")
@Column(name = "is_click")
var isClick: Boolean? = null
@Column(name = "show_time", columnDefinition = "uint64")
var showTime: Long? = null
@Column(name = "click_time", columnDefinition = "uint64")
var clickTime: Long? = null
@Column(name = "hit_payload", columnDefinition = "string")
var hitPayload: String? = null
constructor()
constructor(hitId: String, hitTime: Long, isClick: Boolean, showTime: Long, clickTime: Long, hitPayload: String) {
this.hitId = hitId
this.hitTime = hitTime
this.isClick = isClick
this.showTime = showTime
this.clickTime = clickTime
this.hitPayload = hitPayload
}
}
Конфигурация пайплайна (Spring)
Исходный код (Kotlin)
Пример использует Spring Boot интеграцию для конфигурации. Компьютейшен join регистрируется аннотацией @FlowComputation на классе JoinProcessFunction:
@FlowComputation(id = "join")
@FlowComputation(id = "join")
Типизированные стримы объявляются через ComputationProvider (метод getStreams()):
@Configuration
public class PipelineConfiguration implements ComputationProvider {
@Override
public List<FlowStream<?>> getStreams() {
return List.of(
FlowStreams.typed("hit", Hit.class),
FlowStreams.typed("action", Action.class),
FlowStreams.typed("joined_action", JoinedAction.class)
);
}
}
@Configuration
open class PipelineConfiguration : ComputationProvider {
override fun getStreams(): List<FlowStream<*>> {
return listOf(
FlowStreams.typed("hit", Hit::class.java),
FlowStreams.typed("action", Action::class.java),
FlowStreams.typed("joined_action", JoinedAction::class.java)
)
}
}
Ключевые моменты:
- Компьютейшен
join(JoinProcessFunction) регистрируется аннотацией@FlowComputation(id = "join"). - Регистрируются три типизированных стрима:
hit,actionиjoined_action. - Типизированные стримы позволяют использовать
message.getPayload()для получения POJO-объектов. - Этой регистрации достаточно, чтобы не описывать стримы в статической спеке: их схемы попадают в спеку при запуске.
Точка входа
PipelineMain
Запускает пайплайн или обслуживает его как компаньон — роль выбирается по YT_FLOW_MODE.
public static void main(String[] args) throws Exception {
new SpringApplicationBuilder(PipelineMain.class)
.run(args);
}
@JvmStatic
fun main(args: Array<String>) {
SpringApplicationBuilder(PipelineMain::class.java)
.run(*args)
}
Статическая спека
Computation join
"join" = {
"computation_class_name" = "TJoin";
"group_by_schema" = [
{"name" = "hash"; "expression" = "farm_hash(hit_id)"; "type" = "uint64"};
{"name" = "hit_id"; "type" = "string"};
{"name" = "hit_time"; "type" = "uint64"};
];
"input_stream_ids" = ["action"; "hit"];
"output_stream_ids" = ["joined_action"];
"external_state_managers" = {
"/join-state" = {
"external_state_manager_class_name" = "NYT::NFlow::TSimpleExternalStateManager";
"parameters" = {
"path" = "//path/to/state";
};
};
};
"parameters" = {
"wait_for_actions" = "10s";
};
"timers" = {
"timer" = {};
};
};
group_by_schema— ключ партиционирования:(hash, hit_id, hit_time).input_stream_ids— два входных стрима:actionиhit. Секцииstreamsв спеке нет: схемы этих стримов и стримаjoined_actionвыводятся из моделей данных (см. Модели данных).output_stream_ids— один выходной стрим:joined_action.external_state_managers— top-level секция с описанием External State (на одном уровне сparameters). Ключ ("/join-state") — имя стейта, начинающееся с/; то же имя передаётся в дескрипторStateDescriptors.external("/join-state").external_state_manager_class_name— зарегистрированный класс менеджера (NYT::NFlow::TSimpleExternalStateManagerдля стандартного варианта).parameters/path— путь к динамической таблице YTsaurus; схема ключевых колонок таблицы должна совпадать сgroup_by_schema.parameters.wait_for_actions— время ожидания событий action после хита. Доступен в Java черезRuntimeContext.getComputationParameters().timers.timer— объявление timer stream для закрытия хитов.
Тестирование
Исходный код тестов (Kotlin)
Для тестирования используется TestComputationHarness — тестовая обвязка, позволяющая вызывать doProcess без запуска полного пайплайна.
Настройка тестового окружения
@BeforeEach
void init() throws IOException {
var pipelineContext = new PipelineContext();
Computation join = Computation.builder()
.setComputationId("join")
.setProcessFunction(new JoinProcessFunction())
.build();
pipelineContext.registerComputation(join);
pipelineContext.registerStream(FlowStreams.typed("hit", Hit.class));
pipelineContext.registerStream(FlowStreams.typed("action", Action.class));
pipelineContext.registerStream(FlowStreams.typed("joined_action", JoinedAction.class));
// External state schema for "/join-state"
this.joinStateSchema = TableSchema.builder()
.addValue("hit_payload", ColumnValueType.STRING)
.addValue("show_time", ColumnValueType.UINT64)
.addValue("click_time", ColumnValueType.UINT64)
.build();
// The pipeline spec is packaged as a classpath resource in the app module's
// src/main/resources, so the test reads the single source of truth directly.
try (var specStream = getClass().getResourceAsStream("/pipeline.yson")) {
YTreeNode spec = YTreeTextSerializer.deserialize(specStream);
this.testHarness = TestComputationHarness.builder()
.setPipelineContext(pipelineContext)
.setPipelineSpec(spec)
.addExternalStateSchema("/join-state", joinStateSchema)
.build();
}
}
@BeforeEach
fun init() {
val pipelineContext = PipelineContext()
val join = Computation.builder()
.setComputationId("join")
.setProcessFunction(JoinProcessFunction())
.build()
pipelineContext.registerComputation(join)
pipelineContext.registerStream(FlowStreams.typed("hit", Hit::class.java))
pipelineContext.registerStream(FlowStreams.typed("action", Action::class.java))
pipelineContext.registerStream(FlowStreams.typed("joined_action", JoinedAction::class.java))
// External state schema for "/join-state"
this.joinStateSchema = TableSchema.builder()
.addValue("hit_payload", ColumnValueType.STRING)
.addValue("show_time", ColumnValueType.UINT64)
.addValue("click_time", ColumnValueType.UINT64)
.build()
// The pipeline spec is packaged as a classpath resource in the app module's
// src/main/resources, so the test reads the single source of truth directly.
javaClass.getResourceAsStream("/pipeline.yson")!!.use { specStream ->
val spec = YTreeTextSerializer.deserialize(specStream)
this.testHarness = TestComputationHarness.builder()
.setPipelineContext(pipelineContext)
.setPipelineSpec(spec)
.addExternalStateSchema("/join-state", joinStateSchema)
.build()
}
}
Тест обработки hit-сообщения
@Test
void testHitMessageStoresHitPayloadInState() {
String hitId = "hit-1";
long hitTime = BASE_HIT_TIME;
var messages = List.of(buildHitMessage(hitId, hitTime, "payload-data"));
var request = TestDoProcessRequest.builder("join")
.setMessages(messages)
.setWatermarks(defaultWatermarks())
.build();
var response = testHarness.doProcess(request);
// No output messages yet — timer is scheduled, output comes on timer fire
assertTrue(response.getOutputMessagesFlatten().isEmpty());
// A timer must be scheduled
assertEquals(1, response.getOutputTimersFlatten().size());
var timer = response.getOutputTimersFlatten().getFirst();
assertEquals(hitTime + WAIT_SECONDS, timer.getTriggerTimestamp());
// External state must be updated with hit_payload
assertEquals(1, response.allStates().externalSize("/join-state"));
Payload key = buildKey(hitId, hitTime);
var state = response.allStates().get(JoinProcessFunction.JOINED_ACTION_STATE, key).get();
assertEquals("payload-data", state.get("hit_payload", String.class));
}
@Test
fun testHitMessageStoresHitPayloadInState() {
val hitId = "hit-1"
val hitTime = BASE_HIT_TIME
val messages = listOf(buildHitMessage(hitId, hitTime, "payload-data"))
val request = TestDoProcessRequest.builder("join")
.setMessages(messages)
.setWatermarks(defaultWatermarks())
.build()
val response = testHarness.doProcess(request)
// No output messages yet — timer is scheduled, output comes on timer fire
assertTrue(response.outputMessagesFlatten.isEmpty())
// A timer must be scheduled
assertEquals(1, response.outputTimersFlatten.size)
val timer = response.outputTimersFlatten.first()
assertEquals(hitTime + WAIT_SECONDS, timer.triggerTimestamp)
// External state must be updated with hit_payload
assertEquals(1, response.allStates().externalSize("/join-state"))
val key = buildKey(hitId, hitTime)
val state = response.allStates().get(JoinProcessFunction.JOINED_ACTION_STATE, key).get()!!
assertEquals("payload-data", state.get("hit_payload", String::class.java))
}
Тест полного цикла джойна
@Test
void testFullJoinFlow_HitThenShowThenClickThenTimer() {
String hitId = "hit-20";
long hitTime = BASE_HIT_TIME;
long showTime = hitTime + 3L;
long clickTime = hitTime + 7L;
// Step 1: process hit message
var hitMessages = List.of(buildHitMessage(hitId, hitTime, "full-payload"));
var hitRequest = TestDoProcessRequest.builder("join")
.setMessages(hitMessages)
.setWatermarks(defaultWatermarks())
.build();
var hitResponse = testHarness.doProcess(hitRequest);
assertTrue(hitResponse.getOutputMessagesFlatten().isEmpty());
assertEquals(1, hitResponse.getOutputTimersFlatten().size());
assertEquals(1, hitResponse.allStates().externalSize("/join-state"));
Payload key = buildKey(hitId, hitTime);
Payload stateAfterHit = hitResponse.allStates()
.get(JoinProcessFunction.JOINED_ACTION_STATE, key).get();
assertEquals("full-payload", stateAfterHit.get("hit_payload", String.class));
// Step 2: process show action message, passing the state from step 1
var showMessages = List.of(buildActionMessage(hitId, hitTime, showTime, false, hitTime + 1L));
var showRequest = TestDoProcessRequest.builder("join")
.setMessages(showMessages)
.setState(JoinProcessFunction.JOINED_ACTION_STATE, key, stateAfterHit)
.setWatermarks(defaultWatermarks())
.build();
var showResponse = testHarness.doProcess(showRequest);
assertTrue(showResponse.getOutputMessagesFlatten().isEmpty());
Payload stateAfterShow = showResponse.allStates().get(JoinProcessFunction.JOINED_ACTION_STATE, key)
.get();
assertEquals("full-payload", stateAfterShow.get("hit_payload", String.class));
assertEquals(showTime, stateAfterShow.get("show_time", Long.class));
// Step 3: process click action message, passing the state from step 2
var clickMessages = List.of(buildActionMessage(hitId, hitTime, clickTime, true, hitTime + 2L));
var clickRequest = TestDoProcessRequest.builder("join")
.setMessages(clickMessages)
.setState(JoinProcessFunction.JOINED_ACTION_STATE, key, stateAfterShow)
.setWatermarks(defaultWatermarks())
.build();
var clickResponse = testHarness.doProcess(clickRequest);
assertTrue(clickResponse.getOutputMessagesFlatten().isEmpty());
Payload stateAfterClick = clickResponse.allStates().get(JoinProcessFunction.JOINED_ACTION_STATE, key)
.get();
assertEquals(clickTime, stateAfterClick.get("click_time", Long.class));
// Step 4: fire the timer with the accumulated state
Timer timer = buildTimer(hitId, hitTime);
var timerRequest = TestDoProcessRequest.builder("join")
.setTimers(List.of(timer))
.setState(JoinProcessFunction.JOINED_ACTION_STATE, key, stateAfterClick)
.build();
var timerResponse = testHarness.doProcess(timerRequest);
assertEquals(1, timerResponse.getOutputMessagesFlatten().size());
JoinedAction joinedAction = timerResponse.getOutputMessagesFlatten().getFirst().getPayload();
assertEquals(hitId, joinedAction.getHitId());
assertEquals(hitTime, joinedAction.getHitTime());
assertEquals("full-payload", joinedAction.getHitPayload());
assertEquals(showTime, joinedAction.getShowTime());
assertTrue(joinedAction.getClick());
assertEquals(clickTime, joinedAction.getClickTime());
assertNull(timerResponse.allStates().get(JoinProcessFunction.JOINED_ACTION_STATE, key).get());
}
@Test
fun testFullJoinFlow_HitThenShowThenClickThenTimer() {
val hitId = "hit-20"
val hitTime = BASE_HIT_TIME
val showTime = hitTime + 3L
val clickTime = hitTime + 7L
// Step 1: process hit message
val hitMessages = listOf(buildHitMessage(hitId, hitTime, "full-payload"))
val hitRequest = TestDoProcessRequest.builder("join")
.setMessages(hitMessages)
.setWatermarks(defaultWatermarks())
.build()
val hitResponse = testHarness.doProcess(hitRequest)
assertTrue(hitResponse.outputMessagesFlatten.isEmpty())
assertEquals(1, hitResponse.outputTimersFlatten.size)
assertEquals(1, hitResponse.allStates().externalSize("/join-state"))
val key = buildKey(hitId, hitTime)
val stateAfterHit = hitResponse.allStates()
.get(JoinProcessFunction.JOINED_ACTION_STATE, key).get()!!
assertEquals("full-payload", stateAfterHit.get("hit_payload", String::class.java))
// Step 2: process show action message, passing the state from step 1
val showMessages = listOf(buildActionMessage(hitId, hitTime, showTime, false, hitTime + 1L))
val showRequest = TestDoProcessRequest.builder("join")
.setMessages(showMessages)
.setState(JoinProcessFunction.JOINED_ACTION_STATE, key, stateAfterHit)
.setWatermarks(defaultWatermarks())
.build()
val showResponse = testHarness.doProcess(showRequest)
assertTrue(showResponse.outputMessagesFlatten.isEmpty())
val stateAfterShow = showResponse.allStates()
.get(JoinProcessFunction.JOINED_ACTION_STATE, key).get()!!
assertEquals("full-payload", stateAfterShow.get("hit_payload", String::class.java))
assertEquals(showTime, stateAfterShow.get("show_time", Long::class.java))
// Step 3: process click action message, passing the state from step 2
val clickMessages = listOf(buildActionMessage(hitId, hitTime, clickTime, true, hitTime + 2L))
val clickRequest = TestDoProcessRequest.builder("join")
.setMessages(clickMessages)
.setState(JoinProcessFunction.JOINED_ACTION_STATE, key, stateAfterShow)
.setWatermarks(defaultWatermarks())
.build()
val clickResponse = testHarness.doProcess(clickRequest)
assertTrue(clickResponse.outputMessagesFlatten.isEmpty())
val stateAfterClick = clickResponse.allStates()
.get(JoinProcessFunction.JOINED_ACTION_STATE, key).get()!!
assertEquals(clickTime, stateAfterClick.get("click_time", Long::class.java))
// Step 4: fire the timer with the accumulated state
val timer = buildTimer(hitId, hitTime)
val timerRequest = TestDoProcessRequest.builder("join")
.setTimers(listOf(timer))
.setState(JoinProcessFunction.JOINED_ACTION_STATE, key, stateAfterClick)
.build()
val timerResponse = testHarness.doProcess(timerRequest)
assertEquals(1, timerResponse.outputMessagesFlatten.size)
val joinedAction = timerResponse.outputMessagesFlatten.first().getPayload<JoinedAction>()
assertEquals(hitId, joinedAction.hitId)
assertEquals(hitTime, joinedAction.hitTime)
assertEquals("full-payload", joinedAction.hitPayload)
assertEquals(showTime, joinedAction.showTime)
assertTrue(joinedAction.isClick!!)
assertEquals(clickTime, joinedAction.clickTime)
assertNull(timerResponse.allStates().get(JoinProcessFunction.JOINED_ACTION_STATE, key).get())
}
Полный набор тестов (9 сценариев) — в исходном коде.
Интеграционное тестирование
Интеграционное тестирование Java-пайплайнов выполняется так же, как и для C++ пайплайнов — с запуском полного пайплайна, включая C++ воркеры, очереди и стримы. Подробнее см. Интеграционные тесты.
Ключевые идеи
-
Временное окно: Система ждёт события action в течение
wait_for_actionsсекунд послеhit_time. Все события вне этого окна отбрасываются. -
Watermark для late data: События с
event_timestamp < watermarkсчитаются опоздавшими и отбрасываются. Это неизбежно приводит к потере некоторых данных, но обеспечивает корректность watermark. -
External State для накопления данных: Промежуточные данные (hit_payload, show_time, click_time) хранятся в External State, привязанном к ключу
(hit_id, hit_time). В данном случае нет ограничений на использование только External State, реализация с Internal State также возможна. -
Таймер для закрытия окна: Таймер с
triggerTimestamp = hit_time + wait_for_actionsобеспечивает генерацию результата после закрытия окна ожидания. -
Идемпотентность таймеров: Таймер устанавливается при каждом сообщении, но с одним и тем же
triggerTimestamp. Flow дедуплицирует таймеры с одинаковым ключом иtriggerTimestamp. -
Очистка стейта: После генерации результата стейт очищается через
stateAccessor.clear(), что удаляет строку из таблицы.