Skip to content

Commit 587b817

Browse files
astubbsclaude
andcommitted
test: add regression test for stale container replacement at same offset (confluentinc#909)
Reproduces the exact race condition from PR confluentinc#909: after rebalance, a stale WorkContainer at the same offset blocks fresh work from being added. Verifies that stale entries are replaced and non-stale duplicates are still dropped. See confluentinc#909 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f156939 commit 587b817

1 file changed

Lines changed: 122 additions & 0 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package io.confluent.parallelconsumer.state;
2+
3+
/*-
4+
* Copyright (C) 2020-2026 Confluent, Inc. and contributors
5+
*/
6+
7+
import io.confluent.parallelconsumer.internal.PCModuleTestEnv;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.apache.kafka.clients.consumer.ConsumerRecord;
10+
import org.apache.kafka.common.TopicPartition;
11+
import org.junit.jupiter.api.BeforeEach;
12+
import org.junit.jupiter.api.Test;
13+
import pl.tlinkowski.unij.api.UniLists;
14+
15+
import java.util.List;
16+
17+
import static com.google.common.truth.Truth.assertThat;
18+
import static com.google.common.truth.Truth.assertWithMessage;
19+
20+
/**
21+
* Regression test for upstream PR #909: a stale {@link WorkContainer} at the same offset
22+
* blocks fresh work from being added after rebalance.
23+
* <p>
24+
* The race condition: during rebalance, {@code removeStaleContainers()} runs but can't clean
25+
* shards that don't exist yet. When the control thread resumes processing the old batch, it
26+
* adds a stale container to a newly-created shard. The next poll's fresh container for the
27+
* same offset is then dropped because {@code addWorkContainer} sees "entry already exists."
28+
*
29+
* @see <a href="https://github.com/confluentinc/parallel-consumer/pull/909">confluentinc/parallel-consumer#909</a>
30+
* @see ProcessingShard#addWorkContainer(WorkContainer)
31+
*/
32+
@Slf4j
33+
class ProcessingShardStaleReplacementTest909 {
34+
35+
ModelUtils mu = new ModelUtils();
36+
WorkManager<String, String> wm;
37+
ShardManager<String, String> sm;
38+
PartitionStateManager<String, String> pm;
39+
40+
String topic = "topic";
41+
TopicPartition tp = new TopicPartition(topic, 0);
42+
43+
@BeforeEach
44+
void setup() {
45+
PCModuleTestEnv module = mu.getModule();
46+
wm = module.workManager();
47+
sm = wm.getSm();
48+
pm = wm.getPm();
49+
50+
// initial assignment at epoch 0
51+
wm.onPartitionsAssigned(UniLists.of(tp));
52+
}
53+
54+
/**
55+
* Exact reproduction of the PR #909 race condition timeline:
56+
* <ol>
57+
* <li>Control thread adds work at offset 200 for key "K_B" at epoch N</li>
58+
* <li>Rebalance happens (epoch advances to N+2)</li>
59+
* <li>Control thread (still processing old batch) adds STALE work at offset 300
60+
* for key "K_B" at OLD epoch N — this creates shard K_B with a stale entry</li>
61+
* <li>New poll adds FRESH work at offset 300 for key "K_B" at NEW epoch N+2</li>
62+
* <li>BUG (before fix): fresh work is DROPPED because offset 300 already exists</li>
63+
* <li>FIX: stale entry is replaced with fresh one</li>
64+
* </ol>
65+
*/
66+
@Test
67+
void staleContainerAtSameOffsetShouldBeReplacedByFreshOne() {
68+
long epoch0 = pm.getEpochOfPartition(tp);
69+
70+
// Step 1: add initial work at epoch 0
71+
addWork(epoch0, 100, "K_A");
72+
addWork(epoch0, 200, "K_B");
73+
74+
// Step 2: rebalance (epoch 0 → epoch 2: revoke increments, assign increments again)
75+
wm.onPartitionsRevoked(UniLists.of(tp));
76+
wm.onPartitionsAssigned(UniLists.of(tp));
77+
long epoch2 = pm.getEpochOfPartition(tp);
78+
assertThat(epoch2).isGreaterThan(epoch0);
79+
80+
// Step 3: late-arriving stale work at offset 300 from old epoch
81+
// (control thread still processing old batch after rebalance)
82+
addWork(epoch0, 300, "K_B");
83+
84+
// Step 4: new poll adds fresh work at the SAME offset 300, new epoch
85+
addWork(epoch2, 300, "K_B");
86+
87+
// Step 5: verify the fresh work replaced the stale entry
88+
List<WorkContainer<String, String>> work = sm.getWorkIfAvailable(100);
89+
var offset300 = work.stream().filter(wc -> wc.offset() == 300).findFirst();
90+
91+
assertWithMessage("Fresh work at offset 300 should be available (not blocked by stale entry). " +
92+
"See https://github.com/confluentinc/parallel-consumer/pull/909")
93+
.that(offset300.isPresent()).isTrue();
94+
assertWithMessage("Work at offset 300 should be from the new epoch, not the stale one")
95+
.that(offset300.get().getEpoch()).isEqualTo(epoch2);
96+
}
97+
98+
/**
99+
* Verify that a non-stale duplicate at the same offset is still correctly dropped
100+
* (preserving original behavior).
101+
*/
102+
@Test
103+
void nonStaleDuplicateAtSameOffsetShouldStillBeDropped() {
104+
long epoch0 = pm.getEpochOfPartition(tp);
105+
106+
// Add work at offset 100
107+
addWork(epoch0, 100, "K_A");
108+
109+
// Try to add duplicate at same offset, same epoch — should be dropped
110+
addWork(epoch0, 100, "K_A");
111+
112+
// Should only have one entry
113+
List<WorkContainer<String, String>> work = sm.getWorkIfAvailable(100);
114+
long count = work.stream().filter(wc -> wc.offset() == 100).count();
115+
assertThat(count).isEqualTo(1);
116+
}
117+
118+
private void addWork(long epoch, long offset, String key) {
119+
var record = new ConsumerRecord<>(topic, 0, offset, key, "value");
120+
sm.addWorkContainer(epoch, record);
121+
}
122+
}

0 commit comments

Comments
 (0)