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 @@ -149,7 +149,10 @@ policy), not the published port, so worktrees and repeated syncs with the same s
a warm hit. The migrations shadow follows project config; the declarative shadow forces
`pg_net` off — those are distinct keys when Webhooks are enabled. A warm hit skips the
platform baseline on both shadows (`legacyMigrateNextShadowDatabase` /
`legacySetupShadowDatabase` are baseline-state-aware). Artifact:
`legacySetupShadowDatabase` are baseline-state-aware). When both snapshots are
already published they restore concurrently; a first-run pair that shares a
cache key builds the baseline once and hands it off; otherwise the two shadows
stay sequential so progress lines never interleave. Artifact:
`~/.supabase/cache/shadow-baseline/shadow-baseline-<key>.tar` (~90MB; `SUPABASE_HOME` overrides
the root), keyed by a hash of every input baked into the cluster (including the effective
Webhooks/`pg_net` policy); shared across worktrees with the same settings; retention is LRU
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,16 @@ import {
import { legacyWaitForShadowReady } from "../../../shared/db-bootstrap/health-check.ts";
import {
legacyAcquireShadowDatabase,
legacyPeekShadowBaseline,
type LegacyShadowAcquiredHandle,
type LegacyShadowBaselinePeek,
type LegacyShadowCacheOpts,
} from "../../../shared/db-bootstrap/shadow-cache.ts";
import {
legacyBufferedShadowOutput,
legacyResolvePlanShadowStrategy,
legacyRunPlanShadowProvisions,
} from "./legacy-pgdelta-next-shadow.plan.ts";
import {
legacyMemoizeSuccess,
legacyMigrateNextShadowDatabase,
Expand Down Expand Up @@ -134,19 +141,21 @@ 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),
);
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 @@ -229,19 +238,38 @@ 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);
// Baseline-handoff 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-raced or uncached) never snapshots, so signal immediately.
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),
snapshotKey: handle.snapshotKey,
snapshotKey: seamHandle.snapshotKey,
} satisfies ProvisionedMigrationsShadow;
}).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 @@ -252,7 +280,7 @@ export const legacyPgDeltaNextShadowLayer = Layer.effect(
restoredFromPgDataSnapshot: handle.baselinePresent,
snapshotKey: handle.snapshotKey,
} satisfies ProvisionedDeclarativeShadow;
}).pipe(Effect.provide(runtime), Effect.mapError(nextShadowError));
}).pipe(Effect.provide(runtimeWith(outputService)), Effect.mapError(nextShadowError));

const cacheOpts = (
opts: LegacyPgDeltaNextShadowInput,
Expand All @@ -277,17 +305,47 @@ 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"),
);
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 thread
avallete marked this conversation as resolved.
const strategy = legacyResolvePlanShadowStrategy(migrationsPeek, declarativePeek);
// Peeked inputs are reused only where the acquire follows the peek immediately: the
// migrations acquire always does, the declarative one only under `parallel`. Delayed
// declarative acquires (handoff / sequential) re-resolve so a mid-run `roles.sql`
// edit cannot publish a baseline under a stale key. Identity still uses the acquired
// handles' snapshot keys, so a delayed re-resolve cannot lie about lineage.
const migrationsOpts = withPeek(cacheOpts(opts, "config"), migrationsPeek);
Comment thread
avallete marked this conversation as resolved.
const declarativeOpts =
strategy === "parallel"
? withPeek(cacheOpts(opts, "disabled"), declarativePeek)
: cacheOpts(opts, "disabled");
Comment thread
avallete marked this conversation as resolved.

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,
// Key equality is what encodes lineage: the declarative shadow restored the very tar
// the migrations side either restored or exported this run, so the two clusters are
// physical clones. An absent key (uncached/bypassed/uncachable) is never lineage.
allowSameDatabaseIdentity: legacyAllowSameDatabaseIdentityForPlanShadows({
declarativeRestoredFromPgDataSnapshot: declarative.restoredFromPgDataSnapshot,
sameSnapshotKey:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* Orchestration for pg-delta next's two plan shadows (migrations + declarative) — the strategy
* choice, the concurrency runner, and the output buffering that keeps the user-visible
* transcript free of cross-fiber interleaving. Extracted from
* `legacy-pgdelta-next-shadow.layer.ts` so the branch logic, the baseline-handoff signal, and
* the flush ordering are unit-testable with plain fakes instead of a full Docker/runtime layer
* graph.
*
* The three strategies, chosen from a {@link legacyPeekShadowBaseline} of each shadow:
*
* - `parallel` — both snapshots are published: both provisions warm-restore concurrently. A warm
* provision skips the platform baseline entirely, so the declarative fiber prints nothing and
* the migrations fiber's `Applying migration ...` lines stream live and in order.
* - `baseline-handoff` — both are cold with the same cache key (webhooks agree): the baseline is
* paid exactly once. The migrations shadow cold-provisions; its snapshot export runs at the
* baseline seam (after platform setup, before migration replay) and signals the declarative
* fiber, which then warm-restores from the just-published tar concurrently with the migration
* replay.
* - `sequential` — everything else (different keys, mixed warm/cold, `--no-cache`, cache env off,
* PG<=14/OrioleDB): no baseline can be shared, so run migrations then declarative exactly as
* the pre-parallel code did.
*/

import { Deferred, Effect } from "effect";

import { Output } from "../../../../shared/output/output.service.ts";
import type { LegacyShadowBaselinePeek } from "../../../shared/db-bootstrap/shadow-cache.ts";

export type LegacyPlanShadowStrategy = "parallel" | "baseline-handoff" | "sequential";

/**
* Pure strategy choice from the two peeks. Equal-key implies equal warm/cold state (one key =
* one tar), so `cold`+`cold`+equal-keys is the only shareable-baseline shape; a mixed warm/cold
* pair always means different keys, where nothing can be shared and sequential keeps the cold
* side's baseline prints off the migration replay's live stream.
*/
export function legacyResolvePlanShadowStrategy(
migrations: LegacyShadowBaselinePeek,
declarative: LegacyShadowBaselinePeek,
): LegacyPlanShadowStrategy {
if (migrations.state === "warm" && declarative.state === "warm") return "parallel";
if (
migrations.state === "cold" &&
declarative.state === "cold" &&
migrations.key === declarative.key
) {
return "baseline-handoff";
Comment thread
avallete marked this conversation as resolved.
}
return "sequential";
}

/**
* Runs the two provisions under the chosen strategy.
*
* `provisionMigrations` receives an `onBaselineSeam` effect it must arrange to run once its
* baseline seam passes (the snapshot-export point, before migration replay) — the layer wires it
* into the acquired handle's `snapshotBaseline` via `Effect.ensuring`, and fires it immediately
* when the acquired handle will never run a snapshot (a warm or uncached acquire). The runner
* additionally `Effect.ensuring`s the signal onto the whole migrations provision as a liveness
* backstop, so the declarative waiter can never deadlock.
*/
export const legacyRunPlanShadowProvisions = <M, D, EM, ED, RM, RD>(opts: {
readonly strategy: LegacyPlanShadowStrategy;
readonly provisionMigrations: (onBaselineSeam: Effect.Effect<void>) => Effect.Effect<M, EM, RM>;
readonly provisionDeclarative: Effect.Effect<D, ED, RD>;
}): Effect.Effect<readonly [M, D], EM | ED, RM | RD> => {
switch (opts.strategy) {
case "parallel":
return Effect.all([opts.provisionMigrations(Effect.void), opts.provisionDeclarative], {
concurrency: 2,
});
case "baseline-handoff":
return Effect.gen(function* () {
const seam = yield* Deferred.make<void>();
const signal = Deferred.succeed(seam, undefined).pipe(Effect.asVoid);
return yield* Effect.all(
[
opts.provisionMigrations(signal).pipe(Effect.ensuring(signal)),
Deferred.await(seam).pipe(Effect.andThen(opts.provisionDeclarative)),
],
{ concurrency: 2 },
);
});
case "sequential":
return Effect.gen(function* () {
const migrations = yield* opts.provisionMigrations(Effect.void);
const declarative = yield* opts.provisionDeclarative;
return [migrations, declarative] as const;
});
}
};

export interface LegacyBufferedShadowOutput {
/** The wrapped service to provide to the fiber whose writes must not interleave. */
readonly output: typeof Output.Service;
/**
* Replays every buffered write to the real output, in order. Run it after the live fiber has
* finished (`Effect.ensuring` on the join, not on the buffered fiber — the buffered fiber can
* finish first). Idempotent; writes arriving after a flush pass straight through live so late
* teardown warnings are never lost.
*/
readonly flush: Effect.Effect<void>;
}

/**
* An {@link Output} decorator that buffers `raw`/`rawBytes` (the only channels the shadow
* provisioning paths write to) and delegates everything else live. This is the hard guarantee
* that a concurrently provisioned shadow can never land a line between two of the live fiber's
* lines — in normal mode the buffer stays empty (a warm restore prints nothing), so this exists
* for the anomaly paths: cache warnings and cold-fallback baseline prints.
*
* Deliberately not covering writes that bypass `Output` entirely (`SUPABASE_SHADOW_DEBUG` timing
* lines and failure-path container-log dumps write straight to `process.stderr`).
*/
export function legacyBufferedShadowOutput(
real: typeof Output.Service,
): LegacyBufferedShadowOutput {
type BufferedWrite =
| { readonly kind: "raw"; readonly text: string; readonly stream: "stdout" | "stderr" }
| {
readonly kind: "rawBytes";
readonly bytes: Uint8Array;
readonly stream: "stdout" | "stderr";
};
const buffer: Array<BufferedWrite> = [];
let flushed = false;
const output = Output.of({
...real,
raw: (text, stream = "stdout") =>
Effect.suspend(() => {
if (flushed) return real.raw(text, stream);
buffer.push({ kind: "raw", text, stream });
return Effect.void;
}),
rawBytes: (bytes, stream = "stdout") =>
Effect.suspend(() => {
if (flushed) return real.rawBytes(bytes, stream);
buffer.push({ kind: "rawBytes", bytes, stream });
return Effect.void;
}),
});
const flush = Effect.suspend(() => {
flushed = true;
const pending = buffer.splice(0);
return Effect.forEach(
pending,
(write) =>
write.kind === "raw"
? real.raw(write.text, write.stream)
: real.rawBytes(write.bytes, write.stream),
{ discard: true },
);
});
return { output, flush };
}
Loading
Loading