BREAKING CHANGE(taskbuffer): Introduce constraint-based concurrency with TaskConstraintGroup and TaskManager integration; remove legacy TaskRunner and several Task APIs (breaking); add typed Task.data and update exports and tests.
This commit is contained in:
@@ -137,38 +137,7 @@ tap.test('should reject Taskparallel when a child task throws', async () => {
|
||||
expect(didReject).toBeTrue();
|
||||
});
|
||||
|
||||
// Test 7: TaskRunner continues processing after a task error
|
||||
tap.test('should continue TaskRunner queue after a task error', async () => {
|
||||
const runner = new taskbuffer.TaskRunner();
|
||||
const executionOrder: string[] = [];
|
||||
|
||||
const badTask = new taskbuffer.Task({
|
||||
name: 'runner-bad-task',
|
||||
taskFunction: async () => {
|
||||
executionOrder.push('bad');
|
||||
throw new Error('runner task failure');
|
||||
},
|
||||
});
|
||||
const goodTask = new taskbuffer.Task({
|
||||
name: 'runner-good-task',
|
||||
taskFunction: async () => {
|
||||
executionOrder.push('good');
|
||||
},
|
||||
});
|
||||
|
||||
await runner.start();
|
||||
runner.addTask(badTask);
|
||||
runner.addTask(goodTask);
|
||||
|
||||
// Wait for both tasks to be processed
|
||||
await smartdelay.delayFor(500);
|
||||
await runner.stop();
|
||||
|
||||
expect(executionOrder).toContain('bad');
|
||||
expect(executionOrder).toContain('good');
|
||||
});
|
||||
|
||||
// Test 8: BufferRunner handles errors without hanging
|
||||
// Test 7: BufferRunner handles errors without hanging
|
||||
tap.test('should handle BufferRunner errors without hanging', async () => {
|
||||
let callCount = 0;
|
||||
const bufferedTask = new taskbuffer.Task({
|
||||
|
||||
391
test/test.13.constraints.ts
Normal file
391
test/test.13.constraints.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as taskbuffer from '../ts/index.js';
|
||||
import * as smartdelay from '@push.rocks/smartdelay';
|
||||
|
||||
// Test 1: Task data property — typed data accessible
|
||||
tap.test('should have typed data property on task', async () => {
|
||||
const task = new taskbuffer.Task<undefined, [], { domain: string; priority: number }>({
|
||||
name: 'data-task',
|
||||
data: { domain: 'example.com', priority: 1 },
|
||||
taskFunction: async () => {},
|
||||
});
|
||||
|
||||
expect(task.data.domain).toEqual('example.com');
|
||||
expect(task.data.priority).toEqual(1);
|
||||
});
|
||||
|
||||
// Test 2: Task data defaults to empty object
|
||||
tap.test('should default data to empty object when not provided', async () => {
|
||||
const task = new taskbuffer.Task({
|
||||
name: 'no-data-task',
|
||||
taskFunction: async () => {},
|
||||
});
|
||||
|
||||
expect(task.data).toBeTruthy();
|
||||
expect(typeof task.data).toEqual('object');
|
||||
});
|
||||
|
||||
// Test 3: No-constraint passthrough — behavior unchanged
|
||||
tap.test('should run tasks directly when no constraints are configured', async () => {
|
||||
const manager = new taskbuffer.TaskManager();
|
||||
let executed = false;
|
||||
|
||||
const task = new taskbuffer.Task({
|
||||
name: 'passthrough-task',
|
||||
taskFunction: async () => {
|
||||
executed = true;
|
||||
return 'done';
|
||||
},
|
||||
});
|
||||
|
||||
manager.addTask(task);
|
||||
const result = await manager.triggerTaskByName('passthrough-task');
|
||||
expect(executed).toBeTrue();
|
||||
expect(result).toEqual('done');
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
// Test 4: Group concurrency — 3 tasks, max 2 concurrent, 3rd queues
|
||||
tap.test('should enforce group concurrency limit', async () => {
|
||||
const manager = new taskbuffer.TaskManager();
|
||||
let running = 0;
|
||||
let maxRunning = 0;
|
||||
|
||||
const constraint = new taskbuffer.TaskConstraintGroup<{ group: string }>({
|
||||
name: 'concurrency-test',
|
||||
maxConcurrent: 2,
|
||||
constraintKeyForTask: (task) =>
|
||||
task.data.group === 'workers' ? 'workers' : null,
|
||||
});
|
||||
manager.addConstraintGroup(constraint);
|
||||
|
||||
const makeTask = (id: number) =>
|
||||
new taskbuffer.Task<undefined, [], { group: string }>({
|
||||
name: `worker-${id}`,
|
||||
data: { group: 'workers' },
|
||||
taskFunction: async () => {
|
||||
running++;
|
||||
maxRunning = Math.max(maxRunning, running);
|
||||
await smartdelay.delayFor(200);
|
||||
running--;
|
||||
},
|
||||
});
|
||||
|
||||
const t1 = makeTask(1);
|
||||
const t2 = makeTask(2);
|
||||
const t3 = makeTask(3);
|
||||
|
||||
manager.addTask(t1);
|
||||
manager.addTask(t2);
|
||||
manager.addTask(t3);
|
||||
|
||||
await Promise.all([
|
||||
manager.triggerTaskConstrained(t1),
|
||||
manager.triggerTaskConstrained(t2),
|
||||
manager.triggerTaskConstrained(t3),
|
||||
]);
|
||||
|
||||
expect(maxRunning).toBeLessThanOrEqual(2);
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
// Test 5: Key-based mutual exclusion — same key sequential, different keys parallel
|
||||
tap.test('should enforce key-based mutual exclusion', async () => {
|
||||
const manager = new taskbuffer.TaskManager();
|
||||
const log: string[] = [];
|
||||
|
||||
const constraint = new taskbuffer.TaskConstraintGroup<{ domain: string }>({
|
||||
name: 'domain-mutex',
|
||||
maxConcurrent: 1,
|
||||
constraintKeyForTask: (task) => task.data.domain,
|
||||
});
|
||||
manager.addConstraintGroup(constraint);
|
||||
|
||||
const makeTask = (name: string, domain: string, delayMs: number) =>
|
||||
new taskbuffer.Task<undefined, [], { domain: string }>({
|
||||
name,
|
||||
data: { domain },
|
||||
taskFunction: async () => {
|
||||
log.push(`${name}-start`);
|
||||
await smartdelay.delayFor(delayMs);
|
||||
log.push(`${name}-end`);
|
||||
},
|
||||
});
|
||||
|
||||
const taskA1 = makeTask('a1', 'a.com', 100);
|
||||
const taskA2 = makeTask('a2', 'a.com', 100);
|
||||
const taskB1 = makeTask('b1', 'b.com', 100);
|
||||
|
||||
manager.addTask(taskA1);
|
||||
manager.addTask(taskA2);
|
||||
manager.addTask(taskB1);
|
||||
|
||||
await Promise.all([
|
||||
manager.triggerTaskConstrained(taskA1),
|
||||
manager.triggerTaskConstrained(taskA2),
|
||||
manager.triggerTaskConstrained(taskB1),
|
||||
]);
|
||||
|
||||
// a1 and a2 should be sequential (same key)
|
||||
const a1EndIdx = log.indexOf('a1-end');
|
||||
const a2StartIdx = log.indexOf('a2-start');
|
||||
expect(a2StartIdx).toBeGreaterThanOrEqual(a1EndIdx);
|
||||
|
||||
// b1 should start concurrently with a1 (different key)
|
||||
const a1StartIdx = log.indexOf('a1-start');
|
||||
const b1StartIdx = log.indexOf('b1-start');
|
||||
// Both should start before a1 ends
|
||||
expect(b1StartIdx).toBeLessThan(a1EndIdx);
|
||||
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
// Test 6: Cooldown enforcement
|
||||
tap.test('should enforce cooldown between task executions', async () => {
|
||||
const manager = new taskbuffer.TaskManager();
|
||||
const timestamps: number[] = [];
|
||||
|
||||
const constraint = new taskbuffer.TaskConstraintGroup<{ key: string }>({
|
||||
name: 'cooldown-test',
|
||||
maxConcurrent: 1,
|
||||
cooldownMs: 300,
|
||||
constraintKeyForTask: (task) => task.data.key,
|
||||
});
|
||||
manager.addConstraintGroup(constraint);
|
||||
|
||||
const makeTask = (name: string) =>
|
||||
new taskbuffer.Task<undefined, [], { key: string }>({
|
||||
name,
|
||||
data: { key: 'shared' },
|
||||
taskFunction: async () => {
|
||||
timestamps.push(Date.now());
|
||||
},
|
||||
});
|
||||
|
||||
const t1 = makeTask('cool-1');
|
||||
const t2 = makeTask('cool-2');
|
||||
const t3 = makeTask('cool-3');
|
||||
|
||||
manager.addTask(t1);
|
||||
manager.addTask(t2);
|
||||
manager.addTask(t3);
|
||||
|
||||
await Promise.all([
|
||||
manager.triggerTaskConstrained(t1),
|
||||
manager.triggerTaskConstrained(t2),
|
||||
manager.triggerTaskConstrained(t3),
|
||||
]);
|
||||
|
||||
// Each execution should be at least ~300ms apart (with 200ms tolerance)
|
||||
for (let i = 1; i < timestamps.length; i++) {
|
||||
const gap = timestamps[i] - timestamps[i - 1];
|
||||
expect(gap).toBeGreaterThanOrEqual(100); // 300ms cooldown minus tolerance
|
||||
}
|
||||
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
// Test 7: Multiple constraint groups on one task
|
||||
tap.test('should apply multiple constraint groups to one task', async () => {
|
||||
const manager = new taskbuffer.TaskManager();
|
||||
let running = 0;
|
||||
let maxRunning = 0;
|
||||
|
||||
const globalConstraint = new taskbuffer.TaskConstraintGroup({
|
||||
name: 'global',
|
||||
maxConcurrent: 3,
|
||||
constraintKeyForTask: () => 'all',
|
||||
});
|
||||
|
||||
const groupConstraint = new taskbuffer.TaskConstraintGroup<{ group: string }>({
|
||||
name: 'group',
|
||||
maxConcurrent: 1,
|
||||
constraintKeyForTask: (task) => task.data.group,
|
||||
});
|
||||
|
||||
manager.addConstraintGroup(globalConstraint);
|
||||
manager.addConstraintGroup(groupConstraint);
|
||||
|
||||
const makeTask = (name: string, group: string) =>
|
||||
new taskbuffer.Task<undefined, [], { group: string }>({
|
||||
name,
|
||||
data: { group },
|
||||
taskFunction: async () => {
|
||||
running++;
|
||||
maxRunning = Math.max(maxRunning, running);
|
||||
await smartdelay.delayFor(100);
|
||||
running--;
|
||||
},
|
||||
});
|
||||
|
||||
// Same group - should be serialized by group constraint
|
||||
const t1 = makeTask('multi-1', 'A');
|
||||
const t2 = makeTask('multi-2', 'A');
|
||||
|
||||
manager.addTask(t1);
|
||||
manager.addTask(t2);
|
||||
|
||||
await Promise.all([
|
||||
manager.triggerTaskConstrained(t1),
|
||||
manager.triggerTaskConstrained(t2),
|
||||
]);
|
||||
|
||||
// With group maxConcurrent: 1, only 1 should run at a time
|
||||
expect(maxRunning).toBeLessThanOrEqual(1);
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
// Test 8: Matcher returns null — task runs unconstrained
|
||||
tap.test('should run task unconstrained when matcher returns null', async () => {
|
||||
const manager = new taskbuffer.TaskManager();
|
||||
|
||||
const constraint = new taskbuffer.TaskConstraintGroup<{ skip: boolean }>({
|
||||
name: 'selective',
|
||||
maxConcurrent: 1,
|
||||
constraintKeyForTask: (task) => (task.data.skip ? null : 'constrained'),
|
||||
});
|
||||
manager.addConstraintGroup(constraint);
|
||||
|
||||
let unconstrained = false;
|
||||
const task = new taskbuffer.Task<undefined, [], { skip: boolean }>({
|
||||
name: 'skip-task',
|
||||
data: { skip: true },
|
||||
taskFunction: async () => {
|
||||
unconstrained = true;
|
||||
},
|
||||
});
|
||||
|
||||
manager.addTask(task);
|
||||
await manager.triggerTaskConstrained(task);
|
||||
expect(unconstrained).toBeTrue();
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
// Test 9: Error handling — failed task releases slot, queue drains
|
||||
tap.test('should release slot and drain queue when task fails', async () => {
|
||||
const manager = new taskbuffer.TaskManager();
|
||||
const log: string[] = [];
|
||||
|
||||
const constraint = new taskbuffer.TaskConstraintGroup<{ key: string }>({
|
||||
name: 'error-drain',
|
||||
maxConcurrent: 1,
|
||||
constraintKeyForTask: (task) => task.data.key,
|
||||
});
|
||||
manager.addConstraintGroup(constraint);
|
||||
|
||||
const failTask = new taskbuffer.Task<undefined, [], { key: string }>({
|
||||
name: 'fail-task',
|
||||
data: { key: 'shared' },
|
||||
catchErrors: true,
|
||||
taskFunction: async () => {
|
||||
log.push('fail');
|
||||
throw new Error('intentional');
|
||||
},
|
||||
});
|
||||
|
||||
const successTask = new taskbuffer.Task<undefined, [], { key: string }>({
|
||||
name: 'success-task',
|
||||
data: { key: 'shared' },
|
||||
taskFunction: async () => {
|
||||
log.push('success');
|
||||
},
|
||||
});
|
||||
|
||||
manager.addTask(failTask);
|
||||
manager.addTask(successTask);
|
||||
|
||||
await Promise.all([
|
||||
manager.triggerTaskConstrained(failTask),
|
||||
manager.triggerTaskConstrained(successTask),
|
||||
]);
|
||||
|
||||
expect(log).toContain('fail');
|
||||
expect(log).toContain('success');
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
// Test 10: TaskManager integration — addConstraintGroup + triggerTaskByName
|
||||
tap.test('should route triggerTaskByName through constraints', async () => {
|
||||
const manager = new taskbuffer.TaskManager();
|
||||
let running = 0;
|
||||
let maxRunning = 0;
|
||||
|
||||
const constraint = new taskbuffer.TaskConstraintGroup({
|
||||
name: 'manager-integration',
|
||||
maxConcurrent: 1,
|
||||
constraintKeyForTask: () => 'all',
|
||||
});
|
||||
manager.addConstraintGroup(constraint);
|
||||
|
||||
const t1 = new taskbuffer.Task({
|
||||
name: 'managed-1',
|
||||
taskFunction: async () => {
|
||||
running++;
|
||||
maxRunning = Math.max(maxRunning, running);
|
||||
await smartdelay.delayFor(100);
|
||||
running--;
|
||||
},
|
||||
});
|
||||
|
||||
const t2 = new taskbuffer.Task({
|
||||
name: 'managed-2',
|
||||
taskFunction: async () => {
|
||||
running++;
|
||||
maxRunning = Math.max(maxRunning, running);
|
||||
await smartdelay.delayFor(100);
|
||||
running--;
|
||||
},
|
||||
});
|
||||
|
||||
manager.addTask(t1);
|
||||
manager.addTask(t2);
|
||||
|
||||
await Promise.all([
|
||||
manager.triggerTaskByName('managed-1'),
|
||||
manager.triggerTaskByName('managed-2'),
|
||||
]);
|
||||
|
||||
expect(maxRunning).toBeLessThanOrEqual(1);
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
// Test 11: removeConstraintGroup removes by name
|
||||
tap.test('should remove a constraint group by name', async () => {
|
||||
const manager = new taskbuffer.TaskManager();
|
||||
|
||||
const constraint = new taskbuffer.TaskConstraintGroup({
|
||||
name: 'removable',
|
||||
maxConcurrent: 1,
|
||||
constraintKeyForTask: () => 'all',
|
||||
});
|
||||
|
||||
manager.addConstraintGroup(constraint);
|
||||
expect(manager.constraintGroups.length).toEqual(1);
|
||||
|
||||
manager.removeConstraintGroup('removable');
|
||||
expect(manager.constraintGroups.length).toEqual(0);
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
// Test 12: TaskConstraintGroup reset clears state
|
||||
tap.test('should reset constraint group state', async () => {
|
||||
const constraint = new taskbuffer.TaskConstraintGroup({
|
||||
name: 'resettable',
|
||||
maxConcurrent: 2,
|
||||
cooldownMs: 1000,
|
||||
constraintKeyForTask: () => 'key',
|
||||
});
|
||||
|
||||
// Simulate usage
|
||||
constraint.acquireSlot('key');
|
||||
expect(constraint.getRunningCount('key')).toEqual(1);
|
||||
|
||||
constraint.releaseSlot('key');
|
||||
expect(constraint.getCooldownRemaining('key')).toBeGreaterThan(0);
|
||||
|
||||
constraint.reset();
|
||||
expect(constraint.getRunningCount('key')).toEqual(0);
|
||||
expect(constraint.getCooldownRemaining('key')).toEqual(0);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,34 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import * as taskbuffer from '../ts/index.js';
|
||||
|
||||
let testTaskRunner: taskbuffer.TaskRunner;
|
||||
|
||||
tap.test('should create a valid taskrunner', async () => {
|
||||
testTaskRunner = new taskbuffer.TaskRunner();
|
||||
await testTaskRunner.start();
|
||||
});
|
||||
|
||||
tap.test('should execute task when its scheduled', async (tools) => {
|
||||
const done = tools.defer();
|
||||
testTaskRunner.addTask(
|
||||
new taskbuffer.Task({
|
||||
taskFunction: async () => {
|
||||
console.log('hi');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
testTaskRunner.addTask(
|
||||
new taskbuffer.Task({
|
||||
taskFunction: async () => {
|
||||
console.log('there');
|
||||
done.resolve();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await done.promise;
|
||||
});
|
||||
|
||||
tap.start();
|
||||
Reference in New Issue
Block a user