Files
support_backend/tests/concurrency/round-robin.test.ts
T

81 lines
2.7 KiB
TypeScript
Raw Normal View History

import { describe, it, expect } from 'vitest';
import { redisClient } from '@/infrastructure/cache';
import {
selectViaRoundRobin,
roundRobinSelectIndex,
} from '@/modules/orchestration/assignments/strategies/round-robin.strategy';
/**
* Constitution Principle VII / FR-006 / SC-002: round robin must be concurrency-safe under
* genuinely concurrent selection attempts, not just sequential calls — this is the first
* dedicated multi-writer-race test in this codebase since 003-ticketing's optimistic
* ticket-status concurrency (research.md).
*/
describe('ROUND_ROBIN concurrency safety', () => {
function fakeAgent(id: string) {
return {
id,
teamId: 't',
name: id,
active: true,
userId: null,
createdAt: new Date(),
updatedAt: new Date(),
skills: [],
};
}
it('never selects the same index twice for concurrent calls against the same key, and the final counter matches the call count', async () => {
const nodeId = `concurrency-test-node-${Date.now()}`;
const key = `ticketing:round_robin:${nodeId}`;
await redisClient.del(key);
const eligible = [
fakeAgent('a'),
fakeAgent('b'),
fakeAgent('c'),
fakeAgent('d'),
fakeAgent('e'),
];
const callCount = 25;
const results = await Promise.all(
Array.from({ length: callCount }, () =>
selectViaRoundRobin(eligible, { hierarchyNodeId: nodeId, requiredSkills: [] }),
),
);
// Every call must have selected somebody — never null for a non-empty eligible set.
expect(results.every((r) => r !== null)).toBe(true);
// Across every complete cycle through the 5 agents, each agent must be selected exactly
// callCount/5 times — if two concurrent INCRs had ever collided (both reading the same
// pre-increment value), some agent would be over- or under-selected relative to this exact
// count, since callCount (25) is a clean multiple of the eligible set size (5).
const counts = new Map<string, number>();
for (const r of results) {
if (!r) continue;
counts.set(r.id, (counts.get(r.id) ?? 0) + 1);
}
for (const agent of eligible) {
expect(counts.get(agent.id)).toBe(callCount / eligible.length);
}
const finalCount = await redisClient.get(key);
expect(Number(finalCount)).toBe(callCount);
await redisClient.del(key);
});
it('the pure index function never repeats an index within one full cycle', () => {
const length = 7;
const seen = new Set<number>();
for (let count = 1; count <= length; count++) {
const index = roundRobinSelectIndex(count, length);
expect(seen.has(index)).toBe(false);
seen.add(index);
}
expect(seen.size).toBe(length);
});
});