@@ -72,6 +72,13 @@ public void tearDown() {
7272 OpenLineageContext .resetForTests ();
7373 }
7474
75+ private static class ThrowingFn extends DoFn <Integer , Integer > {
76+ @ ProcessElement
77+ public void processElement () {
78+ throw new IllegalStateException ("boom" );
79+ }
80+ }
81+
7582 /** Mirrors the runtime Lineage calls IO connectors make (e.g. PubsubIO.java). */
7683 private static class ReportingFn extends DoFn <Integer , Integer > {
7784 private transient boolean reported ;
@@ -140,6 +147,114 @@ public void testParentRunFacetAttachedWhenFullyConfigured() throws Exception {
140147 assertEquals ("airflow_namespace" , parent .getJob ().getNamespace ());
141148 }
142149
150+ @ Test
151+ public void testManagedIcebergWriteEmitsDatasetWithSymlink () throws Exception {
152+ String warehouse = "file://" + temporaryFolder .newFolder ("e2e-warehouse" ).getAbsolutePath ();
153+ org .apache .iceberg .catalog .TableIdentifier tableId =
154+ org .apache .iceberg .catalog .TableIdentifier .parse ("demo.orders" );
155+ try (org .apache .iceberg .hadoop .HadoopCatalog catalog =
156+ new org .apache .iceberg .hadoop .HadoopCatalog (
157+ new org .apache .hadoop .conf .Configuration (), warehouse )) {
158+ catalog .createTable (
159+ tableId ,
160+ new org .apache .iceberg .Schema (
161+ org .apache .iceberg .types .Types .NestedField .required (
162+ 1 , "id" , org .apache .iceberg .types .Types .LongType .get ()),
163+ org .apache .iceberg .types .Types .NestedField .required (
164+ 2 , "name" , org .apache .iceberg .types .Types .StringType .get ())));
165+ }
166+ java .util .Map <String , Object > config = new java .util .HashMap <>();
167+ config .put ("table" , "demo.orders" );
168+ config .put ("catalog_name" , "local" );
169+ java .util .Map <String , String > catalogProps = new java .util .HashMap <>();
170+ catalogProps .put ("type" , "hadoop" );
171+ catalogProps .put ("warehouse" , warehouse );
172+ config .put ("catalog_properties" , catalogProps );
173+
174+ PipelineOptions options = PipelineOptionsFactory .create ();
175+ options .setRunner (OpenLineageRunner .class );
176+ org .apache .beam .sdk .schemas .Schema beamSchema =
177+ org .apache .beam .sdk .schemas .Schema .builder ()
178+ .addInt64Field ("id" )
179+ .addStringField ("name" )
180+ .build ();
181+
182+ Pipeline pipeline = Pipeline .create (options );
183+ pipeline
184+ .apply (
185+ Create .of (
186+ org .apache .beam .sdk .values .Row .withSchema (beamSchema )
187+ .addValues (1L , "laptop" )
188+ .build ())
189+ .withRowSchema (beamSchema ))
190+ .apply (
191+ org .apache .beam .sdk .managed .Managed .write (org .apache .beam .sdk .managed .Managed .ICEBERG )
192+ .withConfig (config ));
193+ pipeline .run ().waitUntilFinish ();
194+
195+ List <OpenLineage .RunEvent > events = awaitTerminalEvent ();
196+ // The graph-extracted Iceberg dataset must be on the START event already.
197+ OpenLineage .OutputDataset output = events .get (0 ).getOutputs ().get (0 );
198+ assertEquals ("file" , output .getNamespace ());
199+ assertTrue (output .getName ().endsWith ("/e2e-warehouse/demo/orders" ));
200+ OpenLineage .SymlinksDatasetFacetIdentifiers symlink =
201+ output .getFacets ().getSymlinks ().getIdentifiers ().get (0 );
202+ assertEquals ("demo.orders" , symlink .getName ());
203+ assertEquals ("TABLE" , symlink .getType ());
204+ }
205+
206+ @ Test
207+ public void testStreamingPipelineEmitsRunningHeartbeatsAndAbortOnCancel () throws Exception {
208+ PipelineOptions options = PipelineOptionsFactory .fromArgs ("--blockOnRun=false" ).create ();
209+ options .setRunner (OpenLineageRunner .class );
210+ options .as (OpenLineagePipelineOptions .class ).setOpenLineageTrackingIntervalInSeconds (1 );
211+
212+ Pipeline pipeline = Pipeline .create (options );
213+ pipeline .apply (org .apache .beam .sdk .io .GenerateSequence .from (0 ));
214+ PipelineResult result = pipeline .run ();
215+
216+ // The tracker must emit periodic RUNNING events while the job runs...
217+ awaitEventCount (OpenLineage .RunEvent .EventType .RUNNING , 2 );
218+ // ...and CANCELLED must map to ABORT, per the Flink integration's terminal mapping.
219+ result .cancel ();
220+ awaitEventCount (OpenLineage .RunEvent .EventType .ABORT , 1 );
221+ }
222+
223+ @ Test
224+ public void testFailingPipelineEmitsFailWithErrorMessageFacet () throws Exception {
225+ PipelineOptions options = PipelineOptionsFactory .create ();
226+ options .setRunner (OpenLineageRunner .class );
227+
228+ Pipeline pipeline = Pipeline .create (options );
229+ pipeline .apply (Create .of (1 )).apply (ParDo .of (new ThrowingFn ()));
230+ try {
231+ pipeline .run ().waitUntilFinish ();
232+ throw new AssertionError ("pipeline should have failed" );
233+ } catch (RuntimeException expected ) {
234+ // expected
235+ }
236+
237+ List <OpenLineage .RunEvent > events = awaitEventCount (OpenLineage .RunEvent .EventType .FAIL , 1 );
238+ OpenLineage .RunEvent fail = events .get (events .size () - 1 );
239+ String message = fail .getRun ().getFacets ().getErrorMessage ().getMessage ();
240+ assertTrue ("errorMessage was: " + message , message .contains ("boom" ));
241+ assertEquals ("JAVA" , fail .getRun ().getFacets ().getErrorMessage ().getProgrammingLanguage ());
242+ }
243+
244+ private List <OpenLineage .RunEvent > awaitEventCount (
245+ OpenLineage .RunEvent .EventType type , int minCount ) throws Exception {
246+ long deadline = System .currentTimeMillis () + 30_000 ;
247+ List <OpenLineage .RunEvent > events = readEvents ();
248+ while (System .currentTimeMillis () < deadline ) {
249+ events = readEvents ();
250+ if (events .stream ().filter (e -> e .getEventType () == type ).count () >= minCount ) {
251+ return events ;
252+ }
253+ Thread .sleep (250 );
254+ }
255+ throw new AssertionError ("Expected " + minCount + " " + type + " events; got " + events );
256+ }
257+
143258 @ Test
144259 public void testDisabledOptionSuppressesAllEvents () throws Exception {
145260 PipelineOptions options = PipelineOptionsFactory .create ();
0 commit comments