2121import java .io .IOException ;
2222import java .util .ArrayList ;
2323import java .util .Collections ;
24+ import java .util .HashSet ;
2425import java .util .List ;
2526import java .util .Map ;
2627import java .util .Set ;
28+ import java .util .concurrent .atomic .AtomicInteger ;
2729
2830import org .junit .Assert ;
2931import org .junit .Before ;
3234
3335import org .apache .cassandra .SchemaLoader ;
3436import org .apache .cassandra .Util ;
37+ import org .apache .cassandra .config .DatabaseDescriptor ;
3538import org .apache .cassandra .cql3 .QueryProcessor ;
3639import org .apache .cassandra .cql3 .UntypedResultSet ;
3740import org .apache .cassandra .cql3 .statements .schema .CreateTableStatement ;
3841import org .apache .cassandra .db .ColumnFamilyStore ;
42+ import org .apache .cassandra .db .Directories ;
3943import org .apache .cassandra .db .SystemKeyspace ;
44+ import org .apache .cassandra .db .compaction .writers .CompactionAwareWriter ;
45+ import org .apache .cassandra .db .compaction .writers .MaxSSTableSizeWriter ;
46+ import org .apache .cassandra .db .lifecycle .ILifecycleTransaction ;
4047import org .apache .cassandra .db .lifecycle .LifecycleTransaction ;
48+ import org .apache .cassandra .db .lifecycle .SSTableSet ;
49+ import org .apache .cassandra .db .lifecycle .View ;
50+ import org .apache .cassandra .db .lifecycle .WrappedLifecycleTransaction ;
4151import org .apache .cassandra .db .marshal .UTF8Type ;
52+ import org .apache .cassandra .io .sstable .SSTableRewriter ;
4253import org .apache .cassandra .io .sstable .format .SSTableReader ;
4354import org .apache .cassandra .schema .KeyspaceParams ;
4455import org .apache .cassandra .schema .Schema ;
4556import org .apache .cassandra .schema .TableMetadata ;
4657import org .apache .cassandra .service .ActiveRepairService ;
4758import org .apache .cassandra .utils .FBUtilities ;
4859import org .apache .cassandra .utils .TimeUUID ;
60+ import org .apache .cassandra .utils .concurrent .Refs ;
4961import org .apache .cassandra .utils .concurrent .Transactional ;
5062
5163import static java .lang .String .format ;
@@ -57,20 +69,28 @@ public class CompactionTaskTest
5769 private static TableMetadata cfm ;
5870 private static ColumnFamilyStore cfs ;
5971
72+ private static TableMetadata gcGraceCfm ;
73+ private static ColumnFamilyStore gcGraceCfs ;
74+
6075 @ BeforeClass
6176 public static void setUpClass () throws Exception
6277 {
6378 SchemaLoader .prepareServer ();
6479 cfm = CreateTableStatement .parse ("CREATE TABLE tbl (k INT PRIMARY KEY, v INT)" , "ks" ).build ();
65- SchemaLoader .createKeyspace ("ks" , KeyspaceParams .simple (1 ), cfm );
80+ gcGraceCfm = CreateTableStatement .parse ("CREATE TABLE tbl2 (k INT PRIMARY KEY, col1 INT, col2 INT, col3 INT, data TEXT) WITH gc_grace_seconds = 0" , "ks" ).build ();
81+ SchemaLoader .createKeyspace ("ks" , KeyspaceParams .simple (1 ), cfm , gcGraceCfm );
6682 cfs = Schema .instance .getColumnFamilyStoreInstance (cfm .id );
83+ gcGraceCfs = Schema .instance .getColumnFamilyStoreInstance (gcGraceCfm .id );
6784 }
6885
6986 @ Before
7087 public void setUp () throws Exception
7188 {
7289 cfs .getCompactionStrategyManager ().enable ();
7390 cfs .truncateBlocking ();
91+
92+ gcGraceCfs .getCompactionStrategyManager ().enable ();
93+ gcGraceCfs .truncateBlocking ();
7494 }
7595
7696 @ Test
@@ -142,6 +162,172 @@ public void compactionInterruption() throws Exception
142162 Assert .assertEquals (Transactional .AbstractTransactional .State .ABORTED , txn .state ());
143163 }
144164
165+ /**
166+ * Test that even some SSTables are fully expired, we can still select and reference them
167+ * while they are part of compaction.
168+ *
169+ * @see <a href="https://issues.apache.org/jira/browse/CASSANDRA-19776>CASSANDRA-19776</a>
170+ */
171+ @ Test
172+ public void testFullyExpiredSSTablesAreNotReleasedPrematurely ()
173+ {
174+ Assert .assertEquals (0 , gcGraceCfs .getLiveSSTables ().size ());
175+ gcGraceCfs .getCompactionStrategyManager ().disable ();
176+
177+ // Use large SSTables (10+ MiB) so that switching to new output SSTables happens during
178+ // compaction. Without large enough SSTables, the output fits in one SSTable and no switch happens
179+ int numKeys = 5000 ;
180+ String data = "x" .repeat (2048 ); // ~2KB padding per row
181+
182+ // Similar technique to get fully expired SSTables as in TTLExpiryTest#testAggressiveFullyExpired
183+ // SSTable 1 (will be fully expired - superseded by SSTable 2)
184+ for (int k = 0 ; k < numKeys ; k ++)
185+ {
186+ QueryProcessor .executeInternal ("INSERT INTO ks.tbl2 (k, col1, data) VALUES (?, 1, ?) USING TIMESTAMP 1 AND TTL 1" , k , data );
187+ QueryProcessor .executeInternal ("INSERT INTO ks.tbl2 (k, col2, data) VALUES (?, 1, ?) USING TIMESTAMP 3 AND TTL 1" , k , data );
188+ }
189+ Util .flush (gcGraceCfs );
190+
191+ // SSTable 2 (will be fully expired - superseded by SSTables 3 and 4)
192+ for (int k = 0 ; k < numKeys ; k ++)
193+ {
194+ QueryProcessor .executeInternal ("INSERT INTO ks.tbl2 (k, col1, data) VALUES (?, 1, ?) USING TIMESTAMP 2 AND TTL 1" , k , data );
195+ QueryProcessor .executeInternal ("INSERT INTO ks.tbl2 (k, col2, data) VALUES (?, 1, ?) USING TIMESTAMP 5 AND TTL 1" , k , data );
196+ }
197+ Util .flush (gcGraceCfs );
198+
199+ Set <SSTableReader > toBeObsolete = new HashSet <>(gcGraceCfs .getLiveSSTables ());
200+ Assert .assertEquals (2 , toBeObsolete .size ());
201+
202+ // SSTable 3 (not fully expired)
203+ for (int k = 0 ; k < numKeys ; k ++)
204+ {
205+ QueryProcessor .executeInternal ("INSERT INTO ks.tbl2 (k, col1, data) VALUES (?, 1, ?) USING TIMESTAMP 4 AND TTL 1" , k , data );
206+ QueryProcessor .executeInternal ("INSERT INTO ks.tbl2 (k, col3, data) VALUES (?, 1, ?) USING TIMESTAMP 7 AND TTL 1" , k , data );
207+ }
208+ Util .flush (gcGraceCfs );
209+
210+ // SSTable 4 (not fully expired - col3 has longer TTL)
211+ for (int k = 0 ; k < numKeys ; k ++)
212+ {
213+ QueryProcessor .executeInternal ("INSERT INTO ks.tbl2 (k, col3, data) VALUES (?, 1, ?) USING TIMESTAMP 6 AND TTL 3" , k , data );
214+ QueryProcessor .executeInternal ("INSERT INTO ks.tbl2 (k, col2, data) VALUES (?, 1, ?) USING TIMESTAMP 8 AND TTL 1" , k , data );
215+ }
216+ Util .flush (gcGraceCfs );
217+
218+ Set <SSTableReader > sstables = gcGraceCfs .getLiveSSTables ();
219+ Assert .assertEquals (4 , sstables .size ());
220+
221+ // Enable preemptive opening so that SSTableRewriter.switchWriter() calls checkpoint().
222+ int originalPreemptiveOpenInterval = DatabaseDescriptor .getSSTablePreemptiveOpenIntervalInMiB ();
223+ DatabaseDescriptor .setSSTablePreemptiveOpenIntervalInMiB (2 );
224+
225+ // collector of stacktraces to later check if checkpoint was called in switchWriter
226+ final List <StackTraceElement []> stacks = new ArrayList <>();
227+
228+ try
229+ {
230+ AtomicInteger checkpointCount = new AtomicInteger (0 );
231+
232+ // Hook into transaction's checkpoint and commit methods to verify that after
233+ // checkpointing, all SSTables (including fully expired ones) remain referenceable.
234+ ILifecycleTransaction txn = new WrappedLifecycleTransaction (gcGraceCfs .getTracker ().tryModify (sstables , OperationType .COMPACTION ))
235+ {
236+ @ Override
237+ public void checkpoint ()
238+ {
239+ stacks .add (Thread .currentThread ().getStackTrace ());
240+
241+ for (SSTableReader r : toBeObsolete )
242+ Assert .assertTrue (this .isObsolete (r ));
243+
244+ assertAllSSTablesAreReferenceable ();
245+
246+ super .checkpoint ();
247+
248+ // This is the critical assertion: after checkpoint(), all SSTables in the
249+ // CANONICAL view must still be referenceable. Before the fix, fully expired
250+ // SSTables would lose their references here, causing selectAndReference() to
251+ // spin loop (CASSANDRA-19776).
252+ assertAllSSTablesAreReferenceable ();
253+
254+ checkpointCount .incrementAndGet ();
255+ }
256+
257+ @ Override
258+ public Throwable commit (Throwable accumulate )
259+ {
260+ assertAllSSTablesAreReferenceable ();
261+ return super .commit (accumulate );
262+ }
263+
264+ private void assertAllSSTablesAreReferenceable ()
265+ {
266+ // This simulates what EstimatedPartitionCount metric and similar code paths do.
267+ // It is crucial that tryRef does not return null; a null result means some SSTables
268+ // are not referenceable, which would cause selectAndReference() to spin loop.
269+ ColumnFamilyStore .ViewFragment view = gcGraceCfs .select (View .selectFunction (SSTableSet .CANONICAL ));
270+ Refs <SSTableReader > refs = Refs .tryRef (view .sstables );
271+ Assert .assertNotNull ("Some SSTables in CANONICAL view are not referenceable (CASSANDRA-19776)" , refs );
272+ refs .close ();
273+ }
274+ };
275+
276+ // Use MaxSSTableSizeWriter with a small max size (2 MiB) to force output sstable switches during compaction.
277+ long maxSSTableSize = 2L * 1024 * 1024 ; // 2 MiB
278+ CompactionTask task = new CompactionTask (gcGraceCfs , txn , FBUtilities .nowInSeconds () + 2 )
279+ {
280+ @ Override
281+ public CompactionAwareWriter getCompactionAwareWriter (ColumnFamilyStore cfs ,
282+ Directories directories ,
283+ ILifecycleTransaction transaction ,
284+ Set <SSTableReader > nonExpiredSSTables )
285+ {
286+ return new MaxSSTableSizeWriter (cfs , directories , transaction , nonExpiredSSTables ,
287+ maxSSTableSize , 0 );
288+ }
289+ };
290+
291+ try (CompactionController compactionController = task .getCompactionController (task .inputSSTables ()))
292+ {
293+ Set <SSTableReader > fullyExpiredSSTables = compactionController .getFullyExpiredSSTables ();
294+ Assert .assertEquals (2 , fullyExpiredSSTables .size ());
295+ task .execute (null );
296+ }
297+
298+ // Verify that checkpoint was called more than once, proving that output sstable switching
299+ // happened during compaction. Without MaxSSTableSizeWriter and large enough SSTables,
300+ // checkpoint would only be called at the end, which would not exercise the CASSANDRA-19776 fix.
301+ Assert .assertTrue ("Expected checkpoint() to be called more than once during compaction, but was called "
302+ + checkpointCount .get () + " time(s). Output sstable switching did not occur." ,
303+ checkpointCount .get () > 1 );
304+ }
305+ finally
306+ {
307+ DatabaseDescriptor .setSSTablePreemptiveOpenIntervalInMiB (originalPreemptiveOpenInterval );
308+ }
309+
310+ Assert .assertFalse (stacks .isEmpty ());
311+
312+ boolean checkpointCalledInSSTableRewriter = false ;
313+
314+ for (int i = 0 ; i < stacks .size (); i ++)
315+ {
316+ for (StackTraceElement element : stacks .get (i ))
317+ {
318+ if (element .getClassName ().equals (SSTableRewriter .class .getName ())
319+ && (element .getMethodName ().equals ("switchWriter" )
320+ || element .getMethodName ().equals ("maybeReopenEarly" )))
321+ {
322+ checkpointCalledInSSTableRewriter = true ;
323+ break ;
324+ }
325+ }
326+ }
327+
328+ Assert .assertTrue (checkpointCalledInSSTableRewriter );
329+ }
330+
145331 private static void mutateRepaired (SSTableReader sstable , long repairedAt , TimeUUID pendingRepair , boolean isTransient ) throws IOException
146332 {
147333 sstable .descriptor .getMetadataSerializer ().mutateRepairMetadata (sstable .descriptor , repairedAt , pendingRepair , isTransient );
0 commit comments