Skip to content

Commit da22d8a

Browse files
authored
[server] Fix orphan chunk leak when ignored DCR records precede non-ignored records in batch (linkedin#2627)
Fix orphan chunk leak when DCR-ignored record precedes valid record Fix orphan chunk leak in processIngestionBatch() when a DCR-ignored record precedes a non-ignored record for the same key in an ingestion batch. Add isProcessedResultIgnored() helper to check whether a record was ignored by DCR conflict resolution. Gate seenKeys.add(key) on the record not being ignored, so only actually-produced records participate in manifest link-back.
1 parent 7179905 commit da22d8a

3 files changed

Lines changed: 363 additions & 3 deletions

File tree

clients/da-vinci-client/src/main/java/com/linkedin/davinci/kafka/consumer/StoreIngestionTask.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1586,7 +1586,6 @@ protected void produceToStoreBufferServiceOrKafkaInBatch(
15861586
if (seenKeys.contains(key)) {
15871587
linkBackManifestFromTransientRecord(processedRecord, partitionConsumptionState);
15881588
}
1589-
seenKeys.add(key);
15901589

15911590
totalBytesRead += handleSingleMessage(
15921591
processedRecord,
@@ -1597,6 +1596,14 @@ protected void produceToStoreBufferServiceOrKafkaInBatch(
15971596
beforeProcessingPerRecordTimestampNs,
15981597
beforeProcessingBatchRecordsTimestampMs,
15991598
elapsedTimeForPuttingIntoQueue);
1599+
1600+
// Only track keys that were actually produced (not ignored by DCR).
1601+
// Ignored records don't call setChunkingInfo, so the transient record's
1602+
// manifest is stale. Linking back from a stale transient record would
1603+
// overwrite the next record's correctly-populated manifest with null.
1604+
if (!isProcessedResultIgnored(processedRecord)) {
1605+
seenKeys.add(key);
1606+
}
16001607
}
16011608
} finally {
16021609
ingestionBatchProcessor.unlockKeys(keyLockMap);
@@ -1674,6 +1681,22 @@ static void linkBackManifestFromTransientRecord(
16741681
}
16751682
}
16761683

1684+
/**
1685+
* Returns true if the processed result was ignored (e.g., lost DCR conflict resolution)
1686+
* and no produce to VT occurred.
1687+
*/
1688+
static boolean isProcessedResultIgnored(PubSubMessageProcessedResultWrapper processedRecord) {
1689+
PubSubMessageProcessedResult result = processedRecord.getProcessedResult();
1690+
if (result == null) {
1691+
return false;
1692+
}
1693+
MergeConflictResultWrapper mcr = result.getMergeConflictResultWrapper();
1694+
if (mcr != null) {
1695+
return mcr.getMergeConflictResult().isUpdateIgnored();
1696+
}
1697+
return false;
1698+
}
1699+
16771700
// For testing purpose
16781701
List<PartitionExceptionInfo> getPartitionIngestionExceptionList() {
16791702
return this.partitionIngestionExceptionList;

clients/da-vinci-client/src/test/java/com/linkedin/davinci/kafka/consumer/LinkBackManifestFromTransientRecordTest.java

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44
import static org.mockito.Mockito.never;
55
import static org.mockito.Mockito.verify;
66
import static org.mockito.Mockito.when;
7+
import static org.testng.Assert.assertFalse;
78
import static org.testng.Assert.assertNull;
89
import static org.testng.Assert.assertSame;
10+
import static org.testng.Assert.assertTrue;
911

1012
import com.linkedin.davinci.replication.RmdWithValueSchemaId;
13+
import com.linkedin.davinci.replication.merge.MergeConflictResult;
1114
import com.linkedin.davinci.storage.chunking.ChunkedValueManifestContainer;
1215
import com.linkedin.davinci.utils.ByteArrayKey;
1316
import com.linkedin.venice.message.KafkaKey;
@@ -32,7 +35,10 @@
3235
* chunk deletion works correctly for all records, not just the first.
3336
*/
3437
public 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

Comments
 (0)