This is an automated email from the ASF dual-hosted git repository.

mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 0c7da4dce7 Expose the pipeline hop graph on lineage events, fixes 
#9004 (#9042)
0c7da4dce7 is described below

commit 0c7da4dce709b1d640688b431cf3ddfb8e12a99c
Author: Bart Maertens <[email protected]>
AuthorDate: Wed Oct 21 09:49:08 2026 +0200

    Expose the pipeline hop graph on lineage events, fixes #9004 (#9042)
    
    - add the PIPELINE_GRAPH event kind with PipelineGraphLineagePayload 
(enabled hops, in pipeline order)
    - emit it once per pipeline run, next to STARTED, with the same context
    - document that sinks skip event kinds they do not handle
    - the OpenLineage sink ignores the new kind, so its output does not change 
yet
---
 .../hop-dev-manual/modules/ROOT/pages/lineage.adoc |   5 +-
 .../ROOT/pages/technology/openlineage/index.adoc   |   1 +
 .../hop/lineage/LineageRunLifecycleEmitter.java    |  53 +++++-
 .../apache/hop/lineage/model/LineageEventKind.java |  15 +-
 .../lineage/model/PipelineGraphLineagePayload.java | 101 +++++++++++
 .../org/apache/hop/lineage/spi/ILineageSink.java   |   4 +
 .../hop/lineage/LineagePipelineGraphTest.java      | 199 +++++++++++++++++++++
 .../openlineage/OpenLineageEventMapper.java        |   1 +
 .../openlineage/OpenLineageEventMapperTest.java    |  19 ++
 9 files changed, 393 insertions(+), 5 deletions(-)

diff --git a/docs/hop-dev-manual/modules/ROOT/pages/lineage.adoc 
b/docs/hop-dev-manual/modules/ROOT/pages/lineage.adoc
index f5fdf37a0c..4f13aac749 100644
--- a/docs/hop-dev-manual/modules/ROOT/pages/lineage.adoc
+++ b/docs/hop-dev-manual/modules/ROOT/pages/lineage.adoc
@@ -105,7 +105,7 @@ Each observation is a **`LineageEvent`**:
 
 * **Event id** — Unique id (UUID by default).
 * **Timestamp** — Milliseconds since epoch.
-* **`LineageEventKind`** — One of `RUN_LIFECYCLE`, `TRANSFORM_SCHEMA`, 
`FILE_IO`, `RELATIONAL_IO`, `HTTP_IO`.
+* **`LineageEventKind`** — One of `RUN_LIFECYCLE`, `TRANSFORM_SCHEMA`, 
`FILE_IO`, `RELATIONAL_IO`, `HTTP_IO`, `PIPELINE_GRAPH`. New kinds are only 
ever appended: a sink must skip a kind it does not handle rather than fail the 
batch.
 * **`LineageContext`** — Who/what produced the observation (see above).
 * **`LineagePayload`** — Optional typed payload; may be null if the kind and 
context are enough.
 
@@ -115,6 +115,7 @@ Payload types are plain Java types (strings, longs, enums) 
so sinks can serializ
 * **`RelationalIoLineagePayload`** — Relational/JDBC table access: operation 
(`READ`, `WRITE`, `DELETE`, `EXEC`), the resolved **dataset namespace**, the 
input/output **`RelationalTable`** list (catalog/schema/table), the raw SQL 
when the engine cannot resolve the tables itself, an optional column schema, 
the connection's default catalog and schema, optional per-column provenance 
(**`RelationalWriteColumn`**: target column, stream field, origin transform), 
and an optional **`RelationalLi [...]
 * **`HttpLineagePayload`** — `CLIENT` vs `SERVER`, method, URL, status, 
request/response bytes, duration, success, message.
 * **`RunLifecycleLineagePayload`** — `STARTED` / `FINISHED` plus optional 
detail text.
+* **`PipelineGraphLineagePayload`** — The pipeline's hop graph as a list of 
`from` → `to` transform-name transitions: enabled hops with both ends named, in 
pipeline order. Informational hops are included. A sink uses it to attribute 
datasets to the transform that touched them and to propagate them along the 
graph, instead of pairing all of a pipeline's inputs with all of its outputs.
 * **`TransformSchemaLineagePayload`** — `INPUT` or `OUTPUT` plus a list of 
field names, Hop type names, length, and precision (boundary schema; emitted 
when a transform finishes). Fields usually come from runtime row metadata on 
the transform or row sets. If input was never observed (e.g. _Abort_ when 
`getRow()` immediately returns null), input is derived via 
`PipelineMeta#getPrevTransformFields`. If output was never written (e.g. 
_Detect empty stream_ with a non-empty stream), output is [...]
 
 Convenience factory: `LineageEvent.of(kind, context, payload)` assigns id and 
current time.
@@ -143,7 +144,7 @@ The queue is allocated lazily on the first `emit` from the 
resolved configuratio
 
 The `org.apache.hop.lineage.xp` package registers extension points that 
**emit** `RUN_LIFECYCLE` events (via `LineageRunLifecycleEmitter`) and 
**flush** the hub where needed:
 
-* **`PipelineStart`** — `LineageHubPipelineStartXp` — phase `STARTED`
+* **`PipelineStart`** — `LineageHubPipelineStartXp` — phase `STARTED`, 
followed by one `PIPELINE_GRAPH` event with the same context when the pipeline 
has enabled hops. `PipelineStart` fires after the transform threads have 
started, so the run's transform `STARTED` events, and early I/O such as a Table 
Input read, can arrive before the graph, possibly in an earlier batch. A sink 
that uses the graph collects a run's events by the pipeline's log channel id 
(transform events carry it in the  [...]
 * **`PipelineFinish`** — `LineageHubPipelineFinishXp` — phase `FINISHING` 
(transforms stopped, cleanup)
 * **`PipelineCompleted`** — `LineageHubPipelineCompletedXp` — phase `FINISHED` 
or `FAILED` (from pipeline error count), then `flushQuietly()`
 * **`WorkflowStart`** — `LineageHubWorkflowStartXp` — phase `STARTED`
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/technology/openlineage/index.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/technology/openlineage/index.adoc
index ba55541eb9..bf226b7874 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/technology/openlineage/index.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/technology/openlineage/index.adoc
@@ -87,6 +87,7 @@ properties if needed.
 |`RELATIONAL_IO` |`RunEvent` (`OTHER`) with relational table datasets — see 
<<Database lineage>>. Carries a `schema` facet, a `columnLineage` facet when 
column mappings are derivable, and a `lifecycleStateChange` facet for 
truncate/overwrite writes.
 |`TRANSFORM_SCHEMA` |Skipped (internal row shapes between transforms, not 
physical datasets).
 |`HTTP_IO` |Skipped.
+|`PIPELINE_GRAPH` |Skipped. Datasets stay on the pipeline job.
 |===
 
 The run id is the executing pipeline/workflow log-channel id; file I/O is 
correlated to the parent
diff --git 
a/engine/src/main/java/org/apache/hop/lineage/LineageRunLifecycleEmitter.java 
b/engine/src/main/java/org/apache/hop/lineage/LineageRunLifecycleEmitter.java
index 40a700aecd..53d1669793 100644
--- 
a/engine/src/main/java/org/apache/hop/lineage/LineageRunLifecycleEmitter.java
+++ 
b/engine/src/main/java/org/apache/hop/lineage/LineageRunLifecycleEmitter.java
@@ -17,6 +17,8 @@
 
 package org.apache.hop.lineage;
 
+import java.util.ArrayList;
+import java.util.List;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.Result;
 import org.apache.hop.core.logging.ILogChannel;
@@ -28,8 +30,11 @@ import org.apache.hop.lineage.context.LineageSubjectType;
 import org.apache.hop.lineage.hub.LineageHub;
 import org.apache.hop.lineage.model.LineageEvent;
 import org.apache.hop.lineage.model.LineageEventKind;
+import org.apache.hop.lineage.model.PipelineGraphLineagePayload;
+import org.apache.hop.lineage.model.PipelineGraphLineagePayload.Transition;
 import org.apache.hop.lineage.model.RunLifecycleLineagePayload;
 import org.apache.hop.lineage.model.RunLifecyclePhase;
+import org.apache.hop.pipeline.PipelineHopMeta;
 import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.pipeline.engine.IPipelineEngine;
 import org.apache.hop.pipeline.transform.ITransform;
@@ -167,12 +172,58 @@ public final class LineageRunLifecycleEmitter {
     if (phase == RunLifecyclePhase.FINISHED || phase == 
RunLifecyclePhase.FAILED) {
       ctx.putAttribute(OUTCOME, phase == RunLifecyclePhase.FAILED ? FAILED : 
SUCCESS);
     }
+    LineageContext context = ctx.build();
     LineageHub.getInstance()
         .emit(
             LineageEvent.of(
                 LineageEventKind.RUN_LIFECYCLE,
-                ctx.build(),
+                context,
                 new RunLifecycleLineagePayload(phase, detail)));
+
+    // The hop graph is a fact about the pipeline, not about a phase: emit it 
once, next to the
+    // STARTED event. Sinks that attribute I/O to individual transforms need 
it to know which
+    // transform fed which; see PipelineGraphLineagePayload.
+    //
+    if (phase == RunLifecyclePhase.STARTED && meta != null) {
+      List<Transition> transitions = pipelineTransitions(meta);
+      if (!transitions.isEmpty()) {
+        LineageHub.getInstance()
+            .emit(
+                LineageEvent.of(
+                    LineageEventKind.PIPELINE_GRAPH,
+                    context,
+                    new PipelineGraphLineagePayload(transitions)));
+      }
+    }
+  }
+
+  /**
+   * The enabled hops of a pipeline, in pipeline order, as {@code from -> to} 
transitions.
+   *
+   * <p>Hops with an unknown end are skipped (a half-edited hop can exist in 
memory), as are hops
+   * without names. Informational hops stay in: data flows through them, even 
though they do no I/O
+   * of their own, so dropping them would break a chain for anything that 
propagates datasets along
+   * it.
+   */
+  public static List<Transition> pipelineTransitions(PipelineMeta meta) {
+    if (meta == null) {
+      return List.of();
+    }
+    List<Transition> transitions = new ArrayList<>();
+    for (PipelineHopMeta hop : meta.getHops()) {
+      if (hop == null
+          || !hop.isEnabled()
+          || hop.getFromTransform() == null
+          || hop.getToTransform() == null) {
+        continue;
+      }
+      String from = hop.getFromTransform().getName();
+      String to = hop.getToTransform().getName();
+      if (!Utils.isEmpty(from) && !Utils.isEmpty(to)) {
+        transitions.add(new Transition(from, to));
+      }
+    }
+    return transitions;
   }
 
   public static void emitWorkflow(
diff --git 
a/engine/src/main/java/org/apache/hop/lineage/model/LineageEventKind.java 
b/engine/src/main/java/org/apache/hop/lineage/model/LineageEventKind.java
index fc22ad7d04..a81905eade 100644
--- a/engine/src/main/java/org/apache/hop/lineage/model/LineageEventKind.java
+++ b/engine/src/main/java/org/apache/hop/lineage/model/LineageEventKind.java
@@ -17,7 +17,12 @@
 
 package org.apache.hop.lineage.model;
 
-/** High-level category of a lineage observation. */
+/**
+ * High-level category of a lineage observation.
+ *
+ * <p>Kinds are added over time and only ever appended. A sink must ignore a 
kind it does not
+ * handle, for example with a {@code default} branch, so a newer engine does 
not break it.
+ */
 public enum LineageEventKind {
   RUN_LIFECYCLE,
   /** Transform input/output field names and Hop types (boundary schema). */
@@ -26,5 +31,11 @@ public enum LineageEventKind {
   /** HTTP client traffic from transforms/actions (payload: {@link 
HttpLineagePayload}). */
   HTTP_IO,
   /** Relational/JDBC table read or write (payload: {@link 
RelationalIoLineagePayload}). */
-  RELATIONAL_IO
+  RELATIONAL_IO,
+  /**
+   * The pipeline's hop graph, emitted once per run next to its start 
(payload: {@link
+   * PipelineGraphLineagePayload}). Sinks need it to attribute a dataset to 
the transform that
+   * touched it rather than to the whole pipeline.
+   */
+  PIPELINE_GRAPH
 }
diff --git 
a/engine/src/main/java/org/apache/hop/lineage/model/PipelineGraphLineagePayload.java
 
b/engine/src/main/java/org/apache/hop/lineage/model/PipelineGraphLineagePayload.java
new file mode 100644
index 0000000000..13a837fb75
--- /dev/null
+++ 
b/engine/src/main/java/org/apache/hop/lineage/model/PipelineGraphLineagePayload.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.lineage.model;
+
+import java.util.List;
+import java.util.Objects;
+import lombok.Getter;
+
+/**
+ * The pipeline's hop graph: which transform feeds which, as observed when the 
run started.
+ *
+ * <p>A sink needs this to attribute lineage to the transform that actually 
touched a dataset.
+ * Dataset lineage is derived through jobs — a job's inputs are paired with 
its outputs — so a sink
+ * that files a whole pipeline's I/O under one job can only express the full 
input × output product,
+ * not the true pairs. With the graph a sink can give each transform's job the 
datasets it consumes
+ * (its own reads plus what its upstream transforms produced) and its own 
outputs.
+ *
+ * <p>Only enabled hops with two known ends are listed, in pipeline order. 
Informational hops are
+ * included: data flows through them, even though they do no I/O of their own.
+ *
+ * <p>The graph is emitted with the pipeline's {@code STARTED} event, which 
fires after the
+ * transform threads have started. A transform's own events, including early 
I/O, can therefore
+ * arrive before the graph, even in an earlier batch. Collect a run's events 
by the pipeline's log
+ * channel id (transform events carry it in the {@code pipelineLogChannelId} 
attribute) and resolve
+ * them when the run finishes, rather than assuming the graph comes first.
+ */
+@Getter
+public final class PipelineGraphLineagePayload implements LineagePayload {
+
+  /** One enabled hop: the transform named {@code from} feeds the transform 
named {@code to}. */
+  @Getter
+  public static final class Transition {
+    private final String from;
+    private final String to;
+
+    public Transition(String from, String to) {
+      this.from = Objects.requireNonNull(from, "from");
+      this.to = Objects.requireNonNull(to, "to");
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (o == null || getClass() != o.getClass()) {
+        return false;
+      }
+      Transition that = (Transition) o;
+      return from.equals(that.from) && to.equals(that.to);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(from, to);
+    }
+
+    @Override
+    public String toString() {
+      return from + ">" + to;
+    }
+  }
+
+  private final List<Transition> transitions;
+
+  public PipelineGraphLineagePayload(List<Transition> transitions) {
+    this.transitions = List.copyOf(Objects.requireNonNull(transitions, 
"transitions"));
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    PipelineGraphLineagePayload that = (PipelineGraphLineagePayload) o;
+    return transitions.equals(that.transitions);
+  }
+
+  @Override
+  public int hashCode() {
+    return transitions.hashCode();
+  }
+}
diff --git a/engine/src/main/java/org/apache/hop/lineage/spi/ILineageSink.java 
b/engine/src/main/java/org/apache/hop/lineage/spi/ILineageSink.java
index d5bcbf05ec..40ef2f4691 100644
--- a/engine/src/main/java/org/apache/hop/lineage/spi/ILineageSink.java
+++ b/engine/src/main/java/org/apache/hop/lineage/spi/ILineageSink.java
@@ -43,6 +43,10 @@ public interface ILineageSink {
   /**
    * Deliver a non-empty batch of events. Implementations should not retain 
references to the list
    * or events after returning.
+   *
+   * <p>Skip events of a {@link org.apache.hop.lineage.model.LineageEventKind} 
the sink does not
+   * handle rather than failing: new kinds are added over time, and an 
exception here loses the
+   * whole batch for this sink.
    */
   void accept(List<LineageEvent> events) throws HopException;
 
diff --git 
a/engine/src/test/java/org/apache/hop/lineage/LineagePipelineGraphTest.java 
b/engine/src/test/java/org/apache/hop/lineage/LineagePipelineGraphTest.java
new file mode 100644
index 0000000000..a197d70d9c
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/lineage/LineagePipelineGraphTest.java
@@ -0,0 +1,199 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.lineage;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import org.apache.hop.lineage.hub.LineageHub;
+import org.apache.hop.lineage.model.LineageEvent;
+import org.apache.hop.lineage.model.LineageEventKind;
+import org.apache.hop.lineage.model.PipelineGraphLineagePayload;
+import org.apache.hop.lineage.model.PipelineGraphLineagePayload.Transition;
+import org.apache.hop.lineage.model.RunLifecyclePhase;
+import org.apache.hop.pipeline.PipelineHopMeta;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.engine.IPipelineEngine;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+/**
+ * The graph a sink receives must describe the pipeline that ran: enabled hops 
only, in pipeline
+ * order, and never a transition with a missing end.
+ */
+class LineagePipelineGraphTest {
+
+  private static TransformMeta transform(String name) {
+    TransformMeta transformMeta = new TransformMeta();
+    transformMeta.setName(name);
+    return transformMeta;
+  }
+
+  @Test
+  void chainIsReportedInPipelineOrder() {
+    PipelineMeta meta = new PipelineMeta();
+    TransformMeta read = transform("Read orders");
+    TransformMeta write = transform("Write copy");
+    TransformMeta pause = transform("Pause");
+    meta.getTransforms().add(read);
+    meta.getTransforms().add(write);
+    meta.getTransforms().add(pause);
+    meta.getHops().add(new PipelineHopMeta(read, write));
+    meta.getHops().add(new PipelineHopMeta(write, pause));
+
+    assertEquals(
+        List.of(new Transition("Read orders", "Write copy"), new 
Transition("Write copy", "Pause")),
+        LineageRunLifecycleEmitter.pipelineTransitions(meta));
+  }
+
+  @Test
+  void disabledHopIsNotPartOfTheGraphTheRunExecuted() {
+    PipelineMeta meta = new PipelineMeta();
+    TransformMeta a = transform("A");
+    TransformMeta b = transform("B");
+    meta.getHops().add(new PipelineHopMeta(a, b, false));
+
+    assertTrue(LineageRunLifecycleEmitter.pipelineTransitions(meta).isEmpty());
+  }
+
+  @Test
+  void hopWithoutAnEndIsSkipped() {
+    PipelineMeta meta = new PipelineMeta();
+    TransformMeta a = transform("A");
+    PipelineHopMeta noEnds = new PipelineHopMeta();
+    noEnds.setEnabled(true);
+    PipelineHopMeta noTo = new PipelineHopMeta(a, null);
+    noTo.setEnabled(true);
+    PipelineHopMeta noFrom = new PipelineHopMeta(null, a);
+    noFrom.setEnabled(true);
+    meta.getHops().add(noEnds);
+    meta.getHops().add(noTo);
+    meta.getHops().add(noFrom);
+
+    assertTrue(LineageRunLifecycleEmitter.pipelineTransitions(meta).isEmpty());
+  }
+
+  @Test
+  void hopWithAnUnnamedEndIsSkipped() {
+    PipelineMeta meta = new PipelineMeta();
+    TransformMeta named = transform("A");
+    TransformMeta unnamed = new TransformMeta();
+    meta.getHops().add(new PipelineHopMeta(named, unnamed));
+
+    assertTrue(LineageRunLifecycleEmitter.pipelineTransitions(meta).isEmpty());
+  }
+
+  @Test
+  void diamondKeepsBothBranches() {
+    PipelineMeta meta = new PipelineMeta();
+    TransformMeta read = transform("Read");
+    TransformMeta upper = transform("Upper");
+    TransformMeta lower = transform("Lower");
+    TransformMeta join = transform("Join");
+    meta.getHops().add(new PipelineHopMeta(read, upper));
+    meta.getHops().add(new PipelineHopMeta(read, lower));
+    meta.getHops().add(new PipelineHopMeta(upper, join));
+    meta.getHops().add(new PipelineHopMeta(lower, join));
+
+    assertEquals(
+        List.of(
+            new Transition("Read", "Upper"),
+            new Transition("Read", "Lower"),
+            new Transition("Upper", "Join"),
+            new Transition("Lower", "Join")),
+        LineageRunLifecycleEmitter.pipelineTransitions(meta));
+  }
+
+  @Test
+  void noMetaMeansNoGraph() {
+    assertTrue(LineageRunLifecycleEmitter.pipelineTransitions(null).isEmpty());
+  }
+
+  @Test
+  void startEmitsTheGraphWithTheRunContext() {
+    List<LineageEvent> events = emitPipeline(chain(), 
RunLifecyclePhase.STARTED);
+
+    assertEquals(2, events.size());
+    assertEquals(LineageEventKind.RUN_LIFECYCLE, events.get(0).getKind());
+    LineageEvent graph = events.get(1);
+    assertEquals(LineageEventKind.PIPELINE_GRAPH, graph.getKind());
+    assertSame(events.get(0).getContext(), graph.getContext());
+    assertEquals(
+        List.of(new Transition("Read", "Write")),
+        ((PipelineGraphLineagePayload) graph.getPayload()).getTransitions());
+  }
+
+  @Test
+  void otherPhasesDoNotRepeatTheGraph() {
+    for (RunLifecyclePhase phase : RunLifecyclePhase.values()) {
+      if (phase == RunLifecyclePhase.STARTED) {
+        continue;
+      }
+      List<LineageEvent> events = emitPipeline(chain(), phase);
+      assertEquals(1, events.size(), phase.name());
+      assertEquals(LineageEventKind.RUN_LIFECYCLE, events.get(0).getKind(), 
phase.name());
+    }
+  }
+
+  @Test
+  void pipelineWithoutHopsEmitsNoGraph() {
+    PipelineMeta meta = new PipelineMeta();
+    meta.getTransforms().add(transform("Alone"));
+
+    List<LineageEvent> events = emitPipeline(meta, RunLifecyclePhase.STARTED);
+
+    assertEquals(1, events.size());
+    assertEquals(LineageEventKind.RUN_LIFECYCLE, events.get(0).getKind());
+  }
+
+  private static PipelineMeta chain() {
+    PipelineMeta meta = new PipelineMeta();
+    TransformMeta read = transform("Read");
+    TransformMeta write = transform("Write");
+    meta.getTransforms().add(read);
+    meta.getTransforms().add(write);
+    meta.getHops().add(new PipelineHopMeta(read, write));
+    return meta;
+  }
+
+  @SuppressWarnings("unchecked")
+  private static List<LineageEvent> emitPipeline(PipelineMeta meta, 
RunLifecyclePhase phase) {
+    LineageHub hub = mock(LineageHub.class);
+    IPipelineEngine<PipelineMeta> pipeline = mock(IPipelineEngine.class);
+    when(pipeline.getPipelineMeta()).thenReturn(meta);
+    when(pipeline.getLogChannelId()).thenReturn("pipeline-log");
+
+    try (MockedStatic<LineageHub> staticHub = 
Mockito.mockStatic(LineageHub.class)) {
+      staticHub.when(LineageHub::getInstance).thenReturn(hub);
+      LineageRunLifecycleEmitter.emitPipeline(pipeline, phase, null);
+    }
+
+    ArgumentCaptor<LineageEvent> captor = 
ArgumentCaptor.forClass(LineageEvent.class);
+    verify(hub, atLeastOnce()).emit(captor.capture());
+    return captor.getAllValues();
+  }
+}
diff --git 
a/plugins/tech/openlineage/src/main/java/org/apache/hop/lineage/openlineage/OpenLineageEventMapper.java
 
b/plugins/tech/openlineage/src/main/java/org/apache/hop/lineage/openlineage/OpenLineageEventMapper.java
index 6d030c6eef..e8326cc085 100644
--- 
a/plugins/tech/openlineage/src/main/java/org/apache/hop/lineage/openlineage/OpenLineageEventMapper.java
+++ 
b/plugins/tech/openlineage/src/main/java/org/apache/hop/lineage/openlineage/OpenLineageEventMapper.java
@@ -134,6 +134,7 @@ public final class OpenLineageEventMapper {
       case RELATIONAL_IO -> mapRelationalIo(event);
       case TRANSFORM_SCHEMA -> Optional.empty();
       case HTTP_IO -> Optional.empty();
+      case PIPELINE_GRAPH -> Optional.empty();
     };
   }
 
diff --git 
a/plugins/tech/openlineage/src/test/java/org/apache/hop/lineage/openlineage/OpenLineageEventMapperTest.java
 
b/plugins/tech/openlineage/src/test/java/org/apache/hop/lineage/openlineage/OpenLineageEventMapperTest.java
index b07952aaba..3762543517 100644
--- 
a/plugins/tech/openlineage/src/test/java/org/apache/hop/lineage/openlineage/OpenLineageEventMapperTest.java
+++ 
b/plugins/tech/openlineage/src/test/java/org/apache/hop/lineage/openlineage/OpenLineageEventMapperTest.java
@@ -38,6 +38,8 @@ import org.apache.hop.lineage.model.FileIoTabularColumn;
 import org.apache.hop.lineage.model.LineageEvent;
 import org.apache.hop.lineage.model.LineageEventKind;
 import org.apache.hop.lineage.model.LineageFieldSchema;
+import org.apache.hop.lineage.model.PipelineGraphLineagePayload;
+import org.apache.hop.lineage.model.PipelineGraphLineagePayload.Transition;
 import org.apache.hop.lineage.model.RelationalIoLineagePayload;
 import org.apache.hop.lineage.model.RelationalIoOperation;
 import org.apache.hop.lineage.model.RelationalLifecycle;
@@ -262,6 +264,23 @@ class OpenLineageEventMapperTest {
     assertFalse(mapper.map(event).isPresent());
   }
 
+  // The hop graph does not change the OpenLineage output yet: the pipeline 
job keeps its datasets.
+  @Test
+  void pipelineGraphIsSkipped() {
+    LineageEvent event =
+        new LineageEvent(
+            "evt-graph",
+            1L,
+            LineageEventKind.PIPELINE_GRAPH,
+            LineageContext.builder()
+                .subjectType(LineageSubjectType.PIPELINE)
+                .logChannelId(PIPELINE_RUN_UUID)
+                .pipelineName("graph-pipeline")
+                .build(),
+            new PipelineGraphLineagePayload(List.of(new Transition("Read", 
"Write"))));
+    assertFalse(mapper.map(event).isPresent());
+  }
+
   @Test
   void fileIoReadMapsSchemaFacetFromContentSchema() throws Exception {
     FileIoContentSchema contentSchema =

Reply via email to