Wait Click Join in YTsaurus Flow (Go)
An example of a join pipeline that joins the streams of impressions (hit) and actions (action) by a shared key, using timers and an external state. This is a Go implementation of the similar C++ example.
Structure
The pipeline consists of three computations:
hit_readerandaction_reader— native sources (TSwiftPassthroughOrderedSourceComputation) declared directly in the spec: they read two queues and publish thehitandactionstreams. They have no Go code. Each source declares awatermark_strategy, so the watermark of the pipeline is computed from the event time.join(joinFunction) — a transform computation served by the companion.
The join computation works as follows:
- It receives messages from two input streams:
hitandaction. - It accumulates the window of one impression in the external state
/join-state. - It sets a timer for the moment the waiting window closes.
- When the timer fires, it publishes the join result to the
joined_actionstream or simply clears the state.
Both input streams are grouped by hit_id and hit_time, so everything that belongs to one impression falls into one key. The length of the window is set by the wait_for_actions computation parameter from the spec.
main.go
The entry point: creating the pipeline, registering the only computation, and starting it.
type actionMessage struct {
flow.YSONMessage
HitID string `yson:"hit_id"`
HitTime uint64 `yson:"hit_time"`
IsClick bool `yson:"is_click"`
ActionTime uint64 `yson:"action_time"`
}
type hitMessage struct {
flow.YSONMessage
HitID string `yson:"hit_id"`
HitTime uint64 `yson:"hit_time"`
HitPayload string `yson:"hit_payload"`
}
type joinedActionMessage struct {
flow.YSONMessage
HitID string `yson:"hit_id"`
HitTime uint64 `yson:"hit_time"`
IsClick bool `yson:"is_click"`
ShowTime uint64 `yson:"show_time"`
ClickTime uint64 `yson:"click_time"`
HitPayload string `yson:"hit_payload"`
}
func main() {
pipeline := flow.NewPipeline()
pipeline.AddStreams(
flow.NewYSONStream[actionMessage](actionStream),
flow.NewYSONStream[hitMessage](hitStream),
flow.NewYSONStream[joinedActionMessage](joinedStream),
)
pipeline.Add(flow.NewRowComputation("join", &joinFunction{}))
if err := pipeline.Run(); err != nil {
fmt.Fprintf(os.Stderr, "wait_click_join: %v\n", err)
os.Exit(1)
}
}
join_function.go
The main logic of the join: the function handles two streams and uses timers for window aggregation.
OnMessage
It drops late messages, appends its own field to the impression window, and sets the timer for closing the window:
type joinFunction struct{}
var (
_ flow.RowFunction = (*joinFunction)(nil)
_ flow.RowTimerFunction = (*joinFunction)(nil)
)
func (*joinFunction) OnMessage(
ctx context.Context,
rt flow.Runtime,
msg flow.ExtendedMessage,
out flow.OutputCollector,
) error {
var hit *hitMessage
var action *actionMessage
var hitTime uint64
switch msg.StreamID {
case hitStream:
hit = &hitMessage{}
if err := msg.ConvertTo(hit); err != nil {
return err
}
hitTime = hit.HitTime
case actionStream:
action = &actionMessage{}
if err := msg.ConvertTo(action); err != nil {
return err
}
hitTime = action.HitTime
default:
return fmt.Errorf("unhandled stream %q", msg.StreamID)
}
wait, err := waitForActions(rt)
if err != nil {
return err
}
closeTime := hitTime + wait
if msg.EventTimestamp >= closeTime || msg.EventTimestamp < rt.MinWatermark() {
return nil
}
state, err := flow.OpenExternalState(rt, joinStateName, msg)
if err != nil {
return err
}
var window joinState
if _, err := state.ConvertTo(&window); err != nil {
return err
}
if hit != nil {
window.HitPayload = &hit.HitPayload
} else if action.IsClick {
window.ClickTime = &action.ActionTime
} else {
window.ShowTime = &action.ActionTime
}
if err := state.ConvertFrom(&window); err != nil {
return err
}
out.AddTimer(flow.TimerRequest{TriggerTimestamp: closeTime, EventTimestamp: hitTime})
return nil
}
OnTimer
It closes the window: it publishes the join result if both sides arrived, and clears the state in any case:
func (*joinFunction) OnTimer(
ctx context.Context,
rt flow.Runtime,
timer flow.Timer,
out flow.OutputCollector,
) error {
if timer.StreamID != timerStream {
return fmt.Errorf("unhandled timer stream %q", timer.StreamID)
}
state, err := flow.OpenExternalState(rt, joinStateName, timer)
if err != nil {
return err
}
var window joinState
if _, err := state.ConvertTo(&window); err != nil {
return err
}
if window.ShowTime != nil && *window.ShowTime != 0 && window.HitPayload != nil {
var key joinKey
if err := timer.Key.ConvertTo(&key); err != nil {
return err
}
joined, err := joinedAction(rt, key, window)
if err != nil {
return err
}
out.AddMessage(joined)
}
return state.Clear()
}
Key patterns
- External state through
flow.OpenExternalState(rt, joinStateName, msg):ConvertToreads the window intojoinState, the message changes the field it needs in the structure, andConvertFromsaves the window. - A timer with two timestamps:
out.AddTimer(flow.TimerRequest{TriggerTimestamp: closeTime, EventTimestamp: hitTime})—TriggerTimestampsets when the window will close,EventTimestampties the timer to the event. Messages from both streams set the timer, not onlyhit: an action can arrive earlier than the impression it belongs to, and the window still has to be closed. A message that already falls outside its window or behind the watermark returns early and sets no timer. - Filtering out late data:
msg.EventTimestamp < rt.MinWatermark()drops the messages of already closed windows — otherwise a second join result would be published for one impression. For details, see watermarks. - Computation parameters:
rt.Parameters().Get(waitForActionsParameter, &spelled)reads the window length from the spec instead of hardcoding it. - Handling several input streams: the branch on
msg.StreamIDsplits the logic forhitandaction, and an unknown stream returns an error instead of a silent skip. - The key instead of the state:
timer.Keycarrieshit_idandhit_timefromgroup_by_schema, so the window holds only the data being joined — there is no need to duplicate the key in it.