MiniLucene NRT Mutation and Merge Design and Implementation History¶
Historical objective: Add immutable reader snapshots, refresh-visible uncommitted state, delete/update overlays, explicit segment merge, and reference-owned cleanup without changing old readers.
Architecture: A writer publishes new immutable ReaderSnapshot values. Per-segment immutable live-doc files mask deletes. Refresh and commit publish different roots. Merge builds a new segment off to the side and swaps the writer's segment set only after success. SegmentRegistry retains obsolete files until no owner references them.
Tech Stack: Python 3.12 standard library, pytest, Ruff, uv.
Milestone 1: Freeze reader snapshots and closed-state behavior¶
Recorded file scope:
- Changed: src/minilucene/reader.py
- Added: src/minilucene/snapshot.py
- Added: tests/nrt/test_reader_snapshot.py
Recorded activity 1 — Test intent: snapshot and close tests
import pytest
from minilucene.errors import AlreadyClosedError
def test_reader_snapshot_never_changes_after_later_commit(index):
with index.writer() as writer:
writer.add_document(id="1", body="old")
writer.commit()
old_reader = index.open_reader()
with index.writer() as writer:
writer.add_document(id="2", body="new")
writer.commit()
assert old_reader.max_doc == 1
assert index.open_reader().max_doc == 2
def test_reader_close_is_idempotent_and_operations_fail(index_with_doc):
reader = index_with_doc.open_reader()
reader.close()
reader.close()
with pytest.raises(AlreadyClosedError):
reader.document(0)
Recorded activity 2 — Verification intent: RED
Historical verification covered targeted or full test coverage, including tests/nrt/test_reader_snapshot.py.
Recorded activity 3 — Design outcome: immutable snapshot values
@dataclass(frozen=True)
class SegmentSnapshot:
generation: int
image: SegmentImage
live_docs: frozenset[int]
@dataclass(frozen=True)
class ReaderSnapshot:
schema_fingerprint: str
segments: tuple[SegmentSnapshot, ...]
corpus_stats: CorpusStats
commit_generation: int | None
IndexReader holds exactly one snapshot and lifecycle flag. Searches take a
local reference to the snapshot before checking segment data. Closing releases
ownership once and never mutates the snapshot value.
Recorded activity 4 — Verification intent: GREEN
Historical verification covered targeted or full test coverage, including tests/nrt/test_reader_snapshot.py, tests/contract/test_disk_search.py.
Milestone 2: Publish NRT readers through explicit refresh¶
Recorded file scope:
- Changed: src/minilucene/writer.py
- Changed: src/minilucene/index.py
- Added: tests/nrt/test_refresh_visibility.py
Recorded activity 1 — Test intent: the three-boundary tests
def test_refresh_sees_flushed_uncommitted_documents(index):
committed = index.open_reader()
with index.writer() as writer:
writer.add_document(id="1", body="visible")
nrt = writer.refresh()
assert nrt.search(TermQuery("body", "visible"), 10).total_hits == 1
assert committed.max_doc == 0
assert index.open_reader().max_doc == 0
def test_uncommitted_refresh_state_disappears_after_process_reopen(index):
with index.writer() as writer:
writer.add_document(id="1", body="ephemeral")
assert writer.refresh().max_doc == 1
reopened = type(index).open(index.path)
assert reopened.open_reader().max_doc == 0
Recorded activity 2 — Verification intent: RED
Historical verification covered targeted or full test coverage, including tests/nrt/test_refresh_visibility.py.
Recorded activity 3 — Design outcome: refresh
writer.refresh() flushes the RAM buffer, captures the writer's current
segment set and live-doc masks, computes one global live-doc CorpusStats,
creates a new reader, and records only the writer-owned unpublished reader
handle needed for cleanup. It does not write manifest.json.
Repeated refresh with no state change returns a distinct reader object sharing the same immutable snapshot.
Recorded activity 4 — Verification intent: GREEN
Historical verification covered targeted or full test coverage, including tests/nrt/test_refresh_visibility.py.
Milestone 3: Store immutable live-doc generations¶
Recorded file scope:
- Added: src/minilucene/storage/live_docs.py
- Changed: src/minilucene/storage/manifest.py
- Added: tests/unit/storage/test_live_docs.py
- Added: tests/storage/test_live_docs_commit.py
Recorded activity 1 — Test intent: codec and manifest-reference tests
from minilucene.storage.live_docs import LiveDocsCodec
def test_live_docs_round_trip_and_checksum():
encoded = LiveDocsCodec.encode(max_doc=5, live_docs=frozenset({0, 2, 4}))
assert LiveDocsCodec.decode(5, encoded) == frozenset({0, 2, 4})
def test_manifest_points_to_exact_immutable_live_docs_generation(index):
with index.writer() as writer:
writer.add_document(id="1", body="alpha")
writer.commit()
writer.delete_by_term("id", "1")
writer.commit()
commit = index.manifest().segments[0]
assert commit.live_docs_generation == 1
assert commit.live_docs_checksum
Recorded activity 2 — Verification intent: RED
Historical verification covered targeted or full test coverage, including tests/unit/storage/test_live_docs.py, tests/storage/test_live_docs_commit.py.
Recorded activity 3 — Design outcome: the codec and store
Encode max_doc plus a fixed-length bitset; unused high bits must be zero.
Files are named:
segments/seg_000001/live_000001.bin
Publication writes a unique temp file, fsyncs, atomically renames, and fsyncs
the segment directory. Existing live-doc files never change. Manifest entries
carry generation and SHA-256; None means all documents are live.
Recorded activity 4 — Verification intent: GREEN
Historical verification covered targeted or full test coverage, including tests/unit/storage/test_live_docs.py, tests/storage/test_live_docs_commit.py.
Milestone 4: Delete by exact term across buffer and segments¶
Recorded file scope:
- Changed: src/minilucene/writer.py
- Changed: src/minilucene/index/memory.py
- Added: tests/nrt/test_delete_by_term.py
Recorded activity 1 — Test intent: buffered, flushed, stale-reader, and stats tests
def test_delete_matches_buffered_and_flushed_documents(index):
with index.writer() as writer:
writer.add_document(id="same", body="first")
writer.flush()
writer.add_document(id="same", body="second")
assert writer.delete_by_term("id", "same") == 2
reader = writer.refresh()
assert reader.max_doc == 0
def test_old_reader_keeps_deleted_document_and_new_stats_exclude_it(index):
with index.writer() as writer:
writer.add_document(id="1", body="rare")
writer.add_document(id="2", body="common")
writer.commit()
old = index.open_reader()
with index.writer() as writer:
writer.delete_by_term("id", "1")
new = writer.refresh()
assert old.search(TermQuery("body", "rare"), 10).total_hits == 1
assert new.search(TermQuery("body", "rare"), 10).total_hits == 0
assert new.corpus_stats.live_doc_count == 1
Recorded activity 2 — Verification intent: RED
Historical verification covered targeted or full test coverage, including tests/nrt/test_delete_by_term.py.
Recorded activity 3 — Design outcome: exact-term deletion
Validate the term field is indexed. For buffered documents, maintain a
buffer-local live set so local doc IDs remain dense and postings remain
immutable until flush; flush skips deleted buffered docs and remaps survivors
dense. For existing segments, derive a new frozenset from current live docs.
Apply all derived masks to writer state only after every segment scan succeeds.
The interface returned the number of newly deleted live documents. Repeating the same delete returns zero.
Recorded activity 4 — Verification intent: GREEN
Historical verification covered targeted or full test coverage, including tests/nrt/test_delete_by_term.py.
Milestone 5: Define update as atomic writer-state delete plus add¶
Recorded file scope:
- Changed: src/minilucene/writer.py
- Added: tests/nrt/test_update_document.py
Recorded activity 1 — Test intent: replacement and rollback tests
import pytest
def test_update_deletes_all_matches_then_adds_one_replacement(index):
with index.writer() as writer:
writer.add_document(id="1", body="old one")
writer.add_document(id="1", body="old two")
writer.commit()
deleted = writer.update_document(
field="id", term="1", id="1", body="replacement"
)
assert deleted == 2
reader = writer.refresh()
assert reader.search(TermQuery("body", "old"), 10).total_hits == 0
assert reader.search(TermQuery("body", "replacement"), 10).total_hits == 1
def test_invalid_replacement_leaves_delete_state_unchanged(index):
with index.writer() as writer:
writer.add_document(id="1", body="old")
writer.commit()
with pytest.raises(TypeError):
writer.update_document(field="id", term="1", id="1", body=7)
assert writer.refresh().search(TermQuery("body", "old"), 10).total_hits == 1
Recorded activity 2 — Verification intent: RED
Historical verification covered targeted or full test coverage, including tests/nrt/test_update_document.py.
Recorded activity 3 — Design outcome: prepare-then-swap
Prepare and analyze the replacement document first. Derive delete masks without mutating writer state. Copy the RAM buffer, apply buffered deletes, add the prepared replacement, then swap masks and RAM buffer together. Commit and refresh publish both sides together.
Recorded activity 4 — Verification intent: GREEN
Historical verification covered targeted or full test coverage, including tests/nrt/test_update_document.py.
Milestone 6: Recompute global live-document BM25 statistics¶
Recorded file scope:
- Changed: src/minilucene/search/stats.py
- Changed: src/minilucene/reader.py
- Added: tests/nrt/test_live_bm25_stats.py
Recorded activity 1 — Test intent: multi-segment counterexamples
Construct two logically identical indexes:
- one segment containing only live documents;
- three segments containing the same live documents plus deleted documents.
Assert every term query yields equal N, df, avgdl, scores, and order.
Also assert one segment cannot expose a local-IDF API to its scorer.
Recorded activity 2 — Verification intent: RED
Historical verification covered targeted or full test coverage, including tests/nrt/test_live_bm25_stats.py.
Recorded activity 3 — Centralize snapshot statistics
ReaderSnapshot.build walks each segment's live doc IDs, accumulates global
document count, per-field document count/total length, and per-field/term live
document frequency. It freezes CorpusStats once. Per-segment scorers require
this value as a constructor argument.
Recorded activity 4 — Verification intent: GREEN
Historical verification covered targeted or full test coverage, including tests/nrt/test_live_bm25_stats.py, tests/unit/search/test_bm25.py.
Milestone 7: Merge selected segments with dense doc-ID remapping¶
Recorded file scope:
- Added: src/minilucene/merge.py
- Changed: src/minilucene/writer.py
- Added: tests/nrt/test_segment_merge.py
Recorded activity 1 — Test intent: logical-equivalence tests
def test_merge_skips_deletes_and_preserves_search_results(index_with_segments):
before = index_with_segments.writer_refresh_reader()
expected = snapshot_results(before)
with index_with_segments.writer() as writer:
merged = writer.merge(writer.segment_generations)
after = writer.refresh()
assert merged.max_doc == before.num_live_docs
assert snapshot_results(after) == expected
assert after.segment_generations == (merged.generation,)
Compare term, phrase, prefix, boolean, stored fields, field lengths, scores, and Top-K order before and after merge. Assert new local doc IDs are dense and ordered by selected segment order then prior local doc ID.
Recorded activity 2 — Test intent: failure-before-swap test
Inject failure while publishing merge output. Assert writer segment generations and refresh results remain exactly unchanged and the output is not referenced.
Recorded activity 3 — Verification intent: RED
Historical verification covered targeted or full test coverage, including tests/nrt/test_segment_merge.py.
Recorded activity 4 — Design outcome: merge off to the side
Capture selected segment snapshots. Reject duplicates, unknown generations, or fewer than two segments. Copy only live documents into a new RAM builder in stable order, freeze/publish one image, then replace selected descriptors at the position of the earliest selected segment in one writer-state assignment. Unselected segments retain order.
Recorded activity 5 — Verification intent: GREEN
Historical verification covered targeted or full test coverage, including tests/nrt/test_segment_merge.py.
Milestone 8: Retain segment files by explicit owner references¶
Recorded file scope:
- Added: src/minilucene/storage/registry.py
- Changed: src/minilucene/index.py
- Changed: src/minilucene/reader.py
- Changed: src/minilucene/writer.py
- Added: tests/nrt/test_segment_ownership.py
Recorded activity 1 — Test intent: retained-reader cleanup tests
def test_obsolete_segment_waits_for_old_reader_close(index):
old = build_commit_and_open_reader(index)
old_paths = tuple(index.segment_paths())
merge_and_commit(index)
index.collect_garbage()
assert all(path.exists() for path in old_paths)
old.close()
index.collect_garbage()
assert all(not path.exists() for path in old_paths)
Also prove two readers retain independently, close is idempotent, committed manifest references always retain, writer current state retains uncommitted segments, and temporary paths are never registered.
Recorded activity 2 — Verification intent: RED
Historical verification covered targeted or full test coverage, including tests/nrt/test_segment_ownership.py.
Recorded activity 3 — Design outcome: SegmentRegistry
Track owners by opaque owner ID to generation sets. acquire and release are
idempotent only for the exact owner lifecycle; duplicate acquire is an error.
Garbage collection computes:
complete segment directories
- manifest references
- active owner references
= removable obsolete directories
It never deletes unknown-version or malformed directories automatically; it reports them as retained diagnostics.
Recorded activity 4 — Verification intent: GREEN
Historical verification covered targeted or full test coverage, including tests/nrt/test_segment_ownership.py.
Milestone 9: Close writer and index without leaked ownership¶
Recorded file scope:
- Changed: src/minilucene/writer.py
- Changed: src/minilucene/index.py
- Added: tests/nrt/test_close_lifecycle.py
Recorded activity 1 — Test intent: close-order and diagnostics tests
Assert writer close blocks new mutation, discards unpublished RAM/NRT state, releases its readers and segment owners, releases the lock last, and is idempotent. Assert index close does not invalidate external readers and reports their owner IDs/generations.
Recorded activity 2 — Verification intent: RED
Historical verification covered targeted or full test coverage, including tests/nrt/test_close_lifecycle.py.
Recorded activity 3 — Design outcome: explicit lifecycle states
The design used OPEN, CLOSING, CLOSED. Every public mutation admits only in OPEN.
Close captures and clears writer-owned unpublished readers, releases segment
owners, then lock. Exceptions are aggregated into CloseError after every
cleanup step is attempted.
Recorded activity 4 — Verification intent: GREEN
Historical verification covered targeted or full test coverage, including tests/nrt/test_close_lifecycle.py.
Milestone 10: Accept NRT mutation and merge¶
Recorded file scope:
- Added: tests/acceptance/test_phase3_nrt_mutation.py
- Added: docs/phase3-nrt-mutation.md
Recorded activity 1 — Design outcome: lifecycle acceptance
In one test: commit initial corpus, retain old reader, add and refresh, delete and update, prove old/new visibility, simulate restart to lose NRT-only state, repeat and commit, merge, compare results and scores, close all owners, collect garbage, and assert owner count zero.
Recorded activity 2 — Verification intent: phase acceptance
Historical verification covered targeted or full test coverage, static analysis, bytecode compilation, diff hygiene, including tests/acceptance/test_phase3_nrt_mutation.py.
Recorded activity 3 — Test intent: phase report
docs/phase3-nrt-mutation.md records flush/refresh/commit visibility, immutable
live-doc generations, update semantics, merge remapping, owner retention,
failure experiments, and verification evidence.
Recorded activity 4 — Recorded acceptance snapshot
Historical expected evidence: clean worktree.