Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
172 lines
5.5 KiB
TypeScript
172 lines
5.5 KiB
TypeScript
import type { BackgroundJobHandlers } from "@rakazo/adapter-kit";
|
|
import { Pool } from "pg";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { GraphileJobPublisher, GraphileJobWorkerHost } from "./wakeup.js";
|
|
|
|
const databaseUrl = process.env.DATABASE_URL;
|
|
const describePostgres =
|
|
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
|
|
|
|
let pool: Pool | undefined;
|
|
function testPool(): Pool {
|
|
pool ??= new Pool({ connectionString: databaseUrl, max: 4 });
|
|
return pool;
|
|
}
|
|
afterEach(async () => {
|
|
await pool?.end();
|
|
pool = undefined;
|
|
});
|
|
|
|
function handlers(overrides: Partial<BackgroundJobHandlers> = {}): BackgroundJobHandlers {
|
|
return {
|
|
"run.continue": vi.fn(async () => undefined),
|
|
"routine.wakeup": vi.fn(async () => undefined),
|
|
"computer.update": vi.fn(async () => undefined),
|
|
"computer.sleep": vi.fn(async () => undefined),
|
|
"computer.control-expire": vi.fn(async () => undefined),
|
|
"skill.teaching-expire": vi.fn(async () => undefined),
|
|
"history.compact": vi.fn(async () => undefined),
|
|
"messaging.deliver": vi.fn(async () => undefined),
|
|
"cloud_agent.poll": vi.fn(async () => undefined),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function deferred() {
|
|
let resolve!: () => void;
|
|
const promise = new Promise<void>((done) => {
|
|
resolve = done;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
async function waitFor(assertion: () => void, timeoutMs = 10_000): Promise<void> {
|
|
await vi.waitFor(assertion, { timeout: timeoutMs, interval: 25 });
|
|
}
|
|
|
|
describePostgres("Graphile background jobs (PostgreSQL contract)", () => {
|
|
it("waits for an active handler during graceful shutdown", async () => {
|
|
const publisher = new GraphileJobPublisher(testPool());
|
|
const host = new GraphileJobWorkerHost(testPool(), { concurrency: 1, pollInterval: 25 });
|
|
const entered = deferred();
|
|
const release = deferred();
|
|
let completed = false;
|
|
const target = handlers({
|
|
"run.continue": async () => {
|
|
entered.resolve();
|
|
await release.promise;
|
|
completed = true;
|
|
},
|
|
});
|
|
|
|
try {
|
|
await host.start(target);
|
|
await publisher.enqueue({ name: "run.continue", payload: { runId: "graceful-stop" } });
|
|
await entered.promise;
|
|
|
|
let stopped = false;
|
|
const stopping = host.stop().then(() => {
|
|
stopped = true;
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
expect(stopped).toBe(false);
|
|
|
|
release.resolve();
|
|
await stopping;
|
|
expect(completed).toBe(true);
|
|
} finally {
|
|
release.resolve();
|
|
await host.stop();
|
|
await publisher.close();
|
|
}
|
|
});
|
|
|
|
it("replaces a keyed delayed job without running the old payload or schedule", async () => {
|
|
const publisher = new GraphileJobPublisher(testPool());
|
|
const host = new GraphileJobWorkerHost(testPool(), { concurrency: 1, pollInterval: 25 });
|
|
const received: string[] = [];
|
|
const target = handlers({
|
|
"computer.sleep": async ({ computerId }) => {
|
|
received.push(computerId);
|
|
},
|
|
});
|
|
const key = `contract:replacement:${Date.now()}`;
|
|
|
|
try {
|
|
await host.start(target);
|
|
await publisher.enqueue({
|
|
name: "computer.sleep",
|
|
payload: { computerId: "old" },
|
|
availableAt: new Date(Date.now() + 300),
|
|
replaceKey: key,
|
|
});
|
|
await publisher.enqueue({
|
|
name: "computer.sleep",
|
|
payload: { computerId: "replacement" },
|
|
availableAt: new Date(Date.now() + 800),
|
|
replaceKey: key,
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
expect(received).toEqual([]);
|
|
await waitFor(() => expect(received).toEqual(["replacement"]));
|
|
} finally {
|
|
await host.stop();
|
|
await publisher.cancel(key).catch(() => undefined);
|
|
await publisher.close();
|
|
}
|
|
});
|
|
|
|
it("removes a keyed delayed job before a worker can run it", async () => {
|
|
const publisher = new GraphileJobPublisher(testPool());
|
|
const host = new GraphileJobWorkerHost(testPool(), { concurrency: 1, pollInterval: 25 });
|
|
const target = handlers();
|
|
const key = `contract:cancellation:${Date.now()}`;
|
|
|
|
try {
|
|
await host.start(target);
|
|
await publisher.enqueue({
|
|
name: "computer.sleep",
|
|
payload: { computerId: "cancelled" },
|
|
availableAt: new Date(Date.now() + 300),
|
|
replaceKey: key,
|
|
});
|
|
await publisher.cancel(key);
|
|
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
expect(target["computer.sleep"]).not.toHaveBeenCalled();
|
|
} finally {
|
|
await host.stop();
|
|
await publisher.close();
|
|
}
|
|
});
|
|
|
|
it("persists a handler failure and retries the job successfully", async () => {
|
|
const publisher = new GraphileJobPublisher(testPool());
|
|
const host = new GraphileJobWorkerHost(testPool(), { concurrency: 1, pollInterval: 25 });
|
|
let attempts = 0;
|
|
const target = handlers({
|
|
"run.continue": async () => {
|
|
attempts += 1;
|
|
if (attempts === 1) throw new Error("intentional contract-test failure");
|
|
},
|
|
});
|
|
const key = `contract:retry:${Date.now()}`;
|
|
|
|
try {
|
|
await host.start(target);
|
|
await publisher.enqueue({
|
|
name: "run.continue",
|
|
payload: { runId: "retry" },
|
|
replaceKey: key,
|
|
});
|
|
await waitFor(() => expect(attempts).toBe(2), 15_000);
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
expect(attempts).toBe(2);
|
|
} finally {
|
|
await host.stop();
|
|
await publisher.cancel(key).catch(() => undefined);
|
|
await publisher.close();
|
|
}
|
|
});
|
|
});
|