FN-8127: fix embedded PostgreSQL backups
Enable backup managers to resolve active embedded PostgreSQL runtime URLs safely. - Track embedded backend URLs with generation-aware lifecycle leases. - Keep backup resolution current through owner shutdown and joiner release. - Document PostgreSQL client-tool requirements and add regression coverage. Files changed: .changeset/fn-8127-embedded-backup.md | 7 ++ docs/settings-reference.md | 3 + packages/core/src/__tests__/backup.test.ts | 115 ++++++++++++++++++++ packages/core/src/backup.ts | 17 ++- packages/core/src/index.gate.ts | 9 ++ packages/core/src/index.ts | 9 ++ .../core/src/postgres/active-backend-registry.ts | 119 +++++++++++++++++++++ packages/core/src/postgres/embedded-lifecycle.ts | 5 + packages/core/src/postgres/index.ts | 9 ++ packages/core/src/postgres/startup-factory.ts | 118 +++++++++++++++++--- 10 files changed, 389 insertions(+), 22 deletions(-) Fusion-Task-Id: FN-8127 Fusion-Task-Lineage: 6125be5c-d1d5-4228-a6b1-290311de70d9 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8127-embedded-backup.md
Normal file
7
.changeset/fn-8127-embedded-backup.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Fix fn backup and scheduled database backups in the default embedded PostgreSQL setup.
|
||||||
|
category: fix
|
||||||
|
dev: Tracks embedded runtime URLs with owner/joiner generation leases so stale shutdowns cannot target or clear a newer cluster.
|
||||||
@@ -673,6 +673,9 @@ GitLab configuration examples: leave both URL fields blank for GitLab.com (`http
|
|||||||
| `autoBackupSchedule` | `string` | `"0 2 * * *"` | Backup cron schedule. |
|
| `autoBackupSchedule` | `string` | `"0 2 * * *"` | Backup cron schedule. |
|
||||||
| `autoBackupRetention` | `number` | `7` | Number of backups to retain. |
|
| `autoBackupRetention` | `number` | `7` | Number of backups to retain. |
|
||||||
| `autoBackupDir` | `string` | `".fusion/backups"` | Relative backup directory path. |
|
| `autoBackupDir` | `string` | `".fusion/backups"` | Relative backup directory path. |
|
||||||
|
|
||||||
|
Database backups work with both external PostgreSQL and Fusion's default embedded PostgreSQL deployment. `fn backup` and the built-in **Database Backup** cron/routine use `pg_dump` and `pg_restore`; install PostgreSQL client tools or configure their paths so both executables are available on `PATH`. They are not bundled with `embedded-postgres`.
|
||||||
|
|
||||||
| `memoryBackupEnabled` | `boolean` | `false` | Enable scheduled memory backups. |
|
| `memoryBackupEnabled` | `boolean` | `false` | Enable scheduled memory backups. |
|
||||||
| `memoryBackupSchedule` | `string` | `"0 3 * * *"` | Memory backup cron schedule. |
|
| `memoryBackupSchedule` | `string` | `"0 3 * * *"` | Memory backup cron schedule. |
|
||||||
| `memoryBackupRetention` | `number` | `14` | Number of memory backups to retain. |
|
| `memoryBackupRetention` | `number` | `14` | Number of memory backups to retain. |
|
||||||
|
|||||||
115
packages/core/src/__tests__/backup.test.ts
Normal file
115
packages/core/src/__tests__/backup.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
BackupManager,
|
||||||
|
createBackupManager,
|
||||||
|
resolveBackendConnectionString,
|
||||||
|
} from "../backup.js";
|
||||||
|
import {
|
||||||
|
clearActiveEmbeddedRuntimeUrl,
|
||||||
|
getActiveEmbeddedRuntimeUrl,
|
||||||
|
invalidateEmbeddedRuntimeUrl,
|
||||||
|
registerEmbeddedRuntimeUrl,
|
||||||
|
releaseEmbeddedRuntimeLease,
|
||||||
|
} from "../postgres/active-backend-registry.js";
|
||||||
|
|
||||||
|
const embeddedUrl = "postgresql://postgres:embedded-secret@127.0.0.1:55432/fusion";
|
||||||
|
const externalUrl = "postgresql://operator:external-secret@db.example.test:5432/fusion";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearActiveEmbeddedRuntimeUrl();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("embedded backup runtime URL registry", () => {
|
||||||
|
it("resolves a registered embedded backend and lets BackupManager construct", () => {
|
||||||
|
vi.stubEnv("DATABASE_URL", "");
|
||||||
|
registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: true });
|
||||||
|
|
||||||
|
expect(resolveBackendConnectionString()).toBe(embeddedUrl);
|
||||||
|
expect(() => createBackupManager("/tmp/project/.fusion")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an external DATABASE_URL ahead of the embedded registry", () => {
|
||||||
|
vi.stubEnv("DATABASE_URL", externalUrl);
|
||||||
|
registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: true });
|
||||||
|
|
||||||
|
expect(resolveBackendConnectionString()).toBe(externalUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the actionable error before an embedded lifecycle boots", () => {
|
||||||
|
vi.stubEnv("DATABASE_URL", "");
|
||||||
|
|
||||||
|
expect(resolveBackendConnectionString()).toBeUndefined();
|
||||||
|
expect(() => new BackupManager("/tmp/project/.fusion")).toThrow(
|
||||||
|
"BackupManager requires a PostgreSQL connection string",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an owner URL live when only a joiner releases", () => {
|
||||||
|
const owner = registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: true });
|
||||||
|
const joiner = registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: false });
|
||||||
|
|
||||||
|
releaseEmbeddedRuntimeLease(joiner);
|
||||||
|
expect(getActiveEmbeddedRuntimeUrl()).toBe(embeddedUrl);
|
||||||
|
|
||||||
|
releaseEmbeddedRuntimeLease(owner);
|
||||||
|
expect(getActiveEmbeddedRuntimeUrl()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidates every joiner when the postmaster owner stops", () => {
|
||||||
|
registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: true });
|
||||||
|
registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: false });
|
||||||
|
|
||||||
|
invalidateEmbeddedRuntimeUrl(embeddedUrl);
|
||||||
|
expect(resolveBackendConnectionString()).toBeUndefined();
|
||||||
|
expect(() => new BackupManager("/tmp/project/.fusion")).toThrow(
|
||||||
|
"BackupManager requires a PostgreSQL connection string",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes an old joiner release inert after owner invalidation and re-registration", () => {
|
||||||
|
registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: true });
|
||||||
|
const oldJoiner = registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: false });
|
||||||
|
invalidateEmbeddedRuntimeUrl(embeddedUrl);
|
||||||
|
registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: true });
|
||||||
|
|
||||||
|
releaseEmbeddedRuntimeLease(oldJoiner);
|
||||||
|
expect(getActiveEmbeddedRuntimeUrl()).toBe(embeddedUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes leases from a test reset inert for a re-registered URL", () => {
|
||||||
|
const oldLease = registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: false });
|
||||||
|
clearActiveEmbeddedRuntimeUrl();
|
||||||
|
registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: true });
|
||||||
|
|
||||||
|
releaseEmbeddedRuntimeLease(oldLease);
|
||||||
|
expect(getActiveEmbeddedRuntimeUrl()).toBe(embeddedUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a stale owner invalidate a replacement cluster that reused its URL", () => {
|
||||||
|
const oldOwner = registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: true });
|
||||||
|
const replacementOwner = registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: true });
|
||||||
|
|
||||||
|
invalidateEmbeddedRuntimeUrl(embeddedUrl, oldOwner);
|
||||||
|
expect(getActiveEmbeddedRuntimeUrl()).toBe(embeddedUrl);
|
||||||
|
|
||||||
|
releaseEmbeddedRuntimeLease(replacementOwner);
|
||||||
|
expect(getActiveEmbeddedRuntimeUrl()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the last live registration and ignores unknown invalidation/releases", () => {
|
||||||
|
const firstUrl = "postgresql://postgres:a@127.0.0.1:55431/fusion";
|
||||||
|
const first = registerEmbeddedRuntimeUrl(firstUrl, { ownsProcess: true });
|
||||||
|
const second = registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: false });
|
||||||
|
expect(getActiveEmbeddedRuntimeUrl()).toBe(embeddedUrl);
|
||||||
|
|
||||||
|
invalidateEmbeddedRuntimeUrl("postgresql://unknown@127.0.0.1:9/missing");
|
||||||
|
releaseEmbeddedRuntimeLease({} as never);
|
||||||
|
expect(getActiveEmbeddedRuntimeUrl()).toBe(embeddedUrl);
|
||||||
|
|
||||||
|
releaseEmbeddedRuntimeLease(second);
|
||||||
|
expect(getActiveEmbeddedRuntimeUrl()).toBe(firstUrl);
|
||||||
|
releaseEmbeddedRuntimeLease(first);
|
||||||
|
expect(getActiveEmbeddedRuntimeUrl()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { CronExpressionParser } from "cron-parser";
|
|||||||
import { getDefaultCentralDbPath } from "./central-db.js";
|
import { getDefaultCentralDbPath } from "./central-db.js";
|
||||||
import { PgBackupManager, type PgBackupPair, type PgDumpResult } from "./postgres/pg-backup.js";
|
import { PgBackupManager, type PgBackupPair, type PgDumpResult } from "./postgres/pg-backup.js";
|
||||||
import { resolveBackend } from "./postgres/backend-resolver.js";
|
import { resolveBackend } from "./postgres/backend-resolver.js";
|
||||||
|
import { getActiveEmbeddedRuntimeUrl } from "./postgres/active-backend-registry.js";
|
||||||
import type { ProjectSettings } from "./types.js";
|
import type { ProjectSettings } from "./types.js";
|
||||||
|
|
||||||
export interface BackupFileInfo {
|
export interface BackupFileInfo {
|
||||||
@@ -247,20 +248,18 @@ export function createBackupManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FNXC:BackendFlip 2026-06-26-14:35:
|
* FNXC:PostgresBackup 2026-07-16-12:40:
|
||||||
* Resolve the PostgreSQL connection string for backup operations from the
|
* External deployments resolve directly from DATABASE_URL. Embedded PostgreSQL
|
||||||
* runtime backend. Returns the runtime URL when the backend is external
|
* learns its URL only during asynchronous startup, so use the active lifecycle
|
||||||
* (DATABASE_URL set). Returns undefined for embedded mode (the default
|
* registry as the synchronous fallback. It is intentionally undefined before
|
||||||
* production path since flip-embedded-pg-default when DATABASE_URL is unset),
|
* boot or after owner shutdown, preserving BackupManager's actionable error.
|
||||||
* because the embedded lifecycle provides its URL asynchronously at startup
|
|
||||||
* and cannot be resolved synchronously here.
|
|
||||||
*/
|
*/
|
||||||
function resolveBackendConnectionString(): string | undefined {
|
export function resolveBackendConnectionString(): string | undefined {
|
||||||
const backend = resolveBackend();
|
const backend = resolveBackend();
|
||||||
if (backend.mode === "external" && backend.runtimeUrl) {
|
if (backend.mode === "external" && backend.runtimeUrl) {
|
||||||
return backend.runtimeUrl;
|
return backend.runtimeUrl;
|
||||||
}
|
}
|
||||||
return undefined;
|
return getActiveEmbeddedRuntimeUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -1305,8 +1305,17 @@ export {
|
|||||||
syncBackupAutomation,
|
syncBackupAutomation,
|
||||||
syncBackupRoutine,
|
syncBackupRoutine,
|
||||||
BACKUP_SCHEDULE_NAME,
|
BACKUP_SCHEDULE_NAME,
|
||||||
|
resolveBackendConnectionString,
|
||||||
} from "./backup.js";
|
} from "./backup.js";
|
||||||
export type { BackupInfo, BackupOptions, BackupFileInfo, BackupPairInfo } from "./backup.js";
|
export type { BackupInfo, BackupOptions, BackupFileInfo, BackupPairInfo } from "./backup.js";
|
||||||
|
export {
|
||||||
|
registerEmbeddedRuntimeUrl,
|
||||||
|
releaseEmbeddedRuntimeLease,
|
||||||
|
invalidateEmbeddedRuntimeUrl,
|
||||||
|
getActiveEmbeddedRuntimeUrl,
|
||||||
|
clearActiveEmbeddedRuntimeUrl,
|
||||||
|
} from "./postgres/active-backend-registry.js";
|
||||||
|
export type { EmbeddedRuntimeLease } from "./postgres/active-backend-registry.js";
|
||||||
export {
|
export {
|
||||||
MemoryBackupManager,
|
MemoryBackupManager,
|
||||||
createMemoryBackupManager,
|
createMemoryBackupManager,
|
||||||
|
|||||||
@@ -1357,8 +1357,17 @@ export {
|
|||||||
syncBackupAutomation,
|
syncBackupAutomation,
|
||||||
syncBackupRoutine,
|
syncBackupRoutine,
|
||||||
BACKUP_SCHEDULE_NAME,
|
BACKUP_SCHEDULE_NAME,
|
||||||
|
resolveBackendConnectionString,
|
||||||
} from "./backup.js";
|
} from "./backup.js";
|
||||||
export type { BackupInfo, BackupOptions, BackupFileInfo, BackupPairInfo } from "./backup.js";
|
export type { BackupInfo, BackupOptions, BackupFileInfo, BackupPairInfo } from "./backup.js";
|
||||||
|
export {
|
||||||
|
registerEmbeddedRuntimeUrl,
|
||||||
|
releaseEmbeddedRuntimeLease,
|
||||||
|
invalidateEmbeddedRuntimeUrl,
|
||||||
|
getActiveEmbeddedRuntimeUrl,
|
||||||
|
clearActiveEmbeddedRuntimeUrl,
|
||||||
|
} from "./postgres/active-backend-registry.js";
|
||||||
|
export type { EmbeddedRuntimeLease } from "./postgres/active-backend-registry.js";
|
||||||
export {
|
export {
|
||||||
MemoryBackupManager,
|
MemoryBackupManager,
|
||||||
createMemoryBackupManager,
|
createMemoryBackupManager,
|
||||||
|
|||||||
119
packages/core/src/postgres/active-backend-registry.ts
Normal file
119
packages/core/src/postgres/active-backend-registry.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/*
|
||||||
|
* FNXC:PostgresBackup 2026-07-16-12:40:
|
||||||
|
* Embedded PostgreSQL learns its credential-bearing runtime URL asynchronously,
|
||||||
|
* while backup construction resolves synchronously. This process-local registry
|
||||||
|
* bridges that gap without logging credentials. Leases represent individual
|
||||||
|
* lifecycles within a physical cluster generation: a joiner's release cannot
|
||||||
|
* clear a newer generation, and owner shutdown invalidates every lease because
|
||||||
|
* it is the only lifecycle that actually stops the postmaster.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Opaque handle for one embedded-backend lifecycle registration. */
|
||||||
|
declare const embeddedRuntimeLeaseBrand: unique symbol;
|
||||||
|
export interface EmbeddedRuntimeLease {
|
||||||
|
readonly [embeddedRuntimeLeaseBrand]: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Generation {
|
||||||
|
readonly url: string;
|
||||||
|
readonly epoch: number;
|
||||||
|
readonly id: number;
|
||||||
|
readonly leases: Set<EmbeddedRuntimeLease>;
|
||||||
|
latestRegistration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeaseMetadata {
|
||||||
|
readonly url: string;
|
||||||
|
readonly epoch: number;
|
||||||
|
readonly generation: number;
|
||||||
|
readonly ownsProcess: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generationsByUrl = new Map<string, Generation>();
|
||||||
|
const nextGenerationByUrl = new Map<string, number>();
|
||||||
|
const leaseMetadata = new WeakMap<EmbeddedRuntimeLease, LeaseMetadata>();
|
||||||
|
let registrationSequence = 0;
|
||||||
|
let registryEpoch = 0;
|
||||||
|
|
||||||
|
/** Register a booted embedded lifecycle and return its release-only lease. */
|
||||||
|
export function registerEmbeddedRuntimeUrl(
|
||||||
|
url: string,
|
||||||
|
options: { ownsProcess: boolean },
|
||||||
|
): EmbeddedRuntimeLease {
|
||||||
|
let generation = generationsByUrl.get(url);
|
||||||
|
// FNXC:PostgresBackup 2026-07-16-12:40: An owner started a new postmaster,
|
||||||
|
// so URL reuse must create a new generation rather than retain stale leases.
|
||||||
|
if (!generation || options.ownsProcess) {
|
||||||
|
const id = (nextGenerationByUrl.get(url) ?? 0) + 1;
|
||||||
|
nextGenerationByUrl.set(url, id);
|
||||||
|
generation = { url, epoch: registryEpoch, id, leases: new Set(), latestRegistration: 0 };
|
||||||
|
generationsByUrl.set(url, generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lease = {} as EmbeddedRuntimeLease;
|
||||||
|
generation.leases.add(lease);
|
||||||
|
generation.latestRegistration = ++registrationSequence;
|
||||||
|
leaseMetadata.set(lease, {
|
||||||
|
url,
|
||||||
|
epoch: generation.epoch,
|
||||||
|
generation: generation.id,
|
||||||
|
ownsProcess: options.ownsProcess,
|
||||||
|
});
|
||||||
|
return lease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Release exactly one lifecycle lease; stale generation handles are inert. */
|
||||||
|
export function releaseEmbeddedRuntimeLease(lease: EmbeddedRuntimeLease): void {
|
||||||
|
const metadata = leaseMetadata.get(lease);
|
||||||
|
if (!metadata) return;
|
||||||
|
const generation = generationsByUrl.get(metadata.url);
|
||||||
|
if (
|
||||||
|
!generation
|
||||||
|
|| generation.epoch !== metadata.epoch
|
||||||
|
|| generation.id !== metadata.generation
|
||||||
|
) return;
|
||||||
|
|
||||||
|
generation.leases.delete(lease);
|
||||||
|
if (generation.leases.size === 0) {
|
||||||
|
generationsByUrl.delete(metadata.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate all leases for a cluster generation after its owner stops it.
|
||||||
|
* A lease-aware invalidation cannot remove a newer cluster that reused the URL.
|
||||||
|
*/
|
||||||
|
export function invalidateEmbeddedRuntimeUrl(url: string, lease?: EmbeddedRuntimeLease): void {
|
||||||
|
if (!lease) {
|
||||||
|
generationsByUrl.delete(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const metadata = leaseMetadata.get(lease);
|
||||||
|
const generation = generationsByUrl.get(url);
|
||||||
|
if (
|
||||||
|
metadata?.url === url
|
||||||
|
&& generation?.epoch === metadata.epoch
|
||||||
|
&& generation.id === metadata.generation
|
||||||
|
) {
|
||||||
|
generationsByUrl.delete(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the most recently registered URL whose generation remains live. */
|
||||||
|
export function getActiveEmbeddedRuntimeUrl(): string | undefined {
|
||||||
|
let latest: Generation | undefined;
|
||||||
|
for (const generation of generationsByUrl.values()) {
|
||||||
|
if (generation.leases.size > 0 && (!latest || generation.latestRegistration > latest.latestRegistration)) {
|
||||||
|
latest = generation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return latest?.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset process-local state for isolated tests. */
|
||||||
|
export function clearActiveEmbeddedRuntimeUrl(): void {
|
||||||
|
generationsByUrl.clear();
|
||||||
|
nextGenerationByUrl.clear();
|
||||||
|
registrationSequence = 0;
|
||||||
|
registryEpoch += 1;
|
||||||
|
}
|
||||||
@@ -966,6 +966,11 @@ export class EmbeddedPostgresLifecycle {
|
|||||||
return this.options.port ?? this.resolvedPort;
|
return this.options.port ?? this.resolvedPort;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when this lifecycle started the postmaster rather than joining it. */
|
||||||
|
getOwnsProcess(): boolean {
|
||||||
|
return this.ownsProcess;
|
||||||
|
}
|
||||||
|
|
||||||
/** True when the embedded postgres process is currently running. */
|
/** True when the embedded postgres process is currently running. */
|
||||||
isRunning(): boolean {
|
isRunning(): boolean {
|
||||||
return this.running;
|
return this.running;
|
||||||
|
|||||||
@@ -37,6 +37,15 @@ export {
|
|||||||
type CreateConnectionOptions,
|
type CreateConnectionOptions,
|
||||||
} from "./connection.js";
|
} from "./connection.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
registerEmbeddedRuntimeUrl,
|
||||||
|
releaseEmbeddedRuntimeLease,
|
||||||
|
invalidateEmbeddedRuntimeUrl,
|
||||||
|
getActiveEmbeddedRuntimeUrl,
|
||||||
|
clearActiveEmbeddedRuntimeUrl,
|
||||||
|
type EmbeddedRuntimeLease,
|
||||||
|
} from "./active-backend-registry.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
redactUrlPassword,
|
redactUrlPassword,
|
||||||
redactUrlQueryPassword,
|
redactUrlQueryPassword,
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ import {
|
|||||||
} from "./connection.js";
|
} from "./connection.js";
|
||||||
import { applySchemaBaseline } from "./schema-applier.js";
|
import { applySchemaBaseline } from "./schema-applier.js";
|
||||||
import { createAsyncDataLayer, type AsyncDataLayer } from "./data-layer.js";
|
import { createAsyncDataLayer, type AsyncDataLayer } from "./data-layer.js";
|
||||||
|
import {
|
||||||
|
invalidateEmbeddedRuntimeUrl,
|
||||||
|
registerEmbeddedRuntimeUrl,
|
||||||
|
releaseEmbeddedRuntimeLease,
|
||||||
|
type EmbeddedRuntimeLease,
|
||||||
|
} from "./active-backend-registry.js";
|
||||||
import { runLoadedPluginSchemaInitHooks, type LoadedPluginSchemaContract } from "./plugin-schema-hook.js";
|
import { runLoadedPluginSchemaInitHooks, type LoadedPluginSchemaContract } from "./plugin-schema-hook.js";
|
||||||
import {
|
import {
|
||||||
lookupRegisteredProjectIdByPath,
|
lookupRegisteredProjectIdByPath,
|
||||||
@@ -76,6 +82,7 @@ import {
|
|||||||
type EmbeddedLifecycleLike = {
|
type EmbeddedLifecycleLike = {
|
||||||
start(): Promise<ResolvedBackend>;
|
start(): Promise<ResolvedBackend>;
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
|
getOwnsProcess(): boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const log = createLogger("startup-factory");
|
const log = createLogger("startup-factory");
|
||||||
@@ -166,6 +173,35 @@ interface SchemaBackendBootResult {
|
|||||||
readonly backend: ResolvedBackend;
|
readonly backend: ResolvedBackend;
|
||||||
readonly connections: PostgresConnections;
|
readonly connections: PostgresConnections;
|
||||||
readonly embeddedLifecycle: EmbeddedLifecycleLike | null;
|
readonly embeddedLifecycle: EmbeddedLifecycleLike | null;
|
||||||
|
readonly embeddedRuntimeLease: EmbeddedRuntimeLease | null;
|
||||||
|
readonly embeddedRuntimeUrl: string | null;
|
||||||
|
readonly embeddedOwnsProcess: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:PostgresBackup 2026-07-16-12:40:
|
||||||
|
* stop() only stops a postmaster owned by this lifecycle. Consequently owner
|
||||||
|
* teardown burns the URL generation for every joiner, while joiner teardown
|
||||||
|
* releases only its opaque lease. This keeps synchronous backup resolution
|
||||||
|
* aligned with physical cluster liveness without logging the credential URL.
|
||||||
|
*/
|
||||||
|
async function stopEmbeddedRuntime(
|
||||||
|
lifecycle: EmbeddedLifecycleLike | null,
|
||||||
|
lease: EmbeddedRuntimeLease | null,
|
||||||
|
runtimeUrl: string | null,
|
||||||
|
ownsProcess: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await lifecycle?.stop();
|
||||||
|
} finally {
|
||||||
|
if (lease && runtimeUrl) {
|
||||||
|
if (ownsProcess) {
|
||||||
|
invalidateEmbeddedRuntimeUrl(runtimeUrl, lease);
|
||||||
|
} else {
|
||||||
|
releaseEmbeddedRuntimeLease(lease);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -189,6 +225,9 @@ async function bootSchemaBackend(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let embeddedLifecycle: EmbeddedLifecycleLike | null = null;
|
let embeddedLifecycle: EmbeddedLifecycleLike | null = null;
|
||||||
|
let embeddedRuntimeLease: EmbeddedRuntimeLease | null = null;
|
||||||
|
let embeddedRuntimeUrl: string | null = null;
|
||||||
|
let embeddedOwnsProcess = false;
|
||||||
let resolvedBackend = backend;
|
let resolvedBackend = backend;
|
||||||
if (backend.mode === "embedded") {
|
if (backend.mode === "embedded") {
|
||||||
const { EmbeddedPostgresLifecycle, defaultEmbeddedDataDir, DEFAULT_EMBEDDED_DATABASE } =
|
const { EmbeddedPostgresLifecycle, defaultEmbeddedDataDir, DEFAULT_EMBEDDED_DATABASE } =
|
||||||
@@ -203,6 +242,13 @@ async function bootSchemaBackend(
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
resolvedBackend = await embeddedLifecycle.start();
|
resolvedBackend = await embeddedLifecycle.start();
|
||||||
|
if (resolvedBackend.runtimeUrl) {
|
||||||
|
embeddedRuntimeUrl = resolvedBackend.runtimeUrl;
|
||||||
|
embeddedOwnsProcess = embeddedLifecycle.getOwnsProcess();
|
||||||
|
embeddedRuntimeLease = registerEmbeddedRuntimeUrl(embeddedRuntimeUrl, {
|
||||||
|
ownsProcess: embeddedOwnsProcess,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await embeddedLifecycle.stop().catch(() => undefined);
|
await embeddedLifecycle.stop().catch(() => undefined);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -230,10 +276,22 @@ async function bootSchemaBackend(
|
|||||||
bypassProjectIsolation,
|
bypassProjectIsolation,
|
||||||
});
|
});
|
||||||
await applySchemaBaseline(connections.migration);
|
await applySchemaBaseline(connections.migration);
|
||||||
return { backend: resolvedBackend, connections, embeddedLifecycle };
|
return {
|
||||||
|
backend: resolvedBackend,
|
||||||
|
connections,
|
||||||
|
embeddedLifecycle,
|
||||||
|
embeddedRuntimeLease,
|
||||||
|
embeddedRuntimeUrl,
|
||||||
|
embeddedOwnsProcess,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await connections?.close().catch(() => undefined);
|
await connections?.close().catch(() => undefined);
|
||||||
await embeddedLifecycle?.stop().catch(() => undefined);
|
await stopEmbeddedRuntime(
|
||||||
|
embeddedLifecycle,
|
||||||
|
embeddedRuntimeLease,
|
||||||
|
embeddedRuntimeUrl,
|
||||||
|
embeddedOwnsProcess,
|
||||||
|
).catch(() => undefined);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,7 +310,14 @@ export async function createCentralBackendLayer(
|
|||||||
options: Pick<CreateTaskStoreForBackendOptions, "env" | "backend" | "embeddedPgRequested" | "embeddedDataDir" | "poolMax" | "globalSettingsDir"> = {},
|
options: Pick<CreateTaskStoreForBackendOptions, "env" | "backend" | "embeddedPgRequested" | "embeddedDataDir" | "poolMax" | "globalSettingsDir"> = {},
|
||||||
): Promise<CentralBackendLayerResult> {
|
): Promise<CentralBackendLayerResult> {
|
||||||
const boot = await bootSchemaBackend(options, true);
|
const boot = await bootSchemaBackend(options, true);
|
||||||
const { backend: resolvedBackend, connections, embeddedLifecycle } = boot;
|
const {
|
||||||
|
backend: resolvedBackend,
|
||||||
|
connections,
|
||||||
|
embeddedLifecycle,
|
||||||
|
embeddedRuntimeLease,
|
||||||
|
embeddedRuntimeUrl,
|
||||||
|
embeddedOwnsProcess,
|
||||||
|
} = boot;
|
||||||
try {
|
try {
|
||||||
/*
|
/*
|
||||||
FNXC:CentralPostgresCutover 2026-07-14-19:06:
|
FNXC:CentralPostgresCutover 2026-07-14-19:06:
|
||||||
@@ -301,12 +366,22 @@ export async function createCentralBackendLayer(
|
|||||||
releaseConnections,
|
releaseConnections,
|
||||||
async shutdown(): Promise<void> {
|
async shutdown(): Promise<void> {
|
||||||
await releaseConnections();
|
await releaseConnections();
|
||||||
await embeddedLifecycle?.stop().catch(() => undefined);
|
await stopEmbeddedRuntime(
|
||||||
|
embeddedLifecycle,
|
||||||
|
embeddedRuntimeLease,
|
||||||
|
embeddedRuntimeUrl,
|
||||||
|
embeddedOwnsProcess,
|
||||||
|
).catch(() => undefined);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await connections.close().catch(() => undefined);
|
await connections.close().catch(() => undefined);
|
||||||
await embeddedLifecycle?.stop().catch(() => undefined);
|
await stopEmbeddedRuntime(
|
||||||
|
embeddedLifecycle,
|
||||||
|
embeddedRuntimeLease,
|
||||||
|
embeddedRuntimeUrl,
|
||||||
|
embeddedOwnsProcess,
|
||||||
|
).catch(() => undefined);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,7 +519,13 @@ export async function createTaskStoreForBackend(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let { connections } = boot;
|
let { connections } = boot;
|
||||||
const { backend: resolvedBackend, embeddedLifecycle } = boot;
|
const {
|
||||||
|
backend: resolvedBackend,
|
||||||
|
embeddedLifecycle,
|
||||||
|
embeddedRuntimeLease,
|
||||||
|
embeddedRuntimeUrl,
|
||||||
|
embeddedOwnsProcess,
|
||||||
|
} = boot;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:PostgresMigration 2026-07-10:
|
FNXC:PostgresMigration 2026-07-10:
|
||||||
@@ -661,9 +742,12 @@ export async function createTaskStoreForBackend(
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await connections.close().catch(() => undefined);
|
await connections.close().catch(() => undefined);
|
||||||
if (embeddedLifecycle) {
|
await stopEmbeddedRuntime(
|
||||||
await embeddedLifecycle.stop().catch(() => undefined);
|
embeddedLifecycle,
|
||||||
}
|
embeddedRuntimeLease,
|
||||||
|
embeddedRuntimeUrl,
|
||||||
|
embeddedOwnsProcess,
|
||||||
|
).catch(() => undefined);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`startup-factory: SQLite → PostgreSQL first-boot auto-migration failed (refusing to boot an empty database over existing SQLite data; restore the retained backup and run 'fn db migrate' manually): ${
|
`startup-factory: SQLite → PostgreSQL first-boot auto-migration failed (refusing to boot an empty database over existing SQLite data; restore the retained backup and run 'fn db migrate' manually): ${
|
||||||
err instanceof Error ? err.message : String(err)
|
err instanceof Error ? err.message : String(err)
|
||||||
@@ -751,9 +835,12 @@ export async function createTaskStoreForBackend(
|
|||||||
log.log(`startup phase backend.taskStore.construct: ${Date.now() - constructT0}ms`);
|
log.log(`startup phase backend.taskStore.construct: ${Date.now() - constructT0}ms`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await asyncLayer.close().catch(() => undefined);
|
await asyncLayer.close().catch(() => undefined);
|
||||||
if (embeddedLifecycle) {
|
await stopEmbeddedRuntime(
|
||||||
await embeddedLifecycle.stop().catch(() => undefined);
|
embeddedLifecycle,
|
||||||
}
|
embeddedRuntimeLease,
|
||||||
|
embeddedRuntimeUrl,
|
||||||
|
embeddedOwnsProcess,
|
||||||
|
).catch(() => undefined);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`startup-factory: failed to construct PostgreSQL-backed TaskStore: ${
|
`startup-factory: failed to construct PostgreSQL-backed TaskStore: ${
|
||||||
err instanceof Error ? err.message : String(err)
|
err instanceof Error ? err.message : String(err)
|
||||||
@@ -829,7 +916,12 @@ export async function createTaskStoreForBackend(
|
|||||||
}
|
}
|
||||||
if (shutdownEmbedded) {
|
if (shutdownEmbedded) {
|
||||||
try {
|
try {
|
||||||
await shutdownEmbedded.stop();
|
await stopEmbeddedRuntime(
|
||||||
|
shutdownEmbedded,
|
||||||
|
embeddedRuntimeLease,
|
||||||
|
embeddedRuntimeUrl,
|
||||||
|
embeddedOwnsProcess,
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn(`startup-factory: embedded PostgreSQL stop failed during shutdown: ${
|
log.warn(`startup-factory: embedded PostgreSQL stop failed during shutdown: ${
|
||||||
err instanceof Error ? err.message : String(err)
|
err instanceof Error ? err.message : String(err)
|
||||||
|
|||||||
Reference in New Issue
Block a user