Skip to content

Commit f7b4147

Browse files
authored
perf(logs): order public log pages on an indexed column and retire a superseded document index (#8913)
* perf(logs): order the public log keyset on the raw started_at column The public logs list ordered by date_trunc('milliseconds', started_at), which no index serves, so every page read and sorted every run in scope and computed the handled-error flag for each. Order and compare on the raw column instead, carrying a full-precision cursor value (to_char ... US"Z") so the keyset can name the exact stored instant. Cursors minted before this keep the truncated keyset they were read under until their walk ends, so in-flight pagination neither skips nor repeats rows. Also: - the precise timestamp key opts into stating its implied inclusive bound in keysetAfter, so a resumed page starts its index scan at the cursor instead of filtering down to it; every other keyset list renders unchanged SQL - the workflowIds filter matches workflow_execution_logs.workflow_id rather than the joined workflow.id (equivalent under the SET NULL FK), which lets a multi-workflow list reach the log indexes * perf(db): drop the superseded reconciliation index and index the sync-log prune - Drop doc_connector_reconciliation_idx (contract of 0388): it keys on source_seen_at, so every connector listing's seen stamp is a non-HOT update that rewrites every document index; nothing orders by it since v0.9.4. - Add kcsl_terminal_started_at_idx, partial on the retention prune's status list, so the sync-log prune is a range scan on started_at. * fix(logs): classify timestamp cursors in one place so v1 never 500s on one The v1 decoder admitted any date a Date could parse, while the precise key bound only the six-digit form, so a no-fraction or four-digit cursor passed the decoder and failed in the query as a 500. classifyTimestampCursor now decides for both: a value in the precise form must name a real instant or is invalid (a 400 at the v1 decoder), and any other parseable date in years 1-9999 resumes under the millisecond keyset exactly as before. * test(logs): assert the precise cursor decodes before paging from it
1 parent 218dce3 commit f7b4147

10 files changed

Lines changed: 31413 additions & 45 deletions

File tree

‎apps/sim/lib/api/list-query.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
keysetAfter,
1515
listOrderBy,
1616
numberKey,
17+
preciseTimestampKey,
1718
searchFilter,
1819
textKey,
1920
timestampKey,
@@ -108,6 +109,21 @@ describe('timestampKey', () => {
108109
})
109110
})
110111

112+
describe('preciseTimestampKey', () => {
113+
const preciseKey = preciseTimestampKey<{ at: string }>(thing.createdAt, (r) => r.at)
114+
115+
/** A cursor is caller-controlled, and each of these would reach the cast and 500. */
116+
it.each([
117+
['a millisecond cursor', '2024-01-01T00:00:00.123Z'],
118+
['an impossible calendar day', '2024-02-30T00:00:00.000000Z'],
119+
['an impossible clock time', '2024-01-01T24:00:00.000000Z'],
120+
['year zero', '0000-01-01T00:00:00.000000Z'],
121+
['a non-string value', 1700000000000],
122+
])('rejects %s', (_case, value) => {
123+
expect(preciseKey.bind(value)).toBeNull()
124+
})
125+
})
126+
111127
describe('decimalKey', () => {
112128
const costKey = decimalKey<{ cost: string }>(thing.cost, (r) => r.cost)
113129

‎apps/sim/lib/api/list-query.ts‎

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import {
66
desc,
77
eq,
88
gt,
9+
gte,
910
ilike,
1011
lt,
12+
lte,
1113
or,
1214
type SQL,
1315
type SQLWrapper,
@@ -62,6 +64,11 @@ export interface KeysetKey<Row> {
6264
encode: (row: Row) => CursorKey
6365
/** The cursor value as bindable SQL, or `null` when this key cannot hold it. */
6466
bind: (value: CursorKey) => SQL | null
67+
/**
68+
* Whether a cursor leading with this key also states its inclusive bound — see
69+
* {@link keysetAfter}. Set only on a key an index serves in order.
70+
*/
71+
boundsScan?: boolean
6572
}
6673

6774
/** A text key — names, titles, ids. */
@@ -128,9 +135,9 @@ export function decimalKey<Row>(column: SQLWrapper, read: (row: Row) => string):
128135
* can express, so the `id` tiebreaker is what actually separates rows inside a
129136
* millisecond.
130137
*
131-
* `date_trunc` rules out an index-ordered scan, but none of the timestamp
132-
* columns sorted here are indexed, so it costs nothing today. Adding an index
133-
* to serve one of these sorts means indexing this same expression.
138+
* `date_trunc` rules out an index-ordered scan on the column. A list whose
139+
* sort an index on the raw column should serve uses {@link preciseTimestampKey}
140+
* instead, which orders on the column itself.
134141
*
135142
* The column serializes the `Date` (drizzle's own timestamp encoder) and also
136143
* supplies the cast. A bare placeholder arrives as `unknown` and `date_trunc` is
@@ -154,6 +161,71 @@ export function timestampKey<Row>(column: Column, read: (row: Row) => Date): Key
154161
}
155162
}
156163

164+
/**
165+
* The text spelling a {@link preciseTimestampKey} cursor carries: ISO 8601 in
166+
* UTC with all six fractional digits Postgres stores.
167+
*/
168+
const PRECISE_TIMESTAMP_CURSOR_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/
169+
170+
/**
171+
* What a timestamp cursor value is: a {@link preciseTimestampKey} position, a
172+
* date for the millisecond {@link timestampKey}, or neither.
173+
*
174+
* The one place a timestamp cursor is classified, so a codec that validates a
175+
* caller's token and the keyset that later binds it cannot disagree — a token
176+
* the codec admits but the key refuses would surface as a 500 on a surface that
177+
* renders its own errors.
178+
*
179+
* A value spelled in the precise form is precise or invalid, never a date: it
180+
* must name a real instant, because the pattern admits `2026-02-30`, which a
181+
* `Date` silently rolls into March and Postgres refuses outright. Anything else a
182+
* `Date` parses is a date. Both classes stay within years 1 to 9999, the range
183+
* whose ISO spelling Postgres reads back.
184+
*/
185+
export function classifyTimestampCursor(value: CursorKey): 'precise' | 'date' | 'invalid' {
186+
if (typeof value !== 'string') return 'invalid'
187+
const date = new Date(value)
188+
const year = date.getUTCFullYear()
189+
if (Number.isNaN(date.getTime()) || year < 1 || year > 9999) return 'invalid'
190+
if (!PRECISE_TIMESTAMP_CURSOR_PATTERN.test(value)) return 'date'
191+
return date.toISOString().slice(0, 23) === value.slice(0, 23) ? 'precise' : 'invalid'
192+
}
193+
194+
/**
195+
* A `timestamp` column rendered as a {@link preciseTimestampKey} cursor value.
196+
*
197+
* Select it next to the column so each row carries its own full-precision
198+
* position. Only for a `timestamp` (without time zone) column, which holds UTC
199+
* wall-clock time: `to_char` renders a `timestamptz` in the session time zone,
200+
* which the trailing `Z` would misstate.
201+
*/
202+
export function preciseTimestampText(column: Column): SQL<string> {
203+
return sql<string>`to_char(${column}, 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`
204+
}
205+
206+
/**
207+
* A `timestamp` key ordered and compared on the raw column at full microsecond
208+
* precision, so an index on the column serves the sort.
209+
*
210+
* {@link timestampKey} truncates to milliseconds because its cursor value comes
211+
* from a JS `Date`; this key instead reads the row's {@link preciseTimestampText}
212+
* value, so the cursor names the exact stored instant and the raw ordering needs
213+
* no truncation. `timestamp` input ignores the trailing `Z`. It binds only a
214+
* value {@link classifyTimestampCursor} calls precise.
215+
*/
216+
export function preciseTimestampKey<Row>(
217+
column: Column,
218+
read: (row: Row) => string
219+
): KeysetKey<Row> {
220+
return {
221+
expr: column,
222+
encode: read,
223+
boundsScan: true,
224+
bind: (value) =>
225+
classifyTimestampCursor(value) === 'precise' ? sql`cast(${value} as timestamp)` : null,
226+
}
227+
}
228+
157229
export function sortDirection(order: ListSortOrder): typeof asc {
158230
return order === 'asc' ? asc : desc
159231
}
@@ -233,6 +305,12 @@ export function keysetPage<Row>(
233305
* direction, expanded lexicographically so ties on a leading key fall through
234306
* to the next one.
235307
*
308+
* When the leading key {@link KeysetKey.boundsScan | bounds the scan}, the
309+
* cursor also carries that key's inclusive bound, which the expansion already
310+
* implies. Postgres cannot derive a range from an `OR`, so without it an index
311+
* on the leading key starts every page at the top of the list and filters its
312+
* way down to the cursor.
313+
*
236314
* Returns `null` when the cursor does not fit this sort — wrong number of keys,
237315
* or a value the key cannot hold. Callers render that as a 400 rather than
238316
* paging from a nonsense position.
@@ -255,5 +333,9 @@ export function keysetAfter<Row>(
255333
const clauses = keys.map((key, i) =>
256334
and(...keys.slice(0, i).map((prior, j) => eq(prior.expr, bound[j])), beyond(key.expr, bound[i]))
257335
)
258-
return or(...clauses) ?? null
336+
const after = or(...clauses)
337+
if (!after) return null
338+
if (keys.length === 1 || !keys[0].boundsScan) return after
339+
const atOrBeyond = order === 'asc' ? gte : lte
340+
return and(atOrBeyond(keys[0].expr, bound[0]), after) ?? null
259341
}

‎apps/sim/lib/logs/public-filters.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,10 @@ export function buildLogFilters(filters: LogFilters): SQL<unknown> {
6464

6565
conditions.push(eq(workflowExecutionLogs.workspaceId, filters.workspaceId))
6666

67-
// Workflow IDs filter
67+
// The log's own column, not the joined `workflow.id`: the FK nulls it on delete, so
68+
// both select the same rows, but only this side reaches the log indexes.
6869
if (filters.workflowIds && filters.workflowIds.length > 0) {
69-
conditions.push(inArray(workflow.id, filters.workflowIds))
70+
conditions.push(inArray(workflowExecutionLogs.workflowId, filters.workflowIds))
7071
}
7172

7273
// Folder IDs filter
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/**
2+
* Public log paging against real PostgreSQL: the `(started_at, id)` keyset walks
3+
* rows that share a millisecond in their stored microsecond order, across both
4+
* log tables, without repeating or dropping one.
5+
*/
6+
import { db } from '@sim/db'
7+
import {
8+
jobExecutionLogs,
9+
user,
10+
workflow,
11+
workflowExecutionLogs,
12+
workflowExecutionSnapshots,
13+
workspace,
14+
} from '@sim/db/schema'
15+
import { generateId } from '@sim/utils/id'
16+
import { eq, sql } from 'drizzle-orm'
17+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
18+
import type { CursorKey, ListSortOrder } from '@/lib/api/list-query'
19+
import type { LogFilters } from '@/lib/logs/public-filters'
20+
import {
21+
decodePublicLogCursor,
22+
encodePublicLogCursor,
23+
readPublicLogPage,
24+
} from '@/lib/logs/public-queries'
25+
26+
const prefix = generateId()
27+
const ids = {
28+
owner: `public-log-paging-${prefix}`,
29+
workspace: generateId(),
30+
workflow: generateId(),
31+
snapshot: generateId(),
32+
}
33+
34+
/** Run id → microseconds past `12:00:00.123`, inverted against id order inside the millisecond. */
35+
const WORKFLOW_RUNS = {
36+
[`${prefix}-0`]: -1000,
37+
[`${prefix}-1`]: 100,
38+
[`${prefix}-2`]: 500,
39+
[`${prefix}-3`]: 500,
40+
[`${prefix}-4`]: 900,
41+
}
42+
const JOB_RUNS = { [`${prefix}-5`]: 700 }
43+
44+
/** Stored order, newest first: microseconds decide, and `id` only splits the exact tie. */
45+
const NEWEST_FIRST = [4, 5, 3, 2, 1, 0].map((n) => `${prefix}-${n}`)
46+
47+
function startedAt(offsetMicroseconds: number) {
48+
return sql`timestamp '2026-01-01 12:00:00.123' + ${offsetMicroseconds} * interval '1 microsecond'`
49+
}
50+
51+
async function walk(
52+
filters: Omit<LogFilters, 'workspaceId'>,
53+
order: ListSortOrder,
54+
options: { includeJobRuns: boolean; cursorKeys?: CursorKey[] }
55+
) {
56+
const seen: string[] = []
57+
const cursors: CursorKey[][] = []
58+
let cursorKeys = options.cursorKeys
59+
for (let page = 0; page <= NEWEST_FIRST.length; page++) {
60+
const result = await readPublicLogPage({
61+
filters: { ...filters, workspaceId: ids.workspace },
62+
limit: 1,
63+
includeExecutionData: false,
64+
includeJobRuns: options.includeJobRuns,
65+
sortBy: 'startedAt',
66+
sortOrder: order,
67+
cursorKeys,
68+
})
69+
seen.push(...result.data.map((row) => row.id))
70+
if (!result.nextCursorKeys) break
71+
cursors.push(result.nextCursorKeys)
72+
cursorKeys = result.nextCursorKeys
73+
}
74+
return { seen, cursors }
75+
}
76+
77+
beforeAll(async () => {
78+
const now = new Date()
79+
await db.insert(user).values({
80+
id: ids.owner,
81+
name: 'Public Log Paging',
82+
email: `${ids.owner}@public-log-paging.test`,
83+
emailVerified: true,
84+
createdAt: now,
85+
updatedAt: now,
86+
})
87+
await db.insert(workspace).values({
88+
id: ids.workspace,
89+
name: 'Public Log Paging',
90+
ownerId: ids.owner,
91+
billedAccountUserId: ids.owner,
92+
})
93+
await db.insert(workflow).values({
94+
id: ids.workflow,
95+
userId: ids.owner,
96+
workspaceId: ids.workspace,
97+
name: 'Public Log Paging',
98+
lastSynced: now,
99+
createdAt: now,
100+
updatedAt: now,
101+
})
102+
await db.insert(workflowExecutionSnapshots).values({
103+
id: ids.snapshot,
104+
workflowId: ids.workflow,
105+
stateHash: ids.snapshot,
106+
stateData: {},
107+
})
108+
await db.insert(workflowExecutionLogs).values(
109+
Object.entries(WORKFLOW_RUNS).map(([id, offset]) => ({
110+
id,
111+
workflowId: ids.workflow,
112+
workspaceId: ids.workspace,
113+
executionId: generateId(),
114+
stateSnapshotId: ids.snapshot,
115+
level: 'info',
116+
status: 'completed',
117+
trigger: 'api',
118+
startedAt: startedAt(offset),
119+
}))
120+
)
121+
await db.insert(jobExecutionLogs).values(
122+
Object.entries(JOB_RUNS).map(([id, offset]) => ({
123+
id,
124+
workspaceId: ids.workspace,
125+
executionId: generateId(),
126+
level: 'info',
127+
status: 'completed',
128+
trigger: 'mothership',
129+
startedAt: startedAt(offset),
130+
}))
131+
)
132+
}, 30_000)
133+
134+
afterAll(async () => {
135+
await db.delete(workflowExecutionLogs).where(eq(workflowExecutionLogs.workspaceId, ids.workspace))
136+
await db.delete(jobExecutionLogs).where(eq(jobExecutionLogs.workspaceId, ids.workspace))
137+
await db.delete(workflowExecutionSnapshots).where(eq(workflowExecutionSnapshots.id, ids.snapshot))
138+
await db.delete(workflow).where(eq(workflow.id, ids.workflow))
139+
await db.delete(workspace).where(eq(workspace.id, ids.workspace))
140+
await db.delete(user).where(eq(user.id, ids.owner))
141+
})
142+
143+
describe('public log keyset paging', () => {
144+
it.each([
145+
['desc', NEWEST_FIRST],
146+
['asc', [...NEWEST_FIRST].reverse()],
147+
] as const)(
148+
'pages one run at a time in stored %s order across a shared millisecond and both tables',
149+
async (order, expected) => {
150+
const { seen } = await walk({}, order, { includeJobRuns: true })
151+
152+
expect(seen).toEqual(expected)
153+
}
154+
)
155+
156+
it('pages a workflow filter in stored order', async () => {
157+
const { seen } = await walk({ workflowIds: [ids.workflow] }, 'desc', {
158+
includeJobRuns: false,
159+
})
160+
161+
expect(seen).toEqual(NEWEST_FIRST.filter((id) => id in WORKFLOW_RUNS))
162+
})
163+
164+
/**
165+
* Earlier releases accepted any date a v1 caller sent and resumed it under
166+
* millisecond ordering, which `-0` (`.122`) then the `.123` runs in `id` order.
167+
*/
168+
it.each([
169+
['without a fraction', '2026-01-01T12:00:00Z', [0, 1, 2, 3, 4, 5]],
170+
['with a four-digit fraction', '2026-01-01T12:00:00.1225Z', [1, 2, 3, 4, 5]],
171+
])('resumes a v1 date cursor %s under millisecond ordering', async (_case, startedAt, rest) => {
172+
const cursor = decodePublicLogCursor(
173+
encodePublicLogCursor({ startedAt, id: `${prefix}-0`, order: 'asc' }),
174+
'asc'
175+
)
176+
expect(cursor).not.toBeNull()
177+
178+
const { seen } = await walk({}, 'asc', {
179+
includeJobRuns: true,
180+
cursorKeys: [cursor?.startedAt ?? '', cursor?.id ?? ''],
181+
})
182+
183+
expect(seen).toEqual(rest.map((n) => `${prefix}-${n}`))
184+
})
185+
186+
/**
187+
* A cursor minted before raw ordering names a millisecond and was read under
188+
* millisecond-truncated ordering, where the shared millisecond fell in `id`
189+
* order: `-5`, then `-4`, … Resuming after `-4` must serve exactly the runs
190+
* that order had not reached, and keep minting cursors it can resume.
191+
*/
192+
it('finishes a walk begun under a millisecond cursor in the order it began', async () => {
193+
const { seen, cursors } = await walk({}, 'desc', {
194+
includeJobRuns: true,
195+
cursorKeys: ['2026-01-01T12:00:00.123Z', `${prefix}-4`],
196+
})
197+
198+
expect(seen).toEqual([3, 2, 1, 0].map((n) => `${prefix}-${n}`))
199+
for (const [startedAtKey] of cursors) {
200+
expect(startedAtKey).toMatch(/\.\d{3}Z$/)
201+
}
202+
})
203+
})

0 commit comments

Comments
 (0)