Skip to content

Commit 239526a

Browse files
improvement(tables): read row counts and versions through an append-only change log (#8653)
* improvement(tables): read row counts and versions through an append-only change log * chore(helm): bump chart version for the table row-change fold cron job * fix(tables): seek pending fold tables by index and document the sweep result * fix(tables): give the fold cron route a response contract * chore(db): format regenerated row-change migration snapshot
1 parent 648bf45 commit 239526a

18 files changed

Lines changed: 30486 additions & 35 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import {
4+
type FoldTableRowChangesResponse,
5+
foldTableRowChangesContract,
6+
} from '@/lib/api/contracts/tables'
7+
import { parseRequest } from '@/lib/api/server/validation'
8+
import { verifyCronAuth } from '@/lib/auth/internal'
9+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
import { foldPendingTableRowChanges } from '@/lib/table/row-changes'
11+
12+
const logger = createLogger('FoldTableRowChangesApi')
13+
14+
export const dynamic = 'force-dynamic'
15+
export const maxDuration = 60
16+
17+
/** Leaves the route's `maxDuration` room for a fold started at the deadline to finish. */
18+
const FOLD_SWEEP_BUDGET_MS = 45_000
19+
20+
/**
21+
* Folds the table row-change log into each table's definition row. Each fold is one short
22+
* statement, so the sweep runs in the request; overlapping sweeps skip each other's tables.
23+
*/
24+
export const GET = withRouteHandler(async (request: NextRequest) => {
25+
const authError = verifyCronAuth(request, 'Table row-change fold')
26+
if (authError) return authError
27+
28+
const parsed = await parseRequest(foldTableRowChangesContract, request, {})
29+
if (!parsed.success) return parsed.response
30+
31+
const result = await foldPendingTableRowChanges(FOLD_SWEEP_BUDGET_MS)
32+
logger.info('Table row-change fold sweep completed', { ...result })
33+
const body: FoldTableRowChangesResponse = { success: true, ...result }
34+
return NextResponse.json(body)
35+
})

‎apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -706,8 +706,8 @@ export async function copyForkResourceContainers(
706706
schema: remappedSchema,
707707
createdBy: userId,
708708
rowsVersion: 0,
709-
// Start at 0 - the post-commit content copy raises it to the rows actually
710-
// copied, so a failed/partial copy never advertises the source's count.
709+
// Start at 0 - the row-count trigger counts the rows the content copy actually
710+
// inserts, so a failed/partial copy never advertises the source's count.
711711
rowCount: 0,
712712
// Locks are workspace-local governance and never transit a fork edge —
713713
// mirrors `copy-workflows.ts` writing `locked: false`. Inheriting them
@@ -1343,10 +1343,6 @@ export async function copyForkResourceContent(params: {
13431343
}
13441344
if (rows.length < PROVENANCE_CONTENT_PAGE) break
13451345
}
1346-
await db
1347-
.update(userTableDefinitions)
1348-
.set({ rowCount: copied })
1349-
.where(eq(userTableDefinitions.id, table.childId))
13501346
await completeForkCopyResource(control, `table:${table.childId}`)
13511347
copiedResources += 1
13521348
} catch (error) {

‎apps/sim/lib/api/contracts/tables.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2326,3 +2326,18 @@ export const bulkDeleteTablesContract = defineRouteContract({
23262326
),
23272327
},
23282328
})
2329+
2330+
const foldTableRowChangesResponseSchema = z.object({
2331+
success: z.literal(true),
2332+
folded: z.number().int().min(0),
2333+
skipped: z.number().int().min(0),
2334+
budgetExhausted: z.boolean(),
2335+
})
2336+
2337+
export type FoldTableRowChangesResponse = z.output<typeof foldTableRowChangesResponseSchema>
2338+
2339+
export const foldTableRowChangesContract = defineRouteContract({
2340+
method: 'GET',
2341+
path: '/api/cron/fold-table-row-changes',
2342+
response: { mode: 'json', schema: foldTableRowChangesResponseSchema },
2343+
})
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/**
2+
* The table row-change log against real PostgreSQL: reads add the unfolded tail, and the fold
3+
* moves it into the definition row without changing what a reader sees, under concurrent appends.
4+
*/
5+
import { db } from '@sim/db'
6+
import { userTableDefinitions } from '@sim/db/schema'
7+
import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
8+
import { generateId } from '@sim/utils/id'
9+
import postgres from 'postgres'
10+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
11+
import {
12+
foldPendingTableRowChanges,
13+
foldTableRowChanges,
14+
readCurrentRowsVersion,
15+
} from '@/lib/table/row-changes'
16+
import { getTableById, listTables } from '@/lib/table/service'
17+
18+
const url = readTestDatabaseUrl()
19+
if (process.env.DATABASE_URL !== url) {
20+
throw new Error('This suite requires only the disposable local test database')
21+
}
22+
const control = postgres(url, { max: 8, onnotice: () => {} })
23+
const workspaceId = generateId()
24+
const userId = generateId()
25+
26+
async function createTable(): Promise<string> {
27+
const id = generateId()
28+
await db
29+
.insert(userTableDefinitions)
30+
.values({ id, workspaceId, name: id, schema: { columns: [] }, createdBy: userId })
31+
return id
32+
}
33+
34+
async function logChanges(tableId: string, ...deltas: number[]) {
35+
for (const delta of deltas) {
36+
await control`INSERT INTO user_table_row_changes (table_id, row_delta) VALUES (${tableId}, ${delta})`
37+
}
38+
}
39+
40+
async function stored(tableId: string) {
41+
const [row] = await control<{ row_count: number; rows_version: string; updated_at: Date }[]>`
42+
SELECT row_count, rows_version, updated_at FROM user_table_definitions WHERE id = ${tableId}`
43+
return {
44+
rowCount: row.row_count,
45+
rowsVersion: Number(row.rows_version),
46+
updatedAt: row.updated_at,
47+
}
48+
}
49+
50+
async function tailLength(tableId: string): Promise<number> {
51+
const [{ n }] = await control<{ n: number }[]>`
52+
SELECT count(*)::int AS n FROM user_table_row_changes WHERE table_id = ${tableId}`
53+
return n
54+
}
55+
56+
async function liveRowCount(tableId: string): Promise<number> {
57+
const table = await getTableById(tableId)
58+
if (!table) throw new Error('Fixture table missing')
59+
return table.rowCount
60+
}
61+
62+
describe('table row-change log against real PostgreSQL', () => {
63+
beforeAll(async () => {
64+
await control`INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at)
65+
VALUES (${userId}, 'Row change fixture', ${`${userId}@example.test`}, true, now(), now())`
66+
await control`INSERT INTO workspace (id, name, owner_id, billed_account_user_id)
67+
VALUES (${workspaceId}, 'Row change fixtures', ${userId}, ${userId})`
68+
})
69+
70+
afterAll(async () => {
71+
await control`DELETE FROM workspace WHERE id = ${workspaceId}`
72+
await control`DELETE FROM "user" WHERE id = ${userId}`
73+
await control.end()
74+
})
75+
76+
it('reads the stored count and version plus the unfolded tail', async () => {
77+
const tableId = await createTable()
78+
await logChanges(tableId, 5, -2, 0)
79+
80+
expect(await liveRowCount(tableId)).toBe(3)
81+
expect(await readCurrentRowsVersion(tableId)).toBe(3)
82+
expect(await readCurrentRowsVersion(tableId, workspaceId)).toBe(3)
83+
expect(await readCurrentRowsVersion(tableId, generateId())).toBeNull()
84+
const listed = (await listTables(workspaceId)).find((table) => table.id === tableId)
85+
expect(listed?.rowCount).toBe(3)
86+
})
87+
88+
it('folds the tail into the definition row without changing what readers see', async () => {
89+
const tableId = await createTable()
90+
const before = await stored(tableId)
91+
await logChanges(tableId, 4, 0, -1)
92+
93+
expect(await foldTableRowChanges(tableId)).toBe(true)
94+
95+
expect(await tailLength(tableId)).toBe(0)
96+
const after = await stored(tableId)
97+
expect(after.rowCount).toBe(3)
98+
expect(after.rowsVersion).toBe(3)
99+
expect(after.updatedAt.getTime()).toBeGreaterThanOrEqual(before.updatedAt.getTime())
100+
expect(await liveRowCount(tableId)).toBe(3)
101+
expect(await readCurrentRowsVersion(tableId)).toBe(3)
102+
})
103+
104+
it('leaves updated_at alone when the tail holds only updates', async () => {
105+
const tableId = await createTable()
106+
const before = await stored(tableId)
107+
await logChanges(tableId, 0, 0)
108+
109+
await foldTableRowChanges(tableId)
110+
111+
const after = await stored(tableId)
112+
expect(after.rowsVersion).toBe(2)
113+
expect(after.updatedAt.getTime()).toBe(before.updatedAt.getTime())
114+
})
115+
116+
it('skips a table whose definition row is held instead of waiting on it', async () => {
117+
const tableId = await createTable()
118+
await logChanges(tableId, 2)
119+
120+
const held = control.begin(async (holder) => {
121+
await holder`SELECT 1 FROM user_table_definitions WHERE id = ${tableId} FOR NO KEY UPDATE`
122+
const started = Date.now()
123+
const folded = await foldTableRowChanges(tableId)
124+
return { folded, elapsedMs: Date.now() - started }
125+
})
126+
const { folded, elapsedMs } = await held
127+
128+
expect(folded).toBe(false)
129+
expect(elapsedMs).toBeLessThan(1_000)
130+
expect(await tailLength(tableId)).toBe(1)
131+
expect(await liveRowCount(tableId)).toBe(2)
132+
})
133+
134+
it('keeps the live version exact and non-decreasing while writers append during folds', async () => {
135+
const tableId = await createTable()
136+
const writers = 8
137+
const appendsPerWriter = 40
138+
let appending = true
139+
140+
const appenders = Array.from({ length: writers }, async () => {
141+
for (let i = 0; i < appendsPerWriter; i++) await logChanges(tableId, 1)
142+
})
143+
const folder = (async () => {
144+
while (appending) await foldTableRowChanges(tableId)
145+
})()
146+
const versionsSeen: number[] = []
147+
const reader = (async () => {
148+
while (appending) {
149+
const version = await readCurrentRowsVersion(tableId)
150+
if (version === null) throw new Error('Fixture table missing')
151+
versionsSeen.push(version)
152+
}
153+
})()
154+
155+
const appended = await Promise.allSettled(appenders)
156+
appending = false
157+
await Promise.all([folder, reader])
158+
for (const result of appended) if (result.status === 'rejected') throw result.reason
159+
160+
const total = writers * appendsPerWriter
161+
expect(await readCurrentRowsVersion(tableId)).toBe(total)
162+
expect(await liveRowCount(tableId)).toBe(total)
163+
for (let i = 1; i < versionsSeen.length; i++) {
164+
expect(versionsSeen[i]).toBeGreaterThanOrEqual(versionsSeen[i - 1])
165+
}
166+
167+
await foldTableRowChanges(tableId)
168+
expect(await tailLength(tableId)).toBe(0)
169+
expect(await stored(tableId)).toMatchObject({ rowCount: total, rowsVersion: total })
170+
})
171+
172+
it('sweeps every table with a tail', async () => {
173+
const tableIds = [await createTable(), await createTable(), await createTable()]
174+
for (const tableId of tableIds) await logChanges(tableId, 1, 1)
175+
176+
const result = await foldPendingTableRowChanges(30_000)
177+
178+
expect(result.budgetExhausted).toBe(false)
179+
expect(result.folded).toBeGreaterThanOrEqual(tableIds.length)
180+
for (const tableId of tableIds) {
181+
expect(await tailLength(tableId)).toBe(0)
182+
expect(await stored(tableId)).toMatchObject({ rowCount: 2, rowsVersion: 2 })
183+
}
184+
})
185+
})

0 commit comments

Comments
 (0)