Skip to content

Commit 277e794

Browse files
committed
fix(workspaces): defer telemetry until commit, avoid second pooled connection mid-transaction
createWorkspaceRecord now only fires workspaceCreated telemetry when it commits its own transaction; callers passing a nested executor (the fallback-provisioning path) fire it themselves after their outer transaction commits, avoiding a phantom event for a workspace that gets rolled back. getActivelyBannedUserIds now accepts an executor so the stranded-member check runs the banned-user lookup against the open transaction instead of borrowing a second pooled connection while the first sits idle-in-transaction.
1 parent 6f5e3a3 commit 277e794

6 files changed

Lines changed: 83 additions & 26 deletions

File tree

apps/sim/app/api/workspaces/route.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { createWorkspaceContract } from '@/lib/api/contracts/workspaces'
99
import { parseRequest } from '@/lib/api/server'
1010
import { getSession } from '@/lib/auth'
1111
import type { PlanCategory } from '@/lib/billing/plan-helpers'
12-
import { PlatformEvents } from '@/lib/core/telemetry'
1312
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1413
import { captureServerEvent } from '@/lib/posthog/server'
1514
import { type CreateWorkspaceRecordParams, createWorkspaceRecord } from '@/lib/workspaces/create'
@@ -282,16 +281,6 @@ async function createWorkspace({
282281
billedAccountUserId,
283282
})
284283

285-
try {
286-
PlatformEvents.workspaceCreated({
287-
workspaceId: record.id,
288-
userId,
289-
name,
290-
})
291-
} catch {
292-
// Telemetry should not fail the operation
293-
}
294-
295284
const invitePolicy = await getWorkspaceInvitePolicy({
296285
organizationId,
297286
workspaceMode,

apps/sim/lib/auth/ban.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { db, user } from '@sim/db'
22
import { inArray, sql } from 'drizzle-orm'
33
import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
4+
import type { DbOrTx } from '@/lib/db/types'
45

56
/**
67
* True when a ban is currently in effect. Mirrors better-auth admin-plugin
@@ -34,14 +35,22 @@ export async function isEmailBlocked(email: string | null | undefined): Promise<
3435
* active account ban, or an email/domain in the appconfig blocked lists.
3536
* One user query plus the cached access-control fetch. Throws on db
3637
* failure — callers must fail closed.
38+
*
39+
* Accepts an optional executor so callers already inside a transaction (e.g. a
40+
* workspace-archival safety check under `serializable` isolation) can run this
41+
* against `tx` instead of borrowing a second pooled connection while the first
42+
* sits idle-in-transaction.
3743
*/
38-
export async function getActivelyBannedUserIds(userIds: string[]): Promise<string[]> {
44+
export async function getActivelyBannedUserIds(
45+
userIds: string[],
46+
executor: DbOrTx = db
47+
): Promise<string[]> {
3948
const ids = [...new Set(userIds.filter(Boolean))]
4049
if (ids.length === 0) return []
4150

4251
const [accessControl, rows] = await Promise.all([
4352
getAccessControlConfig(),
44-
db
53+
executor
4554
.select({ id: user.id, email: user.email, banned: user.banned, banExpires: user.banExpires })
4655
.from(user)
4756
.where(inArray(user.id, ids)),

apps/sim/lib/workspaces/create.test.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,23 @@
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55

6-
const { mockTransaction, mockSaveWorkflowToNormalizedTables } = vi.hoisted(() => ({
7-
mockTransaction: vi.fn(),
8-
mockSaveWorkflowToNormalizedTables: vi.fn(),
9-
}))
6+
const { mockTransaction, mockSaveWorkflowToNormalizedTables, mockWorkspaceCreatedEvent } =
7+
vi.hoisted(() => ({
8+
mockTransaction: vi.fn(),
9+
mockSaveWorkflowToNormalizedTables: vi.fn(),
10+
mockWorkspaceCreatedEvent: vi.fn(),
11+
}))
1012

1113
vi.mock('@sim/db', () => ({
1214
db: {
1315
transaction: mockTransaction,
1416
},
1517
}))
1618

19+
vi.mock('@/lib/core/telemetry', () => ({
20+
PlatformEvents: { workspaceCreated: mockWorkspaceCreatedEvent },
21+
}))
22+
1723
vi.mock('@/lib/workflows/defaults', () => ({
1824
buildDefaultWorkflowArtifacts: () => ({ workflowState: { blocks: {}, edges: [] } }),
1925
}))
@@ -62,6 +68,12 @@ describe('createWorkspaceRecord', () => {
6268
expect(record.workspaceMode).toBe('personal')
6369
expect(tx.insert).toHaveBeenCalledTimes(3)
6470
expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledTimes(1)
71+
// Safe to fire immediately: this call committed its own transaction before returning.
72+
expect(mockWorkspaceCreatedEvent).toHaveBeenCalledWith({
73+
workspaceId: record.id,
74+
userId: 'user-1',
75+
name: 'My Workspace',
76+
})
6577
})
6678

6779
it('runs directly against a provided executor instead of opening a nested transaction', async () => {
@@ -78,6 +90,10 @@ describe('createWorkspaceRecord', () => {
7890

7991
expect(mockTransaction).not.toHaveBeenCalled()
8092
expect(tx.insert).toHaveBeenCalledTimes(3)
93+
// Must NOT fire here: the caller's outer transaction hasn't committed yet and could still
94+
// roll back, which would make this a phantom event for a workspace that never existed. The
95+
// caller owns firing this once its own transaction commits.
96+
expect(mockWorkspaceCreatedEvent).not.toHaveBeenCalled()
8197
})
8298

8399
it('skips the default workflow insert when skipDefaultWorkflow is set', async () => {

apps/sim/lib/workspaces/create.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
22
import { permissions, type WorkspaceMode, workflow, workspace } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { generateId } from '@sim/utils/id'
5+
import { PlatformEvents } from '@/lib/core/telemetry'
56
import type { DbOrTx } from '@/lib/db/types'
67
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
78
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
@@ -44,6 +45,10 @@ export interface CreatedWorkspaceRecord {
4445
* (unless skipped) a default starter workflow. Shared by the `POST /api/workspaces` route and
4546
* the workspace-archival safety net that auto-provisions a replacement workspace for a member
4647
* who would otherwise be left with zero workspaces.
48+
*
49+
* Fires the `workspaceCreated` telemetry event itself only when it manages its own transaction
50+
* (no `executor` passed). Callers that pass `executor` are joining an outer transaction that can
51+
* still roll back after this returns, so they own firing that event once their transaction commits.
4752
*/
4853
export async function createWorkspaceRecord({
4954
userId,
@@ -142,6 +147,18 @@ export async function createWorkspaceRecord({
142147
throw error
143148
}
144149

150+
// Only fire when this call committed its own transaction. When `executor` is a caller-supplied
151+
// `tx`, the insert isn't durable yet — the caller's transaction can still roll back after this
152+
// function returns, so firing here would risk a phantom event for a workspace that never
153+
// existed. Callers that pass `executor` must fire this themselves once their transaction commits.
154+
if (!executor) {
155+
try {
156+
PlatformEvents.workspaceCreated({ workspaceId, userId, name })
157+
} catch {
158+
// Telemetry should not fail the operation
159+
}
160+
}
161+
145162
return {
146163
id: workspaceId,
147164
name,

apps/sim/lib/workspaces/lifecycle.test.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ const {
1111
mockListAccessibleWorkspaceRowsForUser,
1212
mockCreateWorkspaceRecord,
1313
mockGetActivelyBannedUserIds,
14+
mockWorkspaceCreatedEvent,
1415
} = vi.hoisted(() => ({
1516
mockSelect: vi.fn(),
1617
mockTransaction: vi.fn(),
1718
mockArchiveWorkflowsForWorkspace: vi.fn(),
1819
mockListAccessibleWorkspaceRowsForUser: vi.fn(),
1920
mockCreateWorkspaceRecord: vi.fn(),
2021
mockGetActivelyBannedUserIds: vi.fn(),
22+
mockWorkspaceCreatedEvent: vi.fn(),
2123
}))
2224

2325
const mockGetWorkspaceWithOwner = permissionsMockFns.mockGetWorkspaceWithOwner
@@ -47,6 +49,10 @@ vi.mock('@/lib/auth/ban', () => ({
4749
getActivelyBannedUserIds: mockGetActivelyBannedUserIds,
4850
}))
4951

52+
vi.mock('@/lib/core/telemetry', () => ({
53+
PlatformEvents: { workspaceCreated: mockWorkspaceCreatedEvent },
54+
}))
55+
5056
vi.mock('@sim/audit', () => auditMock)
5157

5258
import { archiveWorkspace } from './lifecycle'
@@ -192,6 +198,11 @@ describe('workspace lifecycle', () => {
192198
}),
193199
})
194200
)
201+
expect(mockWorkspaceCreatedEvent).toHaveBeenCalledWith({
202+
workspaceId: 'fallback-workspace',
203+
userId: 'user-victim',
204+
name: 'My Workspace',
205+
})
195206
expect(tx.update).toHaveBeenCalledTimes(8)
196207
expect(tx.delete).toHaveBeenCalledTimes(1)
197208
})
@@ -247,10 +258,12 @@ describe('workspace lifecycle', () => {
247258
})
248259
).rejects.toThrow('serialization_failure')
249260

250-
// recordAudit must only ever be called after the transaction has committed — otherwise a
251-
// failed transaction (e.g. a serialization abort) would leave a phantom audit entry pointing
252-
// at a fallback workspace that was rolled back.
261+
// recordAudit and the workspaceCreated telemetry event must only ever fire after the
262+
// transaction has committed — otherwise a failed transaction (e.g. a serialization abort)
263+
// would leave a phantom audit entry / event pointing at a fallback workspace that was
264+
// rolled back.
253265
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
266+
expect(mockWorkspaceCreatedEvent).not.toHaveBeenCalled()
254267
})
255268

256269
it('only provisions a fallback for the one member who would actually be stranded', async () => {

apps/sim/lib/workspaces/lifecycle.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { createLogger } from '@sim/logger'
2020
import { ORG_ADMIN_ROLES } from '@sim/platform-authz/workspace'
2121
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
2222
import { getActivelyBannedUserIds } from '@/lib/auth/ban'
23+
import { PlatformEvents } from '@/lib/core/telemetry'
2324
import type { DbOrTx } from '@/lib/db/types'
2425
import { mcpPubSub } from '@/lib/mcp/pubsub'
2526
import { mcpService } from '@/lib/mcp/service'
@@ -112,7 +113,7 @@ async function findMembersStrandedByArchival(
112113
return []
113114
}
114115

115-
const bannedUserIds = new Set(await getActivelyBannedUserIds(strandedUserIds))
116+
const bannedUserIds = new Set(await getActivelyBannedUserIds(strandedUserIds, executor))
116117
return strandedUserIds.filter((userId) => !bannedUserIds.has(userId))
117118
}
118119

@@ -294,11 +295,23 @@ export async function archiveWorkspace(
294295
return fallbacks
295296
}, transactionConfig)
296297

297-
// Recorded only after the transaction commits — recordAudit is fire-and-forget and doesn't
298-
// participate in the transaction, so recording it earlier could leave a phantom audit entry
299-
// pointing at a fallback workspace that got rolled back (e.g. on a serialization failure).
300-
if (options.actorId) {
301-
for (const fallback of provisionedFallbacks) {
298+
// Recorded/fired only after the transaction commits — recordAudit and the telemetry event are
299+
// fire-and-forget and don't participate in the transaction, so triggering them earlier could
300+
// leave a phantom audit entry / event pointing at a fallback workspace that got rolled back
301+
// (e.g. on a serialization failure). `createWorkspaceRecord` defers its own `workspaceCreated`
302+
// event for exactly this reason when given an `executor` — this is where it gets fired instead.
303+
for (const fallback of provisionedFallbacks) {
304+
try {
305+
PlatformEvents.workspaceCreated({
306+
workspaceId: fallback.workspaceId,
307+
userId: fallback.userId,
308+
name: fallback.name,
309+
})
310+
} catch {
311+
// Telemetry should not fail the operation
312+
}
313+
314+
if (options.actorId) {
302315
recordAudit({
303316
workspaceId: fallback.workspaceId,
304317
actorId: options.actorId,

0 commit comments

Comments
 (0)