ExportXMLWordPrintableJSON

    • 2
    • None
    • None
    • None
    • None
    • None
    • None
    • None

      Use Case

      As a developer using the Node.js driver with MONGODB-OIDC authentication and manually managed transactions (session.startTransaction() / commitTransaction())
      I want a transaction to succeed when its first command triggers OIDC reauthentication (ReauthenticationRequired, 391)
      So that transactions do not fail intermittently when their first command happens to hit an expired access token

      User Experience

      • Expected: After reauthentication, the driver re-sends the first command with startTransaction: true (the server never started the transaction because the original attempt was rejected with 391), and the transaction commits.
      • Actual: The re-sent command omits startTransaction, so the server responds with NoSuchTransaction (251): Given transaction number N on session ... does not match any in-progress transactions. The active transaction number is N-1
      • Impact: Any transaction can fail intermittently when its first command happens to hit an expired OIDC token. The transaction is aborted, so there are no partial writes, but the operation fails and has to be retried by the caller.
      • Versions: Reproduced on driver 6.20.0 and 7.7.0, Node.js 24.21.0.

      Steps to reproduce (local replica set with enableTestCommands=1 and SCRAM auth; 391 injected with a fail point):
      Tested with mongodb-memory-server 8.16.1 (mongod 5.0.19) for driver 6.20.0, and 11.3.0 (mongod 8.2.6) for driver 7.7.0.

      const { MongoMemoryReplSet } = require('mongodb-memory-server');
      const { MongoClient } = require('mongodb');
      
      (async () => {
        const rs = await MongoMemoryReplSet.create({
          instanceOpts: [{ args: ['--setParameter', 'enableTestCommands=1'] }],
          replSet: { count: 1, auth: { enable: true, customRootName: 'root', customRootPwd: 'pw' } },
        });
        const client = new MongoClient(rs.getUri().replace('mongodb://', 'mongodb://root:pw@'), {
          authSource: 'admin',
          monitorCommands: true,
        });
        const sent = [];
        client.on('commandStarted', e => {
          if (e.commandName === 'insert') {
            sent.push({ startTransaction: e.command.startTransaction === true, txnNumber: String(e.command.txnNumber) });
          }
        });
        await client.connect();
        await client.db('t').createCollection('c');
        await client.db('admin').command({
          configureFailPoint: 'failCommand',
          mode: { times: 1 },
          data: { failCommands: ['insert'], errorCode: 391 },
        });
      
        const session = client.startSession();
        try {
          session.startTransaction();
          await client.db('t').collection('c').insertOne({ a: 1 }, { session });
          await session.commitTransaction();
          console.log('COMMIT OK');
        } catch (e) {
          console.log(`FAIL code=${e.code} labels=${JSON.stringify(e.errorLabels)}`);
          console.log(e.message);
        } finally {
          console.log('insert attempts:', JSON.stringify(sent));
          await session.endSession();
          await client.close();
          await rs.stop();
        }
      })();
      

      Output (same on 6.20.0 and 7.7.0; the exact message wording depends on the server version):

      FAIL code=251 labels=["TransientTransactionError"]
      Given transaction number 1 does not match any in-progress transactions. The active transaction number is -1
      insert attempts: [{"startTransaction":true,"txnNumber":"1"},{"startTransaction":false,"txnNumber":"1"}]
      

      Stack trace (6.20.0):

      at Connection.sendCommand (lib/cmap/connection.js:306:27)
      at async Connection.command (lib/cmap/connection.js:334:26)
      at async Server.command (lib/sdam/server.js:213:33)
      at async tryOperation (lib/operations/execute_operation.js:213:32)
      at async executeOperation (lib/operations/execute_operation.js:78:16)
      at async Collection.insertOne (lib/collection.js:154:16)
      

      Analysis: This appears to conflict with two spec requirements:

      Since the original attempt was rejected with 391, the re-attempt is the first command of the transaction from the server's point of view.

      • 6.20.0: applySession() transitions STARTING_TRANSACTION to TRANSACTION_IN_PROGRESS when the first command is built. The reauth branch in Server.command calls conn.command() again, which re-runs applySession(); the state is already TRANSACTION_IN_PROGRESS, so startTransaction is not added.
      • 7.2.0+: #4871 (NODE-7142) moved the transition into updateSessionFromResponse() and keeps STARTING_TRANSACTION only when the error has the RetryableError label. 391 does not have that label, so the state still transitions before the reauth retry.

      The OIDC prose test 4.5 Reauthentication Succeeds when a Session is involved runs a find in a session but not inside a transaction, so it does not cover this case.

      Workaround: session.withTransaction() recovers, because the 251 error carries TransientTransactionError and the callback is retried with a new txnNumber and startTransaction: true:

      insert attempts: [{"startTransaction":true,"txnNumber":"1"},{"startTransaction":false,"txnNumber":"1"},{"startTransaction":true,"txnNumber":"2"}]
      

      Applications that manage transactions manually are still affected.

      Dependencies

      • None known.

      Risks/Unknowns

      • Adjacent behavior: the fix should only keep STARTING_TRANSACTION for the reauthentication retry. Other non-retryable errors on the first command should still transition the state as they do today, so that abortTransaction() and subsequent commands behave the same.
      • Cross-driver alignment: other drivers may have the same gap. A transactions-plus-reauthentication case in the spec tests would cover all of them.
      • Documentation: the transactions docs could mention that withTransaction() also covers this case, since manual transaction code has to implement its own retry on TransientTransactionError to recover.
      • Related: JAVA-5880 (OIDC reauthentication inside a transaction, different root cause), DRIVERS-3195 / NODE-6959 (prose test with a session), NODE-7279 (surfacing reauthentication errors).

      Acceptance Criteria

      Implementation Requirements

      • When the first command of a transaction receives ReauthenticationRequired (391), the re-attempt after reauthentication includes startTransaction: true — for example by treating 391 like the RetryableError case in updateSessionFromResponse(). The same issue exists in 6.x (where the transition happens in applySession()); a backport would help users who cannot move to 7.x yet.

      Testing Requirements

      • A test where the first command inside a transaction fails with 391 via fail point, and the transaction commits after reauthentication.

      Documentation Requirements

      • Optional: mention in the transactions docs that withTransaction() also recovers from reauthentication on the first command.

      Follow Up Requirements

      • Consider a DRIVERS ticket for a cross-driver spec test (transaction whose first command receives 391).
      • We have not checked whether other drivers behave the same way; confirming the expected behavior with the DBX team would help decide whether this belongs in the spec.

            Assignee:
            Daria Pardue
            Reporter:
            Han N/A (EXT)
            None
            Votes:
            0 Vote for this issue
            Watchers:
            2 Start watching this issue

              Created:
              Updated: