From d4e2d749ed1845af8640219bacf70e411d6ed1ad Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 13:36:37 +0000 Subject: [PATCH 1/2] fix(cli): force-close Edge Runtime worker after pg-delta scripts emit output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `supabase db schema declarative sync` could hang indefinitely at 0% CPU after all migrations were applied to the shadow database (supabase/pg-toolbelt#312). Root cause: the pg-delta Deno scripts run inside a one-shot Edge Runtime container and rely on the event loop draining for the worker to be destroyed and the container to exit. The catalog-export script opens a real connection pool (createManagedPool); when a keepalive handle lingers after close() resolves, the worker never exits, so the container never stops. The CLI streams that container's logs with Follow:true (DockerStreamLogs), so a worker that never exits blocks the parent `__catalog` subprocess — and the declarative-sync command that spawned it — forever. Only the error path force-closed the loop (`throw new Error("")`); the success path did not. Fix: force-close the event loop on the success path of every pg-delta Edge Runtime script (diff, declarative-export, catalog-export, and declarative-apply), so the worker is torn down deterministically once output has been flushed. The output is written synchronously before the throw, and RunEdgeRuntimeScript already tolerates the resulting "main worker has been destroyed" exit. Reproduced against supabase/edge-runtime:v1.74.2: a worker with a lingering handle keeps the container `running` and `docker logs -f` (the equivalent of DockerStreamLogs Follow:true) never returns; adding the force-close makes the container exit immediately with output intact. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XbxecW4DVmwgQB1YX321K3 --- .../internal/db/diff/pgdelta_template_test.go | 48 +++++++++++++++++++ .../internal/db/diff/templates/pgdelta.ts | 6 +++ .../diff/templates/pgdelta_catalog_export.ts | 9 ++++ .../templates/pgdelta_declarative_export.ts | 6 +++ .../pgdelta/pgdelta_apply_template_test.go | 33 +++++++++++++ .../templates/pgdelta_declarative_apply.ts | 7 +++ .../shared/legacy-pgdelta.deno-templates.ts | 8 ++-- 7 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 apps/cli-go/internal/db/diff/pgdelta_template_test.go create mode 100644 apps/cli-go/internal/pgdelta/pgdelta_apply_template_test.go diff --git a/apps/cli-go/internal/db/diff/pgdelta_template_test.go b/apps/cli-go/internal/db/diff/pgdelta_template_test.go new file mode 100644 index 0000000000..3fbc2bb792 --- /dev/null +++ b/apps/cli-go/internal/db/diff/pgdelta_template_test.go @@ -0,0 +1,48 @@ +package diff + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// lastCodeLine returns the final non-blank, non-comment line of a script. +func lastCodeLine(script string) string { + lines := strings.Split(script, "\n") + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" || strings.HasPrefix(line, "//") { + continue + } + return line + } + return "" +} + +// Every pg-delta edge-runtime script must force the worker's event loop closed +// once its output has been written. The pg connection pool can leave keepalive +// handles registered even after close() resolves; if the worker never exits, +// the container never stops and the CLI — which streams the container logs with +// Follow:true — blocks forever following them, hanging declarative sync at 0% +// CPU (supabase/pg-toolbelt#312). The success path must terminate +// unconditionally rather than rely on the event loop draining on its own, so +// guard against the force-close being dropped from any template's success path. +func TestPgDeltaScriptsForceCloseOnSuccess(t *testing.T) { + scripts := map[string]string{ + "pgdelta.ts": pgDeltaScript, + "pgdelta_declarative_export.ts": pgDeltaDeclarativeExportScript, + "pgdelta_catalog_export.ts": pgDeltaCatalogExportScript, + } + for name, script := range scripts { + t.Run(name, func(t *testing.T) { + require.NotEmpty(t, script) + // The terminating statement runs on the success path (the catch + // branch no longer re-throws), so the worker is torn down whether + // or not the body succeeded. + assert.Equal(t, `throw new Error("");`, lastCodeLine(script), + "success path must force the Edge Runtime worker to exit so the container stops") + }) + } +} diff --git a/apps/cli-go/internal/db/diff/templates/pgdelta.ts b/apps/cli-go/internal/db/diff/templates/pgdelta.ts index 306fed6a73..0fd9d00e30 100644 --- a/apps/cli-go/internal/db/diff/templates/pgdelta.ts +++ b/apps/cli-go/internal/db/diff/templates/pgdelta.ts @@ -69,3 +69,9 @@ try { // Force close event loop throw new Error(""); } +// Force close the event loop on the success path too. When SOURCE/TARGET are +// live database URLs the plan opens connections whose keepalive handles can keep +// the Edge Runtime worker alive after the diff has been written, so the container +// never exits and the CLI — which follows this container's logs — hangs +// indefinitely at 0% CPU (supabase/pg-toolbelt#312). +throw new Error(""); diff --git a/apps/cli-go/internal/db/diff/templates/pgdelta_catalog_export.ts b/apps/cli-go/internal/db/diff/templates/pgdelta_catalog_export.ts index 992c5f21a8..6b7d426ce1 100644 --- a/apps/cli-go/internal/db/diff/templates/pgdelta_catalog_export.ts +++ b/apps/cli-go/internal/db/diff/templates/pgdelta_catalog_export.ts @@ -21,7 +21,16 @@ try { console.log(stringifyCatalogSnapshot(serializeCatalog(catalog))); } catch (e) { console.error(e); + // Force close event loop throw new Error(""); } finally { await close(); } +// Force close the event loop on the success path too. The connection pool can +// leave keepalive handles registered even after close() resolves, which keeps +// the Edge Runtime worker (and therefore the container) alive after the catalog +// has already been written to stdout. The CLI streams this container's logs with +// Follow:true, so a worker that never exits hangs the parent `__catalog` +// subprocess — and the declarative-sync command that spawned it — indefinitely +// at 0% CPU (supabase/pg-toolbelt#312). +throw new Error(""); diff --git a/apps/cli-go/internal/db/diff/templates/pgdelta_declarative_export.ts b/apps/cli-go/internal/db/diff/templates/pgdelta_declarative_export.ts index 117f16c58e..4656e4690e 100644 --- a/apps/cli-go/internal/db/diff/templates/pgdelta_declarative_export.ts +++ b/apps/cli-go/internal/db/diff/templates/pgdelta_declarative_export.ts @@ -71,3 +71,9 @@ try { // Force close event loop throw new Error(""); } +// Force close the event loop on the success path too. When SOURCE/TARGET are +// live database URLs the plan opens connections whose keepalive handles can keep +// the Edge Runtime worker alive after the export has been written, so the +// container never exits and the CLI — which follows this container's logs — +// hangs indefinitely at 0% CPU (supabase/pg-toolbelt#312). +throw new Error(""); diff --git a/apps/cli-go/internal/pgdelta/pgdelta_apply_template_test.go b/apps/cli-go/internal/pgdelta/pgdelta_apply_template_test.go new file mode 100644 index 0000000000..c0a7e601f3 --- /dev/null +++ b/apps/cli-go/internal/pgdelta/pgdelta_apply_template_test.go @@ -0,0 +1,33 @@ +package pgdelta + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The declarative-apply script connects to TARGET and must force the worker's +// event loop closed once it has written its result JSON. applyDeclarativeSchema +// can leave connection keepalive handles registered, and if the worker never +// exits the container never stops — the CLI, which follows the container logs +// with Follow:true, then hangs indefinitely at 0% CPU (supabase/pg-toolbelt#312). +// The success path must terminate unconditionally, so guard against the +// force-close being dropped. +func TestDeclarativeApplyScriptForceClosesOnSuccess(t *testing.T) { + require.NotEmpty(t, pgDeltaDeclarativeApplyScript) + + lines := strings.Split(pgDeltaDeclarativeApplyScript, "\n") + last := "" + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" || strings.HasPrefix(line, "//") { + continue + } + last = line + break + } + assert.Equal(t, `throw new Error("");`, last, + "success path must force the Edge Runtime worker to exit so the container stops") +} diff --git a/apps/cli-go/internal/pgdelta/templates/pgdelta_declarative_apply.ts b/apps/cli-go/internal/pgdelta/templates/pgdelta_declarative_apply.ts index a6589bf2b0..9dfb07cf62 100644 --- a/apps/cli-go/internal/pgdelta/templates/pgdelta_declarative_apply.ts +++ b/apps/cli-go/internal/pgdelta/templates/pgdelta_declarative_apply.ts @@ -52,3 +52,10 @@ try { } catch (e) { throw e instanceof Error ? e : new Error(String(e)); } +// Force close the event loop on the success path. applyDeclarativeSchema opens a +// connection to TARGET whose keepalive handles can keep the Edge Runtime worker +// alive after the result JSON has been written, so the container never exits and +// the CLI — which follows this container's logs — hangs indefinitely at 0% CPU +// (supabase/pg-toolbelt#312). The catch above re-throws the real error, so this +// only runs once a successful apply has been reported on stdout. +throw new Error(""); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts index 625967555d..e43e9828a8 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts @@ -15,19 +15,19 @@ /** `templates/pgdelta.ts` — diffs SOURCE→TARGET and prints SQL statements. */ export const legacyPgDeltaDiffScript = - 'import {\n createPlan,\n deserializeCatalog,\n formatSqlStatements,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\nimport { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase";\n\nasync function resolveInput(ref: string | undefined) {\n if (!ref) {\n return null;\n }\n if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) {\n return ref;\n }\n const json = await Deno.readTextFile(ref);\n return deserializeCatalog(JSON.parse(json));\n}\n\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\n\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS");\nif (includedSchemas) {\n const schemas = includedSchemas.split(",");\n const schemaFilter = {\n or: [{ "*/schema": schemas }, { "schema/name": schemas }],\n };\n // CompositionPattern `and` is valid FilterDSL; Deno\'s structural typing is strict on `or` branches.\n supabase.filter = {\n and: [supabase.filter!, schemaFilter],\n } as typeof supabase.filter;\n}\n\nconst formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS");\nlet formatOptions = undefined;\nif (formatOptionsRaw) {\n formatOptions = JSON.parse(formatOptionsRaw);\n}\n\ntry {\n const result = await createPlan(\n await resolveInput(source),\n await resolveInput(target),\n {\n ...supabase,\n skipDefaultPrivilegeSubtraction: true,\n },\n );\n let statements = result?.plan.statements ?? [];\n if (formatOptions != null) {\n statements = formatSqlStatements(statements, formatOptions);\n }\n if (Deno.env.get("PGDELTA_DEBUG")) {\n console.error(\n JSON.stringify({\n statementCount: statements.length,\n source: source ? "connected" : "null",\n target: target ? "connected" : "null",\n includedSchemas: includedSchemas ?? null,\n skipDefaultPrivilegeSubtraction: true,\n }),\n );\n }\n for (const sql of statements) {\n console.log(`${sql};`);\n }\n} catch (e) {\n console.error(e);\n // Force close event loop\n throw new Error("");\n}\n'; + 'import {\n createPlan,\n deserializeCatalog,\n formatSqlStatements,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\nimport { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase";\n\nasync function resolveInput(ref: string | undefined) {\n if (!ref) {\n return null;\n }\n if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) {\n return ref;\n }\n const json = await Deno.readTextFile(ref);\n return deserializeCatalog(JSON.parse(json));\n}\n\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\n\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS");\nif (includedSchemas) {\n const schemas = includedSchemas.split(",");\n const schemaFilter = {\n or: [{ "*/schema": schemas }, { "schema/name": schemas }],\n };\n // CompositionPattern `and` is valid FilterDSL; Deno\'s structural typing is strict on `or` branches.\n supabase.filter = {\n and: [supabase.filter!, schemaFilter],\n } as typeof supabase.filter;\n}\n\nconst formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS");\nlet formatOptions = undefined;\nif (formatOptionsRaw) {\n formatOptions = JSON.parse(formatOptionsRaw);\n}\n\ntry {\n const result = await createPlan(\n await resolveInput(source),\n await resolveInput(target),\n {\n ...supabase,\n skipDefaultPrivilegeSubtraction: true,\n },\n );\n let statements = result?.plan.statements ?? [];\n if (formatOptions != null) {\n statements = formatSqlStatements(statements, formatOptions);\n }\n if (Deno.env.get("PGDELTA_DEBUG")) {\n console.error(\n JSON.stringify({\n statementCount: statements.length,\n source: source ? "connected" : "null",\n target: target ? "connected" : "null",\n includedSchemas: includedSchemas ?? null,\n skipDefaultPrivilegeSubtraction: true,\n }),\n );\n }\n for (const sql of statements) {\n console.log(`${sql};`);\n }\n} catch (e) {\n console.error(e);\n // Force close event loop\n throw new Error("");\n}\n// Force close the event loop on the success path too. When SOURCE/TARGET are\n// live database URLs the plan opens connections whose keepalive handles can keep\n// the Edge Runtime worker alive after the diff has been written, so the container\n// never exits and the CLI — which follows this container\'s logs — hangs\n// indefinitely at 0% CPU (supabase/pg-toolbelt#312).\nthrow new Error("");\n'; /** `templates/pgdelta_declarative_export.ts` — exports declarative file payloads. */ export const legacyPgDeltaDeclarativeExportScript = - '// This script is executed inside Edge Runtime by the CLI to export a target\n// schema as declarative file payloads. It accepts either live DB URLs or\n// catalog-file references for SOURCE/TARGET, which enables cached sync flows.\nimport {\n createPlan,\n deserializeCatalog,\n exportDeclarativeSchema,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\nimport { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase";\n\nasync function resolveInput(ref: string | undefined) {\n if (!ref) {\n return null;\n }\n if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) {\n return ref;\n }\n const json = await Deno.readTextFile(ref);\n return deserializeCatalog(JSON.parse(json));\n}\n\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\n\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS");\nif (includedSchemas) {\n const schemas = includedSchemas.split(",");\n const schemaFilter = {\n or: [{ "*/schema": schemas }, { "schema/name": schemas }],\n };\n supabase.filter = {\n and: [supabase.filter!, schemaFilter],\n } as unknown as typeof supabase.filter;\n}\n\nconst formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS");\nlet formatOptions = undefined;\nif (formatOptionsRaw) {\n formatOptions = JSON.parse(formatOptionsRaw);\n}\ntry {\n const result = await createPlan(\n await resolveInput(source),\n await resolveInput(target),\n {\n ...supabase,\n skipDefaultPrivilegeSubtraction: true,\n },\n );\n if (!result) {\n console.log(\n JSON.stringify({\n version: 1,\n mode: "declarative",\n files: [],\n }),\n );\n } else {\n const output = exportDeclarativeSchema(result, {\n integration: supabase,\n formatOptions,\n });\n console.log(\n JSON.stringify(output, (_key, value) =>\n typeof value === "bigint" ? Number(value) : value,\n ),\n );\n }\n} catch (e) {\n console.error(e);\n // Force close event loop\n throw new Error("");\n}\n'; + '// This script is executed inside Edge Runtime by the CLI to export a target\n// schema as declarative file payloads. It accepts either live DB URLs or\n// catalog-file references for SOURCE/TARGET, which enables cached sync flows.\nimport {\n createPlan,\n deserializeCatalog,\n exportDeclarativeSchema,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\nimport { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase";\n\nasync function resolveInput(ref: string | undefined) {\n if (!ref) {\n return null;\n }\n if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) {\n return ref;\n }\n const json = await Deno.readTextFile(ref);\n return deserializeCatalog(JSON.parse(json));\n}\n\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\n\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS");\nif (includedSchemas) {\n const schemas = includedSchemas.split(",");\n const schemaFilter = {\n or: [{ "*/schema": schemas }, { "schema/name": schemas }],\n };\n supabase.filter = {\n and: [supabase.filter!, schemaFilter],\n } as unknown as typeof supabase.filter;\n}\n\nconst formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS");\nlet formatOptions = undefined;\nif (formatOptionsRaw) {\n formatOptions = JSON.parse(formatOptionsRaw);\n}\ntry {\n const result = await createPlan(\n await resolveInput(source),\n await resolveInput(target),\n {\n ...supabase,\n skipDefaultPrivilegeSubtraction: true,\n },\n );\n if (!result) {\n console.log(\n JSON.stringify({\n version: 1,\n mode: "declarative",\n files: [],\n }),\n );\n } else {\n const output = exportDeclarativeSchema(result, {\n integration: supabase,\n formatOptions,\n });\n console.log(\n JSON.stringify(output, (_key, value) =>\n typeof value === "bigint" ? Number(value) : value,\n ),\n );\n }\n} catch (e) {\n console.error(e);\n // Force close event loop\n throw new Error("");\n}\n// Force close the event loop on the success path too. When SOURCE/TARGET are\n// live database URLs the plan opens connections whose keepalive handles can keep\n// the Edge Runtime worker alive after the export has been written, so the\n// container never exits and the CLI — which follows this container\'s logs —\n// hangs indefinitely at 0% CPU (supabase/pg-toolbelt#312).\nthrow new Error("");\n'; /** `templates/pgdelta_catalog_export.ts` — serializes a catalog snapshot for caching. */ export const legacyPgDeltaCatalogExportScript = - '// This script serializes a database catalog for caching/reuse in declarative\n// sync workflows, so later diff/export operations can run from file references.\nimport {\n createManagedPool,\n extractCatalog,\n serializeCatalog,\n stringifyCatalogSnapshot,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\n\nconst target = Deno.env.get("TARGET");\nconst role = Deno.env.get("ROLE") ?? undefined;\n\nif (!target) {\n console.error("TARGET is required");\n throw new Error("");\n}\nconst { pool, close } = await createManagedPool(target, { role });\n\ntry {\n const catalog = await extractCatalog(pool);\n console.log(stringifyCatalogSnapshot(serializeCatalog(catalog)));\n} catch (e) {\n console.error(e);\n throw new Error("");\n} finally {\n await close();\n}\n'; + '// This script serializes a database catalog for caching/reuse in declarative\n// sync workflows, so later diff/export operations can run from file references.\nimport {\n createManagedPool,\n extractCatalog,\n serializeCatalog,\n stringifyCatalogSnapshot,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\n\nconst target = Deno.env.get("TARGET");\nconst role = Deno.env.get("ROLE") ?? undefined;\n\nif (!target) {\n console.error("TARGET is required");\n throw new Error("");\n}\nconst { pool, close } = await createManagedPool(target, { role });\n\ntry {\n const catalog = await extractCatalog(pool);\n console.log(stringifyCatalogSnapshot(serializeCatalog(catalog)));\n} catch (e) {\n console.error(e);\n // Force close event loop\n throw new Error("");\n} finally {\n await close();\n}\n// Force close the event loop on the success path too. The connection pool can\n// leave keepalive handles registered even after close() resolves, which keeps\n// the Edge Runtime worker (and therefore the container) alive after the catalog\n// has already been written to stdout. The CLI streams this container\'s logs with\n// Follow:true, so a worker that never exits hangs the parent `__catalog`\n// subprocess — and the declarative-sync command that spawned it — indefinitely\n// at 0% CPU (supabase/pg-toolbelt#312).\nthrow new Error("");\n'; /** `internal/pgdelta/templates/pgdelta_declarative_apply.ts` — applies declarative files to TARGET. */ export const legacyPgDeltaDeclarativeApplyScript = - '// This script applies declarative schema files to a target database and emits\n// structured JSON so the Go caller can report success/failure deterministically.\nimport {\n applyDeclarativeSchema,\n loadDeclarativeSchema,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20/declarative";\n\nconst schemaPath = Deno.env.get("SCHEMA_PATH");\nconst target = Deno.env.get("TARGET");\n\nif (!schemaPath) {\n throw new Error("SCHEMA_PATH is required");\n}\nif (!target) {\n throw new Error("TARGET is required");\n}\n\ntry {\n const content = await loadDeclarativeSchema(schemaPath);\n if (content.length === 0) {\n console.log(JSON.stringify({ status: "success", totalStatements: 0 }));\n } else {\n const result = await applyDeclarativeSchema({\n content,\n targetUrl: target,\n });\n const apply = result?.apply;\n if (!apply) {\n throw new Error("pg-delta apply returned no result");\n }\n const payload = {\n status: apply.status,\n totalStatements: result.totalStatements ?? 0,\n totalRounds: apply.totalRounds ?? 0,\n totalApplied: apply.totalApplied ?? 0,\n totalSkipped: apply.totalSkipped ?? 0,\n errors: apply.errors ?? [],\n stuckStatements: apply.stuckStatements ?? [],\n // validationErrors is populated when the final\n // check_function_bodies=on pass catches issues that didn\'t surface during\n // the initial apply rounds (e.g. a function body that references a\n // column whose type changed). Without surfacing this field, callers see\n // status=error with empty errors/stuckStatements and no actionable info.\n validationErrors: apply.validationErrors ?? [],\n diagnostics: result.diagnostics ?? [],\n };\n console.log(JSON.stringify(payload));\n if (apply.status !== "success") {\n throw new Error("pg-delta apply failed with status: " + apply.status);\n }\n }\n} catch (e) {\n throw e instanceof Error ? e : new Error(String(e));\n}\n'; + '// This script applies declarative schema files to a target database and emits\n// structured JSON so the Go caller can report success/failure deterministically.\nimport {\n applyDeclarativeSchema,\n loadDeclarativeSchema,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20/declarative";\n\nconst schemaPath = Deno.env.get("SCHEMA_PATH");\nconst target = Deno.env.get("TARGET");\n\nif (!schemaPath) {\n throw new Error("SCHEMA_PATH is required");\n}\nif (!target) {\n throw new Error("TARGET is required");\n}\n\ntry {\n const content = await loadDeclarativeSchema(schemaPath);\n if (content.length === 0) {\n console.log(JSON.stringify({ status: "success", totalStatements: 0 }));\n } else {\n const result = await applyDeclarativeSchema({\n content,\n targetUrl: target,\n });\n const apply = result?.apply;\n if (!apply) {\n throw new Error("pg-delta apply returned no result");\n }\n const payload = {\n status: apply.status,\n totalStatements: result.totalStatements ?? 0,\n totalRounds: apply.totalRounds ?? 0,\n totalApplied: apply.totalApplied ?? 0,\n totalSkipped: apply.totalSkipped ?? 0,\n errors: apply.errors ?? [],\n stuckStatements: apply.stuckStatements ?? [],\n // validationErrors is populated when the final\n // check_function_bodies=on pass catches issues that didn\'t surface during\n // the initial apply rounds (e.g. a function body that references a\n // column whose type changed). Without surfacing this field, callers see\n // status=error with empty errors/stuckStatements and no actionable info.\n validationErrors: apply.validationErrors ?? [],\n diagnostics: result.diagnostics ?? [],\n };\n console.log(JSON.stringify(payload));\n if (apply.status !== "success") {\n throw new Error("pg-delta apply failed with status: " + apply.status);\n }\n }\n} catch (e) {\n throw e instanceof Error ? e : new Error(String(e));\n}\n// Force close the event loop on the success path. applyDeclarativeSchema opens a\n// connection to TARGET whose keepalive handles can keep the Edge Runtime worker\n// alive after the result JSON has been written, so the container never exits and\n// the CLI — which follows this container\'s logs — hangs indefinitely at 0% CPU\n// (supabase/pg-toolbelt#312). The catch above re-throws the real error, so this\n// only runs once a successful apply has been reported on stdout.\nthrow new Error("");\n'; /** * The npm dist-tag/version used for `@supabase/pg-delta` when From 7908b637a4d41d575eb687fa2ae15cadce589123 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 15:09:11 +0000 Subject: [PATCH 2/2] fix(cli): force-close worker in pgcache catalog export too The migrations-catalog cache script (pgcache.TryCacheMigrationsCatalog, used by db start / db push with pg-delta caching) uses the same createManagedPool/extractCatalog/close() pattern as the other pg-delta Edge Runtime scripts and had the same missing success-path force-close, so it could hang the same way (supabase/pg-toolbelt#312). Add the force-close and a guard test. Flagged by automated PR review. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XbxecW4DVmwgQB1YX321K3 --- apps/cli-go/internal/db/pgcache/cache.go | 9 +++++ .../db/pgcache/cache_template_test.go | 33 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 apps/cli-go/internal/db/pgcache/cache_template_test.go diff --git a/apps/cli-go/internal/db/pgcache/cache.go b/apps/cli-go/internal/db/pgcache/cache.go index 5b518464ba..52960c9b06 100644 --- a/apps/cli-go/internal/db/pgcache/cache.go +++ b/apps/cli-go/internal/db/pgcache/cache.go @@ -49,10 +49,19 @@ try { console.log(stringifyCatalogSnapshot(serializeCatalog(catalog))); } catch (e) { console.error(e); + // Force close event loop throw new Error(""); } finally { await close(); } +// Force close the event loop on the success path too. The connection pool can +// leave keepalive handles registered even after close() resolves, which keeps +// the Edge Runtime worker (and therefore the container) alive after the catalog +// has already been written to stdout. The CLI streams this container's logs with +// Follow:true, so a worker that never exits hangs the migrations-catalog cache +// path (db start / db push with pg-delta caching) indefinitely at 0% CPU +// (supabase/pg-toolbelt#312). +throw new Error(""); ` ) diff --git a/apps/cli-go/internal/db/pgcache/cache_template_test.go b/apps/cli-go/internal/db/pgcache/cache_template_test.go new file mode 100644 index 0000000000..1118853597 --- /dev/null +++ b/apps/cli-go/internal/db/pgcache/cache_template_test.go @@ -0,0 +1,33 @@ +package pgcache + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The migrations-catalog cache script (db start / db push with pg-delta caching) +// opens a connection pool and must force the worker's event loop closed once it +// has written its snapshot. If a keepalive handle lingers after close() resolves +// the worker never exits, so the container never stops and the CLI — which +// follows the container logs with Follow:true — hangs indefinitely at 0% CPU +// (supabase/pg-toolbelt#312). Guard against the success-path force-close being +// dropped. +func TestPgDeltaCatalogExportScriptForceClosesOnSuccess(t *testing.T) { + require.NotEmpty(t, pgDeltaCatalogExportTS) + + lines := strings.Split(pgDeltaCatalogExportTS, "\n") + last := "" + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" || strings.HasPrefix(line, "//") { + continue + } + last = line + break + } + assert.Equal(t, `throw new Error("");`, last, + "success path must force the Edge Runtime worker to exit so the container stops") +}