Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,16 @@ import {
import { legacyWaitForShadowReady } from "../../../shared/db-bootstrap/health-check.ts";
import {
legacyAcquireShadowDatabase,
type LegacyShadowAcquiredHandle,
legacyPeekShadowBaseline,
type LegacyShadowBaselinePeek,
type LegacyShadowCacheOpts,
type LegacyShadowAcquiredHandle,
} from "../../../shared/db-bootstrap/shadow-cache.ts";
import {
legacyBufferedShadowOutput,
legacyResolvePlanShadowStrategy,
legacyRunPlanShadowProvisions,
} from "./legacy-pgdelta-next-shadow.plan.ts";
import {
legacyConnectShadowDatabase,
legacyMigrateNextShadowDatabase,
Expand Down Expand Up @@ -82,20 +89,30 @@ interface NativeShadowBase {
readonly image: string;
}

interface ProvisionedMigrationsShadow extends LegacyPgDeltaNextMigrationsShadow {
readonly restoredFromPgDataSnapshot: boolean;
}

interface ProvisionedDeclarativeShadow {
readonly declarativeUrl: string;
readonly restoredFromPgDataSnapshot: boolean;
}

export function legacyAllowSameDatabaseIdentityForRestoredShadows(
migrations: Pick<ProvisionedMigrationsShadow, "restoredFromPgDataSnapshot">,
declarative: Pick<ProvisionedDeclarativeShadow, "restoredFromPgDataSnapshot">,
): boolean {
return migrations.restoredFromPgDataSnapshot && declarative.restoredFromPgDataSnapshot;
/**
* Whether pg-delta's same-database guard must be bypassed for this plan's two shadows — i.e.
* whether they can legitimately report the same PostgreSQL identity (system identifier +
* database OID). That happens exactly when the declarative shadow was physically RESTORED from
* the same snapshot key that also produced the migrations shadow's cluster: same key means same
* tar, and the migrations side is that tar's lineage whether it warm-restored FROM the tar or
* cold-exported it this very run — the baseline handoff, where requiring the migrations handle
* itself to be a warm restore would leave the guard armed against its own clone and fail the
* first cold plan (review: Codex on #6215, P1). A freshly initdb'd declarative shadow always
* carries its own new identity, and different keys mean tars exported from different clusters,
* so both of those stay `false` and keep the guard armed. A `true` alongside identities that
* happen to differ is harmless by design: pg-delta's bypass only takes effect on an exact
* identity match (`schema-plan.ts`'s `trustedCloneBypass`), never on a same-lineage sibling.
*/
export function legacyAllowSameDatabaseIdentityForPlanShadows(opts: {
readonly declarativeRestoredFromPgDataSnapshot: boolean;
readonly sameSnapshotKey: boolean;
}): boolean {
return opts.declarativeRestoredFromPgDataSnapshot && opts.sameSnapshotKey;
}

/**
Expand Down Expand Up @@ -155,19 +172,23 @@ export const legacyPgDeltaNextShadowLayer = Layer.effect(
const dbConnection = yield* LegacyDbConnection;
const httpClient = yield* HttpClient.HttpClient;

const runtime = Layer.mergeAll(
Layer.succeed(FileSystem.FileSystem, fs),
Layer.succeed(Path.Path, path),
Layer.succeed(LegacyDebugFlag, debugFlag),
Layer.succeed(LegacyExperimentalFlag, experimentalFlag),
Layer.succeed(LegacyNetworkIdFlag, networkIdFlag),
Layer.succeed(CliArgs, cliArgs),
Layer.succeed(Output, output),
Layer.succeed(RuntimeInfo, runtimeInfo),
Layer.succeed(LegacyDockerRun, docker),
Layer.succeed(LegacyDbConnection, dbConnection),
Layer.succeed(HttpClient.HttpClient, httpClient),
);
// Parameterized on the Output service so `provisionPlan` can hand a concurrently running
// provision a buffering decorator (`legacyBufferedShadowOutput`) instead of the live one.
const runtimeWith = (outputService: typeof Output.Service) =>
Layer.mergeAll(
Layer.succeed(FileSystem.FileSystem, fs),
Layer.succeed(Path.Path, path),
Layer.succeed(LegacyDebugFlag, debugFlag),
Layer.succeed(LegacyExperimentalFlag, experimentalFlag),
Layer.succeed(LegacyNetworkIdFlag, networkIdFlag),
Layer.succeed(CliArgs, cliArgs),
Layer.succeed(Output, outputService),
Layer.succeed(RuntimeInfo, runtimeInfo),
Layer.succeed(LegacyDockerRun, docker),
Layer.succeed(LegacyDbConnection, dbConnection),
Layer.succeed(HttpClient.HttpClient, httpClient),
);
const runtime = runtimeWith(output);

const nextPort = (excluded?: number) =>
Effect.gen(function* () {
Expand Down Expand Up @@ -248,19 +269,39 @@ export const legacyPgDeltaNextShadowLayer = Layer.effect(
},
);

const provisionMigrations = (input: NativeShadowInput, opts: LegacyShadowCacheOpts) =>
const provisionMigrations = (
input: NativeShadowInput,
opts: LegacyShadowCacheOpts,
onBaselineSeam: Effect.Effect<void> = Effect.void,
) =>
Effect.gen(function* () {
const handle = yield* acquireShadow(input, opts);
yield* awaitShadowReady(input, handle);
const setup = setupRunInput(input, handle);
yield* legacyMigrateNextShadowDatabase(input.spawner, setup, handle);
// The baseline-handoff strategy (`legacy-pgdelta-next-shadow.plan.ts`) waits on
// `onBaselineSeam` before warm-restoring the declarative shadow. A snapshot-cold handle
// reaches that seam when its export publishes the tar; any other handle (warm because
// another process published between peek and acquire, or uncached) never runs a
// snapshot, so signal immediately — the waiter then just re-peeks current disk state.
const seamWillRun = handle.snapshotRequired && !handle.baselinePresent;
const seamHandle: LegacyShadowAcquiredHandle = seamWillRun
? {
...handle,
snapshotBaseline: handle.snapshotBaseline.pipe(Effect.ensuring(onBaselineSeam)),
}
: handle;
if (!seamWillRun) yield* onBaselineSeam;
yield* awaitShadowReady(input, seamHandle);
const setup = setupRunInput(input, seamHandle);
yield* legacyMigrateNextShadowDatabase(input.spawner, setup, seamHandle);
return {
migrationsUrl: legacyToPostgresURL(setup.connConfig),
restoredFromPgDataSnapshot: handle.baselinePresent,
} satisfies ProvisionedMigrationsShadow;
} satisfies LegacyPgDeltaNextMigrationsShadow;
}).pipe(Effect.provide(runtime), Effect.mapError(nextShadowError));

const provisionDeclarative = (input: NativeShadowInput, opts: LegacyShadowCacheOpts) =>
const provisionDeclarative = (
input: NativeShadowInput,
opts: LegacyShadowCacheOpts,
outputService: typeof Output.Service = output,
) =>
Effect.gen(function* () {
const handle = yield* acquireShadow(input, opts);
yield* awaitShadowReady(input, handle);
Expand All @@ -279,7 +320,7 @@ export const legacyPgDeltaNextShadowLayer = Layer.effect(
declarativeUrl: legacyToPostgresURL(setup.connConfig),
restoredFromPgDataSnapshot: handle.baselinePresent,
} satisfies ProvisionedDeclarativeShadow;
}).pipe(Effect.provide(runtime), Effect.mapError(nextShadowError));
}).pipe(Effect.provide(runtimeWith(outputService)), Effect.mapError(nextShadowError));

const cacheOpts = (
opts: LegacyPgDeltaNextShadowInput,
Expand All @@ -304,18 +345,79 @@ export const legacyPgDeltaNextShadowLayer = Layer.effect(
const built = yield* buildNativeBase(opts);
const migrationsInput = buildNativeInput(opts, built, migrationsPort);
const declarativeInput = buildNativeInput(opts, built, declarativePort);
const migrations = yield* provisionMigrations(migrationsInput, cacheOpts(opts, "config"));
const declarative = yield* provisionDeclarative(
declarativeInput,
cacheOpts(opts, "disabled"),
);
// The two shadows are independent — anonymous containers on the distinct host ports
// allocated above, per-invocation scoped temp dirs, and a race-tolerant network
// ensure — so warm provisions run fully concurrently. How much can safely overlap
// when a baseline still has to be BUILT is the strategy question: peek both cache
// states up front and dispatch (see `legacy-pgdelta-next-shadow.plan.ts` for the
// three strategies and their transcript guarantees). The peeks also resolve the
// cache-key inputs once; passing them back through `precomputedKeyInputs` keeps the
// acquire from repeating a live JWKS discovery request.
const [migrationsPeek, declarativePeek] = yield* Effect.all([
legacyPeekShadowBaseline(migrationsInput.base, cacheOpts(opts, "config")),
legacyPeekShadowBaseline(declarativeInput.base, cacheOpts(opts, "disabled")),
]);
const withPeek = (
cache: LegacyShadowCacheOpts,
peek: LegacyShadowBaselinePeek,
): LegacyShadowCacheOpts =>
peek.state === "uncachable"
? cache
: { ...cache, precomputedKeyInputs: peek.keyInputs };
Comment on lines +364 to +366

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Revalidate key inputs before delayed acquisition

When the strategy is sequential, both peeks happen before the migrations shadow runs, but the declarative shadow may not acquire until a long migration replay finishes. Passing its precomputedKeyInputs here means a concurrent edit or removal of supabase/roles.sql is not reflected in the cache key, while legacySetupDatabase later reads and applies the current file (db-setup.ts:1084-1103); the resulting baseline is therefore published under the stale key and subsequent commands can silently restore the wrong roles. Recompute mutable key inputs when the delayed acquire begins, or carry the resolved roles content through to setup.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed in c0511f1. Peeked key inputs are now reused only where the acquire follows the peek immediately — the migrations acquire always, the declarative one only under the parallel strategy. The delayed declarative acquires (handoff and sequential) re-resolve at acquire time, which also self-corrects a handoff whose key genuinely changed mid-run: the recomputed key misses the just-exported tar and the declarative side cold-provisions with the current inputs (and allowSameDatabaseIdentity stays consistent, since a cold declarative reports restoredFromPgDataSnapshot: false).

One residual note for completeness: even with acquire-time resolution, roles.sql is read once for the key and again by legacySetupDatabase a few seconds later during the same cold provision — that acquire→setup window predates this PR (it's inherent to #6184's key design) and is unchanged here.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The memoization is deliberate and keeping it is the correct behavior here, so no code change — only the comment overclaiming "remote JWKS" as refreshed was wrong, corrected in 5390632.

The property that made the roles.sql half of this thread a real bug was key/content divergence. JWKS cannot diverge: the cache-key resolution and legacyResolveDbSetupPrelude consume the same memoized effect on the same shadow input, so a published tar always carries exactly the value its key was computed from — that consistency guarantee is what the memo was added for on #6184 ("guarantees the published snapshot carries the exact value its key was computed from, even if the issuer rotates mid-run"). Resetting the resolver at delayed acquisition would trade that away for freshness the cache doesn't promise anywhere: a warm hit already serves a baseline whose JWKS was resolved up to 14 days earlier under its matching key, so command-start vs acquire-time (seconds to minutes apart) is immaterial — and it would add a second live discovery request per cold sync.


Generated by Claude Code

const strategy = legacyResolvePlanShadowStrategy(migrationsPeek, declarativePeek);
// Peeked inputs are only reused where the acquire follows the peek IMMEDIATELY: the
// migrations acquire always does, the declarative one only under `parallel`. In the
// handoff (waits for the seam) and sequential (waits for the whole migrations
// provision) strategies the declarative acquire is DELAYED, and the key hashes
// `supabase/roles.sql` while the cold setup re-reads that file at its own time —
// reusing a stale peek there could publish a baseline under a key that no longer
// describes it (review: Codex on #6215). Re-resolving at acquire time also
// self-corrects a handoff whose key genuinely changed mid-run: the recomputed key
// misses the just-exported tar and the declarative side correctly cold-provisions
// with the current inputs. The key's OTHER live input, the JWKS resolver, is
// deliberately exempt from this refresh: it is memoized per shadow input
// (`legacyShadowRunInputFromLocalContainerInputs`), so the key and the baked baseline
// always carry the SAME value and cannot diverge; a delayed acquire keeps the
// command-start JWKS, well inside the staleness the snapshot cache accepts by design
// (a warm hit serves a tar up to 14 days old under its matching key).
const migrationsOpts = withPeek(cacheOpts(opts, "config"), migrationsPeek);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Re-resolve the migrations key after both peeks complete

When realtime is enabled and the declarative peek's JWKS resolution is slow, the migrations peek may have already read roles.sql, but the Effect.all at lines 356–359 waits for the slower peer before this acquisition begins. If roles.sql changes during that interval, these precomputed inputs select the old cache key while setup later reads and executes the current file (db-setup.ts:1084–1103), either restoring a stale baseline or publishing the new roles under the wrong key. Fresh evidence beyond the prior delayed-declarative comment is that this rendezvous also delays the supposedly immediate migrations acquire; re-resolve its mutable inputs after both peeks finish.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No change here — this window is not materially reducible and is already dominated by one this layer cannot close.

The gap flagged (migrations peek → acquire) is bounded by the declarative peek's duration: essentially one JWKS discovery request, and only when realtime on PG15+ makes that a live fetch — otherwise it's filesystem reads measured in milliseconds. The window that follows the acquire on every cold provision — docker create, the root-key docker cp, docker start, the readiness wait, the setup prelude — is multiple seconds before legacySetupDatabase re-reads roles.sql at its own time, and is inherent to #6184's key design. Re-resolving the migrations key after the rendezvous moves one read a fraction of a second later while that larger, irreducible window persists, so the mid-run-edit race's reachability and consequences are identical either way. The fix earlier in this review targeted a minutes-long widening (the full migration replay); this is seconds in front of seconds. Fully closing the race means carrying resolved roles content into setup — a #6184 design change out of this PR's scope.


Generated by Claude Code

const declarativeOpts =
strategy === "parallel"
? withPeek(cacheOpts(opts, "disabled"), declarativePeek)
: cacheOpts(opts, "disabled");
Comment on lines +384 to +387

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Refresh the delayed declarative JWKS resolver

When realtime is enabled on PG15+ and the remote issuer rotates its JWKS during a long migrations provision, omitting precomputedKeyInputs here still does not refresh that input: legacyShadowRunInputFromLocalContainerInputs wraps the resolver with legacyMemoizeSuccess (shadow-database.ts:522-529), and the initial declarative peek has already memoized the old value. Consequently, the delayed sequential/handoff acquire and its cold setup both use the command-start JWKS rather than the value available when provisioning actually begins, unlike the pre-change sequential flow. Fresh evidence beyond the earlier stale-key review is that the follow-up re-resolution reuses this already-evaluated memo; construct/reset the declarative resolver at delayed acquisition or otherwise defer its first evaluation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The memoization is deliberate and keeping it is the correct behavior, so no code change — only my comment overclaiming "remote JWKS" as refreshed was wrong, corrected in 5390632.

What made the roles.sql half of this review a real bug was key/content divergence. JWKS cannot diverge: the cache-key resolution and legacyResolveDbSetupPrelude consume the same memoized effect on the same shadow input, so a published tar always carries exactly the value its key was computed from — the memo was added on #6184 for precisely that guarantee ("guarantees the published snapshot carries the exact value its key was computed from, even if the issuer rotates mid-run"). Resetting the resolver at delayed acquisition would trade that consistency away for freshness the cache doesn't promise anywhere — a warm hit already serves a baseline whose JWKS was resolved up to 14 days earlier under its matching key, so command-start vs acquire-time is immaterial — and it would add a second live discovery request per cold sync.


Generated by Claude Code


// In the concurrent strategies the declarative fiber's writes are buffered and
// flushed after the join, so nothing can land between two of the migrations fiber's
// live lines; sequential needs no buffer (one fiber at a time). `Effect.ensuring` on
// the JOIN (not the declarative fiber, which can finish first) so anomaly warnings
// survive failures without ever interleaving.
const buffered =
strategy === "sequential" ? undefined : legacyBufferedShadowOutput(output);
const provisions = legacyRunPlanShadowProvisions({
strategy,
provisionMigrations: (onBaselineSeam) =>
provisionMigrations(migrationsInput, migrationsOpts, onBaselineSeam),
provisionDeclarative: provisionDeclarative(
declarativeInput,
declarativeOpts,
buffered === undefined ? output : buffered.output,
),
});
const [migrations, declarative] = yield* buffered === undefined
? provisions
: provisions.pipe(Effect.ensuring(buffered.flush));
return {
migrationsUrl: migrations.migrationsUrl,
declarativeUrl: declarative.declarativeUrl,
allowSameDatabaseIdentity: legacyAllowSameDatabaseIdentityForRestoredShadows(
migrations,
declarative,
),
// Key equality comes from the peeks (deterministic over inputs, not disk state),
// so a between-fibers eviction or publish cannot make it lie about lineage.
allowSameDatabaseIdentity: legacyAllowSameDatabaseIdentityForPlanShadows({
declarativeRestoredFromPgDataSnapshot: declarative.restoredFromPgDataSnapshot,
sameSnapshotKey:
migrationsPeek.state !== "uncachable" &&
declarativePeek.state !== "uncachable" &&
migrationsPeek.key === declarativePeek.key,
}),
} satisfies LegacyPgDeltaNextPlanShadows;
}).pipe(Effect.mapError(nextShadowError)),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Effect } from "effect";
import { describe, expect, it as vitestIt } from "vitest";

import {
legacyAllowSameDatabaseIdentityForRestoredShadows,
legacyAllowSameDatabaseIdentityForPlanShadows,
legacyPreparePgDeltaNextDeclarativeBaseline,
} from "./legacy-pgdelta-next-shadow.layer.ts";

Expand Down Expand Up @@ -46,20 +46,25 @@ describe("legacyPreparePgDeltaNextDeclarativeBaseline", () => {
});
});

describe("legacyAllowSameDatabaseIdentityForRestoredShadows", () => {
describe("legacyAllowSameDatabaseIdentityForPlanShadows", () => {
vitestIt.each([
{ migrations: true, declarative: true, expected: true },
{ migrations: true, declarative: false, expected: false },
{ migrations: false, declarative: true, expected: false },
{ migrations: false, declarative: false, expected: false },
// A declarative shadow restored from the migrations side's own snapshot key IS a physical
// clone of that cluster — whether the migrations side warm-restored from the tar or
// cold-exported it this run (the baseline handoff) — so the guard must be bypassed.
{ restored: true, sameKey: true, expected: true },
// Restored from a DIFFERENT key's tar: a different originating cluster, own identity.
{ restored: true, sameKey: false, expected: false },
// A freshly initdb'd declarative shadow always carries a brand-new identity.
{ restored: false, sameKey: true, expected: false },
{ restored: false, sameKey: false, expected: false },
])(
"returns $expected for migrations=$migrations and declarative=$declarative",
({ migrations, declarative, expected }) => {
"returns $expected for restored=$restored and sameKey=$sameKey",
({ restored, sameKey, expected }) => {
expect(
legacyAllowSameDatabaseIdentityForRestoredShadows(
{ restoredFromPgDataSnapshot: migrations },
{ restoredFromPgDataSnapshot: declarative },
),
legacyAllowSameDatabaseIdentityForPlanShadows({
declarativeRestoredFromPgDataSnapshot: restored,
sameSnapshotKey: sameKey,
}),
).toBe(expected);
},
);
Expand Down
Loading