93 lines
3.8 KiB
TypeScript
93 lines
3.8 KiB
TypeScript
/**
|
|
* Admission-control arithmetic for {@link RenderCoordinator}. These guard the
|
|
* reserve → submit/release lifecycle that bounds a caller *before* the archive
|
|
* is extracted:
|
|
* - the global queue-depth cap (`RENDER_MAX_QUEUE`) counts reserved slots;
|
|
* - the per-identity cap (`RENDER_MAX_JOBS_PER_USER`) can't be bypassed;
|
|
* - `release()` fully undoes a reservation (the leak the route fix depends on:
|
|
* if a post-reserve step like makeProjectDir throws, the slot must come back).
|
|
*
|
|
* We drive the coordinator directly with in-memory stores so nothing invokes the
|
|
* real Chromium/FFmpeg producer — this is pure counter arithmetic.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import { RenderCoordinator, RenderRejectedError } from '../src/render-coordinator.js';
|
|
import {
|
|
createMemoryArtifactStore,
|
|
createMemoryJobStore,
|
|
succeedingExecutor,
|
|
} from './support/fakes.js';
|
|
|
|
function newCoordinator(): RenderCoordinator {
|
|
return new RenderCoordinator(
|
|
succeedingExecutor,
|
|
createMemoryJobStore(),
|
|
createMemoryArtifactStore().store,
|
|
);
|
|
}
|
|
|
|
describe('RenderCoordinator admission control', () => {
|
|
it('reserve then release fully restores the per-identity slot', () => {
|
|
const m = newCoordinator();
|
|
// Default RENDER_MAX_JOBS_PER_USER is 1.
|
|
const r = m.reserve('alice');
|
|
// A second reserve for the same identity is now rejected...
|
|
expect(() => m.reserve('alice')).toThrow(RenderRejectedError);
|
|
// ...until the first is released (the makeProjectDir-failure path).
|
|
m.release(r);
|
|
expect(() => m.reserve('alice')).not.toThrow();
|
|
});
|
|
|
|
it('release is idempotent and does not double-decrement', () => {
|
|
const m = newCoordinator();
|
|
const r = m.reserve('bob');
|
|
m.release(r);
|
|
m.release(r); // no-op, must not free a slot that isn't held
|
|
// bob now has one free slot; a fresh reserve + a stale release must not
|
|
// let a second concurrent reserve through.
|
|
const r2 = m.reserve('bob');
|
|
expect(() => m.reserve('bob')).toThrow(RenderRejectedError);
|
|
m.release(r2);
|
|
});
|
|
|
|
it('enforces the per-identity cap across distinct identities independently', () => {
|
|
const m = newCoordinator();
|
|
const a = m.reserve('alice');
|
|
const b = m.reserve('bob'); // different identity: allowed
|
|
expect(a.identity).toBe('alice');
|
|
expect(b.identity).toBe('bob');
|
|
expect(() => m.reserve('alice')).toThrow(RenderRejectedError);
|
|
m.release(a);
|
|
m.release(b);
|
|
});
|
|
|
|
it('rejects reservations once the global queue is full', () => {
|
|
const m = newCoordinator();
|
|
// Reserve up to RENDER_MAX_QUEUE (default 20) with unique identities so the
|
|
// per-user guard never fires first, then the next reserve trips the queue cap.
|
|
const held = [];
|
|
for (let i = 0; i < 20; i++) held.push(m.reserve(`user-${i}`));
|
|
expect(() => m.reserve('user-overflow')).toThrow(/queue is full/i);
|
|
held.forEach((r) => m.release(r));
|
|
// Once released, capacity is back.
|
|
expect(() => m.reserve('user-again')).not.toThrow();
|
|
});
|
|
|
|
it('does not leak the identity slot when jobs.create fails', async () => {
|
|
// submit() consumes the reservation and persists the job; if create() throws
|
|
// (a fallible JobStore, e.g. a future Redis backend), run() never runs to
|
|
// decrement the identity — so submit() must decrement it itself.
|
|
const store = createMemoryJobStore();
|
|
store.create = async () => {
|
|
throw new Error('store down');
|
|
};
|
|
const m = new RenderCoordinator(succeedingExecutor, store, createMemoryArtifactStore().store);
|
|
const r = m.reserve('carol');
|
|
await expect(
|
|
m.submit(r, '/tmp/whatever', { fps: 30, quality: 'draft', format: 'mp4' }),
|
|
).rejects.toThrow('store down');
|
|
// The slot must be free again: a fresh reserve for the same identity succeeds.
|
|
expect(() => m.reserve('carol')).not.toThrow();
|
|
});
|
|
});
|