Skip to content

fix(db): rebalance IDENTITY sequences after restore + defensive setval - #2474

Merged
namastex888 merged 1 commit into
devfrom
fix/db-restore-rebalance-identity-sequences
May 22, 2026
Merged

namastex888 merged 1 commit into
devfrom
fix/db-restore-rebalance-identity-sequences

Conversation

@namastex888

Copy link
Copy Markdown
Contributor

Summary

pg_dump --clean --if-exists (used by genie db backup → genie db restore) silently desyncs every IDENTITY sequence in the dump. The next IDENTITY-generated INSERT collides with a restored row's PK and breaks every PG-touching CLI command. Observed on khal-os 2026-05-22 (total genie ls lockout). This PR ships both a corrective fix and a self-healing recovery path.

Root cause

pg_dump --clean --if-exists emits DDL that drops + recreates each table. For IDENTITY columns that resets the implicit sequence to its initial value (1). The dump then COPYs the rows back in with their original explicit ids (1..N). But pg_dump does not emit a corresponding setval() to advance the sequence past N. After restore, the sequence is at 1 (or wherever it landed during DDL) while the table holds rows up to N. The next IDENTITY-generated INSERT requests a sequence value (1, 2, 3, …) and collides with an existing PK.

This affects any user who ran genie db backup → genie db restore, on any IDENTITY-bearing table (which is most of them: _genie_migrations, agents, tasks, events, assignments, …).

Reproduction

$ psql -c "SELECT max(id), count(*) FROM _genie_migrations; "
 max | count
-----+-------
  63 |    63

$ psql -c "SELECT last_value FROM _genie_migrations_id_seq; "
 last_value
------------
         48           ← off by 15; next INSERT will collide

$ genie ls
Error: duplicate key value violates unique constraint \"_genie_migrations_pkey\"
$ genie task list
Error: duplicate key value violates unique constraint \"_genie_migrations_pkey\"
$ genie events errors
Error: duplicate key value violates unique constraint \"_genie_migrations_pkey\"

Fix

1. rebalanceIdentitySequences() in src/lib/db-backup.ts — called from restore() after the psql ON_ERROR_STOP=1 pipe. A single PL/pgSQL DO block walks every attidentity IN ('a', 'd') column in public (skipping views / matviews / foreign / extension tables) and calls setval(pg_get_serial_sequence(...), GREATEST(MAX(col), 1), MAX(col) > 0). Idempotent — empty tables get setval(1, false) (canonical IDENTITY-start behavior); non-empty tables advance past MAX(col). Exported with an injectable runner seam for testability and for operators on a pre-fix host to recover without re-restoring.

2. Defensive setval() in runMigrations() (src/lib/db-migrations.ts) — guarded on pending.length > 0 so the cost is paid only when actually about to INSERT. Self-heals any host that already drifted: upgrading to this version automatically rebalances _genie_migrations_id_seq on first migration apply. No manual SQL needed.

Test plan

  • bun test src/lib/db-backup.test.ts — 16 pass (10 new)
  • bun run typecheck — clean
  • bun run lint — clean (only pre-existing broken-symlink warnings)
  • After release, run genie update --dev + genie ls on khal-os and confirm the migration runner self-heals on first invocation

Files touched

  • src/lib/db-backup.ts (+95) — rebalanceIdentitySequences() + buildIdentityRebalanceSql() + wiring into restore()
  • src/lib/db-migrations.ts (+18) — defensive setval() before the pending-migration INSERT loop
  • src/lib/db-backup.test.ts (+108) — 10 regression tests covering SQL shape, runner contract, error paths, restore wiring order

`pg_dump --clean --if-exists` (used by `genie db backup`) drops every
IDENTITY-bearing table and recreates it via DDL — resetting the implicit
identity sequence to its initial value (1). The subsequent COPY restores
rows with their original explicit ids (1..N), but pg_dump does NOT emit
a setval() to advance the sequence past N. The next IDENTITY-generated
INSERT then collides with an existing row's PK, breaking every
PG-touching CLI command.

Observed on khal-os 2026-05-22: `_genie_migrations` had 63 rows but
`_genie_migrations_id_seq` was at 48; the first attempt to apply pending
migration 064 hit `duplicate key value violates unique constraint
"_genie_migrations_pkey"`. Every `genie ls`, `genie task list`,
`genie events` invocation failed identically — total CLI lockout until
a manual `setval()`.

Two-pronged fix:

1. **`rebalanceIdentitySequences()` in db-backup.ts**, called at the
   end of `restore()`. Walks every `attidentity` column in `public`
   via a single PL/pgSQL DO block and `setval(seq, GREATEST(MAX(col), 1),
   is_called)`. Idempotent: empty tables get `setval(1, false)` so the
   next nextval() yields 1 (canonical IDENTITY-start behavior);
   non-empty tables advance past MAX(col). Exported so operators on a
   pre-fix host can recover without re-restoring. Injectable `runner`
   seam keeps it unit-testable.

2. **Defensive setval in `runMigrations()`** (db-migrations.ts), guarded
   on `pending.length > 0` so the cost is paid only when actually about
   to INSERT. Self-heals any host that already drifted via a pre-fix
   restore, so upgrading to this version is enough — no manual SQL
   needed.

10 new tests pin the SQL shape, runner contract, error paths (non-zero
exit, null status timeout, string-vs-Buffer stderr), and the
restore-then-rebalance ordering in `restore()`.
@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2762e781-94f6-4b24-8d06-97b1bbd26b37

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/db-restore-rebalance-identity-sequences

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@namastex888
namastex888 merged commit 75b7c06 into dev May 22, 2026
16 checks passed

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a mechanism to rebalance PostgreSQL IDENTITY sequences after a database restore to prevent primary key collisions. It includes a new utility to generate and execute PL/pgSQL for sequence alignment, integration into the restore process, and a defensive check within the migration runner. Feedback focuses on optimizing SQL performance by reducing redundant table scans, improving the robustness of sequence name resolution, and refining the type-checking logic for the execution runner to avoid potential runtime errors.

Comment thread src/lib/db-backup.ts
Comment on lines +269 to +292
export function buildIdentityRebalanceSql(): string {
return `DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT n.nspname AS sch, c.relname AS tbl, a.attname AS col
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE a.attidentity IN ('a', 'd')
AND n.nspname = 'public'
AND c.relkind = 'r'
LOOP
EXECUTE format(
'SELECT setval(pg_get_serial_sequence(%L, %L), GREATEST(COALESCE((SELECT MAX(%I) FROM %I.%I), 0), 1), COALESCE((SELECT MAX(%I) FROM %I.%I), 0) > 0)',
r.sch || '.' || r.tbl, r.col,
r.col, r.sch, r.tbl,
r.col, r.sch, r.tbl
);
END LOOP;
END
$$;`;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The PL/pgSQL block in buildIdentityRebalanceSql performs redundant table scans by executing SELECT MAX(...) twice for every identity column (once for the value and once for the is_called flag). This can be optimized by fetching the maximum value into a variable first. Additionally, using quote_ident when constructing the qualified table name for pg_get_serial_sequence ensures robustness against identifiers that require quoting (e.g., those containing spaces or capital letters).

export function buildIdentityRebalanceSql(): string {
  return "DO $$\nDECLARE\n  r RECORD;\n  m BIGINT;\nBEGIN\n  FOR r IN\n    SELECT n.nspname AS sch, c.relname AS tbl, a.attname AS col\n    FROM pg_attribute a\n    JOIN pg_class c ON c.oid = a.attrelid\n    JOIN pg_namespace n ON n.oid = c.relnamespace\n    WHERE a.attidentity IN ('a', 'd')\n      AND n.nspname = 'public'\n      AND c.relkind = 'r'\n  LOOP\n    EXECUTE format('SELECT MAX(%I) FROM %I.%I', r.col, r.sch, r.tbl) INTO m;\n    EXECUTE format(\n      'SELECT setval(pg_get_serial_sequence(%L, %L), GREATEST(COALESCE($1, 0), 1), COALESCE($1, 0) > 0)',\n      quote_ident(r.sch) || '.' || quote_ident(r.tbl), r.col\n    ) USING m;\n  END LOOP;\nEND\n$$;";
}

Comment thread src/lib/db-backup.ts
): void {
// Accept either an env block (legacy positional) or an options bag, so
// restore() can keep passing its env and tests can inject a fake runner.
const isOpts = envOrOpts !== null && typeof envOrOpts === 'object' && 'runner' in envOrOpts;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The check 'runner' in envOrOpts is fragile because NodeJS.ProcessEnv is an object that can contain arbitrary keys. If an environment variable named runner exists (e.g., set by a user or CI environment), this logic will incorrectly treat the environment object as an options object, leading to a runtime error when it attempts to call the string value as a function. It is safer to verify that runner is actually a function.

Suggested change
const isOpts = envOrOpts !== null && typeof envOrOpts === 'object' && 'runner' in envOrOpts;
const isOpts = envOrOpts !== null && typeof envOrOpts === 'object' && typeof (envOrOpts as any).runner === 'function';

Comment thread src/lib/db-migrations.ts
Comment on lines +158 to +162
await sql.unsafe(`SELECT setval(
'_genie_migrations_id_seq',
GREATEST(COALESCE((SELECT MAX(id) FROM _genie_migrations), 0), 1),
COALESCE((SELECT MAX(id) FROM _genie_migrations), 0) > 0
)`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This defensive setval call performs redundant MAX(id) subqueries and relies on a hardcoded sequence name. Using a CTE (Common Table Expression) avoids the double scan, and pg_get_serial_sequence provides a more robust way to resolve the sequence name, ensuring it works even if the sequence naming convention deviates from the default.

  await sql.unsafe("\n    WITH m AS (SELECT MAX(id) AS val FROM _genie_migrations)\n    SELECT setval(\n      pg_get_serial_sequence('_genie_migrations', 'id'),\n      GREATEST(COALESCE(val, 0), 1),\n      COALESCE(val, 0) > 0\n    )\n    FROM m\n  ");

@automagik-genie
automagik-genie deleted the fix/db-restore-rebalance-identity-sequences branch September 25, 2026 04:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants