Skip to content

Commit 8aad8e4

Browse files
committed
fix(db): fence schema push and require direct retirement sessions
1 parent f9f80c1 commit 8aad8e4

3 files changed

Lines changed: 319 additions & 78 deletions

File tree

‎packages/db/maintenance/search-retirement.integration.ts‎

Lines changed: 200 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { spawnSync } from 'node:child_process'
1+
import { spawn, spawnSync } from 'node:child_process'
22
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
33
import { tmpdir } from 'node:os'
44
import { join } from 'node:path'
@@ -163,10 +163,15 @@ describe('operator-driven Search retirement in PostgreSQL', () => {
163163
it('resumes copy, includes late writes, replaces all widths, and only then purges Search chunks', async () => {
164164
expect(await getSearchRetirementStatus(sql)).toBeNull()
165165
await initializeSearchRetirement(sql)
166-
await nextPage()
167-
await nextPage()
166+
for (let page = 0; page < 10; page++) {
167+
if ((await nextPage()).copied > 0) break
168+
}
169+
expect((await getSearchRetirementStatus(sql))?.copied).toBeGreaterThan(0)
170+
expect(
171+
await sql`SELECT id FROM embedding_search_retirement_shadow WHERE id = 'full-1024-1'`
172+
).toHaveLength(1)
168173
await writer`UPDATE embedding SET enabled = false WHERE id = 'full-1024-1'`
169-
await writer`DELETE FROM embedding WHERE id = 'full-384-1'`
174+
await writer`DELETE FROM embedding WHERE id = 'full-1024-3'`
170175
await writer`INSERT INTO embedding (id, knowledge_base_id, document_id, chunk_index, chunk_hash,
171176
content, content_length, token_count, start_offset, end_offset, embedding)
172177
VALUES ('000-late', 'prefix', 'prefix-doc', 5, 'late', 'Late synthetic content', 22, 4, 0, 22,
@@ -192,7 +197,10 @@ describe('operator-driven Search retirement in PostgreSQL', () => {
192197
await sql`SELECT id FROM embedding_search WHERE knowledge_base_id = 'search'`
193198
).toHaveLength(0)
194199
expect(await sql`SELECT id FROM embedding_search WHERE id = 'prefix-4'`).toHaveLength(1)
195-
expect(await sql`SELECT id FROM embedding_search WHERE id = 'full-384-1'`).toHaveLength(0)
200+
expect(await sql`SELECT id FROM embedding_search WHERE id = 'full-1024-3'`).toHaveLength(0)
201+
expect(await sql`SELECT enabled FROM embedding_search WHERE id = 'full-1024-1'`).toEqual([
202+
{ enabled: false },
203+
])
196204
expect(
197205
(
198206
await sql`SELECT vector_dims(vector_512) AS width FROM embedding_search WHERE id = '000-late'`
@@ -568,7 +576,124 @@ describe('operator-driven Search retirement in PostgreSQL', () => {
568576
expect((await sql`SELECT count(*)::int AS n FROM embedding`)[0].n).toBe(28)
569577
})
570578

571-
it('the CLI refuses missing or unhealthy telemetry before initializing and status stays read-only', async () => {
579+
it('refuses db:push while retirement holds its maintenance fence before creating a receipt', async () => {
580+
await writer`SELECT pg_advisory_lock(hashtextextended('sim:search-retirement-maintenance', 0))`
581+
try {
582+
const fixtureUrl = new URL(databaseUrl)
583+
fixtureUrl.pathname = `/${database}`
584+
const result = spawnSync(
585+
'bun',
586+
['--no-env-file', './scripts/push.ts', '--retirement-test-invalid-option'],
587+
{
588+
cwd: fileURLToPath(new URL('..', import.meta.url)),
589+
env: { ...process.env, NODE_ENV: 'development', DATABASE_URL: fixtureUrl.toString() },
590+
encoding: 'utf8',
591+
timeout: 10_000,
592+
maxBuffer: 64 * 1_024,
593+
}
594+
)
595+
expect(result.status).toBe(1)
596+
expect(`${result.stdout}${result.stderr}`).toContain(
597+
'Another migration or retirement operation'
598+
)
599+
expect(`${result.stdout}${result.stderr}`).not.toContain('Unrecognized options')
600+
expect(await getSearchRetirementStatus(sql)).toBeNull()
601+
} finally {
602+
await writer`SELECT pg_advisory_unlock_all()`
603+
}
604+
})
605+
606+
it('fences retirement during db:push and stops database preparation when its fence connection closes', async () => {
607+
await sql`ALTER TABLE workspace_files ADD COLUMN size bigint`
608+
await writer`BEGIN`
609+
await writer`LOCK TABLE workspace_files IN ACCESS EXCLUSIVE MODE`
610+
const fixtureUrl = new URL(databaseUrl)
611+
fixtureUrl.pathname = `/${database}`
612+
const child = spawn(
613+
'bun',
614+
['--no-env-file', './scripts/push.ts', '--force', '--retirement-test-invalid-option'],
615+
{
616+
cwd: fileURLToPath(new URL('..', import.meta.url)),
617+
env: { ...process.env, NODE_ENV: 'development', DATABASE_URL: fixtureUrl.toString() },
618+
stdio: ['ignore', 'pipe', 'pipe'],
619+
timeout: 10_000,
620+
}
621+
)
622+
let output = ''
623+
child.stdout.on('data', (chunk: Buffer) => {
624+
output += chunk.toString()
625+
})
626+
child.stderr.on('data', (chunk: Buffer) => {
627+
output += chunk.toString()
628+
})
629+
const exited = new Promise<number | null>((resolve, reject) => {
630+
child.once('error', reject)
631+
child.once('close', resolve)
632+
})
633+
try {
634+
await vi.waitFor(
635+
async () => {
636+
expect(
637+
await sql`SELECT pid FROM pg_stat_activity
638+
WHERE datname = current_database() AND wait_event_type = 'Lock'
639+
AND query = 'LOCK TABLE public.workspace_files IN ACCESS EXCLUSIVE MODE'`
640+
).toHaveLength(1)
641+
},
642+
{ timeout: 5_000 }
643+
)
644+
await expect(initializeSearchRetirement(sql)).rejects.toThrow(
645+
/Another migration or retirement operation/
646+
)
647+
const [guard] = await sql`SELECT pid, backend_xmin FROM pg_stat_activity
648+
WHERE datname = current_database() AND application_name = 'sim-db-push'`
649+
expect(guard.backend_xmin).toBeNull()
650+
await sql`SELECT pg_terminate_backend(${guard.pid})`
651+
expect(await exited).toBe(1)
652+
expect(output).toContain('Schema-push lock connection closed')
653+
expect(output).not.toContain('Unrecognized options')
654+
await writer`ROLLBACK`
655+
await vi.waitFor(async () => {
656+
expect(
657+
await sql`SELECT pid FROM pg_stat_activity
658+
WHERE datname = current_database() AND wait_event_type = 'Lock'
659+
AND query = 'LOCK TABLE public.workspace_files IN ACCESS EXCLUSIVE MODE'`
660+
).toHaveLength(0)
661+
})
662+
expect(
663+
await sql`SELECT attname FROM pg_attribute
664+
WHERE attrelid = 'workspace_files'::regclass AND attname = 'size' AND NOT attisdropped`
665+
).toHaveLength(1)
666+
expect(await getSearchRetirementStatus(sql)).toBeNull()
667+
} finally {
668+
await writer`ROLLBACK`
669+
await exited
670+
}
671+
})
672+
673+
it('the operator CLI refuses unverified endpoints and connection overrides before connecting', () => {
674+
const script = fileURLToPath(new URL('../scripts/retire-indexed-search.ts', import.meta.url))
675+
for (const url of [
676+
'postgresql://reader@pool.example.invalid:5432/postgres',
677+
'postgresql://reader@fixture.pg.psdb.cloud:6432/postgres?sslmode=verify-full&sslrootcert=system',
678+
'postgresql://reader@fixture.pg.psdb.cloud.example.invalid:5432/postgres',
679+
'postgresql://reader@localhost:5432/production',
680+
'postgresql://reader@fixture.pg.psdb.cloud:5432/postgres?sslmode=disable',
681+
'postgresql://fixture.pg.psdb.cloud:5432/postgres?sslmode=verify-full&sslrootcert=system',
682+
'postgresql://reader@fixture.pg.psdb.cloud:5432/?sslmode=verify-full&sslrootcert=system',
683+
`${databaseUrl}?statement_timeout=0`,
684+
]) {
685+
const result = spawnSync('bun', ['--no-env-file', script, 'identity'], {
686+
env: { ...process.env, NODE_ENV: 'development', MIGRATION_DATABASE_URL: url },
687+
encoding: 'utf8',
688+
timeout: 5_000,
689+
maxBuffer: 64 * 1_024,
690+
})
691+
expect(result.status).toBe(1)
692+
expect(`${result.stdout}${result.stderr}`).not.toContain(url)
693+
}
694+
})
695+
696+
it('the CLI respects health gates, keeps status read-only, and stops after losing its session', async () => {
572697
const fixtureUrl = new URL(databaseUrl)
573698
fixtureUrl.pathname = `/${database}`
574699
const script = fileURLToPath(new URL('../scripts/retire-indexed-search.ts', import.meta.url))
@@ -608,22 +733,20 @@ describe('operator-driven Search retirement in PostgreSQL', () => {
608733
maxSampleAgeMs: 30_000,
609734
})
610735
)
611-
await writeFile(
612-
health,
613-
JSON.stringify({
614-
databaseId,
615-
observedAt: new Date().toISOString(),
616-
healthy: false,
617-
maintenanceAllowed: true,
618-
cutoverAllowed: false,
619-
replicaLagBytes: 0,
620-
replicaLagSeconds: 0,
621-
walBytesPerSecond: 0,
622-
databaseP95Ms: 1,
623-
cpuPercent: 1,
624-
freeStorageBytes: 1_000,
625-
})
626-
)
736+
const sample = {
737+
databaseId,
738+
observedAt: new Date().toISOString(),
739+
healthy: false,
740+
maintenanceAllowed: true,
741+
cutoverAllowed: false,
742+
replicaLagBytes: 0,
743+
replicaLagSeconds: 0,
744+
walBytesPerSecond: 0,
745+
databaseP95Ms: 1,
746+
cpuPercent: 1,
747+
freeStorageBytes: 1_000,
748+
}
749+
await writeFile(health, JSON.stringify(sample))
627750
const refused = invoke(
628751
'prepare',
629752
'--ack-release-drained',
@@ -636,6 +759,61 @@ describe('operator-driven Search retirement in PostgreSQL', () => {
636759
expect(`${refused.stdout}${refused.stderr}`).toContain('unhealthy')
637760
expect(await getSearchRetirementStatus(sql)).toBeNull()
638761
expect(invoke('status').status).toBe(0)
762+
await writeFile(health, JSON.stringify({ ...sample, healthy: true }))
763+
expect(
764+
invoke(
765+
'prepare',
766+
'--ack-release-drained',
767+
'--health-file',
768+
health,
769+
'--health-policy',
770+
policy
771+
).status
772+
).toBe(0)
773+
const runner = spawn(
774+
'bun',
775+
[
776+
'--no-env-file',
777+
script,
778+
'run',
779+
'--pages',
780+
'2',
781+
'--health-file',
782+
health,
783+
'--health-policy',
784+
policy,
785+
],
786+
{
787+
env: environment,
788+
stdio: 'ignore',
789+
timeout: 20_000,
790+
}
791+
)
792+
const exited = new Promise<number | null>((resolve, reject) => {
793+
runner.once('error', reject)
794+
runner.once('close', resolve)
795+
})
796+
try {
797+
await vi.waitFor(
798+
async () => {
799+
expect(
800+
(await sql`SELECT after_id FROM search_retirement_state WHERE id = 1`)[0].after_id
801+
).not.toBe('')
802+
},
803+
{ timeout: 5_000 }
804+
)
805+
const before = await getSearchRetirementStatus(sql)
806+
const [session] = await sql`SELECT pid FROM pg_stat_activity
807+
WHERE datname = current_database() AND application_name = 'sim-search-data-retirement'`
808+
await sql`SELECT pg_terminate_backend(${session.pid})`
809+
const exitCode = await exited
810+
expect(await getSearchRetirementStatus(sql)).toEqual(before)
811+
expect(exitCode).toBe(1)
812+
} finally {
813+
runner.kill('SIGKILL')
814+
await exited
815+
}
816+
await abortSearchRetirement(sql)
639817
} finally {
640818
await rm(directory, { recursive: true, force: true })
641819
}

0 commit comments

Comments
 (0)