diff --git a/benchmarks/src/main/java/org/opensearch/index/engine/LuceneChangesSnapshotBenchmark.java b/benchmarks/src/main/java/org/opensearch/index/engine/LuceneChangesSnapshotBenchmark.java
new file mode 100644
index 0000000000000..b5f921d3ef0ca
--- /dev/null
+++ b/benchmarks/src/main/java/org/opensearch/index/engine/LuceneChangesSnapshotBenchmark.java
@@ -0,0 +1,275 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.index.engine;
+
+import org.apache.lucene.codecs.lucene104.Lucene104Codec;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.LongPoint;
+import org.apache.lucene.document.NumericDocValuesField;
+import org.apache.lucene.document.StoredField;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexWriterConfig;
+import org.apache.lucene.index.NoMergePolicy;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.ScoreDoc;
+import org.apache.lucene.search.Sort;
+import org.apache.lucene.search.SortField;
+import org.apache.lucene.search.TopDocs;
+import org.apache.lucene.search.TopFieldCollectorManager;
+import org.apache.lucene.search.UsageTrackingQueryCachingPolicy;
+import org.apache.lucene.search.similarities.BM25Similarity;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.NIOFSDirectory;
+import org.apache.lucene.util.BytesRef;
+import org.apache.lucene.util.IOUtils;
+import org.opensearch.common.lucene.index.OpenSearchDirectoryReader;
+import org.opensearch.core.index.shard.ShardId;
+import org.opensearch.index.mapper.IdFieldMapper;
+import org.opensearch.index.mapper.SeqNoFieldMapper;
+import org.opensearch.index.mapper.SourceFieldMapper;
+import org.opensearch.index.mapper.Uid;
+import org.opensearch.index.mapper.VersionFieldMapper;
+import org.opensearch.index.translog.Translog;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Random;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Benchmark for reading history from LuceneChangesSnapshot.
+ *
+ *
The {@link Layout} parameter controls how the operations that get read are placed across docIDs and segments,
+ * which is what determines whether a sequential read is possible at all:
+ *
+ *
+ * - {@code CONTIGUOUS} - an append-only history in one segment, read as one gapless ascending run of docIDs. This
+ * is the shape a sequential read is worth the most on.
+ *
- {@code STRIDED} - ascending but non-adjacent docIDs, as produced by a seqNo range that covers only part of a
+ * segment's documents (for example an index with nested documents, whose child documents are excluded). The
+ * {@code stride} parameter sets how far apart the reads are. While consecutive reads still share a compression
+ * block a sequential read keeps paying off; once the stride exceeds a block's document count each read
+ * decompresses a block with nothing to amortize it against, and a per-document read is cheaper.
+ *
- {@code INTERLEAVED} - segments flushed concurrently hold interleaved seqNo ranges, so reading in seqNo order
+ * alternates between leaves while each leaf's own docIDs ascend. {@code stride} sets the flush fan-out.
+ *
+ *
+ * Run a single configuration with, for example:
+ *
+ * ./gradlew -p benchmarks run --args 'LuceneChangesSnapshotBenchmark -p layout=CONTIGUOUS -p docSizeBytes=256'
+ *
+ */
+@Fork(value = 1, jvmArgsAppend = { "-Xms2g", "-Xmx2g" })
+@Warmup(iterations = 3, time = 1)
+@Measurement(iterations = 5, time = 1)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.MILLISECONDS)
+@State(Scope.Benchmark)
+public class LuceneChangesSnapshotBenchmark {
+
+ /**
+ * How the operations that will be read are laid out across docIDs and segments. See the class javadoc.
+ */
+ public enum Layout {
+ CONTIGUOUS,
+ STRIDED,
+ INTERLEAVED
+ }
+
+ /** Operations read per invocation. Every layout reads this many, only their placement differs. */
+ @Param({ "20000" })
+ private int numOps;
+
+ /**
+ * For {@code STRIDED}, how many docIDs apart consecutive reads are; for {@code INTERLEAVED}, how many segments the
+ * seqNo range is interleaved across (the flush fan-out). {@code 1} makes both layouts contiguous.
+ */
+ @Param({ "2" })
+ private int stride;
+
+ @Param({ "CONTIGUOUS", "STRIDED", "INTERLEAVED" })
+ private Layout layout;
+
+ @Param({ "256", "2048" })
+ private int docSizeBytes;
+
+ @Param({ "BEST_SPEED", "BEST_COMPRESSION" })
+ private String codecMode;
+
+ private static final ShardId SHARD_ID = new ShardId("index", "_na_", 0);
+
+ private Path indexPath;
+ private Directory directory;
+ private DirectoryReader reader;
+
+ @Setup(Level.Trial)
+ public void setup() throws IOException {
+ indexPath = Files.createTempDirectory("lucene-changes-snapshot-benchmark");
+ // OpenSearch's HybridDirectory reads stored fields through NIOFS rather than mmap by default; see
+ // IndexModule#INDEX_STORE_HYBRID_NIO_EXTENSIONS, which lists fdt and fdx.
+ directory = new NIOFSDirectory(indexPath);
+ buildIndex();
+ reader = OpenSearchDirectoryReader.wrap(DirectoryReader.open(directory), SHARD_ID);
+ verifyLayout();
+ }
+
+ @TearDown(Level.Trial)
+ public void tearDown() throws IOException {
+ IOUtils.close(reader, directory);
+ IOUtils.rm(indexPath);
+ }
+
+ /**
+ * Drains the whole snapshot, the way the get-changes action does: the seqNo range query, the doc values reads and
+ * the stored fields reads for every operation.
+ */
+ @Benchmark
+ public void drainSnapshot(Blackhole bh) throws IOException {
+ try (LuceneChangesSnapshot snapshot = newSnapshot()) {
+ Translog.Operation op;
+ while ((op = snapshot.next()) != null) {
+ bh.consume(op);
+ }
+ }
+ }
+
+ private LuceneChangesSnapshot newSnapshot() throws IOException {
+ final Engine.Searcher searcher = new Engine.Searcher(
+ "benchmark",
+ reader,
+ new BM25Similarity(),
+ null,
+ new UsageTrackingQueryCachingPolicy(),
+ () -> {} // the reader outlives the snapshot and is closed by tearDown
+ );
+ return new LuceneChangesSnapshot(searcher, LuceneChangesSnapshot.DEFAULT_BATCH_SIZE, 0, numOps - 1, true, true);
+ }
+
+ private void buildIndex() throws IOException {
+ final Lucene104Codec.Mode mode = Lucene104Codec.Mode.valueOf(codecMode);
+ final IndexWriterConfig config = new IndexWriterConfig().setCodec(new Lucene104Codec(mode))
+ // one segment per commit, so the layouts below are exactly what gets read
+ .setMergePolicy(NoMergePolicy.INSTANCE)
+ .setRAMBufferSizeMB(512);
+ try (IndexWriter writer = new IndexWriter(directory, config)) {
+ switch (layout) {
+ case CONTIGUOUS:
+ // seqNo == docID, one segment
+ for (int i = 0; i < numOps; i++) {
+ writer.addDocument(newDocument(i));
+ }
+ break;
+ case STRIDED:
+ // one segment; the range that gets read covers every stride-th docID, the rest hold seqNos above it
+ long filler = numOps;
+ for (int i = 0; i < numOps; i++) {
+ writer.addDocument(newDocument(i));
+ for (int gap = 1; gap < stride; gap++) {
+ writer.addDocument(newDocument(filler++));
+ }
+ }
+ break;
+ case INTERLEAVED:
+ // segments with interleaved seqNo ranges, as concurrently flushed segments have
+ for (int segment = 0; segment < stride; segment++) {
+ for (int seqNo = segment; seqNo < numOps; seqNo += stride) {
+ writer.addDocument(newDocument(seqNo));
+ }
+ writer.commit();
+ }
+ break;
+ default:
+ throw new AssertionError(layout);
+ }
+ writer.commit();
+ }
+ }
+
+ private Document newDocument(long seqNo) {
+ final Document doc = new Document();
+ doc.add(new StoredField(IdFieldMapper.NAME, Uid.encodeId(Long.toString(seqNo))));
+ doc.add(new StoredField(SourceFieldMapper.NAME, new BytesRef(source(seqNo))));
+ doc.add(new LongPoint(SeqNoFieldMapper.NAME, seqNo));
+ doc.add(new NumericDocValuesField(SeqNoFieldMapper.NAME, seqNo));
+ doc.add(new NumericDocValuesField(SeqNoFieldMapper.PRIMARY_TERM_NAME, 1L));
+ doc.add(new NumericDocValuesField(VersionFieldMapper.NAME, 1L));
+ return doc;
+ }
+
+ /**
+ * A pseudo-random payload of the requested size. Random bytes would not compress at all and identical bytes would
+ * compress perfectly, so this mixes a fixed pattern with random content to land somewhere realistic.
+ */
+ private byte[] source(long seqNo) {
+ final byte[] bytes = new byte[docSizeBytes];
+ final Random random = new Random(seqNo);
+ for (int i = 0; i < bytes.length; i++) {
+ bytes[i] = (byte) (i % 8 == 0 ? random.nextInt(26) + 'a' : 'a' + (i % 26));
+ }
+ return bytes;
+ }
+
+ /**
+ * Fails fast if the index does not have the shape the layout intends, so a run cannot quietly measure something
+ * else. Checks that the expected operations are all present and readable, and that the docID sequence in read order
+ * is gapless and ascending only for {@code CONTIGUOUS} - the property that decides whether a sequential read of
+ * stored fields is possible. Deliberately asserts on the index rather than on the snapshot's internals, so this
+ * benchmark also compiles and runs against a baseline checkout.
+ */
+ private void verifyLayout() throws IOException {
+ final IndexSearcher searcher = new IndexSearcher(reader);
+ searcher.setQueryCache(null);
+ final TopDocs topDocs = searcher.search(
+ LongPoint.newRangeQuery(SeqNoFieldMapper.NAME, 0, numOps - 1),
+ new TopFieldCollectorManager(new Sort(new SortField(SeqNoFieldMapper.NAME, SortField.Type.LONG)), numOps, null, 0, false)
+ );
+ if (topDocs.scoreDocs.length != numOps) {
+ throw new IllegalStateException("expected " + numOps + " hits but got " + topDocs.scoreDocs.length);
+ }
+ boolean contiguous = true;
+ for (int i = 0; i < topDocs.scoreDocs.length - 1; i++) {
+ final ScoreDoc current = topDocs.scoreDocs[i];
+ final ScoreDoc next = topDocs.scoreDocs[i + 1];
+ if (current.doc + 1 != next.doc) {
+ contiguous = false;
+ break;
+ }
+ }
+ if (contiguous != (layout == Layout.CONTIGUOUS)) {
+ throw new IllegalStateException(
+ "layout [" + layout + "] with stride [" + stride + "] produced a contiguous read order of [" + contiguous + "]"
+ );
+ }
+ int read = 0;
+ try (LuceneChangesSnapshot snapshot = newSnapshot()) {
+ while (snapshot.next() != null) {
+ read++;
+ }
+ }
+ if (read != numOps) {
+ throw new IllegalStateException("expected " + numOps + " operations but read " + read);
+ }
+ }
+}
diff --git a/server/src/main/java/org/opensearch/index/engine/LuceneChangesSnapshot.java b/server/src/main/java/org/opensearch/index/engine/LuceneChangesSnapshot.java
index 72ccc097e20d6..2744ec76ff80c 100644
--- a/server/src/main/java/org/opensearch/index/engine/LuceneChangesSnapshot.java
+++ b/server/src/main/java/org/opensearch/index/engine/LuceneChangesSnapshot.java
@@ -33,9 +33,11 @@
package org.opensearch.index.engine;
import org.apache.lucene.document.LongPoint;
+import org.apache.lucene.index.FilterLeafReader;
import org.apache.lucene.index.LeafReader;
import org.apache.lucene.index.LeafReaderContext;
import org.apache.lucene.index.NumericDocValues;
+import org.apache.lucene.index.StoredFields;
import org.apache.lucene.search.BooleanClause;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.FieldDoc;
@@ -49,6 +51,7 @@
import org.apache.lucene.search.TopFieldCollectorManager;
import org.apache.lucene.util.ArrayUtil;
import org.opensearch.common.lucene.Lucene;
+import org.opensearch.common.lucene.index.SequentialStoredFieldsLeafReader;
import org.opensearch.common.lucene.search.Queries;
import org.opensearch.common.util.io.IOUtils;
import org.opensearch.core.common.bytes.BytesReference;
@@ -71,6 +74,12 @@
final class LuceneChangesSnapshot implements Translog.Snapshot {
static final int DEFAULT_BATCH_SIZE = 1024;
+ /**
+ * Batches smaller than this are read with the default stored fields reader: acquiring a sequential reader costs a
+ * reader clone plus the decompression of a whole block, which only pays for itself over enough documents.
+ */
+ static final int MIN_SEQUENTIAL_ACCESS_BATCH_SIZE = 10;
+
private final int searchBatchSize;
private final long fromSeqNo, toSeqNo;
private long lastSeenSeqNo;
@@ -84,6 +93,18 @@ final class LuceneChangesSnapshot implements Translog.Snapshot {
private final ParallelArray parallelArray;
private final Closeable onClose;
+ /**
+ * Whether to use a sequential stored fields reader. Only used when there is a gapless run of docIDs
+ * matching sequence number order. This allows for an optimization to decompress a block once and
+ * read multiple stored fields from it, as opposed to the default path that does a decompression on
+ * every stored fields read.
+ */
+ private boolean useSequentialStoredFieldsReader;
+
+ private StoredFields sequentialStoredFields;
+ private int sequentialStoredFieldsLeafOrd = -1;
+ private Thread sequentialStoredFieldsOwner;
+
/**
* Creates a new "translog" snapshot from Lucene for reading operations whose seq# in the specified range.
*
@@ -130,6 +151,7 @@ final class LuceneChangesSnapshot implements Translog.Snapshot {
@Override
public void close() throws IOException {
+ releaseSequentialStoredFieldsReader();
onClose.close();
}
@@ -215,9 +237,13 @@ private void fillParallelArray(ScoreDoc[] scoreDocs, ParallelArray parallelArray
for (int i = 0; i < scoreDocs.length; i++) {
scoreDocs[i].shardIndex = i;
}
- // for better loading performance we sort the array by docID and
- // then visit all leaves in order.
- ArrayUtil.introSort(scoreDocs, Comparator.comparingInt(i -> i.doc));
+ useSequentialStoredFieldsReader = scoreDocs.length >= MIN_SEQUENTIAL_ACCESS_BATCH_SIZE && hasSequentialAccess(scoreDocs);
+ if (useSequentialStoredFieldsReader == false) {
+ releaseSequentialStoredFieldsReader();
+ // for better loading performance we sort the array by docID and
+ // then visit all leaves in order.
+ ArrayUtil.introSort(scoreDocs, Comparator.comparingInt(i -> i.doc));
+ }
int docBase = -1;
int maxDoc = 0;
List leaves = indexSearcher.getIndexReader().leaves();
@@ -242,9 +268,23 @@ private void fillParallelArray(ScoreDoc[] scoreDocs, ParallelArray parallelArray
parallelArray.isTombStone[index] = combinedDocValues.isTombstone(segmentDocID);
parallelArray.hasRecoverySource[index] = combinedDocValues.hasRecoverySource(segmentDocID);
}
- // now sort back based on the shardIndex. we use this to store the previous index
- ArrayUtil.introSort(scoreDocs, Comparator.comparingInt(i -> i.shardIndex));
+ if (useSequentialStoredFieldsReader == false) {
+ // now sort back based on the shardIndex. we use this to store the previous index
+ ArrayUtil.introSort(scoreDocs, Comparator.comparingInt(i -> i.shardIndex));
+ }
+ }
+ }
+
+ /**
+ * Returns whether the batch, in the order it will be read, is a strictly increasing and gapless run of docIDs
+ */
+ private static boolean hasSequentialAccess(ScoreDoc[] scoreDocs) {
+ for (int i = 0; i < scoreDocs.length - 1; i++) {
+ if (scoreDocs[i].doc + 1 != scoreDocs[i + 1].doc) {
+ return false;
+ }
}
+ return true;
}
private static Query operationsRangeQuery(long fromSeqNo, long toSeqNo) {
@@ -291,7 +331,7 @@ private Translog.Operation readDocAsOp(int docIndex) throws IOException {
? SourceFieldMapper.RECOVERY_SOURCE_NAME
: SourceFieldMapper.NAME;
final FieldsVisitor fields = new FieldsVisitor(true, sourceField);
- leaf.reader().storedFields().document(segmentDocID, fields);
+ storedFieldsFor(leaf).document(segmentDocID, fields);
final Translog.Operation op;
final boolean isTombstone = parallelArray.isTombStone[docIndex];
@@ -344,6 +384,53 @@ private Translog.Operation readDocAsOp(int docIndex) throws IOException {
return op;
}
+ private StoredFields storedFieldsFor(LeafReaderContext leaf) throws IOException {
+ if (useSequentialStoredFieldsReader) {
+ // A StoredFields instance may only be consumed in the thread that acquired it,
+ // so track the owning thread and recreate it if consumed by another thread.
+ final Thread currentThread = Thread.currentThread();
+ if (sequentialStoredFields == null
+ || sequentialStoredFieldsLeafOrd != leaf.ord
+ || sequentialStoredFieldsOwner != currentThread) {
+ sequentialStoredFields = sequentialStoredFields(leaf.reader());
+ sequentialStoredFieldsLeafOrd = leaf.ord;
+ sequentialStoredFieldsOwner = currentThread;
+ }
+ return sequentialStoredFields;
+ }
+ return leaf.reader().storedFields();
+ }
+
+ /**
+ * Returns a merge-optimized (sequential) stored fields reader for the leaf
+ */
+ private static StoredFields sequentialStoredFields(LeafReader leaf) throws IOException {
+ LeafReader reader = leaf;
+ while (true) {
+ if (reader instanceof SequentialStoredFieldsLeafReader) {
+ return ((SequentialStoredFieldsLeafReader) reader).getSequentialStoredFieldsReader();
+ }
+ if (reader instanceof FilterLeafReader == false) {
+ return leaf.storedFields();
+ }
+ reader = ((FilterLeafReader) reader).getDelegate();
+ }
+ }
+
+ /**
+ * Drops the cached reader, if any
+ */
+ private void releaseSequentialStoredFieldsReader() {
+ sequentialStoredFields = null;
+ sequentialStoredFieldsLeafOrd = -1;
+ sequentialStoredFieldsOwner = null;
+ }
+
+ // for testing
+ boolean useSequentialStoredFieldsReader() {
+ return useSequentialStoredFieldsReader;
+ }
+
private boolean assertDocSoftDeleted(LeafReader leafReader, int segmentDocId) throws IOException {
final NumericDocValues ndv = leafReader.getNumericDocValues(Lucene.SOFT_DELETES_FIELD);
if (ndv == null || ndv.advanceExact(segmentDocId) == false) {
diff --git a/server/src/test/java/org/opensearch/index/engine/LuceneChangesSnapshotTests.java b/server/src/test/java/org/opensearch/index/engine/LuceneChangesSnapshotTests.java
index b9b0e64d8811f..dfc35e8e99d6f 100644
--- a/server/src/test/java/org/opensearch/index/engine/LuceneChangesSnapshotTests.java
+++ b/server/src/test/java/org/opensearch/index/engine/LuceneChangesSnapshotTests.java
@@ -32,6 +32,13 @@
package org.opensearch.index.engine;
+import org.apache.lucene.codecs.StoredFieldsReader;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.FilterDirectoryReader;
+import org.apache.lucene.index.LeafReader;
+import org.apache.lucene.index.StoredFieldVisitor;
+import org.opensearch.common.CheckedSupplier;
+import org.opensearch.common.lucene.index.SequentialStoredFieldsLeafReader;
import org.opensearch.common.settings.Settings;
import org.opensearch.common.util.io.IOUtils;
import org.opensearch.index.IndexSettings;
@@ -49,10 +56,14 @@
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.instanceOf;
public class LuceneChangesSnapshotTests extends EngineTestCase {
private MapperService mapperService;
@@ -369,4 +380,234 @@ public void testOverFlow() throws Exception {
);
}
}
+
+ public void testOutOfOrderSeqNoUsesDefaultStoredFieldsReader() throws Exception {
+ final int numOps = between(LuceneChangesSnapshot.MIN_SEQUENTIAL_ACCESS_BATCH_SIZE, 100);
+ final Map expectedDocs = new HashMap<>();
+ for (int i = 0; i < numOps; i++) {
+ final long seqNo = numOps - 1 - i;
+ final ParsedDocument doc = createParsedDoc("id-" + seqNo, null);
+ engine.index(replicaIndexForDoc(doc, 1, seqNo, false));
+ expectedDocs.put(seqNo, doc);
+ }
+ engine.refresh("test");
+ Engine.Searcher searcher = engine.acquireSearcher("test", Engine.SearcherScope.INTERNAL);
+ try (LuceneChangesSnapshot snapshot = new LuceneChangesSnapshot(searcher, numOps, 0, numOps - 1, true, true)) {
+ searcher = null;
+ assertThat(snapshot.totalOperations(), equalTo(numOps));
+ // the flag is computed for the first batch by the constructor; a single batch holds every op here
+ assertFalse("descending docIDs must not use the sequential reader", snapshot.useSequentialStoredFieldsReader());
+ assertOpsMatch(drainAll(snapshot), expectedDocs);
+ } finally {
+ IOUtils.close(searcher);
+ }
+ }
+
+ public void testInterleavedSeqNosAcrossSegmentsUseDefaultStoredFieldsReader() throws Exception {
+ final int numOps = 2 * between(LuceneChangesSnapshot.MIN_SEQUENTIAL_ACCESS_BATCH_SIZE, 50);
+ final Map expectedDocs = new HashMap<>();
+ for (int step = 0; step < 2; step++) {
+ for (long seqNo = step; seqNo < numOps; seqNo += 2) {
+ final ParsedDocument doc = createParsedDoc("id-" + seqNo, null);
+ engine.index(replicaIndexForDoc(doc, 1, seqNo, false));
+ expectedDocs.put(seqNo, doc);
+ }
+ engine.flush();
+ }
+ engine.refresh("test");
+ Engine.Searcher searcher = engine.acquireSearcher("test", Engine.SearcherScope.INTERNAL);
+ try (LuceneChangesSnapshot snapshot = new LuceneChangesSnapshot(searcher, numOps, 0, numOps - 1, true, true)) {
+ searcher = null;
+ assertThat(snapshot.totalOperations(), equalTo(numOps));
+ assertFalse("interleaved segments must not use the sequential reader", snapshot.useSequentialStoredFieldsReader());
+ assertOpsMatch(drainAll(snapshot), expectedDocs);
+ } finally {
+ IOUtils.close(searcher);
+ }
+ }
+
+ public void testContiguousReadsReuseSequentialStoredFieldsReader() throws Exception {
+ final int batchSize = LuceneChangesSnapshot.MIN_SEQUENTIAL_ACCESS_BATCH_SIZE;
+ final int numOps = batchSize * between(3, 10);
+ final Map expectedDocs = indexAppendOnly(numOps);
+ final AtomicInteger acquisitions = new AtomicInteger();
+ final AtomicInteger documentsRead = new AtomicInteger();
+ Engine.Searcher searcher = engine.acquireSearcher("test", Engine.SearcherScope.INTERNAL);
+ try {
+ final int numLeaves = searcher.getDirectoryReader().leaves().size();
+ // a batch size well below the op count forces multiple batches, exercising reader reuse across them
+ final Engine.Searcher countingSearcher = countingSearcher(searcher, acquisitions, documentsRead);
+ try (LuceneChangesSnapshot snapshot = new LuceneChangesSnapshot(countingSearcher, batchSize, 0, numOps - 1, true, true)) {
+ searcher = null; // closed by the snapshot through the counting searcher
+ assertTrue("contiguous docIDs must use the sequential reader", snapshot.useSequentialStoredFieldsReader());
+ assertOpsMatch(drainAll(snapshot), expectedDocs);
+ assertThat("sequential reader must be acquired once per leaf and reused", acquisitions.get(), equalTo(numLeaves));
+ assertThat("every read must go through the SequentialStoredFieldsLeafReader", documentsRead.get(), equalTo(numOps));
+ }
+ } finally {
+ IOUtils.close(searcher);
+ }
+ }
+
+ public void testSequentialStoredFieldsReaderIsReacquiredOnThreadChange() throws Exception {
+ final int numOps = between(LuceneChangesSnapshot.MIN_SEQUENTIAL_ACCESS_BATCH_SIZE, 50);
+ final Map expectedDocs = indexAppendOnly(numOps);
+ final AtomicInteger acquisitions = new AtomicInteger();
+ final AtomicInteger documentsRead = new AtomicInteger();
+ Engine.Searcher searcher = engine.acquireSearcher("test", Engine.SearcherScope.INTERNAL);
+ try {
+ final Engine.Searcher countingSearcher = countingSearcher(searcher, acquisitions, documentsRead);
+ try (LuceneChangesSnapshot snapshot = new LuceneChangesSnapshot(countingSearcher, numOps, 0, numOps - 1, true, true)) {
+ searcher = null; // closed by the snapshot through the counting searcher
+ assertTrue(snapshot.useSequentialStoredFieldsReader());
+ final List ops = new ArrayList<>();
+ // every next() runs on a fresh thread; join() gives the happens-before that consumers get from locking
+ Translog.Operation op = callOnNewThread(snapshot::next);
+ while (op != null) {
+ ops.add(op);
+ op = callOnNewThread(snapshot::next);
+ }
+ assertOpsMatch(ops, expectedDocs);
+ assertThat("reader must be re-acquired for every reading thread", acquisitions.get(), equalTo(numOps));
+ assertThat(documentsRead.get(), equalTo(numOps));
+ }
+ } finally {
+ IOUtils.close(searcher);
+ }
+ }
+
+ private Map indexAppendOnly(int numOps) throws IOException {
+ final Map expectedDocs = new HashMap<>();
+ for (int i = 0; i < numOps; i++) {
+ final ParsedDocument doc = createParsedDoc("id-" + i, null);
+ final Engine.IndexResult result = engine.index(indexForDoc(doc));
+ expectedDocs.put(result.getSeqNo(), doc);
+ }
+ engine.refresh("test");
+ return expectedDocs;
+ }
+
+ private void assertOpsMatch(List ops, Map expectedDocs) {
+ assertThat(ops, hasSize(expectedDocs.size()));
+ for (Translog.Operation op : ops) {
+ assertThat(op.toString(), op, instanceOf(Translog.Index.class));
+ final Translog.Index index = (Translog.Index) op;
+ final ParsedDocument expected = expectedDocs.get(op.seqNo());
+ assertNotNull("unexpected seqNo [" + op.seqNo() + "]", expected);
+ assertThat(index.id(), equalTo(expected.id()));
+ assertThat(index.source(), equalTo(expected.source()));
+ }
+ }
+
+ private static T callOnNewThread(CheckedSupplier supplier) throws Exception {
+ final AtomicReference result = new AtomicReference<>();
+ final AtomicReference failure = new AtomicReference<>();
+ final Thread thread = new Thread(() -> {
+ try {
+ result.set(supplier.get());
+ } catch (Exception e) {
+ failure.set(e);
+ }
+ });
+ thread.start();
+ thread.join();
+ if (failure.get() != null) {
+ throw failure.get();
+ }
+ return result.get();
+ }
+
+ /**
+ * Wraps the searcher's reader so that every leaf sits behind a counting {@link SequentialStoredFieldsLeafReader}.
+ * The returned searcher takes ownership of {@code searcher}.
+ */
+ private static Engine.Searcher countingSearcher(Engine.Searcher searcher, AtomicInteger acquisitions, AtomicInteger documentsRead)
+ throws IOException {
+ final DirectoryReader reader = new CountingSequentialDirectoryReader(searcher.getDirectoryReader(), acquisitions, documentsRead);
+ return new Engine.Searcher(
+ searcher.source(),
+ reader,
+ searcher.getSimilarity(),
+ searcher.getQueryCache(),
+ searcher.getQueryCachingPolicy(),
+ searcher
+ );
+ }
+
+ /**
+ * Wraps every leaf in a pass-through {@link SequentialStoredFieldsLeafReader} that counts how many times its
+ * sequential stored fields reader is acquired, and how many documents that reader serves.
+ */
+ private static final class CountingSequentialDirectoryReader extends FilterDirectoryReader {
+ private final AtomicInteger acquisitions;
+ private final AtomicInteger documentsRead;
+
+ CountingSequentialDirectoryReader(DirectoryReader in, AtomicInteger acquisitions, AtomicInteger documentsRead) throws IOException {
+ super(in, new SubReaderWrapper() {
+ @Override
+ public LeafReader wrap(LeafReader reader) {
+ return new SequentialStoredFieldsLeafReader(reader) {
+ @Override
+ protected StoredFieldsReader doGetSequentialStoredFieldsReader(StoredFieldsReader storedFieldsReader) {
+ acquisitions.incrementAndGet();
+ return new CountingStoredFieldsReader(storedFieldsReader, documentsRead);
+ }
+
+ @Override
+ public CacheHelper getCoreCacheHelper() {
+ return reader.getCoreCacheHelper();
+ }
+
+ @Override
+ public CacheHelper getReaderCacheHelper() {
+ return reader.getReaderCacheHelper();
+ }
+ };
+ }
+ });
+ this.acquisitions = acquisitions;
+ this.documentsRead = documentsRead;
+ }
+
+ @Override
+ protected DirectoryReader doWrapDirectoryReader(DirectoryReader in) throws IOException {
+ return new CountingSequentialDirectoryReader(in, acquisitions, documentsRead);
+ }
+
+ @Override
+ public CacheHelper getReaderCacheHelper() {
+ return in.getReaderCacheHelper();
+ }
+ }
+
+ private static final class CountingStoredFieldsReader extends StoredFieldsReader {
+ private final StoredFieldsReader delegate;
+ private final AtomicInteger documentsRead;
+
+ CountingStoredFieldsReader(StoredFieldsReader delegate, AtomicInteger documentsRead) {
+ this.delegate = delegate;
+ this.documentsRead = documentsRead;
+ }
+
+ @Override
+ public void document(int docID, StoredFieldVisitor visitor) throws IOException {
+ documentsRead.incrementAndGet();
+ delegate.document(docID, visitor);
+ }
+
+ @Override
+ public StoredFieldsReader clone() {
+ return new CountingStoredFieldsReader(delegate.clone(), documentsRead);
+ }
+
+ @Override
+ public void checkIntegrity() throws IOException {
+ delegate.checkIntegrity();
+ }
+
+ @Override
+ public void close() throws IOException {
+ delegate.close();
+ }
+ }
}