Skip to content

Commit 7e37f2f

Browse files
committed
perf(invitations): bound the join-preview fan-out off the render critical path
listPendingInvitationsForViewer computed join previews in a serial loop, which was affordable when the switcher dropdown was the only caller. The sidebar prefetch now calls it on every workspace page render, and each preview issues up to three sequential queries — so a viewer with pending invitations paid roughly three round-trips per invitation before the first byte of every route under the workspace layout. Bounds the fan-out with mapWithConcurrency instead. The preview already degrades to null on failure, which is what makes it safe under a mapper that fails all-or-nothing, and a bound keeps the pooled-connection ceiling the serial loop was protecting.
1 parent eac836b commit 7e37f2f

2 files changed

Lines changed: 138 additions & 17 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockGetInvitationJoinPreview, mockListPendingInvitationsForEmail } = vi.hoisted(() => ({
7+
mockGetInvitationJoinPreview: vi.fn(),
8+
mockListPendingInvitationsForEmail: vi.fn(),
9+
}))
10+
11+
vi.mock('@/lib/invitations/core', () => ({
12+
getInvitationJoinPreview: mockGetInvitationJoinPreview,
13+
listPendingInvitationsForEmail: mockListPendingInvitationsForEmail,
14+
}))
15+
16+
import { listPendingInvitationsForViewer } from '@/lib/invitations/pending'
17+
18+
const USER_ID = 'user-1'
19+
const EMAIL = 'invitee@example.com'
20+
21+
function invitation(id: string) {
22+
return {
23+
id,
24+
kind: 'organization' as const,
25+
email: EMAIL,
26+
organizationId: 'org-1',
27+
organizationName: 'Org',
28+
membershipIntent: 'member',
29+
role: 'member',
30+
status: 'pending',
31+
expiresAt: new Date('2026-02-01T00:00:00.000Z'),
32+
createdAt: new Date('2026-01-01T00:00:00.000Z'),
33+
inviterName: 'Ada',
34+
inviterEmail: 'ada@example.com',
35+
grants: [{ workspaceId: 'ws-1', workspaceName: 'WS', permission: 'read' }],
36+
}
37+
}
38+
39+
describe('listPendingInvitationsForViewer', () => {
40+
beforeEach(() => {
41+
vi.clearAllMocks()
42+
})
43+
44+
it('pairs each row with its own preview, in order', async () => {
45+
mockListPendingInvitationsForEmail.mockResolvedValue(['a', 'b', 'c'].map(invitation))
46+
mockGetInvitationJoinPreview.mockImplementation(async (_userId, inv) => ({ for: inv.id }))
47+
48+
const rows = await listPendingInvitationsForViewer(USER_ID, EMAIL)
49+
50+
expect(rows.map((r) => r.id)).toEqual(['a', 'b', 'c'])
51+
expect(rows.map((r) => r.joinPreview)).toEqual([{ for: 'a' }, { for: 'b' }, { for: 'c' }])
52+
})
53+
54+
/**
55+
* The preview is disclosure-only, so one failing row must degrade to `null` rather than
56+
* hiding the invitation or failing the batch — which is what makes the bounded mapper
57+
* safe, since it fails all-or-nothing on a throwing mapper.
58+
*/
59+
it('degrades a failing preview to null without dropping the invitation', async () => {
60+
mockListPendingInvitationsForEmail.mockResolvedValue(['a', 'b'].map(invitation))
61+
mockGetInvitationJoinPreview.mockImplementation(async (_userId, inv) => {
62+
if (inv.id === 'a') throw new Error('preview blew up')
63+
return { for: inv.id }
64+
})
65+
66+
const rows = await listPendingInvitationsForViewer(USER_ID, EMAIL)
67+
68+
expect(rows).toHaveLength(2)
69+
expect(rows[0].joinPreview).toBeNull()
70+
expect(rows[1].joinPreview).toEqual({ for: 'b' })
71+
})
72+
73+
/**
74+
* This runs inside the sidebar prefetch on every workspace page render, and each preview
75+
* issues several queries — so the fan-out stays bounded rather than holding one pooled
76+
* connection per pending invitation.
77+
*/
78+
it('bounds how many previews run at once', async () => {
79+
mockListPendingInvitationsForEmail.mockResolvedValue(
80+
Array.from({ length: 12 }, (_, i) => invitation(`inv-${i}`))
81+
)
82+
let inFlight = 0
83+
let peak = 0
84+
mockGetInvitationJoinPreview.mockImplementation(async () => {
85+
inFlight++
86+
peak = Math.max(peak, inFlight)
87+
await Promise.resolve()
88+
inFlight--
89+
return null
90+
})
91+
92+
await listPendingInvitationsForViewer(USER_ID, EMAIL)
93+
94+
expect(mockGetInvitationJoinPreview).toHaveBeenCalledTimes(12)
95+
expect(peak).toBeGreaterThan(1)
96+
expect(peak).toBeLessThanOrEqual(4)
97+
})
98+
99+
it('serializes dates to the wire shape', async () => {
100+
mockListPendingInvitationsForEmail.mockResolvedValue([invitation('a')])
101+
mockGetInvitationJoinPreview.mockResolvedValue(null)
102+
103+
const [row] = await listPendingInvitationsForViewer(USER_ID, EMAIL)
104+
105+
expect(row.expiresAt).toBe('2026-02-01T00:00:00.000Z')
106+
expect(row.createdAt).toBe('2026-01-01T00:00:00.000Z')
107+
})
108+
})

apps/sim/lib/invitations/pending.ts

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
import { createLogger } from '@sim/logger'
22
import type { ViewerInvitation } from '@/lib/api/contracts/invitations'
3+
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
34
import { getInvitationJoinPreview, listPendingInvitationsForEmail } from '@/lib/invitations/core'
45

56
const logger = createLogger('PendingInvitations')
67

8+
/**
9+
* Bounds the join-preview fan-out. A pending-invitation list is a handful of rows, so this
10+
* resolves the common cases in one batch while still capping how many pooled connections a
11+
* single page render can hold.
12+
*/
13+
const INVITATION_PREVIEW_CONCURRENCY = 4
14+
715
/**
816
* The invitee-facing pending-invitation list behind the workspace switcher's
917
* Invitations section, assembled once for both the `GET /api/invitations` route
@@ -22,26 +30,31 @@ export async function listPendingInvitationsForViewer(
2230
* Each row carries what accepting it will actually do, so the client can echo
2331
* `disclosedWorkspaceIds` on accept — the consent contract the emailed
2432
* `/invite` page honours. A preview failure degrades to `null` rather than
25-
* hiding the invitation.
33+
* hiding the invitation, which is what lets this run under a bounded mapper.
2634
*
27-
* Sequential on purpose: each preview issues several queries, and the sidebar
28-
* prefetch calls this on every workspace page load. Fanning them out with
29-
* `Promise.all` would hold one pooled connection per pending invitation for
30-
* the length of the slowest one. The list is a handful of rows, so the added
31-
* latency is not worth the pool pressure.
35+
* Bounded rather than unbounded: each preview issues up to three sequential
36+
* queries, so `Promise.all` would hold one pooled connection per pending
37+
* invitation for the length of the slowest one. It was previously a serial
38+
* loop for that reason, which is no longer affordable now that the sidebar
39+
* prefetch calls this on every workspace page render rather than only when
40+
* the switcher opens — a serial loop puts every one of those queries on the
41+
* critical path of the first byte.
3242
*/
33-
const previews: Array<Awaited<ReturnType<typeof getInvitationJoinPreview>> | null> = []
34-
for (const inv of invitations) {
35-
try {
36-
previews.push(await getInvitationJoinPreview(userId, inv))
37-
} catch (previewError) {
38-
logger.warn('Failed to compute join preview for pending invitation', {
39-
invitationId: inv.id,
40-
error: previewError,
41-
})
42-
previews.push(null)
43+
const previews = await mapWithConcurrency(
44+
invitations,
45+
INVITATION_PREVIEW_CONCURRENCY,
46+
async (inv) => {
47+
try {
48+
return await getInvitationJoinPreview(userId, inv)
49+
} catch (previewError) {
50+
logger.warn('Failed to compute join preview for pending invitation', {
51+
invitationId: inv.id,
52+
error: previewError,
53+
})
54+
return null
55+
}
4356
}
44-
}
57+
)
4558

4659
return invitations.map(
4760
(inv, index) =>

0 commit comments

Comments
 (0)