Skip to content

Commit 076c6f1

Browse files
Merge branch 'cassandra-5.0' into trunk
* cassandra-5.0: Fix ConcurrentModificationException in compaction garbagecollect
2 parents 8d325d5 + 2b1c839 commit 076c6f1

3 files changed

Lines changed: 372 additions & 1 deletion

File tree

CHANGES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@
303303
* Add the ability to disable bulk loading of SSTables (CASSANDRA-18781)
304304
* Clean up obsolete functions and simplify cql_version handling in cqlsh (CASSANDRA-18787)
305305
Merged from 5.0:
306+
* Fix ConcurrentModificationException in compaction garbagecollect (CASSANDRA-21065)
306307
* Dynamically skip sharding L0 when SAI Vector index present (CASSANDRA-19661)
307308
* Optionally force IndexStatusManager to use the optimized index status format (CASSANDRA-21132)
308309
* Automatically disable zero-copy streaming for legacy sstables with old bloom filter format (CASSANDRA-21092)

src/java/org/apache/cassandra/db/compaction/CompactionManager.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -851,7 +851,9 @@ public Iterable<SSTableReader> filterSSTables(LifecycleTransaction transaction)
851851
List<SSTableReader> filteredSSTables = new ArrayList<>();
852852
if (cfStore.getCompactionStrategyManager().onlyPurgeRepairedTombstones())
853853
{
854-
for (SSTableReader sstable : transaction.originals())
854+
// Copy originals to avoid ConcurrentModificationException when cancel()
855+
// modifies the underlying collection when calling `cancel(..)`
856+
for (SSTableReader sstable : new ArrayList<>(transaction.originals()))
855857
{
856858
if (!sstable.isRepaired())
857859
{
Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package org.apache.cassandra.db.compaction;
19+
20+
import java.io.IOException;
21+
import java.util.Collections;
22+
import java.util.ConcurrentModificationException;
23+
import java.util.Iterator;
24+
import java.util.Set;
25+
import java.util.concurrent.CopyOnWriteArrayList;
26+
27+
import org.junit.Test;
28+
import org.slf4j.Logger;
29+
import org.slf4j.LoggerFactory;
30+
31+
import org.apache.cassandra.Util;
32+
import org.apache.cassandra.cql3.CQLTester;
33+
import org.apache.cassandra.db.ColumnFamilyStore;
34+
import org.apache.cassandra.io.sstable.format.SSTableReader;
35+
import org.apache.cassandra.schema.CompactionParams;
36+
import org.apache.cassandra.utils.concurrent.Ref;
37+
38+
import static org.junit.Assert.assertEquals;
39+
import static org.junit.Assert.assertTrue;
40+
import static org.junit.Assert.fail;
41+
42+
/**
43+
* Tests for garbage collection with only_purge_repaired_tombstones enabled.
44+
*
45+
* Specifically tests the fix for the ConcurrentModificationException that occurred
46+
* when calling nodetool garbagecollect on a table with mixed repaired/unrepaired
47+
* SSTables and only_purge_repaired_tombstones=true.
48+
*
49+
* The bug was in CompactionManager.performGarbageCollection() where the code
50+
* iterated directly over transaction.originals() while calling transaction.cancel()
51+
* inside the loop, causing the underlying collection to be modified during iteration.
52+
*/
53+
public class CompactionGarbageCollectOnlyPurgeRepairedTest extends CQLTester
54+
{
55+
public static final Logger LOGGER = LoggerFactory.getLogger(CompactionGarbageCollectOnlyPurgeRepairedTest.class);
56+
57+
/**
58+
* Tests that garbage collection completes
59+
* when there are mixed repaired and unrepaired SSTables with only_purge_repaired_tombstones=true
60+
*/
61+
@Test
62+
public void testOnlyPurgeRepaired() throws Throwable
63+
{
64+
// Create table with UnifiedCompactionStrategy and only_purge_repaired_tombstones=true
65+
createTable("CREATE TABLE %s (id int PRIMARY KEY, data text) " +
66+
"WITH gc_grace_seconds=0 " +
67+
"AND compaction = {'class':'UnifiedCompactionStrategy', 'only_purge_repaired_tombstones':true}");
68+
69+
ColumnFamilyStore cfs = getCurrentColumnFamilyStore();
70+
cfs.disableAutoCompaction();
71+
72+
// Create multiple SSTables
73+
for (int i = 0; i < 5; i++)
74+
{
75+
execute("INSERT INTO %s (id, data) VALUES (?, ?)", i, "data" + i);
76+
Util.flush(cfs);
77+
}
78+
79+
Set<SSTableReader> sstables = cfs.getLiveSSTables();
80+
assertEquals("Should have 5 SSTables", 5, sstables.size());
81+
82+
// Mark some SSTables as repaired (alternate pattern to ensure mix)
83+
int count = 0;
84+
for (SSTableReader sstable : sstables)
85+
{
86+
if (count % 2 == 0)
87+
{
88+
repair(cfs, sstable);
89+
}
90+
count++;
91+
}
92+
93+
// Verify we have a mix of repaired and unrepaired
94+
long repairedCount = cfs.getLiveSSTables().stream().filter(SSTableReader::isRepaired).count();
95+
long unrepairedCount = cfs.getLiveSSTables().stream().filter(s -> !s.isRepaired()).count();
96+
assertTrue("Should have at least 1 repaired SSTable", repairedCount >= 1);
97+
assertTrue("Should have at least 1 unrepaired SSTable", unrepairedCount >= 1);
98+
99+
// This should NOT throw ConcurrentModificationException
100+
// Before the fix, this would fail when iterating over originals() while cancel() modifies it
101+
try
102+
{
103+
CompactionManager.instance.performGarbageCollection(cfs, CompactionParams.TombstoneOption.CELL, 1);
104+
}
105+
catch (ConcurrentModificationException e)
106+
{
107+
fail("Garbage collection should not throw ConcurrentModificationException. " +
108+
"This indicates the bug in filterSSTables() where transaction.originals() is " +
109+
"iterated while transaction.cancel() modifies the underlying collection.");
110+
}
111+
}
112+
113+
/**
114+
* Tests that garbage collection works when ALL SSTables are unrepaired.
115+
* In this case, all SSTables should be cancelled (not processed for GC)
116+
* since only_purge_repaired_tombstones is true.
117+
*/
118+
@Test
119+
public void testGarbageCollectAllUnrepaired() throws Throwable
120+
{
121+
createTable("CREATE TABLE %s (id int PRIMARY KEY, data text) " +
122+
"WITH gc_grace_seconds=0 " +
123+
"AND compaction = {'class':'UnifiedCompactionStrategy', 'only_purge_repaired_tombstones':true}");
124+
125+
ColumnFamilyStore cfs = getCurrentColumnFamilyStore();
126+
cfs.disableAutoCompaction();
127+
128+
// Create SSTables (all unrepaired by default)
129+
for (int i = 0; i < 3; i++)
130+
{
131+
execute("INSERT INTO %s (id, data) VALUES (?, ?)", i, "data" + i);
132+
Util.flush(cfs);
133+
}
134+
135+
// Verify all are unrepaired
136+
long unrepairedCount = cfs.getLiveSSTables().stream().filter(s -> !s.isRepaired()).count();
137+
assertEquals("All 3 SSTables should be unrepaired", 3, unrepairedCount);
138+
139+
// Should complete without error - all SSTables will be cancelled
140+
CompactionManager.instance.performGarbageCollection(cfs, CompactionParams.TombstoneOption.CELL, 1);
141+
}
142+
143+
/**
144+
* Tests that garbage collection works when ALL SSTables are repaired.
145+
* In this case, no cancellation happens, so no risk of ConcurrentModificationException.
146+
*/
147+
@Test
148+
public void testAllRepaired() throws Throwable
149+
{
150+
createTable("CREATE TABLE %s (id int PRIMARY KEY, data text) " +
151+
"WITH gc_grace_seconds=0 " +
152+
"AND compaction = {'class':'UnifiedCompactionStrategy', 'only_purge_repaired_tombstones':true}");
153+
154+
ColumnFamilyStore cfs = getCurrentColumnFamilyStore();
155+
cfs.disableAutoCompaction();
156+
157+
// Create and repair all SSTables
158+
for (int i = 0; i < 3; i++)
159+
{
160+
execute("INSERT INTO %s (id, data) VALUES (?, ?)", i, "data" + i);
161+
Util.flush(cfs);
162+
}
163+
164+
for (SSTableReader sstable : cfs.getLiveSSTables())
165+
{
166+
repair(cfs, sstable);
167+
}
168+
169+
// Verify all are repaired
170+
long repairedCount = cfs.getLiveSSTables().stream().filter(SSTableReader::isRepaired).count();
171+
assertEquals("All 3 SSTables should be repaired", 3, repairedCount);
172+
173+
// Should complete without error
174+
CompactionManager.instance.performGarbageCollection(cfs, CompactionParams.TombstoneOption.CELL, 1);
175+
}
176+
177+
/**
178+
* Tests garbage collection without only_purge_repaired_tombstones (baseline test).
179+
* This code path doesn't involve the problematic iteration with cancel().
180+
*/
181+
@Test
182+
public void testWithoutOnlyPurgeRepaired() throws Throwable
183+
{
184+
createTable("CREATE TABLE %s (id int PRIMARY KEY, data text) " +
185+
"WITH gc_grace_seconds=0 " +
186+
"AND compaction = {'class':'UnifiedCompactionStrategy'}");
187+
188+
ColumnFamilyStore cfs = getCurrentColumnFamilyStore();
189+
cfs.disableAutoCompaction();
190+
191+
execute("INSERT INTO %s (id, data) VALUES (1, 'data1')");
192+
Util.flush(cfs);
193+
194+
// Should complete without error
195+
CompactionManager.instance.performGarbageCollection(cfs, CompactionParams.TombstoneOption.CELL, 1);
196+
}
197+
198+
/**
199+
* Tests with SizeTieredCompactionStrategy to ensure the fix doesn't break
200+
* the original behavior reported in CASSANDRA-14204.
201+
*/
202+
@Test
203+
public void testSTCS() throws Throwable
204+
{
205+
createTable("CREATE TABLE %s (id int PRIMARY KEY, data text) " +
206+
"WITH gc_grace_seconds=0 " +
207+
"AND compaction = {'class':'SizeTieredCompactionStrategy', 'only_purge_repaired_tombstones':true}");
208+
209+
ColumnFamilyStore cfs = getCurrentColumnFamilyStore();
210+
cfs.disableAutoCompaction();
211+
212+
// Create multiple SSTables
213+
for (int i = 0; i < 4; i++)
214+
{
215+
execute("INSERT INTO %s (id, data) VALUES (?, ?)", i, "data" + i);
216+
Util.flush(cfs);
217+
}
218+
219+
// Mark half as repaired
220+
Iterator<SSTableReader> iter = cfs.getLiveSSTables().iterator();
221+
if (iter.hasNext()) repair(cfs, iter.next());
222+
if (iter.hasNext()) repair(cfs, iter.next());
223+
224+
// Should complete without ConcurrentModificationException
225+
CompactionManager.instance.performGarbageCollection(cfs, CompactionParams.TombstoneOption.CELL, 1);
226+
}
227+
228+
/**
229+
* Tests with LeveledCompactionStrategy to ensure the fix works across
230+
* all compaction strategies.
231+
*/
232+
@Test
233+
public void testLCS() throws Throwable
234+
{
235+
createTable("CREATE TABLE %s (id int PRIMARY KEY, data text) " +
236+
"WITH gc_grace_seconds=0 " +
237+
"AND compaction = {'class':'LeveledCompactionStrategy', 'only_purge_repaired_tombstones':true}");
238+
239+
ColumnFamilyStore cfs = getCurrentColumnFamilyStore();
240+
cfs.disableAutoCompaction();
241+
242+
// Create multiple SSTables
243+
for (int i = 0; i < 4; i++)
244+
{
245+
execute("INSERT INTO %s (id, data) VALUES (?, ?)", i, "data" + i);
246+
Util.flush(cfs);
247+
}
248+
249+
// Mark half as repaired
250+
Iterator<SSTableReader> iter = cfs.getLiveSSTables().iterator();
251+
if (iter.hasNext()) repair(cfs, iter.next());
252+
if (iter.hasNext()) repair(cfs, iter.next());
253+
254+
// Should complete without ConcurrentModificationException
255+
CompactionManager.instance.performGarbageCollection(cfs, CompactionParams.TombstoneOption.CELL, 1);
256+
}
257+
258+
/**
259+
* Tests that reference leaks are detected when ConcurrentModificationException
260+
* prevents proper transaction cleanup.
261+
*
262+
* This test uses Cassandra's Ref.OnLeak callback to detect when SSTable references
263+
* are not properly released. The bug in performGarbageCollection() causes a
264+
* ConcurrentModificationException which prevents the transaction from completing
265+
* properly, leaving SSTable references unreleased (leaked).
266+
*
267+
* The leak would show in logs as:
268+
* ERROR [Reference-Reaper] - LEAK DETECTED: a reference to ...
269+
*/
270+
@Test
271+
public void testLeakDetectionWithMixedRepairedUnrepaired() throws Throwable
272+
{
273+
// Track detected leaks
274+
CopyOnWriteArrayList<Object> detectedLeaks = new CopyOnWriteArrayList<>();
275+
276+
try
277+
{
278+
// Install leak detector callback
279+
Ref.setOnLeak(state -> {
280+
detectedLeaks.add(state);
281+
LOGGER.error("LEAK DETECTED in test: " + state);
282+
});
283+
284+
createTable("CREATE TABLE %s (id int PRIMARY KEY, data text) " +
285+
"WITH gc_grace_seconds=0 " +
286+
"AND compaction = {'class':'UnifiedCompactionStrategy', 'only_purge_repaired_tombstones':true}");
287+
288+
ColumnFamilyStore cfs = getCurrentColumnFamilyStore();
289+
cfs.disableAutoCompaction();
290+
291+
// Create multiple SSTables
292+
for (int i = 0; i < 5; i++)
293+
{
294+
execute("INSERT INTO %s (id, data) VALUES (?, ?)", i, "data" + i);
295+
Util.flush(cfs);
296+
}
297+
298+
// Mark some as repaired to create mixed state
299+
int count = 0;
300+
for (SSTableReader sstable : cfs.getLiveSSTables())
301+
{
302+
if (count % 2 == 0)
303+
{
304+
repair(cfs, sstable);
305+
}
306+
count++;
307+
}
308+
309+
// Attempt garbage collection - this may throw ConcurrentModificationException
310+
// if the bug is present, which would prevent proper reference cleanup
311+
boolean exceptionThrown = false;
312+
try
313+
{
314+
CompactionManager.instance.performGarbageCollection(cfs, CompactionParams.TombstoneOption.CELL, 1);
315+
}
316+
catch (ConcurrentModificationException e)
317+
{
318+
exceptionThrown = true;
319+
LOGGER.error("ConcurrentModificationException caught (bug is present): " + e.getMessage());
320+
}
321+
322+
// Force garbage collection to trigger leak detection
323+
// The Ref cleanup happens via PhantomReference when objects are GC'd
324+
for (int i = 0; i < 5; i++)
325+
{
326+
System.gc();
327+
Thread.sleep(100);
328+
}
329+
330+
// Give the Reference-Reaper thread time to process
331+
Thread.sleep(500);
332+
333+
// If exception was thrown, we expect leaks (bug is present)
334+
// If no exception, we should have no leaks (fix is working)
335+
if (exceptionThrown)
336+
{
337+
// Bug is present - we may see leaks due to improper cleanup
338+
LOGGER.error("Bug detected: ConcurrentModificationException was thrown.");
339+
LOGGER.error("Leaks detected: " + detectedLeaks.size());
340+
fail("ConcurrentModificationException was thrown, indicating the bug is present. " +
341+
"This can cause reference leaks.");
342+
}
343+
else
344+
{
345+
// Fix is working - verify no leaks were detected
346+
assertTrue("No leaks should be detected when garbage collection completes successfully. " +
347+
"Detected leaks: " + detectedLeaks,
348+
detectedLeaks.isEmpty());
349+
}
350+
}
351+
finally
352+
{
353+
// Clear leak handler
354+
Ref.setOnLeak(null);
355+
}
356+
}
357+
358+
/**
359+
* Helper method to mark an SSTable as repaired.
360+
* Follows the pattern from RepairedDataTombstonesTest.
361+
*/
362+
private static void repair(ColumnFamilyStore cfs, SSTableReader sstable) throws IOException
363+
{
364+
sstable.descriptor.getMetadataSerializer().mutateRepairMetadata(sstable.descriptor, System.currentTimeMillis(), null, false);
365+
sstable.reloadSSTableMetadata();
366+
cfs.getTracker().notifySSTableRepairedStatusChanged(Collections.singleton(sstable));
367+
}
368+
}

0 commit comments

Comments
 (0)