Skip to content

Commit 0afb0c8

Browse files
authored
Optimize Indexes (#1239)
Analogous to dbos-inc/dbos-transact-py#656
1 parent c45b974 commit 0afb0c8

6 files changed

Lines changed: 308 additions & 17 deletions

File tree

schemas/system_db_schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface workflow_status {
3232
parent_workflow_id?: string;
3333
serialization: SysDBSerializationFormat | null;
3434
delay_until_epoch_ms?: number | null;
35+
rate_limited?: boolean;
3536
}
3637

3738
export interface notifications {

src/sysdb_migrations/internal/migrations.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export function allMigrations(
66
): ReadonlyArray<DBMigration> {
77
const useListenNotify = opts?.useListenNotify ?? true;
88
const isCockroach = opts?.isCockroach ?? false;
9+
const c = isCockroach ? '' : 'CONCURRENTLY';
910
return [
1011
{
1112
name: '20240123182943_schema',
@@ -432,5 +433,85 @@ export function allMigrations(
432433
)`,
433434
],
434435
},
436+
// Migrations below replace broad indexes on workflow_status with partial
437+
// indexes targeted at individual query patterns (recovery, troubleshooting,
438+
// dequeue, rate-limit count). Each index DDL runs CONCURRENTLY on Postgres
439+
// for safe online deployment; CockroachDB ignores the keyword.
440+
{
441+
online: true,
442+
pg: [`DROP INDEX ${c} IF EXISTS "${schemaName}"."idx_workflow_status_forked_from"`],
443+
},
444+
{
445+
online: true,
446+
pg: [
447+
`CREATE INDEX ${c} IF NOT EXISTS "idx_workflow_status_forked_from" ON "${schemaName}"."workflow_status" ("forked_from") WHERE "forked_from" IS NOT NULL`,
448+
],
449+
},
450+
{
451+
online: true,
452+
pg: [`DROP INDEX ${c} IF EXISTS "${schemaName}"."idx_workflow_status_parent_workflow_id"`],
453+
},
454+
{
455+
online: true,
456+
pg: [
457+
`CREATE INDEX ${c} IF NOT EXISTS "idx_workflow_status_parent_workflow_id" ON "${schemaName}"."workflow_status" ("parent_workflow_id") WHERE "parent_workflow_id" IS NOT NULL`,
458+
],
459+
},
460+
{
461+
online: true,
462+
pg: [`DROP INDEX ${c} IF EXISTS "${schemaName}"."workflow_status_executor_id_index"`],
463+
},
464+
{
465+
online: true,
466+
pg: [
467+
`CREATE UNIQUE INDEX ${c} IF NOT EXISTS "uq_workflow_status_dedup_id" ON "${schemaName}"."workflow_status" ("queue_name", "deduplication_id") WHERE "deduplication_id" IS NOT NULL`,
468+
],
469+
},
470+
{
471+
// CockroachDB stores `UNIQUE (...)` constraints as unique indexes, so
472+
// they must be dropped with DROP INDEX rather than ALTER TABLE DROP CONSTRAINT.
473+
pg: isCockroach
474+
? [`DROP INDEX IF EXISTS "${schemaName}"."uq_workflow_status_queue_name_dedup_id" CASCADE`]
475+
: [
476+
`ALTER TABLE "${schemaName}"."workflow_status" DROP CONSTRAINT IF EXISTS "uq_workflow_status_queue_name_dedup_id"`,
477+
],
478+
},
479+
{
480+
online: true,
481+
pg: [
482+
`CREATE INDEX ${c} IF NOT EXISTS "idx_workflow_status_pending" ON "${schemaName}"."workflow_status" ("created_at") WHERE "status" = 'PENDING'`,
483+
],
484+
},
485+
{
486+
online: true,
487+
pg: [
488+
`CREATE INDEX ${c} IF NOT EXISTS "idx_workflow_status_failed" ON "${schemaName}"."workflow_status" ("status", "created_at") WHERE "status" IN ('ERROR', 'CANCELLED', 'MAX_RECOVERY_ATTEMPTS_EXCEEDED')`,
489+
],
490+
},
491+
{
492+
online: true,
493+
pg: [`DROP INDEX ${c} IF EXISTS "${schemaName}"."workflow_status_status_index"`],
494+
},
495+
{
496+
online: true,
497+
pg: [
498+
`CREATE INDEX ${c} IF NOT EXISTS "idx_workflow_status_in_flight" ON "${schemaName}"."workflow_status" ("queue_name", "status", "priority", "created_at") WHERE "status" IN ('ENQUEUED', 'PENDING')`,
499+
],
500+
},
501+
{
502+
pg: [
503+
`ALTER TABLE "${schemaName}"."workflow_status" ADD COLUMN IF NOT EXISTS "rate_limited" BOOLEAN NOT NULL DEFAULT FALSE`,
504+
],
505+
},
506+
{
507+
online: true,
508+
pg: [
509+
`CREATE INDEX ${c} IF NOT EXISTS "idx_workflow_status_rate_limited" ON "${schemaName}"."workflow_status" ("queue_name", "started_at_epoch_ms") WHERE "rate_limited" = TRUE`,
510+
],
511+
},
512+
{
513+
online: true,
514+
pg: [`DROP INDEX ${c} IF EXISTS "${schemaName}"."idx_workflow_status_queue_status_started"`],
515+
},
435516
];
436517
}

src/sysdb_migrations/migration_runner.ts

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ export type DBMigration = {
44
name?: string;
55
pg?: ReadonlyArray<string>;
66
sqlite3?: ReadonlyArray<string>;
7+
/**
8+
* If true, the migration is executed without wrapping it in any helper that
9+
* suppresses errors, and its statements must be safe to run outside a
10+
* transaction (e.g. `CREATE INDEX CONCURRENTLY`). The runner also cleans up
11+
* indexes left INVALID by a previously interrupted CONCURRENTLY build before
12+
* running the migration.
13+
*/
14+
online?: boolean;
715
};
816

917
/** Get the current DB version, or 0 if table is missing/empty. */
@@ -28,6 +36,7 @@ export async function getCurrentSysDBVersion(client: ClientBase, schemaName: str
2836
export type PgMigratorOptions = {
2937
ignoreErrorCodes?: ReadonlySet<string>;
3038
onWarn?: (msg: string, err?: unknown) => void;
39+
isCockroach?: boolean;
3140
};
3241

3342
const DEFAULT_IGNORABLE_CODES = new Set<string>([
@@ -73,11 +82,49 @@ async function runStatementsIgnoring(
7382
}
7483
}
7584

85+
/**
86+
* Drop indexes left INVALID by a prior interrupted `CREATE INDEX CONCURRENTLY`.
87+
* Postgres marks such indexes invalid; they continue to consume write overhead
88+
* without serving reads, so the next online migration cannot succeed by simply
89+
* re-running `IF NOT EXISTS` (the name is taken).
90+
*/
91+
async function cleanupInvalidIndexes(client: ClientBase, schemaName: string, warn: (m: string) => void): Promise<void> {
92+
const res = await client.query<{ relname: string }>(
93+
`SELECT i.relname
94+
FROM pg_index ix
95+
JOIN pg_class i ON i.oid = ix.indexrelid
96+
JOIN pg_class t ON t.oid = ix.indrelid
97+
JOIN pg_namespace n ON n.oid = t.relnamespace
98+
WHERE NOT ix.indisvalid AND n.nspname = $1`,
99+
[schemaName],
100+
);
101+
for (const row of res.rows) {
102+
warn(`Dropping invalid index "${schemaName}"."${row.relname}" left by a prior failed migration`);
103+
await client.query(`DROP INDEX CONCURRENTLY IF EXISTS "${schemaName}"."${row.relname}"`);
104+
}
105+
}
106+
107+
async function bumpMigrationVersion(client: ClientBase, schemaName: string, version: number): Promise<void> {
108+
// The earliest migrations create the schema and the dbos_migrations table itself,
109+
// so the table is not available to update on the very first iterations of a fresh DB.
110+
const reg = await client.query<{ table_name: string | null }>(
111+
"SELECT table_name FROM information_schema.tables WHERE table_schema = $1 AND table_name = 'dbos_migrations'",
112+
[schemaName],
113+
);
114+
if (!reg.rows[0]?.table_name) return;
115+
116+
const updateRes = await client.query(`UPDATE "${schemaName}"."dbos_migrations" SET "version" = $1`, [version]);
117+
if (updateRes.rowCount === 0) {
118+
await client.query(`INSERT INTO "${schemaName}"."dbos_migrations" ("version") VALUES ($1)`, [version]);
119+
}
120+
}
121+
76122
/**
77123
* Apply all migrations greater than the current DB version.
78124
* - Reads current version (0 if table missing/empty)
79125
* - Applies migrations in order
80-
* - Updates dbos_migrations.version at the end
126+
* - After each migration, persists `dbos_migrations.version` so partial
127+
* progress is recorded
81128
* - Warns if current version > max known (likely newer software concurrently)
82129
*/
83130
export async function runSysMigrationsPg(
@@ -92,7 +139,7 @@ export async function runSysMigrationsPg(
92139
skippedCount: number;
93140
notice?: string;
94141
}> {
95-
const { ignoreErrorCodes = DEFAULT_IGNORABLE_CODES, onWarn = (m) => console.info(m) } = opts;
142+
const { ignoreErrorCodes = DEFAULT_IGNORABLE_CODES, onWarn = (m) => console.info(m), isCockroach = false } = opts;
96143

97144
const current = await getCurrentSysDBVersion(client, schemaName);
98145
const maxKnown = allMigrations.length;
@@ -131,26 +178,30 @@ export async function runSysMigrationsPg(
131178
const stmts = m.pg ?? [];
132179
if (stmts.length === 0) {
133180
onWarn(`Migration "${m.name}" has no Postgres statements; skipping.`);
181+
await bumpMigrationVersion(client, schemaName, v);
134182
skipped++;
135183
lastAppliedVersion = v;
136184
continue;
137185
}
138186

139-
await runStatementsIgnoring(client, stmts, ignoreErrorCodes, (msg, e) =>
140-
onWarn(`${msg}${e ? `\n → ${String((e as { message?: string }).message ?? '')}` : ''}`),
141-
);
187+
// Run migrations in autocommit
188+
if (m.online && !isCockroach) {
189+
// Drop any indexes left INVALID by a prior interrupted run before
190+
// attempting the next CONCURRENTLY statement.
191+
await cleanupInvalidIndexes(client, schemaName, (msg) => onWarn(msg));
192+
for (const s of stmts) {
193+
await client.query(s, []);
194+
}
195+
} else {
196+
await runStatementsIgnoring(client, stmts, ignoreErrorCodes, (msg, e) =>
197+
onWarn(`${msg}${e ? `\n → ${String((e as { message?: string }).message ?? '')}` : ''}`),
198+
);
199+
}
200+
await bumpMigrationVersion(client, schemaName, v);
142201
applied++;
143202
lastAppliedVersion = v;
144203
}
145204

146-
// Update version at the end (insert or update)
147-
const updateRes = await client.query(`UPDATE "${schemaName}"."dbos_migrations" SET "version" = $1`, [
148-
lastAppliedVersion,
149-
]);
150-
if (updateRes.rowCount === 0) {
151-
await client.query(`INSERT into "${schemaName}"."dbos_migrations" ("version") values ($1)`, [lastAppliedVersion]);
152-
}
153-
154205
return {
155206
fromVersion: current,
156207
toVersion: lastAppliedVersion,

src/system_database.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ export async function ensureSystemDatabase(
294294
const isCockroach = /cockroachdb/i.test(versionRes.rows[0]?.version ?? '');
295295
await runSysMigrationsPg(client, allMigrations(schemaName, { useListenNotify, isCockroach }), schemaName, {
296296
onWarn: (e: string) => logger.info(e),
297+
isCockroach,
297298
});
298299
} finally {
299300
try {
@@ -2522,6 +2523,7 @@ export class SystemDatabase {
25222523
const countResult = await client.query<{ count: string }>(
25232524
`SELECT COUNT(*) FROM "${this.schemaName}".workflow_status
25242525
WHERE queue_name = $1
2526+
AND rate_limited = TRUE
25252527
AND status NOT IN ($2, $3)
25262528
AND started_at_epoch_ms > $4
25272529
${partitionFilter.replace('$PARTITION', '$5')}`,
@@ -2574,7 +2576,6 @@ export class SystemDatabase {
25742576
// Retrieve the first max_tasks workflows in the queue.
25752577
// Only retrieve workflows of the local version (or without version set)
25762578
const lockMode = queue.concurrency ? 'FOR UPDATE NOWAIT' : 'FOR UPDATE SKIP LOCKED';
2577-
const orderClause = queue.priorityEnabled ? 'ORDER BY priority ASC, created_at ASC' : 'ORDER BY created_at ASC';
25782579
const limitClause = maxTasks !== Infinity ? `LIMIT ${maxTasks}` : '';
25792580

25802581
const selectParams = [StatusString.ENQUEUED, queue.name, appVersion, ...partitionParams];
@@ -2585,7 +2586,7 @@ export class SystemDatabase {
25852586
AND queue_name = $2
25862587
AND (application_version IS NULL OR application_version = $3)
25872588
${partitionFilter.replace('$PARTITION', '$4')}
2588-
${orderClause}
2589+
ORDER BY priority ASC, created_at ASC
25892590
${limitClause}
25902591
${lockMode}
25912592
`;
@@ -2610,13 +2611,22 @@ export class SystemDatabase {
26102611
executor_id = $2,
26112612
application_version = $3,
26122613
started_at_epoch_ms = $4,
2614+
rate_limited = $5,
26132615
workflow_deadline_epoch_ms = CASE
26142616
WHEN workflow_timeout_ms IS NOT NULL AND workflow_deadline_epoch_ms IS NULL
26152617
THEN (EXTRACT(epoch FROM now()) * 1000)::bigint + workflow_timeout_ms
26162618
ELSE workflow_deadline_epoch_ms
26172619
END
2618-
WHERE workflow_uuid = $5 AND status = $6`,
2619-
[StatusString.PENDING, executorID, appVersion, startTimeMs, id, StatusString.ENQUEUED],
2620+
WHERE workflow_uuid = $6 AND status = $7`,
2621+
[
2622+
StatusString.PENDING,
2623+
executorID,
2624+
appVersion,
2625+
startTimeMs,
2626+
queue.rateLimit !== undefined,
2627+
id,
2628+
StatusString.ENQUEUED,
2629+
],
26202630
);
26212631
if ((updateRes.rowCount ?? 0) > 0) {
26222632
claimedIDs.push(id);

0 commit comments

Comments
 (0)