Add tombstone-interleaving scenarios; fix live-marker stats poisoning

Three new differential scenarios cover row-liveness and TTL shapes: UPDATE-built rows
without primary-key liveness and primary-key-only inserts with liveness but no cells
(rowLivenessShapes), row-TTL vs cell-TTL mixtures including same-timestamp expiring ties
with different TTLs (rowAndCellTtlMix), and same-timestamp ties between an expiring cell
and a live one in both directions, the CASSANDRA-14592 rule (expiringVsLiveTies).

Also fixes a stats-poisoning bug: DeletionTime.ReusableDeletionTime.reset(long, long)
classified NO_DELETION_TIME (Long.MAX_VALUE, the canonical "no deletion" long) as invalid
instead of mapping it to the LIVE marker the way Cell.deletionTimeLongToUnsignedInteger
does, so resetting from LIVE's long values produced a NON-live deletion. A live marker
slipping past MetadataCollector's isLive guard this way would record
minTimestamp=Long.MIN_VALUE, a bogus tombstone count, and an encoding-stats minimum
collapsed to the epoch — stats-only severity, but those stats feed compaction heuristics.
DeletionTimeTest.resetWithLiveLongsStaysLive pins the round-trip directly.
This commit is contained in:
Jon Haddad 2026-07-27 11:04:17 -07:00
parent a277c2015d
commit 18b992569f
3 changed files with 173 additions and 1 deletions

View File

@ -442,7 +442,13 @@ public abstract class DeletionTime implements Comparable<DeletionTime>, IMeasura
public void reset(long markedForDeleteAt, long localDeletionTime)
{
this.markedForDeleteAt = markedForDeleteAt;
if (localDeletionTime < 0 || localDeletionTime > Cell.MAX_DELETION_TIME) // invalid
if (localDeletionTime == Cell.NO_DELETION_TIME)
// NO_DELETION_TIME (Long.MAX_VALUE) is the canonical "no deletion" long and must
// round-trip to the LIVE marker (deletionTimeLongToUnsignedInteger semantics);
// classifying it as invalid made reset(LIVE.markedForDeleteAt(),
// LIVE.localDeletionTime()) produce a NON-live deletion
this.localDeletionTimeUnsignedInteger = LOCAL_DELETION_TIME_LIVE;
else if (localDeletionTime < 0 || localDeletionTime > Cell.MAX_DELETION_TIME) // invalid
this.localDeletionTimeUnsignedInteger = Cell.MAX_DELETION_TIME_UNSIGNED_INTEGER + 1;
else
this.localDeletionTimeUnsignedInteger = Cell.deletionTimeLongToUnsignedInteger(localDeletionTime);

View File

@ -0,0 +1,70 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.db;
import org.junit.Test;
import org.apache.cassandra.db.rows.Cell;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class DeletionTimeTest
{
/**
* NO_DELETION_TIME (Long.MAX_VALUE) is the canonical "no deletion" long value and must
* round-trip through the long-based reset to a LIVE deletion time, mirroring
* {@link Cell#deletionTimeLongToUnsignedInteger}. It used to be classified as invalid,
* so {@code reset(LIVE.markedForDeleteAt(), LIVE.localDeletionTime())} produced a
* NON-live deletion which let a live marker slip past MetadataCollector's isLive
* guard during cursor compaction and poison minTimestamp/tombstone stats.
*/
@Test
public void resetWithLiveLongsStaysLive()
{
DeletionTime.ReusableDeletionTime reusable = DeletionTime.ReusableDeletionTime.live();
assertTrue(reusable.isLive());
reusable.reset(DeletionTime.LIVE.markedForDeleteAt(), DeletionTime.LIVE.localDeletionTime());
assertTrue("reset with LIVE's long values must stay live, got mfda=" + reusable.markedForDeleteAt() +
" ldt=" + reusable.localDeletionTime(), reusable.isLive());
assertEquals(DeletionTime.LIVE.localDeletionTime(), reusable.localDeletionTime());
}
@Test
public void resetWithRealAndInvalidValues()
{
DeletionTime.ReusableDeletionTime reusable = DeletionTime.ReusableDeletionTime.live();
reusable.reset(123456789L, 1_700_000_000L);
assertFalse(reusable.isLive());
assertEquals(123456789L, reusable.markedForDeleteAt());
assertEquals(1_700_000_000L, reusable.localDeletionTime());
// negative and beyond-max (but not NO_DELETION_TIME) stay classified as invalid
reusable.reset(1L, -5L);
assertFalse(reusable.isLive());
assertFalse(reusable.validate());
reusable.reset(1L, Cell.MAX_DELETION_TIME + 1);
assertFalse(reusable.isLive());
assertFalse(reusable.validate());
}
}

View File

@ -443,4 +443,100 @@ public class EdgeCaseDifferentialCompactionTest extends DifferentialCompactionTe
assertCursorMatchesIteratorAcrossGenerations(cfs, ALLOWLIST);
}
/**
* Row liveness shapes: UPDATE-built rows carry NO primary-key liveness (different row
* flags than INSERT-built rows), primary-key-only INSERTs carry liveness and ZERO cells,
* and merges must reconcile liveness presence/absence across sstables exactly.
*/
@Test
public void rowLivenessShapes() throws Exception
{
createTable("CREATE TABLE %s (pk bigint, ck bigint, v1 text, v2 text, PRIMARY KEY (pk, ck))");
ColumnFamilyStore cfs = getCurrentColumnFamilyStore();
cfs.disableAutoCompaction();
// UPDATE-built rows (no liveness) and liveness-only rows in sstable 1
for (long ck = 0; ck < 10; ck++)
execute("UPDATE %s SET v1 = ?, v2 = ? WHERE pk = ? AND ck = ?", "u" + ck, "w" + ck, 1L, ck);
for (long ck = 10; ck < 15; ck++)
execute("INSERT INTO %s (pk, ck) VALUES (?, ?)", 1L, ck);
flush();
// sstable 2: INSERT onto UPDATE-rows (liveness arrives later), cell tombstones onto
// liveness-only rows (row must survive on liveness alone), cell delete that empties
// an UPDATE-row entirely (no liveness + no cells = row vanishes)
for (long ck = 0; ck < 4; ck++)
execute("INSERT INTO %s (pk, ck, v1) VALUES (?, ?, ?)", 1L, ck, "i" + ck);
for (long ck = 10; ck < 13; ck++)
execute("INSERT INTO %s (pk, ck, v1, v2) VALUES (?, ?, null, null)", 1L, ck);
execute("DELETE v1, v2 FROM %s WHERE pk = ? AND ck = ?", 1L, 5L);
flush();
assertCursorMatchesIteratorAcrossGenerations(cfs, ALLOWLIST);
}
/**
* Row-level TTL (INSERT USING TTL sets liveness TTL + cell TTLs) merged against
* cell-level TTL (UPDATE USING TTL sets only cell TTLs) and against plain writes;
* includes same-timestamp expiring writes whose TTLs differ (rules (c)/(d) of the
* resolveRegular decision table run off localExpirationTime/ttl, not just timestamps).
*/
@Test
public void rowAndCellTtlMix() throws Exception
{
createTable("CREATE TABLE %s (pk bigint, ck bigint, v1 text, v2 text, PRIMARY KEY (pk, ck))");
ColumnFamilyStore cfs = getCurrentColumnFamilyStore();
cfs.disableAutoCompaction();
for (long ck = 0; ck < 12; ck++)
execute("INSERT INTO %s (pk, ck, v1, v2) VALUES (?, ?, ?, ?) USING TTL 86400", 1L, ck, "a" + ck, "b" + ck);
flush();
// cell-level TTL different from the row TTL; plain overwrites clearing TTLs;
// expiring-vs-expiring same-timestamp ties with different TTLs
for (long ck = 0; ck < 6; ck++)
execute("UPDATE %s USING TTL 172800 SET v1 = ? WHERE pk = ? AND ck = ?", "c" + ck, 1L, ck);
for (long ck = 6; ck < 9; ck++)
execute("INSERT INTO %s (pk, ck, v1) VALUES (?, ?, ?)", 1L, ck, "plain" + ck);
for (long ck = 9; ck < 12; ck++)
execute("UPDATE %s USING TTL 100000 AND TIMESTAMP 5000 SET v2 = ? WHERE pk = ? AND ck = ?", "t1" + ck, 1L, ck);
flush();
for (long ck = 9; ck < 12; ck++)
execute("UPDATE %s USING TTL 50000 AND TIMESTAMP 5000 SET v2 = ? WHERE pk = ? AND ck = ?", "t2" + ck, 1L, ck);
flush();
assertCursorMatchesIteratorAcrossGenerations(cfs, ALLOWLIST);
}
/**
* Expiring-vs-live cells at the SAME timestamp, both directions across sstables: the
* CASSANDRA-14592 rule an expiring (or deleted) cell beats a live one on timestamp
* tie regardless of value. Implemented in resolveRegular rule (a); this pins it at the
* differential level (timestampTies only covered live-vs-live and delete-vs-live).
*/
@Test
public void expiringVsLiveTies() throws Exception
{
createTable("CREATE TABLE %s (pk bigint, ck bigint, v text, PRIMARY KEY (pk, ck))");
ColumnFamilyStore cfs = getCurrentColumnFamilyStore();
cfs.disableAutoCompaction();
// direction 1: live first, expiring second
for (long ck = 0; ck < 5; ck++)
execute("INSERT INTO %s (pk, ck, v) VALUES (?, ?, ?) USING TIMESTAMP 1000", 1L, ck, "zzz-live" + ck);
// direction 2 partition: expiring first
for (long ck = 0; ck < 5; ck++)
execute("INSERT INTO %s (pk, ck, v) VALUES (?, ?, ?) USING TIMESTAMP 1000 AND TTL 86400", 2L, ck, "aaa-ttl" + ck);
flush();
for (long ck = 0; ck < 5; ck++)
execute("INSERT INTO %s (pk, ck, v) VALUES (?, ?, ?) USING TIMESTAMP 1000 AND TTL 86400", 1L, ck, "aaa-ttl" + ck);
for (long ck = 0; ck < 5; ck++)
execute("INSERT INTO %s (pk, ck, v) VALUES (?, ?, ?) USING TIMESTAMP 1000", 2L, ck, "zzz-live" + ck);
flush();
assertCursorMatchesIteratorAcrossGenerations(cfs, ALLOWLIST);
}
}