You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
IndexWriter#abortMerges() sets the abort flag on running merges and waits for them to finish, relying on merges observing the flag periodically (the comment in abortMerges says "It should not take very long because they periodically check if they are aborted."). That assumption holds for the I/O-bound merge phases, because every write goes through the merge rate limiter, which checks for abort. Since #16264 / #16281, checkIntegrity() is also covered.
HNSW graph construction breaks this assumption: HnswGraphBuilder#addVectors is a pure-CPU loop that performs no writes and never checks OneMerge#isAborted(). For large vector segments this phase runs for 10–25 minutes single-threaded, during which rollback() / abortMerges() block unconditionally.
We believe this is now the last significant blind spot in merge abort handling, and it is the dominant one for vector-heavy indexes: in our measurements graph construction is 93.6% of merge CPU, while the checksum phase addressed by #16281 is 0.1%.
Production impact
Elasticsearch 9.4.2 (Lucene 10.4.0) cluster, HNSW-indexed dense vectors (1M × 384d float32 + 575k × 96d quantized per shard). During a routine rolling restart, each data node's shutdown blocked on abortMerges waiting for an in-flight HNSW merge, for 24–31 minutes per node, exceeding the 30-minute termination grace period, so pods had to be force-killed.
Aborting thread:
"elasticsearch[...][generic][T#…]" TIMED_WAITING
at java.lang.Object.wait0(java.base/Native Method)
at org.apache.lucene.index.IndexWriter.doWait(IndexWriter.java:5571)
at org.apache.lucene.index.IndexWriter.abortMerges(IndexWriter.java:2760)
at org.apache.lucene.index.IndexWriter.rollbackInternalNoCommit(IndexWriter.java:2514)
at org.apache.lucene.index.IndexWriter.rollbackInternal(IndexWriter.java:2483)
The single merge thread it waits for (RUNNABLE the whole time, no writes):
"elasticsearch[...][merge][T#3]" RUNNABLE
... (vector scoring frames elided)
at org.apache.lucene.util.hnsw.HnswGraphSearcher.searchLevel(HnswGraphSearcher.java:342)
at org.apache.lucene.util.hnsw.HnswGraphBuilder.addGraphNodeInternal(HnswGraphBuilder.java:312)
at org.apache.lucene.util.hnsw.HnswGraphBuilder.addGraphNode(HnswGraphBuilder.java:354)
at org.apache.lucene.util.hnsw.HnswGraphBuilder.addVectors(HnswGraphBuilder.java:233)
at org.apache.lucene.util.hnsw.HnswGraphBuilder.build(HnswGraphBuilder.java:201)
at org.apache.lucene.util.hnsw.IncrementalHnswGraphMerger.merge(IncrementalHnswGraphMerger.java:218)
at org.apache.lucene.codecs.lucene99.Lucene99HnswVectorsWriter.mergeOneField(Lucene99HnswVectorsWriter.java:449)
at org.apache.lucene.codecs.perfield.PerFieldKnnVectorsFormat$FieldsWriter.mergeOneField(PerFieldKnnVectorsFormat.java:126)
at org.apache.lucene.codecs.KnnVectorsWriter.merge(KnnVectorsWriter.java:105)
at org.apache.lucene.index.SegmentMerger.mergeVectorValues(SegmentMerger.java:271)
...
at org.apache.lucene.index.IndexWriter.mergeMiddle(IndexWriter.java:5323)
at org.apache.lucene.index.IndexWriter.merge(IndexWriter.java:4784)
Measurements
Instrumenting one incident-class merge (TieredMergePolicy deletes-purging rewrite of a ~3 GB segment, 1.9M docs, JFR + async-profiler):
Graph construction = 93.6% of merge CPU samples (higher than its wall-time share because the copy phases are I/O-bound). main has the same shape: HnswGraphBuilder#addVectors contains no abort/cancellation hook.
A standalone reproduction below (lucene-core only, 160k × 384d vectors): rollback() called 8s into a forceMerge blocks for 68.6s on stock 10.4.0, which is the entire remaining graph build.
Reproduction (lucene-core 10.4.0 on the classpath, ~1 min indexing + ~1 min to observe the block)
Observed on 10.4.0 (Apple M-series, JDK 23, --add-modules jdk.incubator.vector):
calling rollback() at t=8.0s after merge start
### rollback() returned in 68.63s ###
With a periodic abort check added to HnswGraphBuilder#addVectors (same machine, same seed):
calling rollback() at t=8.0s after merge start
[bg] forceMerge exception after 8.2s: ... MergeAbortedException ... [ABORTED]
### rollback() returned in 0.20s ###
Note: on 10.4.0 this reproduction always takes the full-rebuild path (HnswGraphBuilder#addVectors), because PerFieldKnnVectorsFormat.FieldsReader does not implement HnswGraphProvider there, so graph reuse never kicks in under the default codec. On main, where the wrapper does implement it, clean segments take the MergingHnswGraphBuilder join path instead. That path also has no abort checks (see step 3 below), so the repro demonstrates the block either way.
Extend the pattern established by #16264 / #16281 to the graph build path:
The plumbing already exists on main: MergeState#oneMerge and the null-safe MergeState#checkAborted() (Check if merge is aborted before executing file integrity checks #16264), which KnnVectorsWriter#merge already invokes once per reader before merging. What's missing is handing that same check down from Lucene99HnswVectorsWriter#mergeOneField through the HNSW graph merger into the builders.
In HnswGraphBuilder#addVectors, invoke it periodically, e.g. every 1024 nodes. Each node insertion costs hundreds of vector comparisons, so the overhead is unmeasurable. (This and step 3 could likely be consolidated into a single check in the shared node-insertion path addGraphNode/addGraphNodeInternal. Happy to follow whichever reviewers prefer.)
Cover the incremental-merge path too (MergingHnswGraphBuilder#build's remainder-insertion loop and updateGraph), which inserts via addGraphNode directly.
Same null contract as checkIntegrity(OneMerge): flush-time graph builds pass null and are unaffected.
We validated the approach with a patched 10.4.0 in the affected cluster (same periodic check in addVectors, wired through a ThreadLocal shim only because 10.4 predates MergeState#oneMerge, while the proposal above uses the explicit plumbing instead). Node shutdown during a live graph build went from 24–31 min (grace exceeded, force kill) to 2.2 s, measured between the node's "stopping" and "stopped" log lines. Aborted merges clean up through the normal MergeAbortedException path, and undisturbed merges complete unchanged.
Happy to contribute a PR with a test (rollback during HNSW merge must abort promptly) if this direction sounds right.
Version and environment details
Lucene 10.4.0 (bundled in Elasticsearch 9.4.2). addVectors on main also lacks abort checks.
OpenJDK 26.0.1, Linux aarch64, single-threaded merges (no intra-merge executor).
Description
Summary
IndexWriter#abortMerges()sets the abort flag on running merges and waits for them to finish, relying on merges observing the flag periodically (the comment inabortMergessays "It should not take very long because they periodically check if they are aborted."). That assumption holds for the I/O-bound merge phases, because every write goes through the merge rate limiter, which checks for abort. Since #16264 / #16281,checkIntegrity()is also covered.HNSW graph construction breaks this assumption:
HnswGraphBuilder#addVectorsis a pure-CPU loop that performs no writes and never checksOneMerge#isAborted(). For large vector segments this phase runs for 10–25 minutes single-threaded, during whichrollback()/abortMerges()block unconditionally.We believe this is now the last significant blind spot in merge abort handling, and it is the dominant one for vector-heavy indexes: in our measurements graph construction is 93.6% of merge CPU, while the checksum phase addressed by #16281 is 0.1%.
Production impact
Elasticsearch 9.4.2 (Lucene 10.4.0) cluster, HNSW-indexed dense vectors (1M × 384d float32 + 575k × 96d quantized per shard). During a routine rolling restart, each data node's shutdown blocked on
abortMergeswaiting for an in-flight HNSW merge, for 24–31 minutes per node, exceeding the 30-minute termination grace period, so pods had to be force-killed.Aborting thread:
The single merge thread it waits for (RUNNABLE the whole time, no writes):
Measurements
Instrumenting one incident-class merge (TieredMergePolicy deletes-purging rewrite of a ~3 GB segment, 1.9M docs, JFR + async-profiler):
Graph construction = 93.6% of merge CPU samples (higher than its wall-time share because the copy phases are I/O-bound).
mainhas the same shape:HnswGraphBuilder#addVectorscontains no abort/cancellation hook.A standalone reproduction below (lucene-core only, 160k × 384d vectors):
rollback()called 8s into aforceMergeblocks for 68.6s on stock 10.4.0, which is the entire remaining graph build.Reproduction (lucene-core 10.4.0 on the classpath, ~1 min indexing + ~1 min to observe the block)
Observed on 10.4.0 (Apple M-series, JDK 23,
--add-modules jdk.incubator.vector):With a periodic abort check added to
HnswGraphBuilder#addVectors(same machine, same seed):Note: on 10.4.0 this reproduction always takes the full-rebuild path (
HnswGraphBuilder#addVectors), becausePerFieldKnnVectorsFormat.FieldsReaderdoes not implementHnswGraphProviderthere, so graph reuse never kicks in under the default codec. Onmain, where the wrapper does implement it, clean segments take theMergingHnswGraphBuilderjoin path instead. That path also has no abort checks (see step 3 below), so the repro demonstrates the block either way.Prior reports
IndicesClusterStateService#removeIndicesblocks on IO while aborting merges elastic/elasticsearch#88055) was closed pointing to it.OneMergeProgressintoMergeStatefor exactly this scenario (HNSW stack in the report), but received no review and its plumbing is now superseded byMergeState#oneMerge(Check if merge is aborted before executing file integrity checks #16264).Proposed fix
Extend the pattern established by #16264 / #16281 to the graph build path:
MergeState#oneMergeand the null-safeMergeState#checkAborted()(Check if merge is aborted before executing file integrity checks #16264), whichKnnVectorsWriter#mergealready invokes once per reader before merging. What's missing is handing that same check down fromLucene99HnswVectorsWriter#mergeOneFieldthrough the HNSW graph merger into the builders.HnswGraphBuilder#addVectors, invoke it periodically, e.g. every 1024 nodes. Each node insertion costs hundreds of vector comparisons, so the overhead is unmeasurable. (This and step 3 could likely be consolidated into a single check in the shared node-insertion pathaddGraphNode/addGraphNodeInternal. Happy to follow whichever reviewers prefer.)MergingHnswGraphBuilder#build's remainder-insertion loop andupdateGraph), which inserts viaaddGraphNodedirectly.checkIntegrity(OneMerge): flush-time graph builds pass null and are unaffected.We validated the approach with a patched 10.4.0 in the affected cluster (same periodic check in
addVectors, wired through a ThreadLocal shim only because 10.4 predatesMergeState#oneMerge, while the proposal above uses the explicit plumbing instead). Node shutdown during a live graph build went from 24–31 min (grace exceeded, force kill) to 2.2 s, measured between the node's "stopping" and "stopped" log lines. Aborted merges clean up through the normalMergeAbortedExceptionpath, and undisturbed merges complete unchanged.Happy to contribute a PR with a test (rollback during HNSW merge must abort promptly) if this direction sounds right.
Version and environment details