The save file holds only generation and count pairs, so the constructor fills the counts map and builds no commit. For the saved generations, the generation to IndexCommit map is filled in onInit, which an IndexWriter calls when it opens with the policy (snapshot(), the only other path, is refused until then). getSnapshotCount() reads the counts; getSnapshots() and getIndexCommit(gen) read the commits map, empty until then.
- A deletion policy decides which old commits an
IndexWriterdeletes.SnapshotDeletionPolicywraps another policy and lets you pin a commit withsnapshot(), for example while a backup copies its files, and unpin it withrelease(). - Each commit has a generation number (1 for the first commit) and an
IndexCommitobject that lists its files. When anIndexWriteropens, it hands the policy the commits it finds in the index (onInit). PersistentSnapshotDeletionPolicyalso writes the pinned generations and their counts to asnapshots_Nfile, so the pins survive a restart: you create the policy again over that file and pass it to a newIndexWriter.
IndexWriter opens with it: the save file fills only the counts (generation 1: 1), so getIndexCommit(1) is null; after onInit the commit is there too. Select the still to open it at full size. Generated with AI, checked against the code.- You snapshot commit generation 1 through a
PersistentSnapshotDeletionPolicyand close the writer. The save file now records generation 1 with count 1. - After a restart you create the policy again over that file with
OpenMode.APPEND("initializing the class with the snapshots information") and, before opening anIndexWriter, ask what is snapshotted:getSnapshotCount()is 1. - You expect
getIndexCommit(1)to return the commit of generation 1, which is still in the index, andgetSnapshots()to list it. - Instead
getIndexCommit(1)is null, though the only case its javadoc names for null is "not currently snapshotted", andgetSnapshots()is[], with no error. In the same statesnapshot()andrelease(1)do throwIllegalStateException. - Open an
IndexWriterwith the same policy and everything agrees: the commit of generation 1, a list of 1. The save file holds only generation counts; the commits come from the writer when it opens.
Applications using the opt-in, experimental PersistentSnapshotDeletionPolicy that, after a restart, read getSnapshots() or getIndexCommit(gen) before an IndexWriter has opened with the policy, for example a backup tool that lists saved snapshots first. They see none, without an error, and could think a snapshot is gone (possible, not observed). The getters change nothing; once a writer opens, all agree.
Mechanism, terms and numbers626 words
PersistentSnapshotDeletionPolicy saves each snapshotted commit generation and its reference count to a snapshots_N file, so snapshots survive a restart. When the policy is created again over that file, its constructor loads only those counts and builds no IndexCommit; for the saved generations the IndexCommit objects are handed to it later, by the IndexWriter that opens with it (onInit). Until then getSnapshotCount() reports the saved snapshots while getSnapshots() is empty and getIndexCommit(gen) returns null, although the only case its javadoc names for null is "not currently snapshotted". The design is deliberate and nothing is lost: once a writer opens with the policy, the saved commits that are still in the index are protected and every getter agrees. What is missing is documentation: no javadoc, CHANGES or MIGRATE entry says what the getters of a re-created policy return before it has been passed to an IndexWriter, and the constructor's javadoc suggests more ("initializing the class with the snapshots information"). The case for "intended" is strong, so the fix is a few javadoc sentences, not a code change.
- Two maps.
SnapshotDeletionPolicykeepsrefCounts("Records how many snapshots are held against each commit generation",SnapshotDeletionPolicy.java:43-44) andindexCommits("Used to map gen to IndexCommit",:46-47). - The constructor fills only the first. The
PersistentSnapshotDeletionPolicyconstructor callsloadPriorSnapshots()(PersistentSnapshotDeletionPolicy.java:99). It reads the header, the entry count and the (generation, count) pairs of thesnapshots_Nfiles it finds, keeps those of the newest inrefCounts(:215-241), deletes older or broken save files and sets the next save-file number (:253-262). The file holds nothing else (persist(),:161-184), and it may live in aDirectoryother than the index (:39-42). The constructor is given only thatDirectory, so without another source of the commit list it cannot in general build the current commits; it builds none. - For the saved generations,
onInitfills the second.onInitsetsinitCalled = trueand puts each commit the writer hands over intoindexCommitsif its generation is inrefCounts(SnapshotDeletionPolicy.java:69-81). The only other code that writes the map,incRef(:115-126), is reached fromsnapshot()and from an error path ofrelease(IndexCommit), both refused beforeonInit(item 5).IndexWritercallsonInit, throughIndexFileDeleter(IndexFileDeleter.java:237), when it opens with the policy (IndexDeletionPolicy.java:46-62: "The writer locates all index commits present in the index directory and calls this method").onInitis public, so other code could call it; anIndexWriteropening with the policy is the normal path. - The getters read different maps.
getSnapshotCount()sumsrefCounts(SnapshotDeletionPolicy.java:162-170);getSnapshots()(:157-160) andgetIndexCommit(long)(:172-178) readindexCommits. BeforeonInitthey give 1,[]and null. - Only the mutators are guarded.
snapshot()(SnapshotDeletionPolicy.java:142-146) andreleaseGen()(:94-98, reached fromrelease(long),PersistentSnapshotDeletionPolicy.java:156-159) throwIllegalStateExceptionwhileinitCalledis false ("Used to detect misuse",SnapshotDeletionPolicy.java:55-56). The two commit getters have no guard and no caveat.
One empty document, one commit (generation 1), one snapshot(), writer closed. A policy created again with OpenMode.APPEND (or, since a save file exists, the default two-argument constructor) over the same directory: getSnapshotCount() = 1, getLastSaveFile() is set, DirectoryReader.listCommits(dir) contains generation 1, getSnapshots() = [], getIndexCommit(1) = null; snapshot() and release(1) throw IllegalStateException ("this instance is not being used by IndexWriter; be sure to use the instance returned from writer.getConfig().getIndexDeletionPolicy()"). After new IndexWriter(dir, config with this instance): getIndexCommit(1) is the commit of generation 1, getSnapshots().size() = 1, and release(1) works and is saved. (On a directory without a save file, OpenMode.APPEND throws IllegalStateException "no snapshots stored in this directory", PersistentSnapshotDeletionPolicy.java:101-103; the default constructor does not.)
Exposure and likelihood199 words
- Who. Applications that opt into
PersistentSnapshotDeletionPolicy(@lucene.experimental) and, after a restart, readgetSnapshots()orgetIndexCommit(gen)from the re-created policy before anIndexWriterhas been opened with it: a backup or restore tool that first lists the saved snapshots, or code that usesgetIndexCommit(gen) != nullas "is this generation still snapshotted?" before releasing it (a check mentioned as a possible workaround on LUCENE-4973). - What goes wrong. Without any error they see no snapshotted commits while
getSnapshotCount()reports them, and could conclude that a saved snapshot is gone, then for example skip a backup or a release. These are possible consequences, not observed ones; no user report shows them. The getters change nothing: the saved counts are intact, and once a writer opens with the policy,onInitprotects the saved commits that are still in the index and every getter agrees. - Default vs opt-in. The policy is opt-in; default codec, directory and merge settings are enough. One document and one snapshot; no scale involved.
- How likely. Rare. The usual flow, and Lucene's own restart test (
testExistingSnapshots), open the writer first. No user report or question about this state was found since it appeared in 4.4.0 (2013).
One JUnit test, one method: snapshot generation 1, create the policy again with OpenMode.APPEND, check getSnapshotCount() = 1 (passes), then assert per its javadoc that getIndexCommit(1) is not null (fails). Public API, default config, one empty document, single thread; it failed the same way with each of the three seeds run.
/*
* 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.lucene.index;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.IndexWriterConfig.OpenMode;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestReopenedPersistentSnapshotGetIndexCommit extends LuceneTestCase {
public void testGetIndexCommitBeforeWriterOpens() throws Exception {
try (Directory dir = newDirectory()) {
PersistentSnapshotDeletionPolicy psdp =
new PersistentSnapshotDeletionPolicy(
new KeepOnlyLastCommitDeletionPolicy(), dir, OpenMode.CREATE);
long gen;
try (IndexWriter w =
new IndexWriter(dir, new IndexWriterConfig().setIndexDeletionPolicy(psdp))) {
w.addDocument(new Document());
w.commit();
gen = psdp.snapshot().getGeneration();
}
PersistentSnapshotDeletionPolicy reopened =
new PersistentSnapshotDeletionPolicy(
new KeepOnlyLastCommitDeletionPolicy(), dir, OpenMode.APPEND);
assertEquals(1, reopened.getSnapshotCount());
// expected: the snapshotted commit (null only if "not currently snapshotted"); actual: null
assertNotNull(
"expected the IndexCommit of snapshotted generation " + gen + ", got null",
reopened.getIndexCommit(gen));
}
}
}
cp $REPRO/TestReopenedPersistentSnapshotGetIndexCommit.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestReopenedPersistentSnapshotGetIndexCommit -Ptests.seed=5EED0890 -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)
- Expected
getIndexCommit(1)returns the commit of the snapshotted generation 1 (null only if "not currently snapshotted").- Observed
getIndexCommit(1)is null whilegetSnapshotCount()is 1: "expected the IndexCommit of snapshotted generation 1, got null", with assertions on and off.
More files, environment, full output289 words
- Seeds. One empty document, one commit, one snapshot; the test framework randomizes the
Directoryand the config, and the three seeds run,5EED0890,7B890EA1and7B890DA2, give the same failure. Assertions on and off fail the same way. - What it is not. It demonstrates the mismatch between
getIndexCommit(long)and its javadoc. It is not a regression test for the recommended javadoc fix, which changes no code, so the test keeps failing under it. The hunt's control passes: it opens and closes anIndexWriterwith the re-created policy before the read. Its other differences (class and method name, no assertion message, an extra check that the commit has generationgen) do not change what it exercises. - Releases. Standalone, it fails on 10.5.2 as on main, and it also fails on 9.12.3 and 10.6.0. The version probes, which port it and also read
getSnapshots(), fail on every release from 4.4.0 to 10.5.2 that was run.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.5.2, and its ports on 4.4.0 to 10.6.0 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
cp $REPRO/TestReopenedPersistentSnapshotGetIndexCommit.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestReopenedPersistentSnapshotGetIndexCommit -Ptests.seed=5EED0890 -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)
# gradle, main 645d4fe14a20, seed 5EED0890, -Ptests.asserts=true
TestReopenedPersistentSnapshotGetIndexCommit > testGetIndexCommitBeforeWriterOpens FAILED
java.lang.AssertionError: expected the IndexCommit of snapshotted generation 1, got null
at __randomizedtesting.SeedInfo.seed([5EED0890:53150589B4C14A07]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.assertTrue(Assert.java:42)
at org.junit.Assert.assertNotNull(Assert.java:713)
at org.apache.lucene.index.TestReopenedPersistentSnapshotGetIndexCommit.testGetIndexCommitBeforeWriterOpens(TestReopenedPersistentSnapshotGetIndexCommit.java:44)
:lucene:core:test (FAILURE): 1 test, 1 failure
# -Ptests.asserts=false: the same failure
# documentation fix (option A): changes no code; the same failure, assertions on and off
# loud getters (option B): java.lang.IllegalStateException: snapshots were loaded but no IndexWriter has been
# opened with this instance yet; open an IndexWriter with it before reading its IndexCommitsgetIndexCommit(long)"returns null if this IndexCommit is not currently snapshotted", yet generation 1 is counted and still in the index.- The class keeps snapshots "across the life of an application"; nothing says a writer must open before the getters work.
- The code is intended, but only
snapshot()andrelease()guard this state, so the getters answer without an error.
The full argument388 words
- The getters' own javadoc.
getIndexCommit(long): "Retrieve an IndexCommit from its generation; returns null if this IndexCommit is not currently snapshotted" (SnapshotDeletionPolicy.java:172-175).getSnapshots(): "Returns all IndexCommits held by at least one snapshot" (:157).getSnapshotCount(): "Returns the total number of snapshots currently held" (:162). In the repro state generation 1 is counted by the same object and is inDirectoryReader.listCommits(dir). The javadoc ofgetIndexCommit(long)names no other case for null and does not mention the state beforeonInit. - The persistent class promises the restart. "a persistence layer so that snapshots can be maintained across the life of an application" (
PersistentSnapshotDeletionPolicy.java:34-37); theOpenModeparameter: "open an existing index, initializing the class with the snapshots information" (:85-87). - Nothing says what the getters return before a writer opens. Not the javadoc of either class,
IndexDeletionPolicy,IndexWriterConfig.setIndexDeletionPolicy(IndexWriterConfig.java:207-220),lucene/CHANGES.txt(LUCENE-4973 at:13564, LUCENE-4976 at:13715) orlucene/MIGRATE.md. The general lifecycle is documented:IndexDeletionPolicyis an "Expert" policy set onIndexWriterConfig(IndexDeletionPolicy.java:23-27) whoseonInitgets the commits from the writer (:46-62), and the deletion policy "Only takes effect when IndexWriter is first created" (IndexWriterConfig.java:219). The closest thing to a caveat for this state is the exception message of the two mutators. getIndexCommit(gen)was added for applications that persist generations. On LUCENE-4973 (#6037), Shai Erera wrote on 2013-05-01 "pSDP needs to have a getIC(gen) because if the apps persists the gens, it needs a way to obtain their IC instance". On 2013-05-02, asking thatreleaseGenaccept a generation that is not snapshotted, they mentioned as a possible workaround "calling sdp.getIC(gen) and if not null call release". The thread does not discuss a policy that is read beforeonInit.- Early reads happen upstream, but only of the count. Upstream tests read
getSnapshotCount()from a re-created policy before attaching it, after every snapshot was released (count 0;TestPersistentSnapshotDeletionPolicy.java:161-164,:178-181). No upstream test reads the commit getters or a positive count in that state. - Not claimed. The
snapshot()/release(gen)IllegalStateException(the designed misuse guard; its message names the fix), and "getSnapshots()must list whatgetSnapshotCount()counts" (one counts snapshot references, the other lists commits; they also differ after attachment when a saved generation has no commit left).
The devil's-advocate check289 words
The strongest case for "intended", from the study's devil's advocate and an executing cross-model reviewer (OpenAI GPT-5.6, verdict not real): LUCENE-4973 moved onInit "to build the gen -> IC map" into SnapshotDeletionPolicy on purpose (Michael McCandless, #6037), and LUCENE-4976 "fixed PSDP to detect if you try to snapshot/release when it's not being used by an IW" (#6040). IndexDeletionPolicy is an Expert writer callback whose onInit supplies the commits; snapshot() protects a commit only "as long as this IndexDeletionPolicy is used"; the policy "Only takes effect when IndexWriter is first created"; upstream's restart test attaches first; and the save Directory may differ from the index, so the constructor is not in general able to build the commits. On that reading, commit lookups before attachment are outside the getters' meaningful domain. The cross-model fact check of this doc (OpenAI GPT-5.6) rated this argument "Strong": "A tiny clarification may be welcome; a defect claim is weak."
What it leaves open: it shows the code is intended, which is why this is classed as a documentation gap and not as a code bug. It does not show that the documentation describes this state. A careful reader can infer it by combining the public IndexDeletionPolicy contract (the writer supplies the commits in onInit) with the implementation (for the saved generations, the generation to IndexCommit map is filled there), but no text connects that contract to the getters of a re-created policy. The persistent class's javadoc points the other way, and the guard added on purpose covers only the mutators, so the getters answer without an error where the mutators fail loudly. The executing reviewer conceded "A narrow javadoc clarification could improve usability"; that clarification is the proposed fix, and nothing more is proposed.
Recommended A. Document the lifecycle (recommended)
Document the lifecycle: a re-created policy reads only the snapshotted generations and their counts; until an IndexWriter has been opened with it, getSnapshots() is empty, getIndexCommit(long) returns null, and snapshot() and release(long) throw. No behaviour change. Validated with gradle: the format and javadoc checks and the snapshot and writer suites pass.
All options and trade-offs3 options ยท 1,137 words
Two options were applied in a scratch worktree of main 645d4fe14a20 and run with gradle; a third is described only. The documentation fix is the proposal, because the code is deliberate and has behaved this way since 4.4.0. Open PR #16482 already edits both files.
Add a paragraph to the PersistentSnapshotDeletionPolicy class javadoc (what a re-created policy has before a writer opens with it), qualify "initializing the class with the snapshots information" on the OpenMode parameter, and add one sentence each to getSnapshots() and getIndexCommit(long).
diff --git a/lucene/core/src/java/org/apache/lucene/index/PersistentSnapshotDeletionPolicy.java b/lucene/core/src/java/org/apache/lucene/index/PersistentSnapshotDeletionPolicy.java
index d3fa95c1623..4077d94b0da 100644
--- a/lucene/core/src/java/org/apache/lucene/index/PersistentSnapshotDeletionPolicy.java
+++ b/lucene/core/src/java/org/apache/lucene/index/PersistentSnapshotDeletionPolicy.java
@@ -44,6 +44,13 @@ import org.apache.lucene.util.IOUtils;
* <p>This class adds a {@link #release(long)} method to release commits from a previous snapshot's
* {@link IndexCommit#getGeneration}.
*
+ * <p>A policy created over previously saved snapshots (for example after a restart) reads only the
+ * snapshotted commit generations and their counts from the save file: {@link #getSnapshotCount()}
+ * and {@link #getLastSaveFile()} reflect them right away, but the {@link IndexCommit}s come from
+ * the {@link IndexWriter}. Until an {@link IndexWriter} has been opened with this instance, {@link
+ * #getSnapshots()} is empty, {@link #getIndexCommit(long)} returns null, and {@link #snapshot()}
+ * and {@link #release(long)} throw {@link IllegalStateException}.
+ *
* @lucene.experimental
*/
public class PersistentSnapshotDeletionPolicy extends SnapshotDeletionPolicy {
@@ -84,7 +91,8 @@ public class PersistentSnapshotDeletionPolicy extends SnapshotDeletionPolicy {
* @param dir the {@link Directory} which will be used to persist the snapshots information.
* @param mode specifies whether a new index should be created, deleting all existing snapshots
* information (immediately), or open an existing index, initializing the class with the
- * snapshots information.
+ * snapshots information (the snapshotted generations and their counts; see the class javadoc
+ * for what is available before an {@link IndexWriter} has been opened with this instance).
*/
public PersistentSnapshotDeletionPolicy(IndexDeletionPolicy primary, Directory dir, OpenMode mode)
throws IOException {
diff --git a/lucene/core/src/java/org/apache/lucene/index/SnapshotDeletionPolicy.java b/lucene/core/src/java/org/apache/lucene/index/SnapshotDeletionPolicy.java
index 19f2c0d110f..5cc07c157c1 100644
--- a/lucene/core/src/java/org/apache/lucene/index/SnapshotDeletionPolicy.java
+++ b/lucene/core/src/java/org/apache/lucene/index/SnapshotDeletionPolicy.java
@@ -154,7 +154,14 @@ public class SnapshotDeletionPolicy extends IndexDeletionPolicy {
return lastCommit;
}
- /** Returns all IndexCommits held by at least one snapshot. */
+ /**
+ * Returns all IndexCommits held by at least one snapshot.
+ *
+ * <p>The commits are the ones handed to this policy by the {@link IndexWriter} that uses it. A
+ * {@link PersistentSnapshotDeletionPolicy} created over previously saved snapshots returns an
+ * empty list until an {@link IndexWriter} has been opened with it, although {@link
+ * #getSnapshotCount()} already counts the saved snapshots.
+ */
public synchronized List<IndexCommit> getSnapshots() {
return new ArrayList<>(indexCommits.values());
}
@@ -171,7 +178,8 @@ public class SnapshotDeletionPolicy extends IndexDeletionPolicy {
/**
* Retrieve an {@link IndexCommit} from its generation; returns null if this IndexCommit is not
- * currently snapshotted
+ * currently snapshotted, or if no {@link IndexWriter} has been opened with this policy yet (for
+ * example a {@link PersistentSnapshotDeletionPolicy} created over previously saved snapshots).
*/
public synchronized IndexCommit getIndexCommit(long gen) {
return indexCommits.get(gen);Trade-offs
No behaviour change; it states what the code does in the three places a restart user reads. Optionally add a CHANGES entry under "Documentation". #16482 already removes the stale javadoc of the private loadPriorSnapshots(). The two getter sentences go into SnapshotDeletionPolicy, so they also describe a plain SnapshotDeletionPolicy, for which they hold as well (before a writer opens it has neither snapshots nor commits); they name PersistentSnapshotDeletionPolicy as the case that matters. They do not cover a third case: after onInit, a saved generation whose commit is gone stays counted but has no commit. A clause could cover it.
What the checks validated
- Applied in a scratch worktree of
645d4fe14a20and run with gradle (seed5EED0890)::lucene:core:checkGoogleJavaFormatand:lucene:core:renderJavadocpass, and the renderedPersistentSnapshotDeletionPolicy.htmlandSnapshotDeletionPolicy.htmlshow the new sentences. - The snapshot-policy,
IndexFileDeleter,IndexWriterConfig,DirectoryReader,DirectoryReaderReopen,IndexWriterandControlledRealTimeReopenThreadsuites pass: 228 tests, 2 skipped, assertions on. - The minimal test keeps failing with the same assertion, assertions on and off, as expected for a change that touches only comments.
- A first draft failed
checkGoogleJavaFormaton line length in the class javadoc; the diff shown is the re-wrapped text that passes. Not run:gradlew checkor other modules.
getSnapshots() and getIndexCommit(long) throw IllegalStateException when snapshots were loaded but no IndexWriter has opened with the policy yet, like snapshot() and release() already do.
diff --git a/lucene/core/src/java/org/apache/lucene/index/SnapshotDeletionPolicy.java b/lucene/core/src/java/org/apache/lucene/index/SnapshotDeletionPolicy.java
index 19f2c0d110f..ce473fcf5c7 100644
--- a/lucene/core/src/java/org/apache/lucene/index/SnapshotDeletionPolicy.java
+++ b/lucene/core/src/java/org/apache/lucene/index/SnapshotDeletionPolicy.java
@@ -156,6 +156,7 @@ public class SnapshotDeletionPolicy extends IndexDeletionPolicy {
/** Returns all IndexCommits held by at least one snapshot. */
public synchronized List<IndexCommit> getSnapshots() {
+ ensureCommitsKnown();
return new ArrayList<>(indexCommits.values());
}
@@ -174,9 +175,18 @@ public class SnapshotDeletionPolicy extends IndexDeletionPolicy {
* currently snapshotted
*/
public synchronized IndexCommit getIndexCommit(long gen) {
+ ensureCommitsKnown();
return indexCommits.get(gen);
}
+ /** Snapshots loaded before any IndexWriter used this instance have no IndexCommit yet. */
+ private void ensureCommitsKnown() {
+ if (initCalled == false && refCounts.isEmpty() == false) {
+ throw new IllegalStateException(
+ "snapshots were loaded but no IndexWriter has been opened with this instance yet; open an IndexWriter with it before reading its IndexCommits");
+ }
+ }
+
/** Wraps each {@link IndexCommit} as a {@link SnapshotCommitPoint}. */
private List<IndexCommit> wrapCommits(List<? extends IndexCommit> commits) {
List<IndexCommit> wrappedCommits = new ArrayList<>(commits.size());Trade-offs
Turns the silent wrong answer into an exception that names the fix, only in the trap state; a fresh or attached policy behaves as before. But it changes behaviour in an experimental API: code that reads these getters on a re-created policy before attaching it and copes with null or [] would now throw. Needs @throws javadoc and a CHANGES entry, probably on main only. The minimal test then fails with the exception instead of the assertion.
What the checks validated
- The same worktree and seed:
:lucene:core:checkGoogleJavaFormatpasses, and the same suites pass (228 tests, 2 skipped). Only the two snapshot-policy suites call these getters anywhere in the Lucene sources (grep), so no upstream test reads them on an unattached policy with saved snapshots. - The minimal test now fails, assertions on and off, with
IllegalStateException: snapshots were loaded but no IndexWriter has been opened with this instance yet; open an IndexWriter with it before reading its IndexCommitsinstead of the silent null. - Not run:
renderJavadoc(the change adds no public javadoc) or other modules.
When the save Directory also holds the index, fill indexCommits from DirectoryReader.listCommits(dir) for the loaded generations.
Trade-offs
Works only when the save file sits next to the index (the class allows another Directory), adds index I/O and a new failure path to a constructor that reads one small file, hands out reader-side commits that onInit later replaces with the writer's, and reverses the 2013 design in which commits come only from the writer. Not built or run.
Backup or restore layers built on this policy that list snapshots from a re-created policy before opening an IndexWriter would report none; opening the writer first avoids it.
Non-formal. A model from another family (OpenAI GPT-5.6) proposed checking what a restarted snapshot policy reports; an executed lifecycle probe printed count=1 commits=0, and the finder reduced it to the constructor and onInit split by reading the code. The unit's model-based test and TLA+ model ran on the same classes but did not find it.
The method in full132 words
- Lead. An independent model from another family (OpenAI GPT-5.6), using a configuration-lifecycle framing, proposed the question: what does a restarted snapshot policy report?
- Probe. An executed lifecycle probe on main
a9779ef3c193printedpersistentAfterRestart count=1 commits=0and anIllegalStateExceptionforrelease(2026-10-28). - Reduction. A hunt unit (snapshot deletion policies) took the lead; its finder traced it by code reading to the constructor /
onInitsplit and wrote a public-API JUnit. The unit's bounded-exhaustive model-based test and its TLA+ model of commit retention ran on the same classes, but their findings were other items, so no tool output gave the first signal. - Verification. An independent reproducer, cross-model reviews (OpenAI GPT-5.6), an intended-behaviour devil's advocate, an adjudication, a re-verification and a tie-break; the devil's advocate and the adjudication trimmed the claim to the documentation gap.
No prior report found (searched 2026-10-28 and 2026-11-01): GitHub issues and PRs incl. the JIRA archive, commits, mailing lists, downstream trackers, Stack Overflow and the web. Closest: open PR #16482, which edits the same two files but not these getters or their javadoc.
Searches4 searches
Read, not the same bug: open PR #16482 "Modernize SnapshotDeletionPolicy and PersistentSnapshotDeletionPolicy" (@gsmiller, unchanged since 2026-08-25; it deletes the stale private loadPriorSnapshots() javadoc and adds a release(long) comment about a saved generation with no commit after onInit, a different state; it refactors onInit, the initCalled guard and getSnapshotCount() without changing their behaviour and leaves getSnapshots(), getIndexCommit(long), their javadoc and the constructor javadoc untouched; the study's repro still fails with its two files applied); #6037 / LUCENE-4973 and #6040 / LUCENE-4976 (the 2013 design; use before onInit never discussed); the 2013 java-user thread "Question on Lucene hot-backup functionality." and Stack Overflow 17753226 (a policy looked empty because IndexWriter used a clone of it, LUCENE-4876 / #5941, gone since 4.5.0; a different trigger); #3666 / LUCENE-2592 (2010, the old id-based API: the policy kept an IndexWriter open on its snapshots directory, so a second process could not read the saved snapshot information while the first held that write lock; fixed by a static readSnapshotsInfo, which later became the private loadPriorSnapshots(); a lock problem, three years before this behaviour began). The public bug list (linked from tracking issue #16937) names LFV-890; no maintainer has commented on it in #16937 or on the dev@ thread. Limits: private reports cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, incl. migrated JIRA) | 2026-10-28 and 2026-11-01 | scripted prior-report searches of 45, 75 and 102 queries (getSnapshots, getIndexCommit, getSnapshotCount, restart, reopen, APPEND, backup, onInit, before IndexWriter, ...); complete result lists for PersistentSnapshotDeletionPolicy (7) and SnapshotDeletionPolicy (16), bodies and comments scanned | no report |
| Open PRs and recent activity | 2026-10-28 and 2026-11-01 | file lists of all open PRs and of all 81 PRs updated since 2026-10-27; every issue and PR updated since 2026-10-27 | only #16482 touches the two files; nothing about the pre-attachment state |
| Mailing lists | 2026-10-28 and 2026-11-01 | dev@, java-user@, issues@ and commits@: the last 14 days and all-time threads naming the classes | no report; the 2013 hot-backup thread is a different trigger |
| Downstream projects, Q&A and the web | 2026-10-28 and 2026-11-01 | Solr, Lucene.NET, Elasticsearch and OpenSearch trackers; Stack Overflow; general web queries | no report |
Introduced in 4.4.0 by LUCENE-4973 (#6037, 30e43c836ab, 2013-05-02), which split the state into counts loaded by the constructor and commits filled in onInit; LUCENE-4976 (#6040) added getSnapshots() a day later. Every release from 4.4.0 to 10.6.0 and main behaves this way; 4.3.1 had an older id-based API. Not fixed upstream.
Versions tested7 rows
- Introduced: LUCENE-4973 (issue title "SnapshotDeletionPolicy should not require an id"; commit subject "LUCENE-4973: Persistent/SnapshotDeletionPolicy no longer require a unique id"; Michael McCandless, 2013-05-02; an SVN-era patch, the issue is now #6037):
30e43c836abon trunk,2d33a3bf9ef4on branch_4x, first released in 4.4.0. It addedrefCounts(loaded by the constructor),indexCommits(for the saved generations, filled inonInit),getSnapshotCount()andgetIndexCommit(long). - Second ingredient: LUCENE-4976 ("use single file to hold PersistentSnapshotDeletionPolicy state on disk"; #6040, 2013-05-03,
efeaac746ae/ branch_4xc82d9836349) addedList<IndexCommit> getSnapshots()over the same map and theinitCalledguard onsnapshot()andreleaseGen(). Same first release. - Before: 3.1.0 to 4.3.1 had an id-based API whose
getSnapshots()listed the saved snapshot before attachment;getSnapshot(id)already returned null before attachment there. - 4.4.0 only:
IndexWriterstill cloned its config, so even the instance passed in stayed in this state after the writer opened; since 4.5.0 the writer uses the instance you pass. - Upstream: 13 later commits touch the two files (sync and old-save-file cleanup in 2013, error paths, reformatting, the #14913 error-path cleanup in 2025); none changes the split between the counts the constructor loads and the commits
onInitsupplies. Upstream mainf815e679bf6c(2026-11-01) has the same two files as645d4fe14a20.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails, gradle and standalone, assertions on and off |
| 10.6.0, 10.5.2 | affected | minimal test fails (10.5.2 with assertions on and off; the 10.5.2 version probe also shows the empty list) |
| 10.0.0, 9.12.3, 9.0.0 | affected | count 1 with an empty list and a null lookup before attachment; all agree after a writer opens |
| 8.11.4, 7.7.3, 6.6.6, 5.5.5, 5.0.0 | affected | the same |
| 4.10.4, 4.5.0 | affected | the same |
| 4.4.0 | first release | the same; the passed-in instance also stays empty after the writer opens (config clone) |
| 3.1.0, 4.0.0, 4.3.1 | older API | id-based API: getSnapshots() lists the saved snapshot before attachment; the generation API is absent (the 4.x port does not compile on 4.3.1) |
Every check agrees on the behaviour and the cause. The study's reproducer, devil's advocate and adjudication, a re-verification and the tie-break call it a documentation gap with the code by design; an executing cross-model reviewer (OpenAI GPT-5.6) voted not real as a Lucene defect but conceded that a javadoc clarification could help.
Checks and verdicts396 words
The study ran on main a9779ef3c193, whose two policy files equal those at 645d4fe14a20. The executing cross-model review ran on ea57360912ed, also identical for these files; its test was later re-run on 645d4fe14a20 with the same result.
| Check | Verdict | What it found |
|---|---|---|
| study finder | reproduced, documentation defect | Public-API JUnit fails 2 of 2 on main (assertions on and off), branch_10_6, 10.5.2, 9.12.3 and with #16482 applied. |
| independent reproducer | real, documentation only | Own test fails 4 of 6 with both controls passing (save file in the index directory and in a separate one); a framework-free main() shows the same on main, 10.5.2, 9.12.3 and 8.11.4. |
| root-cause adversary | uncertain, medium (reading only) | OpenAI GPT-5.6; agreed with the root cause, called the intended-behaviour argument strong and recommended documenting the lifecycle boundary. |
| devil's advocate | real, documentation only | Required dropping the release(long) leg (designed guard) and the 'list must equal count' framing. |
| adjudication | confirmed, doc, minor | Re-ran the finder's test and the independent reproduction's main(). |
| re-verification (2026-11-01) | real, doc | Minimal test fails on main and 10.5.2, assertions on and off; the attach-first control passes. |
| cross-model review, refute lens | not real, medium (executed) | Reproduced with its own test (index and save file in separate directories, commit checked on disk), root cause agrees; reads early lookups as out of contract, concedes a javadoc clarification could help. |
| Second adjudication | real, doc | New seeds on main and 10.5.2; a probe shows the default constructor reaches the same state, the commit is on disk, and everything agrees after a writer opens. |
| this doc's fix validation | options A and B run | Gradle in a scratch worktree: the minimal test on stock main, then options A and B with the module's snapshot and writer suites. |
| independent fact check (2026-11-01) | real, doc; two wording errors fixed | Re-ran the minimal test (standalone and the exact gradle command, assertions on and off), options A and B and its own probe; every code citation, commit and quote checked. Two prior-art wording errors (#16937, #16482) are fixed in this doc. |
| cross-model fact check (2026-11-01) | behaviour real; wording narrowed | OpenAI GPT-5.6, reading the saved runs: the behaviour is real; over-broad wording is narrowed in this doc. It rated the intended-behaviour argument "Strong" and called a defect claim weak; a tiny clarification may be welcome. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 490 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,276 words
- Registry title
- A PersistentSnapshotDeletionPolicy re-opened over its save file counts the persisted snapshots, but getSnapshots() is empty and getIndexCommit(gen) returns null until an IndexWriter has opened with it, and no javadoc states this precondition (documentation gap; the behaviour is by design)
- Area
- index / deletion policies (SnapshotDeletionPolicy and PersistentSnapshotDeletionPolicy restart lifecycle, javadoc)
- Priority
- Low, rank 1127: This by-design documentation gap in the opt-in experimental PersistentSnapshotDeletionPolicy loses nothing and only misleads early readers.
- Impact class
- minor
- Impact note
- Applications that opt into the @lucene.experimental PersistentSnapshotDeletionPolicy and, after a restart, read getSnapshots() or getIndexCommit(gen) from the re-created policy before opening an IndexWriter with it (for example a backup or restore tool that lists the persisted snapshots first) silently see no snapshotted commits while getSnapshotCount() reports them; nothing is deleted, written or corrupted, the getters reflect the persisted snapshots once a writer has opened with the policy, and snapshot()/release(gen) fail loudly with an IllegalStateException that names the fix; default codec and directory, no scale needed.
- Summary
- You restart an application that uses PersistentSnapshotDeletionPolicy, create the policy again over its save file with OpenMode.APPEND (whose javadoc says this opens the existing snapshots, 'initializing the class with the snapshots information'), and ask it which commits are snapshotted before you open an IndexWriter with it. You expect getSnapshots() and getIndexCommit(gen) to show the persisted snapshot that getSnapshotCount() counts, or the javadoc to say that the policy must first be passed to an IndexWriter. Instead getSnapshotCount() is 1 while getSnapshots() is empty and getIndexCommit(gen) returns null, which its javadoc says means 'not currently snapshotted'; this is by design (the IndexCommit objects come from the writer's onInit call), but no javadoc states the precondition.
- Introduced
- 30e43c836ab10a4745e60ce8a2d18b4d58a210da (trunk; branch_4x backport 2d33a3bf9ef4): LUCENE-4973 'Persistent/SnapshotDeletionPolicy no longer require a unique id' (migrated as apache/lucene#6037; SVN-era patch by mikemccand, no PR). It replaced the id-based state with refCounts (generation -> count, loaded by the PersistentSnapshotDeletionPolicy constructor) and indexCommits (generation -> IndexCommit, filled only in SnapshotDeletionPolicy.onInit) and added getSnapshotCount() and getIndexCommit(long). The next day efeaac746ae (trunk; branch_4x c82d9836349), LUCENE-4976 'use single file to hold PersistentSnapshotDeletionPolicy state on disk' (#6040), added List<IndexCommit> getSnapshots() (empty before onInit) and the initCalled guard on snapshot()/releaseGen(). Both reached 4.4.0; no bisection inside 4.4.0 is possible. Later edits are cosmetic (3b67b174932 diamond, 2014; 2d6ad2fee6d reformat, 2020) [read: git blame on main a9779ef, git log -S and per-commit diffs in the history clone; exec: 4.3.1 not applicable (API absent), 4.4.0 fails] (2013-05-02 (30e43c836ab, LUCENE-4973) and 2013-05-03 (efeaac746ae, LUCENE-4976); both first released in 4.4.0 (tagged 2013-07-22))
- First affected version
- 4.4.0 [exec] (the first release with the generation-based API; the follow-up's version probe fails there). 4.3.1 is the last unaffected release [exec]: getSnapshotCount(), getIndexCommit(long), List<IndexCommit> getSnapshots() and release(long) did not exist (the probe does not compile), and the id-based getSnapshots() of 3.1-4.3.1 listed the persisted snapshot before attachment (executed on 3.1.0, 4.0.0 and 4.3.1), although getSnapshot(id) already returned null before attachment
- Affected versions
- Every release from 4.4.0 through 10.6.0 and main a9779ef3c193. [exec] the follow-up's version probe (getSnapshotCount()=1, getSnapshots().size()=0, getIndexCommit(gen)=null before attachment; all three agree after a writer has opened) fails on 4.4.0, 4.5.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main; the finder's JUnit fails 2 of 2 on main (-ea and -da), branch_10_6 4ae946e5a9b (10.6.0, released 2026-10-29), 10.5.2, 9.12.3 and with open PR #16482's two files shadowed; the independent reproduction's JUnit fails 4 of 6 on main (-ea and -da, two seeds), 10.5.2 and 9.12.3, and its framework-free main() on main (-ea and -da), 10.5.2, 9.12.3 and 8.11.4. In 4.4.0 only, IndexWriter still cloned its config, so even the instance the caller passed in stayed in this state after the writer opened; since 4.5.0 the passed instance is the writer's [exec]. [read] the code paths are unchanged since 2013 apart from cosmetic edits. Not fixed upstream: the newest commits on the two files are #14913 (PersistentSnapshotDeletionPolicy, author date 2025-07-14) and 2d6ad2fee6df (SnapshotDeletionPolicy, 2020-12-23), and open PR #16482 leaves onInit, the getters and their javadoc unchanged [read 2026-10-28]. All runs on aarch64 with JDK 25.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal
- Upstream and prior reports
- none found (searched 2026-10-28): two scripted prior-report searches (45 and 75 queries over apache/lucene issues, PRs and commits, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive, downstream repositories and the web; 159 distinct hits, 26 medium, all Lucene ones read), complete listings of the apache/lucene searches 'PersistentSnapshotDeletionPolicy' (7) and 'SnapshotDeletionPolicy' (16) with bodies and comments scanned, the file lists of 410 open PRs (only #16482 touches the two files), every PR created since 2026-10-25 and issue since 2026-10-20, Solr, Lucene.NET, Elasticsearch and OpenSearch trackers, JIRA across projects, Stack Overflow and the web; refreshed on 2026-10-28 (no PR created since 2026-10-26 touches the files, no new commit on them, #16482 unchanged). Related, not this bug: #6037 (LUCENE-4973) and #6040 (LUCENE-4976), the 2013 design behind it; open PR #16482 'Modernize SnapshotDeletionPolicy and PersistentSnapshotDeletionPolicy' (gsmiller, head 67ced02, last updated 2026-08-25, unmerged) keeps onInit, the getters, their javadoc and the guard, deletes the stale javadoc on the private loadPriorSnapshots(), and has a release(long) code comment about a persisted generation with no IndexCommit after onInit (a different, post-attachment state); the 2013 java-user thread 'Question on Lucene hot-backup functionality.' (also Stack Overflow 17753226), where a user's SnapshotDeletionPolicy looked empty because IndexWriter used a private clone of it (LUCENE-4876; gone since 4.5.0); #3666 (LUCENE-2592, 2010), a static reader of the snapshot information without a writer, made private in 4.4.0.
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test through gradle with assertions on and off, and with the javadoc fix and the loud-getters alternative, with the snapshot-policy,IndexFileDeleter,IndexWriterConfig,DirectoryReader,DirectoryReaderReopen,IndexWriterandControlledRealTimeReopenThreadsuites, in a scratch worktree (2026-11-01); standalone on main and 10.5.2; version probes on 4.4.0 to 10.5.2, and the minimal test also on 9.12.3 and 10.6.0. The study ran on maina9779ef3c193, whose two policy files equal those at645d4fe14a20; upstream mainf815e679bf6c(2026-11-01) has the same files.
- Finding record
lucene/core/src/java/org/apache/lucene/index/PersistentSnapshotDeletionPolicy.java:44-45 (release(long) 'to release commits from a previous snapshot's IndexCommit#getGeneration'), :47 (@lucene.experimental), :85-87 (APPEND 'open an existing index, initializing the class with the snapshots information'), :89-104 (constructor calls loadPriorSnapshots()), :156-158 (release(long) -> releaseGen), :206-210 (stale javadoc on the private loadPriorSnapshots)lucene/core/src/java/org/apache/lucene/index/SnapshotDeletionPolicy.java:55-56 ('Used to detect misuse' initCalled), :70-81 (onInit sets initCalled and fills indexCommits for loaded generations), :94-98 and :142-146 (IllegalStateException before onInit), :157-160 (getSnapshots 'Returns all IndexCommits held by at least one snapshot'), :162-170 (getSnapshotCount 'the total number of snapshots currently held'), :172-177 (getIndexCommit 'returns null if this IndexCommit is not currently snapshotted')lucene/core/src/test/org/apache/lucene/index/TestPersistentSnapshotDeletionPolicy.java:46-86 (testExistingSnapshots opens the writer before the getters), :164 and :181 (getSnapshotCount() read from an unattached re-created policy, asserting 0)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/index/SnapshotDeletionPolicy.java:43-47, :55-56, :69-81, :94-98, :115-126, :142-146, :157-178lucene/core/src/java/org/apache/lucene/index/PersistentSnapshotDeletionPolicy.java:39-42, :89-104, :156-159, :161-184, :211-264lucene/core/src/java/org/apache/lucene/index/IndexDeletionPolicy.java:46-62; lucene/core/src/java/org/apache/lucene/index/IndexFileDeleter.java:237
- Who is affected
lucene/core/src/java/org/apache/lucene/index/PersistentSnapshotDeletionPolicy.java:47 (@lucene.experimental)lucene/core/src/test/org/apache/lucene/index/TestPersistentSnapshotDeletionPolicy.java:46-91 (testExistingSnapshots attaches before the getters); :161-164, :178-181 (count 0 read before attaching)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/SnapshotDeletionPolicy.java:128-132, :157-178lucene/core/src/java/org/apache/lucene/index/PersistentSnapshotDeletionPolicy.java:34-37, :85-87lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:207-220; lucene/CHANGES.txt:13564, :13715; lucene/MIGRATE.mdlucene/core/src/test/org/apache/lucene/index/TestPersistentSnapshotDeletionPolicy.java:161-164, :178-181lucene/core/src/java/org/apache/lucene/index/IndexDeletionPolicy.java:23-27, :46-62
- Suggested fix
lucene/core/src/java/org/apache/lucene/index/PersistentSnapshotDeletionPolicy.java:39-42, :206-210
- TestReopenedPersistentSnapshotGetIndexCommit.java:
repro/LFV-890/src/org/apache/lucene/index/TestReopenedPersistentSnapshotGetIndexCommit.java(49 lines)
assets/LFV-890/bug.png(490 KB)
