Skip to content

Commit 3a9e070

Browse files
committed
test(tables): isolate the fence plan fixture and require both racers to wait
1 parent 2fd8743 commit 3a9e070

1 file changed

Lines changed: 88 additions & 49 deletions

File tree

‎apps/sim/lib/table/rows/row-writes.integration.ts‎

Lines changed: 88 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import { tableBillingMock, tableBillingMockFns } from '@sim/testing/mocks/table-
1212
import { tableTriggerMock } from '@sim/testing/mocks/table-trigger.mock'
1313
import { tableWorkflowColumnsMock } from '@sim/testing/mocks/table-workflow-columns.mock'
1414
import { sleep } from '@sim/utils/helpers'
15-
import { generateId } from '@sim/utils/id'
15+
import { generateId, generateShortId } from '@sim/utils/id'
1616
import { sql } from 'drizzle-orm'
17+
import { drizzle } from 'drizzle-orm/postgres-js'
1718
import postgres from 'postgres'
1819
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
1920

@@ -23,7 +24,6 @@ vi.mock('@/lib/table/workflow-columns', () => tableWorkflowColumnsMock)
2324
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
2425

2526
import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants'
26-
import { withSeqscanOff } from '@/lib/table/planner'
2727
import {
2828
batchInsertRows,
2929
deleteRowsByFilter,
@@ -45,15 +45,10 @@ const control = postgres(url, { max: 4, onnotice: () => {} })
4545
const workspaceId = generateId()
4646
const userId = generateId()
4747

48-
/**
49-
* `db:push` never installs migration-only objects: the `rows_version` triggers and the
50-
* `(workspace_id, table_id)` dependency statistic. Only the cases that depend on them need them.
51-
*/
52-
const [{ migrated }] = await control<{ migrated: boolean }[]>`SELECT
53-
EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'user_table_rows_version_update_trigger')
54-
AND EXISTS (
55-
SELECT 1 FROM pg_statistic_ext WHERE stxname = 'user_table_rows_workspace_table_stats'
56-
) AS migrated`
48+
/** `db:push` never installs the migration-only `rows_version` triggers; only those cases need them. */
49+
const [{ migrated }] = await control<{ migrated: boolean }[]>`SELECT EXISTS (
50+
SELECT 1 FROM pg_trigger WHERE tgname = 'user_table_rows_version_update_trigger'
51+
) AS migrated`
5752

5853
async function createTable(columns: ColumnDefinition[]): Promise<TableDefinition> {
5954
const id = generateId()
@@ -80,6 +75,7 @@ async function rowsVersion(tableId: string): Promise<number> {
8075
interface QueryPlan {
8176
'Node Type': string
8277
'Index Name'?: string
78+
'Subplan Name'?: string
8379
Plans?: QueryPlan[]
8480
}
8581

@@ -383,36 +379,74 @@ describe('table row writes against real PostgreSQL', () => {
383379

384380
expect([...result.affectedRowIds].sort()).toEqual([...expected].sort())
385381
})
382+
})
386383

387-
/** A table large enough that the planner's index choice reflects its statistics. */
388-
async function seedPlanFixture(): Promise<(filter: Filter) => Promise<QueryPlan[]>> {
389-
const table = await createTable(textColumns('kind', 'n'))
390-
await control`INSERT INTO user_table_rows (id, table_id, workspace_id, data, order_key)
391-
SELECT ${table.id} || '-' || n, ${table.id}, ${workspaceId},
384+
/**
385+
* Plans come from a private copy of `user_table_rows` (same columns and indexes, no triggers) in
386+
* its own schema, holding only this fixture and analyzed from a full sample. Integration files
387+
* share one database in parallel, so plans against the shared relation would depend on every
388+
* other suite's rows and statistics.
389+
*/
390+
describe('limited filtered query plans', () => {
391+
const planSchema = `row_writes_${generateShortId()
392+
.replace(/[^a-zA-Z0-9]/g, '')
393+
.toLowerCase()}`
394+
const planConnection = postgres(url, {
395+
max: 1,
396+
onnotice: () => {},
397+
connection: { search_path: `${planSchema}, public` },
398+
})
399+
const planTable: TableDefinition = {
400+
id: 'table-plan',
401+
workspaceId: 'workspace-plan',
402+
name: 'Plan fixture',
403+
description: null,
404+
schema: { columns: textColumns('kind', 'n') },
405+
metadata: null,
406+
rowCount: 20_000,
407+
maxRows: 100_000,
408+
createdBy: 'user-plan',
409+
locks: { schemaLocked: false, insertLocked: false, updateLocked: false, deleteLocked: false },
410+
archivedAt: null,
411+
createdAt: new Date(0),
412+
updatedAt: new Date(0),
413+
}
414+
415+
beforeAll(async () => {
416+
await planConnection`CREATE SCHEMA ${planConnection(planSchema)}`
417+
await planConnection`CREATE TABLE user_table_rows
418+
(LIKE public.user_table_rows INCLUDING DEFAULTS INCLUDING INDEXES)`
419+
await planConnection`INSERT INTO user_table_rows (id, table_id, workspace_id, data, order_key)
420+
SELECT 'row-' || n, ${planTable.id}, ${planTable.workspaceId},
392421
jsonb_build_object('kind', CASE WHEN n % 500 = 0 THEN 'hit' ELSE 'miss' END, 'n', n::text),
393422
'a' || lpad(n::text, 6, '0')
394423
FROM generate_series(1, 20000) AS n`
395-
await control`ANALYZE user_table_rows`
424+
await planConnection`ANALYZE user_table_rows`
425+
})
396426

397-
return async (filter) => {
398-
const filterClause = buildFilterClause(
399-
filter,
400-
USER_TABLE_ROWS_SQL_NAME,
401-
table.schema.columns
402-
)
403-
if (!filterClause) throw new Error('Fixture filter compiled to no clause')
404-
const plans = await withSeqscanOff((trx) =>
405-
trx.execute<{ 'QUERY PLAN': { Plan: QueryPlan }[] }>(
406-
sql`EXPLAIN (FORMAT JSON) ${firstMatchingRowsQuery(table, filter, filterClause, 5, 'rows')}`
407-
)
427+
afterAll(async () => {
428+
await planConnection`DROP SCHEMA ${planConnection(planSchema)} CASCADE`
429+
await planConnection.end()
430+
})
431+
432+
async function planFor(filter: Filter): Promise<QueryPlan[]> {
433+
const filterClause = buildFilterClause(
434+
filter,
435+
USER_TABLE_ROWS_SQL_NAME,
436+
planTable.schema.columns
437+
)
438+
if (!filterClause) throw new Error('Fixture filter compiled to no clause')
439+
const query = firstMatchingRowsQuery(planTable, filter, filterClause, 5, 'rows')
440+
const plans = await drizzle(planConnection).transaction(async (trx) => {
441+
await trx.execute(sql`SET LOCAL enable_seqscan = off`)
442+
return trx.execute<{ 'QUERY PLAN': { Plan: QueryPlan }[] }>(
443+
sql`EXPLAIN (FORMAT JSON) ${query}`
408444
)
409-
return planNodes(plans[0]['QUERY PLAN'][0].Plan)
410-
}
445+
})
446+
return planNodes(plans[0]['QUERY PLAN'][0].Plan)
411447
}
412448

413449
it('fences only a containment filter and leaves any other filter unfenced', async () => {
414-
const planFor = await seedPlanFixture()
415-
416450
const fenced = await planFor({ kind: 'hit' })
417451
expect(fenced.some((node) => node['Node Type'] === 'CTE Scan')).toBe(true)
418452

@@ -421,17 +455,16 @@ describe('table row writes against real PostgreSQL', () => {
421455
expect(walked[0]['Node Type']).toBe('Limit')
422456
})
423457

424-
it.skipIf(!migrated)('probes the tenant GIN index inside the fence', async () => {
425-
const planFor = await seedPlanFixture()
426-
427-
const fenced = await planFor({ kind: 'hit' })
428-
expect(
429-
fenced.some(
430-
(node) =>
431-
node['Node Type'] === 'Bitmap Index Scan' &&
432-
node['Index Name'] === 'user_table_rows_tenant_data_gin_idx'
433-
)
434-
).toBe(true)
458+
it('probes the containment GIN index inside the fence', async () => {
459+
const matched = (await planFor({ kind: 'hit' })).find(
460+
(node) => node['Subplan Name'] === 'CTE matched'
461+
)
462+
const probe =
463+
matched && planNodes(matched).find((node) => node['Node Type'] === 'Bitmap Index Scan')
464+
const [index] = await planConnection<{ indexdef: string }[]>`SELECT indexdef FROM pg_indexes
465+
WHERE schemaname = ${planSchema} AND indexname = ${probe?.['Index Name'] ?? ''}`
466+
expect(index?.indexdef).toContain('USING gin')
467+
expect(index?.indexdef).toContain('jsonb_path_ops')
435468
})
436469
})
437470

@@ -442,7 +475,8 @@ describe('table row writes against real PostgreSQL', () => {
442475

443476
/**
444477
* Holds the table's row-order lock while both inserts start, so each one's pre-insert work runs
445-
* before either can write. Released once both wait on the lock.
478+
* before either can write. Released only once both are seen waiting on this table's lock;
479+
* otherwise the inserts could run one after the other and prove nothing about the race.
446480
*/
447481
async function raceUnderHeldOrderLock(
448482
tableId: string,
@@ -453,13 +487,18 @@ describe('table row writes against real PostgreSQL', () => {
453487
await holder`BEGIN`
454488
await holder`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_rows_pos:${tableId}`}, 0))`
455489
const racers = Promise.allSettled([insert(), insert()])
456-
for (let attempt = 0; attempt < 400; attempt++) {
457-
const [{ waiting }] = await control<{ waiting: number }[]>`SELECT count(*)::int AS waiting
458-
FROM pg_locks WHERE locktype = 'advisory' AND NOT granted
459-
AND database = (SELECT oid FROM pg_database WHERE datname = current_database())`
460-
if (waiting >= 2) break
490+
let waiting = 0
491+
for (let attempt = 0; attempt < 400 && waiting < 2; attempt++) {
461492
await sleep(5)
493+
;[{ waiting }] = await control<{ waiting: number }[]>`
494+
WITH lock AS (SELECT hashtextextended(${`user_table_rows_pos:${tableId}`}, 0) AS key)
495+
SELECT count(*)::int AS waiting FROM pg_locks, lock
496+
WHERE locktype = 'advisory' AND NOT granted AND objsubid = 1
497+
AND database = (SELECT oid FROM pg_database WHERE datname = current_database())
498+
AND classid = ((lock.key >> 32) & 4294967295)::oid
499+
AND objid = (lock.key & 4294967295)::oid`
462500
}
501+
expect(waiting).toBe(2)
463502
await holder`COMMIT`
464503
return await racers
465504
} finally {

0 commit comments

Comments
 (0)