This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch cassandra-5.0
in repository https://gitbox.apache.org/repos/asf/cassandra.git
The following commit(s) were added to refs/heads/cassandra-5.0 by this push:
new e8b7eac7ed Bound and chunk the BTI partition index preload
e8b7eac7ed is described below
commit e8b7eac7ed1e62a8d3cccb014c6c4b3f9f0a33c4
Author: Alexandre Viau <[email protected]>
AuthorDate: Mon Sep 28 09:14:11 2026 -0400
Bound and chunk the BTI partition index preload
Opening a BTI SSTable whose bloom filter is uninformative warms the whole
Partitions.db, one byte per page through a reader that follows
disk_access_mode. That
costs one pread or one readahead-bounded page fault per page: on a 309 GiB
index,
81.1M reads at 50 MiB/s regardless of access mode. It also warms far more
than the
available page cache can hold.
Add cassandra.bti.partition_index_preload_size to bound how much is warmed.
The tail
is warmed because the trie is written bottom-up, so the upper levels
traversed by
every lookup are at the end of the file, while the bulk at the front is
leaf pages
that a lookup reads one of.
The property takes a human-readable size, e.g. 512MiB. 0B skips warming
entirely,
which was not previously possible: preload is enabled whenever the bloom
filter is
uninformative, so the only way to avoid it was to lower
bloom_filter_fp_chance below
1.0. A negative value warms the whole index and is the default, so
behaviour is
unchanged unless the property is set.
The warmed range is read in 1 MiB positioned reads on the channel rather
than one
byte per page, which are unaffected by disk_access_mode; page cache is
shared between
the buffered and mapped views of a file, so a mapped lookup still finds the
data
resident.
The trade-offs: the whole range is copied into userspace rather than one
byte per
page, and under a mapped mode the first lookup takes a minor fault the walk
had
already resolved. Under disk_access_mode: standard the warm no longer
populates the
chunk cache, nor evicts it; lookups fill it on first touch instead.
Assisted-by: Claude Code:claude-opus-5
patch by Alexandre Viau; reviewed by Brandon Williams, Branimir Lambov for
CASSANDRA-21632
---
CHANGES.txt | 1 +
.../config/CassandraRelevantProperties.java | 2 +
.../io/sstable/format/bti/PartitionIndex.java | 77 ++++++++++++++++++----
.../io/sstable/format/bti/PartitionIndexTest.java | 45 +++++++++++++
4 files changed, 114 insertions(+), 11 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 65e27fa29b..1cdbf799c1 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,4 +1,5 @@
5.0.10
+ * Bound and chunk the BTI partition index preload (CASSANDRA-21632)
* Restore volatile on CommitLogSegment.cdcState dropped in merge
(CASSANDRA-21665)
* Fix compressed scan read-ahead buffer Block leak and chunk-cache pollution
by scans (CASSANDRA-21671)
* A read on a replica that does not know the index yet fails with a
NullPointerException (CASSANDRA-21658)
diff --git
a/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java
b/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java
index 8b8fac9270..1a9e2dfa43 100644
--- a/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java
+++ b/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java
@@ -74,6 +74,8 @@ public enum CassandraRelevantProperties
*/
BOOTSTRAP_SKIP_SCHEMA_CHECK("cassandra.skip_schema_check"),
BROADCAST_INTERVAL_MS("cassandra.broadcast_interval_ms", "60000"),
+ /** Caps how much of a BTI partition index is preloaded, e.g. "16MiB". 0B
disables preloading; negative (default) preloads it all. */
+
BTI_PARTITION_INDEX_PRELOAD_SIZE("cassandra.bti.partition_index_preload_size",
"-1B"),
BTREE_BRANCH_SHIFT("cassandra.btree.branchshift", "5"),
BTREE_FAN_FACTOR("cassandra.btree.fanfactor"),
/** Represents the maximum size (in bytes) of a serialized mutation that
can be cached **/
diff --git
a/src/java/org/apache/cassandra/io/sstable/format/bti/PartitionIndex.java
b/src/java/org/apache/cassandra/io/sstable/format/bti/PartitionIndex.java
index cfdfe37eed..a082547607 100644
--- a/src/java/org/apache/cassandra/io/sstable/format/bti/PartitionIndex.java
+++ b/src/java/org/apache/cassandra/io/sstable/format/bti/PartitionIndex.java
@@ -17,6 +17,7 @@
*/
package org.apache.cassandra.io.sstable.format.bti;
+import java.io.EOFException;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.ByteBuffer;
@@ -25,6 +26,7 @@ import com.google.common.annotations.VisibleForTesting;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.apache.cassandra.config.CassandraRelevantProperties;
import org.apache.cassandra.db.DecoratedKey;
import org.apache.cassandra.db.PartitionPosition;
import org.apache.cassandra.dht.IPartitioner;
@@ -37,7 +39,7 @@ import org.apache.cassandra.io.util.DataOutputPlus;
import org.apache.cassandra.io.util.File;
import org.apache.cassandra.io.util.FileDataInput;
import org.apache.cassandra.io.util.FileHandle;
-import org.apache.cassandra.io.util.PageAware;
+import org.apache.cassandra.io.util.FileUtils;
import org.apache.cassandra.io.util.Rebufferer;
import org.apache.cassandra.io.util.SizedInts;
import org.apache.cassandra.utils.ByteBufferUtil;
@@ -78,6 +80,13 @@ public class PartitionIndex implements SharedCloseable
public static final int FOOTER_LENGTH = 3 * 8;
private static final int FLAG_HAS_HASH_BYTE = 8;
+ /**
+ * Chunk size used to warm the partition index in {@link #warmIndex}.
Large enough that reads are
+ * device-bound rather than bounded by per-request overhead, small enough
to be a negligible
+ * transient allocation per opening thread.
+ */
+ private static final int PRELOAD_CHUNK_SIZE = (int) FileUtils.ONE_MIB;
+
@VisibleForTesting
public PartitionIndex(FileHandle fh, long trieRoot, long keyCount,
DecoratedKey first, DecoratedKey last)
{
@@ -196,21 +205,67 @@ public class PartitionIndex implements SharedCloseable
DecoratedKey first = partitioner != null ?
partitioner.decorateKey(ByteBufferUtil.readWithShortLength(rdr)) : null;
DecoratedKey last = partitioner != null ?
partitioner.decorateKey(ByteBufferUtil.readWithShortLength(rdr)) : null;
if (preload)
- {
- int csum = 0;
- // force a read of all the pages of the index
- for (long pos = 0; pos < fh.dataLength(); pos +=
PageAware.PAGE_SIZE)
- {
- rdr.seek(pos);
- csum += rdr.readByte();
- }
- logger.trace("Checksum {}", csum); // Note: trace is
required so that reads aren't optimized away.
- }
+ warmIndex(fh, firstPos);
return new PartitionIndex(fh, root, keyCount, first, last);
}
}
+ /**
+ * Warms the partition index so trie lookups don't each fault it in.
+ *
+ * Chunked positioned reads on the channel, not one byte per page through
a {@link FileDataInput},
+ * which follows {@code disk_access_mode} and so costs a pread or a page
fault per page. A
+ * positioned read is unaffected by access mode, and page cache is shared
between the buffered and
+ * mapped views of a file, so a mapped lookup afterwards still finds the
data resident.
+ *
+ * When bounded, warms the trie's tail, since the trie is written
bottom-up and every lookup
+ * traverses the upper levels that end up there. The bound is relative to
{@code firstPos} (the
+ * trie's end), not the file's end, since the file tail past it isn't trie
data.
+ *
+ * @param firstPos offset where the trie ends and the first/last key data
begins
+ * @return the number of bytes read
+ * @see CassandraRelevantProperties#BTI_PARTITION_INDEX_PRELOAD_SIZE
+ */
+ @VisibleForTesting
+ static long warmIndex(FileHandle fh, long firstPos) throws EOFException
+ {
+ long limit =
CassandraRelevantProperties.BTI_PARTITION_INDEX_PRELOAD_SIZE.getSizeInBytes();
+ if (limit == 0)
+ {
+ logger.trace("Partition index warming is disabled, not warming
{}", fh.path());
+ return 0;
+ }
+
+ if (firstPos <= 0)
+ return 0;
+
+ long start = limit > 0 && limit < firstPos ? firstPos - limit : 0;
+
+ ByteBuffer buffer = ByteBuffer.allocateDirect((int)
Math.min(PRELOAD_CHUNK_SIZE, firstPos - start));
+ try
+ {
+ long pos = start;
+ while (pos < firstPos)
+ {
+ buffer.clear();
+ buffer.limit((int) Math.min(buffer.capacity(), firstPos -
pos));
+
+ // ChannelProxy.read may return a short read; advance by what
was actually read.
+ int read = fh.channel.read(buffer, pos);
+ if (read < 0)
+ throw new EOFException("Failed to read " + fh.path() + "
at position " + pos);
+
+ pos += read;
+ }
+ return pos - start;
+ }
+ finally
+ {
+ FileUtils.clean(buffer);
+ }
+ }
+
@Override
public void close()
{
diff --git
a/test/unit/org/apache/cassandra/io/sstable/format/bti/PartitionIndexTest.java
b/test/unit/org/apache/cassandra/io/sstable/format/bti/PartitionIndexTest.java
index 6ceee331f6..a9d01c95e2 100644
---
a/test/unit/org/apache/cassandra/io/sstable/format/bti/PartitionIndexTest.java
+++
b/test/unit/org/apache/cassandra/io/sstable/format/bti/PartitionIndexTest.java
@@ -53,9 +53,11 @@ import org.apache.cassandra.db.DecoratedKey;
import org.apache.cassandra.dht.ByteOrderedPartitioner;
import org.apache.cassandra.dht.IPartitioner;
import org.apache.cassandra.dht.RandomPartitioner;
+import org.apache.cassandra.distributed.shared.WithProperties;
import org.apache.cassandra.io.tries.TrieNode;
import org.apache.cassandra.io.tries.Walker;
import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileDataInput;
import org.apache.cassandra.io.util.FileHandle;
import org.apache.cassandra.io.util.FileUtils;
import org.apache.cassandra.io.util.PageAware;
@@ -67,6 +69,7 @@ import org.apache.cassandra.utils.ByteBufferUtil;
import org.apache.cassandra.utils.Pair;
import org.apache.cassandra.utils.bytecomparable.ByteComparable;
+import static
org.apache.cassandra.config.CassandraRelevantProperties.BTI_PARTITION_INDEX_PRELOAD_SIZE;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -128,6 +131,48 @@ public class PartitionIndexTest
testGetEq(generateSequentialIndex(COUNT));
}
+ @Test
+ public void testWarmIndexHonoursPreloadSize() throws IOException
+ {
+ Pair<List<DecoratedKey>, PartitionIndex> data =
generateRandomIndex(COUNT);
+ try (PartitionIndex index = data.right)
+ {
+ FileHandle fh = index.getFileHandle();
+ long firstPos;
+ try (FileDataInput rdr = fh.createReader(fh.dataLength() -
PartitionIndex.FOOTER_LENGTH))
+ {
+ firstPos = rdr.readLong();
+ }
+ assertTrue("Generated index is too small to bound: " + firstPos,
firstPos > 2 * PageAware.PAGE_SIZE);
+
+ // the default warms the whole trie
+ assertEquals(firstPos, PartitionIndex.warmIndex(fh, firstPos));
+
+ try (WithProperties ignored = new
WithProperties().set(BTI_PARTITION_INDEX_PRELOAD_SIZE, "-1B"))
+ {
+ assertEquals(firstPos, PartitionIndex.warmIndex(fh, firstPos));
+ }
+
+ // a bound at or above the trie length also warms the whole trie
+ try (WithProperties ignored = new
WithProperties().set(BTI_PARTITION_INDEX_PRELOAD_SIZE, (firstPos + 1) + "B"))
+ {
+ assertEquals(firstPos, PartitionIndex.warmIndex(fh, firstPos));
+ }
+
+ // a bound below the trie length warms exactly that much of the
tail
+ try (WithProperties ignored = new
WithProperties().set(BTI_PARTITION_INDEX_PRELOAD_SIZE, (firstPos / 2) + "B"))
+ {
+ assertEquals(firstPos / 2, PartitionIndex.warmIndex(fh,
firstPos));
+ }
+
+ // 0B disables warming entirely
+ try (WithProperties ignored = new
WithProperties().set(BTI_PARTITION_INDEX_PRELOAD_SIZE, "0B"))
+ {
+ assertEquals(0, PartitionIndex.warmIndex(fh, firstPos));
+ }
+ }
+ }
+
@Test
public void testBrokenFile() throws IOException
{
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]