Public: anyone can read this; the crew can change it.
The apps runtime stops writing to its meter before the database closes #3511
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3496-the-apps-runtime-stops-writing-to-its-me → main 1 commit fork 2 threads · 2 unresolved
2 files
changed +54
−1
src/apps/runtime.ts +17
| @@ -407,6 +407,11 @@ export class AppsRuntime { | ||
| 407 | 407 | ) { |
| 408 | 408 | this.gate = options.gate ?? new Gate(options.concurrency ?? 2); |
| 409 | 409 | runtimes.set(app, this); |
| 410 | // Closing the forge closes the database next: end every open awake | |
| 411 | // interval now, while it can be written, and write nothing after. | |
| 412 | app.closing.signal.addEventListener("abort", () => this.closeMeter(), { | |
| 413 | once: true, | |
| 414 | }); | |
| 410 | 415 | } |
| 411 | 416 | |
| 412 | 417 | /** The gate this runtime's deploys wait at; a hosted child shares it. */ |
| @@ -1900,6 +1905,7 @@ export class AppsRuntime { | ||
| 1900 | 1905 | * worth taking a deploy, a sleep or a shutdown down with it. |
| 1901 | 1906 | */ |
| 1902 | 1907 | private metered<T>(write: () => T): T | undefined { |
| 1908 | if (this.app.closing.signal.aborted) return undefined; | |
| 1903 | 1909 | try { |
| 1904 | 1910 | return write(); |
| 1905 | 1911 | } catch (error) { |
| @@ -1908,6 +1914,17 @@ export class AppsRuntime { | ||
| 1908 | 1914 | } |
| 1909 | 1915 | } |
| 1910 | 1916 | |
| 1917 | /** The forge is closing: stop the sweeper, end open intervals, then the meter goes quiet. */ | |
| 1918 | private closeMeter(): void { | |
| 1919 | this.stopped = true; | |
| 1920 | if (this.sweeper !== undefined) clearInterval(this.sweeper); | |
closeMeter repeats the closeAllAwake-and-log shape that metered() already has. A small private helper that both stop() and closeMeter call would keep one copy. |
||
| 1921 | try { | |
| 1922 | closeAllAwake(this.app.db, new Date().toISOString()); | |
| 1923 | } catch (error) { | |
| 1924 | log.error("meter write failed", { error: String(error) }); | |
| 1925 | } | |
| 1926 | } | |
| 1927 | ||
| 1911 | 1928 | /** The environment began serving: its awake interval opens, if not open. */ |
| 1912 | 1929 | private awake(state: Environment, at: string): void { |
| 1913 | 1930 | this.metered(() => |
test/meter.test.ts +37 −1
| @@ -2,7 +2,8 @@ | ||
| 2 | 2 | * The meter: awake intervals and storage samples, and the two sums read |
| 3 | 3 | * from them — awake seconds and GB-hours — checked against a hand count. |
| 4 | 4 | */ |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 5 | import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; | |
| 6 | import { Database } from "bun:sqlite"; | |
| 6 | 7 | import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; |
| 7 | 8 | import { tmpdir } from "node:os"; |
| 8 | 9 | import { join } from "node:path"; |
| @@ -260,3 +261,38 @@ describe("the runtime meters what it runs", () => { | ||
| 260 | 261 | ); |
| 261 | 262 | }, 30_000); |
| 262 | 263 | }); |
| 264 | ||
| 265 | describe("closing the forge", () => { | |
| 266 | test("ends the open interval while the database is open and writes nothing after", async () => { | |
| 267 | const dataDir = await mkdtemp(join(tmpdir(), "forge-meter-close-")); | |
| 268 | const lines: string[] = []; | |
| 269 | const spy = spyOn(console, "error").mockImplementation((...args: unknown[]) => { | |
| 270 | lines.push(args.map(String).join(" ")); | |
| 271 | }); | |
| 272 | try { | |
| 273 | const app = createApp({ dataDir, port: 0 }); | |
| 274 | const runtime = new AppsRuntime(app, { | |
| 275 | domain: "apps.test", | |
| 276 | scheme: "http", | |
| 277 | forgeUrl: "http://localhost", | |
| 278 | sweepEveryMs: 20, | |
| 279 | }); | |
| 280 | await runtime.start(); | |
| 281 | openAwake(app.db, { repoId: "r9", env: "production", placement: "owned", at: T("09:00") }); | |
| 282 | app.close(); | |
| 283 | // The sweeper would have beaten the meter by now, on a closed database. | |
| 284 | await Bun.sleep(80); | |
| 285 | await runtime.stop(); | |
| 286 | expect(lines.filter((l) => l.includes("meter write failed"))).toEqual([]); | |
| 287 | const db = new Database(join(dataDir, "forge.db"), { readonly: true }); | |
| 288 | const row = db | |
| 289 | .query<MeterRow, []>("SELECT * FROM meter WHERE repo_id = 'r9'") | |
| 290 | .get(); | |
| 291 | db.close(); | |
| 292 | expect(row!.ended_at).not.toBeNull(); | |
| 293 | } finally { | |
| 294 | spy.mockRestore(); | |
| 295 | await rm(dataDir, { recursive: true, force: true }); | |
| 296 | } | |
| 297 | }); | |
| 298 | }); |
new above
offline
This listener is never removed. Detach it in stop() so a stopped runtime does not close intervals again when the forge closes.