Level 6: Indexing Path and Storage Engine
You have learned how OpenSearch defends itself with tests (Level 5). Now you learn what it is defending: the write path — the journey of a document from an HTTP request to durable, searchable bytes on disk. This is the heart of OpenSearch as a storage engine, and it is where the project meets Apache Lucene.
A document does not simply "get saved." It is routed to a shard, applied to a primary, versioned and stamped with a sequence number, written to Lucene's in-memory buffer and appended to a write-ahead log (the translog), replicated to replicas, then — separately and asynchronously — made visible by a refresh and made durable as a Lucene commit by a flush, while background merges keep the segment count sane. Every one of those words is a class you can open, a setting you can tune, and a source of real bugs you can fix.
By the end of this level you can stand at any point on the write path and say which class is running, what invariant it maintains, and what would break if it were wrong.
Learning Objectives
By the end of Level 6 you must be able to:
- Trace a
POST /<index>/_docand a_bulkrequest from the REST layer all the way to Lucene'sIndexWriterand the translog, naming every hop and the class that owns it. - Explain the division of labor between
IndicesService,IndexService,IndexShard, theEngineabstraction and itsInternalEngineimplementation, theTranslog, and the mapping stack (MapperService/DocumentMapper). - Explain how versioning and sequence numbers (
_seq_no, primary term) are assigned on the primary and replayed on replicas, and how local/global checkpoints track durability. - Distinguish refresh (visibility — opens a new searcher), flush (durability — Lucene commit + translog trim), and merge (segment housekeeping), and reason about the settings that control each.
- Reason about durability under failure: what the translog guarantees, what
index.translog.durabilitycontrols, and what gets replayed on recovery. - Map a parsed JSON document through the mapping/analysis pipeline into Lucene fields.
The Write Path, End to End
A single-document index and a bulk request share almost the whole path; bulk just batches at the coordinating and shard level. The canonical flow:
flowchart TD
A["HTTP POST /idx/_doc or _bulk"] --> B["RestController → RestIndexAction / RestBulkAction"]
B --> C["NodeClient.execute(...) → TransportBulkAction"]
C -->|resolve routing, group by shard| D["TransportShardBulkAction<br/>(extends TransportReplicationAction)"]
D -->|on the PRIMARY node| E["IndexShard.applyIndexOperationOnPrimary(...)"]
E -->|parse + map| F["MapperService / DocumentMapper → ParsedDocument (Lucene fields)"]
E -->|assign _seq_no, version, primary term| G["InternalEngine.index(Engine.Index)"]
G --> H["Lucene IndexWriter.addDocument / updateDocument"]
G --> I["Translog.add(operation) (write-ahead log)"]
D -->|replicate the same op| J["replica shards: applyIndexOperationOnReplica → InternalEngine.index"]
H -.->|later, async| K["refresh: open new DirectoryReader → docs become searchable"]
H -.->|later, async| L["flush: IndexWriter.commit() + trim translog (durable Lucene commit)"]
H -.->|background| M["merge: MergePolicy/MergeScheduler combine segments"]
Read it as three phases:
- Coordination (any node): REST parses the request;
TransportBulkActionresolves index/routing, may create the index, applies pipelines, and groups operations by target shard. - Primary write (the node holding the primary):
TransportShardBulkActionruns each op throughIndexShard.applyIndexOperationOnPrimary, which parses + maps the source, then callsInternalEngine.index(...), which writes to Lucene'sIndexWriterand appends to theTranslog. Versioning and the sequence number are assigned here, on the primary. - Replication (replica nodes):
TransportReplicationActionships the same operation (with the already-assigned_seq_no/version/primary term) to each replica, which applies it viaapplyIndexOperationOnReplica. (With segment replication, replicas instead copy finished segments from the primary — see replication.)
Then, decoupled from the request, three background processes govern visibility, durability, and storage shape:
| Process | Trigger | What it does | Deep dive |
|---|---|---|---|
| Refresh | index.refresh_interval (default 1s) or _refresh | Opens a new Lucene searcher so buffered docs become searchable. Cheap-ish; does not fsync. | refresh/flush/merge |
| Flush | translog size/age, _flush, or shard close | IndexWriter.commit() (fsync segments) + start a fresh translog generation. Makes the commit point durable. | engine internals |
| Merge | segment count/size policy | MergePolicy picks segments; MergeScheduler merges them, reclaiming deletes. | refresh/flush/merge |
Note: Refresh ≠ flush. Refresh makes docs visible; flush makes them durable as a Lucene commit. Between refreshes, a doc is buffered (visible after refresh) but already durable via the translog (fsync'd per request by default). This separation — visibility vs. durability — is the single most important mental model in this level.
Mappings and Analysis: From JSON to Lucene Fields
Before a document reaches the engine, its JSON source is turned into Lucene fields. The
MapperService holds the index's mapping; the DocumentMapper parses the source into a
ParsedDocument (a set of Lucene IndexableFields) according to each field's Mapper. Text fields
run through an analyzer (a Tokenizer + TokenFilter chain) into indexed terms; keyword/numeric
fields are indexed verbatim and as DocValues. Dynamic mapping infers types for unseen fields and triggers
a cluster-state mapping update.
JSON source ──DocumentMapper──► fields:
"title": "Fast Search" ──(text, standard analyzer)──► terms: [fast, search] + (optional) DocValues
"views": 42 ──(long)──────────────────────► points + DocValues
"tag": "prod" ──(keyword)────────────────────► term: [prod] + DocValues
Analysis is the subject of Lab 6.3 (you build a custom analyzer plugin) and the mapping & analysis deep dive.
Sequence Numbers and Checkpoints
Every write on a primary is stamped with two coordinates that make replication and recovery correct:
| Concept | What it is | Owner |
|---|---|---|
_seq_no | A monotonically increasing per-shard sequence number assigned on the primary | LocalCheckpointTracker (via InternalEngine) |
| primary term | A counter incremented each time a new primary is elected; disambiguates ops from different primaries | ReplicationTracker / cluster state |
| local checkpoint | Highest _seq_no below which this copy has every op | LocalCheckpointTracker |
| global checkpoint | Highest _seq_no below which all in-sync copies have every op — the durable, replicated watermark | ReplicationTracker |
_version | Per-document version for optimistic concurrency (if_seq_no/if_primary_term) | InternalEngine versioning |
The global checkpoint is what lets recovery be cheap: a recovering replica only needs operations after
the global checkpoint (replayed from the translog), not the whole shard. This is the backbone of
peer recovery. When you read _seq_no assignment in
Lab 6.1, you are reading the code that makes all of this work.
# See the seqno/checkpoint plumbing for yourself.
grep -rn "LocalCheckpointTracker\|globalCheckpoint\|primaryTerm\|generateSeqNo\|SequenceNumbers" \
server/src/main/java/org/opensearch/index/seqno \
server/src/main/java/org/opensearch/index/engine/InternalEngine.java | head -30
Key Classes
| Class | Location | Role |
|---|---|---|
IndicesService | server/.../indices/IndicesService.java | Owns all IndexServices on the node; creates/closes indices; the node-level entry point to shards |
IndexService | server/.../index/IndexService.java | One per index per node; owns that index's IndexShards, MapperService, IndexSettings, analysis registry |
IndexShard | server/.../index/shard/IndexShard.java | One shard copy; the operational unit. applyIndexOperationOnPrimary/OnReplica, refresh, flush, recovery, the Engine |
Engine (abstract) | server/.../index/engine/Engine.java | The storage-engine contract: index, delete, get, refresh, flush, acquireSearcher |
InternalEngine | server/.../index/engine/InternalEngine.java | The default engine: wraps Lucene IndexWriter/SearcherManager + the Translog; assigns seqno/version |
Translog | server/.../index/translog/Translog.java | The write-ahead log: add, sync, generations, replay on recovery |
MapperService | server/.../index/mapper/MapperService.java | Holds the index mapping; resolves field Mappers and analyzers |
DocumentMapper | server/.../index/mapper/DocumentMapper.java | Parses a JSON source into a ParsedDocument of Lucene fields |
# Confirm the locations on your branch (names are stable; paths may shift slightly).
for c in IndicesService IndexService index/shard/IndexShard index/engine/Engine \
index/engine/InternalEngine index/translog/Translog index/mapper/MapperService \
index/mapper/DocumentMapper; do
find server/src/main/java -path "*org/opensearch/${c}.java" -o -name "$(basename $c).java" -path "*org/opensearch/*" 2>/dev/null
done | sort -u
The Labs
| Lab | Title | Type |
|---|---|---|
| 6.1 | Trace a Document Index Request to Lucene | Timed code-reading trace |
| 6.2 | IndexShard, InternalEngine, and the Translog | Deep hands-on (curl + reading) |
| 6.3 | Build It — A Custom Analyzer | Build-it plugin |
These build on each other: 6.1 maps the territory, 6.2 makes you operate the engine from the outside and reason about translog/refresh/flush, and 6.3 has you extend the analysis pipeline with a real plugin.
Deliverables
You must demonstrate all of the following before advancing to Level 7:
-
A written reading-log artifact tracing
_docand_bulkfrom REST toIndexWriter.addDocumentandTranslog.add, naming each class and where_seq_no/version are assigned (Lab 6.1). -
Empirical observations, via
curl, of refresh changing visibility, flush trimming the translog, andindex.translog.durabilitychanging fsync behavior — mapped back to the source (Lab 6.2). -
A working custom analyzer plugin: a
Plugin implements AnalysisPlugin, a token filter factory, a LuceneTokenFilter, built withopensearch.opensearchplugin, installed, and verified with_analyzeand anOpenSearchTokenStreamTestCaseunit test (Lab 6.3). - From memory: the difference between refresh, flush, and merge; what the global checkpoint enables; and why the translog exists when Lucene already persists segments on commit.
Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| Conflating refresh and flush | You expect _refresh to make data durable (it doesn't) | Refresh = visibility; flush = Lucene commit (durability) |
| Thinking the translog is for search | You look for query data in it | Translog is a write-ahead log for recovery, not a read path |
Assuming _seq_no is assigned on replicas | You misread replication | Primary assigns it; replicas replay the same value |
Setting refresh_interval to a tiny value in prod | Refresh thrash, segment explosion, merge pressure | Default 1s; raise it for write-heavy ingest |
Reading InternalEngine before Engine | You miss the abstraction boundary | Read the Engine contract first, then the impl |
| Forgetting analysis happens before the engine | You look for tokenization in InternalEngine | Tokenization is in the mapper/analysis layer |
Treating _bulk as a totally separate path | Duplicated mental model | It's the single-doc path, batched per shard |
How to Verify Success
# 1) You can locate every hop on the write path from a single grep chain.
grep -rn "applyIndexOperationOnPrimary" server/src/main/java/org/opensearch/index/shard/IndexShard.java
grep -rn "public IndexResult index" server/src/main/java/org/opensearch/index/engine/InternalEngine.java
grep -rn "addDocument\|updateDocument\|translog.add\|getTranslog().add" \
server/src/main/java/org/opensearch/index/engine/InternalEngine.java
# 2) You can drive visibility vs durability from the outside (Lab 6.2).
curl -s -XPUT 'localhost:9200/demo?pretty'
curl -s -XPOST 'localhost:9200/demo/_doc?pretty' -H 'Content-Type: application/json' -d '{"t":"hello"}'
curl -s 'localhost:9200/demo/_search?pretty' # likely 0 hits before refresh
curl -s -XPOST 'localhost:9200/demo/_refresh?pretty'
curl -s 'localhost:9200/demo/_search?pretty' # now 1 hit
curl -s 'localhost:9200/_cat/segments/demo?v'
When you can trace a document to Lucene, explain seqno/checkpoints, drive refresh/flush/merge from
curl, and have built a working analysis plugin, you are ready for the search side in
Level 7. For the underlying theory, lean on the
engine internals,
translog,
refresh/flush/merge,
mapping & analysis, and
index-shard lifecycle deep dives.