Skip to content
About
Main content

Stream-table joins and enrichment

Which enrichment option is correct, and why does correctness need a temporal join on a dimension stream?

Stream-table joins: enrichment and async I/O

Enrichment means adding fields from a slowly changing reference table (here, restaurant data) to each stream record. It is a different problem from joining two streams. There is a clear ladder of implementations.

Option 1: per-record remote lookup

The simplest option: for each record, call a remote service or database. It caps at concurrency ÷ RTT. That is in disguise.

The fix is : allow N requests in flight at the same time. Throughput is multiplied by N until the remote service becomes the bottleneck. With N = 50: 50 ÷ 0.005 = 10,000 records/s per task.

Async brings a choice:

  • Unordered completion gives maximum throughput, and breaks per-key ordering: a later record may be emitted before an earlier one of the same key.
  • Ordered completion keeps order by buffering finished results until all earlier ones finish. That brings back head-of-line blocking (one slow item blocks all items behind it), and the in-flight buffer becomes checkpointed state.

Further points:

  • A cache in front of the lookup adds the problem of cache coherence, including stale reads.
  • The remote table may have changed between the first run and the . So this option is incompatible with exactly-once state semantics (Exactly-once).

Option 2: broadcast the table

Send the whole table into every instance's . This is deterministic and has no RTT. The memory cost is bounded by table size × parallelism. It is fine for a few MB, and useless at a few GB.

Option 3: co-partition the table as a keyed changelog stream

Consume the table's CDC feed (every insert, update and delete) with the same key as the fact stream, and hold it as . This scales and is deterministic. It forces the question that everyone skips:

Joining against whatever is currently in state is a . It is non-reproducible: replay the same input tomorrow and you may get different output, because the dimension rows changed in between. That is Ana's third symptom (the re-run with different numbers). Her first run used the commission rate that was current at that moment. The re-run used today's rates.

A temporal join uses the dimension version that was valid at the fact's . This requires:

  • retaining multiple versions per key in state, and
  • a on the dimension stream too, so the job knows when it has seen all versions up to the fact's time.

This is the bitemporal model (data has both a valid time and a recorded time) materialized as operator state. It is the correct but expensive option.

Two side effects of the correct option

  1. The dimension stream's watermark now gates the fact stream. An idle dimension (restaurants change rarely!) stalls the whole job, unless idleness is handled explicitly (idle channels).
  2. Point-in-time correctness here is the same mechanism as preventing training-serving skew in machine-learning feature stores. (That is the problem where a model is trained on feature values as they were, but served with values as they are now, or the reverse.) Same problem, different vocabulary.

The ladder in one table

OptionThroughput limitState costDeterministic on replay?Main danger
Per-record lookupconcurrency ÷ RTTnonenoRTT cap, non-determinism
Async I/O + cacheN × (1 ÷ RTT), remote capacityin-flight buffernoordering choice, staleness
Broadcast tablelocaltable size × parallelismyestoo big for large tables
as keyed state (processing-time)localtable sizeno (version is "now")non-reproducible
localmany versions per keyyessecond watermark, idle-source stalls