plan_stability_subjoin_cardinality_md reconstruction mishandles open-ended date IXSCAN bounds

XMLWordPrintableJSON

    • Type: Bug
    • Resolution: Fixed
    • Priority: Major - P3
    • 9.0.0-rc0
    • Affects Version/s: None
    • Component/s: None
    • None
    • Query Optimization
    • Fully Compatible
    • ALL
    • Hide

      Mongosh script to reproduce:

      // Inlining the buggy helpers from plan_stability_subjoin_cardinality_md.js.
      
      /**
       * Reconstruct the index bound out of an indexBounds string.
       * Since the indexBounds are strings containing serialized representations,
       * we need to do a lot of heavy lifting to reconstruct the original predicate.
       */
      function parseIndexBound(boundStr) {
          // Bounds look like this: '["abc", "abc"]', '(5.0, 10.0]', '[MinKey, MaxKey]'
          const match = boundStr.match(/^([\[\(])\s*(.+?)\s*,\s*(.+?)\s*([\]\)])$/);
          if (!match) {
              return {
                  isEquality: false,
                  isFullRange: false,
                  lower: {value: undefined, inclusive: true},
                  upper: {value: undefined, inclusive: true},
              };
          }
      
          const lowerInclusive = match[1] === "[";
          const upperInclusive = match[4] === "]";
          const lowerVal = parseIndexBoundLiteral(match[2].trim());
          const upperVal = parseIndexBoundLiteral(match[3].trim());
      
          const isEquality =
              lowerInclusive &&
              upperInclusive &&
              JSON.stringify(lowerVal) === JSON.stringify(upperVal) &&
              lowerVal !== "MinKey" &&
              lowerVal !== "MaxKey";
      
          const isFullRange = lowerInclusive && upperInclusive && lowerVal === "MinKey" && upperVal === "MaxKey";
      
          return {
              isEquality,
              isFullRange,
              lower: {value: lowerVal, inclusive: lowerInclusive},
              upper: {value: upperVal, inclusive: upperInclusive},
          };
      }
      
      /**
       * Parse an individual literal as seen in an index bound
       */
      function parseIndexBoundLiteral(str) {
          if (str === "MinKey") return "MinKey";
          if (str === "MaxKey") return "MaxKey";
          if (str === "null") return null;
          if (str === "true") return true;
          if (str === "false") return false;
          if (str === "Infinity" || str === "inf") return Infinity;
          if (str === "-Infinity" || str === "-inf") return -Infinity;
      
          // Try to parse as number
          const num = Number(str);
          if (!isNaN(num) && str !== '""' && str !== "") return num;
      
          // Remove surrounding quotes if present
          const strMatch = str.match(/^"(.*)"$/);
          if (strMatch) return strMatch[1];
      
          // ObjectId
          const oidMatch = str.match(/^ObjectId\('([a-f0-9]+)'\)$/);
          if (oidMatch) return new ObjectId(oidMatch[1]);
      
          // ISODate
          const isoDateMatch = str.match(/^ISODate\('(.+)'\)$/);
          if (isoDateMatch) return new ISODate(isoDateMatch[1]);
      
          // Date
          const dateMatch = str.match(/^new Date\((.+)\)$/);
          if (dateMatch) {
              print(parseInt(dateMatch[1]));
              return new Date(parseInt(dateMatch[1]));
          }
      
          return str;
      }
      
      const coll = db.coll;
      coll.createIndex({a: 1});
      coll.insert({a: new Date("2020-01-02")});
      const explain = coll.find({a: {$gt: new Date("2020-01-01")}}).explain();
      printjson(explain);
      const boundStr = explain.queryPlanner.winningPlan.inputStage.indexBounds.a[0];
      const parsed = parseIndexBound(boundStr);
      
      print("Parsed lower: " + parsed.lower.value);
      print("Parsed upper: " + parsed.upper.value);
      
      print("Parsed upper === Invalid Date: " + isNaN(parsed.upper.value));
      
      Show
      Mongosh script to reproduce: // Inlining the buggy helpers from plan_stability_subjoin_cardinality_md.js. /** * Reconstruct the index bound out of an indexBounds string. * Since the indexBounds are strings containing serialized representations, * we need to do a lot of heavy lifting to reconstruct the original predicate. */ function parseIndexBound(boundStr) { // Bounds look like this : '[ "abc" , "abc" ]' , '(5.0, 10.0]' , '[MinKey, MaxKey]' const match = boundStr.match(/^([\[\(])\s*(.+?)\s*,\s*(.+?)\s*([\]\)])$/); if (!match) { return { isEquality: false , isFullRange: false , lower: {value: undefined, inclusive: true }, upper: {value: undefined, inclusive: true }, }; } const lowerInclusive = match[1] === "[" ; const upperInclusive = match[4] === "]" ; const lowerVal = parseIndexBoundLiteral(match[2].trim()); const upperVal = parseIndexBoundLiteral(match[3].trim()); const isEquality = lowerInclusive && upperInclusive && JSON.stringify(lowerVal) === JSON.stringify(upperVal) && lowerVal !== "MinKey" && lowerVal !== "MaxKey" ; const isFullRange = lowerInclusive && upperInclusive && lowerVal === "MinKey" && upperVal === "MaxKey" ; return { isEquality, isFullRange, lower: {value: lowerVal, inclusive: lowerInclusive}, upper: {value: upperVal, inclusive: upperInclusive}, }; } /** * Parse an individual literal as seen in an index bound */ function parseIndexBoundLiteral(str) { if (str === "MinKey" ) return "MinKey" ; if (str === "MaxKey" ) return "MaxKey" ; if (str === " null " ) return null ; if (str === " true " ) return true ; if (str === " false " ) return false ; if (str === "Infinity" || str === "inf" ) return Infinity; if (str === "-Infinity" || str === "-inf" ) return -Infinity; // Try to parse as number const num = Number (str); if (!isNaN(num) && str !== ' ""' && str !== " ") return num; // Remove surrounding quotes if present const strMatch = str.match(/^ "(.*)" $/); if (strMatch) return strMatch[1]; // ObjectId const oidMatch = str.match(/^ObjectId\( '([a-f0-9]+)' \)$/); if (oidMatch) return new ObjectId(oidMatch[1]); // ISODate const isoDateMatch = str.match(/^ISODate\( '(.+)' \)$/); if (isoDateMatch) return new ISODate(isoDateMatch[1]); // Date const dateMatch = str.match(/^ new Date\((.+)\)$/); if (dateMatch) { print(parseInt(dateMatch[1])); return new Date(parseInt(dateMatch[1])); } return str; } const coll = db.coll; coll.createIndex({a: 1}); coll.insert({a: new Date( "2020-01-02" )}); const explain = coll.find({a: {$gt: new Date( "2020-01-01" )}}).explain(); printjson(explain); const boundStr = explain.queryPlanner.winningPlan.inputStage.indexBounds.a[0]; const parsed = parseIndexBound(boundStr); print( "Parsed lower: " + parsed.lower.value); print( "Parsed upper: " + parsed.upper.value); print( "Parsed upper === Invalid Date: " + isNaN(parsed.upper.value));
    • None
    • None
    • None
    • None
    • None
    • None
    • None

      The plan reconstruction logic in plan_stability_subjoin_cardinality_md.js mishandles IXSCAN bounds on indexes that contain dates when the index range is open ended, e.g. {a: {$gt: new Date("2020-01-01")}}. When the index range is open ended, the MongoDB server inserts new Date(INT64_MAX) (code) or new Date(INT64_MIN) (code)(depending on the side of the open bound). The reconstructed pipeline emits NaN for the open bound e.g. in the case of an open upper bound (note INT64_MAX = 9223372036854775807)
      For the index bounds:

      "indexBounds": {
        "o_orderdate": [
        "(new Date(887932800000), new Date(9223372036854775807)]"
      ]}
      

      We see the reconstructed plan emit:

       {
        "o_orderdate" : {
          "$gt" : ISODate("1998-02-20T00:00:00Z"),
          "$lte" : ISODate("0NaN-NaN-NaNTNaN:NaN:NaNZ")
        }
      }
      

      The issue is in this logic where if we see a "Date" when parsing index bounds literal for reconstruction, we simply call the js parseInt(<date>).

      This results in the following sequence of issues (based on a combination of my + Claude understanding):
      1. parseInt("9223372036854775807") loses precision because parseInt() converts upto Number.MAX_SAFE_INTEGER which is 2^53 - 1, but INT64_MAX is 2^63 - 1. parseInt returns 9223372036854776000 (rounded to the nearest representable double).
      2. new Date(9223372036854776000) exceeds JavaScript's Date clamp at 8.64e15, so the constructor returns Invalid Date.

      A possible fix is:
      In parseIndexBoundLiteral() we recognize the INT64_MAX/INT64_MIN sentinel values and return the existing "MaxKey"/"MinKey" strings, so later on the downstream predicate builder (which already handles that) correctly omits the bounds).

      This surfaced because SERVER-125579 makes pipeline-form $lookup with an absorbed $match join-eligible and thus we choose an access path that produces an edge case that is not handled by the reconstruction code. This bug predates SERVER-125579 and the attached repro script shows a much simpler find() query triggering this issue. This particular issue surfaced on query idx 11:

      {"aggregate":"lineitem","pipeline":[
      {"$lookup":{"from":"orders","localField":"l_orderkey","foreignField":"o_orderkey","pipeline":[
      {"$match":{"$and":[{"o_orderdate":{"$gt":"1998-02-20T00:00:00.000Z"}}]}},
      {"$match":{"$and":[{"o_orderpriority":"3-MEDIUM"}]}}],"as":"orders"}},
      {"$unwind":"$orders"},
      {"$lookup":{"from":"customer","localField":"orders.o_custkey","foreignField":"c_custkey","pipeline":[
      {"$match":{"$and":[{"c_acctbal":{"$gte":6089.13}}]}}],"as":"customer"}},
      {"$unwind":"$customer"},
      {"$lookup":{"from":"supplier","localField":"customer.c_nationkey","foreignField":"s_nationkey","pipeline":[
      {"$match":{"$or":[{"s_acctbal":{"$eq":3580.35}},{"s_name":"Supplier#000000338"}]}},
      {"$match":{"$nor":[{"s_nationkey":13},{"s_nationkey":6}]}},
      {"$match":{"$nor":[{"s_acctbal":{"$eq":3839.44}},{"s_acctbal":{"$gt":9583.11}}]}}],"as":"supplier"}},
      {"$unwind":"$supplier"},
      {"$match":{"$and":[{"customer.c_mktsegment":"MACHINERY"}]}}],"cursor":{},"idx":11}
      

       

            Assignee:
            Naafiyan Ahmed
            Reporter:
            Naafiyan Ahmed
            Votes:
            0 Vote for this issue
            Watchers:
            2 Start watching this issue

              Created:
              Updated:
              Resolved: