44import static org .mockito .Mockito .never ;
55import static org .mockito .Mockito .verify ;
66import static org .mockito .Mockito .when ;
7+ import static org .testng .Assert .assertFalse ;
78import static org .testng .Assert .assertNull ;
89import static org .testng .Assert .assertSame ;
10+ import static org .testng .Assert .assertTrue ;
911
1012import com .linkedin .davinci .replication .RmdWithValueSchemaId ;
13+ import com .linkedin .davinci .replication .merge .MergeConflictResult ;
1114import com .linkedin .davinci .storage .chunking .ChunkedValueManifestContainer ;
1215import com .linkedin .davinci .utils .ByteArrayKey ;
1316import com .linkedin .venice .message .KafkaKey ;
3235 * chunk deletion works correctly for all records, not just the first.
3336 */
3437public class LinkBackManifestFromTransientRecordTest {
35- private PubSubMessageProcessedResultWrapper createAAResult (byte [] keyBytes , ChunkedValueManifest oldManifest ) {
38+ private PubSubMessageProcessedResultWrapper createAAResult (
39+ byte [] keyBytes ,
40+ ChunkedValueManifest oldManifest ,
41+ boolean ignored ) {
3642 DefaultPubSubMessage message = mock (DefaultPubSubMessage .class );
3743 KafkaKey kafkaKey = mock (KafkaKey .class );
3844 when (message .getKey ()).thenReturn (kafkaKey );
@@ -43,9 +49,16 @@ private PubSubMessageProcessedResultWrapper createAAResult(byte[] keyBytes, Chun
4349
4450 RmdWithValueSchemaId oldRmd = new RmdWithValueSchemaId ();
4551
52+ MergeConflictResult mergeConflictResult =
53+ ignored ? MergeConflictResult .getIgnoredResult () : mock (MergeConflictResult .class );
54+ if (!ignored ) {
55+ when (mergeConflictResult .isUpdateIgnored ()).thenReturn (false );
56+ }
57+
4658 MergeConflictResultWrapper mcr = mock (MergeConflictResultWrapper .class );
4759 when (mcr .getOldValueManifestContainer ()).thenReturn (manifestContainer );
4860 when (mcr .getOldRmdWithValueSchemaId ()).thenReturn (oldRmd );
61+ when (mcr .getMergeConflictResult ()).thenReturn (mergeConflictResult );
4962
5063 PubSubMessageProcessedResult processedResult = new PubSubMessageProcessedResult (mcr );
5164
@@ -54,6 +67,10 @@ private PubSubMessageProcessedResultWrapper createAAResult(byte[] keyBytes, Chun
5467 return wrapper ;
5568 }
5669
70+ private PubSubMessageProcessedResultWrapper createAAResult (byte [] keyBytes , ChunkedValueManifest oldManifest ) {
71+ return createAAResult (keyBytes , oldManifest , false );
72+ }
73+
5774 private PubSubMessageProcessedResultWrapper createNonAAResult (byte [] keyBytes ) {
5875 DefaultPubSubMessage message = mock (DefaultPubSubMessage .class );
5976 KafkaKey kafkaKey = mock (KafkaKey .class );
@@ -82,6 +99,7 @@ private ChunkedValueManifest createManifest(int keysWithChunkIdSuffixCount) {
8299 * calls the actual {@link StoreIngestionTask#linkBackManifestFromTransientRecord} method.
83100 * In real code, handleSingleMessage (which updates the transient record) runs between
84101 * link-back calls; here we simulate that via mock return value sequencing.
102+ * Only non-ignored records are added to seenKeys, matching the production code.
85103 */
86104 private void simulateLinkBackLoop (
87105 List <PubSubMessageProcessedResultWrapper > processedResults ,
@@ -92,7 +110,10 @@ private void simulateLinkBackLoop(
92110 if (seenKeys .contains (key )) {
93111 StoreIngestionTask .linkBackManifestFromTransientRecord (processedRecord , pcs );
94112 }
95- seenKeys .add (key );
113+ // Only track keys that were actually produced (not ignored by DCR)
114+ if (!StoreIngestionTask .isProcessedResultIgnored (processedRecord )) {
115+ seenKeys .add (key );
116+ }
96117 }
97118 }
98119
@@ -375,4 +396,112 @@ public void testNullProcessedResultDoesNotCrash() {
375396 // r2 has null processedResult, so no transient record lookup should happen
376397 verify (pcs , never ()).getTransientRecord (key );
377398 }
399+
400+ @ Test
401+ public void testIsProcessedResultIgnored () {
402+ byte [] key = new byte [] { 1 };
403+
404+ // Ignored AA result
405+ PubSubMessageProcessedResultWrapper ignored = createAAResult (key , null , true );
406+ assertTrue (StoreIngestionTask .isProcessedResultIgnored (ignored ));
407+
408+ // Non-ignored AA result
409+ PubSubMessageProcessedResultWrapper nonIgnored = createAAResult (key , createManifest (1 ));
410+ assertFalse (StoreIngestionTask .isProcessedResultIgnored (nonIgnored ));
411+
412+ // WriteCompute result (L/F+WC, never ignored)
413+ PubSubMessageProcessedResultWrapper wcResult = createNonAAResult (key );
414+ assertFalse (StoreIngestionTask .isProcessedResultIgnored (wcResult ));
415+
416+ // Null processedResult
417+ DefaultPubSubMessage message = mock (DefaultPubSubMessage .class );
418+ KafkaKey kafkaKey = mock (KafkaKey .class );
419+ when (message .getKey ()).thenReturn (kafkaKey );
420+ when (kafkaKey .getKey ()).thenReturn (key );
421+ PubSubMessageProcessedResultWrapper nullResult = new PubSubMessageProcessedResultWrapper (message );
422+ assertFalse (StoreIngestionTask .isProcessedResultIgnored (nullResult ));
423+ }
424+
425+ /**
426+ * When an ignored record (DCR-lost) precedes a non-ignored record for the same key in a batch,
427+ * the non-ignored record's manifest must NOT be overwritten with null from the stale transient record.
428+ * This is the core bug scenario: [ignored_record, non_ignored_record] for same key.
429+ */
430+ @ Test
431+ public void testIgnoredRecordDoesNotTriggerLinkBackForSubsequentRecord () {
432+ byte [] key = new byte [] { 1 };
433+ ChunkedValueManifest mPrev = createManifest (3 );
434+
435+ // Record 1: ignored by DCR (old timestamp)
436+ PubSubMessageProcessedResultWrapper r1 = createAAResult (key , null , true );
437+
438+ // Record 2: wins DCR, has correct manifest from pre-processing
439+ PubSubMessageProcessedResultWrapper r2 = createAAResult (key , mPrev );
440+
441+ PartitionConsumptionState pcs = mock (PartitionConsumptionState .class );
442+ // If linkBack were incorrectly called, it would read a transient record with null manifest
443+ PartitionConsumptionState .TransientRecord staleTransient = mock (PartitionConsumptionState .TransientRecord .class );
444+ when (staleTransient .getValueManifest ()).thenReturn (null );
445+ when (staleTransient .getRmdManifest ()).thenReturn (null );
446+ when (pcs .getTransientRecord (key )).thenReturn (staleTransient );
447+
448+ List <PubSubMessageProcessedResultWrapper > results = new ArrayList <>();
449+ results .add (r1 );
450+ results .add (r2 );
451+
452+ simulateLinkBackLoop (results , pcs );
453+
454+ // r2's manifest should remain mPrev (set during pre-processing), NOT overwritten with null
455+ assertSame (
456+ r2 .getProcessedResult ().getMergeConflictResultWrapper ().getOldValueManifestContainer ().getManifest (),
457+ mPrev );
458+
459+ // linkBack should NOT have been called since the ignored record was not added to seenKeys
460+ verify (pcs , never ()).getTransientRecord (key );
461+ }
462+
463+ /**
464+ * When an ignored record appears between two non-ignored records for the same key,
465+ * the third record should still get its manifest linked back from the transient record
466+ * (set by the first record's produce), skipping the ignored record entirely.
467+ */
468+ @ Test
469+ public void testIgnoredRecordBetweenTwoProducedRecordsDoesNotBreakLinkBack () {
470+ byte [] key = new byte [] { 1 };
471+ ChunkedValueManifest m1 = createManifest (3 );
472+ ChunkedValueManifest m2 = createManifest (5 );
473+ ChunkedValueManifest rmdM2 = createManifest (10 );
474+
475+ // Record 1: wins DCR, produces
476+ PubSubMessageProcessedResultWrapper r1 = createAAResult (key , m1 );
477+ // Record 2: ignored by DCR
478+ PubSubMessageProcessedResultWrapper r2 = createAAResult (key , null , true );
479+ // Record 3: wins DCR, needs linkBack from r1's produce
480+ PubSubMessageProcessedResultWrapper r3 = createAAResult (key , null );
481+
482+ PartitionConsumptionState pcs = mock (PartitionConsumptionState .class );
483+ PartitionConsumptionState .TransientRecord transientAfterR1 = mock (PartitionConsumptionState .TransientRecord .class );
484+ when (transientAfterR1 .getValueManifest ()).thenReturn (m2 );
485+ when (transientAfterR1 .getRmdManifest ()).thenReturn (rmdM2 );
486+ when (pcs .getTransientRecord (key )).thenReturn (transientAfterR1 );
487+
488+ List <PubSubMessageProcessedResultWrapper > results = new ArrayList <>();
489+ results .add (r1 );
490+ results .add (r2 );
491+ results .add (r3 );
492+
493+ simulateLinkBackLoop (results , pcs );
494+
495+ // r1 keeps its original manifest
496+ assertSame (
497+ r1 .getProcessedResult ().getMergeConflictResultWrapper ().getOldValueManifestContainer ().getManifest (),
498+ m1 );
499+ // r3 gets linked back from r1's transient record
500+ assertSame (
501+ r3 .getProcessedResult ().getMergeConflictResultWrapper ().getOldValueManifestContainer ().getManifest (),
502+ m2 );
503+ assertSame (
504+ r3 .getProcessedResult ().getMergeConflictResultWrapper ().getOldRmdWithValueSchemaId ().getRmdManifest (),
505+ rmdM2 );
506+ }
378507}
0 commit comments