Skip to content

Commit 3bab08f

Browse files
committed
refactor(hub): run each local allocation in a sidecar process of its own
The local provisioner goes back to stock's model, as its own harness runs it: ensure starts a sidecar process for the allocation and destroy stops it and removes its files, so cleanup is always confirmed and no process keeps the memory of deployments it no longer hosts. Each sidecar advertises one active deployment. A late ensure for a destroyed identity is fenced in memory, as the hub mints a fresh identity for every ensure after a restart. Boot never respawns: it retires every sidecar an earlier boot listed, in this layout or the single shared sidecar's, and removes their files. A sidecar that exits on its own is retired at once. The provisioner refuses a start past the configured number of sidecars, or while the box lacks the memory for one beside those still connecting, as retryable capacity. The stock provisioner conformance suite runs against it again.
1 parent 1c1919c commit 3bab08f

10 files changed

Lines changed: 415 additions & 784 deletions

‎apps/hub/src/server.ts‎

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import { connectRailwaySidecars, sweepRailwaySidecarsOf, type RailwaySidecarProv
4646
import { createHostCapacity } from "./sidecar/host-capacity.js";
4747
import { createLocalSidecarManifestStore } from "./sidecar/manifest-store.js";
4848
import { createPlacementChooser, localSidecarUsage } from "./sidecar/placement.js";
49-
import { restoreLocalSidecars } from "./sidecar/restore.js";
49+
import { bootLocalSidecars } from "./sidecar/boot.js";
5050
import { createSpawnLocalSidecar } from "./sidecar/spawn.js";
5151
import { SIDECAR_ADAPTER_MANIFEST } from "./sidecar-config.js";
5252
import { createPortalHandler, isPortalRequest, withPortalCors } from "./portal.js";
@@ -163,17 +163,12 @@ const local = createLocalProcessSidecarProvisioner({
163163
spawnSidecar: createSpawnLocalSidecar({
164164
interchangeDir: V,
165165
credentialEncryptionKey: env.SIDECAR_CREDENTIAL_ENCRYPTION_KEY,
166-
maxActiveDeployments: localSidecarCap,
167166
processEnv: childProcessEnv(env),
168167
}),
169-
manifests: createLocalSidecarManifestStore({
170-
dataRoot: localSidecarDataRoot,
171-
cipher: credentialCipher,
172-
log: logJson,
173-
}),
168+
manifests: createLocalSidecarManifestStore({ dataRoot: localSidecarDataRoot, log: logJson }),
174169
capacity: createHostCapacity({ cgroupRoot: "/sys/fs/cgroup", meminfo: "/proc/meminfo" }),
175170
adapterManifest: SIDECAR_ADAPTER_MANIFEST,
176-
onAccepted: notePlaced,
171+
maxSidecars: localSidecarCap,
177172
log: logJson,
178173
});
179174
const docker = await connectDockerSidecars({ docker: dockerSettings(env), database, adapterManifest: SIDECAR_ADAPTER_MANIFEST });
@@ -247,6 +242,7 @@ const composition = await createInterchangeHub({
247242
});
248243
const stock = composition.server;
249244
composition.sidecarRouter.events.on("sidecar.allocated.connected", notePlaced);
245+
composition.sidecarRouter.events.on("sidecar.allocated.connected", local.connected);
250246

251247
/** The hub's credentials, and the push that takes back from sidecars an App key a read moved to the hub. */
252248
const credentialDeps = {
@@ -261,7 +257,7 @@ const movedAppKeys: string[] = [];
261257
for (const tenantId of await githubTenants(composition.db)) {
262258
if (await moveAppKey(credentialDeps, tenantId)) movedAppKeys.push(tenantId);
263259
}
264-
await restoreLocalSidecars(composition.db, local);
260+
await bootLocalSidecars(composition.db, local);
265261
if (dockerUp) logJson({ level: "info", msg: "docker_sidecars_swept", removed: await sweepDockerSidecarsOf(composition.db, dockerUp) });
266262
async function tendE2b(sidecars: E2bSidecarProvisioner): Promise<void> {
267263
try {

‎apps/hub/src/sidecar/boot.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { schema, type DB } from "@intx/db";
2+
import { and, inArray, notInArray } from "drizzle-orm";
3+
import type { LocalProcessSidecarProvisioner } from "./local-provisioner.js";
4+
5+
export async function bootLocalSidecars(db: DB["db"], local: LocalProcessSidecarProvisioner): Promise<void> {
6+
async function stillOpen(allocationIds: readonly string[]): Promise<ReadonlySet<string>> {
7+
if (allocationIds.length === 0) return new Set();
8+
const ids = [...allocationIds];
9+
const allocations = await db
10+
.select({ id: schema.sidecarAllocation.id })
11+
.from(schema.sidecarAllocation)
12+
.where(and(inArray(schema.sidecarAllocation.id, ids), notInArray(schema.sidecarAllocation.status, ["released", "failed"])));
13+
const probes = await db
14+
.select({ id: schema.workflowProbe.id })
15+
.from(schema.workflowProbe)
16+
.where(and(inArray(schema.workflowProbe.id, ids), notInArray(schema.workflowProbe.status, ["succeeded", "failed"])));
17+
return new Set([...allocations, ...probes].map((row) => row.id));
18+
}
19+
await local.boot({ stillOpen });
20+
}

‎apps/hub/src/sidecar/host-capacity.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ test("without a cgroup limit MemAvailable decides, and a box reporting neither h
5252
expect(await createHostCapacity(await box({})).freeBytes()).toBeUndefined();
5353
});
5454

55-
test("the sidecar needs one footprint free, and no figure never declines", () => {
56-
expect(shortfall(SIDECAR_BYTES)).toBeUndefined();
57-
expect(shortfall(SIDECAR_BYTES / 2)).toBe(`${SIDECAR_BYTES / 2 / MiB} MiB free, ${SIDECAR_BYTES / MiB} MiB needed`);
58-
expect(shortfall(undefined)).toBeUndefined();
55+
test("each sidecar still starting holds one footprint, and no figure never declines", () => {
56+
expect(shortfall(2 * SIDECAR_BYTES, 1)).toBeUndefined();
57+
expect(shortfall(2 * SIDECAR_BYTES, 2)).toBe(`${(2 * SIDECAR_BYTES) / MiB} MiB free, ${(3 * SIDECAR_BYTES) / MiB} MiB needed`);
58+
expect(shortfall(undefined, 100)).toBeUndefined();
5959
});

‎apps/hub/src/sidecar/host-capacity.ts‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
// Whether this box has the memory to start the local sidecar: free memory in
1+
// Whether this box has the memory for one more local sidecar: free memory in
22
// the container's cgroup (v2, else v1) where a limit is set, else Linux's
3-
// MemAvailable. A box with neither, such as a macOS dev machine, never
4-
// declines: os.freemem there leaves out memory the system would reclaim.
3+
// MemAvailable, less one footprint for each sidecar started that has not
4+
// connected yet, as its memory is not in use yet. A box with neither, such as
5+
// a macOS dev machine, never declines: os.freemem there leaves out memory the
6+
// system would reclaim.
57
import fs from "node:fs/promises";
68
import os from "node:os";
79
import path from "node:path";
@@ -67,8 +69,9 @@ export function createHostCapacity({ cgroupRoot, meminfo }: HostCapacityOpts): H
6769
};
6870
}
6971

70-
/** Why there is no room to start the sidecar, or undefined when there is. */
71-
export function shortfall(free: number | undefined): string | undefined {
72-
if (free === undefined || free >= SIDECAR_BYTES) return undefined;
73-
return `${Math.floor(free / MiB)} MiB free, ${SIDECAR_BYTES / MiB} MiB needed`;
72+
/** Why there is no room for one more sidecar beside `starting` ones not connected yet, or undefined when there is. */
73+
export function shortfall(free: number | undefined, starting: number): string | undefined {
74+
const needed = SIDECAR_BYTES * (starting + 1);
75+
if (free === undefined || free >= needed) return undefined;
76+
return `${Math.floor(free / MiB)} MiB free, ${needed / MiB} MiB needed`;
7477
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { describeProvisionerConformance } from "@corbits/provisioner-test";
5+
import { createLocalProcessSidecarProvisioner } from "./local-provisioner.js";
6+
import { createLocalSidecarManifestStore } from "./manifest-store.js";
7+
8+
type FakeProcess = {
9+
readonly sidecarId: string;
10+
running: boolean;
11+
};
12+
13+
describeProvisionerConformance("local-process", async () => {
14+
const dataRoot = await fs.mkdtemp(path.join(os.tmpdir(), "local-provisioner-conformance-"));
15+
const processes: FakeProcess[] = [];
16+
const manifests = createLocalSidecarManifestStore({ dataRoot, log: () => {} });
17+
18+
const local = createLocalProcessSidecarProvisioner({
19+
dataRoot,
20+
capacity: { freeBytes: async () => undefined },
21+
adapterManifest: [],
22+
maxSidecars: 4,
23+
spawnSidecar: function spawnSidecar({ sidecarId }) {
24+
const process: FakeProcess = { sidecarId, running: true };
25+
processes.push(process);
26+
const exit = Promise.withResolvers<{ code: number | null; signal: string | null }>();
27+
return {
28+
pid: processes.length,
29+
exited: exit.promise,
30+
kill(signal) {
31+
process.running = false;
32+
exit.resolve({ code: null, signal });
33+
},
34+
};
35+
},
36+
manifests,
37+
log: () => {},
38+
});
39+
local.provisioner.attach?.({ sidecarStopped: async () => ({ kind: "retired" }) });
40+
await local.boot({ stillOpen: async (ids) => new Set(ids) });
41+
42+
return {
43+
provisioner: local.provisioner,
44+
async live(allocationId) {
45+
let manifest: { generation: number; sidecarId: string };
46+
try {
47+
manifest = JSON.parse(await fs.readFile(path.join(dataRoot, "manifests", `${allocationId}.json`), "utf8"));
48+
} catch {
49+
return [];
50+
}
51+
return processes
52+
.filter((process) => process.running && process.sidecarId === manifest.sidecarId)
53+
.map(({ sidecarId }) => ({ generation: manifest.generation, sidecarId }));
54+
},
55+
capabilityChecks: {},
56+
async dispose() {
57+
await local.shutdown();
58+
await fs.rm(dataRoot, { recursive: true, force: true });
59+
},
60+
};
61+
});

0 commit comments

Comments
 (0)