From 7481a968a849571dcb0b9740c5d40fc6a017a13d Mon Sep 17 00:00:00 2001 From: os-elon Date: Sat, 22 Aug 2026 15:30:11 +0000 Subject: [PATCH 1/2] fix(rest): one kernel-waiter window per request via KernelResolver.resolveEnvironment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A REST request on a multi-tenant host paid the host's kernel-waiter window twice. `RestApiPlugin`'s wrapper asked `resolveKernel` — a kernel-acquisition api — for an environment id and discarded the kernel it bought; `resolveProtocol` then acquired it again. Free on a warm environment, a second serial wait on a cold or wedged one: measured 42s to a 503 on REST-owned routes against 21s on dispatcher-owned ones (`waiterTimeoutMs: 20s`). Adds the optional `KernelResolver.resolveEnvironment?(context, defaultKernel)` to the ADR-0006 contract — resolve the environment, acquire no kernel — and has the REST wrapper prefer it, leaving `resolveProtocol` as the single acquisition point. `?.`-optional, so every existing resolver keeps working unchanged. Fail-closed preserved and pinned: the surviving `getOrCreate` still rejects and the caller still gets the declared 503. `waiterTimeoutMs` untouched. Fixes #10988 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r --- .../kernel-resolver-resolve-environment.md | 39 ++ packages/rest/src/rest-api-plugin.ts | 32 +- ...-resolve-environment-single-window.test.ts | 410 ++++++++++++++++++ packages/rest/src/rest-server.ts | 30 ++ .../http-dispatcher.kernel-resolver.test.ts | 60 +++ packages/runtime/src/http-dispatcher.ts | 43 ++ 6 files changed, 613 insertions(+), 1 deletion(-) create mode 100644 .changeset/kernel-resolver-resolve-environment.md create mode 100644 packages/rest/src/rest-resolve-environment-single-window.test.ts diff --git a/.changeset/kernel-resolver-resolve-environment.md b/.changeset/kernel-resolver-resolve-environment.md new file mode 100644 index 0000000000..be3029ccee --- /dev/null +++ b/.changeset/kernel-resolver-resolve-environment.md @@ -0,0 +1,39 @@ +--- +"@objectstack/runtime": minor +"@objectstack/rest": patch +--- + +`KernelResolver` gains an optional environment-only member so a REST request +pays ONE kernel-waiter window instead of two (#10988). + +`RestApiPlugin` wraps the host's ADR-0006 `kernel-resolver` so `RestServer` can +ask "which environment is this request in?". It asked `resolveKernel` — a +kernel-ACQUISITION api — and kept only `context.environmentId`. A host resolver +writes the id and then awaits that environment's kernel, so the wrapper paid a +full waiter window and discarded what it bought; `resolveProtocol` then acquired +the kernel again. Free on a warm environment (a cache hit, which is why this was +invisible), a second serial wait on a cold or wedged one. Measured on a live +multi-tenant host with `waiterTimeoutMs: 20s`: REST-owned routes +(`/api/v1/discovery`, `/api/v1/data/:object`) answered 503 after ~42s where +dispatcher-owned routes answered after ~21s. + +`KernelResolver.resolveEnvironment?(context, defaultKernel)` resolves ONLY the +request's environment onto the context, acquiring no kernel; the REST wrapper +prefers it when the host implements it, leaving `resolveProtocol` as the single +kernel-acquisition point on the path. + +**Non-breaking, and no flag day.** The member is `?.`-optional: a host that +implements only `resolveKernel` type-checks and behaves exactly as before (it +keeps paying the discarded acquisition on cold builds), so this ships before any +host implements the new half. Adding an optional member to an interface the +framework CONSUMES cannot invalidate an existing implementation — every resolver +already in the field still satisfies the contract. Marked `minor` on +`@objectstack/runtime` because it is a new public capability on an exported +contract, `patch` on `@objectstack/rest` because the wrapper change is a fix +with no surface of its own. + +Fail-closed is unchanged and pinned: the surviving `getOrCreate` still rejects +for a genuinely unavailable kernel, so the caller still gets the host's declared +503 — a shorter wait to the same verdict, never a response served against no +kernel. `waiterTimeoutMs` is a host setting and is untouched; the defect was +waiting twice, not waiting wrong. diff --git a/packages/rest/src/rest-api-plugin.ts b/packages/rest/src/rest-api-plugin.ts index 191bf31a56..b99b06c92b 100644 --- a/packages/rest/src/rest-api-plugin.ts +++ b/packages/rest/src/rest-api-plugin.ts @@ -192,7 +192,37 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { // resolver strategy starts keying off routePath, // add prefix-stripped assembly here. const context: { request: unknown; environmentId?: string } = { request: req }; - await kernelResolver.resolveKernel(context, hostKernelFacade); + // Ask the environment-only question when the host + // can answer it. This wrapper wants an ID and + // nothing else; `resolveKernel` is a kernel- + // ACQUISITION api, and the kernel it hands back + // here is discarded on the success path and lost + // with the rejection on the cold one. That discard + // is free on a warm environment and is a whole + // waiter window on a cold or wedged one — measured + // on a live host with `waiterTimeoutMs: 20s`, + // REST-owned routes (`/api/v1/discovery`, + // `/api/v1/data/:object`) answered 503 after 42s + // against a wedged environment because + // `resolveProtocol` then opens a SECOND window to + // acquire the kernel for real. Preferring + // `resolveEnvironment` leaves `resolveProtocol` as + // the single acquisition point, so one request + // pays one window. + // + // ⛔ No fallback to `resolveKernel` when this + // returns without setting `environmentId`: an unset + // id is the seam's FINAL answer for an unscoped / + // control-plane request (see + // `RestRequestEnvResolver`), and "retry with the + // expensive method" would re-buy exactly the window + // this prefers away, on precisely the requests that + // need no environment at all. + if (typeof kernelResolver.resolveEnvironment === 'function') { + await kernelResolver.resolveEnvironment(context, hostKernelFacade); + } else { + await kernelResolver.resolveKernel(context, hostKernelFacade); + } return context.environmentId; }, }; diff --git a/packages/rest/src/rest-resolve-environment-single-window.test.ts b/packages/rest/src/rest-resolve-environment-single-window.test.ts new file mode 100644 index 0000000000..12b689b547 --- /dev/null +++ b/packages/rest/src/rest-resolve-environment-single-window.test.ts @@ -0,0 +1,410 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0006 `KernelResolver.resolveEnvironment` — one kernel-waiter window per + * REST request. + * + * ## What is being pinned, and why the obvious assertion is worthless here + * + * `RestApiPlugin` wraps the host's `kernel-resolver` so `RestServer` can ask + * "which environment is this request in?". It asked `resolveKernel` — a kernel + * ACQUISITION api — and kept only `context.environmentId`. A real host resolver + * writes the id and THEN awaits the kernel, so the wrapper paid a full waiter + * window and discarded what it bought; `resolveProtocol` then acquired the + * kernel again. Measured on a live multi-tenant host (`waiterTimeoutMs: 20s`): + * dispatcher-owned routes answered 503 in ~21s, REST-owned ones + * (`/api/v1/discovery`, `/api/v1/data/:object`) in ~42s. + * + * "The request still answered" / "an env id came back" pass on today's code, + * on the naive fix, and on the real one — the card measured the naive fix + * (catch the resolver's throw, keep the id it already wrote) at 2.38x/2.02x + * with **two** acquisitions, because the window is spent inside the resolver + * call before any id is returned. So every assertion below counts the + * MECHANISM: `getOrCreate` calls per request. + * + * Three properties, each with its own leg: + * 1. one acquisition per REST-owned route when the host implements + * `resolveEnvironment`; + * 2. still two when the host implements only `resolveKernel` — the + * back-compat path is EXERCISED, not assumed, since it is the state every + * deployed host is in until it implements the new member; + * 3. fail-closed survives — the surviving `getOrCreate` still rejects and the + * caller still gets the host's declared 503, never a response served + * against no kernel. This is the property most at risk from collapsing the + * windows, so it is asserted rather than argued. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { createRestApiPlugin } from './rest-api-plugin'; + +// --------------------------------------------------------------------------- +// Doubles +// --------------------------------------------------------------------------- + +const ENV = 'env_probe'; +const ANON_API = { api: { requireAuth: false } }; + +/** One bounded waiter window, scaled down from the host's 20s to keep tests fast. */ +const WINDOW_MS = 20; + +function createMockServer() { + return { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +function createMockProtocol(tag: string) { + return { + __tag: tag, + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + getMetaItem: vi.fn().mockResolvedValue({}), + findData: vi.fn().mockResolvedValue([]), + getData: vi.fn().mockResolvedValue({}), + createData: vi.fn().mockResolvedValue({ id: '1' }), + updateData: vi.fn().mockResolvedValue({}), + deleteData: vi.fn().mockResolvedValue({ success: true }), + }; +} + +/** + * The error a host's kernel manager rejects a wedged build with — cloud's + * `kernel_warming`, carrying the `503` / `SERVICE_UNAVAILABLE` pair REST's + * declared-status passthrough answers with. + */ +function kernelWarmingError() { + return Object.assign(new Error('Environment kernel is still warming up.'), { + status: 503, + statusCode: 503, + code: 'SERVICE_UNAVAILABLE', + declaredCode: 'kernel_warming', + }); +} + +/** + * A kernel manager whose build stays in flight: every `getOrCreate` opens its + * OWN bounded wait and rejects when it expires. Counting its calls counts + * waiter windows, which is the whole subject of this file. + */ +function wedgedKernelManager() { + const acquisitions: string[] = []; + return { + acquisitions, + getOrCreate: (environmentId: string) => { + acquisitions.push(environmentId); + return new Promise((_resolve, reject) => { + setTimeout(() => reject(kernelWarmingError()), WINDOW_MS); + }); + }, + }; +} + +/** A kernel manager that hands back a live per-environment kernel (warm path). */ +function warmKernelManager(services: Record) { + const acquisitions: string[] = []; + const kernel = { getServiceAsync: vi.fn(async (name: string) => services[name]) }; + return { + acquisitions, + kernel, + getOrCreate: async (environmentId: string) => { + acquisitions.push(environmentId); + return kernel; + }, + }; +} + +/** + * A host `kernel-resolver` shaped like a real one: it resolves the environment + * onto the context FIRST and only then awaits that environment's kernel — the + * ordering that makes `resolveKernel` an expensive way to ask a cheap question. + * + * `environmentOnly: true` adds the ADR-0006 `resolveEnvironment` member: same + * environment answer, no acquisition. + */ +function hostKernelResolver(km: { getOrCreate: (id: string) => Promise }, opts: { environmentOnly: boolean }) { + const resolveKernel = vi.fn(async (context: any) => { + context.environmentId = ENV; // resolved BEFORE the kernel is awaited + return await km.getOrCreate(ENV); // ← the window this wrapper used to buy and discard + }); + const resolveEnvironment = vi.fn(async (context: any) => { + context.environmentId = ENV; // and nothing else + }); + const resolver: any = { resolveKernel }; + if (opts.environmentOnly) resolver.resolveEnvironment = resolveEnvironment; + return { resolver, resolveKernel, resolveEnvironment }; +} + +function createMockPluginContext(services: Record) { + return { + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (services[name]) return services[name]; + throw new Error(`Service '${name}' not found`); + }), + getServices: vi.fn(() => new Map(Object.entries(services))), + hook: vi.fn(), + trigger: vi.fn().mockResolvedValue(undefined), + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getKernel: vi.fn(), + }; +} + +type HostArgs = { + kernelResolver?: any; + kernelManager: any; + hostProtocol?: any; + /** Omit to boot a host with no legacy chain at all. */ + envRegistry?: any; +}; + +/** + * The legacy hostname chain, which a real multi-tenant host wires next to its + * `kernel-resolver`. It is load-bearing for the back-compat leg and NOT + * scenery: when the resolver's kernel await rejects, + * `resolveRequestEnvironmentId` swallows the throw and this chain supplies the + * id — which is what lets `resolveProtocol` go on to open the SECOND waiter + * window the card measured at 42s. A host without it would degrade to the + * control-plane protocol instead, a different (and much worse) shape. + */ +function legacyHostnameRegistry() { + return { + resolveByHostname: vi.fn().mockResolvedValue({ environmentId: ENV }), + resolveById: vi.fn().mockResolvedValue({}), + }; +} + +/** + * Boot the REAL plugin over these services and hand back the route handlers it + * registered. Driving the plugin (rather than `new RestServer(...)`) is the + * point: the wrapper under test is built inside `RestApiPlugin.start`, so a + * test that constructed its own `requestEnvResolver` would assert nothing about + * the code that ships. + */ +async function bootRest(args: HostArgs) { + const server = createMockServer(); + const hostProtocol = args.hostProtocol ?? createMockProtocol('host'); + const services: Record = { + 'http.server': server, + protocol: hostProtocol, + objectql: { registerObject: vi.fn(), find: vi.fn().mockResolvedValue([]) }, + 'kernel-manager': args.kernelManager, + }; + if (args.kernelResolver) services['kernel-resolver'] = args.kernelResolver; + if (args.envRegistry) services['env-registry'] = args.envRegistry; + + const ctx = createMockPluginContext(services); + const plugin = createRestApiPlugin({ api: ANON_API as any }); + await plugin.init?.(ctx as any); + await (plugin as any).start(ctx as any); + + const routeFor = (path: string) => { + const call = server.get.mock.calls.find((c: any[]) => c[0] === path); + expect(call, `GET ${path} must be registered`).toBeDefined(); + return call![1]; + }; + + /** Drive one request and report exactly what the caller received. */ + const drive = async (path: string, req: any) => { + const res = { + json: vi.fn(), + status: vi.fn().mockReturnThis(), + send: vi.fn(), + setHeader: vi.fn(), + header: vi.fn(), + headersSent: false, + }; + await routeFor(path)(req, res); + return { + status: res.status.mock.calls.at(-1)?.[0], + body: res.json.mock.calls.at(-1)?.[0], + res, + }; + }; + + return { drive, hostProtocol, services, envRegistry: args.envRegistry }; +} + +const discoveryReq = () => ({ params: {}, query: {}, headers: { host: 'tenant-a.example.com' }, url: '/api/v1/discovery' }); +const dataReq = () => ({ params: { object: 'sys_user' }, query: {}, headers: { host: 'tenant-a.example.com' }, url: '/api/v1/data/sys_user' }); + +const REST_OWNED_ROUTES: Array<[string, string, () => any]> = [ + ['/api/v1/discovery', '/api/v1/discovery', discoveryReq], + ['/api/v1/data/:object', '/api/v1/data/:object', dataReq], +]; + +// --------------------------------------------------------------------------- +// 1 + 3. One window, and it still fails closed +// --------------------------------------------------------------------------- + +describe('a host implementing resolveEnvironment pays ONE waiter window per REST request', () => { + for (const [label, path, makeReq] of REST_OWNED_ROUTES) { + it(`${label} acquires exactly one kernel`, async () => { + const km = wedgedKernelManager(); + const { resolver, resolveKernel, resolveEnvironment } = hostKernelResolver(km, { environmentOnly: true }); + const envRegistry = legacyHostnameRegistry(); + const { drive } = await bootRest({ kernelResolver: resolver, kernelManager: km, envRegistry }); + + const { status, body } = await drive(path, makeReq()); + + // THE pin: one request, one kernel acquisition. Two is today's defect, + // and the naive catch-and-keep-the-id fix measured two as well. + expect(km.acquisitions).toEqual([ENV]); + // The environment question was asked of the environment-only member, and + // the acquisition api was never touched by the wrapper. + expect(resolveEnvironment).toHaveBeenCalledTimes(1); + expect(resolveKernel).not.toHaveBeenCalled(); + // The resolver answered normally, so the legacy chain never ran — the id + // came from the seam, not from a degrade. + expect(envRegistry.resolveByHostname).not.toHaveBeenCalled(); + // Fail-closed: the surviving getOrCreate still rejects, so the caller + // still gets the host's declared 503. Shorter wait, same verdict. + expect(status).toBe(503); + expect(body.code).toBe('SERVICE_UNAVAILABLE'); + }); + } + + it('never serves a REST-owned route against a kernel it could not acquire', async () => { + const km = wedgedKernelManager(); + const { resolver } = hostKernelResolver(km, { environmentOnly: true }); + const hostProtocol = createMockProtocol('host'); + const { drive } = await bootRest({ + kernelResolver: resolver, + kernelManager: km, + hostProtocol, + envRegistry: legacyHostnameRegistry(), + }); + + const discovery = await drive('/api/v1/discovery', discoveryReq()); + const data = await drive('/api/v1/data/:object', dataReq()); + + // Collapsing the windows must not degrade "waited, then 503" into "served + // from the CONTROL-PLANE protocol" — the failure mode a fallback-on-throw + // would have introduced. The host protocol answers neither request. + expect(discovery.status).toBe(503); + expect(data.status).toBe(503); + expect(hostProtocol.getDiscovery).not.toHaveBeenCalled(); + expect(hostProtocol.findData).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// 2. The back-compat path, exercised +// --------------------------------------------------------------------------- + +describe('a host implementing only resolveKernel keeps working — and keeps paying twice', () => { + for (const [label, path, makeReq] of REST_OWNED_ROUTES) { + it(`${label} still acquires two kernels`, async () => { + const km = wedgedKernelManager(); + const { resolver, resolveKernel } = hostKernelResolver(km, { environmentOnly: false }); + const envRegistry = legacyHostnameRegistry(); + const { drive } = await bootRest({ kernelResolver: resolver, kernelManager: km, envRegistry }); + + const { status, body } = await drive(path, makeReq()); + + // `resolveEnvironment` is `?.`-optional precisely so this host needs no + // change to keep serving. It pays the documented double window; that is + // the deal, and it is pinned so "it still works" is a measurement. + // + // The exact production shape: window #1 is the resolver's own kernel + // await, whose rejection is swallowed by `resolveRequestEnvironmentId`; + // the legacy hostname chain then supplies the same id, and + // `resolveProtocol` opens window #2 to acquire the kernel for real. + expect(km.acquisitions).toEqual([ENV, ENV]); + expect(resolveKernel).toHaveBeenCalledTimes(1); + expect(envRegistry.resolveByHostname).toHaveBeenCalled(); + expect(status).toBe(503); + expect(body.code).toBe('SERVICE_UNAVAILABLE'); + }); + } +}); + +// --------------------------------------------------------------------------- +// The environment answer itself is unchanged +// --------------------------------------------------------------------------- + +describe('collapsing the window does not change WHICH environment serves the request', () => { + it('a warm request is served from the resolved environment kernel, acquired once', async () => { + const envProtocol = createMockProtocol('env'); + const km = warmKernelManager({ protocol: envProtocol, mcp: undefined }); + const { resolver } = hostKernelResolver(km, { environmentOnly: true }); + const hostProtocol = createMockProtocol('host'); + const { drive } = await bootRest({ kernelResolver: resolver, kernelManager: km, hostProtocol }); + + const { body } = await drive('/api/v1/discovery', discoveryReq()); + + // Every acquisition is for the environment the resolver named, and the + // document is the ENVIRONMENT kernel's, not the control plane's. + expect(new Set(km.acquisitions)).toEqual(new Set([ENV])); + expect(envProtocol.getDiscovery).toHaveBeenCalled(); + expect(hostProtocol.getDiscovery).not.toHaveBeenCalled(); + expect(body.version).toBeDefined(); + }); + + it('drops the discarded acquisition from the warm path too — one per environment resolution', async () => { + const withNew = warmKernelManager({ protocol: createMockProtocol('env') }); + const withoutNew = warmKernelManager({ protocol: createMockProtocol('env') }); + + const a = await bootRest({ + kernelResolver: hostKernelResolver(withNew, { environmentOnly: true }).resolver, + kernelManager: withNew, + }); + await a.drive('/api/v1/discovery', discoveryReq()); + + const b = await bootRest({ + kernelResolver: hostKernelResolver(withoutNew, { environmentOnly: false }).resolver, + kernelManager: withoutNew, + }); + await b.drive('/api/v1/discovery', discoveryReq()); + + // The waste is on the success path too — it is simply free there, because + // a warm `getOrCreate` is a cache hit, which is why this went unnoticed for + // so long. `/discovery` resolves the environment TWICE (once for the + // protocol, once for the mcp-serveability probe), so the old wrapper bought + // and discarded a kernel twice: four acquisitions where two are genuine. + expect(withNew.acquisitions).toEqual([ENV, ENV]); + expect(withoutNew.acquisitions).toEqual([ENV, ENV, ENV, ENV]); + }); +}); + +// --------------------------------------------------------------------------- +// "No environment" stays a final answer +// --------------------------------------------------------------------------- + +describe('resolveEnvironment leaving the id unset is FINAL', () => { + it('does not retry through resolveKernel, and acquires nothing', async () => { + const km = wedgedKernelManager(); + const resolveKernel = vi.fn(async (context: any) => { + context.environmentId = ENV; + return await km.getOrCreate(ENV); + }); + const hostProtocol = createMockProtocol('host'); + const { drive } = await bootRest({ + kernelResolver: { + resolveKernel, + // A control-plane / unscoped request: the resolver deliberately names + // no environment. + resolveEnvironment: vi.fn(async () => { /* no id */ }), + }, + kernelManager: km, + hostProtocol, + }); + + const { body } = await drive('/api/v1/discovery', discoveryReq()); + + // "Unscoped" is the seam's contract answer, not a decline. Retrying through + // the acquisition api would re-buy the very window this prefers away, on + // requests that need no environment at all. + expect(resolveKernel).not.toHaveBeenCalled(); + expect(km.acquisitions).toEqual([]); + expect(hostProtocol.getDiscovery).toHaveBeenCalled(); + expect(body.version).toBeDefined(); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 6383c77eaa..d95ecec386 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -744,6 +744,17 @@ export interface RestEnvRegistry { * built-in chain must NOT second-guess it. Only a thrown error falls back to * the legacy chain, so a misbehaving resolver degrades to pre-seam behavior * instead of taking down REST routing. + * + * **Cost.** Answering this question must not acquire a kernel. `RestApiPlugin` + * builds the adapter over the host's `kernel-resolver` and prefers its + * environment-only member (`KernelResolver.resolveEnvironment`, ADR-0006) for + * exactly that reason: asking the kernel-ACQUISITION method for an id bought a + * kernel and discarded it, and on a cold or wedged environment that discard is + * a full waiter window — after which {@link RestServer.resolveProtocol} opens + * a second one to acquire the kernel for real. Measured on a live host with a + * 20s `waiterTimeoutMs`: 42s to a 503 on REST-owned routes against 21s on + * dispatcher-owned ones. A host that implements only `resolveKernel` still + * works, and still pays twice. */ export interface RestRequestEnvResolver { resolveRequestEnvironmentId(req: unknown): Promise; @@ -1109,6 +1120,25 @@ export class RestServer { return undefined; } + /** + * Resolve the protocol that serves this request — and THE single + * kernel-acquisition point on the path a REST-owned route takes to answer. + * + * Two steps, deliberately of different kinds: {@link + * resolveRequestEnvironmentId} answers *which environment* (cheap, and + * kernel-free wherever the host implements `resolveEnvironment` — see + * {@link RestRequestEnvResolver}), then `getOrCreate` acquires that + * environment's kernel. Anything that collapses them back together + * re-creates the double waiter window: the env question routed through a + * kernel-acquisition API, paid for, discarded, then paid again here. + * + * **This `getOrCreate` fails closed and must keep doing so.** A genuinely + * unavailable kernel rejects here, the rejection propagates to the route's + * `handleRouteError`, and the caller gets the host's declared 503 — never a + * response served against no kernel. Removing the first (wasted) window + * shortened the wait to that 503; it did not, and must not, turn it into a + * success. + */ private async resolveProtocol(environmentId?: string, req?: any): Promise { if (environmentId === 'platform') return this.protocol; const envId = await this.resolveRequestEnvironmentId(environmentId, req); diff --git a/packages/runtime/src/http-dispatcher.kernel-resolver.test.ts b/packages/runtime/src/http-dispatcher.kernel-resolver.test.ts index 251db3e5fc..c037e0525f 100644 --- a/packages/runtime/src/http-dispatcher.kernel-resolver.test.ts +++ b/packages/runtime/src/http-dispatcher.kernel-resolver.test.ts @@ -102,3 +102,63 @@ describe('HttpDispatcher — ADR-0006 kernelResolver seam', () => { expect(context.environmentId).toBeUndefined(); }); }); + +/** + * The optional environment-only member (`resolveEnvironment`) exists for + * consumers that want the ID and nothing else — `@objectstack/rest`'s + * `resolveRequestEnvironmentId` wrapper, which used to buy a kernel through + * `resolveKernel` and discard it, paying a whole waiter window for an id. + * + * The dispatcher is NOT such a consumer: `resolveRequestScope` serves the + * request FROM the resolved kernel, so it must keep asking the acquisition + * method. Pinned here because "prefer the cheaper method everywhere" is the + * obvious-looking follow-up edit, and here it would leave `context.kernel` on + * `defaultKernel` — every multi-tenant request silently served from the host + * kernel, which is the class of defect ADR-0006 Phase 5 exists to prevent. + */ +describe('HttpDispatcher — resolveEnvironment is not the dispatcher\'s question', () => { + it('still acquires through resolveKernel when the host implements both', async () => { + const defaultKernel = makeKernel('default'); + const envKernel = makeKernel('env'); + + const resolveEnvironment = vi.fn((ctx: HttpProtocolContext) => { + ctx.environmentId = 'env-from-resolver'; + }); + const resolveKernel = vi.fn(async (ctx: HttpProtocolContext) => { + ctx.environmentId = 'env-from-resolver'; + return envKernel; + }); + // Typed as the contract, so this also pins that a resolver carrying the + // new member satisfies `KernelResolver`. + const resolver: KernelResolver = { resolveKernel, resolveEnvironment }; + const dispatcher = new HttpDispatcher(defaultKernel, undefined, { + kernelResolver: resolver, + enforceProjectMembership: false, + }); + + const context: any = { request: { headers: { host: 'tenant.example.com' } } }; + await dispatcher.dispatch('GET', '/data/widget', undefined, {}, context); + + expect(resolveKernel).toHaveBeenCalledTimes(1); + expect(resolveEnvironment).not.toHaveBeenCalled(); + // The request is served from the kernel the resolver handed back — the + // whole reason the dispatcher may not take the cheap door. + expect(context.kernel).toBe(envKernel); + expect(context.environmentId).toBe('env-from-resolver'); + }); + + it('is unaffected by a resolver that omits the optional member', async () => { + const defaultKernel = makeKernel('default'); + const envKernel = makeKernel('env'); + const resolver: KernelResolver = { resolveKernel: vi.fn(async () => envKernel) }; + const dispatcher = new HttpDispatcher(defaultKernel, undefined, { + kernelResolver: resolver, + enforceProjectMembership: false, + }); + + const context: any = { request: { headers: {} } }; + await dispatcher.dispatch('GET', '/data/widget', undefined, {}, context); + + expect(context.kernel).toBe(envKernel); + }); +}); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index f228316a25..2026ee9c68 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -147,6 +147,49 @@ export interface KernelResolver { context: HttpProtocolContext, defaultKernel: ObjectKernel, ): Promise | ObjectKernel | undefined; + + /** + * Resolve ONLY the request's environment onto `context`. **No kernel + * acquisition.** + * + * `resolveKernel` answers two questions at once — *which environment?* and + * *give me its kernel* — because every dispatcher consumer needed both. + * A consumer that needs only the first (`@objectstack/rest`'s + * `resolveRequestEnvironmentId` seam) had no way to ask for it, so it asked + * the kernel-acquisition API and threw the kernel away. On a WARM + * environment the discarded acquisition is a cache hit and costs nothing, + * which is why it stayed invisible; on a cold or wedged one it opens the + * host's whole bounded waiter window, and the consumer then acquires the + * kernel again for real. Measured on a live multi-tenant host with a 20s + * `waiterTimeoutMs`: REST-owned routes answered 503 after **42s** — two + * serial windows — where dispatcher-owned routes answered after 21s. + * Catching the resolver's rejection and keeping the id it already wrote + * does NOT recover it (measured 2.38x/2.02x, still two acquisitions): the + * window is spent inside the resolver call, before any id is returned. + * + * Contract for an implementor: + * - Write `context.environmentId` (and, where it is free, `dataDriver`) + * exactly as `resolveKernel` would for the same request — this is + * `resolveKernel`'s environment-resolution half, split out, not a second + * strategy. Two answers for one request is the failure this seam exists + * to prevent. + * - Acquire NO kernel. An implementation that awaits one has simply + * reproduced `resolveKernel` under a new name. + * - Leaving `context.environmentId` unset is a real answer — "unscoped / + * control-plane / single-environment" — and consumers treat it as final. + * Do not signal "ask `resolveKernel` instead" by declining. + * + * Optional on purpose: a host that implements only `resolveKernel` keeps + * working unchanged (it keeps paying for the discarded acquisition on cold + * builds), so this landed with no flag day. Consumers that need the kernel + * itself — the dispatcher's own `resolveRequestScope`, its + * `resolveProjectKernelObjectQL` seam — keep calling `resolveKernel`; this + * member never replaces it. + */ + resolveEnvironment?( + context: HttpProtocolContext, + defaultKernel: ObjectKernel, + ): Promise | void; } /** From c567b8bd9d11b558f2e2ef593954c4a4528dc5eb Mon Sep 17 00:00:00 2001 From: os-elon Date: Sat, 22 Aug 2026 16:04:15 +0000 Subject: [PATCH 2/2] test(rest): keep the new pins out of packages/rest's frozen tsc debt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:type-check-debt --re-measure` caught the new test file adding 3 raw errors to the `@objectstack/rest` TEST_DEBT entry (155 -> 158): one TS2835 (extensionless relative import under nodenext) and two TS2550 (`Array.at` is not in this program's lib target). Fixed at the source — the ledger is a shrink-only ratchet and is untouched. Re-measured: 155, zero from this file. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r --- .../src/rest-resolve-environment-single-window.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/rest/src/rest-resolve-environment-single-window.test.ts b/packages/rest/src/rest-resolve-environment-single-window.test.ts index 12b689b547..8296481af8 100644 --- a/packages/rest/src/rest-resolve-environment-single-window.test.ts +++ b/packages/rest/src/rest-resolve-environment-single-window.test.ts @@ -35,7 +35,7 @@ */ import { describe, it, expect, vi } from 'vitest'; -import { createRestApiPlugin } from './rest-api-plugin'; +import { createRestApiPlugin } from './rest-api-plugin.js'; // --------------------------------------------------------------------------- // Doubles @@ -222,9 +222,12 @@ async function bootRest(args: HostArgs) { headersSent: false, }; await routeFor(path)(req, res); + // `.at(-1)` is not in this program's lib target — read the last call the + // long way rather than widening the package's frozen tsc debt. + const lastArg = (calls: any[][]): any => (calls.length ? calls[calls.length - 1][0] : undefined); return { - status: res.status.mock.calls.at(-1)?.[0], - body: res.json.mock.calls.at(-1)?.[0], + status: lastArg(res.status.mock.calls), + body: lastArg(res.json.mock.calls), res, }; };