Migrating From Elasticsearch to OpenSearch
Swapping the search engine underneath a live JanusGraph deployment from Elasticsearch to OpenSearch is a backend substitution the graph must never notice, and this guide is the ordered cutover that keeps index.search.backend=elasticsearch set the entire time while the hostname behind it quietly changes from one cluster to the other. It sits under the OpenSearch Sync Patterns reference and turns that page’s version-negotiation and drift guidance into a runbook: stand up OpenSearch reporting a compatible version, mirror analyzers and mappings so predicates tokenize identically, seed the new cluster by reindex-from-remote, flip index.search.hostname, rebuild through the Management API, prove parity, and only then retire Elasticsearch. The failure this prevents is the mid-cutover surprise where a routed full-text predicate that matched thousands of documents on Elasticsearch matches nothing on OpenSearch because a default analyzer diverged — discovered in production instead of in Step 1.
index.search.hostname moves. OpenSearch is seeded from Elasticsearch, JanusGraph rebuilds from storage to guarantee parity, and the old cluster is retired only after the count check passes.Prerequisites
Migration is a data-integrity operation; a skipped compatibility check turns into silent query gaps weeks later.
- JanusGraph 0.6.x or 1.0.x already running against Elasticsearch through the REST client, with the mixed-index schema registered and
ENABLED. - A target OpenSearch cluster (1.3+ or 2.x) provisioned with the security plugin, TLS, and a scoped
janusgraph_svcaccount holdingindices:data/write/bulk,manage, and read privileges. - Network reachability from OpenSearch to Elasticsearch on 9200, because reindex-from-remote pulls from the source and the connection is initiated by OpenSearch.
- A recorded schema export. Capture
mgmt.printSchema()and the live Elasticsearch_mappingfor every mixed index so you can prove analyzer parity, not assume it. - A maintenance window for the final
REINDEXfrom storage and a rollback plan that can repointindex.search.hostnameback to Elasticsearch within one restart. - Storage aligned first. Confirm the storage keyspace Replication Strategies are healthy, since the authoritative
REINDEXreads from storage and an under-replicated view would seed OpenSearch from stale data.
Step 1 — Stand up OpenSearch with version and mapping parity
OpenSearch answers the Elasticsearch REST protocol but reports its own version lineage, which the JanusGraph client can mis-detect. Force it to advertise the compatible Elasticsearch version on every node, then mirror the analyzers and mappings that Elasticsearch created so predicates tokenize the same way.
# opensearch.yml — on EVERY node, or detection is intermittent across a rolling cluster
compatibility.override_main_response_version: true
Recreate each mixed index on OpenSearch with the exact mapping exported from Elasticsearch, paying attention to analyzers because a default standard versus keyword difference silently changes what a textContains predicate matches:
# Pull the source mapping and settings, strip read-only fields, apply to OpenSearch
curl -s 'http://es-cluster-01:9200/janusgraph_mixed/_mapping' | jq '.janusgraph_mixed.mappings' > mapping.json
curl -s 'http://es-cluster-01:9200/janusgraph_mixed/_settings' \
| jq '{settings: {index: {number_of_shards: .janusgraph_mixed.settings.index.number_of_shards,
analysis: .janusgraph_mixed.settings.index.analysis}}}' > settings.json
jq -s '{settings: .[0].settings, mappings: .[1]}' settings.json mapping.json \
| curl -sk -u janusgraph_svc:"$OPENSEARCH_SVC_PASSWORD" \
-XPUT 'https://opensearch-cluster-01:9200/janusgraph_mixed' \
-H 'Content-Type: application/json' -d @-
Verify: OpenSearch reports the compatible version and the analyzer for a text field matches the source exactly.
curl -sk -u janusgraph_svc:"$OPENSEARCH_SVC_PASSWORD" \
'https://opensearch-cluster-01:9200/' | jq '.version.number, .version.distribution'
# Compare tokenization of the same input on both clusters — output must be identical
for host in http://es-cluster-01:9200 https://opensearch-cluster-01:9200; do
curl -sk -u janusgraph_svc:"$OPENSEARCH_SVC_PASSWORD" \
"$host/janusgraph_mixed/_analyze" -H 'Content-Type: application/json' \
-d '{"field": "name", "text": "Ada Lovelace"}' | jq '[.tokens[].token]'
done
Step 2 — Seed OpenSearch by reindex-from-remote
Copy the existing documents into OpenSearch so the new cluster starts warm, using OpenSearch’s remote-reindex to pull directly from Elasticsearch. This is a bulk copy of current documents; the authoritative rebuild from storage happens after cutover in Step 3.
# Whitelist the ES host in opensearch.yml first: reindex.remote.whitelist: "es-cluster-01:9200"
curl -sk -u janusgraph_svc:"$OPENSEARCH_SVC_PASSWORD" \
-XPOST 'https://opensearch-cluster-01:9200/_reindex?wait_for_completion=false' \
-H 'Content-Type: application/json' -d '{
"source": {
"remote": { "host": "http://es-cluster-01:9200" },
"index": "janusgraph_mixed",
"size": 1000
},
"dest": { "index": "janusgraph_mixed", "op_type": "index" }
}'
Using op_type: index keeps the copy idempotent — the Elasticsearch document _id is the JanusGraph element id, so a re-run overwrites rather than duplicates. Track the returned task rather than blocking the terminal on a multi-hour copy.
Verify: the reindex task completes without failures and document counts converge.
curl -sk -u janusgraph_svc:"$OPENSEARCH_SVC_PASSWORD" \
'https://opensearch-cluster-01:9200/_tasks?actions=*reindex&detailed' \
| jq '.nodes[].tasks[] | {status: .status, error: .error}'
Step 3 — Cut over the hostname and reindex from storage
Now move the one property that changes. Point index.search.hostname at OpenSearch, keep the backend value on elasticsearch, and add the OpenSearch security wiring. Then run an authoritative REINDEX from storage so OpenSearch reflects the true graph state — not just whatever Elasticsearch held at copy time, which may have trailed storage by a replication window.
# janusgraph.properties — the backend value is deliberately unchanged
index.search.backend=elasticsearch
index.search.hostname=opensearch-cluster-01,opensearch-cluster-02
index.search.port=9200
index.search.elasticsearch.client-only=true
index.search.elasticsearch.ssl.enabled=true
index.search.elasticsearch.http.auth.type=basic
index.search.elasticsearch.http.auth.basic.username=janusgraph_svc
index.search.elasticsearch.http.auth.basic.password=${OPENSEARCH_SVC_PASSWORD}
# Protect the segment-merge pipeline during the rebuild.
index.search.elasticsearch.bulk-refresh=false
Restart Gremlin Server, then drive the rebuild through the Management API so any drift between the Step 2 copy and current storage is reconciled from the source of truth:
// Gremlin console against the freshly cut-over server
mgmt = graph.openManagement()
mixed = mgmt.getGraphIndex('janusgraph_mixed')
mgmt.updateIndex(mixed, org.janusgraph.core.schema.SchemaAction.REINDEX).get()
mgmt.commit()
Verify: JanusGraph is talking to OpenSearch and the reindex reached ENABLED.
mgmt = graph.openManagement()
println mgmt.getGraphIndex('janusgraph_mixed').getIndexStatus(
mgmt.getPropertyKey('name')) // expect ENABLED
mgmt.rollback()
Step 4 — Verify parity, then decommission Elasticsearch
Do not retire Elasticsearch on the strength of a successful reindex — prove parity first by comparing document counts and spot-checking that the same predicates return the same results on both clusters.
es=$(curl -s 'http://es-cluster-01:9200/janusgraph_mixed/_count' | jq '.count')
os=$(curl -sk -u janusgraph_svc:"$OPENSEARCH_SVC_PASSWORD" \
'https://opensearch-cluster-01:9200/janusgraph_mixed/_count' | jq '.count')
echo "elasticsearch=$es opensearch=$os delta=$((es - os))"
# delta within your churn tolerance (near zero on a quiesced graph) => parity holds
Run a handful of representative routed predicates against both and confirm result sets match. Once parity holds and OpenSearch has served live traffic through a soak window, decommission Elasticsearch: remove it from any load balancer, delete its indices, and scrub the old hostname from configuration management so a replaced node cannot rejoin pointing at the dead cluster.
Verify: the count delta is within tolerance and the old hostname appears nowhere in the deployed config.
grep -rn "es-cluster-01" /etc/janusgraph/ && echo "STALE REF — remove before decommission" || echo "clean"
Fallback and rollback procedures
Because the backend value never changed, rollback is a hostname flip, not a re-architecture.
If Step 1 shows analyzer divergence. A text field tokenizes differently on OpenSearch than Elasticsearch. Do not proceed — fix the OpenSearch mapping to declare the same analyzer explicitly rather than relying on either engine’s default, re-run the _analyze comparison, and only then seed. Shipping a mismatched analyzer means routed predicates will silently under-match after cutover.
If Step 2 reindex-from-remote fails partway. The task is idempotent because document ids are stable, so simply re-issue it; already-copied documents are overwritten, not duplicated. If it fails on a connection error, confirm reindex.remote.whitelist includes the Elasticsearch host on every OpenSearch node.
If Step 3 leaves the index not ENABLED. The reindex did not complete or the mapping rejected a field. Inspect the OpenSearch _mapping for a dynamic field creation that indicates a schema drift, reconcile the offending property key, and re-run SchemaAction.REINDEX. The general recovery path for a stalled or partial reindex is in Reindexing & Recovery.
If parity fails or production regresses after cutover. Roll back immediately by repointing the hostname at Elasticsearch, which is still authoritative because you did not decommission it yet:
# janusgraph.properties — revert the single line and restart
index.search.hostname=es-cluster-01,es-cluster-02,es-cluster-03
Restart Gremlin Server, confirm routed queries return to their prior latency and result counts, and investigate the OpenSearch mapping or analyzer difference before retrying. Keep Elasticsearch running until at least one full soak window has passed on OpenSearch — a routed predicate that matches nothing after switching backends is almost always an analyzer or mapping divergence, the same failure mode analyzed in Mixed Index Routing.
Frequently Asked Questions
Do I change index.search.backend to opensearch during the migration?
No. JanusGraph has no native opensearch backend value. It reaches OpenSearch through its Elasticsearch-compatible client, so index.search.backend stays elasticsearch and every index.search.elasticsearch.* key keeps its name. The only property that moves is index.search.hostname, plus the TLS and auth settings the OpenSearch security plugin requires.
Is reindex-from-remote enough, or do I still need SchemaAction.REINDEX?
Both, in sequence. Reindex-from-remote is a fast bulk copy that seeds OpenSearch with the documents Elasticsearch currently holds, but Elasticsearch may itself trail storage by a replication window, so the copy can be slightly stale. The Management API REINDEX after cutover rebuilds from the authoritative storage backend, reconciling any drift and guaranteeing the new index reflects true graph state.
Why must the analyzers match exactly before I cut over?
Because routing depends on tokenization. Elasticsearch and OpenSearch can apply different default analyzers, and a textContains predicate that split “Ada Lovelace” into two tokens on one engine but indexed it as a single keyword on the other will match nothing after the switch. Comparing _analyze output on both clusters catches this in Step 1 instead of in a production incident.
How do I roll back if OpenSearch misbehaves after cutover?
Repoint index.search.hostname back at Elasticsearch and restart Gremlin Server. Because you did not decommission Elasticsearch until parity was proven, it is still authoritative and fully in sync, so rollback is a single-line config revert plus one restart rather than a data-recovery exercise.
Related
- Up a level: OpenSearch Sync Patterns — version negotiation, security wiring, and drift triage for the OpenSearch target.
- Resolving OpenSearch Index Drift in Production — the count-comparison and repair loop that keeps the migrated index in sync afterward.
- Reindexing & Recovery — the authoritative rebuild-from-storage procedure the cutover depends on.
- Mixed Index Routing — analyzer and mapping alignment so predicates route identically across backends.
- Elasticsearch Integration — the source-side transport and bulk wiring you are migrating away from.