fix: acknowledge pi maintenance barrier
This commit is contained in:
+20
-4
@@ -15,7 +15,7 @@ import { settingsRoutes, effectiveSettings } from "./routes/settings.js";
|
||||
import { createPiModelLister } from "./pi/list-models.js";
|
||||
import { loadSettings, type Settings } from "./settings/settings-store.js";
|
||||
import { ReadinessManager } from "./runtime/readiness-manager.js";
|
||||
import { createMaintenanceGate } from "./runtime/maintenance-gate.js";
|
||||
import { MaintenanceBarrier } from "./runtime/maintenance-gate.js";
|
||||
import { WorkspaceRegistry } from "./workspaces/registry.js";
|
||||
import { createProductionWorkspaceDiagnoser } from "./workspaces/diagnostics.js";
|
||||
import { workspaceRoutes, type WorkspaceDiagnoser } from "./routes/workspaces.js";
|
||||
@@ -33,8 +33,7 @@ export interface BuildAppDeps {
|
||||
workspaceRegistry?: WorkspaceRegistry;
|
||||
workspaceDiagnoser?: WorkspaceDiagnoser;
|
||||
workspaceRuntimeSupport?: (workspace: WorkspaceDescriptor) => boolean;
|
||||
/** Returns true while a host maintenance transaction is preventing new runtimes. */
|
||||
maintenanceGate?: () => boolean;
|
||||
maintenanceBarrier?: MaintenanceBarrier;
|
||||
}
|
||||
|
||||
export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance {
|
||||
@@ -97,12 +96,27 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
app.get("/health", async () => ({ status: "ok" }));
|
||||
app.get("/health/dwh", async () => tht.dbPing());
|
||||
app.get("/me", async (req) => getPrincipal(req));
|
||||
const maintenanceBarrier = deps?.maintenanceBarrier ?? new MaintenanceBarrier();
|
||||
sessionRoutes(app, {
|
||||
mgr, tht: tht as ThtRunner, hub, getSettings, readiness, listModels, workspaceRegistry,
|
||||
dwhPrecheck: config.dwhPrecheck,
|
||||
legacyWorkspaceMode: config.legacyWorkspaceMode,
|
||||
workspaceRuntimeSupport,
|
||||
maintenanceGate: deps?.maintenanceGate ?? createMaintenanceGate(config.maintenanceFile),
|
||||
maintenanceBarrier,
|
||||
});
|
||||
app.post("/internal/maintenance/activate", async (req, reply) => {
|
||||
if (!isLoopback(req.ip) || req.principal?.subject !== "thothctl-maintenance") return reply.code(403).send({ error: "loopback maintenance control required" });
|
||||
await maintenanceBarrier.activate();
|
||||
return maintenanceBarrier.status();
|
||||
});
|
||||
app.post("/internal/maintenance/deactivate", async (req, reply) => {
|
||||
if (!isLoopback(req.ip) || req.principal?.subject !== "thothctl-maintenance") return reply.code(403).send({ error: "loopback maintenance control required" });
|
||||
maintenanceBarrier.deactivate();
|
||||
return maintenanceBarrier.status();
|
||||
});
|
||||
app.get("/internal/maintenance/status", async (req, reply) => {
|
||||
if (!isLoopback(req.ip) || req.principal?.subject !== "thothctl-maintenance") return reply.code(403).send({ error: "loopback maintenance control required" });
|
||||
return maintenanceBarrier.status();
|
||||
});
|
||||
sqlRoutes(app, { tht: tht as ThtRunner, getSettings });
|
||||
metaRoutes(app, { harnessDir: config.harnessDir, listModels });
|
||||
@@ -111,3 +125,5 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
function isLoopback(ip: string): boolean { return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1"; }
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { ReadinessManager } from "../runtime/readiness-manager.js";
|
||||
import type { ListModelsFn } from "./meta.js";
|
||||
import type { WorkspaceRegistry } from "../workspaces/registry.js";
|
||||
import type { WorkspaceDescriptor } from "../workspaces/schema.js";
|
||||
import type { MaintenanceBarrier } from "../runtime/maintenance-gate.js";
|
||||
|
||||
const BOOTSTRAP_FAILURE_MESSAGE =
|
||||
"Session startup failed. Check configuration and connectivity, then Resume the session.";
|
||||
@@ -37,8 +38,7 @@ export function sessionRoutes(
|
||||
legacyWorkspaceMode?: boolean;
|
||||
/** Fail-closed installation/runtime transport capability check. */
|
||||
workspaceRuntimeSupport: (workspace: WorkspaceDescriptor) => boolean;
|
||||
/** Host-controlled admission guard. Existing runtimes deliberately continue. */
|
||||
maintenanceGate: () => boolean;
|
||||
maintenanceBarrier: MaintenanceBarrier;
|
||||
},
|
||||
) {
|
||||
const lifecycleTails = new Map<string, Promise<void>>();
|
||||
@@ -81,6 +81,14 @@ export function sessionRoutes(
|
||||
code: "maintenance",
|
||||
error: "Session admission is temporarily paused for maintenance. Try again shortly.",
|
||||
});
|
||||
const admissionLeases = new WeakMap<object, () => void>();
|
||||
app.addHook("preHandler", async (req, reply) => {
|
||||
if (req.method !== "POST" || !(req.url === "/sessions" || /^\/sessions\/[^/]+\/resume(?:\?|$)/.test(req.url))) return;
|
||||
const release = d.maintenanceBarrier.acquire();
|
||||
if (!release) return maintenanceReply(reply);
|
||||
admissionLeases.set(req, release);
|
||||
});
|
||||
app.addHook("onResponse", async (req) => { admissionLeases.get(req)?.(); });
|
||||
|
||||
/** Include retained historical descriptors so removed workspaces remain resumable. */
|
||||
const sessionRevisions = async () => {
|
||||
@@ -279,7 +287,6 @@ export function sessionRoutes(
|
||||
});
|
||||
|
||||
app.post("/sessions", async (req, reply) => {
|
||||
if (d.maintenanceGate()) return maintenanceReply(reply);
|
||||
const b = req.body as {
|
||||
question: string; name?: string; workspace?: string; workspaceId?: string;
|
||||
provider?: string; model?: string; thinking?: string;
|
||||
@@ -510,7 +517,6 @@ export function sessionRoutes(
|
||||
return reply.code(204).send();
|
||||
});
|
||||
app.post("/sessions/:id/resume", async (req, reply) => {
|
||||
if (d.maintenanceGate()) return maintenanceReply(reply);
|
||||
const id = (req.params as any).id;
|
||||
const principal = getPrincipal(req);
|
||||
return withSessionLifecycle(id, async () => {
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import { existsSync } from "node:fs";
|
||||
/** An in-process admission barrier. A lease spans the complete create/resume decision. */
|
||||
export class MaintenanceBarrier {
|
||||
private active = false;
|
||||
private admissions = 0;
|
||||
private waiters: (() => void)[] = [];
|
||||
|
||||
/**
|
||||
* A host-side lifecycle transaction creates this marker before it checks for active sessions.
|
||||
* The marker is intentionally only an admission gate: it must never terminate existing Pi
|
||||
* processes or make their in-flight work unavailable.
|
||||
*/
|
||||
export function createMaintenanceGate(markerFile: string): () => boolean {
|
||||
return () => existsSync(markerFile);
|
||||
acquire(): (() => void) | undefined {
|
||||
if (this.active) return undefined;
|
||||
this.admissions += 1;
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
this.admissions -= 1;
|
||||
if (this.admissions === 0) this.waiters.splice(0).forEach((resolve) => resolve());
|
||||
};
|
||||
}
|
||||
|
||||
async activate(): Promise<void> {
|
||||
this.active = true;
|
||||
if (this.admissions === 0) return;
|
||||
await new Promise<void>((resolve) => this.waiters.push(resolve));
|
||||
}
|
||||
|
||||
deactivate(): void { this.active = false; }
|
||||
status(): { active: boolean; admissions: number } { return { active: this.active, admissions: this.admissions }; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { test, expect } from "vitest";
|
||||
import { MaintenanceBarrier } from "../src/runtime/maintenance-gate.js";
|
||||
|
||||
test("activation waits for an in-flight admission lease and rejects later admissions", async () => {
|
||||
const gate = new MaintenanceBarrier();
|
||||
const release = gate.acquire();
|
||||
expect(release).toBeTypeOf("function");
|
||||
let acknowledged = false;
|
||||
const activation = gate.activate().then(() => { acknowledged = true; });
|
||||
await Promise.resolve();
|
||||
expect(acknowledged).toBe(false);
|
||||
expect(gate.acquire()).toBeUndefined();
|
||||
release?.();
|
||||
await activation;
|
||||
expect(acknowledged).toBe(true);
|
||||
expect(gate.status()).toEqual({ active: true, admissions: 0 });
|
||||
gate.deactivate();
|
||||
expect(gate.acquire()).toBeTypeOf("function");
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { tmpdir } from "node:os";
|
||||
import { buildApp as buildRealApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { SseHub } from "../src/sse/sse-hub.js";
|
||||
import { MaintenanceBarrier } from "../src/runtime/maintenance-gate.js";
|
||||
|
||||
const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs");
|
||||
const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation.json");
|
||||
@@ -76,8 +77,10 @@ test("upstream requests without a principal fail before a Pi runtime can be crea
|
||||
});
|
||||
|
||||
test("maintenance rejects new and resumed session admission without interrupting running sessions", async () => {
|
||||
const maintenanceBarrier = new MaintenanceBarrier();
|
||||
await maintenanceBarrier.activate();
|
||||
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
|
||||
maintenanceGate: () => true,
|
||||
maintenanceBarrier,
|
||||
thtRunner: { withPrincipal: () => ({ sessionShow: async () => ({ id: "open", status: "open" }) }) } as any,
|
||||
});
|
||||
|
||||
@@ -94,7 +97,7 @@ test("maintenance rejects new and resumed session admission without interrupting
|
||||
expect(resume.json()).toEqual(create.json());
|
||||
});
|
||||
|
||||
test("the on-disk maintenance marker gates admission in an upstream server profile", async () => {
|
||||
test("a server-profile marker does not weaken the in-process maintenance gate", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "tht-maintenance-"));
|
||||
try {
|
||||
const marker = path.join(dir, "maintenance.json");
|
||||
@@ -105,8 +108,7 @@ test("the on-disk maintenance marker gates admission in an upstream server profi
|
||||
const response = await app.inject({
|
||||
method: "POST", url: "/sessions", headers: aliceHeaders, payload: { question: "q" },
|
||||
});
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.json().code).toBe("maintenance");
|
||||
expect(response.statusCode).not.toBe(503);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user