@@ -206,6 +206,60 @@ public void prepareUdfInputFn_validJsonWithData() {
206206 pipeline .run ();
207207 }
208208
209+ @ Test
210+ public void prepareUdfInputFn_wrappedPayload () {
211+ // Test that PrepareUdfInputFn correctly extracts the inner event from a wrapped DLQ payload.
212+ // This simulates an event that has failed before and is being retried from the Dead Letter
213+ // Queue.
214+ // The structure contains the event under "changeEvent" and metadata like "dataCollection".
215+ String fullEventJson =
216+ "{\" changeEvent\" :{\" data\" :\" {\\ \" name\\ \" :\\ \" John\\ \" }\" },\" dataCollection\" :\" test\" }" ;
217+
218+ // We pass the fullEventJson as both payload and originalPayload, which is typical for the start
219+ // of a retry flow.
220+ FailsafeElement <String , String > input = FailsafeElement .of (fullEventJson , fullEventJson );
221+
222+ PCollectionTuple result =
223+ pipeline
224+ .apply (
225+ "CreateInput" ,
226+ Create .of (input )
227+ .withCoder (FailsafeElementCoder .of (StringUtf8Coder .of (), StringUtf8Coder .of ())))
228+ .apply (
229+ "PrepareUdfInput" ,
230+ ParDo .of (new DataStreamMongoDBToFirestore .PrepareUdfInputFn ())
231+ .withOutputTags (
232+ SUCCESS_TAG ,
233+ TupleTagList .of (DataStreamMongoDBToFirestore .PREPARE_FAILURE_TAG )));
234+
235+ result
236+ .get (SUCCESS_TAG )
237+ .setCoder (FailsafeElementCoder .of (StringUtf8Coder .of (), StringUtf8Coder .of ()));
238+ result
239+ .get (DataStreamMongoDBToFirestore .PREPARE_FAILURE_TAG )
240+ .setCoder (FailsafeElementCoder .of (StringUtf8Coder .of (), StringUtf8Coder .of ()));
241+
242+ PAssert .that (result .get (SUCCESS_TAG ))
243+ .satisfies (
244+ iterable -> {
245+ FailsafeElement <String , String > element = iterable .iterator ().next ();
246+ // Verify that the original payload is preserved intact for DLQ purposes.
247+ assertEquals (fullEventJson , element .getOriginalPayload ());
248+ try {
249+ ObjectMapper mapper = new ObjectMapper ();
250+ JsonNode node = mapper .readTree (element .getPayload ());
251+ // Verify that PrepareUdfInputFn successfully extracted the inner "data" field
252+ // from the wrapped "changeEvent", rather than passing the whole wrapper to UDF.
253+ assertEquals ("John" , node .get ("name" ).asText ());
254+ } catch (Exception e ) {
255+ throw new RuntimeException (e );
256+ }
257+ return null ;
258+ });
259+
260+ pipeline .run ();
261+ }
262+
209263 @ Test
210264 public void prepareUdfInputFn_missingData_routesToDlq () {
211265 String fullEventJson = "{\" op\" :\" u\" }" ;
@@ -371,47 +425,6 @@ public void prepareUdfInputFn_wrappedEvent_succeeds() {
371425 pipeline .run ();
372426 }
373427
374- @ Test
375- public void prepareUdfInputFn_alreadyProcessed_bypassesUdf () {
376- String processedJson = "{\" _metadata_udf_processed\" : true, \" data\" : \" {}\" }" ;
377- FailsafeElement <String , String > input = FailsafeElement .of (processedJson , processedJson );
378-
379- PCollectionTuple result =
380- pipeline
381- .apply (
382- "CreateInput" ,
383- Create .of (input )
384- .withCoder (FailsafeElementCoder .of (StringUtf8Coder .of (), StringUtf8Coder .of ())))
385- .apply (
386- "PrepareUdfInput" ,
387- ParDo .of (new DataStreamMongoDBToFirestore .PrepareUdfInputFn ())
388- .withOutputTags (
389- SUCCESS_TAG ,
390- TupleTagList .of (DataStreamMongoDBToFirestore .BYPASS_UDF_TAG )
391- .and (DataStreamMongoDBToFirestore .PREPARE_FAILURE_TAG )));
392-
393- result
394- .get (SUCCESS_TAG )
395- .setCoder (FailsafeElementCoder .of (StringUtf8Coder .of (), StringUtf8Coder .of ()));
396- result
397- .get (DataStreamMongoDBToFirestore .BYPASS_UDF_TAG )
398- .setCoder (FailsafeElementCoder .of (StringUtf8Coder .of (), StringUtf8Coder .of ()));
399- result
400- .get (DataStreamMongoDBToFirestore .PREPARE_FAILURE_TAG )
401- .setCoder (FailsafeElementCoder .of (StringUtf8Coder .of (), StringUtf8Coder .of ()));
402-
403- PAssert .that (result .get (DataStreamMongoDBToFirestore .BYPASS_UDF_TAG ))
404- .satisfies (
405- iterable -> {
406- FailsafeElement <String , String > element = iterable .iterator ().next ();
407- assertEquals (processedJson , element .getOriginalPayload ());
408- assertEquals (processedJson , element .getPayload ());
409- return null ;
410- });
411-
412- pipeline .run ();
413- }
414-
415428 @ Test
416429 public void prepareUdfInputFn_invalidJson_routesToDlq () {
417430 String invalidJson = "{invalid}" ;
@@ -483,7 +496,6 @@ public void restoreUdfOutputFn_unchangedPayload() {
483496 try {
484497 ObjectMapper mapper = new ObjectMapper ();
485498 JsonNode node = mapper .readTree (element .getPayload ());
486- assertFalse (node .has ("_metadata_udf_processed" ));
487499 assertEquals ("{\" name\" :\" John\" }" , node .get ("data" ).asText ());
488500 } catch (Exception e ) {
489501 throw new RuntimeException (e );
@@ -573,11 +585,70 @@ public void restoreUdfOutputFn_changedPayload() {
573585 JsonNode dataNode = mapper .readTree (dataStr );
574586 assertEquals ("Jane" , dataNode .get ("name" ).asText ());
575587
576- // Verify that originalPayload is ALSO overwritten with the transformed data
588+ // Verify that originalPayload is PRESERVED and not overwritten
577589 JsonNode originalNode = mapper .readTree (element .getOriginalPayload ());
578- String originalDataStr = originalNode .get ("data" ).asText ();
590+ JsonNode originalDataNode = originalNode .get ("data" );
591+ assertEquals ("John" , originalDataNode .get ("name" ).asText ());
592+ } catch (Exception e ) {
593+ throw new RuntimeException (e );
594+ }
595+ return null ;
596+ });
597+
598+ pipeline .run ();
599+ }
600+
601+ @ Test
602+ public void restoreUdfOutputFn_wrappedPayload () {
603+ // Test that RestoreUdfOutputFn correctly merges transformed data back into the wrapped event
604+ // (DLQ format). This ensures that when we retry an event from DLQ and apply UDF, the result
605+ // is correctly placed back into the wrapped structure for further processing.
606+ String fullEventJson =
607+ "{\" changeEvent\" :{\" data\" :\" {\\ \" name\\ \" :\\ \" John\\ \" }\" },\" dataCollection\" :\" test\" }" ;
608+ String transformedData = "{\" name\" :\" Jane\" }" ;
609+ FailsafeElement <String , String > input = FailsafeElement .of (fullEventJson , transformedData );
610+
611+ PCollectionTuple result =
612+ pipeline
613+ .apply (
614+ "CreateInput" ,
615+ Create .of (input )
616+ .withCoder (FailsafeElementCoder .of (StringUtf8Coder .of (), StringUtf8Coder .of ())))
617+ .apply (
618+ "RestoreUdfOutput" ,
619+ ParDo .of (new DataStreamMongoDBToFirestore .RestoreUdfOutputFn ())
620+ .withOutputTags (
621+ SUCCESS_TAG ,
622+ TupleTagList .of (DataStreamMongoDBToFirestore .RESTORE_FAILURE_TAG )));
623+
624+ result
625+ .get (SUCCESS_TAG )
626+ .setCoder (FailsafeElementCoder .of (StringUtf8Coder .of (), StringUtf8Coder .of ()));
627+ result
628+ .get (DataStreamMongoDBToFirestore .RESTORE_FAILURE_TAG )
629+ .setCoder (FailsafeElementCoder .of (StringUtf8Coder .of (), StringUtf8Coder .of ()));
630+
631+ PAssert .that (result .get (SUCCESS_TAG ))
632+ .satisfies (
633+ iterable -> {
634+ FailsafeElement <String , String > element = iterable .iterator ().next ();
635+ try {
636+ ObjectMapper mapper = new ObjectMapper ();
637+ JsonNode node = mapper .readTree (element .getPayload ());
638+
639+ // Verify that the inner changeEvent has the updated data after UDF application.
640+ JsonNode innerEvent = node .get ("changeEvent" );
641+ String dataStr = innerEvent .get ("data" ).asText ();
642+ JsonNode dataNode = mapper .readTree (dataStr );
643+ assertEquals ("Jane" , dataNode .get ("name" ).asText ());
644+
645+ // Verify that originalPayload is PRESERVED as the original wrapped event,
646+ // ensuring auditability and safety for further retries.
647+ JsonNode originalNode = mapper .readTree (element .getOriginalPayload ());
648+ JsonNode originalInnerEvent = originalNode .get ("changeEvent" );
649+ String originalDataStr = originalInnerEvent .get ("data" ).asText ();
579650 JsonNode originalDataNode = mapper .readTree (originalDataStr );
580- assertEquals ("Jane " , originalDataNode .get ("name" ).asText ());
651+ assertEquals ("John " , originalDataNode .get ("name" ).asText ());
581652 } catch (Exception e ) {
582653 throw new RuntimeException (e );
583654 }
@@ -975,12 +1046,6 @@ public void applyUdfToDataField_mixedEvents_correctlyProcessed() throws IOExcept
9751046 assertTrue (eventMap .containsKey ("UPDATE" ));
9761047 assertTrue (eventMap .containsKey ("READ" ));
9771048
978- // Verify UDF processed flag
979- assertTrue (eventMap .get ("INSERT" ).has ("_metadata_udf_processed" ));
980- assertTrue (eventMap .get ("UPDATE" ).has ("_metadata_udf_processed" ));
981- assertTrue (eventMap .get ("READ" ).has ("_metadata_udf_processed" ));
982- assertFalse (eventMap .get ("DELETE" ).has ("_metadata_udf_processed" ));
983-
9841049 try {
9851050 // Verify UDF was applied to INSERT, UPDATE, READ
9861051 String insertDataStr = eventMap .get ("INSERT" ).get ("data" ).asText ();
0 commit comments