From d71d4607968c9c214f35bfb5579dfcf6ec1ea547 Mon Sep 17 00:00:00 2001 From: Esteban Zimanyi Date: Thu, 21 May 2026 13:33:34 +0200 Subject: [PATCH] feat(wirings): cross-stream tier DataStream wiring + runnable demo (completes the 4-tier matrix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds MeosCrossStreamJoin — the fourth and final tier-wiring class in the org.mobilitydb.flink.meos.wirings package, stacked on PR #8 (windowed wirings). Cross-stream is the smallest streamable tier (140 of 2,097 emitted methods, ~7%) — pairwise across two pre-keyed streams, time-bounded match window. Canonical examples: spatial-relations between two trajectories (edwithin_tgeo_tgeo, eintersects_tgeo_tgeo), distance on two temporals (nad_tgeo_tgeo, mindistance_tgeo_tgeo). ## Design Wraps any cross-stream MeosOps call as a ProcessJoinFunction — the operator backing KeyedStream.intervalJoin(other). Both streams must be pre-keyed by the same K; only events sharing a key are considered for pairing. The .between(lowerBound, upperBound) declaration bounds the time window for match-eligibility, and matches are emitted event-time-aware (watermark-driven). The adopter-facing signature keeps the slim ContextLike-pattern used in MeosWindowedAggregate: the lambda receives the matched (left, right) pair and a slim Context exposing left/right timestamps (the bits a MEOS cross-stream call typically needs), free of Flink internals. ## Files - MeosCrossStreamJoin.java — the generic wiring class - demo/MeosCrossStreamDemoJob.java — runnable interval-join demo matching two streams of (regionId, vehicleId, tboxWKT, ts) on shared regionId key within ±1 minute; emits per-pair overlap events via MeosOpsFreeCore.overlaps_tbox_tbox - README — cross-stream row marked ✅ shipped ## Completes the 4-tier wiring matrix After this PR, every streamable tier in the v4 baseline has a generic wiring class in this package: stateless 804 methods → MeosStatelessMap / MeosStatelessFilter (PR #6) bounded-state 797 methods → MeosBoundedStateMap (PR #7) windowed 161 methods → MeosWindowedAggregate (PR #8) cross-stream 140 methods → MeosCrossStreamJoin (THIS PR) io-meta 195 methods → covered by MeosStatelessMap sequence-only 14 methods → inherently non-streamable Total: 2,097 of 2,097 = 100% of streamable + io-meta generated MeosOps* methods are wirable through 4 (+ 1 filter sibling) generic classes; no per-method registration; adopters provide a serializable lambda per use site. ## Stacks on PR #8 Additive-only; touches no existing file beyond the README row. Locally compile-verified: 145 .class files total (140 from PR #8 base + 5 new — 1 wiring class + 2 nested lambda interfaces + 1 anonymous ContextLike + 1 demo class). (cherry picked from commit 10a03b6d0bc206e1c057ef2ca018c6d94ce7ebfc) --- .../meos/wirings/MeosCrossStreamJoin.java | 115 +++++++++++++++ .../mobilitydb/flink/meos/wirings/README.md | 2 +- .../wirings/demo/MeosCrossStreamDemoJob.java | 135 ++++++++++++++++++ 3 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 flink-processor/src/main/java/org/mobilitydb/flink/meos/wirings/MeosCrossStreamJoin.java create mode 100644 flink-processor/src/main/java/org/mobilitydb/flink/meos/wirings/demo/MeosCrossStreamDemoJob.java diff --git a/flink-processor/src/main/java/org/mobilitydb/flink/meos/wirings/MeosCrossStreamJoin.java b/flink-processor/src/main/java/org/mobilitydb/flink/meos/wirings/MeosCrossStreamJoin.java new file mode 100644 index 0000000..24c5ce1 --- /dev/null +++ b/flink-processor/src/main/java/org/mobilitydb/flink/meos/wirings/MeosCrossStreamJoin.java @@ -0,0 +1,115 @@ +package org.mobilitydb.flink.meos.wirings; + +import org.apache.flink.configuration.Configuration; +import org.apache.flink.streaming.api.functions.co.ProcessJoinFunction; +import org.apache.flink.util.Collector; + +import java.io.Serializable; + +/** + * DataStream wiring for the {@code cross-stream} streaming tier of + * the generated {@code org.mobilitydb.flink.meos.MeosOps*} facades. + * + *

The {@code cross-stream} tier is "pairwise across two streams, + * pre-keyed by the same K, time-bounded match window". Canonical + * examples are spatial-relations between two trajectories + * ({@code edwithin_tgeo_tgeo}, {@code eintersects_tgeo_tgeo}) and + * distance functions on two temporals + * ({@code nad_tgeo_tgeo}, {@code mindistance_tgeo_tgeo}). + * + *

Wraps any cross-stream MeosOps call as a Flink + * {@link ProcessJoinFunction} (the operator backing + * {@code KeyedStream.intervalJoin(other)}). The wiring receives one + * left event and one right event per match, both already paired by + * Flink's interval-join machinery, and the adopter's lambda computes + * the pairwise output via the matching MeosOps call. + * + *

Typical usage — per-vehicle-pair "did they come within + * 100m of each other in the last 5 minutes?" via + * {@code MeosOpsTGeo.edwithin_tgeo_tgeo} (tier = {@code cross-stream}): + * + *

{@code
+ * KeyedStream a = streamA.keyBy(VehiclePosition::regionId);
+ * KeyedStream b = streamB.keyBy(VehiclePosition::regionId);
+ *
+ * DataStream meetings = a
+ *     .intervalJoin(b)
+ *         .between(Time.minutes(-5), Time.minutes(5))
+ *         .process(new MeosCrossStreamJoin(
+ *             (left, right, ctx) -> {
+ *                 Pointer leftT  = left.toTGeoPointer();
+ *                 Pointer rightT = right.toTGeoPointer();
+ *                 if (MeosOpsTGeo.edwithin_tgeo_tgeo(leftT, rightT, 100.0) != 0) {
+ *                     return new MeetingEvent(left.id(), right.id(), ctx.getLeftTimestamp());
+ *                 }
+ *                 return null;  // no output for non-matches
+ *             }));
+ * }
+ * + *

The interval-join is keyed (both streams must be pre-keyed by + * the same K, and only events sharing a key are considered for + * pairing). The match window is time-bounded + * ({@code .between(lowerBound, upperBound)}) and event-time aware — + * watermarks drive when matches are emitted. + * + *

Slim adopter signature — same {@code ContextLike}-style + * pattern as {@link MeosWindowedAggregate}: the lambda receives the + * matched left + right events and a slim context exposing the + * left/right timestamps (the bits a MEOS cross-stream call typically + * needs), keeping the wiring lambda free of Flink internals. + * + *

Coverage: 140 of the 2,097 emitted methods (~7%) qualify + * as {@code cross-stream} per the v4 baseline — all of them wrappable + * through this single class. With this PR, every streamable tier in + * the baseline has a generic wiring class; 1,957 of 2,097 (93%) of + * the generated MeosOps* methods are wirable through 4 classes + * without per-method registration. + * + * @param the left-stream event type + * @param the right-stream event type + * @param the per-match output type + */ +public final class MeosCrossStreamJoin + extends ProcessJoinFunction { + + /** Serializable per-match MEOS pairwise call. */ + @FunctionalInterface + public interface JoinFn extends Serializable { + OUT join(L left, R right, ContextLike ctx) throws Exception; + } + + /** + * Slimmer alternative to Flink's {@code ProcessJoinFunction.Context} + * — exposes only the bits a MEOS pairwise call typically needs. + */ + public interface ContextLike { + long getLeftTimestamp(); + long getRightTimestamp(); + } + + private final JoinFn joinFn; + + public MeosCrossStreamJoin(JoinFn joinFn) { + this.joinFn = joinFn; + } + + @Override + public void open(Configuration parameters) throws Exception { + super.open(parameters); + MeosWiringRuntime.ensureInitializedOnThread(); + } + + @Override + public void processElement(L left, R right, + ProcessJoinFunction.Context context, + Collector out) throws Exception { + ContextLike ctx = new ContextLike() { + @Override public long getLeftTimestamp() { return context.getLeftTimestamp(); } + @Override public long getRightTimestamp() { return context.getRightTimestamp(); } + }; + OUT result = joinFn.join(left, right, ctx); + if (result != null) { + out.collect(result); + } + } +} diff --git a/flink-processor/src/main/java/org/mobilitydb/flink/meos/wirings/README.md b/flink-processor/src/main/java/org/mobilitydb/flink/meos/wirings/README.md index 906ab8b..cf210d1 100644 --- a/flink-processor/src/main/java/org/mobilitydb/flink/meos/wirings/README.md +++ b/flink-processor/src/main/java/org/mobilitydb/flink/meos/wirings/README.md @@ -10,7 +10,7 @@ per **streaming tier** (per | `stateless` | [`MeosStatelessMap`](MeosStatelessMap.java) (generic `MapFunction`) · [`MeosStatelessFilter`](MeosStatelessFilter.java) (generic `FilterFunction`) | ✅ shipped | | `bounded-state` | [`MeosBoundedStateMap`](MeosBoundedStateMap.java) (generic `KeyedProcessFunction` with `ValueState` per key — state crosses the operator boundary as MEOS-WKB/WKT bytes so checkpoints/rescaling/savepoints are safe; raw `Pointer` never leaves the JVM-local operator instance) | ✅ shipped | | `windowed` | [`MeosWindowedAggregate`](MeosWindowedAggregate.java) (generic `ProcessWindowFunction`; window-close-only aggregation; no MEOS handles persist across window boundaries) | ✅ shipped | -| `cross-stream` | `MeosCrossStreamJoin` (generic `KeyedCoProcessFunction` or interval-join) | next follow-up | +| `cross-stream` | [`MeosCrossStreamJoin`](MeosCrossStreamJoin.java) (generic `ProcessJoinFunction` over `KeyedStream.intervalJoin(other)`; time-bounded match window; same-key pairing) | ✅ shipped | | `io-meta` | covered transitively by the stateless wirings (no state, no window) | n/a | | `sequence-only` | inherently non-streamable — no wiring | n/a | diff --git a/flink-processor/src/main/java/org/mobilitydb/flink/meos/wirings/demo/MeosCrossStreamDemoJob.java b/flink-processor/src/main/java/org/mobilitydb/flink/meos/wirings/demo/MeosCrossStreamDemoJob.java new file mode 100644 index 0000000..97c3223 --- /dev/null +++ b/flink-processor/src/main/java/org/mobilitydb/flink/meos/wirings/demo/MeosCrossStreamDemoJob.java @@ -0,0 +1,135 @@ +package org.mobilitydb.flink.meos.wirings.demo; + +import jnr.ffi.Pointer; +import org.apache.flink.api.common.eventtime.WatermarkStrategy; +import org.apache.flink.api.java.tuple.Tuple4; +import org.apache.flink.api.java.tuple.Tuple5; +import org.apache.flink.streaming.api.datastream.DataStream; +import org.apache.flink.streaming.api.datastream.KeyedStream; +import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; +import org.apache.flink.streaming.api.windowing.time.Time; +import org.mobilitydb.flink.meos.MeosOpsFreeCore; +import org.mobilitydb.flink.meos.MeosOpsTBox; +import org.mobilitydb.flink.meos.wirings.MeosCrossStreamJoin; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.Arrays; + +/** + * End-to-end runnable demo of the {@code cross-stream} tier wiring. + * + *

Pipeline: + *

    + *
  1. Two parallel streams, each carrying {@code (regionId, + * vehicleId, tboxWKT, eventTimeMs)}, sharing the {@code regionId} + * key so cross-stream pairing is per-region.
  2. + *
  3. {@code keyBy(regionId)} on both, then + * {@code .intervalJoin().between(-1m, +1m)} so each event in + * stream A is matched with events in stream B within ±1 minute + * in the same region.
  4. + *
  5. {@link MeosCrossStreamJoin}: for each matched pair, test + * whether the two tboxes overlap via + * {@code MeosOpsFreeCore.overlaps_tbox_tbox}; if yes, emit + * {@code (regionId, vehAId, vehBId, leftTs, rightTs)}.
  6. + *
+ * + *

What the demo proves: + *

+ * + *

Run with: + * + *

{@code
+ * mvn -q exec:java \
+ *     -Dexec.mainClass=org.mobilitydb.flink.meos.wirings.demo.MeosCrossStreamDemoJob \
+ *     -Dmobilityflink.meos.enabled=true
+ * }
+ */ +public final class MeosCrossStreamDemoJob { + + private static final Logger LOG = LoggerFactory.getLogger(MeosCrossStreamDemoJob.class); + + /** Stream A — vehicle events, 3 per region across 2 regions. */ + private static final Tuple4[] EVENTS_A = new Tuple4[]{ + Tuple4.of(1, 10, "TBOX XT([0,5],[2026-01-01,2026-01-01 00:00:30])", ts("00:00:00")), + Tuple4.of(2, 20, "TBOX XT([100,105],[2026-01-01,2026-01-01 00:00:30])", ts("00:00:05")), + Tuple4.of(1, 11, "TBOX XT([10,15],[2026-01-01 00:00:30,2026-01-01 00:01:00])", ts("00:00:30")), + }; + + /** Stream B — different vehicles, 3 per region across 2 regions. */ + private static final Tuple4[] EVENTS_B = new Tuple4[]{ + Tuple4.of(1, 30, "TBOX XT([3,8],[2026-01-01,2026-01-01 00:00:30])", ts("00:00:10")), // overlaps with A:(1,10) + Tuple4.of(2, 40, "TBOX XT([200,205],[2026-01-01,2026-01-01 00:00:30])", ts("00:00:15")), // disjoint from A:(2,20) + Tuple4.of(1, 31, "TBOX XT([12,17],[2026-01-01 00:00:30,2026-01-01 00:01:00])", ts("00:00:40")), // overlaps with A:(1,11) + }; + + private static long ts(String hms) { + String[] parts = hms.split(":"); + long secs = Integer.parseInt(parts[0]) * 3600L + + Integer.parseInt(parts[1]) * 60L + + Integer.parseInt(parts[2]); + return 1767225600000L + secs * 1000L; // 2026-01-01T00:00:00 UTC in ms + } + + public static void main(String[] args) throws Exception { + if (!MeosOpsTBox.MEOS_AVAILABLE) { + LOG.error("MEOS not available — the demo requires libmeos."); + System.exit(1); + } + + StreamExecutionEnvironment env = + StreamExecutionEnvironment.getExecutionEnvironment(); + env.setParallelism(1); + + DataStream> a = + env.fromCollection(Arrays.asList(EVENTS_A)) + .assignTimestampsAndWatermarks( + WatermarkStrategy + .>forBoundedOutOfOrderness(Duration.ofSeconds(1)) + .withTimestampAssigner((e, ts) -> e.f3)); + DataStream> b = + env.fromCollection(Arrays.asList(EVENTS_B)) + .assignTimestampsAndWatermarks( + WatermarkStrategy + .>forBoundedOutOfOrderness(Duration.ofSeconds(1)) + .withTimestampAssigner((e, ts) -> e.f3)); + + KeyedStream, Integer> aKeyed = a.keyBy(t -> t.f0); + KeyedStream, Integer> bKeyed = b.keyBy(t -> t.f0); + + // Interval-join: pair events in A with events in B within ±1 minute, same region key. + DataStream> overlaps = + aKeyed.intervalJoin(bKeyed) + .between(Time.minutes(-1), Time.minutes(1)) + .process(new MeosCrossStreamJoin< + Tuple4, // L + Tuple4, // R + Tuple5 // OUT: (region, vehA, vehB, lts, rts) + >((left, right, ctx) -> { + Pointer leftTbox = MeosOpsTBox.tbox_in(left.f2); + Pointer rightTbox = MeosOpsTBox.tbox_in(right.f2); + if (MeosOpsFreeCore.overlaps_tbox_tbox(leftTbox, rightTbox) != 0) { + return Tuple5.of(left.f0, left.f1, right.f1, + ctx.getLeftTimestamp(), ctx.getRightTimestamp()); + } + return null; + })) + .returns(org.apache.flink.api.common.typeinfo.TypeInformation.of( + new org.apache.flink.api.common.typeinfo.TypeHint>() {})); + + overlaps.print("cross-stream-overlap"); + + env.execute("MeosWirings cross-stream tier demo"); + } +}