Skip to content

Commit 5e4d642

Browse files
committed
fix(workspaces): align detachment with invitation lock order
1 parent ed4996c commit 5e4d642

6 files changed

Lines changed: 382 additions & 8 deletions

File tree

‎.github/workflows/test-build.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ jobs:
129129
lib/billing/core/usage-analytics-queries.postgres.test.ts
130130
lib/billing/core/organization-usage-pagination.postgres.test.ts
131131
lib/billing/organizations/member-limits.postgres.test.ts
132+
lib/workspaces/organization-workspaces.postgres.test.ts
132133
lib/billing/calculations/usage-reservation.test.ts
133134
134135
- name: Verify access request pagination and impact in PostgreSQL

‎apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
schemaMock,
1111
} from '@sim/testing'
1212
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
13+
import { OrchestrationError } from '@/lib/core/orchestration/types'
1314

1415
const {
1516
mockDetachOrganizationWorkspacesTx,
@@ -197,4 +198,23 @@ describe('admin organization DELETE', () => {
197198
expect(recordAudit).not.toHaveBeenCalled()
198199
expect(recordAuditBatch).not.toHaveBeenCalled()
199200
})
201+
202+
it('returns a retryable conflict if the workspace lock set changed', async () => {
203+
queueOrganization()
204+
queueTableRows(schemaMock.subscription, [])
205+
queueTableRows(schemaMock.member, [{ value: 3 }])
206+
const message = 'Organization workspaces changed during detachment; retry'
207+
mockDetachOrganizationWorkspacesTx.mockRejectedValueOnce(
208+
new OrchestrationError('conflict', message)
209+
)
210+
211+
const response = await DELETE(deleteRequest('acme-inc'), routeContext)
212+
213+
expect(response.status).toBe(409)
214+
expect(await response.json()).toMatchObject({ error: { message } })
215+
expect(mockEnqueueResourceCleanup).not.toHaveBeenCalled()
216+
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
217+
expect(recordAudit).not.toHaveBeenCalled()
218+
expect(recordAuditBatch).not.toHaveBeenCalled()
219+
})
200220
})

‎apps/sim/app/api/v1/admin/organizations/[id]/route.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
ENTITLED_SUBSCRIPTION_STATUSES,
5858
TERMINAL_SUBSCRIPTION_STATUSES,
5959
} from '@/lib/billing/subscriptions/utils'
60+
import { OrchestrationError } from '@/lib/core/orchestration/types'
6061
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
6162
import { enqueueOrganizationResourceCleanup } from '@/lib/organizations/resource-cleanup'
6263
import { detachOrganizationWorkspacesTx } from '@/lib/workspaces/organization-workspaces'
@@ -338,6 +339,9 @@ export const DELETE = withRouteHandler(
338339
})
339340
} catch (error) {
340341
logger.error('Admin API: Failed to delete organization', { error, organizationId })
342+
if (error instanceof OrchestrationError && error.code === 'conflict') {
343+
return conflictResponse(error.message)
344+
}
341345
return internalErrorResponse('Failed to delete organization')
342346
}
343347
})
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/** @vitest-environment node */
2+
3+
import * as schema from '@sim/db/schema'
4+
import { generateId } from '@sim/utils/id'
5+
import { eq, sql } from 'drizzle-orm'
6+
import { drizzle } from 'drizzle-orm/postgres-js'
7+
import postgres from 'postgres'
8+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
9+
10+
const { databaseUrl } = vi.hoisted(() => {
11+
const databaseUrl = process.env.BILLING_USAGE_TEST_DATABASE_URL
12+
if (databaseUrl && !['localhost', '127.0.0.1', '[::1]'].includes(new URL(databaseUrl).hostname)) {
13+
throw new Error('Workspace detachment integration tests require a disposable local database')
14+
}
15+
return { databaseUrl }
16+
})
17+
vi.unmock('drizzle-orm')
18+
vi.unmock('@sim/db/schema')
19+
20+
import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership'
21+
import { acquireInvitationMutationLocks } from '@/lib/invitations/locks'
22+
import { detachOrganizationWorkspacesTx } from '@/lib/workspaces/organization-workspaces'
23+
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
24+
25+
const schemaName = `workspace_detach_${generateId().replaceAll('-', '')}`
26+
const connection = databaseUrl
27+
? postgres(databaseUrl, {
28+
max: 3,
29+
prepare: false,
30+
connection: { search_path: schemaName, application_name: schemaName },
31+
onnotice: () => undefined,
32+
})
33+
: undefined
34+
const database = connection ? drizzle(connection, { schema }) : undefined
35+
36+
beforeAll(async () => {
37+
if (!connection) return
38+
await connection.unsafe(`CREATE SCHEMA "${schemaName}"`)
39+
await connection.unsafe(`
40+
CREATE TABLE member (id text PRIMARY KEY, organization_id text, user_id text, role text);
41+
CREATE TABLE organization (id text PRIMARY KEY, storage_used_bytes bigint NOT NULL);
42+
CREATE TABLE invitation (
43+
id text PRIMARY KEY, organization_id text REFERENCES organization(id) ON DELETE CASCADE
44+
);
45+
CREATE TABLE user_stats (user_id text PRIMARY KEY, storage_used_bytes bigint NOT NULL);
46+
CREATE TABLE workspace (
47+
id text PRIMARY KEY, name text, owner_id text, organization_id text, workspace_mode text,
48+
billed_account_user_id text, allow_personal_api_keys boolean DEFAULT true,
49+
archived_at timestamp, organization_assigned_at timestamp, updated_at timestamp,
50+
storage_used_bytes bigint NOT NULL
51+
);
52+
CREATE TABLE permissions (
53+
id text PRIMARY KEY, user_id text, entity_type text, entity_id text, permission_type text,
54+
created_at timestamp, updated_at timestamp, UNIQUE(user_id, entity_type, entity_id)
55+
);
56+
CREATE TABLE workspace_files (workspace_id text, context text, size_bytes bigint);
57+
CREATE TABLE knowledge_base (id text PRIMARY KEY, workspace_id text);
58+
CREATE TABLE document (
59+
knowledge_base_id text, file_size bigint, connector_id text, deleted_at timestamp
60+
);
61+
`)
62+
})
63+
64+
beforeEach(async () => {
65+
if (!connection) return
66+
await connection.unsafe(`
67+
TRUNCATE member, organization, invitation, user_stats, workspace, permissions,
68+
workspace_files, knowledge_base, document;
69+
INSERT INTO member VALUES ('owner-membership', 'org', 'org-owner', 'owner');
70+
INSERT INTO organization VALUES ('org', 40);
71+
INSERT INTO invitation VALUES ('invitation', 'org');
72+
INSERT INTO user_stats VALUES ('org-owner', 5);
73+
INSERT INTO workspace (
74+
id, name, owner_id, organization_id, workspace_mode, billed_account_user_id,
75+
organization_assigned_at, storage_used_bytes
76+
) VALUES ('workspace', 'Workspace', 'workspace-owner', 'org', 'organization', 'org-owner', now(), 40);
77+
INSERT INTO workspace_files VALUES ('workspace', 'workspace', 40);
78+
`)
79+
})
80+
81+
afterAll(async () => {
82+
if (!connection) return
83+
await connection.unsafe(`DROP SCHEMA "${schemaName}" CASCADE`)
84+
await connection.end()
85+
})
86+
87+
describe.skipIf(!databaseUrl)('organization workspace detachment lock order', () => {
88+
it.each(['standalone', 'organization-delete'] as const)(
89+
'lets acceptance finish before %s without inverting invitation, workspace, or organization locks',
90+
async (mode) => {
91+
const acceptanceReady = Promise.withResolvers<void>()
92+
const continueAcceptance = Promise.withResolvers<void>()
93+
const acceptance = database!
94+
.transaction(async (tx) => {
95+
await tx.execute(sql`SET LOCAL statement_timeout = '4s'`)
96+
await acquireInvitationMutationLocks(tx, {
97+
invitationIds: ['invitation'],
98+
workspaceIds: [],
99+
})
100+
await tx
101+
.select({ id: schema.invitation.id })
102+
.from(schema.invitation)
103+
.where(eq(schema.invitation.id, 'invitation'))
104+
.for('update')
105+
if (mode === 'organization-delete') {
106+
acceptanceReady.resolve()
107+
await continueAcceptance.promise
108+
}
109+
await acquireInvitationMutationLocks(tx, {
110+
invitationIds: [],
111+
workspaceIds: ['workspace'],
112+
})
113+
const current = await getWorkspaceWithOwner('workspace', {
114+
executor: tx,
115+
forUpdate: true,
116+
})
117+
expect(current?.organizationId).toBe('org')
118+
if (mode === 'standalone') {
119+
acceptanceReady.resolve()
120+
await continueAcceptance.promise
121+
}
122+
await acquireOrganizationMutationLock(tx, 'org')
123+
})
124+
.catch((error: unknown) => {
125+
acceptanceReady.reject(error)
126+
throw error
127+
})
128+
const acceptanceOutcome = Promise.allSettled([acceptance])
129+
await acceptanceReady.promise
130+
131+
const detachPid = Promise.withResolvers<number>()
132+
const detachment = database!
133+
.transaction(async (tx) => {
134+
await tx.execute(sql`SET LOCAL statement_timeout = '4s'`)
135+
const [backend] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`)
136+
detachPid.resolve(backend.pid)
137+
const result = await detachOrganizationWorkspacesTx(tx, 'org')
138+
if (mode === 'organization-delete') {
139+
await tx.delete(schema.organization).where(eq(schema.organization.id, 'org'))
140+
}
141+
return result
142+
})
143+
.catch((error: unknown) => {
144+
detachPid.reject(error)
145+
throw error
146+
})
147+
const detachOutcome = Promise.allSettled([detachment])
148+
149+
try {
150+
const pid = await detachPid.promise
151+
await vi.waitFor(
152+
async () => {
153+
const [waiting] = await connection!`
154+
SELECT wait_event_type FROM pg_stat_activity WHERE pid = ${pid}
155+
`
156+
expect(waiting.wait_event_type).toBe('Lock')
157+
},
158+
{ timeout: 2000 }
159+
)
160+
} finally {
161+
continueAcceptance.resolve()
162+
await Promise.all([acceptanceOutcome, detachOutcome])
163+
}
164+
165+
await expect(acceptance).resolves.toBeUndefined()
166+
await expect(detachment).resolves.toMatchObject({
167+
detachedWorkspaceIds: ['workspace'],
168+
billedAccountUserId: 'org-owner',
169+
auditEntries: [{ resourceId: 'workspace' }],
170+
})
171+
const [detached] = await connection!`
172+
SELECT organization_id, workspace_mode, billed_account_user_id,
173+
organization_assigned_at, storage_used_bytes::int
174+
FROM workspace WHERE id = 'workspace'
175+
`
176+
expect(detached).toEqual({
177+
organization_id: null,
178+
workspace_mode: 'grandfathered_shared',
179+
billed_account_user_id: 'org-owner',
180+
organization_assigned_at: null,
181+
storage_used_bytes: 40,
182+
})
183+
expect(
184+
await connection!`SELECT storage_used_bytes::int FROM organization WHERE id = 'org'`
185+
).toEqual(mode === 'organization-delete' ? [] : [{ storage_used_bytes: 0 }])
186+
expect(await connection!`SELECT id FROM invitation`).toEqual(
187+
mode === 'organization-delete' ? [] : [{ id: 'invitation' }]
188+
)
189+
expect(
190+
await connection!`SELECT storage_used_bytes::int FROM user_stats WHERE user_id = 'org-owner'`
191+
).toEqual([{ storage_used_bytes: 45 }])
192+
expect(
193+
await connection!`
194+
SELECT user_id, entity_type, entity_id, permission_type FROM permissions
195+
`
196+
).toEqual([
197+
{
198+
user_id: 'org-owner',
199+
entity_type: 'workspace',
200+
entity_id: 'workspace',
201+
permission_type: 'admin',
202+
},
203+
])
204+
}
205+
)
206+
})

0 commit comments

Comments
 (0)