Skip to content

Commit cd837b6

Browse files
committed
chore(db): add dormant org search recovery runbook and scripts
Organization indexed search is dormant now that live search serves those queries, but an organization search index can still hold most of the knowledge search rows and all of the Tin keyword projection, inflating the shared vector index for every workspace search. This adds an ordered, resumable runbook and operator scripts to reclaim that space. Every script only reads unless --execute is passed. - disable-tin-projection: drops the three Tin sync triggers and truncates embedding_keyword_tin in one transaction, with short lock timeouts retried within a budget. The shared document ACL fan-out is left intact and becomes a probe of an empty table. - restore-tin-projection: reinstalls those triggers from the 0019 and 0024 migration functions themselves, with an optional 0019 backfill. - delete-search-index-documents: deletes one search index's connector documents and chunks in cursor pages, mirroring the app's connector cleanup worker. It refuses unless the base is a search index and every live connector is paused or disabled with no sync lease, re-checked before every page. Storage objects are queued through the app's own storage cleanup outbox in the deleting transaction, with a backlog ceiling. The knowledge base and connectors are kept; their listing cursors are reset so a resumed connector lists everything again. Deletes fire no row triggers and write no projector marks, since projection rows go by foreign-key cascade. - maintenance: health report, REINDEX INDEX CONCURRENTLY (HNSW first, before vacuum) and one-table-at-a-time VACUUM (VERBOSE, ANALYZE). Unit tests cover the guards, paging and resume, retries, backpressure and dry runs. A local-only PostgreSQL integration test exercises the real scripts end to end and is not registered in CI. No migration or schema changes.
1 parent 535f052 commit cd837b6

14 files changed

Lines changed: 2861 additions & 0 deletions

‎apps/sim/scripts/dormant-org-search/README.md‎

Lines changed: 269 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { resolveMigrationDatabaseUrl } from '@sim/db/script-migrations/database-url'
2+
import postgres, { type Sql } from 'postgres'
3+
4+
/**
5+
* Whether a run may write. Every script in this directory reads and reports unless the operator
6+
* passes `--execute`; `--dry-run` is accepted for explicitness, and passing both is refused rather
7+
* than resolved in either direction.
8+
*/
9+
export function resolveExecuteFlag(flags: { execute?: boolean; 'dry-run'?: boolean }): boolean {
10+
if (flags.execute && flags['dry-run']) {
11+
throw new Error('Pass either --execute or --dry-run, not both')
12+
}
13+
return flags.execute === true
14+
}
15+
16+
/** Parses a positive integer flag, refusing anything a typo could turn into an unbounded run. */
17+
export function parsePositiveInteger(name: string, value: string | undefined, fallback: number) {
18+
if (value === undefined) return fallback
19+
const parsed = Number(value)
20+
if (!Number.isSafeInteger(parsed) || parsed < 1) {
21+
throw new Error(`--${name} must be a positive integer, got ${value}`)
22+
}
23+
return parsed
24+
}
25+
26+
/** Parses a non-negative integer flag, such as a pause in milliseconds. */
27+
export function parseNonNegativeInteger(name: string, value: string | undefined, fallback: number) {
28+
if (value === undefined) return fallback
29+
const parsed = Number(value)
30+
if (!Number.isSafeInteger(parsed) || parsed < 0) {
31+
throw new Error(`--${name} must be a non-negative integer, got ${value}`)
32+
}
33+
return parsed
34+
}
35+
36+
/**
37+
* One dedicated connection on the migrations role (`MIGRATION_DATABASE_URL`, else `DATABASE_URL`),
38+
* the role that owns the knowledge tables and can therefore drop their triggers, truncate, reindex
39+
* and vacuum them. One connection keeps session settings on the connection that uses them.
40+
*/
41+
export function connectMigrationRole(): Sql {
42+
const url = resolveMigrationDatabaseUrl()
43+
if (!url) throw new Error('MIGRATION_DATABASE_URL or DATABASE_URL is required')
44+
return postgres(url, {
45+
max: 1,
46+
max_lifetime: null,
47+
onnotice: () => undefined,
48+
connection: { application_name: 'sim-dormant-org-search-ops' },
49+
})
50+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { parseDeleteSearchIndexDocumentsArgs } from '@/scripts/dormant-org-search/delete-search-index-documents'
6+
7+
describe('parseDeleteSearchIndexDocumentsArgs', () => {
8+
it('defaults to a dry run with the documented bounds', () => {
9+
expect(parseDeleteSearchIndexDocumentsArgs(['--knowledge-base-id=kb-1'])).toEqual({
10+
knowledgeBaseId: 'kb-1',
11+
execute: false,
12+
pageSize: 200,
13+
chunkBatchSize: 1_000,
14+
pauseMs: 250,
15+
maxPages: undefined,
16+
afterId: '',
17+
storageCleanupCeiling: 2_000,
18+
resetConnectors: true,
19+
lockTimeoutMs: 5_000,
20+
statementTimeoutMs: 60_000,
21+
})
22+
})
23+
24+
it('writes only with --execute', () => {
25+
expect(
26+
parseDeleteSearchIndexDocumentsArgs(['--knowledge-base-id=kb-1', '--execute']).execute
27+
).toBe(true)
28+
expect(
29+
parseDeleteSearchIndexDocumentsArgs(['--knowledge-base-id=kb-1', '--dry-run']).execute
30+
).toBe(false)
31+
expect(() =>
32+
parseDeleteSearchIndexDocumentsArgs(['--knowledge-base-id=kb-1', '--execute', '--dry-run'])
33+
).toThrow('not both')
34+
})
35+
36+
it('parses resume and bound flags', () => {
37+
expect(
38+
parseDeleteSearchIndexDocumentsArgs([
39+
'--knowledge-base-id=kb-1',
40+
'--max-pages=5',
41+
'--after-id=doc-9',
42+
'--pause-ms=0',
43+
'--no-connector-reset',
44+
])
45+
).toMatchObject({ maxPages: 5, afterId: 'doc-9', pauseMs: 0, resetConnectors: false })
46+
})
47+
48+
it('refuses a missing base, unknown flags and non-positive bounds', () => {
49+
expect(() => parseDeleteSearchIndexDocumentsArgs([])).toThrow('--knowledge-base-id')
50+
expect(() =>
51+
parseDeleteSearchIndexDocumentsArgs(['--knowledge-base-id=kb-1', '--force'])
52+
).toThrow()
53+
expect(() =>
54+
parseDeleteSearchIndexDocumentsArgs(['--knowledge-base-id=kb-1', '--page-size=0'])
55+
).toThrow('positive integer')
56+
expect(() =>
57+
parseDeleteSearchIndexDocumentsArgs(['--knowledge-base-id=kb-1', '--max-pages=1.5'])
58+
).toThrow('positive integer')
59+
})
60+
})
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#!/usr/bin/env bun
2+
3+
/**
4+
* Deletes every connector-owned document and chunk of one organization search index, keeping the
5+
* knowledge base row and its (paused) connectors, then clears the connectors' listing cursors so a
6+
* resumed connector lists its source from scratch.
7+
*
8+
* Usage:
9+
* DATABASE_URL=<migrations-role-dsn> bun apps/sim/scripts/dormant-org-search/delete-search-index-documents.ts \
10+
* --knowledge-base-id=<knowledge-base-id> # dry run: guard + first pages
11+
* DATABASE_URL=<migrations-role-dsn> bun apps/sim/scripts/dormant-org-search/delete-search-index-documents.ts \
12+
* --knowledge-base-id=<knowledge-base-id> --execute [--max-pages=50] [--after-id=<document-id>]
13+
*
14+
* Options:
15+
* --page-size=200 documents per page
16+
* --chunk-batch-size=1000 chunks per delete transaction
17+
* --pause-ms=250 pause after every committed transaction
18+
* --max-pages=N stop after N pages (dry run default 3; unbounded when executing)
19+
* --after-id=<document-id> resume after this document id
20+
* --storage-cleanup-ceiling=2000 pending storage cleanup events before the run waits
21+
* --lock-timeout-ms=5000 per transaction
22+
* --statement-timeout-ms=60000 per statement
23+
* --no-connector-reset keep the connectors' listing cursors
24+
*
25+
* Exit codes: 0 finished or stopped at --max-pages (resume with the logged --after-id), 1 failed,
26+
* 2 refused by a precondition (checked before every page; nothing after the refusal is written).
27+
*/
28+
29+
import { parseArgs } from 'node:util'
30+
import { createLogger } from '@sim/logger'
31+
import { toError } from '@sim/utils/errors'
32+
import { generateShortId } from '@sim/utils/id'
33+
import {
34+
parseNonNegativeInteger,
35+
parsePositiveInteger,
36+
resolveExecuteFlag,
37+
} from '@/scripts/dormant-org-search/cli'
38+
import {
39+
DEFAULT_CHUNK_BATCH_SIZE,
40+
DEFAULT_DOCUMENT_PAGE_SIZE,
41+
DEFAULT_PAUSE_MS,
42+
DEFAULT_STORAGE_CLEANUP_CEILING,
43+
deleteSearchIndexDocuments,
44+
type SearchIndexDeletionOptions,
45+
SearchIndexDeletionRefused,
46+
} from '@/scripts/dormant-org-search/search-index-deletion'
47+
48+
const logger = createLogger('DeleteSearchIndexDocuments')
49+
50+
/** Refused by a precondition, before the run or before one of its pages. */
51+
export const EXIT_REFUSED = 2
52+
53+
export interface DeleteSearchIndexDocumentsArgs
54+
extends Omit<SearchIndexDeletionOptions, 'requestId' | 'sleep'> {
55+
lockTimeoutMs: number
56+
statementTimeoutMs: number
57+
}
58+
59+
/** Parses the command line; every mutation requires `--execute`. */
60+
export function parseDeleteSearchIndexDocumentsArgs(
61+
argv: readonly string[]
62+
): DeleteSearchIndexDocumentsArgs {
63+
const { values } = parseArgs({
64+
args: [...argv],
65+
options: {
66+
'knowledge-base-id': { type: 'string' },
67+
execute: { type: 'boolean' },
68+
'dry-run': { type: 'boolean' },
69+
'page-size': { type: 'string' },
70+
'chunk-batch-size': { type: 'string' },
71+
'pause-ms': { type: 'string' },
72+
'max-pages': { type: 'string' },
73+
'after-id': { type: 'string' },
74+
'storage-cleanup-ceiling': { type: 'string' },
75+
'lock-timeout-ms': { type: 'string' },
76+
'statement-timeout-ms': { type: 'string' },
77+
'no-connector-reset': { type: 'boolean' },
78+
},
79+
strict: true,
80+
})
81+
const knowledgeBaseId = values['knowledge-base-id']?.trim()
82+
if (!knowledgeBaseId) throw new Error('--knowledge-base-id is required')
83+
return {
84+
knowledgeBaseId,
85+
execute: resolveExecuteFlag(values),
86+
pageSize: parsePositiveInteger('page-size', values['page-size'], DEFAULT_DOCUMENT_PAGE_SIZE),
87+
chunkBatchSize: parsePositiveInteger(
88+
'chunk-batch-size',
89+
values['chunk-batch-size'],
90+
DEFAULT_CHUNK_BATCH_SIZE
91+
),
92+
pauseMs: parseNonNegativeInteger('pause-ms', values['pause-ms'], DEFAULT_PAUSE_MS),
93+
maxPages:
94+
values['max-pages'] === undefined
95+
? undefined
96+
: parsePositiveInteger('max-pages', values['max-pages'], 1),
97+
afterId: values['after-id'] ?? '',
98+
storageCleanupCeiling: parsePositiveInteger(
99+
'storage-cleanup-ceiling',
100+
values['storage-cleanup-ceiling'],
101+
DEFAULT_STORAGE_CLEANUP_CEILING
102+
),
103+
resetConnectors: values['no-connector-reset'] !== true,
104+
lockTimeoutMs: parsePositiveInteger('lock-timeout-ms', values['lock-timeout-ms'], 5_000),
105+
statementTimeoutMs: parsePositiveInteger(
106+
'statement-timeout-ms',
107+
values['statement-timeout-ms'],
108+
60_000
109+
),
110+
}
111+
}
112+
113+
async function main(): Promise<number> {
114+
const args = parseDeleteSearchIndexDocumentsArgs(process.argv.slice(2))
115+
/** Imported after parsing, so an argument error is reported before the app's database client requires DATABASE_URL. */
116+
const { drizzleSearchIndexDeletionStore } = await import(
117+
'@/scripts/dormant-org-search/search-index-deletion-store'
118+
)
119+
const { lockTimeoutMs, statementTimeoutMs, ...options } = args
120+
try {
121+
await deleteSearchIndexDocuments(
122+
drizzleSearchIndexDeletionStore({ lockTimeoutMs, statementTimeoutMs }),
123+
{ ...options, requestId: `dormant-org-search:${generateShortId()}` }
124+
)
125+
return 0
126+
} catch (error) {
127+
if (error instanceof SearchIndexDeletionRefused) {
128+
logger.error('Refused by a precondition; no further page was written', {
129+
reasons: error.reasons,
130+
})
131+
return EXIT_REFUSED
132+
}
133+
throw error
134+
}
135+
}
136+
137+
if (import.meta.main) {
138+
main().then(
139+
(code) => process.exit(code),
140+
(error) => {
141+
logger.error('Search index deletion failed', toError(error))
142+
process.exit(1)
143+
}
144+
)
145+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
disableTinProjection,
7+
TIN_TRIGGERS,
8+
type TinProjectionDatabase,
9+
type TinProjectionState,
10+
} from '@/scripts/dormant-org-search/disable-tin-projection'
11+
12+
/** A database whose Tin triggers and rows change only through the calls under test. */
13+
function fakeDatabase(initial: Partial<TinProjectionState> = {}) {
14+
const state: TinProjectionState = {
15+
triggers: [...TIN_TRIGGERS],
16+
hasRows: true,
17+
estimatedRows: 1_000,
18+
totalBytes: 8_192,
19+
functionsInstalled: true,
20+
...initial,
21+
}
22+
const database: TinProjectionDatabase & { drops: number } = {
23+
drops: 0,
24+
readState: async () => ({ ...state, triggers: [...state.triggers] }),
25+
dropTriggersAndTruncate: async () => {
26+
database.drops += 1
27+
state.triggers = []
28+
state.hasRows = false
29+
state.totalBytes = 0
30+
},
31+
}
32+
return { database, state }
33+
}
34+
35+
describe('disableTinProjection', () => {
36+
it('drops nothing in a dry run', async () => {
37+
const { database } = fakeDatabase()
38+
const result = await disableTinProjection(database, { execute: false })
39+
expect(database.drops).toBe(0)
40+
expect(result).toMatchObject({ executed: false, after: null })
41+
expect(result.before.triggers.map((trigger) => trigger.name)).toEqual([
42+
'embedding_keyword_tin_sync',
43+
'knowledge_base_keyword_tin_sync',
44+
'embedding_keyword_tin_source_acl_set',
45+
])
46+
})
47+
48+
it('drops the triggers and truncates when executing', async () => {
49+
const { database } = fakeDatabase()
50+
const result = await disableTinProjection(database, { execute: true })
51+
expect(database.drops).toBe(1)
52+
expect(result.after).toMatchObject({ triggers: [], hasRows: false })
53+
})
54+
55+
it('does nothing when the triggers are gone and the table is empty', async () => {
56+
const { database } = fakeDatabase({ triggers: [], hasRows: false, estimatedRows: -1 })
57+
const result = await disableTinProjection(database, { execute: true })
58+
expect(database.drops).toBe(0)
59+
expect(result.executed).toBe(false)
60+
})
61+
62+
it('truncates again when only rows remain', async () => {
63+
const { database } = fakeDatabase({ triggers: [] })
64+
await disableTinProjection(database, { execute: true })
65+
expect(database.drops).toBe(1)
66+
})
67+
68+
it('fails when a trigger survives the drop', async () => {
69+
const { database, state } = fakeDatabase()
70+
database.dropTriggersAndTruncate = async () => {
71+
state.triggers = [TIN_TRIGGERS[0]]
72+
}
73+
await expect(disableTinProjection(database, { execute: true })).rejects.toThrow(
74+
'still installed'
75+
)
76+
})
77+
})

0 commit comments

Comments
 (0)