Skip to content

Commit 235764e

Browse files
committed
fix(admin): stop counting workflow runs on dashboard list pages
The organizations and users lists counted each entity's distinct workflow executions alongside its spend. No entity index carries execution_id, so the count heap-fetched every workflow ledger row in each entity's period and the list grew past the statement timeout as the ledger grew. - Lists and the reporting-period preview read spend only (index-only) and return workflowRuns: null. - Organization detail counts runs once, total plus every actor's share in one GROUPING SETS scan, under a 5s budget via readLedgerBounded; past it the count is null and the page still loads. Former actors' runs come from the same scan. - The contract makes workflowRuns nullable.
1 parent 2fc9739 commit 235764e

6 files changed

Lines changed: 331 additions & 327 deletions

File tree

‎apps/sim/lib/admin/dashboard-organizations.test.ts‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -233,13 +233,12 @@ describe('listDashboardOrganizations', () => {
233233
},
234234
])
235235
queueTableRows(usageLog, [{ entityId: 'org-1', cost: '2.5' }])
236-
queueTableRows(usageLog, [{ entityId: 'org-1', workflowRuns: 3 }])
237236

238237
const result = await listDashboardOrganizations({ search: '', limit: 50, offset: 0 })
239238

240239
expect(result.data[0]).toMatchObject({
241240
reportingPeriod: { source: 'stripe' },
242-
usage: { usedDollars: 2.5, workflowRuns: 3 },
241+
usage: { usedDollars: 2.5, workflowRuns: null },
243242
})
244243
})
245244

@@ -275,14 +274,13 @@ describe('listDashboardOrganizations', () => {
275274
},
276275
])
277276
queueTableRows(usageLog, [{ entityId: 'org-1', cost: '2.5' }])
278-
queueTableRows(usageLog, [{ entityId: 'org-1', workflowRuns: 3 }])
279277
queueTableRows(member, [{ organizationId: 'org-1', cost: '1.5' }])
280278

281279
const result = await listDashboardOrganizations({ search: '', limit: 50, offset: 0 })
282280

283281
expect(result.data[0]).toMatchObject({
284282
reportingPeriod: { source: 'reporting', anchorDate: '2026-01-01', interval: 'year' },
285-
usage: { usedDollars: 2.5, workflowRuns: 3 },
283+
usage: { usedDollars: 2.5 },
286284
})
287285
})
288286
})

‎apps/sim/lib/admin/dashboard-usage.integration.ts‎

Lines changed: 65 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@ import { drizzle } from 'drizzle-orm/postgres-js'
44
import postgres from 'postgres'
55
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
66

7-
const { select } = vi.hoisted(() => ({ select: vi.fn() }))
7+
const { select, transaction } = vi.hoisted(() => ({ select: vi.fn(), transaction: vi.fn() }))
88
const databaseUrl = readTestDatabaseUrl()
9-
vi.mock('@sim/db', () => ({ db: { select } }))
9+
vi.mock('@sim/db', () => ({ db: { select, transaction } }))
1010

1111
import {
12-
type DashboardLedgerWindow,
13-
readDashboardHistoricalActorUsage,
14-
readDashboardLedgerUsage,
15-
readDashboardLedgerUsageByUser,
12+
organizationWindow,
13+
readDashboardHistoricalActorSpend,
14+
readDashboardLedgerSpend,
15+
readDashboardLedgerSpendByUser,
16+
readDashboardWorkflowRuns,
1617
} from '@/lib/admin/dashboard-usage'
1718
import type { ResolvedUsagePeriod } from '@/lib/billing/core/reporting-period'
1819

@@ -30,11 +31,6 @@ const reportingPeriod: ResolvedUsagePeriod = {
3031
anchorDate: '2026-01-15',
3132
interval: 'month',
3233
}
33-
const organizationWindow = (id: string, period: ResolvedUsagePeriod): DashboardLedgerWindow => ({
34-
entity: { type: 'organization', id },
35-
period,
36-
})
37-
3834
const P = `'2026-08-01', '2026-09-01'`
3935
const PREVIOUS = `'2026-07-01', '2026-08-01'`
4036

@@ -106,6 +102,7 @@ beforeAll(async () => {
106102
`)
107103
const database = drizzle(connection)
108104
select.mockImplementation((fields) => database.select(fields))
105+
transaction.mockImplementation((run, config) => database.transaction(run, config))
109106
})
110107

111108
afterAll(async () => {
@@ -114,63 +111,89 @@ afterAll(async () => {
114111
})
115112

116113
describe('dashboard ledger usage', () => {
117-
it('sums spend and counts distinct workflow runs per entity over each period', async () => {
118-
const usage = await readDashboardLedgerUsage([
114+
it('sums spend per entity over each period', async () => {
115+
const spend = await readDashboardLedgerSpend([
119116
organizationWindow('org-a', stampedPeriod),
120117
organizationWindow('org-b', reportingPeriod),
121118
organizationWindow('org-c', stampedPeriod),
122119
{ entity: { type: 'user', id: 'p1' }, period: stampedPeriod },
123120
])
124121

125-
expect(Object.fromEntries(usage)).toEqual({
126-
'org-a': { cost: 3.38, workflowRuns: 5 },
127-
'org-b': { cost: 1.75, workflowRuns: 3 },
128-
p1: { cost: 0.6, workflowRuns: 1 },
129-
})
122+
expect(Object.fromEntries(spend)).toEqual({ 'org-a': 3.38, 'org-b': 1.75, p1: 0.6 })
130123
})
131124

132125
it('reads an entity once however many times it is asked for', async () => {
133-
const usage = await readDashboardLedgerUsage([
126+
const spend = await readDashboardLedgerSpend([
134127
organizationWindow('org-a', stampedPeriod),
135128
organizationWindow('org-a', stampedPeriod),
136129
])
137-
expect(usage.get('org-a')).toEqual({ cost: 3.38, workflowRuns: 5 })
130+
expect(spend.get('org-a')).toBe(3.38)
138131
})
139132

140-
it('splits spend and runs by acting user, counting a shared run for each actor', async () => {
141-
const usage = await readDashboardLedgerUsageByUser([
133+
it('splits spend by acting user', async () => {
134+
const spend = await readDashboardLedgerSpendByUser([
142135
organizationWindow('org-a', stampedPeriod),
143136
organizationWindow('org-b', reportingPeriod),
144137
])
145138

146-
expect(Object.fromEntries(usage.get('org-a') ?? [])).toEqual({
147-
u1: { cost: 1.51, workflowRuns: 2 },
148-
u2: { cost: 0.37, workflowRuns: 2 },
149-
u3: { cost: 1, workflowRuns: 1 },
150-
u4: { cost: 0.2, workflowRuns: 0 },
151-
u5: { cost: 0.3, workflowRuns: 1 },
152-
})
153-
expect(Object.fromEntries(usage.get('org-b') ?? [])).toEqual({
154-
u6: { cost: 1.75, workflowRuns: 3 },
139+
expect(Object.fromEntries(spend.get('org-a') ?? [])).toEqual({
140+
u1: 1.51,
141+
u2: 0.37,
142+
u3: 1,
143+
u4: 0.2,
144+
u5: 0.3,
155145
})
146+
expect(Object.fromEntries(spend.get('org-b') ?? [])).toEqual({ u6: 1.75 })
156147
})
157148

158149
it('narrows the per-user split to the requested users', async () => {
159150
const window = organizationWindow('org-a', stampedPeriod)
160151

161-
const usage = await readDashboardLedgerUsageByUser([window], ['u2', 'u4'])
162-
expect(Object.fromEntries(usage.get('org-a') ?? [])).toEqual({
163-
u2: { cost: 0.37, workflowRuns: 2 },
164-
u4: { cost: 0.2, workflowRuns: 0 },
165-
})
166-
expect((await readDashboardLedgerUsageByUser([window], [])).size).toBe(0)
152+
const spend = await readDashboardLedgerSpendByUser([window], ['u2', 'u4'])
153+
expect(Object.fromEntries(spend.get('org-a') ?? [])).toEqual({ u2: 0.37, u4: 0.2 })
154+
expect((await readDashboardLedgerSpendByUser([window], [])).size).toBe(0)
167155
})
168156

169-
it('counts only actors who are neither members nor collaborators of the organization', async () => {
170-
expect(await readDashboardHistoricalActorUsage('org-a', stampedPeriod)).toEqual({
171-
usedDollars: 1.5,
172-
workflowRuns: 2,
173-
actorCount: 3,
157+
it('counts distinct workflow runs in total and for each actor of a shared run', async () => {
158+
const orgA = await readDashboardWorkflowRuns(organizationWindow('org-a', stampedPeriod))
159+
expect(orgA?.total).toBe(5)
160+
expect(Object.fromEntries(orgA?.byUser ?? [])).toEqual({ u1: 2, u2: 2, u3: 1, u5: 1 })
161+
162+
const orgB = await readDashboardWorkflowRuns(organizationWindow('org-b', reportingPeriod))
163+
expect(orgB?.total).toBe(3)
164+
expect(Object.fromEntries(orgB?.byUser ?? [])).toEqual({ u6: 3 })
165+
166+
const empty = await readDashboardWorkflowRuns(organizationWindow('org-c', stampedPeriod))
167+
expect(empty?.total).toBe(0)
168+
expect(empty?.byUser.size).toBe(0)
169+
})
170+
171+
it('reports runs as unknown when the count outlasts its budget', async () => {
172+
const locker = postgres(databaseUrl, {
173+
max: 1,
174+
prepare: false,
175+
connection: { search_path: schemaName },
176+
onnotice: () => undefined,
177+
})
178+
const released = Promise.withResolvers<void>()
179+
const lockHeld = Promise.withResolvers<void>()
180+
const holder = locker.begin(async (tx) => {
181+
await tx.unsafe('LOCK TABLE usage_log IN ACCESS EXCLUSIVE MODE')
182+
lockHeld.resolve()
183+
await released.promise
174184
})
185+
try {
186+
await lockHeld.promise
187+
expect(await readDashboardWorkflowRuns(organizationWindow('org-a', stampedPeriod))).toBeNull()
188+
} finally {
189+
released.resolve()
190+
await holder
191+
await locker.end()
192+
}
193+
}, 20_000)
194+
195+
it('spends only for actors who are neither members nor collaborators of the organization', async () => {
196+
const spend = await readDashboardHistoricalActorSpend('org-a', stampedPeriod)
197+
expect(Object.fromEntries(spend)).toEqual({ u3: 1, u4: 0.2, u5: 0.3 })
175198
})
176199
})

0 commit comments

Comments
 (0)