Repository navigation
Two runtime-hardening fixes: interaction inline limit, worker readiness #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 2 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
baaac95
fix(engine): give checkpoint-driving artifacts their own 2 MiB inline…
hutusi a79bd5c
fix(deploy): prove each worker replica consumes before calling a depl…
hutusi 0f3dfaa
fix(engine): read big patch evidence back from the store instead of f…
hutusi aeb0645
fix: address codex review of PR #18 — evidence digest, readiness live…
hutusi be25fd3
fix: address second codex review — readiness scoping, api-health gate…
hutusi f5385e0
fix: address CodeRabbit review — file-kind storage wording, DB-clock …
hutusi 4f55e33
fix: wait for the schema in the worker image, not via a compose healt…
hutusi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| import { beforeAll, describe, expect, it } from "bun:test"; | ||
| import { createDb, migrateDb, workerHeartbeats } from "@agrippa/db"; | ||
| import { eq, sql } from "drizzle-orm"; | ||
| import { markConsumersReady, touchWorkerHeartbeat } from "./readiness"; | ||
|
|
||
| const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL ?? "postgres://localhost:5432/agrippa_test"; | ||
|
|
||
| // one pool for the whole suite — a pool per fixture exhausts max_connections | ||
| const db = createDb(TEST_DATABASE_URL); | ||
| let dbUp = true; | ||
| try { | ||
| await db.execute(sql`select 1`); | ||
| } catch { | ||
| dbUp = false; | ||
| console.warn("[test] postgres unreachable — skipping worker readiness suite"); | ||
| } | ||
|
|
||
| const rowFor = async (containerId: string) => { | ||
| const [row] = await db | ||
| .select() | ||
| .from(workerHeartbeats) | ||
| .where(eq(workerHeartbeats.containerId, containerId)); | ||
| return row; | ||
| }; | ||
|
|
||
| describe.skipIf(!dbUp)("worker readiness heartbeats", () => { | ||
| beforeAll(async () => { | ||
| // drop the drizzle schema too — the migrator journals there, and dropping | ||
| // only public makes the migrations silently no-op on the next run | ||
| await db.execute(sql`drop schema public cascade`); | ||
| await db.execute(sql`create schema public`); | ||
| await db.execute(sql`drop schema if exists drizzle cascade`); | ||
| await migrateDb(db); | ||
| }); | ||
|
|
||
| it("writes a consumers-ready row for this container", async () => { | ||
| await markConsumersReady(db, "ctr-ready"); | ||
| const row = await rowFor("ctr-ready"); | ||
| expect(row?.consumersReadyAt).not.toBeNull(); | ||
| expect(row?.heartbeatAt).not.toBeNull(); | ||
| }); | ||
|
|
||
| it("re-freshens readiness on restart of the same container", async () => { | ||
| await markConsumersReady(db, "ctr-restart"); | ||
| const stale = new Date(Date.now() - 60 * 60 * 1000); | ||
| await db | ||
| .update(workerHeartbeats) | ||
| .set({ consumersReadyAt: stale, heartbeatAt: stale }) | ||
| .where(eq(workerHeartbeats.containerId, "ctr-restart")); | ||
|
|
||
| // same container id boots again (restart: unless-stopped) — the deploy | ||
| // freshness window must see the NEW boot, not the old one | ||
| await markConsumersReady(db, "ctr-restart"); | ||
| const row = await rowFor("ctr-restart"); | ||
| expect(row?.consumersReadyAt?.getTime()).toBeGreaterThan(stale.getTime()); | ||
| expect(row?.heartbeatAt?.getTime()).toBeGreaterThan(stale.getTime()); | ||
| }); | ||
|
|
||
| it("touch bumps liveness without ever granting readiness", async () => { | ||
| // a fresh row via touch alone (e.g. after an out-of-band delete) must not | ||
| // count as ready — readiness is written only after boss.work() returns | ||
| await touchWorkerHeartbeat(db, "ctr-touch-only"); | ||
| expect((await rowFor("ctr-touch-only"))?.consumersReadyAt).toBeNull(); | ||
|
|
||
| // and on a ready row, touch must preserve the boot-time readiness stamp | ||
| await markConsumersReady(db, "ctr-touch-ready"); | ||
| const before = (await rowFor("ctr-touch-ready"))?.consumersReadyAt; | ||
| await touchWorkerHeartbeat(db, "ctr-touch-ready"); | ||
| expect((await rowFor("ctr-touch-ready"))?.consumersReadyAt?.getTime()).toBe(before?.getTime()); | ||
| }); | ||
|
|
||
| it("prunes containers silent for over a week", async () => { | ||
| const ancient = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); | ||
| await db.insert(workerHeartbeats).values({ | ||
| containerId: "ctr-gone", | ||
| startedAt: ancient, | ||
| consumersReadyAt: ancient, | ||
| heartbeatAt: ancient, | ||
| }); | ||
|
|
||
| await markConsumersReady(db, "ctr-alive"); | ||
| expect(await rowFor("ctr-gone")).toBeUndefined(); | ||
| expect(await rowFor("ctr-alive")).toBeDefined(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import { type Db, workerHeartbeats } from "@agrippa/db"; | ||
| import { lt, sql } from "drizzle-orm"; | ||
|
|
||
| /** | ||
| * Per-container liveness rows (issue #15). Registrations are global per | ||
| * executor, so they cannot prove each replica came up; deploy verification | ||
| * (infra/deploy.sh worker_ok) counts fresh consumers_ready_at rows instead — | ||
| * one per expected replica. | ||
| */ | ||
|
|
||
| /** | ||
| * Call only after boss.work() has returned for every consumer. Writing it any | ||
| * earlier reopens the register-then-wedge gap this row exists to close: a | ||
| * worker that hangs in consumer setup must never look ready. | ||
| */ | ||
| export async function markConsumersReady(db: Db, containerId: string): Promise<void> { | ||
| const now = new Date(); | ||
| await db | ||
| .insert(workerHeartbeats) | ||
| .values({ containerId, startedAt: now, consumersReadyAt: now, heartbeatAt: now }) | ||
| .onConflictDoUpdate({ | ||
| target: workerHeartbeats.containerId, | ||
| set: { consumersReadyAt: now, heartbeatAt: now }, | ||
| }); | ||
| // containers are recreated on every deploy, so rows accumulate one per | ||
| // container forever; a week of silence is far past any freshness window | ||
| await db | ||
| .delete(workerHeartbeats) | ||
| .where(lt(workerHeartbeats.heartbeatAt, sql`now() - interval '7 days'`)); | ||
| } | ||
|
|
||
| /** Sweeper-interval liveness bump; never touches consumersReadyAt. */ | ||
| export async function touchWorkerHeartbeat(db: Db, containerId: string): Promise<void> { | ||
| const now = new Date(); | ||
| await db | ||
| .insert(workerHeartbeats) | ||
| .values({ containerId, startedAt: now, heartbeatAt: now }) | ||
| .onConflictDoUpdate({ | ||
| target: workerHeartbeats.containerId, | ||
| set: { heartbeatAt: now }, | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.