feat(taskbuffer): add sliding-window rate limiting and result-sharing to TaskConstraintGroup and integrate with TaskManager
This commit is contained in:
@@ -870,4 +870,566 @@ tap.test('should use both task.data and input in constraint key', async () => {
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Rate Limiting Tests
|
||||
// =============================================================================
|
||||
|
||||
// Test 24: Basic N-per-window rate limiting
|
||||
tap.test('should enforce N-per-window rate limit', async () => {
|
||||
const manager = new taskbuffer.TaskManager();
|
||||
const execTimestamps: number[] = [];
|
||||
|
||||
const constraint = new taskbuffer.TaskConstraintGroup({
|
||||
name: 'rate-limit-basic',
|
||||
maxConcurrent: Infinity,
|
||||
constraintKeyForExecution: () => 'shared',
|
||||
rateLimit: {
|
||||
maxPerWindow: 3,
|
||||
windowMs: 1000,
|
||||
},
|
||||
});
|
||||
manager.addConstraintGroup(constraint);
|
||||
|
||||
const makeTask = (id: number) =>
|
||||
new taskbuffer.Task({
|
||||
name: `rl-${id}`,
|
||||
taskFunction: async () => {
|
||||
execTimestamps.push(Date.now());
|
||||
return `done-${id}`;
|
||||
},
|
||||
});
|
||||
|
||||
const tasks = [makeTask(1), makeTask(2), makeTask(3), makeTask(4), makeTask(5)];
|
||||
for (const t of tasks) manager.addTask(t);
|
||||
|
||||
const results = await Promise.all(tasks.map((t) => manager.triggerTaskConstrained(t)));
|
||||
|
||||
// All 5 should eventually complete
|
||||
expect(results).toEqual(['done-1', 'done-2', 'done-3', 'done-4', 'done-5']);
|
||||
|
||||
// First 3 should execute nearly simultaneously
|
||||
const firstBatchSpread = execTimestamps[2] - execTimestamps[0];
|
||||
expect(firstBatchSpread).toBeLessThan(100);
|
||||
|
||||
// 4th and 5th should wait for the window to slide (at least ~900ms after first)
|
||||
const fourthDelay = execTimestamps[3] - execTimestamps[0];
|
||||
expect(fourthDelay).toBeGreaterThanOrEqual(900);
|
||||
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
// Test 25: Rate limit + maxConcurrent interaction
|
||||
tap.test('should enforce both rate limit and maxConcurrent independently', async () => {
|
||||
const manager = new taskbuffer.TaskManager();
|
||||
let running = 0;
|
||||
let maxRunning = 0;
|
||||
const execTimestamps: number[] = [];
|
||||
|
||||
const constraint = new taskbuffer.TaskConstraintGroup({
|
||||
name: 'rate-concurrent',
|
||||
maxConcurrent: 2,
|
||||
constraintKeyForExecution: () => 'shared',
|
||||
rateLimit: {
|
||||
maxPerWindow: 3,
|
||||
windowMs: 2000,
|
||||
},
|
||||
});
|
||||
manager.addConstraintGroup(constraint);
|
||||
|
||||
const makeTask = (id: number) =>
|
||||
new taskbuffer.Task({
|
||||
name: `rc-${id}`,
|
||||
taskFunction: async () => {
|
||||
running++;
|
||||
maxRunning = Math.max(maxRunning, running);
|
||||
execTimestamps.push(Date.now());
|
||||
await smartdelay.delayFor(50);
|
||||
running--;
|
||||
},
|
||||
});
|
||||
|
||||
const tasks = [makeTask(1), makeTask(2), makeTask(3), makeTask(4)];
|
||||
for (const t of tasks) manager.addTask(t);
|
||||
|
||||
await Promise.all(tasks.map((t) => manager.triggerTaskConstrained(t)));
|
||||
|
||||
// Concurrency limit should be enforced
|
||||
expect(maxRunning).toBeLessThanOrEqual(2);
|
||||
|
||||
// 4th task should wait for rate limit window (only 3 allowed per 2s)
|
||||
const fourthDelay = execTimestamps[3] - execTimestamps[0];
|
||||
expect(fourthDelay).toBeGreaterThanOrEqual(1900);
|
||||
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
// Test 26: Rate limit + cooldownMs interaction
|
||||
tap.test('should enforce both rate limit and cooldown together', async () => {
|
||||
const manager = new taskbuffer.TaskManager();
|
||||
const execTimestamps: number[] = [];
|
||||
|
||||
const constraint = new taskbuffer.TaskConstraintGroup({
|
||||
name: 'rate-cooldown',
|
||||
maxConcurrent: 1,
|
||||
cooldownMs: 200,
|
||||
constraintKeyForExecution: () => 'shared',
|
||||
rateLimit: {
|
||||
maxPerWindow: 2,
|
||||
windowMs: 2000,
|
||||
},
|
||||
});
|
||||
manager.addConstraintGroup(constraint);
|
||||
|
||||
const makeTask = (id: number) =>
|
||||
new taskbuffer.Task({
|
||||
name: `rcd-${id}`,
|
||||
taskFunction: async () => {
|
||||
execTimestamps.push(Date.now());
|
||||
},
|
||||
});
|
||||
|
||||
const tasks = [makeTask(1), makeTask(2), makeTask(3)];
|
||||
for (const t of tasks) manager.addTask(t);
|
||||
|
||||
await Promise.all(tasks.map((t) => manager.triggerTaskConstrained(t)));
|
||||
|
||||
// Cooldown between first and second: at least 200ms
|
||||
const gap12 = execTimestamps[1] - execTimestamps[0];
|
||||
expect(gap12).toBeGreaterThanOrEqual(150);
|
||||
|
||||
// Third task blocked by rate limit (only 2 per 2000ms window) AND cooldown
|
||||
const gap13 = execTimestamps[2] - execTimestamps[0];
|
||||
expect(gap13).toBeGreaterThanOrEqual(1900);
|
||||
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
// Test 27: Per-key rate limit independence
|
||||
tap.test('should apply rate limit per key independently', async () => {
|
||||
const manager = new taskbuffer.TaskManager();
|
||||
const execLog: string[] = [];
|
||||
|
||||
const constraint = new taskbuffer.TaskConstraintGroup({
|
||||
name: 'rate-per-key',
|
||||
constraintKeyForExecution: (_task, input?: string) => input,
|
||||
rateLimit: {
|
||||
maxPerWindow: 1,
|
||||
windowMs: 2000,
|
||||
},
|
||||
});
|
||||
manager.addConstraintGroup(constraint);
|
||||
|
||||
const task = new taskbuffer.Task({
|
||||
name: 'rate-key-task',
|
||||
taskFunction: async (input: string) => {
|
||||
execLog.push(input);
|
||||
},
|
||||
});
|
||||
manager.addTask(task);
|
||||
|
||||
// Trigger 2 for key-A and 1 for key-B
|
||||
const [r1, r2, r3] = await Promise.all([
|
||||
manager.triggerTaskConstrained(task, 'key-A'),
|
||||
manager.triggerTaskConstrained(task, 'key-B'),
|
||||
manager.triggerTaskConstrained(task, 'key-A'), // should wait for window
|
||||
]);
|
||||
|
||||
// key-A and key-B first calls should both execute immediately
|
||||
expect(execLog[0]).toEqual('key-A');
|
||||
expect(execLog[1]).toEqual('key-B');
|
||||
// key-A second call eventually executes
|
||||
expect(execLog).toContain('key-A');
|
||||
expect(execLog.length).toEqual(3);
|
||||
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
// Test 28: getNextAvailableDelay returns correct value
|
||||
tap.test('should return correct getNextAvailableDelay and canRun after waiting', async () => {
|
||||
const constraint = new taskbuffer.TaskConstraintGroup({
|
||||
name: 'delay-check',
|
||||
constraintKeyForExecution: () => 'key',
|
||||
rateLimit: {
|
||||
maxPerWindow: 1,
|
||||
windowMs: 500,
|
||||
},
|
||||
});
|
||||
|
||||
// Initially: can run, no delay
|
||||
expect(constraint.canRun('key')).toBeTrue();
|
||||
expect(constraint.getNextAvailableDelay('key')).toEqual(0);
|
||||
|
||||
// Acquire and release to record a completion
|
||||
constraint.acquireSlot('key');
|
||||
constraint.releaseSlot('key');
|
||||
|
||||
// Now: rate limit saturated
|
||||
expect(constraint.canRun('key')).toBeFalse();
|
||||
const delay = constraint.getNextAvailableDelay('key');
|
||||
expect(delay).toBeGreaterThan(0);
|
||||
expect(delay).toBeLessThanOrEqual(500);
|
||||
|
||||
// Wait for window to slide
|
||||
await smartdelay.delayFor(delay + 50);
|
||||
|
||||
expect(constraint.canRun('key')).toBeTrue();
|
||||
expect(constraint.getNextAvailableDelay('key')).toEqual(0);
|
||||
});
|
||||
|
||||
// Test 29: reset() clears rate-limit timestamps
|
||||
tap.test('should clear rate limit timestamps on reset', async () => {
|
||||
const constraint = new taskbuffer.TaskConstraintGroup({
|
||||
name: 'reset-rate',
|
||||
constraintKeyForExecution: () => 'key',
|
||||
rateLimit: {
|
||||
maxPerWindow: 1,
|
||||
windowMs: 60000,
|
||||
},
|
||||
});
|
||||
|
||||
constraint.acquireSlot('key');
|
||||
constraint.releaseSlot('key');
|
||||
expect(constraint.canRun('key')).toBeFalse();
|
||||
|
||||
constraint.reset();
|
||||
expect(constraint.canRun('key')).toBeTrue();
|
||||
expect(constraint.getRateLimitDelay('key')).toEqual(0);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Result Sharing Tests
|
||||
// =============================================================================
|
||||
|
||||
// Test 30: Basic result sharing — multiple waiters get first task's result
|
||||
tap.test('should share result with queued tasks (share-latest mode)', async () => {
|
||||
const manager = new taskbuffer.TaskManager();
|
||||
let execCount = 0;
|
||||
|
||||
const constraint = new taskbuffer.TaskConstraintGroup({
|
||||
name: 'share-basic',
|
||||
maxConcurrent: 1,
|
||||
constraintKeyForExecution: () => 'shared',
|
||||
resultSharingMode: 'share-latest',
|
||||
});
|
||||
manager.addConstraintGroup(constraint);
|
||||
|
||||
const makeTask = (id: number) =>
|
||||
new taskbuffer.Task({
|
||||
name: `share-${id}`,
|
||||
taskFunction: async () => {
|
||||
execCount++;
|
||||
await smartdelay.delayFor(100);
|
||||
return 'shared-result';
|
||||
},
|
||||
});
|
||||
|
||||
const t1 = makeTask(1);
|
||||
const t2 = makeTask(2);
|
||||
const t3 = makeTask(3);
|
||||
|
||||
manager.addTask(t1);
|
||||
manager.addTask(t2);
|
||||
manager.addTask(t3);
|
||||
|
||||
const [r1, r2, r3] = await Promise.all([
|
||||
manager.triggerTaskConstrained(t1),
|
||||
manager.triggerTaskConstrained(t2),
|
||||
manager.triggerTaskConstrained(t3),
|
||||
]);
|
||||
|
||||
// Only 1 execution, all get same result
|
||||
expect(execCount).toEqual(1);
|
||||
expect(r1).toEqual('shared-result');
|
||||
expect(r2).toEqual('shared-result');
|
||||
expect(r3).toEqual('shared-result');
|
||||
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
// Test 31: Different keys get independent results
|
||||
tap.test('should share results independently per key', async () => {
|
||||
const manager = new taskbuffer.TaskManager();
|
||||
let execCount = 0;
|
||||
|
||||
const constraint = new taskbuffer.TaskConstraintGroup({
|
||||
name: 'share-per-key',
|
||||
maxConcurrent: 1,
|
||||
constraintKeyForExecution: (_task, input?: string) => input,
|
||||
resultSharingMode: 'share-latest',
|
||||
});
|
||||
manager.addConstraintGroup(constraint);
|
||||
|
||||
const task = new taskbuffer.Task({
|
||||
name: 'keyed-share',
|
||||
taskFunction: async (input: string) => {
|
||||
execCount++;
|
||||
await smartdelay.delayFor(50);
|
||||
return `result-for-${input}`;
|
||||
},
|
||||
});
|
||||
manager.addTask(task);
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
manager.triggerTaskConstrained(task, 'key-A'),
|
||||
manager.triggerTaskConstrained(task, 'key-B'),
|
||||
]);
|
||||
|
||||
// Different keys → both execute independently
|
||||
expect(execCount).toEqual(2);
|
||||
expect(r1).toEqual('result-for-key-A');
|
||||
expect(r2).toEqual('result-for-key-B');
|
||||
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
// Test 32: Default mode ('none') — no sharing
|
||||
tap.test('should not share results when mode is none (default)', async () => {
|
||||
const manager = new taskbuffer.TaskManager();
|
||||
let execCount = 0;
|
||||
|
||||
const constraint = new taskbuffer.TaskConstraintGroup({
|
||||
name: 'no-share',
|
||||
maxConcurrent: 1,
|
||||
constraintKeyForExecution: () => 'shared',
|
||||
// resultSharingMode defaults to 'none'
|
||||
});
|
||||
manager.addConstraintGroup(constraint);
|
||||
|
||||
const makeTask = (id: number) =>
|
||||
new taskbuffer.Task({
|
||||
name: `noshare-${id}`,
|
||||
taskFunction: async () => {
|
||||
execCount++;
|
||||
await smartdelay.delayFor(50);
|
||||
return `result-${execCount}`;
|
||||
},
|
||||
});
|
||||
|
||||
const t1 = makeTask(1);
|
||||
const t2 = makeTask(2);
|
||||
|
||||
manager.addTask(t1);
|
||||
manager.addTask(t2);
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
manager.triggerTaskConstrained(t1),
|
||||
manager.triggerTaskConstrained(t2),
|
||||
]);
|
||||
|
||||
// Both should execute independently
|
||||
expect(execCount).toEqual(2);
|
||||
expect(r1).toEqual('result-1');
|
||||
expect(r2).toEqual('result-2');
|
||||
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
// Test 33: Sharing takes priority over shouldExecute for queued tasks
|
||||
tap.test('should not call shouldExecute for shared results', async () => {
|
||||
const manager = new taskbuffer.TaskManager();
|
||||
let shouldExecuteCalls = 0;
|
||||
let execCount = 0;
|
||||
|
||||
const constraint = new taskbuffer.TaskConstraintGroup({
|
||||
name: 'share-vs-should',
|
||||
maxConcurrent: 1,
|
||||
constraintKeyForExecution: () => 'shared',
|
||||
resultSharingMode: 'share-latest',
|
||||
shouldExecute: () => {
|
||||
shouldExecuteCalls++;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
manager.addConstraintGroup(constraint);
|
||||
|
||||
const makeTask = (id: number) =>
|
||||
new taskbuffer.Task({
|
||||
name: `svs-${id}`,
|
||||
taskFunction: async () => {
|
||||
execCount++;
|
||||
await smartdelay.delayFor(100);
|
||||
return 'shared-value';
|
||||
},
|
||||
});
|
||||
|
||||
const t1 = makeTask(1);
|
||||
const t2 = makeTask(2);
|
||||
const t3 = makeTask(3);
|
||||
|
||||
manager.addTask(t1);
|
||||
manager.addTask(t2);
|
||||
manager.addTask(t3);
|
||||
|
||||
const initialShouldExecuteCalls = shouldExecuteCalls;
|
||||
|
||||
await Promise.all([
|
||||
manager.triggerTaskConstrained(t1),
|
||||
manager.triggerTaskConstrained(t2),
|
||||
manager.triggerTaskConstrained(t3),
|
||||
]);
|
||||
|
||||
// Only 1 execution
|
||||
expect(execCount).toEqual(1);
|
||||
|
||||
// shouldExecute called once for the first task, but not for shared results
|
||||
// (t2 and t3 get shared result without going through executeWithConstraintTracking)
|
||||
const totalShouldExecuteCalls = shouldExecuteCalls - initialShouldExecuteCalls;
|
||||
expect(totalShouldExecuteCalls).toEqual(1);
|
||||
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
// Test 34: Error results NOT shared — queued task executes after failure
|
||||
tap.test('should not share error results with queued tasks', async () => {
|
||||
const manager = new taskbuffer.TaskManager();
|
||||
let execCount = 0;
|
||||
|
||||
const constraint = new taskbuffer.TaskConstraintGroup({
|
||||
name: 'share-error',
|
||||
maxConcurrent: 1,
|
||||
constraintKeyForExecution: () => 'shared',
|
||||
resultSharingMode: 'share-latest',
|
||||
});
|
||||
manager.addConstraintGroup(constraint);
|
||||
|
||||
const failTask = new taskbuffer.Task({
|
||||
name: 'fail-share',
|
||||
catchErrors: true,
|
||||
taskFunction: async () => {
|
||||
execCount++;
|
||||
await smartdelay.delayFor(50);
|
||||
throw new Error('fail');
|
||||
},
|
||||
});
|
||||
|
||||
const successTask = new taskbuffer.Task({
|
||||
name: 'success-share',
|
||||
taskFunction: async () => {
|
||||
execCount++;
|
||||
await smartdelay.delayFor(50);
|
||||
return 'success-result';
|
||||
},
|
||||
});
|
||||
|
||||
manager.addTask(failTask);
|
||||
manager.addTask(successTask);
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
manager.triggerTaskConstrained(failTask),
|
||||
manager.triggerTaskConstrained(successTask),
|
||||
]);
|
||||
|
||||
// Both should have executed (error result not shared)
|
||||
expect(execCount).toEqual(2);
|
||||
expect(r2).toEqual('success-result');
|
||||
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
// Test 35: Multiple constraint groups — sharing from one group applies
|
||||
tap.test('should share result when any applicable group has sharing enabled', async () => {
|
||||
const manager = new taskbuffer.TaskManager();
|
||||
let execCount = 0;
|
||||
|
||||
const sharingGroup = new taskbuffer.TaskConstraintGroup({
|
||||
name: 'sharing-group',
|
||||
maxConcurrent: 1,
|
||||
constraintKeyForExecution: () => 'shared',
|
||||
resultSharingMode: 'share-latest',
|
||||
});
|
||||
|
||||
const nonSharingGroup = new taskbuffer.TaskConstraintGroup({
|
||||
name: 'non-sharing-group',
|
||||
maxConcurrent: 5,
|
||||
constraintKeyForExecution: () => 'all',
|
||||
// resultSharingMode defaults to 'none'
|
||||
});
|
||||
|
||||
manager.addConstraintGroup(sharingGroup);
|
||||
manager.addConstraintGroup(nonSharingGroup);
|
||||
|
||||
const makeTask = (id: number) =>
|
||||
new taskbuffer.Task({
|
||||
name: `multi-share-${id}`,
|
||||
taskFunction: async () => {
|
||||
execCount++;
|
||||
await smartdelay.delayFor(100);
|
||||
return 'multi-group-result';
|
||||
},
|
||||
});
|
||||
|
||||
const t1 = makeTask(1);
|
||||
const t2 = makeTask(2);
|
||||
|
||||
manager.addTask(t1);
|
||||
manager.addTask(t2);
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
manager.triggerTaskConstrained(t1),
|
||||
manager.triggerTaskConstrained(t2),
|
||||
]);
|
||||
|
||||
// Only 1 execution due to sharing from the sharing group
|
||||
expect(execCount).toEqual(1);
|
||||
expect(r1).toEqual('multi-group-result');
|
||||
expect(r2).toEqual('multi-group-result');
|
||||
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
// Test 36: Result sharing + rate limit combo
|
||||
tap.test('should resolve rate-limited waiters with shared result', async () => {
|
||||
const manager = new taskbuffer.TaskManager();
|
||||
let execCount = 0;
|
||||
|
||||
const constraint = new taskbuffer.TaskConstraintGroup({
|
||||
name: 'share-rate',
|
||||
maxConcurrent: 1,
|
||||
constraintKeyForExecution: () => 'shared',
|
||||
resultSharingMode: 'share-latest',
|
||||
rateLimit: {
|
||||
maxPerWindow: 1,
|
||||
windowMs: 5000,
|
||||
},
|
||||
});
|
||||
manager.addConstraintGroup(constraint);
|
||||
|
||||
const makeTask = (id: number) =>
|
||||
new taskbuffer.Task({
|
||||
name: `sr-${id}`,
|
||||
taskFunction: async () => {
|
||||
execCount++;
|
||||
await smartdelay.delayFor(50);
|
||||
return 'rate-shared-result';
|
||||
},
|
||||
});
|
||||
|
||||
const t1 = makeTask(1);
|
||||
const t2 = makeTask(2);
|
||||
const t3 = makeTask(3);
|
||||
|
||||
manager.addTask(t1);
|
||||
manager.addTask(t2);
|
||||
manager.addTask(t3);
|
||||
|
||||
const startTime = Date.now();
|
||||
const [r1, r2, r3] = await Promise.all([
|
||||
manager.triggerTaskConstrained(t1),
|
||||
manager.triggerTaskConstrained(t2),
|
||||
manager.triggerTaskConstrained(t3),
|
||||
]);
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
// Only 1 execution; waiters get shared result without waiting for rate limit window
|
||||
expect(execCount).toEqual(1);
|
||||
expect(r1).toEqual('rate-shared-result');
|
||||
expect(r2).toEqual('rate-shared-result');
|
||||
expect(r3).toEqual('rate-shared-result');
|
||||
|
||||
// Should complete quickly (not waiting 5s for rate limit window)
|
||||
expect(elapsed).toBeLessThan(1000);
|
||||
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
Reference in New Issue
Block a user