From 916ef5c5307103d9bea44446a40da5d31de2b3fe Mon Sep 17 00:00:00 2001 From: Bilal Atique <74999164+BilalAtique@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:30:50 +0500 Subject: [PATCH] fix(mongo-adapter): retry a transaction aborted with a transient error (#11515) Co-authored-by: Maxwell Afamefuna Nzekwe <70326615+maxnze1@users.noreply.github.com> Co-authored-by: Gustavo Valverde --- .../mongo-transient-transaction-retry.md | 8 +++++ docs/content/docs/adapters/mongo.mdx | 4 +++ docs/content/docs/concepts/database.mdx | 2 +- packages/core/src/context/transaction.test.ts | 34 ++++++++++++++++++ packages/core/src/context/transaction.ts | 5 ++- packages/core/src/db/adapter/index.ts | 4 +++ .../mongo-adapter/src/mongodb-adapter.test.ts | 29 ++++++++++++++- packages/mongo-adapter/src/mongodb-adapter.ts | 36 ++++++++----------- 8 files changed, 98 insertions(+), 24 deletions(-) create mode 100644 .changeset/mongo-transient-transaction-retry.md diff --git a/.changeset/mongo-transient-transaction-retry.md b/.changeset/mongo-transient-transaction-retry.md new file mode 100644 index 0000000000..8adb55ac32 --- /dev/null +++ b/.changeset/mongo-transient-transaction-retry.md @@ -0,0 +1,8 @@ +--- +"@better-auth/core": patch +"@better-auth/mongo-adapter": patch +--- + +If you pass `client` to `mongodbAdapter` on a replica set, concurrent requests that write the same documents no longer fail with a 500. Before, MongoDB aborted all but one of them with a write conflict, so a SCIM directory that provisions several users at once returned 500 for most of them. The adapter now retries such a transaction, as MongoDB requires, for up to 120 seconds or until your `MongoClient`'s `timeoutMS`. Under contention a request can take longer instead of failing. + +A retry runs the work again. `databaseHooks` `before` hooks can run more than once for one request, and `after` hooks still run once, after the commit. Work that the request does outside the database before it commits can also repeat on a retried request. For example, an email sign-up can send its verification email twice. Requests that succeed on the first attempt are unaffected, and so are other adapters. diff --git a/docs/content/docs/adapters/mongo.mdx b/docs/content/docs/adapters/mongo.mdx index a108e6d04e..298bafb5ac 100644 --- a/docs/content/docs/adapters/mongo.mdx +++ b/docs/content/docs/adapters/mongo.mdx @@ -38,6 +38,10 @@ export const auth = betterAuth({ }); ``` +When two requests write the same document at the same time, MongoDB aborts one of the transactions with a write conflict and expects the application to retry it. The adapter retries such a transaction for up to 120 seconds, so a request under heavy contention can take longer instead of failing. To lower that limit, set the `timeoutMS` option on your `MongoClient`. It also applies to every other operation on that client. + +A retry runs the whole operation again, including `before` [database hooks](/docs/concepts/database#database-hooks) and plugin callbacks that receive the transaction. Send emails and call other services from `after` hooks, which run once, after the commit. + ### Standalone server A standalone MongoDB server does not support transactions. Create `db` with `MongoClient` and pass it to the adapter without the `client` option: diff --git a/docs/content/docs/concepts/database.mdx b/docs/content/docs/concepts/database.mdx index c060d1e664..ed2324d992 100644 --- a/docs/content/docs/concepts/database.mdx +++ b/docs/content/docs/concepts/database.mdx @@ -883,7 +883,7 @@ There are two types of hooks you can define: #### 1. Before Hook * **Purpose**: This hook is called before the respective entity (user, session, or account) is created, updated, or deleted. -* **Behavior**: If the hook returns `false`, the operation will be aborted. And If it returns a data object, it'll replace the original payload. +* **Behavior**: If the hook returns `false`, the operation will be aborted. And If it returns a data object, it'll replace the original payload. On MongoDB with transactions, a before hook can run more than once for one operation: when MongoDB aborts the transaction because of a concurrent write, the adapter runs it again. Send emails and call other services from an after hook instead. #### 2. After Hook diff --git a/packages/core/src/context/transaction.test.ts b/packages/core/src/context/transaction.test.ts index 08898eca97..08fab9282b 100644 --- a/packages/core/src/context/transaction.test.ts +++ b/packages/core/src/context/transaction.test.ts @@ -223,6 +223,40 @@ describe("runWithTransaction", () => { }); }); + /** + * @see https://github.com/better-auth/better-auth/issues/11514 + */ + it("runs only the hooks of the attempt that commits when the adapter retries the callback", async () => { + const transactionAdapter = {} as DBTransactionAdapter; + let attempts = 0; + const adapter = { + transaction: async ( + callback: (trx: DBTransactionAdapter) => Promise, + ) => { + try { + return await callback(transactionAdapter); + } catch { + return callback(transactionAdapter); + } + }, + } as DBAdapter; + const hookRuns: number[] = []; + + await runWithTransaction(adapter, async () => { + attempts += 1; + const attempt = attempts; + await queueAfterTransactionHook(async () => { + hookRuns.push(attempt); + }); + if (attempt === 1) { + throw new Error("write conflict"); + } + }); + + expect(attempts).toBe(2); + expect(hookRuns).toEqual([2]); + }); + it("reports a handled after-commit hook failure without rejecting committed work", async () => { const { adapter } = createTransactionHarness(); const onError = vi.fn(); diff --git a/packages/core/src/context/transaction.ts b/packages/core/src/context/transaction.ts index 898338598c..263ab39093 100644 --- a/packages/core/src/context/transaction.ts +++ b/packages/core/src/context/transaction.ts @@ -130,12 +130,15 @@ export const runWithTransaction = async < // connection a single-connection store would need for the lookup. const pendingSchemaCheck = runtimeSchemaCheckFor(adapter)?.(); if (pendingSchemaCheck) await pendingSchemaCheck; - const pendingHooks: Array<() => Promise> = []; + let pendingHooks: Array<() => Promise> = []; let result: Awaited; let error: unknown; let hasError = false; try { result = await adapter.transaction(async (trx) => { + // An adapter may run this callback again after a transient abort. + // Only the hooks of the attempt that commits may run. + pendingHooks = []; return als.run( { adapter: trx as unknown as StoredAdapter, diff --git a/packages/core/src/db/adapter/index.ts b/packages/core/src/db/adapter/index.ts index 21053fccbd..72dbcb98ca 100644 --- a/packages/core/src/db/adapter/index.ts +++ b/packages/core/src/db/adapter/index.ts @@ -509,6 +509,10 @@ export type DBAdapter = { /** * Execute multiple operations in a transaction. * If the adapter doesn't support transactions, operations will be executed sequentially. + * + * An adapter may run `callback` more than once, for example when MongoDB + * aborts the transaction with a transient error, so work outside the + * database belongs in `queueAfterTransactionHook`. */ transaction: ( callback: (trx: DBTransactionAdapter) => Promise, diff --git a/packages/mongo-adapter/src/mongodb-adapter.test.ts b/packages/mongo-adapter/src/mongodb-adapter.test.ts index f1d7b7e5e5..820f90a434 100644 --- a/packages/mongo-adapter/src/mongodb-adapter.test.ts +++ b/packages/mongo-adapter/src/mongodb-adapter.test.ts @@ -187,7 +187,8 @@ describe("mongodb-adapter", () => { }), ).rejects.toBe(callbackError); expect(commit).not.toHaveBeenCalled(); - expect(abort).not.toHaveBeenCalled(); + // `withTransaction` aborts a failed callback itself; no raw command is needed. + expect(abort).toHaveBeenCalledOnce(); expect(command).not.toHaveBeenCalled(); expect(end).toHaveBeenCalledOnce(); await client.close(); @@ -610,6 +611,32 @@ describe("mongodb-adapter", () => { }); }); +describe("transactions", () => { + /** + * @see https://github.com/better-auth/better-auth/issues/11514 + * @see https://www.mongodb.com/docs/manual/core/transactions-in-applications/ + */ + it("retries the callback when MongoDB aborts the transaction with a transient error", async () => { + const client = new MongoClient("mongodb://localhost:27017"); + const adapter = mongodbAdapter(client.db("test"), { client })({}); + let attempts = 0; + + const result = await adapter.transaction(async () => { + attempts += 1; + if (attempts === 1) { + const writeConflict = new MongoServerError({ errmsg: "WriteConflict" }); + writeConflict.addErrorLabel(MongoErrorLabel.TransientTransactionError); + throw writeConflict; + } + return "committed"; + }); + + expect(result).toBe("committed"); + expect(attempts).toBe(2); + await client.close(); + }); +}); + describe("uuid support", () => { const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; diff --git a/packages/mongo-adapter/src/mongodb-adapter.ts b/packages/mongo-adapter/src/mongodb-adapter.ts index db54fea29c..1cc3561728 100644 --- a/packages/mongo-adapter/src/mongodb-adapter.ts +++ b/packages/mongo-adapter/src/mongodb-adapter.ts @@ -814,8 +814,6 @@ export const mongodbAdapter = ( let commitAttempted = false; try { - session.startTransaction(); - const adapter = createAdapterFactory({ config: { ...adapterOptions!.config, @@ -824,26 +822,22 @@ export const mongodbAdapter = ( adapter: createCustomAdapter(db, session), })(lazyOptions!); - const result = await cb(adapter); - - commitAttempted = true; - await session.commitTransaction(); - return result; + // `withTransaction` retries a `TransientTransactionError` (such as a + // WriteConflict between concurrent transactions) and an unknown + // commit result, so `cb` can run more than once. + return await session.withTransaction(async () => { + commitAttempted = false; + const result = await cb(adapter); + // `withTransaction` commits as soon as the callback resolves. + commitAttempted = true; + return result; + }); } catch (err) { - if (session.inTransaction()) { - await session.abortTransaction(); - } else if (commitAttempted) { - // A commit that failed in flight leaves the transaction - // open on the server, but the driver no longer reports the - // session as being in one, so `abortTransaction()` is not - // an option. Send the abort as a command on the same - // session so the server does not hold the writes until - // `transactionLifetimeLimitSeconds`. - // - // Only a failed commit can leave that state behind: if the - // transaction never started, or failed before the commit, - // there is nothing open and sending the command would just - // produce a second error to log. + // `withTransaction` aborts when the callback fails, but not when the + // commit fails in flight: the driver then no longer reports the + // session as being in a transaction, while the server keeps it open + // until `transactionLifetimeLimitSeconds`. Abort it with a command. + if (commitAttempted) { await abortTransactionCommand(config.client, session); } throw err;