Skip to content

Commit 2ac7622

Browse files
committed
Merge branch 'wip/next-rollout-tests' into wip/platform-org-wiring
2 parents ab46186 + 53aeaec commit 2ac7622

30 files changed

Lines changed: 1280 additions & 106 deletions

‎.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.

‎.github/scripts/http-e2e.sh‎

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ platform_pid=''
3636
platform_tag=''
3737
platform_log=''
3838
agent_pid=''
39+
failing_zone_pid=''
40+
appconfig_pid=''
3941
browsers_pid=''
4042
local_app_url=''
4143
# `dev` or `start`; a group sets it before start_app.
@@ -78,6 +80,14 @@ finish() {
7880
kill "$agent_pid" 2>/dev/null || true
7981
wait "$agent_pid" 2>/dev/null || true
8082
fi
83+
if [ -n "$failing_zone_pid" ]; then
84+
kill "$failing_zone_pid" 2>/dev/null || true
85+
wait "$failing_zone_pid" 2>/dev/null || true
86+
fi
87+
if [ -n "$appconfig_pid" ]; then
88+
kill "$appconfig_pid" 2>/dev/null || true
89+
wait "$appconfig_pid" 2>/dev/null || true
90+
fi
8191
if [ -n "$browsers_pid" ]; then
8292
kill -- "-$browsers_pid" 2>/dev/null || true
8393
wait "$browsers_pid" 2>/dev/null || true
@@ -162,11 +172,18 @@ start_platform() {
162172
platform_log="$report_dir/platform-live-platform.log"
163173
E2E_APP="$platform_tag" SIM_PLATFORM_API_ORIGIN="$local_app_url" SIM_PLATFORM_BASE_PATH=/org setsid node \
164174
../../node_modules/next/dist/bin/next start platform --hostname 127.0.0.1 --port "$port" \
165-
> "$platform_log" 2>&1 &
175+
>> "$platform_log" 2>&1 &
166176
platform_pid=$!
167177
wait_ready "$platform_pid" "http://127.0.0.1:$port/org" 'Platform zone'
168178
}
169179

180+
# stop_platform: takes the zone away from under a running main app, as an outage does.
181+
stop_platform() {
182+
bash "$GITHUB_WORKSPACE/.github/scripts/stop-session.sh" "$platform_pid" "$platform_tag"
183+
wait "$platform_pid" 2>/dev/null || true
184+
platform_pid=''
185+
}
186+
170187
# platform_live_environment: one hosted Enterprise organization, the deterministic seed its specs
171188
# read, and a scripted worker in place of the model. Hosted is decided by the app's hostname, which
172189
# the job maps to loopback. Google and Microsoft answer only to real credentials, so those clients
@@ -208,6 +225,8 @@ platform_live_environment() {
208225
export PLATFORM_LIVE_ROLE_TARGET_EMAIL=rae@platform-live.test
209226
export PLATFORM_LIVE_REMOVABLE_EMAIL=remy@platform-live.test
210227
export PLATFORM_LIVE_REFUSED_INVITE_EMAIL=otto@platform-live.test
228+
export PLATFORM_LIVE_OFF_ORGANIZATION_ID=platform-live-organization-off
229+
export PLATFORM_LIVE_OFF_EMAIL=odessa@platform-live.test
211230
export PLATFORM_LIVE_ORCHESTRATOR=1
212231
bun --no-env-file scripts/platform-live-seed.ts
213232
}
@@ -488,9 +507,11 @@ JS
488507
bun run test:landing-seo:e2e
489508
;;
490509

491-
# The zone as deployed: flag on, /org rewritten to the Platform build. Then the same build
492-
# with the flag off, which must put everyone back on the legacy surface. Both report, whichever
493-
# fails.
510+
# The zone as deployed: flag on, /org rewritten to the Platform build. Then, on that running app,
511+
# the zone goes away, answers 503 to everything, and comes back. Then the same build with the flag
512+
# off, which must put everyone back on the legacy surface. Then that build with its flags read from
513+
# AppConfig, the real rollout path, targeting one organization by id. Last, that zone's build
514+
# misconfigured, which must refuse to start. Every phase reports, whichever fails.
494515
platform-live-zone)
495516
platform_live_environment
496517
export SIM_PLATFORM_ORIGIN=http://127.0.0.1:3028
@@ -504,21 +525,56 @@ JS
504525
wait_for_browsers
505526
live_status=0
506527
run_platform_live zone platform-live || live_status=$?
528+
stop_platform
529+
run_platform_live zone-down platform-live-zone-down || live_status=$?
530+
bun --no-env-file platform/checks/live/support/failing-zone.ts 3028 \
531+
> "$report_dir/platform-live-failing-zone.log" 2>&1 &
532+
failing_zone_pid=$!
533+
wait_ready "$failing_zone_pid" http://127.0.0.1:3028/health 'failing zone'
534+
run_platform_live zone-down platform-live-zone-5xx || live_status=$?
535+
kill "$failing_zone_pid"
536+
wait "$failing_zone_pid" 2>/dev/null || true
537+
failing_zone_pid=''
538+
start_platform 3028
539+
run_platform_live zone-recovered platform-live-zone-recovered || live_status=$?
507540
stop_app
508541
start_app platform-live-flag-off 3027 'Platform main, flag off' record-http-status
509542
run_platform_live flag-off platform-live-flag-off || live_status=$?
543+
stop_app
544+
bun --no-env-file platform/checks/live/support/appconfig-service.ts 3033 \
545+
> "$report_dir/platform-live-appconfig.log" 2>&1 &
546+
appconfig_pid=$!
547+
wait_ready "$appconfig_pid" http://127.0.0.1:3033/health 'AppConfig fixture'
548+
export APPCONFIG_APPLICATION=platform-live APPCONFIG_ENVIRONMENT=ci AWS_REGION=us-east-1
549+
export AWS_ACCESS_KEY_ID=platform-live-ci AWS_SECRET_ACCESS_KEY=platform-live-ci
550+
export AWS_ENDPOINT_URL_APPCONFIGDATA=http://127.0.0.1:3033
551+
export PLATFORM_LIVE_APPCONFIG_URL=http://127.0.0.1:3033
552+
start_app platform-live-rollout 3027 'Platform main, per-organization rollout' record-http-status
553+
run_platform_live rollout platform-live-rollout || live_status=$?
554+
run_platform_live zone-boot platform-live-zone-boot || live_status=$?
510555
exit "$live_status"
511556
;;
512557

513558
# No zone at all: built without SIM_PLATFORM_ORIGIN, so even with the flag on nothing may change.
559+
# Then that image as a self-hoster runs it: not on a Sim hostname, billing off, no rollout flag.
560+
# Both report, whichever fails.
514561
platform-live-flag-off)
515562
platform_live_environment
516563
export PLATFORM_ORG_UI=true
564+
export ALLOWED_ORIGINS=http://127.0.0.1:3027
517565
start_browser_install platform-live --only-shell chromium
518566
start_app platform-live-flag-off 3027 'Main, no zone' record-http-status
519567
start_realtime platform-live-flag-off 3030
520568
wait_for_browsers
521-
run_platform_live flag-off platform-live-flag-off
569+
live_status=0
570+
run_platform_live flag-off platform-live-flag-off || live_status=$?
571+
stop_app
572+
unset PLATFORM_ORG_UI BILLING_ENABLED NEXT_PUBLIC_BILLING_ENABLED
573+
unset ENTERPRISE_ENABLED NEXT_PUBLIC_ENTERPRISE_ENABLED
574+
export E2E_APP_PUBLIC_URL=http://127.0.0.1:3027 PLATFORM_LIVE_ORIGIN=http://127.0.0.1:3027
575+
start_app platform-live-self-hosted 3027 'Main, self-hosted' record-http-status
576+
run_platform_live self-hosted platform-live-self-hosted || live_status=$?
577+
exit "$live_status"
522578
;;
523579

524580
*)

‎.github/workflows/platform-design.yml‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@ jobs:
112112

113113
# The Platform against the real stack: Postgres, Redis, migrations, the production main app and
114114
# the production Platform. `zone` is the deployed topology, and then that same build with the flag
115-
# off. `flag-off` builds with no zone at all, so it is the only proof /org is truly absent.
115+
# off. `flag-off` builds with no zone at all, so it is the only proof /org is truly absent, and
116+
# then runs that build self-hosted.
116117
# Group environment and seed: `.github/scripts/http-e2e.sh`.
117118
live:
118119
name: live (${{ matrix.topology }})
@@ -189,8 +190,21 @@ jobs:
189190
if: always() && matrix.topology == 'zone'
190191
run: >-
191192
bun run check:platform-parity
193+
--job zone
192194
--report "$RUNNER_TEMP/e2e/platform-live/report.json"
195+
--report "$RUNNER_TEMP/e2e/platform-live-zone-down/report.json"
196+
--report "$RUNNER_TEMP/e2e/platform-live-zone-5xx/report.json"
197+
--report "$RUNNER_TEMP/e2e/platform-live-zone-recovered/report.json"
193198
--report "$RUNNER_TEMP/e2e/platform-live-flag-off/report.json"
199+
--report "$RUNNER_TEMP/e2e/platform-live-rollout/report.json"
200+
--report "$RUNNER_TEMP/e2e/platform-live-zone-boot/report.json"
201+
- name: Hold the parity ledger's flag-off rows to this run
202+
if: always() && matrix.topology == 'flag-off'
203+
run: >-
204+
bun run check:platform-parity
205+
--job flag-off
206+
--report "$RUNNER_TEMP/e2e/platform-live-flag-off/report.json"
207+
--report "$RUNNER_TEMP/e2e/platform-live-self-hosted/report.json"
194208
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
195209
if: always()
196210
with:

‎apps/sim/app/o/[organizationId]/layout.tsx‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,13 @@ export default async function OrganizationLayout({
7474
if (!context) {
7575
return <OrganizationAccessDenied />
7676
}
77-
const platformOrgUi = await isPlatformOrgUiEnabled(organizationId)
78-
if (platformOrgUi) {
79-
const legacyUrl = (await headers()).get(LEGACY_URL_HEADER)
80-
const platformUrl = legacyUrl && mapLegacyOrganizationUrl(legacyUrl, organizationId)
81-
if (platformUrl) redirect(platformUrl)
82-
}
77+
// The proxy sends no legacy URL while the zone is unavailable: this organization stays here, on
78+
// client navigation too, until the zone can serve it.
79+
const legacyUrl = (await isPlatformOrgUiEnabled(organizationId))
80+
? (await headers()).get(LEGACY_URL_HEADER)
81+
: null
82+
const platformUrl = legacyUrl && mapLegacyOrganizationUrl(legacyUrl, organizationId)
83+
if (platformUrl) redirect(platformUrl)
8384
const peopleEnabledLookup = canBrowsePeople(organizationId, context.viewer.role)
8485
const simLinksEnabledLookup = isSimLinksEnabled(organizationId)
8586
if (
@@ -145,7 +146,7 @@ export default async function OrganizationLayout({
145146
>
146147
<OrganizationProvider context={context}>
147148
<OrganizationToastSink />
148-
{platformOrgUi && (
149+
{legacyUrl && (
149150
<Suspense fallback={null}>
150151
<PlatformRouteGuard organizationId={organizationId} />
151152
</Suspense>
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+
})

0 commit comments

Comments
 (0)