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
58 changes: 56 additions & 2 deletions packages/stack/src/LocalStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ export const localStackLayer = (
config.functions === false ? undefined : config.functions,
);
const edgeRuntimeConfigRef = yield* Ref.make(config.edgeRuntime);
// A whole-stack stop changes the orchestrator desired state for every running service to
// `stopped`. Keep that lifecycle intent separate from an explicit service stop so a later
// lazy activation can restore only services that were running before the whole-stack stop.
const wholeStackStoppedServicesRef = yield* Ref.make<ReadonlySet<ServiceName>>(new Set());
const disposedSignal = yield* Deferred.make<void>();
const lifecycleLock = Semaphore.makeUnsafe(1);
const projectionLock = Semaphore.makeUnsafe(1);
Expand Down Expand Up @@ -626,6 +630,22 @@ export const localStackLayer = (
const withLifecycleLock = lifecycleLock.withPermit;
const syncRuntimeProjectedStates = (runtime: RuntimeState) =>
syncProjectedStates(runtime.orchestrator, runtime.serviceProjection);
const clearWholeStackStopAllowance = (services: ReadonlyArray<ServiceName>) =>
Ref.update(wholeStackStoppedServicesRef, (current) => {
const next = new Set(current);
for (const service of services) next.delete(service);
return next;
});
const wholeStackStopAllowance = Ref.get(wholeStackStoppedServicesRef);
const rememberWholeStackStoppedServices = (runtime: RuntimeState) =>
Effect.gen(function* () {
const running = (yield* runtime.orchestrator.getAllStates).flatMap((state) => {
if (state.desired !== "running") return [];
const service = SERVICE_NAMES.find((candidate) => candidate === state.name);
return service !== undefined && enabledServices.includes(service) ? [service] : [];
});
yield* Ref.set(wholeStackStoppedServicesRef, new Set(running));
});
const serviceStartOptions = {
// Reservation may yield while disposal flips the lifecycle state.
beforeStart: (name: string) =>
Expand Down Expand Up @@ -736,6 +756,11 @@ export const localStackLayer = (
return yield* new StackNotRunningError({ phase });
}
});
const clearWholeStackStopAllowanceAfterSuccess = (services: ReadonlyArray<ServiceName>) =>
Effect.gen(function* () {
yield* requireRunningPhase;
yield* clearWholeStackStopAllowance(services);
}).pipe(lifecycleLock.withPermit);
const requireMutable = (operation: string) =>
Effect.suspend(() =>
disposed || disposing
Expand Down Expand Up @@ -849,6 +874,9 @@ export const localStackLayer = (
// Close the race with a concurrent stack stop before taking
// the lock-free healthy-request fast path.
yield* requireRunningPhase;
yield* clearWholeStackStopAllowanceAfterSuccess(
lifecycleTargetsForService(enabledServices, service),
);
return;
}
if (existing !== undefined) {
Expand All @@ -859,14 +887,18 @@ export const localStackLayer = (
activationReadinessPolicy(service, config.readiness, config.readinessSource),
),
);
yield* clearWholeStackStopAllowanceAfterSuccess(
lifecycleTargetsForService(enabledServices, service),
);
return;
}
yield* prepareServices([service]);
const started = yield* Effect.gen(function* () {
yield* requireRunningPhase;
const concurrentlyStarted = yield* inspectStartedTargets(service);
if (concurrentlyStarted !== undefined) return concurrentlyStarted;
return yield* beginStartTargets(service, new Set());
const allowedWholeStackStops = yield* wholeStackStopAllowance;
return yield* beginStartTargets(service, allowedWholeStackStops);
}).pipe(withLifecycleLock);
yield* waitForTargets(started).pipe((effect) =>
withReadinessPolicy(
Expand All @@ -875,6 +907,9 @@ export const localStackLayer = (
activationReadinessPolicy(service, config.readiness, config.readinessSource),
),
);
yield* clearWholeStackStopAllowanceAfterSuccess(
lifecycleTargetsForService(enabledServices, service),
);
}).pipe(cleanupOnReadinessFailure);

const stack = {
Expand Down Expand Up @@ -933,6 +968,7 @@ export const localStackLayer = (
(effect) => withReadinessPolicy(effect, "stack"),
);
yield* syncRuntimeProjectedStates(runtime);
yield* clearWholeStackStopAllowance(["postgres", ...eager]);
} else {
yield* prepareServices(enabledServices);
yield* requireMutable("start");
Expand All @@ -942,6 +978,7 @@ export const localStackLayer = (
withReadinessPolicy(effect, "stack"),
);
yield* syncRuntimeProjectedStates(runtime);
yield* clearWholeStackStopAllowance(enabledServices);
}
yield* requireMutable("start");
yield* Ref.set(phaseRef, "running");
Expand All @@ -956,10 +993,16 @@ export const localStackLayer = (
if (disposed) {
return;
}
const phase = yield* Ref.get(phaseRef);
if (phase === "stopped") {
return;
}
if (runtimeState === undefined) {
yield* Ref.set(wholeStackStoppedServicesRef, new Set());
yield* Ref.set(phaseRef, "stopped");
return;
}
yield* rememberWholeStackStoppedServices(runtimeState);
Comment thread
jgoux marked this conversation as resolved.

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 Preserve the stop snapshot during interrupted teardown retries

When an Effect caller interrupts stack.stop after Orchestrator.stop changes the services' desired states to stopped but before the lifecycle phase reaches stopped, retrying stop enters here with phase stopping and replaces the original snapshot with an empty set; the new early return only handles phase stopped. After the retry completes and the stack starts again, previously running lazy services are treated as explicitly stopped, so proxy activation fails with a build error/503. Preserve the existing snapshot while retrying an in-progress teardown, or make callers join one interruption-safe stop operation.

AGENTS.md reference: AGENTS.md:L160-L167

Useful? React with 👍 / 👎.

yield* Ref.set(phaseRef, "stopping");
yield* runtimeState.orchestrator.stop;
yield* Ref.set(phaseRef, "stopped");
Expand All @@ -974,12 +1017,19 @@ export const localStackLayer = (
const started = yield* Effect.gen(function* () {
yield* requireMutable(`start service ${name}`);
yield* requireRunningPhase;
const allowedWholeStackStops = yield* wholeStackStopAllowance;
return yield* beginStartTargets(
service,
new Set(lifecycleTargetsForService(enabledServices, service)),
new Set([
...allowedWholeStackStops,
...lifecycleTargetsForService(enabledServices, service),
]),
);
}).pipe(withLifecycleLock);
yield* waitForTargets(started).pipe((effect) => withReadinessPolicy(effect, name));
yield* clearWholeStackStopAllowanceAfterSuccess(
lifecycleTargetsForService(enabledServices, service),
);
}).pipe(cleanupOnReadinessFailure),
stopService: (name) =>
Effect.gen(function* () {
Expand All @@ -993,6 +1043,9 @@ export const localStackLayer = (
).toReversed()) {
yield* runtime.orchestrator.stopService(target);
}
yield* clearWholeStackStopAllowance(
lifecycleTargetsForService(enabledServices, service),
);
// Settle the public projection before returning so callers observe
// the stop immediately, matching the start/restart/waitReady paths.
yield* syncRuntimeProjectedStates(runtime);
Expand All @@ -1011,6 +1064,7 @@ export const localStackLayer = (
return { runtime, targets: [service] };
}).pipe(withLifecycleLock);
yield* waitForTargets(started).pipe((effect) => withReadinessPolicy(effect, name));
yield* clearWholeStackStopAllowanceAfterSuccess([service]);
}).pipe(cleanupOnReadinessFailure),
reloadFunctions: (opts) =>
Effect.gen(function* () {
Expand Down
17 changes: 11 additions & 6 deletions packages/stack/src/Stack.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -886,7 +886,7 @@ describe("Stack", () => {
}).pipe(Effect.provide(layer), Effect.timeout("5 seconds"));
});

it.live("restarts activated companions after stopping the stack", () => {
it.live("restarts activated analytics companions after stopping and restarting the stack", () => {
const graph = Effect.runSync(
buildGraph([
{
Expand Down Expand Up @@ -962,10 +962,10 @@ describe("Stack", () => {
...defaultConfig.servicePolicies,
auth: "off",
postgrest: "lazy",
pgmeta: "eager",
studio: "eager",
analytics: "eager",
vector: "eager",
pgmeta: "off",
studio: "off",
analytics: "lazy",
vector: "lazy",
},
auth: false,
} satisfies ResolvedStackConfig;
Expand All @@ -980,11 +980,16 @@ describe("Stack", () => {

return Effect.gen(function* () {
const stack = yield* Stack;
const activator = yield* StackServiceActivator;
yield* stack.start;
yield* activator.activate("analytics");
expect((yield* stack.getState("analytics")).status).toBe("Healthy");
expect((yield* stack.getState("vector")).status).toBe("Healthy");
yield* stack.stop;
yield* stack.start;
yield* stack.restartService("analytics");
yield* activator.activate("analytics");

expect((yield* stack.getState("studio")).status).toBe("Healthy");
expect((yield* stack.getState("analytics")).status).toBe("Healthy");
expect((yield* stack.getState("vector")).status).toBe("Healthy");
}).pipe(Effect.provide(layer), Effect.timeout("10 seconds"));
Expand Down
11 changes: 8 additions & 3 deletions packages/stack/src/bun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,19 @@ export async function prefetch(options?: PrefetchOptions): Promise<PrefetchResul
).catch((error: unknown) => {
throw toStackError(error);
});
const resolverLayer = BinaryResolver.make(defaultCacheRoot()).pipe(
const resolverLayer = BinaryResolver.make(options?.cacheRoot ?? defaultCacheRoot()).pipe(
Layer.provide(FetchHttpClient.layer),
);
const preparationLayer = StackPreparation.layer.pipe(Layer.provide(resolverLayer));
const effectOptions = {
versions: options?.versions,
services: options?.services,
enabledServices: options?.enabledServices,
};
const resolvedOptions: PrefetchEffectOptions =
runtime.mode === "native"
? { ...options, mode: "native" }
: { ...options, mode: "docker", containerRuntime: runtime.containerRuntime };
? { ...effectOptions, mode: "native" }
: { ...effectOptions, mode: "docker", containerRuntime: runtime.containerRuntime };
return Effect.runPromise(
prefetchEffect(resolvedOptions).pipe(
Effect.provide(preparationLayer),
Expand Down
1 change: 1 addition & 0 deletions packages/stack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type { ServiceName, VersionManifest } from "./versions.ts";
export type { ServiceResolution, StackPreparationError } from "./StackPreparation.ts";
export type { PrefetchOptions, PrefetchResult } from "./prefetch.ts";
export type { StackHandle } from "./stackHandle.ts";
export { StackError } from "./errors.ts";
export type {
FunctionsReloadConfig,
FunctionsRuntimeConfig,
Expand Down
11 changes: 8 additions & 3 deletions packages/stack/src/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,19 @@ export async function prefetch(options?: PrefetchOptions): Promise<PrefetchResul
).catch((error: unknown) => {
throw toStackError(error);
});
const resolverLayer = BinaryResolver.make(defaultCacheRoot()).pipe(
const resolverLayer = BinaryResolver.make(options?.cacheRoot ?? defaultCacheRoot()).pipe(
Layer.provide(FetchHttpClient.layer),
);
const preparationLayer = StackPreparation.layer.pipe(Layer.provide(resolverLayer));
const effectOptions = {
versions: options?.versions,
services: options?.services,
enabledServices: options?.enabledServices,
};
const resolvedOptions: PrefetchEffectOptions =
runtime.mode === "native"
? { ...options, mode: "native" }
: { ...options, mode: "docker", containerRuntime: runtime.containerRuntime };
? { ...effectOptions, mode: "native" }
: { ...effectOptions, mode: "docker", containerRuntime: runtime.containerRuntime };
return Effect.runPromise(
prefetchEffect(resolvedOptions).pipe(
Effect.provide(preparationLayer),
Expand Down
4 changes: 3 additions & 1 deletion packages/stack/src/prefetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import { StackPreparation } from "./StackPreparation.ts";
import type { ServiceName } from "./ServiceName.ts";

export interface PrefetchOptions {
/** Root directory used for native binary cache entries. */
readonly cacheRoot?: string;
readonly versions?: StackPreparationInput["versions"];
readonly services?: StackPreparationInput["services"];
readonly enabledServices?: StackPreparationInput["enabledServices"];
readonly mode?: "native" | "docker";
}

export type PrefetchEffectOptions = Omit<PrefetchOptions, "mode"> &
export type PrefetchEffectOptions = Omit<PrefetchOptions, "mode" | "cacheRoot"> &
(
| { readonly mode?: "native"; readonly containerRuntime?: never }
| { readonly mode: "docker"; readonly containerRuntime: ContainerRuntime }
Expand Down
Loading
Loading