Wait Click Join in YTsaurus Flow (Java)
The pipeline performs a join of streams of hits and actions within a time window. For each hit, the system waits for related actions (show, click) within a specified time interval, then generates a merged event.
Source code (Kotlin)
For a detailed description of the task and join logic, see the C++ version description.
Problem statement
You have a system with three types of events:
- hit — a user request event. It contains
hit_id,hit_time, andhit_payload. - action show — an event indicating that the user saw the response. It contains
hit_id,hit_time,action_time, andis_click = false. - action click — an event indicating that the user clicked the response. It contains
hit_id,hit_time,action_time, andis_click = true.
Data arrives in two streams: hit and action.
The output produces a join: for each show, you attach information about whether a click occurred and the hit_payload from the hit. Hits without shows are ignored. The system waits for action events for wait_for_actions seconds after hit_time.
Data flow diagram
JoinProcessFunction
The core pipeline logic is implemented in JoinProcessFunction, which processes two streams — hit and 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}")
}
}
Key patterns
Late data filtering
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
}
Messages that arrive with an event time less than the current watermark are considered late and are ignored. For more details about watermarks, see the Watermarks & Timers section.
Timers for closing the window
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)
When you receive a hit, you set a timer for hit_time + wait_for_actions. The timer triggers when the watermark reaches maxTime, meaning the system is sure that all events with event_time < maxTime have been processed.
ExternalStateAccessor with PayloadBuilder
The state is stored in an external dynamic table. PayloadBuilder lets you update individual state fields without full re-serialization:
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())
State cleanup
After the timer fires and the output event is generated, you clear the state using stateAccessor.clear() to avoid accumulating outdated profiles.
Data models
POJO classes with JPA annotations are defined for typed message handling.
Warning
You must ensure that the order of fields in the typed data model matches the order of columns in the stream definition in the static spec. If you break the field order, it can lead to hard-to-diagnose errors.
If the stream is not described in the static spec, the typed model sets the column order. Keep that order compatible with the output queues and tables.
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
}
}
Pipeline configuration (Spring)
Source code (Kotlin)
The example uses the Spring Boot integration for configuration. The join computation is registered with the @FlowComputation annotation on the JoinProcessFunction class:
@FlowComputation(id = "join")
@FlowComputation(id = "join")
Typed streams are declared via ComputationProvider (the getStreams() method):
@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)
)
}
}
Key points:
- The
joincomputation (JoinProcessFunction) is registered with the@FlowComputation(id = "join")annotation. - You register three typed streams:
hit,action, andjoined_action. - Typed streams let you use
message.getPayload()to retrieve POJO objects.
Entry points
PipelineMain
This single entry point starts the pipeline or serves as its companion, depending on 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)
}
Static spec
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_schemais the partitioning key:(hash, hit_id, hit_time).input_stream_idsare two input streams:actionandhit.output_stream_idsis one output stream:joined_action.external_state_managersis a top-level section that describes External State (on the same level asparameters). The key ("/join-state") is the state name that starts with/; you pass the same name to theStateDescriptors.external("/join-state")descriptor.external_state_manager_class_nameis the registered manager class (NYT::NFlow::TSimpleExternalStateManagerfor the standard option).parameters/pathis the path to the YTsaurus dynamic table; the key column schema of the table must matchgroup_by_schema.parameters.wait_for_actionsis the time you wait for action events after a hit. You can access it in Java viaRuntimeContext.getComputationParameters().timers.timerdeclares the timer stream for closing hits.
Testing
You use TestComputationHarness for testing. It’s a test harness that lets you call doProcess without running the full pipeline.
Set up the test environment
@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()
}
}
Test hit message processing
@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 the full join flow
@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())
}
The full test suite (9 scenarios) is in the source code.
Integration testing
You run integration testing for Java pipelines the same way as for C++ pipelines: by launching the full pipeline, including C++ workers, queues, and streams. For details, see Integration tests.
Key ideas
-
Time window: The system waits for action events for
wait_for_actionsseconds afterhit_time. You discard all events outside this window. -
Watermark for late data: Events with
event_timestamp < watermarkare considered late and are discarded. This inevitably leads to some data loss, but it ensures watermark correctness. -
External State for data accumulation: You store intermediate data (hit_payload, show_time, click_time) in External State, which is bound to the key
(hit_id, hit_time). In this case, you’re not limited to using only External State; an implementation with Internal State is also possible. -
Timer for closing the window: A timer with
triggerTimestamp = hit_time + wait_for_actionsensures result generation after the wait window closes. -
Idempotency of timers: You set the timer on every message, but with the same
triggerTimestamp. Flow deduplicates timers with the same key andtriggerTimestamp. -
State cleanup: After you generate the result, you clear the state via
stateAccessor.clear(), which removes the row from the table.