fix(foundry): move Better Auth operations from queues to actions to fix production auth timeout

The org actor's workflow queue is shared with GitHub sync, webhooks, task
mutations, and billing (20+ queue names processed sequentially). During
OAuth callback, auth operations would time out waiting behind long-running
queue handlers, causing Better Auth's parseState to redirect to
?error=please_restart_the_process.

Auth operations are simple SQLite reads/writes with no cross-actor side
effects, so they are safe to run as actions that execute immediately
without competing in the queue.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-16 21:26:13 -07:00
parent 84a80d59d7
commit e7b9ac6854
7 changed files with 211 additions and 233 deletions

View file

@ -1,21 +1,4 @@
import {
and,
asc,
count as sqlCount,
desc,
eq,
gt,
gte,
inArray,
isNotNull,
isNull,
like,
lt,
lte,
ne,
notInArray,
or,
} from "drizzle-orm";
import { and, asc, count as sqlCount, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, lte, ne, notInArray, or } from "drizzle-orm";
import { authAccountIndex, authEmailIndex, authSessionIndex, authVerification } from "../db/schema.js";
import { APP_SHELL_ORGANIZATION_ID } from "../constants.js";
@ -151,10 +134,7 @@ export async function betterAuthDeleteEmailIndexMutation(c: any, input: { email:
await c.db.delete(authEmailIndex).where(eq(authEmailIndex.email, input.email)).run();
}
export async function betterAuthUpsertAccountIndexMutation(
c: any,
input: { id: string; providerId: string; accountId: string; userId: string },
) {
export async function betterAuthUpsertAccountIndexMutation(c: any, input: { id: string; providerId: string; accountId: string; userId: string }) {
assertAppOrganization(c);
const now = Date.now();
@ -198,8 +178,15 @@ export async function betterAuthDeleteAccountIndexMutation(c: any, input: { id?:
export async function betterAuthCreateVerificationMutation(c: any, input: { data: Record<string, unknown> }) {
assertAppOrganization(c);
await c.db.insert(authVerification).values(input.data as any).run();
return await c.db.select().from(authVerification).where(eq(authVerification.id, input.data.id as string)).get();
await c.db
.insert(authVerification)
.values(input.data as any)
.run();
return await c.db
.select()
.from(authVerification)
.where(eq(authVerification.id, input.data.id as string))
.get();
}
export async function betterAuthUpdateVerificationMutation(c: any, input: { where: any[]; update: Record<string, unknown> }) {
@ -209,7 +196,11 @@ export async function betterAuthUpdateVerificationMutation(c: any, input: { wher
if (!predicate) {
return null;
}
await c.db.update(authVerification).set(input.update as any).where(predicate).run();
await c.db
.update(authVerification)
.set(input.update as any)
.where(predicate)
.run();
return await c.db.select().from(authVerification).where(predicate).get();
}
@ -220,7 +211,11 @@ export async function betterAuthUpdateManyVerificationMutation(c: any, input: {
if (!predicate) {
return 0;
}
await c.db.update(authVerification).set(input.update as any).where(predicate).run();
await c.db
.update(authVerification)
.set(input.update as any)
.where(predicate)
.run();
const row = await c.db.select({ value: sqlCount() }).from(authVerification).where(predicate).get();
return row?.value ?? 0;
}
@ -247,7 +242,71 @@ export async function betterAuthDeleteManyVerificationMutation(c: any, input: {
return rows.length;
}
/**
* Better Auth adapter actions exposed as actions (not queue commands) so they
* execute immediately without competing in the organization workflow queue.
*
* The org actor's workflow queue is shared with GitHub sync, webhook processing,
* task mutations, and billing operations. When the queue is busy, auth operations
* would time out (10s), causing Better Auth's parseState to throw a non-StateError
* which redirects to ?error=please_restart_the_process.
*
* Auth operations are safe to run as actions because they are simple SQLite
* reads/writes scoped to this actor instance with no cross-actor side effects.
*/
export const organizationBetterAuthActions = {
// --- Mutation actions (formerly queue commands) ---
async betterAuthCreateVerification(c: any, input: { data: Record<string, unknown> }) {
return await betterAuthCreateVerificationMutation(c, input);
},
async betterAuthUpdateVerification(c: any, input: { where: any[]; update: Record<string, unknown> }) {
return await betterAuthUpdateVerificationMutation(c, input);
},
async betterAuthUpdateManyVerification(c: any, input: { where: any[]; update: Record<string, unknown> }) {
return await betterAuthUpdateManyVerificationMutation(c, input);
},
async betterAuthDeleteVerification(c: any, input: { where: any[] }) {
await betterAuthDeleteVerificationMutation(c, input);
return { ok: true };
},
async betterAuthDeleteManyVerification(c: any, input: { where: any[] }) {
return await betterAuthDeleteManyVerificationMutation(c, input);
},
async betterAuthUpsertSessionIndex(c: any, input: { sessionId: string; sessionToken: string; userId: string }) {
return await betterAuthUpsertSessionIndexMutation(c, input);
},
async betterAuthDeleteSessionIndex(c: any, input: { sessionId?: string; sessionToken?: string }) {
await betterAuthDeleteSessionIndexMutation(c, input);
return { ok: true };
},
async betterAuthUpsertEmailIndex(c: any, input: { email: string; userId: string }) {
return await betterAuthUpsertEmailIndexMutation(c, input);
},
async betterAuthDeleteEmailIndex(c: any, input: { email: string }) {
await betterAuthDeleteEmailIndexMutation(c, input);
return { ok: true };
},
async betterAuthUpsertAccountIndex(c: any, input: { id: string; providerId: string; accountId: string; userId: string }) {
return await betterAuthUpsertAccountIndexMutation(c, input);
},
async betterAuthDeleteAccountIndex(c: any, input: { id?: string; providerId?: string; accountId?: string }) {
await betterAuthDeleteAccountIndexMutation(c, input);
return { ok: true };
},
// --- Read actions ---
async betterAuthFindSessionIndex(c: any, input: { sessionId?: string; sessionToken?: string }) {
assertAppOrganization(c);

View file

@ -7,17 +7,6 @@ export const ORGANIZATION_QUEUE_NAMES = [
"organization.command.refreshTaskSummaryForBranch",
"organization.command.snapshot.broadcast",
"organization.command.syncGithubSession",
"organization.command.better_auth.session_index.upsert",
"organization.command.better_auth.session_index.delete",
"organization.command.better_auth.email_index.upsert",
"organization.command.better_auth.email_index.delete",
"organization.command.better_auth.account_index.upsert",
"organization.command.better_auth.account_index.delete",
"organization.command.better_auth.verification.create",
"organization.command.better_auth.verification.update",
"organization.command.better_auth.verification.update_many",
"organization.command.better_auth.verification.delete",
"organization.command.better_auth.verification.delete_many",
"organization.command.github.sync_progress.apply",
"organization.command.github.webhook_receipt.record",
"organization.command.github.organization_shell.sync_from_github",

View file

@ -20,19 +20,6 @@ import {
registerTaskBranchMutation,
removeTaskSummaryMutation,
} from "./actions/task-mutations.js";
import {
betterAuthCreateVerificationMutation,
betterAuthDeleteAccountIndexMutation,
betterAuthDeleteEmailIndexMutation,
betterAuthDeleteManyVerificationMutation,
betterAuthDeleteSessionIndexMutation,
betterAuthDeleteVerificationMutation,
betterAuthUpdateManyVerificationMutation,
betterAuthUpdateVerificationMutation,
betterAuthUpsertAccountIndexMutation,
betterAuthUpsertEmailIndexMutation,
betterAuthUpsertSessionIndexMutation,
} from "./actions/better-auth.js";
import {
applyOrganizationFreePlanMutation,
applyOrganizationStripeCustomerMutation,
@ -85,31 +72,6 @@ const COMMAND_HANDLERS: Record<OrganizationQueueName, WorkflowHandler> = {
return { ok: true };
},
// Better Auth index mutations
"organization.command.better_auth.session_index.upsert": async (c, body) => betterAuthUpsertSessionIndexMutation(c, body),
"organization.command.better_auth.session_index.delete": async (c, body) => {
await betterAuthDeleteSessionIndexMutation(c, body);
return { ok: true };
},
"organization.command.better_auth.email_index.upsert": async (c, body) => betterAuthUpsertEmailIndexMutation(c, body),
"organization.command.better_auth.email_index.delete": async (c, body) => {
await betterAuthDeleteEmailIndexMutation(c, body);
return { ok: true };
},
"organization.command.better_auth.account_index.upsert": async (c, body) => betterAuthUpsertAccountIndexMutation(c, body),
"organization.command.better_auth.account_index.delete": async (c, body) => {
await betterAuthDeleteAccountIndexMutation(c, body);
return { ok: true };
},
"organization.command.better_auth.verification.create": async (c, body) => betterAuthCreateVerificationMutation(c, body),
"organization.command.better_auth.verification.update": async (c, body) => betterAuthUpdateVerificationMutation(c, body),
"organization.command.better_auth.verification.update_many": async (c, body) => betterAuthUpdateManyVerificationMutation(c, body),
"organization.command.better_auth.verification.delete": async (c, body) => {
await betterAuthDeleteVerificationMutation(c, body);
return { ok: true };
},
"organization.command.better_auth.verification.delete_many": async (c, body) => betterAuthDeleteManyVerificationMutation(c, body),
// GitHub sync mutations
"organization.command.github.sync_progress.apply": async (c, body) => {
await applyGithubSyncProgressMutation(c, body);

View file

@ -1,7 +1,51 @@
import { asc, count as sqlCount, desc } from "drizzle-orm";
import { applyJoinToRow, applyJoinToRows, buildWhere, columnFor, tableFor } from "../query-helpers.js";
import {
createAuthRecordMutation,
updateAuthRecordMutation,
updateManyAuthRecordsMutation,
deleteAuthRecordMutation,
deleteManyAuthRecordsMutation,
} from "../workflow.js";
/**
* Better Auth adapter actions exposed as actions (not queue commands) so they
* execute immediately without competing in the user workflow queue.
*
* The user actor's workflow queue is shared with profile upserts, session state,
* and task state operations. When the queue is busy, auth operations would time
* out (10s), causing Better Auth's parseState to throw a non-StateError which
* redirects to ?error=please_restart_the_process.
*
* Auth operations are safe to run as actions because they are simple SQLite
* reads/writes scoped to this actor instance with no cross-actor side effects.
*/
export const betterAuthActions = {
// --- Mutation actions (formerly queue commands) ---
async betterAuthCreateRecord(c: any, input: { model: string; data: Record<string, unknown> }) {
return await createAuthRecordMutation(c, input);
},
async betterAuthUpdateRecord(c: any, input: { model: string; where: any[]; update: Record<string, unknown> }) {
return await updateAuthRecordMutation(c, input);
},
async betterAuthUpdateManyRecords(c: any, input: { model: string; where: any[]; update: Record<string, unknown> }) {
return await updateManyAuthRecordsMutation(c, input);
},
async betterAuthDeleteRecord(c: any, input: { model: string; where: any[] }) {
await deleteAuthRecordMutation(c, input);
return { ok: true };
},
async betterAuthDeleteManyRecords(c: any, input: { model: string; where: any[] }) {
return await deleteManyAuthRecordsMutation(c, input);
},
// --- Read actions ---
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async betterAuthFindOneRecord(c, input: { model: string; where: any[]; join?: any }) {

View file

@ -19,11 +19,6 @@ import { buildWhere, columnFor, materializeRow, persistInput, persistPatch, tabl
// ---------------------------------------------------------------------------
export const USER_QUEUE_NAMES = [
"user.command.auth.create",
"user.command.auth.update",
"user.command.auth.update_many",
"user.command.auth.delete",
"user.command.auth.delete_many",
"user.command.profile.upsert",
"user.command.session_state.upsert",
"user.command.task_state.upsert",
@ -240,14 +235,6 @@ export async function deleteTaskStateMutation(c: any, input: { taskId: string; s
type WorkflowHandler = (loopCtx: any, body: any) => Promise<any>;
const COMMAND_HANDLERS: Record<UserQueueName, WorkflowHandler> = {
"user.command.auth.create": async (c, body) => createAuthRecordMutation(c, body),
"user.command.auth.update": async (c, body) => updateAuthRecordMutation(c, body),
"user.command.auth.update_many": async (c, body) => updateManyAuthRecordsMutation(c, body),
"user.command.auth.delete": async (c, body) => {
await deleteAuthRecordMutation(c, body);
return { ok: true };
},
"user.command.auth.delete_many": async (c, body) => deleteManyAuthRecordsMutation(c, body),
"user.command.profile.upsert": async (c, body) => upsertUserProfileMutation(c, body),
"user.command.session_state.upsert": async (c, body) => upsertSessionStateMutation(c, body),
"user.command.task_state.upsert": async (c, body) => upsertTaskStateMutation(c, body),