Streaming Merge Join with Missing Keys

Coding · Medium · Free problem

Two streaming data sources are producing tuples in near-real-time:

  • Stream A (temperature): $(t, \text{loc}, \text{temp})$ -- temperature readings tagged by timestamp and location.
  • Stream B (humidity): $(t, \text{loc}, \text{humid})$ -- humidity readings tagged by timestamp and location.

Both streams are *roughly* sorted by time, but events can arrive late or out of order, and either stream may be missing entries for certain $(t, \text{loc})$ pairs. Your job is to produce a joined output stream keyed by $(t, \text{loc})$ that contains the best-available temperature and humidity values for each key.

Implement the function:

``` merge_join(temp_events, humid_events) ```

Inputs. temp_events and humid_events are each a list of [t, loc, value] events (temperature values and humidity values respectively). Events may repeat a (t, loc) key and may be unsorted.

Output. Return a list of joined records [t, loc, temp, humid], one per distinct (t, loc) key that appears in either stream, sorted by (loc, t).

Value rule (last-value-carried-forward). For a given key (t, loc), each side's value is the value of the most recent event at that loc with event_t <= t. If several events at that loc share the same latest timestamp, the last one in input order wins. If no event with event_t <= t exists on a side, use None (null) for that side.

Design considerations to keep in mind: what data structures buffer/index events per location, how missing keys are filled, how late/out-of-order arrivals are handled, and the time/space complexity of the windowed state.

Example

merge_join([[1, "A", 20.0], [3, "A", 22.0]], [[1, "A", 50.0], [2, "A", 55.0]]) -> [[1, "A", 20.0, 50.0], [2, "A", 20.0, 55.0], [3, "A", 22.0, 55.0]]

The key (2, "A") appears only in the humidity stream, so its temperature is carried forward from t=1 (20.0); likewise (3, "A") carries humidity forward from t=2 (55.0).

Hints

  1. Think about what data structure lets you efficiently look up and insert events keyed by location and time -- you need fast ordered access within each location.
  2. The core problem is deciding *when* to emit a joined record. Look into watermarking: a threshold timestamp below which you declare all data has arrived, so you can safely flush and evict.
  3. For missing keys, consider last-value-carried-forward (LVCF) -- when one stream has no entry at a given $(t, \text{loc})$, carry forward the most recent prior value from that stream using binary search on your sorted buffer.

Worked Solution

How to Think About It: This is a classic stream-processing / merge-join problem -- the kind you would face building a real-time data pipeline for a trading floor (think merging a market-data feed with an order feed). The core tension is between *completeness* and *latency*: you want to join on $(t, \text{loc})$, but one side might be missing or late. The offline formulation here fixes the fill strategy: last-value-carried-forward (LVCF) per location.

The contract precisely. The output has one record per distinct (t, loc) key appearing in either stream. For each key and each side, the value is the most recent event at that location with event_t <= t (ties on the same timestamp resolved by input order -- the last event wins), or None if no such event exists. Records are sorted by (loc, t). Note the value is carried forward *within a side* too: if a location has temperature events at t=1 and t=3, then a humidity-only key at t=2 reports the t=1 temperature.

Algorithm:

  1. Index each stream as loc -> list of (event_t, order, value) and sort each list by (event_t, order). Recording the arrival order makes tie-breaking (last write wins at a timestamp) deterministic.
  2. Collect the set of output keys as the union of (loc, t) across both streams.
  3. For each key (loc, t), scan that location's sorted list and take the value of the latest entry with event_t <= t (LVCF); this naturally yields the last-in-input value at the maximal qualifying timestamp. Missing side -> None.
  4. Emit [t, loc, temp, humid] for every key, sorted by (loc, t).

Code:

```python def merge_join(temp_events, humid_events): # Join two event streams on (t, loc) with last-value-carried-forward fill. # Output keys = union of (t, loc) across both streams (deduplicated). # For each key, each side's value is the most recent event at that loc with # event_t <= t (ties broken by input order: the last-arriving event wins), # or None if no such event exists. Records sorted by (loc, t).

def build(events): # loc -> list of (event_t, order, value), sorted by (event_t, order) d = {} for order, (t, loc, val) in enumerate(events): d.setdefault(loc, []).append((t, order, val)) for loc in d: d[loc].sort() return d

temp_d = build(temp_events) humid_d = build(humid_events)

keys = set() for t, loc, _ in temp_events: keys.add((loc, t)) for t, loc, _ in humid_events: keys.add((loc, t))

def lvcf(d, loc, t): lst = d.get(loc) if not lst: return None best = None for event_t, _order, val in lst: # sorted ascending by (event_t, order) if event_t <= t: best = val else: break return best

result = [] for loc, t in sorted(keys): tv = lvcf(temp_d, loc, t) hv = lvcf(humid_d, loc, t) result.append([t, loc, tv, hv]) return result

```

Practical Considerations:

  • Watermarking (streaming variant): in a true streaming system you would advance a watermark $W$ = min of the two streams' latest timestamps minus a slack factor, emit joins for keys with $t \le W$, and evict state older than $W - \text{grace}$. Flink and Kafka Streams call this "event-time processing with allowed lateness." The offline function above assumes all events are already in hand, so it can compute every LVCF fill exactly.
  • Ties / duplicates: multiple events at the same (t, loc) are resolved last-write-wins via the recorded input order.
  • LVCF vs. interpolation: LVCF is simple and conservative. For continuous signals, linear interpolation between the two nearest timestamps may be more accurate but adds complexity.

Complexity:

  • Building and sorting the indexes: $O(N \log N)$ where $N$ is the number of events.
  • The LVCF scan shown is linear per key for clarity; with bisect on the sorted per-location list it is $O(\log n)$ per lookup, giving $O((K) \log n)$ over $K$ output keys.
  • Space: $O(N)$ for the buffered per-location state.

Intuition

This problem captures the fundamental tension in any real-time data join: you cannot wait forever for the missing side of a join, but emitting too early means incomplete data. In quant finance, this exact pattern appears when merging market data feeds (trade prices from one source, quotes from another, both keyed by symbol and timestamp). The watermark is the mechanism that converts an unbounded waiting problem into a bounded one -- it is the system's promise that "I will tolerate up to X seconds of lateness, and after that I move on." The grace period is a business decision, not a technical one: low latency (small grace) means more incomplete joins; high completeness (large grace) means more memory and higher latency.

The subtlety most people miss is the fill strategy for genuinely missing data. LVCF (carrying the last known value forward) is the standard choice in time-series joins because sensor readings are typically persistent -- if the temperature was 72 degrees at 10:00 and you have no reading at 10:01, 72 is a reasonable fill. But this assumption breaks down for event-driven data (like trades), where absence of an event is meaningful. Choosing the right fill strategy requires understanding the semantics of the data, not just the mechanics of the join.

Open the full interactive solver →