Debugging Mixed Index Fallback Misses

When a traversal that should hit the mixed index quietly full-scans the whole vertex space instead, nothing errors — the query just returns, slowly — and this guide is the procedure for catching that silent fallback, naming why the optimizer refused the index, and forcing the failure to be loud so it can never hide again. It sits under the Mixed Index Routing reference and drills into the single most expensive routing outcome: the predicate the optimizer could not match to any index, so it fell through to a scan that was sub-millisecond in staging and multi-second in production. The failure this prevents is diagnosis-by-guess — restarting the search cluster or rebuilding an index that was fine, when the real cause was a predicate the index cannot serve, an analyzer that tokenizes differently than you assumed, or an index build that never actually reached ENABLED. You read the profile, check the status, isolate the mismatch, then set the flag that turns future misses into exceptions.

The decision path for diagnosing why a JanusGraph traversal fell back to a full scan A slow traversal is diagnosed in three checks. First, .profile() shows whether the query used an index step or a full-scan step. If it scanned, mgmt.printIndexes() shows whether the property key index reached ENABLED or is stuck at INSTALLED or REGISTERED. If the index is enabled but the query still scanned, the cause is a predicate or analyzer mismatch that the index cannot serve. Each branch leads to a specific fix, and setting query.force-index true converts any future fallback from a silent scan into a thrown exception. Slow traversal fast in staging, slow in prod CHECK 1 · .profile() index step or full-scan step? scan step CHECK 2 · mgmt.printIndexes() status ENABLED? INSTALLED / REGISTERED ENABLED index not built REINDEX + awaitGraphIndexStatus until ENABLED predicate mismatch wrong predicate type or analyzer / mapping drift query.force-index=true future fallback throws instead of silently scanning
Three checks in order — profile, index status, predicate match — each with one fix. Setting query.force-index=true makes every future miss loud.

Prerequisites

The whole point is to replace guessing with reading, so make sure you can read the two authoritative signals before you change anything.

  • JanusGraph 0.6.x or 1.0.x with a registered mixed index on the property keys the slow traversal touches, and Gremlin console access to the live graph.
  • The exact slow traversal reproduced against production data — a fallback that only appears at production cardinality will not show up on a ten-vertex staging graph.
  • Read access to the search cluster _cat/indices and _analyze endpoints to confirm documents exist and check tokenization.
  • A staging graph you can restart so you can toggle query.force-index=true without throwing on unindexed traversals in production mid-incident.
  • Knowledge of which predicates each index serves. A mixed index answers textContains, geoWithin, and range predicates; an equality on a composite key stays in storage. If you are unsure which keys are eligible at all, that is governed by Property Indexing Rules.

Step 1 — Read the .profile() output for the scan step

.profile() is the ground truth for what the optimizer actually did, not what you hoped it would do. Run the failing traversal with it appended and look for whether the first step used an index or scanned every element.

groovy
// Gremlin console — append .profile() to the actual slow query
g.V().has('user', 'bio', textContains('robotics')).limit(20).profile()

Read the step list. A healthy routed query shows a JanusGraphStep annotated with an index name and a small results count reaching the first step. A fallback shows a JanusGraphStep(vertex) with no index and a results count near your total vertex count, plus the dominant time in that step:

text
Step                                                Count  Traversers  Time (ms)
=====================================================================================
JanusGraphStep([],[bio.textContains(robotics)])   18       18       4210.113   <-- scanned
  \_condition=(bio textContains robotics)
  \_isFitted=false          <-- the tell: predicate NOT served by an index
  \_query=[]
  \_index=                  <-- empty: no index was used
optimization                                                          0.041

isFitted=false and an empty index field are the signature of a fallback miss. If instead you see isFitted=true and an index name, the query is routing correctly and your latency problem is elsewhere.

Verify: you can state from the profile whether the query scanned or used an index, and if it scanned, which predicate was not fitted.

groovy
// Confirm the fallback is time-dominant, not a rounding artifact
g.V().has('user', 'bio', textContains('robotics')).limit(20).profile().next()

Step 2 — Check index status with mgmt.printIndexes()

If Step 1 shows a scan, the first suspect is an index that was defined but never finished building. An index stuck at INSTALLED or REGISTERED cannot serve queries — only ENABLED routes.

groovy
mgmt = graph.openManagement()
mgmt.printIndexes()          // human-readable dump of every index and its status
// Or check one key precisely:
idx = mgmt.getGraphIndex('userBio')
println idx.getIndexStatus(mgmt.getPropertyKey('bio'))   // want: ENABLED
mgmt.rollback()

If the status is anything but ENABLED, the index never became routable. Drive it to ENABLED by registering and reindexing, then block until the status flips rather than assuming it did:

groovy
mgmt = graph.openManagement()
idx = mgmt.getGraphIndex('userBio')
mgmt.updateIndex(idx, org.janusgraph.core.schema.SchemaAction.REINDEX).get()
mgmt.commit()
// Block until ENABLED so you do not re-test against a still-building index
org.janusgraph.graphdb.database.management.ManagementSystem
  .awaitGraphIndexStatus(graph, 'userBio')
  .status(org.janusgraph.core.schema.SchemaStatus.ENABLED)
  .call()

Verify: the index reports ENABLED and the search cluster actually holds documents.

bash
curl -s 'http://es-cluster-01:9200/_cat/indices?v' | grep janusgraph

Step 3 — Isolate a predicate or analyzer mismatch

If the index is ENABLED but Step 1 still shows a scan, the optimizer rejected the index because the predicate does not match what the index can serve. Two mismatches dominate: a predicate type the index does not support, and an analyzer that tokenizes the stored text differently than the query term.

groovy
// Predicate-type mismatch: a full-text index does NOT serve a plain eq() —
// eq() needs a composite index or the STRING mapping, not TEXT.
g.V().has('user', 'bio', 'robotics').profile()          // eq — may not fit a TEXT mixed index
g.V().has('user', 'bio', textContains('robotics')).profile()  // textContains — fits TEXT

For a full-text field, confirm the analyzer tokenizes the stored value and the query term the same way — a keyword mapping will not match a textContains token, and a standard analyzer lowercases where you may have expected exact case:

bash
# Does 'Robotics' in the document tokenize to the same term the query searches?
curl -s 'http://es-cluster-01:9200/janusgraph_userbio/_analyze' \
  -H 'Content-Type: application/json' \
  -d '{"field": "bio", "text": "Robotics research"}' | jq '[.tokens[].token]'
# Expect ["robotics","research"] for a standard analyzer; a keyword mapping returns the whole string

If tokenization diverges from what the predicate expects, the mapping or analyzer has drifted — realign it and reindex. This is the same analyzer-divergence class that bites during a backend migration; the mapping realignment path is in Reindexing & Recovery.

Verify: switching the query to the predicate type the index serves produces isFitted=true in the profile.

groovy
g.V().has('user', 'bio', textContains('robotics')).limit(20).profile()  // expect isFitted=true, index=userBio

Step 4 — Force failures with query.force-index and apply the fix

Once you know the cause, make the class of failure impossible to miss again. With query.force-index=true, JanusGraph throws on any traversal that would fall back to a full scan instead of silently scanning — turning a latency regression into an immediate, testable exception.

properties
# janusgraph.properties — fail loudly instead of scanning silently
query.force-index=true

Restart, then re-run the traversal. If the index and predicate are now correct, it returns fast; if any routing gap remains, it throws Could not find a suitable index naming the unindexed step, which is exactly the signal you want in CI and staging:

groovy
// With force-index on, an unindexed predicate throws here instead of scanning
try {
  g.V().has('user', 'bio', textContains('robotics')).limit(20).toList()
} catch (Exception e) {
  println "routing gap still present: ${e.message}"
}

Keep query.force-index=true in staging and CI so a routing regression fails the build; in production, pair it with a pre-defined composite index for every critical path so a mixed-index outage degrades to a bounded storage query rather than a thrown error. The circuit-breaker topology for that degrade-instead-of-throw behavior is in the sibling Configuring Mixed Index Fallback Chains.

Verify: the fixed traversal returns fast under force-index, and an intentionally unindexed predicate throws.

groovy
// Negative control: this SHOULD throw with force-index on
g.V().has('user', 'unindexed_field', 'x').toList()

Fallback and rollback procedures

Each check has a clean recovery, and you should confirm the fix before enabling force-index fleet-wide.

If Step 1’s profile is ambiguous. On some versions the step annotations are terse. Run the traversal with .profile().next() to force full materialization of the metrics, and compare the dominant step time against the total vertex count — a step whose count approaches the vertex total is a scan regardless of how the annotation reads.

If Step 2’s reindex never reaches ENABLED. The awaitGraphIndexStatus call is timing out because the reindex job stalled — a common cause is an under-replicated storage view feeding the rebuild. Run nodetool repair on the storage keyspace first so the index is not populated from missing data, then retry the reindex. The full stalled-reindex recovery is in the Reindexing & Recovery reference.

If Step 3 realignment breaks other queries. Changing an analyzer or mapping affects every predicate on that field. Reindex into a new index name, verify the affected queries against it, and swap the index binding only after parity holds — do not mutate the live mapping in place under traffic.

If query.force-index=true throws on legitimate traversals in production. You enabled it before every critical path had an index, so a real query is now failing instead of scanning. Roll it back immediately, add composite indexes for the throwing paths, and re-enable it only once staging is clean:

properties
# Revert to permit fallback scans while you close the remaining routing gaps
query.force-index=false

Restart, confirm the previously throwing traversals return, and treat each one as a routing gap to close before turning the flag back on.

Frequently Asked Questions

How do I know from .profile() that a query scanned instead of using the index? Look at the first JanusGraphStep. A routed query shows isFitted=true with a populated index= field and a small result count reaching the step. A fallback shows isFitted=false, an empty index= field, and a result count near your total vertex or edge count, with the bulk of the traversal time spent in that step. The empty index field and isFitted=false together are conclusive.

My index says ENABLED but the query still full-scans — why? An enabled index only routes predicates it can serve. If the query uses a predicate type the index does not support — a plain eq() against a full-text TEXT mapping, for example — or the field analyzer tokenizes the stored value differently than the query term, the optimizer cannot fit the index and falls back. Confirm the predicate type and compare tokenization with the _analyze API before rebuilding anything.

What does query.force-index=true actually change? It changes the fallback behavior from silent to loud. Without it, a traversal that matches no index quietly runs a full scan; with it, that same traversal throws Could not find a suitable index naming the offending step. It does not make new queries route — it makes routing gaps fail fast so they surface in staging and CI instead of as a production latency regression.

Should I enable query.force-index in production? Only after every critical traversal path has a suitable index, and ideally paired with a composite-index fallback so a mixed-index outage degrades to a bounded storage query rather than a thrown exception. Enable it unconditionally in staging and CI, where a thrown error is exactly the signal you want; in production, gate it behind confidence that no legitimate path relies on a scan.