From e37fe3f0a862df6faacb9a1d4b5745c9a08c2525 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Wed, 10 Jun 2026 19:47:11 -0400 Subject: [PATCH 1/2] feat(compute): add runtime sleep guard --- docs/architecture/package-structure.md | 3 +- package.json | 1 + packages/compute/AGENTS.md | 12 ++ packages/compute/README.md | 50 ++++++ packages/compute/package.json | 48 ++++++ packages/compute/src/index.ts | 5 + packages/compute/src/scale-to-zero-control.ts | 59 +++++++ packages/compute/src/scale-to-zero.ts | 145 ++++++++++++++++++ packages/compute/tests/scale-to-zero.test.ts | 124 +++++++++++++++ packages/compute/tsconfig.json | 7 + packages/compute/tsdown.config.ts | 13 ++ pnpm-lock.yaml | 15 ++ 12 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 packages/compute/AGENTS.md create mode 100644 packages/compute/README.md create mode 100644 packages/compute/package.json create mode 100644 packages/compute/src/index.ts create mode 100644 packages/compute/src/scale-to-zero-control.ts create mode 100644 packages/compute/src/scale-to-zero.ts create mode 100644 packages/compute/tests/scale-to-zero.test.ts create mode 100644 packages/compute/tsconfig.json create mode 100644 packages/compute/tsdown.config.ts diff --git a/docs/architecture/package-structure.md b/docs/architecture/package-structure.md index 564f137..b209c00 100644 --- a/docs/architecture/package-structure.md +++ b/docs/architecture/package-structure.md @@ -1,8 +1,9 @@ # Package Structure -The repository currently contains one publishable package: +The repository currently contains two publishable packages: - `packages/cli`: the public Prisma CLI beta package +- `packages/compute`: runtime utilities for deployed Prisma compute applications The root workspace owns shared scripts, docs, release preparation, and examples. diff --git a/package.json b/package.json index 76fec88..bdc72d6 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "packageManager": "pnpm@10.30.0", "scripts": { "build:cli": "pnpm --filter @prisma/cli build", + "build:compute": "pnpm --filter @prisma/compute build", "lint:skills": "node scripts/validate-skills.mjs", "prepare": "skills add ./skills --skill '*' --agent universal claude-code -y", "prepare:cli-publish": "node scripts/prepare-cli-publish.mjs", diff --git a/packages/compute/AGENTS.md b/packages/compute/AGENTS.md new file mode 100644 index 0000000..8265ca1 --- /dev/null +++ b/packages/compute/AGENTS.md @@ -0,0 +1,12 @@ +# AGENTS.md + +## Verification + +When changing `@prisma/compute`, run the package-local checks: + +```bash +pnpm --filter @prisma/compute test +pnpm --filter @prisma/compute build +``` + +Keep `README.md` consumer-focused because it is included in the published npm package. diff --git a/packages/compute/README.md b/packages/compute/README.md new file mode 100644 index 0000000..6e45238 --- /dev/null +++ b/packages/compute/README.md @@ -0,0 +1,50 @@ +# Prisma Compute + +`@prisma/compute` provides runtime utilities for applications deployed to Prisma +Compute. + +## Preventing Application Sleep + +Applications deployed to Prisma Compute can sleep after short periods of inactivity. When an application sleeps, Prisma Compute snapshots its memory and resumes from that snapshot when the next request arrives. + +This works well for request-driven code, but background work outside the request lifecycle can be interrupted when the application sleeps. Examples include `setTimeout`, `setInterval`, and background `Promise` work. + +`@prisma/compute` provides two utilities that signal to Prisma Compute that work is still active and the application should stay awake. + +`waitUntil` keeps the application awake until a `Promise` settles. It returns `void`, so callers should keep using the original promise for result and error handling. Pass an `AbortSignal`, usually from `AbortSignal.timeout(ms)`, as a safety bound if the promise does not settle. `waitUntil` can be called multiple times during a single request. + +```ts +import { waitUntil } from "@prisma/compute"; + +waitUntil(doCriticalWork(), { signal: AbortSignal.timeout(30_000) }); +``` + +`ScaleToZeroGuard` is a disposable object that keeps the application awake until the guard is released. Use it for a scoped function or block of background work. `ScaleToZeroGuard` can be created multiple times during a single request and is safe to nest. Pass an `AbortSignal`, usually from `AbortSignal.timeout(ms)`, as a safety bound if release is not reached. + +Read more about disposables and the `using` keyword in the [MDN resource management guide](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Resource_management). + +```ts +import { ScaleToZeroGuard } from "@prisma/compute"; + +async function runsInBackground() { + // guard is acquired here + using guard = new ScaleToZeroGuard({ signal: AbortSignal.timeout(30_000) }); + await doCriticalWork(); +} // guard is released here +``` + +If `using` is not available, call `.release()` manually. Always release the guard in a `finally` block so it is released even if the guarded code throws. + +```ts +import { ScaleToZeroGuard } from "@prisma/compute"; + +async function runsInBackground() { + const guard = new ScaleToZeroGuard({ signal: AbortSignal.timeout(30_000) }); + + try { + await doCriticalWork(); + } finally { + guard.release(); + } +} +``` diff --git a/packages/compute/package.json b/packages/compute/package.json new file mode 100644 index 0000000..92e01d4 --- /dev/null +++ b/packages/compute/package.json @@ -0,0 +1,48 @@ +{ + "name": "@prisma/compute", + "version": "0.1.0-development", + "description": "Utilities for applications running on the Prisma compute runtime.", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=22.12.0" + }, + "keywords": [ + "prisma", + "compute", + "runtime" + ], + "repository": { + "type": "git", + "url": "https://github.com/prisma/prisma-cli.git", + "directory": "packages/compute" + }, + "homepage": "https://github.com/prisma/prisma-cli#readme", + "bugs": { + "url": "https://github.com/prisma/prisma-cli/issues" + }, + "license": "Apache-2.0", + "scripts": { + "build": "tsdown", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^22.19.19", + "tsdown": "^0.21.10", + "typescript": "^6.0.3", + "vitest": "^4.1.8" + } +} diff --git a/packages/compute/src/index.ts b/packages/compute/src/index.ts new file mode 100644 index 0000000..3a3dd4c --- /dev/null +++ b/packages/compute/src/index.ts @@ -0,0 +1,5 @@ +export { + ScaleToZeroGuard, + waitUntil, + type ScaleToZeroGuardOptions, +} from "./scale-to-zero"; diff --git a/packages/compute/src/scale-to-zero-control.ts b/packages/compute/src/scale-to-zero-control.ts new file mode 100644 index 0000000..e74131f --- /dev/null +++ b/packages/compute/src/scale-to-zero-control.ts @@ -0,0 +1,59 @@ +import fs from "node:fs"; + +const DEFAULT_CONTROL_FILE_PATH = "/uk/libukp/scale_to_zero_disable"; + +type ControlFileState = + | { kind: "uninitialized"; path: string } + | { kind: "unavailable"; path: string } + | { kind: "open"; fd: number; path: string }; + +let controlFileState: ControlFileState = { + kind: "uninitialized", + path: DEFAULT_CONTROL_FILE_PATH, +}; + +export type ScaleToZeroSignal = "acquire" | "release"; + +export function writeScaleToZeroSignal(signal: ScaleToZeroSignal): boolean { + const state = getControlFileState(); + + if (state.kind !== "open") { + return false; + } + + try { + fs.writeSync(state.fd, signal === "acquire" ? "+" : "-"); + return true; + } catch { + return false; + } +} + +function getControlFileState(): ControlFileState { + if (controlFileState.kind !== "uninitialized") { + return controlFileState; + } + + try { + controlFileState = { + kind: "open", + fd: fs.openSync(controlFileState.path, fs.constants.O_WRONLY), + path: controlFileState.path, + }; + } catch { + controlFileState = { kind: "unavailable", path: controlFileState.path }; + } + + return controlFileState; +} + +export function configureScaleToZeroControlFileForTests(path: string | undefined): void { + if (controlFileState.kind === "open") { + fs.closeSync(controlFileState.fd); + } + + controlFileState = { + kind: "uninitialized", + path: path ?? DEFAULT_CONTROL_FILE_PATH, + }; +} diff --git a/packages/compute/src/scale-to-zero.ts b/packages/compute/src/scale-to-zero.ts new file mode 100644 index 0000000..ec5302f --- /dev/null +++ b/packages/compute/src/scale-to-zero.ts @@ -0,0 +1,145 @@ +import { writeScaleToZeroSignal } from "./scale-to-zero-control"; + +/** + * Options for holding a Prisma Compute sleep guard. + */ +export interface ScaleToZeroGuardOptions { + /** + * Signal that releases the guard when aborted. + * + * Use `AbortSignal.timeout(ms)` for a time bound, or pass a request or + * operation signal to tie the guard to caller-owned cancellation. Passing a + * signal is strongly recommended as a safety bound for dangling guards. + */ + signal?: AbortSignal; +} + +/** + * Keeps a Prisma Compute application awake for scoped async work. + * + * Creating a guard signals the compute runtime to stay awake. Calling + * {@link ScaleToZeroGuard.release}, leaving a `using` scope, or reaching + * the configured abort signal releases that signal. Release is idempotent, so + * manual release and disposal can be combined safely. + * + * Pass `signal` whenever possible, usually from `AbortSignal.timeout(ms)`, to + * bound how long the guard can keep the instance awake if release is not reached. + * + * Outside the Prisma Compute runtime, where the sleep control endpoint is + * unavailable, the guard is a no-op. + * + * @example + * ```ts + * import { ScaleToZeroGuard } from "@prisma/compute"; + * + * using guard = new ScaleToZeroGuard({ signal: AbortSignal.timeout(30_000) }); + * await doCriticalWork(); + * ``` + * + * @example + * ```ts + * const guard = new ScaleToZeroGuard(); + * try { + * await doCriticalWork(); + * } finally { + * guard.release(); + * } + * ``` + */ +export class ScaleToZeroGuard implements Disposable { + #active: boolean; + #abortSignal: AbortSignal | undefined; + #abortListener: (() => void) | undefined; + + /** + * Creates a guard and immediately signals the compute runtime to stay awake. + * + * If `signal` is already aborted, no signal is written. If `signal` aborts + * while the guard is active, the guard releases itself. Passing a signal is + * recommended as a safety bound if release is not reached. + */ + constructor(options: ScaleToZeroGuardOptions = {}) { + if (options.signal?.aborted) { + this.#active = false; + return; + } + + this.#active = writeScaleToZeroSignal("acquire"); + + if (this.#active && options.signal !== undefined) { + this.#abortSignal = options.signal; + this.#abortListener = () => { + this.release(); + }; + options.signal.addEventListener("abort", this.#abortListener, { + once: true, + }); + } + } + + /** + * Releases the guard's keep-awake signal. + * + * This method is idempotent. Calling it multiple times, or calling it before + * a `using` scope exits, writes at most one release signal. + */ + release(): void { + if (!this.#active) { + return; + } + + this.#active = false; + this.#removeAbortListener(); + writeScaleToZeroSignal("release"); + } + + /** + * Releases the guard when used with TypeScript's `using` syntax. + * + * Most callers should prefer `using` for scoped work and call + * {@link ScaleToZeroGuard.release} only when release needs to happen before + * the scope exits. + */ + [Symbol.dispose](): void { + this.release(); + } + + #removeAbortListener(): void { + if (this.#abortSignal === undefined || this.#abortListener === undefined) { + return; + } + + this.#abortSignal.removeEventListener("abort", this.#abortListener); + this.#abortSignal = undefined; + this.#abortListener = undefined; + } +} + +/** + * Keeps a Prisma Compute application awake until a promise settles. + * + * The guard is acquired immediately, then released from a `finally` handler on + * the passed promise. This function returns `void`; callers should keep using + * the original promise for result and error handling. If `signal` aborts first, + * only the guard is released; the passed promise continues independently. + * + * Pass `signal`, usually from `AbortSignal.timeout(ms)`, to bound guard lifetime + * even when the promise does not settle. + * + * @example + * ```ts + * import { waitUntil } from "@prisma/compute"; + * + * waitUntil(sendWebhook(), { signal: AbortSignal.timeout(10_000) }); + * ``` + */ +export function waitUntil( + promise: PromiseLike, + options?: ScaleToZeroGuardOptions, +): void { + const guard = new ScaleToZeroGuard(options); + + void Promise.resolve(promise).finally(() => { + guard.release(); + }); +} diff --git a/packages/compute/tests/scale-to-zero.test.ts b/packages/compute/tests/scale-to-zero.test.ts new file mode 100644 index 0000000..591dc77 --- /dev/null +++ b/packages/compute/tests/scale-to-zero.test.ts @@ -0,0 +1,124 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ScaleToZeroGuard, waitUntil } from "../src/index"; +import { configureScaleToZeroControlFileForTests } from "../src/scale-to-zero-control"; + +async function createControlFile(): Promise<{ dir: string; file: string }> { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "prisma-compute-")); + const file = path.join(dir, "scale_to_zero_disable"); + await fs.writeFile(file, ""); + configureScaleToZeroControlFileForTests(file); + + return { dir, file }; +} + +async function readSignals(file: string): Promise { + return await fs.readFile(file, "utf8"); +} + +describe("scale-to-zero guard", () => { + afterEach(async () => { + vi.useRealTimers(); + configureScaleToZeroControlFileForTests(undefined); + }); + + it("writes acquire and release signals for a disposable guard", async () => { + const { file } = await createControlFile(); + + using guard = new ScaleToZeroGuard(); + + expect(await readSignals(file)).toBe("+"); + guard.release(); + expect(await readSignals(file)).toBe("+-"); + }); + + it("releases only once when release is called multiple times", async () => { + const { file } = await createControlFile(); + const guard = new ScaleToZeroGuard(); + + guard.release(); + guard.release(); + guard[Symbol.dispose](); + + expect(await readSignals(file)).toBe("+-"); + }); + + it("releases automatically when the signal aborts", async () => { + const { file } = await createControlFile(); + const controller = new AbortController(); + + const guard = new ScaleToZeroGuard({ signal: controller.signal }); + expect(await readSignals(file)).toBe("+"); + + controller.abort(); + expect(await readSignals(file)).toBe("+-"); + + guard.release(); + expect(await readSignals(file)).toBe("+-"); + }); + + it("does not acquire when the signal is already aborted", async () => { + const { file } = await createControlFile(); + const controller = new AbortController(); + controller.abort(); + + const guard = new ScaleToZeroGuard({ signal: controller.signal }); + guard.release(); + + expect(await readSignals(file)).toBe(""); + }); + + it("waitUntil releases after the promise resolves", async () => { + const { file } = await createControlFile(); + const promise = Promise.resolve("done"); + + expect(waitUntil(promise)).toBeUndefined(); + await expect(promise).resolves.toBe("done"); + await Promise.resolve(); + + expect(await readSignals(file)).toBe("+-"); + }); + + it("waitUntil signal abort releases before a still-pending promise settles", async () => { + const { file } = await createControlFile(); + const controller = new AbortController(); + let resolvePromise: (value: string) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + expect(waitUntil(promise, { signal: controller.signal })).toBeUndefined(); + expect(await readSignals(file)).toBe("+"); + + controller.abort(); + expect(await readSignals(file)).toBe("+-"); + + resolvePromise!("done"); + await expect(promise).resolves.toBe("done"); + await Promise.resolve(); + expect(await readSignals(file)).toBe("+-"); + }); + + it("is a no-op when the control file is unavailable", async () => { + configureScaleToZeroControlFileForTests(path.join(os.tmpdir(), "missing-scale-to-zero-file")); + const promise = Promise.resolve("done"); + + expect(waitUntil(promise)).toBeUndefined(); + await expect(promise).resolves.toBe("done"); + }); + + it("removes the abort listener after manual release", async () => { + const { file } = await createControlFile(); + const controller = new AbortController(); + const guard = new ScaleToZeroGuard({ signal: controller.signal }); + + guard.release(); + controller.abort(); + + expect(await readSignals(file)).toBe("+-"); + }); +}); diff --git a/packages/compute/tsconfig.json b/packages/compute/tsconfig.json new file mode 100644 index 0000000..5741a26 --- /dev/null +++ b/packages/compute/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "ESNext.Disposable"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/packages/compute/tsdown.config.ts b/packages/compute/tsdown.config.ts new file mode 100644 index 0000000..6a3a131 --- /dev/null +++ b/packages/compute/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + }, + format: ["esm"], + clean: true, + unbundle: true, + fixedExtension: false, + outDir: "dist", + dts: true, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdf208c..ec290da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,6 +82,21 @@ importers: specifier: ^4.1.8 version: 4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/compute: + devDependencies: + '@types/node': + specifier: ^22.19.19 + version: 22.19.19 + tsdown: + specifier: ^0.21.10 + version: 0.21.10(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.8 + version: 4.1.8(@types/node@22.19.19)(vite@7.3.5(@types/node@22.19.19)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + packages: '@babel/generator@8.0.0-rc.3': From 6b02c699eb7277ac81f88cf79f1c99c2f7f4d6bb Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Thu, 11 Jun 2026 11:27:40 +0200 Subject: [PATCH 2/2] fix(compute): handle waitUntil rejections --- .github/workflows/preview-cli-package.yml | 10 ++++++++++ packages/compute/src/scale-to-zero.ts | 8 +++++--- packages/compute/tests/scale-to-zero.test.ts | 11 +++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/preview-cli-package.yml b/.github/workflows/preview-cli-package.yml index 6306986..343834f 100644 --- a/.github/workflows/preview-cli-package.yml +++ b/.github/workflows/preview-cli-package.yml @@ -58,10 +58,18 @@ jobs: id: cli_tests run: pnpm --filter @prisma/cli test + - name: Run compute tests + id: compute_tests + run: pnpm --filter @prisma/compute test + - name: Build CLI package id: cli_build run: pnpm --filter @prisma/cli build + - name: Build compute package + id: compute_build + run: pnpm --filter @prisma/compute build + - name: Prepare staged preview package id: prepare_preview run: node scripts/prepare-cli-publish.mjs .publish/cli --version '${{ steps.cli_version.outputs.version }}' @@ -79,7 +87,9 @@ jobs: echo echo "- Version: \`${{ steps.cli_version.outputs.version }}\`" echo "- CLI tests: \`${{ steps.cli_tests.outcome || 'skipped' }}\`" + echo "- Compute tests: \`${{ steps.compute_tests.outcome || 'skipped' }}\`" echo "- Build: \`${{ steps.cli_build.outcome || 'skipped' }}\`" + echo "- Compute build: \`${{ steps.compute_build.outcome || 'skipped' }}\`" echo "- Package staging: \`${{ steps.prepare_preview.outcome || 'skipped' }}\`" echo "- pkg.pr.new publish: \`${{ steps.publish_preview.outcome || 'skipped' }}\`" PUBLISH_OUTCOME="${{ steps.publish_preview.outcome || 'skipped' }}" diff --git a/packages/compute/src/scale-to-zero.ts b/packages/compute/src/scale-to-zero.ts index ec5302f..21398e2 100644 --- a/packages/compute/src/scale-to-zero.ts +++ b/packages/compute/src/scale-to-zero.ts @@ -139,7 +139,9 @@ export function waitUntil( ): void { const guard = new ScaleToZeroGuard(options); - void Promise.resolve(promise).finally(() => { - guard.release(); - }); + void Promise.resolve(promise) + .finally(() => { + guard.release(); + }) + .catch(() => {}); } diff --git a/packages/compute/tests/scale-to-zero.test.ts b/packages/compute/tests/scale-to-zero.test.ts index 591dc77..48106f7 100644 --- a/packages/compute/tests/scale-to-zero.test.ts +++ b/packages/compute/tests/scale-to-zero.test.ts @@ -83,6 +83,17 @@ describe("scale-to-zero guard", () => { expect(await readSignals(file)).toBe("+-"); }); + it("waitUntil releases after the promise rejects", async () => { + const { file } = await createControlFile(); + const promise = Promise.reject(new Error("background failed")); + + expect(waitUntil(promise)).toBeUndefined(); + await expect(promise).rejects.toThrow("background failed"); + await Promise.resolve(); + + expect(await readSignals(file)).toBe("+-"); + }); + it("waitUntil signal abort releases before a still-pending promise settles", async () => { const { file } = await createControlFile(); const controller = new AbortController();