Skip to content

Commit 1cc9aac

Browse files
committed
fix(organizations): stop the Home waiting probe reading every organization's pauses
The planner cannot estimate jsonb_path_exists, so under LIMIT 1 it expected an early match and scanned paused_executions from the top. A workspace with no person-waiting pause read the whole table, every tenant's pauses included. With the pauses as a lateral of their own it has to start from the workspace's workflows and take the workflow_id index, so no new index is needed. The test measures the real plan on real PostgreSQL: 110,020 rows read before, 5,000 after.
1 parent b568160 commit 1cc9aac

4 files changed

Lines changed: 209 additions & 7 deletions

File tree

‎.github/CI.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ Every HTTP and browser group consumes the same production build in an isolated j
4646

4747
The Platform's fixture and live suites stay in `platform-design.yml`, outside `checks / ci`. The HTTP and production matrices consume one `next-build` artifact, built without `SIM_PLATFORM_ORIGIN`, while the `zone` topology bakes that origin into its rewrite and also builds the Platform app, so it needs its own job, gate flag and plan output rather than a suite entry. A task that depends on `ci:shared` would select two 16-vCPU jobs of up to 75 minutes for nearly every app PR and hold every staging push's deployment gate on them. The cost is that a red run is advisory, and a path-filtered workflow cannot be a required check: one that never starts never reports, so the status does not block a merge until the suite joins the aggregate.
4848

49+
The `zone` job runs its build through several phases on one database: the suite, the zone gone, the zone answering 503, the zone back, the flag off, flags read from a fixture of AppConfig's service with one organization targeted, and misconfigured zones that must refuse to start. The `flag-off` job runs its no-zone build, then the same build self-hosted. The two cannot read each other's reports, so `check:platform-parity --job` holds each to its own rows.
50+
4951
Desktop inbox protocol, assistant actions UI, and organization search UI run as three parallel HTTP jobs with isolated databases, apps, and reports. Each harness owns its acceptance task, so an edit selects its own job. The inbox protocol task depends on server and realtime code; the browser tasks also depend on workspace UI. Desktop inbox needs no browser. The UI jobs and browser extension install Chromium while their app starts, waiting for both before executing assertions; browser-install failures still fail the job and preserve the install log. No acceptance assertions are removed by the split.
5052

5153
Live Electron acceptance tracks its live-app suite, fixtures, and Playwright configuration separately from shell-only smoke tests. Changing a shell-only smoke case runs the dedicated desktop workflow without starting the app's production build and live lease checks.
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/**
2+
* What the Home Workspaces probe reads when a workspace has thousands of unanswered time pauses, the
3+
* shape of a schedule waiting on many runs. Each pause point is a JSON read the planner cannot index
4+
* or estimate, so a probe with no match reads every live pause of the workspaces it was asked about.
5+
* The bound this holds is that it reads no more than that: the plan is measured on real PostgreSQL
6+
* with the schema's own indexes, and a neighbor's pauses are never touched. Written as a plain join
7+
* under `LIMIT 1`, this reads the whole table instead.
8+
*/
9+
import { db } from '@sim/db'
10+
import { user, workflow, workspace } from '@sim/db/schema'
11+
import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
12+
import { generateId } from '@sim/utils/id'
13+
import { eq, inArray, sql } from 'drizzle-orm'
14+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
15+
import { readReviewAndLastRun } from '@/lib/organizations/home/workspace-activity'
16+
17+
const LIVE_PAUSES = 5_000
18+
const WORKFLOWS_PER_WORKSPACE = 20
19+
const PAUSES_PER_WORKFLOW = LIVE_PAUSES / WORKFLOWS_PER_WORKSPACE
20+
/**
21+
* The rest of the deployment. In a table this workspace dominates a sequential scan is the right
22+
* plan, and it would say nothing about the table production has.
23+
*/
24+
const NEIGHBOR_WORKFLOWS = 100
25+
const PAUSES_PER_NEIGHBOR_WORKFLOW = 1_000
26+
/** Each loop of a plan node reports its rows rounded, so a total can only be held this closely. */
27+
const ROUNDING_SLACK = 1.05
28+
/** Far above what this costs, so only a change of plan or a pathological runner can reach it. */
29+
const EXECUTION_CEILING_MS = 5_000
30+
31+
const ownerId = generateId()
32+
const timeOnly = generateId()
33+
const neighbor = generateId()
34+
const oneWaiting = generateId()
35+
const workspaces = [timeOnly, neighbor, oneWaiting]
36+
37+
const timePausePoints = {
38+
context: {
39+
contextId: 'context',
40+
blockId: 'wait',
41+
response: { data: { resumeAt: '2030-01-01T00:00:00.000Z' } },
42+
registeredAt: '2026-01-01T00:00:00.000Z',
43+
resumeStatus: 'paused',
44+
snapshotReady: true,
45+
pauseKind: 'time',
46+
resumeAt: '2030-01-01T00:00:00.000Z',
47+
},
48+
}
49+
const humanPausePoints = {
50+
context: { ...timePausePoints.context, blockId: 'approval', pauseKind: 'human', resumeAt: null },
51+
}
52+
53+
const workflowIdsOf = (workspaceId: string) =>
54+
Array.from(
55+
{ length: workspaceId === neighbor ? NEIGHBOR_WORKFLOWS : WORKFLOWS_PER_WORKSPACE },
56+
(_, index) => `${workspaceId}-${index}`
57+
)
58+
59+
/** Inserted in one statement: these are the fixture, not the behavior. */
60+
function insertPauses(workflowId: string, count: number, pausePoints: object) {
61+
return db.execute(sql`
62+
insert into paused_executions (
63+
id, workflow_id, execution_id, execution_snapshot, pause_points, total_pause_count
64+
)
65+
select gen_random_uuid()::text, ${workflowId}, gen_random_uuid()::text, '{}'::jsonb,
66+
${JSON.stringify(pausePoints)}::jsonb, 1
67+
from generate_series(1, ${count})
68+
`)
69+
}
70+
71+
interface PlanNode {
72+
'Node Type': string
73+
'Relation Name'?: string
74+
'Actual Rows': number
75+
'Actual Loops': number
76+
'Rows Removed by Filter'?: number
77+
Plans?: PlanNode[]
78+
}
79+
80+
function collectNodes(node: PlanNode): PlanNode[] {
81+
return [node, ...(node.Plans ?? []).flatMap(collectNodes)]
82+
}
83+
84+
/** The planner's real choice for the real query, with its parameters written in. */
85+
async function measureProbe(workspaceIds: string[]) {
86+
const { sql: text, params } = readReviewAndLastRun(workspaceIds).toSQL()
87+
const inlined = text.replace(/\$(\d+)/g, (_, position: string) => {
88+
const value = params[Number(position) - 1]
89+
return typeof value === 'number' ? String(value) : `'${String(value).replaceAll("'", "''")}'`
90+
})
91+
const rows = await db.execute(sql.raw(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${inlined}`))
92+
const [plan] = (rows[0] as { 'QUERY PLAN': { Plan: PlanNode; 'Execution Time': number }[] })[
93+
'QUERY PLAN'
94+
]
95+
if (!plan) throw new Error('EXPLAIN returned no plan')
96+
const pauseReads = collectNodes(plan.Plan).filter(
97+
(node) => node['Relation Name'] === 'paused_executions'
98+
)
99+
return {
100+
executionMs: plan['Execution Time'],
101+
scans: pauseReads.map((node) => node['Node Type']),
102+
rowsRead: pauseReads.reduce(
103+
(total, node) =>
104+
total +
105+
(node['Actual Rows'] + (node['Rows Removed by Filter'] ?? 0)) * node['Actual Loops'],
106+
0
107+
),
108+
}
109+
}
110+
111+
beforeAll(async () => {
112+
const now = new Date()
113+
await db.insert(user).values({
114+
id: ownerId,
115+
name: 'Workspace activity fixture',
116+
email: `${ownerId}@workspace-activity.test`,
117+
emailVerified: true,
118+
createdAt: now,
119+
updatedAt: now,
120+
})
121+
await insertWorkspaceFixture(
122+
db,
123+
workspaces.map((id) => ({
124+
id,
125+
name: 'Workspace activity fixture',
126+
ownerId,
127+
billedAccountUserId: ownerId,
128+
}))
129+
)
130+
await db.insert(workflow).values(
131+
workspaces.flatMap((workspaceId) =>
132+
workflowIdsOf(workspaceId).map((id) => ({
133+
id,
134+
workspaceId,
135+
name: id,
136+
userId: ownerId,
137+
lastSynced: now,
138+
createdAt: now,
139+
updatedAt: now,
140+
}))
141+
)
142+
)
143+
for (const workspaceId of workspaces) {
144+
for (const workflowId of workflowIdsOf(workspaceId)) {
145+
// Before its time pauses, so a probe that stops at the first match stops right away.
146+
if (workspaceId === oneWaiting) await insertPauses(workflowId, 1, humanPausePoints)
147+
await insertPauses(
148+
workflowId,
149+
workspaceId === neighbor ? PAUSES_PER_NEIGHBOR_WORKFLOW : PAUSES_PER_WORKFLOW,
150+
timePausePoints
151+
)
152+
}
153+
}
154+
await db.execute(sql`analyze paused_executions`)
155+
await db.execute(sql`analyze workflow`)
156+
}, 120_000)
157+
158+
afterAll(async () => {
159+
await deleteWorkspaceFixture(db, inArray(workspace.id, workspaces))
160+
await db.delete(user).where(eq(user.id, ownerId))
161+
}, 120_000)
162+
163+
describe('the waiting probe under thousands of time pauses', () => {
164+
it('does not report a time pause as waiting on a person', async () => {
165+
const [row] = await readReviewAndLastRun([timeOnly])
166+
expect(row?.waitingWorkflowName).toBeNull()
167+
}, 30_000)
168+
169+
it('with no match reads this workspace’s pauses through an index, and nobody else’s', async () => {
170+
const { rowsRead, scans, executionMs } = await measureProbe([timeOnly])
171+
expect(rowsRead).toBeGreaterThanOrEqual(LIVE_PAUSES / ROUNDING_SLACK)
172+
expect(rowsRead).toBeLessThanOrEqual(LIVE_PAUSES * ROUNDING_SLACK)
173+
expect(scans).not.toContain('Seq Scan')
174+
expect(executionMs).toBeLessThan(EXECUTION_CEILING_MS)
175+
}, 30_000)
176+
177+
it('stops at the first pause waiting on a person', async () => {
178+
const [row] = await readReviewAndLastRun([oneWaiting])
179+
expect(row?.waitingWorkflowName).not.toBeNull()
180+
181+
const { rowsRead } = await measureProbe([oneWaiting])
182+
expect(rowsRead).toBeLessThanOrEqual(PAUSES_PER_WORKFLOW * ROUNDING_SLACK)
183+
}, 30_000)
184+
185+
it('costs each workspace only its own pauses when asked about several', async () => {
186+
const { rowsRead } = await measureProbe([timeOnly, oneWaiting])
187+
expect(rowsRead).toBeLessThanOrEqual((LIVE_PAUSES + PAUSES_PER_WORKFLOW) * ROUNDING_SLACK)
188+
}, 30_000)
189+
})

‎apps/sim/lib/organizations/home/workspace-activity.ts‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,23 +54,33 @@ export function readRunningWorkflows(
5454
/**
5555
* Per workspace, one workflow waiting on a person and the newest retained run. Each is a `LIMIT 1`
5656
* probe, so many unanswered pauses cost what one does once a match exists. The waiting workflow is
57-
* therefore any of them rather than the newest. With no match, the probe reads every live pause.
57+
* therefore any of them rather than the newest. With no match, the probe reads every live pause of
58+
* that workspace.
59+
*
60+
* The pauses are a lateral of their own so the planner has to start from the workspace's workflows.
61+
* It cannot estimate the JSON test, and as a plain join under `LIMIT 1` it expects an early match and
62+
* scans the whole table for one, reading every organization's pauses once per workspace.
5863
*/
5964
export function readReviewAndLastRun(workspaceIds: readonly string[]) {
6065
const logs = workflowExecutionLogs
61-
const waiting = db
62-
.select({ workflowName: workflow.name })
63-
.from(workflow)
64-
.innerJoin(pausedExecutions, eq(pausedExecutions.workflowId, workflow.id))
66+
const waitingPause = db
67+
.select({ id: pausedExecutions.id })
68+
.from(pausedExecutions)
6569
.where(
6670
and(
67-
eq(workflow.workspaceId, workspace.id),
68-
isNull(workflow.archivedAt),
71+
eq(pausedExecutions.workflowId, workflow.id),
6972
inArray(pausedExecutions.status, LIVE_PAUSED_STATUSES),
7073
sql`jsonb_path_exists(${pausedExecutions.pausePoints}, ${WAITING_ON_A_PERSON})`
7174
)
7275
)
7376
.limit(1)
77+
.as('waiting_pause')
78+
const waiting = db
79+
.select({ workflowName: workflow.name })
80+
.from(workflow)
81+
.innerJoinLateral(waitingPause, sql`true`)
82+
.where(and(eq(workflow.workspaceId, workspace.id), isNull(workflow.archivedAt)))
83+
.limit(1)
7484
.as('waiting')
7585
const lastRun = db
7686
.select({

‎apps/sim/platform/docs/org-parity.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ The shell lane's rows, one per row of its audit. `shell.spec` is `checks/live/sh
361361
| Home | Get started checklist | `app/o/[id]/home/components/get-started/get-started.tsx` | Done | "Search mode on Home offers Get started". Completing each step has no live check |
362362
| Home | Suggested action rows and Connect from a suggestion | `suggested-actions.tsx` | Open | Chat lane. Needs the block template catalog (337-file closure, banned by the seam) behind a route, and a way to hand a prompt to the composer draft. Recents hold that slot on Platform |
363363
| Home | Plan toggle, composer handoff and server chat id | `organization-conversation.tsx` | Open | Chat lane owns `home-composer` |
364+
| Home | Workspace status read stays bounded under thousands of time pauses: only the asked workspaces' pauses are read, stopping at the first waiting on a person | `lib/organizations/home/workspace-activity.ts` | Done | `workspace-activity.integration.ts` measures the real plan on real PostgreSQL. No index is needed; a workspace with no match still reads every one of its live pauses |
364365

365366
## Zone
366367

0 commit comments

Comments
 (0)