Files
openmaic/OpenMAIC/render-service/test/render-coordinator-admission.test.ts
2026-08-16 14:58:47 +08:00

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();
});
});