Skip to content

Commit 5a099a7

Browse files
committed
improvement(knowledge): warm the search projection after its backfill
1 parent ea31ca7 commit 5a099a7

5 files changed

Lines changed: 298 additions & 1 deletion

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
vi.mock('@sim/db/script-migrations/0021_embedding_search_connector', () => ({
7+
PROJECTION_SOURCE_ACL_TABLES: ['embedding_search', 'embedding_keyword_tin'],
8+
}))
9+
10+
import {
11+
pgPrewarmInstalled,
12+
prewarmRelation,
13+
prewarmSearchProjection,
14+
} from '@/lib/knowledge/search/prewarm'
15+
16+
interface Statement {
17+
query: string
18+
parameters?: string[]
19+
}
20+
21+
/** A session that records every statement and answers from the case's catalog. */
22+
function session(state: { installed: boolean; relations?: string[]; failing?: string[] }): {
23+
statements: Statement[]
24+
unsafe: (query: string, parameters?: string[]) => Promise<unknown[]>
25+
} {
26+
const statements: Statement[] = []
27+
return {
28+
statements,
29+
unsafe: async (query: string, parameters?: string[]) => {
30+
statements.push({ query, parameters })
31+
if (query.includes('pg_extension')) return state.installed ? [{ '?column?': 1 }] : []
32+
if (query.includes('pg_class'))
33+
return (state.relations ?? []).map((relation) => ({ relation }))
34+
if (query.includes('pg_prewarm(')) {
35+
const [relation] = parameters ?? []
36+
if (state.failing?.includes(relation))
37+
throw new Error(`relation "${relation}" does not exist`)
38+
return [{ pages: 7 }]
39+
}
40+
return []
41+
},
42+
}
43+
}
44+
45+
describe('prewarmSearchProjection', () => {
46+
beforeEach(() => {
47+
vi.clearAllMocks()
48+
})
49+
50+
it('does nothing where the extension is absent, so the application role never needs it', async () => {
51+
const fake = session({ installed: false })
52+
await expect(prewarmSearchProjection(fake)).resolves.toEqual([])
53+
expect(fake.statements).toHaveLength(1)
54+
expect(fake.statements[0].query).toContain("extname = 'pg_prewarm'")
55+
})
56+
57+
it('reads the projections and their ranking indexes in the order the catalog lists them', async () => {
58+
const fake = session({
59+
installed: true,
60+
relations: [
61+
'embedding_search',
62+
'embedding_keyword_tin',
63+
'embedding_search_512_cosine_hnsw_idx',
64+
],
65+
})
66+
const warmed = await prewarmSearchProjection(fake)
67+
expect(warmed.map((item) => item.relation)).toEqual([
68+
'embedding_search',
69+
'embedding_keyword_tin',
70+
'embedding_search_512_cosine_hnsw_idx',
71+
])
72+
expect(warmed.every((item) => item.pages === 7)).toBe(true)
73+
const listed = fake.statements.find((statement) => statement.query.includes('pg_class'))
74+
expect(listed?.parameters).toEqual([
75+
'{embedding_search,embedding_keyword_tin}',
76+
'{hnsw,tin,gin}',
77+
])
78+
expect(listed?.query).toContain("ORDER BY c.relkind = 'r' DESC")
79+
const reads = fake.statements.filter((statement) => statement.query.includes('pg_prewarm('))
80+
expect(reads.map((statement) => statement.parameters)).toEqual([
81+
['embedding_search'],
82+
['embedding_keyword_tin'],
83+
['embedding_search_512_cosine_hnsw_idx'],
84+
])
85+
expect(reads.every((statement) => statement.query.includes("'read'"))).toBe(true)
86+
})
87+
88+
it('skips a relation that fails to warm and carries on with the rest', async () => {
89+
const fake = session({
90+
installed: true,
91+
relations: ['embedding_search', 'embedding_search_512_cosine_hnsw_idx'],
92+
failing: ['embedding_search'],
93+
})
94+
const warmed = await prewarmSearchProjection(fake)
95+
expect(warmed.map((item) => item.relation)).toEqual(['embedding_search_512_cosine_hnsw_idx'])
96+
})
97+
98+
it('returns nothing when the catalog cannot be read, never failing its caller', async () => {
99+
const fake = session({ installed: true })
100+
fake.unsafe = async (query: string) => {
101+
if (query.includes('pg_extension')) return [{ '?column?': 1 }]
102+
throw new Error('permission denied for table pg_class')
103+
}
104+
await expect(prewarmSearchProjection(fake)).resolves.toEqual([])
105+
})
106+
})
107+
108+
describe('prewarmRelation', () => {
109+
it('reports the pages read for one relation', async () => {
110+
const fake = session({ installed: true })
111+
await expect(prewarmRelation(fake, 'embedding_search')).resolves.toMatchObject({
112+
relation: 'embedding_search',
113+
pages: 7,
114+
})
115+
expect(await pgPrewarmInstalled(fake)).toBe(true)
116+
})
117+
})
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { PROJECTION_SOURCE_ACL_TABLES } from '@sim/db/script-migrations/0021_embedding_search_connector'
2+
import { createLogger } from '@sim/logger'
3+
import { getErrorMessage } from '@sim/utils/errors'
4+
5+
const logger = createLogger('SearchProjectionPrewarm')
6+
7+
/**
8+
* The access methods a ranking touches at random: the vector graphs, the Tin keyword index, and
9+
* the GIN index the on-row permission test reads. The remaining b-trees serve hydration, which
10+
* reads a handful of rows by key and is fast cold.
11+
*/
12+
const RANKING_ACCESS_METHODS = ['hnsw', 'tin', 'gin'] as const
13+
14+
/** The one call the helper needs from a `postgres` connection or a reserved session. */
15+
export interface PrewarmSession {
16+
unsafe(query: string, parameters?: string[]): PromiseLike<ArrayLike<Record<string, unknown>>>
17+
}
18+
19+
export interface PrewarmedRelation {
20+
relation: string
21+
pages: number
22+
elapsedMs: number
23+
}
24+
25+
/**
26+
* `pg_prewarm` is not a trusted extension, so the application role cannot create it and no
27+
* migration can; a superuser installs it once. Without it the projection warms only as searches
28+
* touch it, which is what a bulk operation leaves behind.
29+
*/
30+
export async function pgPrewarmInstalled(session: PrewarmSession): Promise<boolean> {
31+
const rows = await session.unsafe("SELECT 1 FROM pg_extension WHERE extname = 'pg_prewarm'")
32+
return rows.length > 0
33+
}
34+
35+
/**
36+
* Reads one relation into the operating system's cache. `read` mode leaves shared buffers to the
37+
* workload, where `buffer` mode would evict them wholesale to make room.
38+
*/
39+
export async function prewarmRelation(
40+
session: PrewarmSession,
41+
relation: string
42+
): Promise<PrewarmedRelation> {
43+
const startedAt = Date.now()
44+
const [row] = Array.from(
45+
await session.unsafe("SELECT pg_prewarm($1::regclass, 'read')::int AS pages", [relation])
46+
)
47+
return { relation, pages: Number(row?.pages ?? 0), elapsedMs: Date.now() - startedAt }
48+
}
49+
50+
/**
51+
* Warms the ranking projections after something streamed through them. A backfill or index build
52+
* reads every heap page in order and pushes the vector graphs out of cache; the next searches
53+
* then fetch the graph one random page at a time from disk, take seconds, and end at their
54+
* deadline with partial results. Reading the projections back in makes the first search after a
55+
* bulk operation as fast as the thousandth.
56+
*
57+
* Heaps go first and the ranking indexes last, so where the cache cannot hold everything the
58+
* indexes are what survives: a walk reads far more index pages than heap pages. Relations are
59+
* resolved through the search path, so a schema that carries its own copy warms its own copy.
60+
* A relation that fails to warm is logged and skipped; warming is never worth failing the
61+
* operation that asked for it.
62+
*/
63+
export async function prewarmSearchProjection(
64+
session: PrewarmSession
65+
): Promise<PrewarmedRelation[]> {
66+
if (!(await pgPrewarmInstalled(session))) {
67+
logger.warn('pg_prewarm is not installed; the search projection warms only as it is searched')
68+
return []
69+
}
70+
let relations: string[]
71+
try {
72+
relations = await rankingRelations(session)
73+
} catch (error) {
74+
logger.warn('Search projection relations could not be listed', {
75+
error: getErrorMessage(error),
76+
})
77+
return []
78+
}
79+
const warmed: PrewarmedRelation[] = []
80+
for (const relation of relations) {
81+
try {
82+
warmed.push(await prewarmRelation(session, relation))
83+
} catch (error) {
84+
logger.warn('Search projection relation failed to warm', {
85+
relation,
86+
error: getErrorMessage(error),
87+
})
88+
}
89+
}
90+
logger.info('Search projection warmed', {
91+
relations: warmed.length,
92+
pages: warmed.reduce((sum, item) => sum + item.pages, 0),
93+
elapsedMs: warmed.reduce((sum, item) => sum + item.elapsedMs, 0),
94+
})
95+
return warmed
96+
}
97+
98+
/** The projections' heaps, then their ranking indexes smallest first, as the search path finds them. */
99+
async function rankingRelations(session: PrewarmSession): Promise<string[]> {
100+
const rows = await session.unsafe(
101+
`WITH heaps AS (
102+
SELECT to_regclass(name) AS oid FROM unnest($1::text[]) AS name
103+
)
104+
SELECT c.oid::regclass::text AS relation
105+
FROM pg_class c
106+
JOIN pg_am am ON am.oid = c.relam
107+
LEFT JOIN pg_index i ON i.indexrelid = c.oid
108+
WHERE c.oid IN (SELECT oid FROM heaps)
109+
OR (
110+
i.indrelid IN (SELECT oid FROM heaps)
111+
AND i.indisvalid
112+
AND am.amname = ANY($2::text[])
113+
)
114+
ORDER BY c.relkind = 'r' DESC, pg_relation_size(c.oid)`,
115+
[toArrayLiteral(PROJECTION_SOURCE_ACL_TABLES), toArrayLiteral(RANKING_ACCESS_METHODS)]
116+
)
117+
return Array.from(rows, (row) => String(row.relation))
118+
}
119+
120+
/** Postgres array literal for identifiers that carry no quotes, commas or braces. */
121+
function toArrayLiteral(values: readonly string[]): string {
122+
return `{${values.join(',')}}`
123+
}

‎apps/sim/lib/knowledge/search/projection-source-acl-backfill.test.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55

6-
const { mockBackfill, mockEnd, mockPostgres, mockTasksTrigger } = vi.hoisted(() => ({
6+
const { mockBackfill, mockEnd, mockPostgres, mockPrewarm, mockTasksTrigger } = vi.hoisted(() => ({
77
mockBackfill: vi.fn(),
88
mockEnd: vi.fn(async () => undefined),
99
mockPostgres: vi.fn(),
10+
mockPrewarm: vi.fn(async () => []),
1011
mockTasksTrigger: vi.fn(async () => ({ id: 'run-1' })),
1112
}))
1213

@@ -16,6 +17,7 @@ vi.mock('@sim/db/script-migrations/0021_embedding_search_connector', () => ({
1617
backfillProjectionSourceAcl: mockBackfill,
1718
}))
1819
vi.mock('postgres', () => ({ default: mockPostgres }))
20+
vi.mock('@/lib/knowledge/search/prewarm', () => ({ prewarmSearchProjection: mockPrewarm }))
1921
vi.mock('@trigger.dev/sdk', () => ({ tasks: { trigger: mockTasksTrigger } }))
2022
vi.mock('@/lib/core/async-jobs/region', () => ({ resolveTriggerRegion: async () => 'us-east-1' }))
2123
vi.mock('@/lib/core/utils/background', () => ({
@@ -57,6 +59,15 @@ describe('runProjectionSourceAclBackfill', () => {
5759
expect(mockEnd).toHaveBeenCalledTimes(1)
5860
})
5961

62+
it('warms the projections on the same connection once both are filled, before closing it', async () => {
63+
await runProjectionSourceAclBackfill({})
64+
expect(mockPrewarm).toHaveBeenCalledTimes(1)
65+
expect(mockPrewarm).toHaveBeenCalledWith(connection)
66+
expect(mockPrewarm.mock.invocationCallOrder[0]).toBeLessThan(
67+
mockEnd.mock.invocationCallOrder[0]
68+
)
69+
})
70+
6071
it('resumes after the cursor in its projection and from the start of the next', async () => {
6172
await runProjectionSourceAclBackfill({
6273
cursor: { projection: 'embedding_keyword_tin', afterId: 'chunk-9' },
@@ -80,6 +91,7 @@ describe('runProjectionSourceAclBackfill', () => {
8091
})
8192
expect(mockBackfill).toHaveBeenCalledTimes(1)
8293
expect(mockBackfill.mock.calls[0][2].budgetMs).toBeLessThanOrEqual(1000)
94+
expect(mockPrewarm).not.toHaveBeenCalled()
8395
expect(mockEnd).toHaveBeenCalledTimes(1)
8496
})
8597

‎apps/sim/lib/knowledge/search/projection-source-acl-backfill.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { resolveTriggerRegion } from '@/lib/core/async-jobs/region'
1010
import { env } from '@/lib/core/config/env'
1111
import { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
1212
import { runDetached } from '@/lib/core/utils/background'
13+
import { prewarmSearchProjection } from '@/lib/knowledge/search/prewarm'
1314

1415
const logger = createLogger('ProjectionSourceAclBackfill')
1516

@@ -69,6 +70,8 @@ export async function runProjectionSourceAclBackfill(
6970
logger.info('Projection source and ACL backfill complete', {
7071
elapsedMs: Date.now() - startedAt,
7172
})
73+
/** The fill just streamed through both projections; put the ranking pages back before anyone searches. */
74+
await prewarmSearchProjection(sql)
7275
return null
7376
} finally {
7477
await sql.end()
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env bun
2+
3+
/**
4+
* Reads the search ranking projections back into the database's cache. Run it after anything that
5+
* streams through them outside the backfill — a restore, a failover, an index rebuild — or when
6+
* searches have started ending at their deadline with partial results after such an event. Needs
7+
* the `pg_prewarm` extension, which a superuser installs once; without it the script says so and
8+
* does nothing.
9+
*
10+
* Usage:
11+
* bun apps/sim/scripts/prewarm-search-projection.ts
12+
*/
13+
14+
import { resolveDbUrl } from '@sim/db'
15+
import { createLogger } from '@sim/logger'
16+
import { toError } from '@sim/utils/errors'
17+
import postgres from 'postgres'
18+
import { prewarmSearchProjection } from '@/lib/knowledge/search/prewarm'
19+
20+
const logger = createLogger('PrewarmSearchProjection')
21+
22+
async function main(): Promise<void> {
23+
const url = resolveDbUrl('DATABASE_URL', process.env.SIM_DB_ROLE?.trim() || 'web')
24+
if (!url) throw new Error('DATABASE_URL is required to warm the search projection')
25+
const sql = postgres(url, { max: 1, max_lifetime: null, onnotice: () => undefined })
26+
try {
27+
const warmed = await prewarmSearchProjection(sql)
28+
for (const item of warmed) logger.info('Warmed', item)
29+
} finally {
30+
await sql.end()
31+
}
32+
}
33+
34+
if (import.meta.main) {
35+
main().then(
36+
() => process.exit(0),
37+
(error) => {
38+
logger.error('Prewarm failed', toError(error))
39+
process.exit(1)
40+
}
41+
)
42+
}

0 commit comments

Comments
 (0)