-
Notifications
You must be signed in to change notification settings - Fork 852
Add SolrCloud update consistency documentation #4716
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
dsmiley
wants to merge
2
commits into
apache:main
Choose a base branch
from
dsmiley:dev-docs-update-internals
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,215 @@ | ||
| = Distributed Update Internals (SolrCloud) | ||
| :toc: macro | ||
| :toclevels: 3 | ||
|
|
||
| toc::[] | ||
|
|
||
| == Why this doc | ||
|
|
||
| The Solr Reference Guide states the user-facing consistency model of SolrCloud updates in | ||
| https://github.com/apache/solr/blob/main/solr/solr-ref-guide/modules/deployment-guide/pages/solrcloud-update-consistency.adoc[SolrCloud Update Consistency Model]. | ||
| This document explains *how* those guarantees are implemented: the distributed update path from the receiving node through the shard leader to the replicas, and the versioning scheme that everything else leans on. | ||
|
|
||
| Class names below are under `solr/core/src/java/org/apache/solr/` unless otherwise noted. | ||
|
|
||
| == Request lifecycle | ||
|
|
||
| An `/update` request is parsed by a loader into a stream of `AddUpdateCommand` / `DeleteUpdateCommand` / `CommitUpdateCommand` objects, each fed through the update request processor (URP) chain. | ||
| Everything discussed here happens inside two processors near the end of that chain: `update.processor.DistributedZkUpdateProcessor` (subclass of `DistributedUpdateProcessor`; "DUP" below) and `update.processor.RunUpdateProcessor`. | ||
| URPs configured *before* the DUP run only on the node that received the client request; URPs *after* it (and `RunUpdateProcessor`) run on the leader and again on every replica. | ||
|
|
||
| [source,mermaid] | ||
| ---- | ||
| sequenceDiagram | ||
| participant C as Client | ||
| participant N as Receiving node | ||
| participant L as Shard leader | ||
| participant R as NRT/TLOG replicas | ||
|
|
||
| C->>N: /update (batch of docs) | ||
| N->>L: forward each doc (DistribPhase=TOLEADER) | ||
| L->>L: per-doc lock; OCC check; assign _version_ | ||
| L->>L: Lucene write + tlog append | ||
| L--)R: stream doc (DistribPhase=FROMLEADER, async) | ||
| R->>R: drop if stale version, else apply + tlog | ||
| L->>L: finish(): await replica responses,<br/>demote failed replicas via shard terms,<br/>flush tlog | ||
| L-->>N: per-request response (+ achieved rf) | ||
| N-->>C: HTTP 200 (or error) | ||
| ---- | ||
|
|
||
| === Routing: setupRequest | ||
|
|
||
| `DistributedZkUpdateProcessor.setupRequest()` decides, per document, what role this node plays. | ||
| It computes the target slice via the collection's `DocRouter`, looks up the shard leader with `ZkStateReader.getLeaderRetry(...)`, and compares it to the local core. | ||
| The `DISTRIB_UPDATE_PARAM` (`update.distrib`) carries a `DistribPhase` marking where the request came from: | ||
|
|
||
| * `NONE` — an external client request; if this node is not the leader, set `forwardToLeader` and target a single `SolrCmdDistributor.ForwardNode` (the leader). | ||
| * `TOLEADER` — forwarded from a peer; this node is (should be) the leader; compute the replica fan-out list. | ||
| * `FROMLEADER` — forwarded from the leader; apply locally only, no further distribution. | ||
|
|
||
| The fan-out list comes from `getReplicaNodesForLeader(...)`: only `NRT` and `TLOG` replicas (PULL replicas never receive updates), excluding down/non-live replicas and any replica whose shard term is already behind the leader's (`ZkShardTerms.skipSendingUpdatesTo`) — those are collected in `skippedCoreNodeNames` and will have their terms pushed further down at request end. | ||
|
|
||
| Retry limits for internal hops are asymmetric: forwarding to a leader retries up to `solr.retries.on.forward` (default 25) times, while a leader sending to followers retries only `solr.retries.to.followers` (default 3) times — a follower that can't take the update is demoted instead of retried hard. | ||
|
|
||
| === Leader-side processing | ||
|
|
||
| For an add, `DistributedUpdateProcessor.versionAdd` runs the whole per-document decision inside `UpdateLocks.runWithLock(id, ...)` — see <<UpdateLocks>>. | ||
| `leaderLogic` is true when this core is the leader and the command is not a replay/peer-sync (`leaderLogicWithVersionIntegrityCheck`); a non-leader receiving an update *without* a version is rejected as an invalid state. | ||
|
|
||
| On the leader: | ||
|
|
||
| 1. If the command carries a client-supplied version constraint, perform the optimistic-concurrency check (see <<Leader-side optimistic concurrency>>). | ||
| 2. If the command is an atomic (partial) update, resolve it into a full replacement document (`getUpdatedDocument` → `AtomicUpdateDocumentMerger`), reading the current document through `RealTimeGetComponent.getInputDocument` — which sees uncommitted state via the tlog. | ||
| 3. Assign a new version: `cmd.setVersion(vinfo.getNewClock())` (see <<The _version_ clock>>). | ||
| 4. Apply locally: `doLocalAdd` → `RunUpdateProcessor` → `DirectUpdateHandler2.addDoc` (Lucene `updateDocument`) and `UpdateLog.add` (tlog append). | ||
| 5. Hand the (now fully-resolved, versioned) document to `doDistribAdd` for fan-out. | ||
|
|
||
| Because steps 1–4 happen under the per-document lock, concurrent updates to the same id are serialized at the leader, read-modify-write atomic updates are linearizable per document, and version assignment order matches apply order per document. | ||
|
|
||
| === Fan-out: SolrCmdDistributor and StreamingSolrClients | ||
|
|
||
| `update.SolrCmdDistributor.distribAdd/distribDelete` submits each command to the target nodes. | ||
| Ordinary adds/deletes are *fire-and-forget at the per-document level*: `update.StreamingSolrClients` maintains one `ConcurrentUpdateJettySolrClient` per destination URL (queue size 100, deliberately low thread count — the class comments that more threads "could cause updates to be reordered on a greater scale"), and the document is queued into its stream without waiting for a response. | ||
|
|
||
| Exceptions that are sent synchronously (blocking per command): in-place updates (a dependent in-place update must not overtake its predecessor in a stream), and forwards to sub-shard leaders or routing-rule targets during shard split / migrate. | ||
|
|
||
| The leader therefore does not know a document's replica outcome at the time it processes the next document. | ||
| All outcomes are collected at request end. | ||
|
|
||
| === Request finish: acknowledgment and error triage | ||
|
|
||
| When the loader has fed all commands, `DistributedUpdateProcessor.finish()` runs `doDistribFinish()` and then `RunUpdateProcessor.finish()`. | ||
| `DistributedZkUpdateProcessor.doDistribFinish()` is where the acknowledgment semantics live: | ||
|
|
||
| 1. If this leader changed its index and skipped any known-stale replicas, bump terms now: `ZkShardTerms.ensureTermsIsHigher(leader, skippedCoreNodeNames)`. | ||
| 2. `cmdDistrib.finish()` — *block* until every queued replica request has completed (this is the only wait for replica responses in the whole path). | ||
| 3. Walk `cmdDistrib.getErrors()` and triage: | ||
| * Error on a `ForwardNode` (this node → leader): added to `errorsForClient`; the client sees the failure and may retry. | ||
| * Error on a `StdNode` (leader → follower): *not* a client error — the code comments "for now we don't error - we assume if it was added locally, we succeeded". | ||
| Unless the error is a commit (`commit_end_point` requests never trigger recovery) the follower's coreNodeName is collected for demotion — after double-checking against ZK that we are still the leader and the errored node is still one of our replicas. | ||
| * Special case: if the remote error's metadata says `cause=LeaderChanged` (SOLR-6511 — the "follower" now believes it is the leader), the error *is* propagated to the client so it can retry against the new leader. | ||
| 4. `ensureTermsIsHigher(leader, replicasShouldBeInLowerTerms)` — the demotion. | ||
| The demoted replica's term watcher notices it is behind and puts the core into recovery. | ||
| This term mechanism (SOLR-11702) is the replacement for the old znode-based "leader-initiated recovery" (LIR); no LIR znodes exist anymore. | ||
| 5. Compute the achieved replication factor: each shard leader counts itself plus each follower that acked (`LeaderRequestReplicationTracker`), the originating node takes the minimum across shards (`RollupRequestReplicationTracker`), and the result is reported as `rf` in the response header. | ||
| It is purely informational; a top-of-class TODO ("optionally fail if n replicas are not reached...") records the unimplemented alternative. | ||
|
|
||
| Client-visible errors are aggregated into `DistributedUpdatesAsyncException` (status: the common code if all agree, else 400 if all 4xx, else 500). | ||
|
|
||
| Finally `RunUpdateProcessor.finish()` calls `UpdateLog.finish(null)` — the per-request tlog flush described next. | ||
|
|
||
| === Durability: UpdateLog and TransactionLog | ||
|
|
||
| `update.UpdateLog` owns a current `update.TransactionLog` (tlog) plus recent old ones, and an in-memory map from doc id to a `LogPtr` into the tlog — the map that makes uncommitted documents visible to realtime get and to atomic-update resolution. | ||
|
|
||
| Writes: `UpdateLog.add/delete/deleteByQuery` append a record to the current tlog through a buffered `FastOutputStream` over a file channel. | ||
| *Nothing is flushed per document.* | ||
| Durability happens per *request*, in `TransactionLog.finish(syncLevel)`: | ||
|
|
||
| * `NONE` — do nothing. | ||
| * `FLUSH` (the default) — flush the JVM buffer to the OS; survives a JVM crash / `kill -9`, but not an OS crash or power loss. | ||
| * `FSYNC` — additionally `channel.force(true)`. | ||
| The fsync is deliberately outside the buffer lock; the code notes a partial last record after power failure is expected and tolerated by the reader. | ||
|
|
||
| `syncLevel` is configured on `<updateLog>` in `solrconfig.xml`. | ||
| So the ref-guide statement "documents are written to the tlog before the indexing call returns" is true, but with default `FLUSH` the response does not imply the bytes reached the disk platter. | ||
|
|
||
| Commits rotate the tlog: `UpdateLog.preCommit` starts a new tlog (so the old one is definitely fully covered by the index commit), and `postCommit` writes a commit marker into the old one. | ||
| On startup, `UpdateLog.recoverFromLog()` replays any tlog tail not covered by a commit — this is what makes acked-but-uncommitted updates survive a restart. | ||
| Retention is bounded by `numRecordsToKeep` (default 100) and `maxNumLogsToKeep` (default 10), which also bound how far a replica can fall behind before PeerSync is impossible and full replication is required. | ||
|
|
||
| The UpdateLog also has a state machine (`ACTIVE`, `BUFFERING`, `APPLYING_BUFFERED`, `REPLAYING`) used during recovery and shard split: while a core is recovering, incoming `FROMLEADER` updates are written to a separate buffer tlog *without* being applied, and replayed at the end (`applyBufferedUpdates`). | ||
| State transitions quiesce all in-flight updates through `UpdateLocks.blockUpdates()` (the write side of a fair read/write lock; every normal update holds the read side). | ||
| See `dev-docs/shard-split/shard-split.adoc` for the shard-split use of buffering. | ||
|
|
||
| === Replica-side processing | ||
|
|
||
| A replica receiving `DistribPhase.FROMLEADER` runs the same `versionAdd`/`versionDelete` but with `leaderLogic == false`: | ||
|
|
||
| * An update without a `\_version_` is rejected (`missing _version_ on update from leader`) — replicas never mint versions. | ||
| * If the local UpdateLog is not `ACTIVE` (the core is recovering), the update is written to the buffer tlog and dropped (no index write). | ||
| * Otherwise the *drop rule* runs — the single check that makes asynchronous, possibly-reordered delivery safe: | ||
| + | ||
| [source,java] | ||
| ---- | ||
| Long lastVersion = vinfo.lookupVersion(cmd.getIndexedId()); | ||
| if (lastVersion != null && Math.abs(lastVersion) >= versionOnUpdate) { | ||
| // This update is a repeat, or was reordered. We need to drop this update. | ||
| return true; | ||
| } | ||
| ---- | ||
| + | ||
| Application on a replica is therefore idempotent (repeats are dropped) and order-insensitive *per document* (an older version arriving late is dropped). | ||
| Nothing orders updates across different documents. | ||
| * On a TLOG replica (not currently leader), the command additionally gets `UpdateCommand.IGNORE_INDEXWRITER`: it is recorded in the tlog but not indexed — the index arrives later by segment replication, and the tlog exists so the replica can replay it if elected leader. | ||
|
|
||
| Deletes store *negative* versions (hence the `Math.abs`), letting a version lookup distinguish "deleted at version v" from "exists at version v" while still ordering both. | ||
|
|
||
| Two reorder edge cases get dedicated machinery: | ||
|
|
||
| * *Delete-by-query*: on the leader, `versionDeleteByQuery` runs under `UpdateLocks.blockUpdates()` — DBQ quiesces *all* updates on the core, because it can affect any document. | ||
| Replicas keep a list of recent DBQs and re-execute them over an add that arrives out of order relative to the DBQ. | ||
| A DBQ is also fanned out from the originating node to *all* shard leaders, and is not atomic across shards. | ||
| * *In-place updates* carry `distrib.inplace.prevversion`; a replica that has not yet seen that previous version waits for it (`waitForDependentUpdates`, using the per-doc lock's `Condition`), and if it never arrives fetches the full document from the leader (`fetchFullUpdateFromLeader`). | ||
|
|
||
| == Versioning and optimistic concurrency | ||
|
|
||
| === The _version_ clock | ||
|
|
||
| `update.VersionInfo.getNewClock()` implements a time-based Lamport clock, synchronized per core: | ||
|
|
||
| [source,java] | ||
| ---- | ||
| long time = System.currentTimeMillis(); | ||
| long result = time << 20; | ||
| if (result <= vclock) { | ||
| result = vclock + 1; | ||
| } | ||
| vclock = result; | ||
| ---- | ||
|
|
||
| Properties that matter: | ||
|
|
||
| * Strictly increasing per core, so per-document last-writer-wins is well defined under a single leader. | ||
| * Wall-clock based so that a restarted or newly elected leader (with an empty in-memory clock) does not go back in time relative to versions already in the index — correctness across leader changes leans on cluster clocks being roughly synchronized. | ||
| The low 20 bits are a same-millisecond counter (~1M versions/ms before the clock runs ahead of real time). | ||
| * Not contiguous — a commented-out pure-counter alternative in `VersionInfo` notes contiguous versions would make missing-update detection easier; Solr instead detects gaps via PeerSync's version-list exchange. | ||
|
|
||
| The `\_version_` field must exist in the schema, single-valued, indexed-or-docValues and stored-or-docValues (`VersionInfo.getAndCheckVersionField`). | ||
| It must be assigned by Solr internally: user-supplied values would break the replica drop rule. | ||
| (Use `DocBasedVersionConstraintsProcessorFactory` for application-level version fields.) | ||
|
|
||
| === Where the version constraint comes from | ||
|
|
||
| `versionOnUpdate` is taken, in priority order, from the command itself, the document's `\_version_` field, or the `\_version_` request parameter. | ||
| On internal `FROMLEADER` hops it is the leader-assigned version; on client requests it is the client's optimistic-concurrency constraint (0 when absent). | ||
| One subtlety: a leader receiving a document forwarded from *another collection* (`distrib.from.collection`, the MIGRATE path) discards the incoming version and stamps its own. | ||
|
|
||
| === Leader-side optimistic concurrency | ||
|
|
||
| When a client supplies a nonzero version, the leader checks it against `vinfo.lookupVersion(id)` (tlog first, then index — uncommitted state counts) before assigning the new version: | ||
|
|
||
| * `> 1` — must equal the current version exactly. | ||
| * `1` — the document must exist (any positive current version). | ||
| * `< 0` — the document must not exist. | ||
| * `0` — no check. | ||
|
|
||
| A failed check raises `ErrorCode.CONFLICT` (HTTP 409) — unless `failOnVersionConflicts=false`, which silently drops the update instead (useful for batch loads where any conflicting doc should just be skipped). | ||
| The check runs only on the leader, under the per-document lock, so it is atomic with the version assignment: two clients doing conditional updates on the same document cannot both win. | ||
|
|
||
| === UpdateLocks | ||
|
|
||
| `update.UpdateLocks` (SOLR-14679) replaced the historical fixed-size `VersionBucket` striping. | ||
| It keeps a hash-keyed map of pooled, fair `ReentrantLock`+`Condition` pairs with refcounting, so a lock exists only while some thread is operating on that document id, plus the global `blockUpdatesLock` read/write lock described earlier. | ||
| Lock acquisition times out after `docLockTimeoutMs`, surfacing pathological contention as an error rather than a hang. | ||
|
|
||
| Everything that must be atomic per document happens inside `runWithLock`: the OCC check, atomic-update read-modify-write, version assignment, tlog append and index write. | ||
| This is also what makes realtime get reliable — a concurrent RTG cannot observe a state between the tlog map update and the version assignment. | ||
|
|
||
| == References | ||
|
|
||
| * Ref guide: https://github.com/apache/solr/blob/main/solr/solr-ref-guide/modules/deployment-guide/pages/solrcloud-update-consistency.adoc[SolrCloud Update Consistency Model] (the user-facing contract), plus the pages on shards and indexing, recoveries and write tolerance, commits and transaction logs, partial document updates, and realtime get. | ||
| * `dev-docs/shard-split/shard-split.adoc` — tlog buffering during shard split. | ||
| * JIRA: https://issues.apache.org/jira/browse/SOLR-11702[SOLR-11702] (shard terms replace LIR), https://issues.apache.org/jira/browse/SOLR-14679[SOLR-14679] (`UpdateLocks` replaces version buckets), https://issues.apache.org/jira/browse/SOLR-6511[SOLR-6511] (`LeaderChanged` propagation), https://issues.apache.org/jira/browse/SOLR-7141[SOLR-7141] (recovery vs. in-flight updates). | ||
|
|
||
| Not covered here (candidates for future documents): replica-type internals (NRT/TLOG/PULL), recovery and leader election (`RecoveryStrategy`, `PeerSync`, `ZkShardTerms` invariants, `leaderVoteWait`), and commit/visibility internals. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This new section looks very useful for agents, like a "map" of where to read more on dev topics. I like it.