mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* 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
127 lines
4.1 KiB
TypeScript
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);
|
|
});
|
|
});
|