Files
open-design/apps/daemon/tests/collab-fanout-concurrency.test.ts
T
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

301 lines
9.9 KiB
TypeScript

// Every fan-out in the collab layer ends in a `vela` child process — one pull
// per shared resource, one push per dirty project. Both fan-outs are sized by
// user data (how much a workspace shares, how many projects a run touched), so
// an unbounded fan-out makes the daemon's peak subprocess count a property of
// the workspace instead of the machine. These specs pin the peak.
import { describe, expect, it, vi } from 'vitest';
import {
COLLAB_VELA_FANOUT_CONCURRENCY,
ConcurrencyGate,
} from '../src/collab/concurrency-gate.js';
import { createTeamResourceListCache } from '../src/collab/team-resource-list-cache.js';
import { CollabPublishScheduler } from '../src/collab/publish-scheduler.js';
import type {
TeamResourceRequestScope,
TeamResourceShareRecord,
} from '../src/collab/team-resource-share.js';
const SCOPE: TeamResourceRequestScope = {
principal: {
memberId: 'wm-1',
teamId: 'ws-1',
role: 'owner',
lifecycleState: 'active',
workspaceType: 'team',
},
canShare: true,
};
function sharedResources(count: number): TeamResourceShareRecord[] {
return Array.from({ length: count }, (_, index) => ({
id: `user:res-${index}`,
ownerMemberId: 'wm-owner',
})) as TeamResourceShareRecord[];
}
/** Records the high-water mark of overlapping calls to the wrapped operation. */
function concurrencyProbe() {
let active = 0;
let peak = 0;
const release: Array<() => void> = [];
return {
get peak() {
return peak;
},
get active() {
return active;
},
/** Settle every operation currently parked inside the probe. */
drain() {
const parked = release.splice(0, release.length);
for (const resolve of parked) resolve();
},
async run(): Promise<void> {
active += 1;
peak = Math.max(peak, active);
await new Promise<void>((resolve) => release.push(resolve));
active -= 1;
},
};
}
/** Let parked operations settle and their continuations run. */
async function settle(probe: ReturnType<typeof concurrencyProbe>, rounds: number) {
for (let round = 0; round < rounds; round += 1) {
probe.drain();
await Promise.resolve();
await Promise.resolve();
}
}
describe('shared team resource materialization fan-out', () => {
it('holds the cap across every resource kind refreshing together', async () => {
// The three listing surfaces are separate caches, and one client poll
// refreshes all of them. A gate owned per cache would bound each kind on
// its own and let the daemon's real peak reach cap x 3.
const probe = concurrencyProbe();
const gate = new ConcurrencyGate(COLLAB_VELA_FANOUT_CONCURRENCY);
const kinds = ['design_system', 'plugin', 'skill'].map(() =>
createTeamResourceListCache({
share: { sharedResources: async () => sharedResources(20) } as never,
sync: () => probe.run(),
gate,
invalidateSharedCommand: () => {},
}),
);
const reading = Promise.all(kinds.map((list) => list(SCOPE)));
for (let tick = 0; tick < 4; tick += 1) await Promise.resolve();
const peakBeforeAnySettles = probe.peak;
await settle(probe, 20 * kinds.length + 2);
const listings = await reading;
expect(listings.flatMap((listing) => listing.ids)).toHaveLength(60);
expect(peakBeforeAnySettles).toBeLessThanOrEqual(COLLAB_VELA_FANOUT_CONCURRENCY);
expect(probe.peak).toBeLessThanOrEqual(COLLAB_VELA_FANOUT_CONCURRENCY);
});
it('never materializes more shared resources at once than the vela fan-out cap', async () => {
const RESOURCE_COUNT = 60;
const probe = concurrencyProbe();
const list = createTeamResourceListCache({
share: {
sharedResources: async () => sharedResources(RESOURCE_COUNT),
} as never,
sync: () => probe.run(),
gate: new ConcurrencyGate(COLLAB_VELA_FANOUT_CONCURRENCY),
invalidateSharedCommand: () => {},
});
const reading = list(SCOPE);
// Let the listing resolve and the fan-out schedule whatever it will.
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
const peakBeforeAnySettles = probe.peak;
await settle(probe, RESOURCE_COUNT + 2);
const listing = await reading;
expect(listing.ids).toHaveLength(RESOURCE_COUNT);
expect(peakBeforeAnySettles).toBeLessThanOrEqual(COLLAB_VELA_FANOUT_CONCURRENCY);
expect(probe.peak).toBeLessThanOrEqual(COLLAB_VELA_FANOUT_CONCURRENCY);
});
it('still materializes every shared resource exactly once', async () => {
const seen: string[] = [];
const list = createTeamResourceListCache({
share: {
sharedResources: async () => sharedResources(25),
} as never,
sync: async (resource) => {
seen.push(resource.id);
},
gate: new ConcurrencyGate(COLLAB_VELA_FANOUT_CONCURRENCY),
invalidateSharedCommand: () => {},
});
const listing = await list(SCOPE);
expect(seen).toHaveLength(25);
expect(new Set(seen).size).toBe(25);
expect(listing.ids).toEqual(seen.slice().sort((a, b) => seen.indexOf(a) - seen.indexOf(b)));
});
it('surfaces a failing materialization instead of swallowing it', async () => {
const list = createTeamResourceListCache({
share: {
sharedResources: async () => sharedResources(8),
} as never,
sync: async (resource) => {
if (resource.id === 'user:res-3') throw new Error('pull failed');
},
gate: new ConcurrencyGate(COLLAB_VELA_FANOUT_CONCURRENCY),
invalidateSharedCommand: () => {},
});
await expect(list(SCOPE)).rejects.toThrow('pull failed');
});
});
describe('cross-project publish fan-out', () => {
it('never publishes more projects at once than the vela fan-out cap', async () => {
vi.useFakeTimers();
try {
const PROJECT_COUNT = 40;
const probe = concurrencyProbe();
const scheduler = new CollabPublishScheduler({
adapter: { publish: () => probe.run().then(() => ({ version: 1 })) },
debounceMs: 10,
});
for (let index = 0; index < PROJECT_COUNT; index += 1) {
scheduler.notifyChanged(`p${index}`);
}
// Every project's debounce expires in the same tick — the worst case a
// run boundary or a workspace-wide reconcile produces.
await vi.advanceTimersByTimeAsync(10);
expect(probe.peak).toBeLessThanOrEqual(COLLAB_VELA_FANOUT_CONCURRENCY);
// Draining lets the queued projects through; the cap must hold there too.
for (let round = 0; round < PROJECT_COUNT + 2; round += 1) {
probe.drain();
await vi.advanceTimersByTimeAsync(0);
}
expect(probe.peak).toBeLessThanOrEqual(COLLAB_VELA_FANOUT_CONCURRENCY);
scheduler.dispose();
} finally {
vi.useRealTimers();
}
});
it('does not stack a second publish for a change that arrives while queued', async () => {
vi.useFakeTimers();
try {
// Fill every slot with parked publishes, then queue one more project.
const blocker = concurrencyProbe();
const reasons: string[] = [];
const scheduler = new CollabPublishScheduler({
adapter: {
publish: async ({ projectId, reason }) => {
if (projectId !== 'queued') {
await blocker.run();
return { version: 1 };
}
reasons.push(reason);
return { version: 1 };
},
},
debounceMs: 10,
});
for (let index = 0; index < COLLAB_VELA_FANOUT_CONCURRENCY; index += 1) {
scheduler.notifyChanged(`blocker-${index}`);
}
scheduler.notifyChanged('queued', 'first');
await vi.advanceTimersByTimeAsync(10);
expect(reasons).toEqual([]); // still behind the cap
// The author keeps editing while the publish waits for a slot. The
// adapter has not read the project yet, so this must NOT become a
// second publish — it must become the reason of the queued one.
scheduler.notifyChanged('queued', 'second');
scheduler.notifyChanged('queued', 'third');
blocker.drain();
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(10);
expect(reasons).toEqual(['third']);
scheduler.dispose();
} finally {
vi.useRealTimers();
}
});
it('still re-publishes for a change that arrives after the adapter started reading', async () => {
vi.useFakeTimers();
try {
const inFlight = concurrencyProbe();
const reasons: string[] = [];
const scheduler = new CollabPublishScheduler({
adapter: {
publish: async ({ reason }) => {
reasons.push(reason);
await inFlight.run();
return { version: 1 };
},
},
debounceMs: 10,
});
scheduler.notifyChanged('p1', 'first');
await vi.advanceTimersByTimeAsync(10);
expect(reasons).toEqual(['first']); // admitted, adapter is reading
scheduler.notifyChanged('p1', 'second');
inFlight.drain();
await vi.advanceTimersByTimeAsync(10);
inFlight.drain();
await vi.advanceTimersByTimeAsync(10);
expect(reasons).toEqual(['first', 'second']);
scheduler.dispose();
} finally {
vi.useRealTimers();
}
});
it('publishes every dirty project once the queue drains', async () => {
vi.useFakeTimers();
try {
const PROJECT_COUNT = 20;
const published: string[] = [];
const scheduler = new CollabPublishScheduler({
adapter: {
publish: async ({ projectId }) => {
published.push(projectId);
return { version: 1 };
},
},
debounceMs: 10,
});
for (let index = 0; index < PROJECT_COUNT; index += 1) {
scheduler.notifyChanged(`p${index}`);
}
await vi.advanceTimersByTimeAsync(10);
await vi.advanceTimersByTimeAsync(0);
expect(published).toHaveLength(PROJECT_COUNT);
expect(new Set(published).size).toBe(PROJECT_COUNT);
scheduler.dispose();
} finally {
vi.useRealTimers();
}
});
});