From cc956c153072623a7f6693ef19a0fc27d1a1bc43 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 28 Oct 2026 18:13:26 -0700 Subject: [PATCH 1/2] fix(billing): serialize Gmail inbox allowance decisions so racing alias signups can't both keep it --- .../lib/admin/dashboard-credit-grant.test.ts | 10 +- apps/sim/lib/admin/dashboard.ts | 4 +- apps/sim/lib/billing/core/usage.test.ts | 2 + apps/sim/lib/billing/core/usage.ts | 67 +++++----- apps/sim/lib/users/free-allowance.ts | 21 ++++ apps/sim/lib/users/gmail-alias.integration.ts | 92 ++++++++++++-- apps/sim/lib/users/gmail-alias.ts | 115 +++++++++++++----- packages/testing/src/mocks/schema.mock.ts | 2 + 8 files changed, 228 insertions(+), 85 deletions(-) create mode 100644 apps/sim/lib/users/free-allowance.ts diff --git a/apps/sim/lib/admin/dashboard-credit-grant.test.ts b/apps/sim/lib/admin/dashboard-credit-grant.test.ts index 1f2a5b2164f..8e4072d79e2 100644 --- a/apps/sim/lib/admin/dashboard-credit-grant.test.ts +++ b/apps/sim/lib/admin/dashboard-credit-grant.test.ts @@ -21,7 +21,7 @@ const hoistedMocks = vi.hoisted(() => ({ billingSubscriptions: [] as unknown[], idempotencyCalls: [] as { namespace: string; requestFingerprint: string }[], moveWorkspace: vi.fn(), - userHasDeletedAccountIdentity: vi.fn(async () => false), + identityHadFreeAllowance: vi.fn(async () => false), })) vi.mock('@sim/audit', () => auditMock) @@ -54,8 +54,8 @@ vi.mock('@/lib/billing/enterprise-outbox', () => ({ resolveEnterpriseMetadataIntent: vi.fn(), })) vi.mock('@/lib/core/outbox/service', () => outboxServiceMock) -vi.mock('@/lib/users/deleted-identity', () => ({ - userHasDeletedAccountIdentity: hoistedMocks.userHasDeletedAccountIdentity, +vi.mock('@/lib/users/free-allowance', () => ({ + identityHadFreeAllowance: hoistedMocks.identityHadFreeAllowance, })) import { grantDashboardOrganizationBalance, grantDashboardUserBalance } from '@/lib/admin/dashboard' @@ -154,8 +154,8 @@ describe('grantDashboardUserBalance', () => { ) }) - it('creates a missing stats row without the free allowance for a returning deleted identity', async () => { - mocks.userHasDeletedAccountIdentity.mockResolvedValueOnce(true) + it('creates a missing stats row without the free allowance for an identity that already had it', async () => { + mocks.identityHadFreeAllowance.mockResolvedValueOnce(true) queueTableRows(user, [{ id: 'user-1' }]) queueTableRows(userStats, [ { creditBalance: '0', currentUsageLimit: '0', freeTierEligible: false }, diff --git a/apps/sim/lib/admin/dashboard.ts b/apps/sim/lib/admin/dashboard.ts index cca7cf7102a..692aa874028 100644 --- a/apps/sim/lib/admin/dashboard.ts +++ b/apps/sim/lib/admin/dashboard.ts @@ -78,7 +78,7 @@ import { executeTransactionallyIdempotent } from '@/lib/core/idempotency/transac import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import type { DbOrTx } from '@/lib/db/types' import { deliverRunStops } from '@/lib/mothership/request/application/stop-delivery' -import { userHasDeletedAccountIdentity } from '@/lib/users/deleted-identity' +import { identityHadFreeAllowance } from '@/lib/users/free-allowance' import { ownedAttachableWorkspacesWhere } from '@/lib/workspaces/organization-workspaces' interface PaginationInput { @@ -1503,7 +1503,7 @@ export async function grantDashboardUserBalance( * the same email check as every other row creation. Looked up before the * transaction so it never holds a second pooled connection inside it. */ - const freeTierEligibleIfCreated = !(await userHasDeletedAccountIdentity(userId, { + const freeTierEligibleIfCreated = !(await identityHadFreeAllowance(userId, { includeSignIns: false, })) const outcome = await db.transaction(async (tx) => { diff --git a/apps/sim/lib/billing/core/usage.test.ts b/apps/sim/lib/billing/core/usage.test.ts index 06de309074b..897b2cb1c46 100644 --- a/apps/sim/lib/billing/core/usage.test.ts +++ b/apps/sim/lib/billing/core/usage.test.ts @@ -60,6 +60,8 @@ vi.mock('@/lib/users/deleted-identity', () => ({ })) vi.mock('@/lib/users/gmail-alias', () => ({ + acquireGmailInboxLock: vi.fn(async () => {}), + laterGmailAliasesWithAllowance: vi.fn(async () => []), userHasGmailAliasAccount: vi.fn(async () => false), })) diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 4cf0fed5fc2..c18df4cd308 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -46,11 +46,9 @@ import { isBillingEnabled } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import type { DbClient, DbOrTx } from '@/lib/db/types' import { APP_ENTRY_PATH } from '@/lib/navigation/paths' -import { - isDeletedAccountIdentity, - userHasDeletedAccountIdentity, -} from '@/lib/users/deleted-identity' -import { userHasGmailAliasAccount } from '@/lib/users/gmail-alias' +import { isDeletedAccountIdentity } from '@/lib/users/deleted-identity' +import { identityHadFreeAllowance } from '@/lib/users/free-allowance' +import { acquireGmailInboxLock, laterGmailAliasesWithAllowance } from '@/lib/users/gmail-alias' const logger = createLogger('UsageManagement') @@ -192,22 +190,6 @@ function calculateOrgUsageLimit( */ const usersCreatedInAuthRequest = new WeakMap() -/** - * Whether a new user's identity already had the free allowance: its email or a - * linked sign-in (with `includeSignIns`) belonged to a recently deleted account, - * or another account already holds its Gmail inbox. - */ -async function identityHadFreeAllowance( - userId: string, - { includeSignIns }: { includeSignIns: boolean }, - executor: DbOrTx = db -): Promise { - return ( - (await userHasDeletedAccountIdentity(userId, { includeSignIns }, executor)) || - (await userHasGmailAliasAccount(userId, executor)) - ) -} - function isUserCreatedInAuthRequest( userId: string, authRequest: object | null | undefined @@ -219,8 +201,9 @@ function isUserCreatedInAuthRequest( * Handle new user setup when they join the platform. * Creates the userStats record with the free allowance, which is zero when the * user's email or an already-linked social sign-in belongs to an account - * deleted within the retention window, or when another account holds its Gmail - * inbox. Every user Better Auth creates comes through here, including SCIM- and + * deleted within the retention window, or when an earlier account holds its + * Gmail inbox; it decides under that inbox's lock (see `lib/users/gmail-alias`). + * Every user Better Auth creates comes through here, including SCIM- and * SSO-provisioned users, so their email is always checked at creation. * * @param authRequest Better Auth's endpoint context for the signup request, when there is one @@ -228,18 +211,28 @@ function isUserCreatedInAuthRequest( export async function handleNewUser(userId: string, authRequest?: object | null): Promise { if (authRequest) usersCreatedInAuthRequest.set(authRequest, userId) try { - const freeTierEligible = !(await identityHadFreeAllowance(userId, { includeSignIns: true })) - if (!freeTierEligible) { - logger.info('Withholding the free allowance from an identity that already had it', { + await db.transaction(async (tx) => { + await acquireGmailInboxLock(tx, userId) + const freeTierEligible = !(await identityHadFreeAllowance( userId, + { includeSignIns: true }, + tx + )) + if (!freeTierEligible) { + logger.info('Withholding the free allowance from an identity that already had it', { + userId, + }) + } + await tx.insert(userStats).values({ + id: generateId(), + userId: userId, + currentUsageLimit: getFreeTierAllowance(freeTierEligible).toString(), + freeTierEligible, + usageLimitUpdatedAt: new Date(), }) - } - await db.insert(userStats).values({ - id: generateId(), - userId: userId, - currentUsageLimit: getFreeTierAllowance(freeTierEligible).toString(), - freeTierEligible, - usageLimitUpdatedAt: new Date(), + for (const aliasId of await laterGmailAliasesWithAllowance(userId, tx)) { + await forfeitFreeTierAllowance(tx, aliasId) + } }) logger.info('User stats record created for new user', { userId }) @@ -305,10 +298,10 @@ export async function ensureUserStatsExists( /** * Creates a user's `user_stats` row with the free allowance when none exists, on `executor` - * (the caller's transaction, or `db`). Only a row this call creates gets the deleted-identity - * check signup makes: the email always, and the linked sign-ins only when `includeSignIns` is - * set. An existing row costs only the conflicting insert. A failed check leaves the allowance in - * place and never fails the caller or aborts its transaction. + * (the caller's transaction, or `db`). Only a row this call creates gets the check signup makes: + * the email always, and the linked sign-ins only when `includeSignIns` is set. An existing row + * costs only the conflicting insert. A failed check leaves the allowance in place and never fails + * the caller or aborts its transaction. * * @returns whether this call created the row */ diff --git a/apps/sim/lib/users/free-allowance.ts b/apps/sim/lib/users/free-allowance.ts new file mode 100644 index 00000000000..d8fbb781e34 --- /dev/null +++ b/apps/sim/lib/users/free-allowance.ts @@ -0,0 +1,21 @@ +import { db } from '@sim/db' +import type { DbOrTx } from '@/lib/db/types' +import { userHasDeletedAccountIdentity } from '@/lib/users/deleted-identity' +import { userHasGmailAliasAccount } from '@/lib/users/gmail-alias' + +/** + * Whether a new user's identity already had the free allowance: its email or a + * linked sign-in (with `includeSignIns`) belonged to a recently deleted account, + * or an earlier account holds its Gmail inbox. Every path that creates a user's + * stats row decides its allowance with this. + */ +export async function identityHadFreeAllowance( + userId: string, + { includeSignIns }: { includeSignIns: boolean }, + executor: DbOrTx = db +): Promise { + return ( + (await userHasDeletedAccountIdentity(userId, { includeSignIns }, executor)) || + (await userHasGmailAliasAccount(userId, executor)) + ) +} diff --git a/apps/sim/lib/users/gmail-alias.integration.ts b/apps/sim/lib/users/gmail-alias.integration.ts index 226219d25ee..cc75d4a680f 100644 --- a/apps/sim/lib/users/gmail-alias.integration.ts +++ b/apps/sim/lib/users/gmail-alias.integration.ts @@ -1,17 +1,21 @@ /** * A dot, `+tag`, or `googlemail.com` variant of an existing Gmail address must not get a second - * free allowance. Runs against real PostgreSQL through the production signup-stats and - * fallback-stats paths, and checks the lookup is served by `user_gmail_mailbox_idx`. + * free allowance, even when two signups on one inbox race. Runs against real PostgreSQL through + * the production signup-stats and fallback-stats paths, and checks the lookup is served by + * `user_gmail_mailbox_idx`. */ import { db } from '@sim/db' import { gmailMailbox, user, userStats } from '@sim/db/schema' +import { createDeferred } from '@sim/testing' import { authMock } from '@sim/testing/mocks/auth.mock' import { envFlagsMock } from '@sim/testing/mocks/env-flags.mock' import { generateId } from '@sim/utils/id' import { eq, inArray, sql } from 'drizzle-orm' +import { alias } from 'drizzle-orm/pg-core' import { afterAll, describe, expect, it, vi } from 'vitest' import { ensureUserStatsExists, handleNewUser } from '@/lib/billing/core/usage' import { getFreeTierLimit } from '@/lib/billing/subscriptions/utils' +import { acquireGmailInboxLock } from '@/lib/users/gmail-alias' vi.mock('@/lib/core/config/env-flags', () => ({ ...envFlagsMock, @@ -60,6 +64,11 @@ async function statsOf(userId: string) { return { freeTierEligible: row?.freeTierEligible, limit: Number(row?.currentUsageLimit) } } +/** An account created a minute ago, so any account inserted after it counts as later. */ +function insertEarlierUser(email: string): Promise { + return insertUser(email, { createdAt: new Date(Date.now() - 60_000) }) +} + /** A Gmail local part no other test run can collide with. */ function uniqueLocalPart(): string { return `alias${generateId().replaceAll('-', '')}` @@ -127,18 +136,16 @@ describe('Gmail inboxes and the free allowance', () => { it('withholds it from a stats row the fallback creates after signup failed to', async () => { const local = uniqueLocalPart() await signUp(`${local}@gmail.com`) - const alias = await insertUser(`${local.slice(0, 3)}.${local.slice(3)}@gmail.com`) + const later = await insertUser(`${local.slice(0, 3)}.${local.slice(3)}@gmail.com`) - await ensureUserStatsExists(alias) + await ensureUserStatsExists(later) - expect(await statsOf(alias)).toEqual({ freeTierEligible: false, limit: 0 }) + expect(await statsOf(later)).toEqual({ freeTierEligible: false, limit: 0 }) }) it('keeps the allowance with the inbox’s first account when its stats row comes after an alias’s', async () => { const local = uniqueLocalPart() - const original = await insertUser(`${local}@gmail.com`, { - createdAt: new Date(Date.now() - 60_000), - }) + const original = await insertEarlierUser(`${local}@gmail.com`) const alias = await signUp(`${local}+later@gmail.com`) await ensureUserStatsExists(original) @@ -164,11 +171,78 @@ describe('Gmail inboxes and the free allowance', () => { expect(eligible.sort()).toEqual([false, true]) }) + it('withdraws it from an alias whose signup decided before the earlier account was visible', async () => { + const local = uniqueLocalPart() + const later = await signUp(`${local}+fast@gmail.com`) + const original = await insertEarlierUser(`${local}@gmail.com`) + + await handleNewUser(original, {}) + + expect(await statsOf(original)).toEqual({ freeTierEligible: true, limit: getFreeTierLimit() }) + expect(await statsOf(later)).toEqual({ freeTierEligible: false, limit: 0 }) + }) + + it('never withdraws it from a later alias when the fallback creates the earlier account’s row', async () => { + const local = uniqueLocalPart() + const original = await insertEarlierUser(`${local}@gmail.com`) + const later = await signUp(`${local}+later@gmail.com`) + await db.update(userStats).set({ freeTierEligible: true }).where(eq(userStats.userId, later)) + + await ensureUserStatsExists(original) + + expect(await statsOf(original)).toEqual({ freeTierEligible: true, limit: getFreeTierLimit() }) + expect((await statsOf(later)).freeTierEligible).toBe(true) + }) + + it('makes a racing signup on the same inbox wait for the inbox lock', async () => { + const local = uniqueLocalPart() + const original = await insertEarlierUser(`${local}@gmail.com`) + const racer = await insertUser(`${local.slice(0, 4)}.${local.slice(4)}@gmail.com`) + const held = createDeferred() + const release = createDeferred() + const pending: Promise[] = [] + try { + const holder = db.transaction(async (tx) => { + await acquireGmailInboxLock(tx, original) + const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + held.resolve(connection.pid) + await release.promise + }) + pending.push(holder) + void holder.catch((error: unknown) => held.reject(error)) + const blockerPid = await held.promise + const signup = handleNewUser(racer, {}) + pending.push(signup) + + await vi.waitFor( + async () => { + const [waiting] = await db.execute<{ pid: number }>(sql` + SELECT pid FROM pg_stat_activity + WHERE datname = current_database() + AND ${blockerPid}::int = ANY(pg_blocking_pids(pid)) + `) + expect(waiting).toBeDefined() + }, + { timeout: 5_000, interval: 25 } + ) + expect((await statsOf(racer)).freeTierEligible).toBeUndefined() + + release.resolve() + await holder + await signup + expect(await statsOf(racer)).toEqual({ freeTierEligible: false, limit: 0 }) + } finally { + release.resolve() + await Promise.allSettled(pending) + } + }) + it('finds the inbox through user_gmail_mailbox_idx rather than a scan of user', async () => { + const subject = alias(user, 'subject') const plan = await db.transaction(async (tx) => { await tx.execute(sql`SET LOCAL enable_seqscan = off`) const rows = await tx.execute<{ 'QUERY PLAN': string }>( - sql`EXPLAIN SELECT ${user.id} FROM ${user} WHERE ${gmailMailbox(user.email)} = ${`${uniqueLocalPart()}@gmail.com`} LIMIT 1` + sql`EXPLAIN SELECT ${user.id} FROM ${user} AS "subject" JOIN ${user} ON ${gmailMailbox(user.email)} = ${gmailMailbox(subject.email)} WHERE ${subject.id} = ${generateId()}` ) return rows.map((row) => row['QUERY PLAN']).join('\n') }) diff --git a/apps/sim/lib/users/gmail-alias.ts b/apps/sim/lib/users/gmail-alias.ts index c2f5df30458..87043ca383d 100644 --- a/apps/sim/lib/users/gmail-alias.ts +++ b/apps/sim/lib/users/gmail-alias.ts @@ -1,9 +1,11 @@ import { db } from '@sim/db' -import { gmailMailbox, user } from '@sim/db/schema' +import { gmailMailbox, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, lt, or, sql } from 'drizzle-orm' +import { and, eq, gt, lt, or, sql } from 'drizzle-orm' +import { alias } from 'drizzle-orm/pg-core' import { isBillingEnabled } from '@/lib/core/config/env-flags' -import type { DbOrTx } from '@/lib/db/types' +import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks' +import type { DbOrTx, DbTransaction } from '@/lib/db/types' /** * One free allowance per Gmail inbox. Gmail delivers every dot and `+tag` @@ -12,24 +14,67 @@ import type { DbOrTx } from '@/lib/db/types' * for a fresh allowance each time. The later account is created normally but * without the free allowance, like a returning deleted-account identity. * + * Two signups on one inbox can race: Better Auth commits each user row before + * its signup hook runs, so a later account's check can run before the earlier + * row is visible. Each signup therefore decides under the inbox's lock + * ({@link acquireGmailInboxLock}) and, once the earlier account decides, it + * withdraws the allowance from any later one that got in first + * ({@link laterGmailAliasesWithAllowance}). The stats-row fallback, which runs + * only when a signup's own decision failed, checks without the lock rather + * than hold it through its caller's transaction. + * * Only meaningful where billing is enabled. */ const logger = createLogger('GmailAlias') +const GMAIL_INBOX_LOCK_TIMEOUT_MS = 5_000 + +/** The account being decided; `user` in the queries below is every other account. */ +const subject = alias(user, 'subject') + +const onSubjectInbox = sql`${gmailMailbox(user.email)} = ${gmailMailbox(subject.email)}` + +/** Orders accounts by creation time; the id breaks a creation-time tie. */ +function createdRelativeToSubject(compare: typeof lt | typeof gt) { + return or( + compare(user.createdAt, subject.createdAt), + and(eq(user.createdAt, subject.createdAt), compare(user.id, subject.id)) + ) +} + +/** + * Serializes free-allowance decisions for the Gmail inbox this user's address + * delivers to, until `tx` ends. Takes no lock for any other address. Bounds + * `lock_timeout` for the rest of `tx`, so take it in a short transaction of + * its own. + */ +export async function acquireGmailInboxLock(tx: DbTransaction, userId: string): Promise { + if (!isBillingEnabled) return + const [row] = await tx + .select({ mailbox: gmailMailbox(user.email) }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + if (!row?.mailbox) return + await tx.execute( + sql`select set_config('lock_timeout', ${`${GMAIL_INBOX_LOCK_TIMEOUT_MS}ms`}, true)` + ) + await acquireAdvisoryXactLock(tx, 'gmail_inbox', `gmail-inbox:${row.mailbox}`) +} + /** * Whether an earlier account, banned or not, already holds the Gmail inbox this - * user's address delivers to. Only an account created before this one counts - * (the id breaks a creation-time tie), so the inbox's first account keeps the - * allowance however late its own stats row is created. The earlier account - * counts whether or not it verified the address: the allowance is granted at - * signup, before verification, and where verification is off a password - * account never verifies, so a verified-only rule would let every alias - * registered unverified keep its allowance. Pass the caller's transaction as - * `executor` to read on it; the read then runs in a savepoint, so a failed - * lookup never aborts that transaction. Fails open: a lookup error returns - * `false` and is logged, because refusing a legitimate person's allowance is - * worse than one missed match. + * user's address delivers to. Only an account created before this one counts, + * so the inbox's first account keeps the allowance however late its own stats + * row is created. The earlier account counts whether or not it verified the + * address: the allowance is granted at signup, before verification, and where + * verification is off a password account never verifies, so a verified-only + * rule would let every alias registered unverified keep its allowance. Pass the + * caller's transaction as `executor` to read on it; the read then runs in a + * savepoint, so a failed lookup never aborts that transaction. Fails open: a + * lookup error returns `false` and is logged, because refusing a legitimate + * person's allowance is worse than one missed match. */ export async function userHasGmailAliasAccount( userId: string, @@ -37,26 +82,13 @@ export async function userHasGmailAliasAccount( ): Promise { if (!isBillingEnabled) return false const read = async (reader: DbOrTx) => { - const [self] = await reader - .select({ mailbox: gmailMailbox(user.email), createdAt: user.createdAt }) - .from(user) - .where(eq(user.id, userId)) - .limit(1) - if (!self?.mailbox) return false - const [other] = await reader + const [earlier] = await reader .select({ id: user.id }) - .from(user) - .where( - and( - sql`${gmailMailbox(user.email)} = ${self.mailbox}`, - or( - lt(user.createdAt, self.createdAt), - and(eq(user.createdAt, self.createdAt), lt(user.id, userId)) - ) - ) - ) + .from(subject) + .innerJoin(user, and(onSubjectInbox, createdRelativeToSubject(lt))) + .where(eq(subject.id, userId)) .limit(1) - return Boolean(other) + return Boolean(earlier) } try { return await (executor === db ? read(db) : executor.transaction(read)) @@ -65,3 +97,22 @@ export async function userHasGmailAliasAccount( return false } } + +/** + * The later accounts on this user's Gmail inbox that still hold the free + * allowance: ones whose own check ran before this user's row was visible. + * Called by a new user's signup under {@link acquireGmailInboxLock}. + */ +export async function laterGmailAliasesWithAllowance( + userId: string, + executor: DbOrTx +): Promise { + if (!isBillingEnabled) return [] + const later = await executor + .select({ id: user.id }) + .from(subject) + .innerJoin(user, and(onSubjectInbox, createdRelativeToSubject(gt))) + .innerJoin(userStats, and(eq(userStats.userId, user.id), eq(userStats.freeTierEligible, true))) + .where(eq(subject.id, userId)) + return later.map((row) => row.id) +} diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 0d2f22b534e..0d51eb2ebab 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -85,6 +85,8 @@ export const schemaMock = { foldedEmail: (column: unknown) => column, /** The schema's display-name expression; returns its column, like {@link foldedEmail}. */ displaySegmentKey: (column: unknown) => column, + /** The schema's Gmail inbox expression; returns its column, like {@link foldedEmail}. */ + gmailMailbox: (column: unknown) => column, /** Custom column type for tsvector. */ tsvector: 'tsvector', /** Custom column type for bytea. */ From 14b5fb2c73c21abe709e5bac15d20c8c6850fc30 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 28 Oct 2026 18:21:56 -0700 Subject: [PATCH 2/2] fix(billing): restore lock_timeout after taking the Gmail inbox lock --- apps/sim/lib/users/free-allowance.ts | 4 ++-- apps/sim/lib/users/gmail-alias.ts | 13 ++++++------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/sim/lib/users/free-allowance.ts b/apps/sim/lib/users/free-allowance.ts index d8fbb781e34..225f826f491 100644 --- a/apps/sim/lib/users/free-allowance.ts +++ b/apps/sim/lib/users/free-allowance.ts @@ -6,8 +6,8 @@ import { userHasGmailAliasAccount } from '@/lib/users/gmail-alias' /** * Whether a new user's identity already had the free allowance: its email or a * linked sign-in (with `includeSignIns`) belonged to a recently deleted account, - * or an earlier account holds its Gmail inbox. Every path that creates a user's - * stats row decides its allowance with this. + * or an earlier account holds its Gmail inbox. Signup, the stats-row fallback, + * and the admin balance grant decide a new row's allowance with this. */ export async function identityHadFreeAllowance( userId: string, diff --git a/apps/sim/lib/users/gmail-alias.ts b/apps/sim/lib/users/gmail-alias.ts index 87043ca383d..272555312f4 100644 --- a/apps/sim/lib/users/gmail-alias.ts +++ b/apps/sim/lib/users/gmail-alias.ts @@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq, gt, lt, or, sql } from 'drizzle-orm' import { alias } from 'drizzle-orm/pg-core' import { isBillingEnabled } from '@/lib/core/config/env-flags' -import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks' +import { acquireAdvisoryXactLock, withLockTimeout } from '@/lib/db/advisory-locks' import type { DbOrTx, DbTransaction } from '@/lib/db/types' /** @@ -45,9 +45,8 @@ function createdRelativeToSubject(compare: typeof lt | typeof gt) { /** * Serializes free-allowance decisions for the Gmail inbox this user's address - * delivers to, until `tx` ends. Takes no lock for any other address. Bounds - * `lock_timeout` for the rest of `tx`, so take it in a short transaction of - * its own. + * delivers to, until `tx` ends. Takes no lock for any other address. The wait + * is bounded by its own `lock_timeout`, restored once the lock is held. */ export async function acquireGmailInboxLock(tx: DbTransaction, userId: string): Promise { if (!isBillingEnabled) return @@ -57,10 +56,10 @@ export async function acquireGmailInboxLock(tx: DbTransaction, userId: string): .where(eq(user.id, userId)) .limit(1) if (!row?.mailbox) return - await tx.execute( - sql`select set_config('lock_timeout', ${`${GMAIL_INBOX_LOCK_TIMEOUT_MS}ms`}, true)` + const mailbox = row.mailbox + await withLockTimeout(tx, GMAIL_INBOX_LOCK_TIMEOUT_MS, () => + acquireAdvisoryXactLock(tx, 'gmail_inbox', `gmail-inbox:${mailbox}`) ) - await acquireAdvisoryXactLock(tx, 'gmail_inbox', `gmail-inbox:${row.mailbox}`) } /**