Resolving OpenSearch Index Drift in Production
This guide walks an on-call engineer through a deterministic reconciliation loop — detect, quarantine, extract, rebuild, re-ingest, validate — that resolves OpenSearch index drift in a live JanusGraph cluster and closes the specific failure where a graph mutation commits to storage but its matching document never becomes searchable. It is the repair runbook under OpenSearch Sync Patterns; if you have not yet wired the backend or tuned refresh semantics, do that there first, because the parameters below assume a working async dispatch path. In Apache JanusGraph, mixed indexes are asynchronous by design: the storage backend (Cassandra, ScyllaDB, or HBase) commits transactional graph mutations first, then index mutations are dispatched to OpenSearch on a separate thread pool. Network partitions, shard-allocation failures, and misaligned refresh intervals routinely open a document-level gap, and once drift crosses an acceptable threshold, query accuracy degrades and read-after-write assumptions fail. Treat drift as a capacity-and-configuration problem, not a transient network event — heuristic retries will not converge, a deterministic loop will.
Prerequisites
Confirm every item before you touch a production index. Skipping the health and permission checks is the most common cause of a “repair” that widens the delta instead of closing it.
- JanusGraph 0.6.x or 1.0.x running against a CQL storage backend. If storage itself is unstable, stabilize it via Cassandra backend setup before attempting index repair — a drifting storage layer makes any authoritative extract meaningless.
- OpenSearch 1.x or 2.x reachable from every JanusGraph node, addressed through JanusGraph’s Elasticsearch-compatible backend (the
index.search.backendvalue stayselasticsearch). Cluster health must be at leastyellow; ared-status cluster is a fallback scenario, not a reconciliation one. jq,curl, andgremlinpythonon the operator host, withgremlinpythonmatching your server’s TinkerPop line (3.5.x for JG 0.6, 3.6.x for JG 1.0).- Write access to
janusgraph.propertiesand the ability to route writes to read-only or a standby during a maintenance window. - A known-good driver pool. Size it per the connection pooling model so thread starvation during the extract phase is not misdiagnosed as fresh drift.
- A recent OpenSearch snapshot you can restore from. Verify it exists before you delete anything.
Step 1 — Detect and quantify divergence
Drift detection must bypass JanusGraph’s query layer and compare raw storage state against OpenSearch document state directly. Relying on g.V().hasLabel(...).count() masks index-level failures, because JanusGraph silently falls back to full storage scans when a mixed index is degraded — the count looks correct while the index is empty.
Run a direct count comparison: query OpenSearch for its document count, then force a Gremlin traversal that must use the mixed index so it fails fast if the index is degraded.
# 1. Query OpenSearch directly for the indexed document count
curl -s --fail -X GET "https://opensearch-cluster:9200/janusgraph_vertex/_count" \
-H "Content-Type: application/json" \
-d '{"query": {"match_all": {}}}' | jq -r '.count'
# 2. Force JanusGraph to use the mixed index (fails fast if the index is degraded)
curl -s --fail -X POST "https://janusgraph-server:8182/gremlin" \
-H "Content-Type: application/json" \
-d '{"gremlin": "g.V().hasLabel(\"entity\").has(\"name\", textContainsRegex(\".*\")).count().next()", "bindings": {}}'
Interpret the delta immediately:
- OpenSearch count < storage count: index mutations are dropping or queued indefinitely. Dispatch thread exhaustion or bulk-request rejections are the primary suspects.
- OpenSearch count > storage count: stale deletions or orphaned documents from failed transaction rollbacks remain in the index.
- Counts match but queries fail: index mapping corruption or analyzer misconfiguration, not a count problem.
Cross-reference the delta against JanusGraph logs for the failure signature that tells you why it drifted:
grep -E "IndexMutation|BulkRequest|RejectedExecution|circuit_breaking_exception" \
/var/log/janusgraph/server.log | tail -40
Correlate the timestamps with OpenSearch thread-pool stats. RejectedExecutionException on the JanusGraph side almost always pairs with a non-zero rejected count on the OpenSearch write pool — that pairing is the root cause, and it is the one this loop fixes. Automate this count-comparison as a scheduled worker and alert when divergence exceeds ~0.5% of indexed cardinality.
Step 2 — Quarantine write traffic
Stop new mutations from compounding the delta before you extract state, or the extract races the live write path and can never reach parity. Route writes to a standby graph or put the affected cluster into read-only mode:
# Force JanusGraph to reject index-backed writes cleanly while you reconcile
curl -s -X POST "https://janusgraph-server:8182/gremlin" \
-H "Content-Type: application/json" \
-d '{"gremlin": "mgmt = graph.openManagement(); mgmt.setConsistency(mgmt.getGraphIndex(\"searchByEntity\"), ConsistencyModifier.LOCK); mgmt.commit()"}'
If you cannot quarantine at the graph, freeze the drift window at the index by disabling refresh so no half-written state churns during the rebuild:
curl -s -X PUT "https://opensearch-cluster:9200/janusgraph_vertex/_settings" \
-H "Content-Type: application/json" \
-d '{"index.refresh_interval": "-1"}'
Step 3 — Extract authoritative state from storage
The storage backend is the source of truth; the index is a derived projection. Pull the complete set of indexed properties straight from storage with a full-scan traversal and stream it to newline-delimited JSON for bulk ingestion. Deriving the OpenSearch _id deterministically from the vertex id is what makes the whole loop idempotent — a replayed extract overwrites rather than duplicates.
from gremlin_python.process.anonymous_traversal import traversal
from gremlin_python.driver.driver_remote_connection import DriverRemoteConnection
import json
conn = DriverRemoteConnection('ws://janusgraph-server:8182/gremlin', 'g')
g = traversal().withRemote(conn)
# Full scan of indexed properties straight from the storage backend.
# valueMap(True) returns T.id / T.label enum keys; stringify them for JSON.
with open('authoritative_state.ndjson', 'w') as out:
for doc in g.V().hasLabel('entity').valueMap(True).toList():
vid = doc.get('id') or doc.get(list(doc.keys())[0])
source = {str(k): (v[0] if isinstance(v, list) else v) for k, v in doc.items()}
# Bulk NDJSON: deterministic _id makes re-ingestion a create-or-replace
out.write(json.dumps({"index": {"_id": str(vid)}}) + "\n")
out.write(json.dumps(source, default=str) + "\n")
conn.close()
For a very large graph, page this scan by a monotonic cursor property instead of a single toList() so a crashed extract resumes rather than restarting.
Step 4 — Rebuild the OpenSearch index
Delete the drifted index and recreate it with the correct mapping. Pin dynamic: strict so a stray field cannot silently reshape the mapping mid-repair; the shard count you choose here should match the predicate layout described in mixed-index routing.
# 1. Delete the drifted index
curl -s -X DELETE "https://opensearch-cluster:9200/janusgraph_vertex"
# 2. Recreate with an explicit mapping (adjust fields to your schema)
curl -s -X PUT "https://opensearch-cluster:9200/janusgraph_vertex" \
-H "Content-Type: application/json" \
-d '{
"settings": {"number_of_shards": 5, "number_of_replicas": 1, "refresh_interval": "-1"},
"mappings": {"dynamic": "strict", "properties": {"name": {"type": "text"}, "id": {"type": "keyword"}}}
}'
Step 5 — Bulk re-ingest the authoritative state
Feed the NDJSON from Step 3 into the OpenSearch _bulk endpoint. Because every action line carries a deterministic _id, this write is a create-or-replace — rerunning it after a partial failure converges instead of duplicating.
# Chunk large files to stay under the bulk payload ceiling (~5MB per request)
split -l 20000 authoritative_state.ndjson bulk_chunk_
for chunk in bulk_chunk_*; do
curl -s -X POST "https://opensearch-cluster:9200/_bulk" \
-H "Content-Type: application/x-ndjson" \
--data-binary "@${chunk}" | jq -e '.errors == false' > /dev/null \
|| echo "ERRORS in ${chunk} — inspect before continuing"
done
# Restore refresh so freshly ingested documents become searchable
curl -s -X PUT "https://opensearch-cluster:9200/janusgraph_vertex/_settings" \
-H "Content-Type: application/json" \
-d '{"index.refresh_interval": "30s"}'
Step 6 — Verify parity, then resume writes
Re-run the Step 1 count comparison. The loop is complete only when the delta is 0 and the write pool is quiet.
# Storage count
STORAGE=$(curl -s -X POST "https://janusgraph-server:8182/gremlin" \
-H "Content-Type: application/json" \
-d '{"gremlin": "g.V().hasLabel(\"entity\").count().next()"}' | jq -r '.result.data["@value"][0]["@value"]')
# Index count after refresh has run
INDEX=$(curl -s "https://opensearch-cluster:9200/janusgraph_vertex/_count" | jq -r '.count')
echo "storage=${STORAGE} index=${INDEX} delta=$((STORAGE - INDEX))"
# Confirm the write pool is not still shedding load
curl -s "https://opensearch-cluster:9200/_cat/thread_pool/write?v&h=node_name,queue,rejected"
A delta of 0 with rejected=0 means the projection is rebuilt and stable. Lift the quarantine from Step 2, re-enable writes, and watch _cat/thread_pool/write for 15 minutes to confirm dispatch stays clean under live traffic. If rejected climbs again immediately, the drift will recur — fix capacity in the Hardening section below before you consider the incident closed.
Fallback and rollback procedures
Each step has a defined recovery path. Do not skip verification between recovery actions.
- Step 1 (traversal errors instead of returning a count). The mixed index is not merely drifted, it is unqueryable — treat this as corruption and go straight to snapshot restore rather than an incremental rebuild. Verify snapshot integrity first:
curl -s "https://opensearch-cluster:9200/_snapshot/repo/snap-latest/_status" | jq -r '.snapshots[].state'must reportSUCCESS. - Step 2 (cannot quarantine at the graph). Fall back to disabling refresh at the index (shown above) and, if the delta is still growing, put JanusGraph into index-bypass mode with
query.force-index=falseso reads fall back to storage scans. Bound scan cost withquery.page-sizeto avoid OOM, and accept the latency hit to preserve availability. - Step 3 (extract stalls or the JVM is under memory pressure). Page the scan by a monotonic cursor and resume from the last committed value instead of restarting the full
toList(). Confirm the driver pool is not starved before blaming storage — thread starvation looks identical to a hung extract. - Step 4 (delete succeeds but recreate fails). Do not resume writes against a missing index — JanusGraph will fall back to storage scans and mask the problem. Restore the mapping from your snapshot:
curl -s -X POST "https://opensearch-cluster:9200/_snapshot/repo/snap-latest/_restore" -H "Content-Type: application/json" -d '{"indices": "janusgraph_vertex"}'. - Step 5 (bulk returns
"errors": true). Inspect the per-item errors in the response. Amapper_parsing_exceptionmeans Step 4’s mapping does not match the extracted fields — fix the mapping and re-ingest (idempotent). A429/es_rejected_execution_exceptionmeans the write pool is saturated; raisethread_pool.write.queue_size, lower the chunk size, and replay only the failed chunks. - Step 6 (delta never reaches 0). If parity refuses to converge after a clean re-ingest, a recent mapping change is the likely culprit. Roll back to the previous index alias, point
index.search.hostnameat the stable cluster, and restart the JanusGraph server pool. Then re-run the loop from Step 3 against the reverted mapping.
Hardening against recurrence
Reconciliation that is not followed by capacity alignment just schedules the next incident. Align JanusGraph’s dispatch parameters with OpenSearch’s ingest limits:
- Thread pool and queue sizing. Keep
rejectedat0on_cat/thread_pool/write; raisethread_pool.write.queue_sizein OpenSearch when rejections appear under normal load. - Bulk request limits. Set
index.search.elasticsearch.bulk-sizeso payloads stay below ~5MB; larger payloads trip circuit breakers and inflate retry latency. - Refresh interval. Run
index.refresh_intervalat30s–60sfor high-throughput pipelines and applywait_foronly to the narrow set of writes that need immediate visibility. See the official OpenSearch index settings for cluster-wide tuning. - Persistent retry queue. Back the pipeline with a disk-durable queue (Kafka or Redis) so index mutations survive a JVM restart and replay after OpenSearch recovers, closing the silent-drop gap that no in-memory retry can cover.
Record the final drift metrics, the exact commands you ran, and the last-good snapshot id in the incident runbook so the next on-call engineer inherits a converged baseline.
Related
- Up a level: OpenSearch Sync Patterns — the parent reference for the JanusGraph-to-OpenSearch boundary this runbook repairs.
- Syncing JanusGraph with Elasticsearch Step by Step — the initial-sync procedure whose parity check surfaces the drift this page resolves.
- Configuring Mixed Index Fallback Chains — shard alignment so a rebuilt index lands documents on balanced shards.
- Eventual vs Strong Consistency Tradeoffs in JanusGraph — choosing the acknowledgment and refresh boundary that determines how quickly drift can appear.
- JanusGraph Connection Pool Tuning Guide — sizing the driver pool so extract-phase starvation is not mistaken for fresh index lag.