Express user-index plan spins forever when an index entry references a missing record

    • Type: Bug
    • Resolution: Unresolved
    • Priority: Critical - P2
    • None
    • Affects Version/s: None
    • Component/s: None
    • None
    • Query Execution
    • ALL
    • QE 2026-09-15
    • None
    • None
    • None
    • None
    • None
    • None
    • None

      Problem

      LookupViaUserIndex::consumeOne loops forever when an index entry points to a record that does not exist. A single orphan index key turns any point query that takes the express user-index path into an unbounded CPU spin, bounded only by the operation's deadline.

      Observed on HELP-98643: a dev cluster on rc1018 ran at 100% CPU on the primary with read tickets fully consumed and roughly 350k/sec WiredTiger search calls sustaining a fraction of the normal throughput.

      Mechanism

      express_plan.h#L713-L728

              bool found = FetchCallback{}(
                  opCtx, collection, _indexCatalogEntry, keyEntry, _projection, obj, _stats);
              if (!found) {
                  ...
                  logRecordNotFound(...);
                  return Ready();
              }
      
      1. consumeOne creates a new index cursor on every call and seeks the same startKey. The cursor is a local, destroyed on return, and the class holds no cursor member, so no index position carries between calls.
      2. On a fetch miss it logs the warning and returns Ready() without setting _exhausted.
      3. The driver at plan_executor_express.cpp#L382-L408 loops while !haveOutput and breaks only on _plan.exhausted(). Ready() is not terminal, so it calls consumeOne again.
      4. Same key, same miss, forever. Each iteration pays a fresh newCursor plus seekForKeyValueView and increments keysExamined.

      The loop calls _opCtx->checkForInterrupt() each iteration, so the operation dies at maxTimeMS or on killOp. It is a per-operation CPU burn, not a permanent hang. With no maxTimeMS set it spins until killed.

      Two sibling iterators in the same file handle the identical condition correctly, both setting _exhausted = true; return Exhausted();:

      • IdLookupViaIndex, express_plan.h L355-L363
      • IdLookupOnClusteredCollection, express_plan.h L503-L508

      All three branches were written in one commit, 0db16e1817 (SERVER-89445), which added record-not-found handling to each iterator and chose a different terminator for the user-index one.

      Note the miss branch is only reachable through FetchFromCollectionCallback. The covered path, CreateDocumentFromIndexKey, builds the document from the index key and always returns true.

      Also note logRecordNotFound returns early when the recovery unit is ignoring prepare conflicts. On that path the loop spins with no log line at all, so absence of the "Erroneous index key found" warning does not rule this out.

      Proposed fix

      Change the miss branch to _exhausted = true; return Exhausted();, matching the two IdLookup* iterators. This turns an unbounded spin into one logged warning and an empty result.

      This loses nothing the current code could have returned. After a successful continuation, consumeOne already sets _exhausted = true; return Exhausted{};, so one document is the maximum today regardless of the miss branch.

      Constraint for whoever picks this up

      Raised by Justin Seyster on HELP-98643, and it must be handled or the fix becomes a correctness bug later.

      The class comment on LookupViaUserIndex (express_plan.h L624-L631) states that the iterator "can produce multiple matching documents". That is not implemented – there is no cursor member, and the success path terminates after one document. getIndexForExpressEquality agrees, admitting this plan only for a unique single-field index or a limit(1) query, with TODO SERVER-87016 and the note that the express executor "cannot iterate (yet)": plan_executor_express.cpp#L1006-L1018

      The risk: if SERVER-87016 later implements iteration, a hardcoded _exhausted = true in the miss branch becomes a silent truncation bug. A legitimate multi-result query would stop at the first orphan index key instead of skipping past it and returning the remaining matches.

      Two ways to handle it:

      1. Take Exhausted() now, add a comment tying the choice to SERVER-87016, and correct the stale class comment in the same change so the semantics are not ambiguous next time.
      2. Persist the index cursor and advance past the bad entry. Closer to the documented intent and to the direction of SERVER-105226, but a larger change and harder to backport.

      Ready() is wrong under both readings. If the iterator could produce multiple results, correct miss handling is to advance past the bad entry, which requires a persisted cursor. Re-seeking startKey on a fresh cursor returns the same bad entry every time.

      The stale class comment should be fixed either way.

      Verification

      The spin is visible without reproducing the corruption:

      • Slow query log for the affected shape shows planSummary: EXPRESS_IXSCAN with keysExamined in the millions and nreturned: 0. incNumKeysExamined(1) runs once per iteration, so the counter is a direct spin count.
      • A CPU profile shows a hot stack through LookupViaUserIndex::consumeOne, newCursor and seekForKeyValueView.

      For a unit test, an orphan index entry plus a limit(1) equality query on a non-unique btree index should return empty with one warning logged, rather than looping.

      Context

      The orphan index keys on the HELP-98643 cluster are attributed to WT-18159, which is absent from rc1018. That is a separate fix and is already in rc1019. This ticket covers only the express-path amplification: any index corruption, from any source, should not cost 100% CPU.

            Assignee:
            Cian Nugent McGealy
            Reporter:
            Zixuan Zhuang
            Votes:
            1 Vote for this issue
            Watchers:
            6 Start watching this issue

              Created:
              Updated: