2026-09-03 11:58:17 +05:30
|
|
|
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,
|
2026-09-07 12:45:37 +05:30
|
|
|
userId: null,
|
2026-09-03 11:58:17 +05:30
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|