mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Three defects in the bounded document reader. Image uploads were decoded into their thumbnails in the server process. A header check is all that stood between a decoder and an allocation the file chooses, and the GIF check showed how that fails. The image is now decoded in the same child process PDFs run in, under its memory ceiling and time limit; the header check stays in front of it. The reader's one slot went to whoever asked first, and a waiting item waited without end, so a few heavy PDFs from one account held everyone's lab scans behind them. The slot now goes to a read a person is waiting on before background work, to accounts in turn, to a few waiting items per account, and an item that waits past its lane's deadline is answered busy, which callers treat as retryable. Any child that ended without an answer counted as a document past its limits, so a kernel kill caused by the container's overall memory marked a good PDF as permanently failed. Only a bound the reader measured (the sampled memory, the time limit, the child's heap cap) is final now; any other end is run once more and then answered interrupted, which the thumbnail job retries within its ordinary attempts.
139 lines
4.8 KiB
TypeScript
139 lines
4.8 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { createReaderQueue, type ReaderTicket } from "../reader-queue";
|
|
|
|
/**
|
|
* The document reader has one slot. It used to go to whoever asked first,
|
|
* and a waiting item waited without end: a few heavy PDFs from one account
|
|
* held every other account's lab scan behind them. These pin the order the
|
|
* slot is handed out in, the cap on what one account may queue, and the
|
|
* deadline a waiting item gets.
|
|
*/
|
|
|
|
const WAIT = { interactive: 1_000, background: 5_000 };
|
|
|
|
function queue(maxQueuedPerOwner = 10) {
|
|
return createReaderQueue({ slots: 1, maxQueuedPerOwner, waitMs: WAIT });
|
|
}
|
|
|
|
/** Acquire, and note the order in which tickets are granted. */
|
|
function recorder() {
|
|
const order: string[] = [];
|
|
const tickets = new Map<string, ReaderTicket>();
|
|
const track = (label: string, pending: Promise<ReaderTicket>) =>
|
|
pending.then((ticket) => {
|
|
if (ticket.ok) order.push(label);
|
|
tickets.set(label, ticket);
|
|
return ticket;
|
|
});
|
|
const release = (label: string) => {
|
|
const ticket = tickets.get(label);
|
|
if (ticket?.ok) ticket.release();
|
|
};
|
|
return { order, track, release };
|
|
}
|
|
|
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
|
|
describe("the reader queue", () => {
|
|
it("takes accounts in turn instead of first come, first served", async () => {
|
|
const q = queue();
|
|
const r = recorder();
|
|
void r.track("a1", q.acquire("a", "background"));
|
|
void r.track("a2", q.acquire("a", "background"));
|
|
void r.track("a3", q.acquire("a", "background"));
|
|
void r.track("b1", q.acquire("b", "background"));
|
|
void r.track("c1", q.acquire("c", "background"));
|
|
await flush();
|
|
for (const label of ["a1", "a2", "b1", "c1", "a3"]) {
|
|
r.release(label);
|
|
await flush();
|
|
}
|
|
// a1 held the slot. Of the waiting items a2 came first, then b and c
|
|
// each get their turn before a's third item comes round.
|
|
expect(r.order).toEqual(["a1", "a2", "b1", "c1", "a3"]);
|
|
});
|
|
|
|
it("serves a read a person is waiting on before background work", async () => {
|
|
const q = queue();
|
|
const r = recorder();
|
|
void r.track("bg-running", q.acquire("a", "background"));
|
|
void r.track("bg-a", q.acquire("a", "background"));
|
|
void r.track("bg-b", q.acquire("b", "background"));
|
|
void r.track("lab-scan", q.acquire("c", "interactive"));
|
|
await flush();
|
|
r.release("bg-running");
|
|
await flush();
|
|
expect(r.order).toEqual(["bg-running", "lab-scan"]);
|
|
});
|
|
|
|
it("refuses an account's item past its waiting cap at once, without touching others", async () => {
|
|
const q = queue(2);
|
|
const r = recorder();
|
|
await r.track("a-running", q.acquire("a", "interactive"));
|
|
void r.track("a-1", q.acquire("a", "interactive"));
|
|
void r.track("a-2", q.acquire("a", "background"));
|
|
await expect(q.acquire("a", "interactive")).resolves.toMatchObject({
|
|
ok: false,
|
|
reason: "queue-full",
|
|
});
|
|
// Another account still gets in line, and its turn comes before the
|
|
// capped account's second waiting item.
|
|
void r.track("b-1", q.acquire("b", "interactive"));
|
|
expect(q.snapshot().waiting).toEqual({ interactive: 2, background: 1 });
|
|
for (const label of ["a-running", "a-1"]) {
|
|
r.release(label);
|
|
await flush();
|
|
}
|
|
expect(r.order).toEqual(["a-running", "a-1", "b-1"]);
|
|
r.release("b-1");
|
|
await flush();
|
|
expect(r.order.at(-1)).toBe("a-2");
|
|
r.release("a-2");
|
|
});
|
|
|
|
describe("with a deadline", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("answers a waiting item busy when its lane's deadline passes, and keeps the slot for the next", async () => {
|
|
const q = queue();
|
|
const held = await q.acquire("a", "background");
|
|
const waiting = q.acquire("b", "interactive");
|
|
const later = q.acquire("c", "background");
|
|
|
|
await vi.advanceTimersByTimeAsync(WAIT.interactive + 1);
|
|
await expect(waiting).resolves.toEqual({
|
|
ok: false,
|
|
reason: "wait-expired",
|
|
retryAfterMs: WAIT.interactive,
|
|
});
|
|
expect(q.snapshot()).toEqual({
|
|
running: 1,
|
|
waiting: { interactive: 0, background: 1 },
|
|
});
|
|
|
|
// The expired item left the queue: releasing hands the slot to the
|
|
// next one in line, not to nobody.
|
|
if (held.ok) held.release();
|
|
await expect(later).resolves.toMatchObject({ ok: true });
|
|
});
|
|
|
|
it("releases a slot once, however often release is called", async () => {
|
|
const q = queue();
|
|
const first = await q.acquire("a", "background");
|
|
if (!first.ok) throw new Error("expected the slot");
|
|
first.release();
|
|
first.release();
|
|
expect(q.snapshot().running).toBe(0);
|
|
const a = await q.acquire("a", "background");
|
|
expect(a.ok).toBe(true);
|
|
expect(q.snapshot().running).toBe(1);
|
|
});
|
|
});
|
|
});
|