Skip to content

Commit 36e1735

Browse files
authored
fix(admin): stop counting workflow runs on dashboard list pages (#9630)
* 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. * test(admin): avoid Promise.withResolvers in the run-budget test * fix(admin): count former actors' runs once, not once per actor Former actors' workflow runs were the sum of their per-user counts, so a run shared by two former actors counted twice. Group the same scan by a former-actor flag as well and read the distinct count from it. The flag tests membership against two uncorrelated lists, so each is hashed once. Also drop the last non-null assertion in the organizations list and shrink the explicit-any baseline.
1 parent 1d55abf commit 36e1735

7 files changed

Lines changed: 371 additions & 368 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: 77 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+
readDashboardOrganizationWorkflowRuns,
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

@@ -90,6 +86,9 @@ beforeAll(async () => {
9086
('u3', 'model', 'copilot', 0.1, 'organization', 'org-a', ${P}, NULL, '2026-08-07'),
9187
('u4', 'model', 'copilot', 0.2, 'organization', 'org-a', ${P}, NULL, '2026-08-07'),
9288
('u5', 'model', 'workflow', 0.3, 'organization', 'org-a', ${P}, 'e11', '2026-08-07'),
89+
-- one run shared by two former actors
90+
('u3', 'model', 'workflow', 0.1, 'organization', 'org-a', ${P}, 'e12', '2026-08-08'),
91+
('u5', 'model', 'workflow', 0.1, 'organization', 'org-a', ${P}, 'e12', '2026-08-08'),
9392
9493
-- org-b, reporting period: matched on created_at, start inclusive and end exclusive,
9594
-- whatever the rows are stamped with.
@@ -106,6 +105,7 @@ beforeAll(async () => {
106105
`)
107106
const database = drizzle(connection)
108107
select.mockImplementation((fields) => database.select(fields))
108+
transaction.mockImplementation((run, config) => database.transaction(run, config))
109109
})
110110

111111
afterAll(async () => {
@@ -114,63 +114,98 @@ afterAll(async () => {
114114
})
115115

116116
describe('dashboard ledger usage', () => {
117-
it('sums spend and counts distinct workflow runs per entity over each period', async () => {
118-
const usage = await readDashboardLedgerUsage([
117+
it('sums spend per entity over each period', async () => {
118+
const spend = await readDashboardLedgerSpend([
119119
organizationWindow('org-a', stampedPeriod),
120120
organizationWindow('org-b', reportingPeriod),
121121
organizationWindow('org-c', stampedPeriod),
122122
{ entity: { type: 'user', id: 'p1' }, period: stampedPeriod },
123123
])
124124

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-
})
125+
expect(Object.fromEntries(spend)).toEqual({ 'org-a': 3.58, 'org-b': 1.75, p1: 0.6 })
130126
})
131127

132128
it('reads an entity once however many times it is asked for', async () => {
133-
const usage = await readDashboardLedgerUsage([
129+
const spend = await readDashboardLedgerSpend([
134130
organizationWindow('org-a', stampedPeriod),
135131
organizationWindow('org-a', stampedPeriod),
136132
])
137-
expect(usage.get('org-a')).toEqual({ cost: 3.38, workflowRuns: 5 })
133+
expect(spend.get('org-a')).toBe(3.58)
138134
})
139135

140-
it('splits spend and runs by acting user, counting a shared run for each actor', async () => {
141-
const usage = await readDashboardLedgerUsageByUser([
136+
it('splits spend by acting user', async () => {
137+
const spend = await readDashboardLedgerSpendByUser([
142138
organizationWindow('org-a', stampedPeriod),
143139
organizationWindow('org-b', reportingPeriod),
144140
])
145141

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 },
142+
expect(Object.fromEntries(spend.get('org-a') ?? [])).toEqual({
143+
u1: 1.51,
144+
u2: 0.37,
145+
u3: 1.1,
146+
u4: 0.2,
147+
u5: 0.4,
155148
})
149+
expect(Object.fromEntries(spend.get('org-b') ?? [])).toEqual({ u6: 1.75 })
156150
})
157151

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

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)
155+
const spend = await readDashboardLedgerSpendByUser([window], ['u2', 'u4'])
156+
expect(Object.fromEntries(spend.get('org-a') ?? [])).toEqual({ u2: 0.37, u4: 0.2 })
157+
expect((await readDashboardLedgerSpendByUser([window], [])).size).toBe(0)
167158
})
168159

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,
160+
it('counts distinct workflow runs in total, for each actor of a shared run, and once across former actors', async () => {
161+
const orgA = await readDashboardOrganizationWorkflowRuns('org-a', stampedPeriod)
162+
expect(orgA?.total).toBe(6)
163+
expect(Object.fromEntries(orgA?.byUser ?? [])).toEqual({ u1: 2, u2: 2, u3: 2, u5: 2 })
164+
expect(orgA?.formerActors).toBe(3)
165+
166+
const orgB = await readDashboardOrganizationWorkflowRuns('org-b', reportingPeriod)
167+
expect(orgB?.total).toBe(3)
168+
expect(Object.fromEntries(orgB?.byUser ?? [])).toEqual({ u6: 3 })
169+
expect(orgB?.formerActors).toBe(3)
170+
171+
const empty = await readDashboardOrganizationWorkflowRuns('org-c', stampedPeriod)
172+
expect(empty?.total).toBe(0)
173+
expect(empty?.byUser.size).toBe(0)
174+
expect(empty?.formerActors).toBe(0)
175+
})
176+
177+
it('reports runs as unknown when the count outlasts its budget', async () => {
178+
const locker = postgres(databaseUrl, {
179+
max: 1,
180+
prepare: false,
181+
connection: { search_path: schemaName },
182+
onnotice: () => undefined,
183+
})
184+
let release: () => void = () => undefined
185+
const released = new Promise<void>((resolve) => {
186+
release = resolve
187+
})
188+
let markLocked: () => void = () => undefined
189+
const locked = new Promise<void>((resolve) => {
190+
markLocked = resolve
191+
})
192+
const holder = locker.begin(async (tx) => {
193+
await tx.unsafe('LOCK TABLE usage_log IN ACCESS EXCLUSIVE MODE')
194+
markLocked()
195+
await released
174196
})
197+
try {
198+
await locked
199+
expect(await readDashboardOrganizationWorkflowRuns('org-a', stampedPeriod)).toBeNull()
200+
} finally {
201+
release()
202+
await holder
203+
await locker.end()
204+
}
205+
}, 20_000)
206+
207+
it('spends only for actors who are neither members nor collaborators of the organization', async () => {
208+
const spend = await readDashboardHistoricalActorSpend('org-a', stampedPeriod)
209+
expect(Object.fromEntries(spend)).toEqual({ u3: 1.1, u4: 0.2, u5: 0.4 })
175210
})
176211
})

0 commit comments

Comments
 (0)