Files
open-design/apps/daemon/tests/collab-concurrency-gate.test.ts
lefarcenandGitHub ac26c56184 fix(collab): bound the two unbounded vela fan-outs (#6711)
* refactor(collab): extract the team resource listing cache out of server.ts

Pure move, no behavior change: `cachedTeamResourceList` and its scope key
leave the 11k-line server.ts closure for the module that already owns the
listing caches. Nothing about the SWR window, the sync fan-out, or the
invalidation path changes here — the move is what makes the fan-out
testable in the next commit.

* test(collab): pin the vela fan-out concurrency cap (red)

Both collab fan-outs are unbounded today: the peak equals the data size,
not any daemon-owned limit. On this commit the two cap specs fail with
peak=60 (one per shared resource) and peak=40 (one per dirty project).
The three sibling specs — every resource materialized exactly once, a
failing pull still surfacing, every dirty project eventually published —
pass here and must keep passing once the cap lands.

* fix(collab): bound the two vela fan-outs to a daemon-owned cap

Both collab fan-outs were sized by user data rather than by the machine.
Materializing a workspace's shared resources ran one `vela resource pull`
per shared design system / plugin / skill, all at once, on a single
listing read. Publishing ran one `vela resource push` per project whose
debounce expired in the same tick — per-project re-entry was guarded,
across projects nothing was. A workspace that shares 200 resources
therefore decided how many concurrent transfers this daemon opened.

Route both through one `ConcurrencyGate`. Every operation still runs,
exactly once, and still propagates its own failure to its own caller;
only the peak changes. The uncontended path stays synchronous so callers
that observe "the adapter was invoked by the time this returned" — the
run-boundary flush — behave as before.

The scheduler distinguishes queued from reading: a publish waiting for a
slot has not looked at the project yet, so a change arriving in that
window updates the reason it will publish under instead of stacking a
second publish behind it. Once the adapter is reading, the pre-existing
dirty/re-publish path is unchanged.

Cap specs went red at peak=60 and peak=40 on the parent commit.

* fix(collab): size the fan-out cap at 8, matching the hub's own bound

Measured against real child processes rather than promises: a 40-resource
fan-out peaked at 40 concurrent processes before the cap, 4 at cap=4, and
8 at cap=8. The latency cost is real and worth stating — 457ms unbounded,
4235ms at 4, 2152ms at 8 — so pick the bound the hub already puts on its
own per-request fan-out (MAX_BLOB_INSPECTION_CONCURRENCY) instead of a
tighter number neither side would recognize.

* fix(collab): give every resource kind one shared materialization budget

The gate was built inside createTeamResourceListCache, and server.ts
builds three of those caches — design systems, plugins, skills. One
client poll refreshes all three, so each acquired its own eight slots and
the daemon's real peak was cap x 3. The PR claimed a peak of eight; it
was twenty-four.

Move the gate to the composition root and inject it, so the bound is a
property of the daemon rather than of each listing surface. Measured with
real child processes across the three caches: 24 before, 8 after, with
all 60 resources still materialized.

The publish scheduler keeps its own gate: pulls and pushes are opposite
directions with different latency sensitivity, and coupling them would
let a large listing refresh delay a teammate's publish. The daemon-wide
worst case from these two sites is therefore 8 + 8, which is what the
docs now say instead of an unqualified 8.

Reported by PerishCode on #6711.

* docs(collab): state the real daemon-wide peak, not a per-site number
2026-08-11 07:02:55 +00:00

127 lines
4.1 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { ConcurrencyGate, mapWithGate } from '../src/collab/concurrency-gate.js';
/** A promise with its resolvers exposed, so a test can park an operation. */
function deferred<T = void>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('ConcurrencyGate', () => {
it('rejects a capacity that cannot admit anything', () => {
expect(() => new ConcurrencyGate(0)).toThrow(/positive integer/);
expect(() => new ConcurrencyGate(-1)).toThrow(/positive integer/);
expect(() => new ConcurrencyGate(1.5)).toThrow(/positive integer/);
});
it('starts an uncontended operation synchronously', () => {
const gate = new ConcurrencyGate(2);
let started = false;
void gate.run(async () => {
started = true;
});
// No await: callers that relied on the pre-gate synchronous call still see it.
expect(started).toBe(true);
});
it('holds the extra operations at the cap and admits them as slots free', async () => {
const gate = new ConcurrencyGate(2);
const gates = [deferred(), deferred(), deferred()];
const started: number[] = [];
const runs = gates.map((parked, index) =>
gate.run(async () => {
started.push(index);
await parked.promise;
}),
);
expect(started).toEqual([0, 1]);
expect(gate.active).toBe(2);
expect(gate.pending).toBe(1);
gates[0]!.resolve();
await runs[0];
expect(started).toEqual([0, 1, 2]);
gates[1]!.resolve();
gates[2]!.resolve();
await Promise.all(runs);
expect(gate.active).toBe(0);
expect(gate.pending).toBe(0);
});
it('admits waiters in arrival order', async () => {
const gate = new ConcurrencyGate(1);
const blocker = deferred();
const order: string[] = [];
const first = gate.run(async () => {
order.push('first');
await blocker.promise;
});
const queued = ['a', 'b', 'c'].map((label) =>
gate.run(async () => {
order.push(label);
}),
);
blocker.resolve();
await Promise.all([first, ...queued]);
expect(order).toEqual(['first', 'a', 'b', 'c']);
});
it('releases the slot when an operation rejects, and re-throws unchanged', async () => {
const gate = new ConcurrencyGate(1);
const boom = new Error('transfer failed');
await expect(gate.run(async () => { throw boom; })).rejects.toBe(boom);
expect(gate.active).toBe(0);
// A failed operation must not permanently shrink the gate.
await expect(gate.run(async () => 'ok')).resolves.toBe('ok');
});
it('releases the slot when an operation throws synchronously', async () => {
const gate = new ConcurrencyGate(1);
expect(() => gate.run((() => { throw new Error('sync boom'); }) as never)).toThrow('sync boom');
expect(gate.active).toBe(0);
await expect(gate.run(async () => 'ok')).resolves.toBe('ok');
});
});
describe('mapWithGate', () => {
it('keeps input order regardless of completion order', async () => {
const gate = new ConcurrencyGate(3);
const delays = [30, 0, 10, 20, 5];
const results = await mapWithGate(delays, gate, async (delay, index) => {
await new Promise((resolve) => setTimeout(resolve, delay));
return index;
});
expect(results).toEqual([0, 1, 2, 3, 4]);
});
it('propagates the first rejection like Promise.all does', async () => {
const gate = new ConcurrencyGate(2);
await expect(
mapWithGate([1, 2, 3], gate, async (value) => {
if (value === 2) throw new Error('item 2 failed');
return value;
}),
).rejects.toThrow('item 2 failed');
// Every admitted operation still released its slot.
await expect(gate.run(async () => 'ok')).resolves.toBe('ok');
});
it('runs an empty fan-out without touching the gate', async () => {
const gate = new ConcurrencyGate(1);
await expect(mapWithGate([], gate, async () => 1)).resolves.toEqual([]);
expect(gate.active).toBe(0);
});
});