Skip to content

Commit 20ec175

Browse files
authored
perf(db): serve workspace usage drill-downs index-only on usage_log (#9447)
* perf(db): serve workspace usage drill-downs index-only on usage_log Every Workspaces drill-down read filters on the billing entity, one workspace and a created_at window, and reads cost with user_id, source or workflow_id. usage_log_workspace_created_at_idx held only (workspace_id, created_at), so the planner matched the window there or on usage_log_billing_entity_created_at_cost_idx and fetched a heap row per match to check the rest: the workspace's rows on one, the whole organization's on the other. 0441 dropped the compact entity/period index that used to narrow the second through a BitmapAnd, so a large organization's workflow ranking went from about 4.6s to 13.7s, reading thousands of rows per row it returned. usage_log_workspace_created_at_cost_idx replaces it: the same leading (workspace_id, created_at), then the entity and every column the drill-down reads. Each drill-down read is now an index-only scan bounded by the workspace's rows in the window, and the readers that name only the workspace (the foreign key's ON DELETE SET NULL, the ledger list filtered to a workspace) keep the order they had. The new index is built concurrently before the old one is dropped, so inserts maintain the same number of indexes. The plan integration test explains the exact SQL of the totals, series and rankings for a workspace holding most and a small share of its organization's usage, and fails on the old schema. * perf(db): scope the workspace covering index's claim to created_at windows
1 parent d59a275 commit 20ec175

7 files changed

Lines changed: 31773 additions & 6 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* The plans the Workspaces drill-down reads get in PostgreSQL, proven on the exact SQL the
3+
* analytics queries send: every read narrowed to one workspace over a `created_at` window is an
4+
* index-only scan of `usage_log_workspace_created_at_cost_idx`, bounded by that workspace's rows
5+
* in the window, whether the workspace holds a sliver of its organization's usage or nearly all
6+
* of it. Every other index leaves the workspace or the entity to a heap fetch per matched row.
7+
*/
8+
import { db } from '@sim/db'
9+
import { usageLog, user, workflow, workspace } from '@sim/db/schema'
10+
import { explainCaptured, type PlanNode } from '@sim/db/testing/explain'
11+
import { assertDisposableTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
12+
import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
13+
import { generateId } from '@sim/utils/id'
14+
import { eq, inArray, type SQL, sql } from 'drizzle-orm'
15+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
16+
import { buildUsageAnalyticsScope } from '@/lib/billing/core/usage-analytics'
17+
import {
18+
readUsageBreakdown,
19+
readUsageBreakdownOverTime,
20+
readUsageTimeSeries,
21+
readUsageTotals,
22+
} from '@/lib/billing/core/usage-analytics-queries'
23+
24+
const COVERING_INDEX = 'usage_log_workspace_created_at_cost_idx'
25+
26+
const member = generateId()
27+
const organization = { type: 'organization', id: `org-${generateId()}` } as const
28+
const otherOrganization = `org-${generateId()}`
29+
const workspaces = { dominant: generateId(), minor: generateId(), other: generateId() }
30+
const workflowIds = [generateId(), generateId(), generateId()]
31+
const anchor = new Date('2026-06-01T00:00:00Z')
32+
const window = {
33+
kind: 'range',
34+
from: new Date(anchor.getTime() - 30 * 24 * 60 * 60 * 1000),
35+
to: anchor,
36+
} as const
37+
38+
/**
39+
* Ledger rows a minute apart over sixty days. The organization's rows go mostly to the dominant
40+
* workspace and an eighth to the minor one, and an unrelated organization's workspace holds as
41+
* many again, so a scan bounded by the organization or by the whole window is never the cheap
42+
* one. The minor workspace also carries rows billed to its creator before it joined the
43+
* organization, which the drill-down must skip.
44+
*/
45+
async function seedLedger() {
46+
await db.execute(sql`
47+
INSERT INTO ${usageLog} (id, user_id, category, source, description, cost,
48+
billing_entity_type, billing_entity_id, billing_period_start, billing_period_end,
49+
workspace_id, workflow_id, created_at)
50+
SELECT 'ul_plan_' || n || '_' || ${member}, ${member}, 'fixed',
51+
(ARRAY['workflow', 'copilot', 'wand']::usage_log_source[])[1 + n % 3], 'run', 0.01,
52+
CASE WHEN n % 16 = 15 THEN 'user' ELSE 'organization' END::billing_entity_type,
53+
CASE WHEN n % 16 = 15 THEN ${member} WHEN n % 2 = 0 THEN ${otherOrganization}
54+
ELSE ${organization.id} END,
55+
timestamp '2026-03-01', timestamp '2026-07-01',
56+
CASE WHEN n % 2 = 0 THEN ${workspaces.other} WHEN n % 16 IN (7, 15) THEN ${workspaces.minor}
57+
ELSE ${workspaces.dominant} END,
58+
CASE WHEN n % 2 = 1 AND n % 5 < 3 THEN (ARRAY[${workflowIds[0]}, ${workflowIds[1]},
59+
${workflowIds[2]}])[1 + n % 3] END,
60+
${anchor.toISOString()}::timestamp - n * interval '1 minute'
61+
FROM generate_series(1, 86400) AS n`)
62+
await db.execute(sql`VACUUM ANALYZE ${usageLog}`)
63+
}
64+
65+
const coveringScan = (nodes: PlanNode[]) =>
66+
nodes.find((node) => node['Index Name'] === COVERING_INDEX)
67+
68+
describe('Workspaces drill-down plans in PostgreSQL', () => {
69+
beforeAll(async () => {
70+
assertDisposableTestDatabaseUrl(process.env.DATABASE_URL ?? '')
71+
const now = new Date()
72+
await db.insert(user).values({
73+
id: member,
74+
name: 'Plan fixture',
75+
email: `${member}@fixture.test`,
76+
emailVerified: true,
77+
createdAt: now,
78+
updatedAt: now,
79+
})
80+
await insertWorkspaceFixture(
81+
db,
82+
Object.values(workspaces).map((id) => ({
83+
id,
84+
name: 'Plan fixture',
85+
ownerId: member,
86+
billedAccountUserId: member,
87+
}))
88+
)
89+
await db.insert(workflow).values(
90+
workflowIds.map((id, index) => ({
91+
id,
92+
userId: member,
93+
workspaceId: workspaces.dominant,
94+
name: `Plan fixture ${index}`,
95+
lastSynced: now,
96+
createdAt: now,
97+
updatedAt: now,
98+
}))
99+
)
100+
await seedLedger()
101+
})
102+
103+
afterAll(async () => {
104+
await db.delete(usageLog).where(eq(usageLog.userId, member))
105+
await deleteWorkspaceFixture(db, inArray(workspace.id, Object.values(workspaces)))
106+
await db.delete(user).where(eq(user.id, member))
107+
})
108+
109+
const reads: [string, (scope: SQL[]) => Promise<unknown>][] = [
110+
['total', (scope) => readUsageTotals(scope)],
111+
['time series', (scope) => readUsageTimeSeries(scope, 'day', 'America/Los_Angeles')],
112+
[
113+
'source series',
114+
(scope) => readUsageBreakdownOverTime(scope, 'source', 'America/Los_Angeles', 'hour'),
115+
],
116+
[
117+
'member series',
118+
(scope) => readUsageBreakdownOverTime(scope, 'member', 'America/Los_Angeles', 'hour'),
119+
],
120+
['workflow ranking', (scope) => readUsageBreakdown(scope, 'workflow')],
121+
]
122+
123+
it.each(
124+
(['dominant', 'minor'] as const).flatMap((share) =>
125+
reads.map(([label, read]) => ({ label, share, read }))
126+
)
127+
)(
128+
'reads the $label of the $share workspace index-only from its rows in the window',
129+
async ({ share, read }) => {
130+
const scope = buildUsageAnalyticsScope(organization, window, workspaces[share])
131+
const scan = coveringScan(await explainCaptured(db, () => read(scope)))
132+
expect(scan?.['Node Type']).toBe('Index Only Scan')
133+
expect(scan?.['Index Cond']).toMatch(/^\(\(workspace_id = .+\) AND \(created_at >= .+\)/)
134+
}
135+
)
136+
})

‎apps/sim/lib/billing/core/usage-analytics-queries.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ import type { DbClient } from '@/lib/db/types'
2727
* and the member/source breakdowns are index-only. `workspace_id`, `workflow_id`,
2828
* `description`, `metadata`, and `execution_id` are NOT in it, so the remaining
2929
* reads heap-fetch per row — which is why they are issued only for the dimension
30-
* actually being viewed.
30+
* actually being viewed. A read narrowed to one workspace runs on
31+
* `usage_log_workspace_created_at_cost_idx` instead, which also covers `workflow_id`.
3132
*/
3233

3334
export interface UsageTimeSeriesRow {

‎apps/sim/lib/billing/core/usage-analytics.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,10 @@ export function buildUsageAnalyticsScope(
8787
* entity predicates above are — so a workspace belonging to another organization
8888
* narrows to nothing rather than disclosing anything.
8989
*
90-
* `workspace_id` is not in `usage_log_billing_entity_created_at_cost_idx`, so a
91-
* narrowed read heap-fetches per row in the window. That is the cost the Workspaces
92-
* tab already pays to rank its list, and it is only ever paid inside a drill-down.
90+
* A narrowed read over a `created_at` window is index-only on
91+
* `usage_log_workspace_created_at_cost_idx`, which carries the entity and every
92+
* column the series and the member, source and workflow rankings read, so it scans
93+
* the workspace's rows in the window rather than the organization's.
9394
*/
9495
workspaceId?: string
9596
): SQL[] {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
-- Replaces usage_log_workspace_created_at_idx with a covering index under the same two leading
2+
-- columns.
3+
--
4+
-- A Workspaces drill-down over a created_at window (a range, or a reporting period) filters on the
5+
-- billing entity, the workspace and that window, and reads cost with user_id, source or
6+
-- workflow_id. The old index held only
7+
-- (workspace_id, created_at), so a drill-down matched the window there or on
8+
-- usage_log_billing_entity_created_at_cost_idx and fetched every matched row from the heap to
9+
-- check the rest: the workspace's rows on one, the whole organization's on the other. Dropping
10+
-- usage_log_billing_entity_period_idx in 0441 took away the bitmap that used to narrow the second.
11+
-- With the entity and every column those reads return in the key, each one is an index-only scan
12+
-- bounded by the workspace's rows in the window. A drill-down over a Stripe period matches the
13+
-- period stamps instead, which this index does not carry, so it keeps the plans it had.
14+
--
15+
-- The new key extends the old one, so it serves every read the old index served in the same
16+
-- order: the workspace_id foreign key's ON DELETE SET NULL, and the ledger list filtered to a
17+
-- workspace. The old index is dropped once the new one is built, so ledger inserts maintain the
18+
-- same number of indexes.
19+
--
20+
-- Replay-safety: usage_log is a hot append-only ledger, so the build and the drop are
21+
-- CONCURRENTLY, below an embedded COMMIT, and a failure there replays the whole file from the top.
22+
-- A failed concurrent build leaves an INVALID index that IF NOT EXISTS would keep: the replay moves
23+
-- only that one aside to be dropped, so a valid index built by an earlier attempt is kept as is.
24+
DO $$
25+
BEGIN
26+
IF EXISTS (
27+
SELECT 1 FROM pg_index WHERE indexrelid = to_regclass('usage_log_workspace_created_at_cost_idx_failed')
28+
AND (indisvalid OR indrelid <> to_regclass('usage_log'))
29+
) THEN
30+
RAISE EXCEPTION 'Refusing to drop unexpected recovery index usage_log_workspace_created_at_cost_idx_failed';
31+
END IF;
32+
IF EXISTS (
33+
SELECT 1 FROM pg_index WHERE indexrelid = to_regclass('usage_log_workspace_created_at_cost_idx')
34+
AND indrelid = to_regclass('usage_log') AND NOT indisvalid
35+
) THEN
36+
ALTER INDEX "usage_log_workspace_created_at_cost_idx" RENAME TO "usage_log_workspace_created_at_cost_idx_failed";
37+
END IF;
38+
END $$;--> statement-breakpoint
39+
COMMIT;--> statement-breakpoint
40+
SET lock_timeout = 0;--> statement-breakpoint
41+
DROP INDEX CONCURRENTLY IF EXISTS "usage_log_workspace_created_at_cost_idx_failed";--> statement-breakpoint
42+
CREATE INDEX CONCURRENTLY IF NOT EXISTS "usage_log_workspace_created_at_cost_idx" ON "usage_log" USING btree ("workspace_id","created_at","billing_entity_type","billing_entity_id","user_id","source","cost","workflow_id");--> statement-breakpoint
43+
DROP INDEX CONCURRENTLY IF EXISTS "usage_log_workspace_created_at_idx";--> statement-breakpoint
44+
SET lock_timeout = '5s';

0 commit comments

Comments
 (0)