BREAKING CHANGE(constraints): make TaskConstraintGroup constraint matcher input-aware and add shouldExecute pre-execution hook
This commit is contained in:
120
readme.md
120
readme.md
@@ -120,7 +120,7 @@ const manager = new TaskManager();
|
||||
const domainMutex = new TaskConstraintGroup<{ domain: string }>({
|
||||
name: 'domain-mutex',
|
||||
maxConcurrent: 1,
|
||||
constraintKeyForTask: (task) => task.data.domain,
|
||||
constraintKeyForExecution: (task, input?) => task.data.domain,
|
||||
});
|
||||
|
||||
manager.addConstraintGroup(domainMutex);
|
||||
@@ -156,7 +156,7 @@ Cap how many tasks can run concurrently across a group:
|
||||
const dnsLimit = new TaskConstraintGroup<{ group: string }>({
|
||||
name: 'dns-concurrency',
|
||||
maxConcurrent: 3,
|
||||
constraintKeyForTask: (task) =>
|
||||
constraintKeyForExecution: (task) =>
|
||||
task.data.group === 'dns' ? 'dns' : null, // null = skip constraint
|
||||
});
|
||||
|
||||
@@ -173,7 +173,7 @@ const rateLimiter = new TaskConstraintGroup<{ domain: string }>({
|
||||
name: 'api-rate-limit',
|
||||
maxConcurrent: 1,
|
||||
cooldownMs: 11000,
|
||||
constraintKeyForTask: (task) => task.data.domain,
|
||||
constraintKeyForExecution: (task) => task.data.domain,
|
||||
});
|
||||
|
||||
manager.addConstraintGroup(rateLimiter);
|
||||
@@ -187,7 +187,7 @@ Limit total concurrent tasks system-wide:
|
||||
const globalCap = new TaskConstraintGroup({
|
||||
name: 'global-cap',
|
||||
maxConcurrent: 10,
|
||||
constraintKeyForTask: () => 'all', // same key = shared limit
|
||||
constraintKeyForExecution: () => 'all', // same key = shared limit
|
||||
});
|
||||
|
||||
manager.addConstraintGroup(globalCap);
|
||||
@@ -208,26 +208,119 @@ await manager.triggerTask(dnsTask);
|
||||
|
||||
### Selective Constraints
|
||||
|
||||
Return `null` from `constraintKeyForTask` to exempt a task from a constraint group:
|
||||
Return `null` from `constraintKeyForExecution` to exempt a task from a constraint group:
|
||||
|
||||
```typescript
|
||||
const constraint = new TaskConstraintGroup<{ priority: string }>({
|
||||
name: 'low-priority-limit',
|
||||
maxConcurrent: 2,
|
||||
constraintKeyForTask: (task) =>
|
||||
constraintKeyForExecution: (task) =>
|
||||
task.data.priority === 'low' ? 'low-priority' : null, // high priority tasks skip this constraint
|
||||
});
|
||||
```
|
||||
|
||||
### Input-Aware Constraints 🎯
|
||||
|
||||
The `constraintKeyForExecution` function receives both the **task** and the **runtime input** passed to `trigger(input)`. This means the same task triggered with different inputs can be constrained independently:
|
||||
|
||||
```typescript
|
||||
const extractTLD = (domain: string) => {
|
||||
const parts = domain.split('.');
|
||||
return parts.slice(-2).join('.');
|
||||
};
|
||||
|
||||
// Same TLD → serialized. Different TLDs → parallel.
|
||||
const tldMutex = new TaskConstraintGroup({
|
||||
name: 'tld-mutex',
|
||||
maxConcurrent: 1,
|
||||
constraintKeyForExecution: (task, input?: string) => {
|
||||
if (!input) return null;
|
||||
return extractTLD(input); // "example.com", "other.org", etc.
|
||||
},
|
||||
});
|
||||
|
||||
manager.addConstraintGroup(tldMutex);
|
||||
|
||||
// These two serialize (same TLD "example.com")
|
||||
const p1 = manager.triggerTaskConstrained(getCert, 'app.example.com');
|
||||
const p2 = manager.triggerTaskConstrained(getCert, 'api.example.com');
|
||||
|
||||
// This runs in parallel (different TLD "other.org")
|
||||
const p3 = manager.triggerTaskConstrained(getCert, 'my.other.org');
|
||||
```
|
||||
|
||||
You can also combine `task.data` and `input` for composite keys:
|
||||
|
||||
```typescript
|
||||
const providerDomain = new TaskConstraintGroup<{ provider: string }>({
|
||||
name: 'provider-domain',
|
||||
maxConcurrent: 1,
|
||||
constraintKeyForExecution: (task, input?: string) => {
|
||||
return `${task.data.provider}:${input || 'default'}`;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Pre-Execution Check with `shouldExecute` ✅
|
||||
|
||||
The `shouldExecute` callback runs right before a queued task executes. If it returns `false`, the task is skipped and its promise resolves with `undefined`. This is perfect for scenarios where a prior execution's outcome makes subsequent queued tasks unnecessary:
|
||||
|
||||
```typescript
|
||||
const certCache = new Map<string, string>();
|
||||
|
||||
const certConstraint = new TaskConstraintGroup({
|
||||
name: 'cert-mutex',
|
||||
maxConcurrent: 1,
|
||||
constraintKeyForExecution: (task, input?: string) => {
|
||||
if (!input) return null;
|
||||
return extractTLD(input);
|
||||
},
|
||||
shouldExecute: (task, input?: string) => {
|
||||
if (!input) return true;
|
||||
// Skip if a wildcard cert already covers this TLD
|
||||
return certCache.get(extractTLD(input)) !== 'wildcard';
|
||||
},
|
||||
});
|
||||
|
||||
const getCert = new Task({
|
||||
name: 'get-certificate',
|
||||
taskFunction: async (domain: string) => {
|
||||
const cert = await acme.getCert(domain);
|
||||
if (cert.isWildcard) certCache.set(extractTLD(domain), 'wildcard');
|
||||
return cert;
|
||||
},
|
||||
});
|
||||
|
||||
manager.addConstraintGroup(certConstraint);
|
||||
manager.addTask(getCert);
|
||||
|
||||
const r1 = manager.triggerTaskConstrained(getCert, 'app.example.com'); // runs, gets wildcard
|
||||
const r2 = manager.triggerTaskConstrained(getCert, 'api.example.com'); // queued → skipped!
|
||||
const r3 = manager.triggerTaskConstrained(getCert, 'my.other.org'); // parallel (different TLD)
|
||||
|
||||
const [cert1, cert2, cert3] = await Promise.all([r1, r2, r3]);
|
||||
// cert2 === undefined (skipped because wildcard already covers example.com)
|
||||
```
|
||||
|
||||
**`shouldExecute` semantics:**
|
||||
|
||||
- Runs right before execution (after slot acquisition, before `trigger()`)
|
||||
- Also checked on immediate (non-queued) triggers
|
||||
- Returns `false` → skip execution, deferred resolves with `undefined`
|
||||
- Can be async (return `Promise<boolean>`)
|
||||
- Has closure access to external state modified by prior executions
|
||||
- If multiple constraint groups have `shouldExecute`, **all** must return `true`
|
||||
|
||||
### How It Works
|
||||
|
||||
When you trigger a task through `TaskManager` (via `triggerTask`, `triggerTaskByName`, `addExecuteRemoveTask`, or cron), the manager:
|
||||
|
||||
1. Evaluates all registered constraint groups against the task
|
||||
2. If no constraints apply (all matchers return `null`) → runs immediately
|
||||
3. If all applicable constraints have capacity → acquires slots and runs
|
||||
1. Evaluates all registered constraint groups against the task and input
|
||||
2. If no constraints apply (all matchers return `null`) → checks `shouldExecute` → runs or skips
|
||||
3. If all applicable constraints have capacity → acquires slots → checks `shouldExecute` → runs or skips
|
||||
4. If any constraint blocks → enqueues the task; when a running task completes, the queue is drained
|
||||
5. Cooldown-blocked tasks auto-retry after the shortest remaining cooldown expires
|
||||
6. Queued tasks re-check `shouldExecute` when their turn comes — stale work is automatically pruned
|
||||
|
||||
## 🎯 Core Concepts
|
||||
|
||||
@@ -732,7 +825,7 @@ const manager = new TaskManager();
|
||||
const tenantLimit = new TaskConstraintGroup<{ tenantId: string }>({
|
||||
name: 'tenant-concurrency',
|
||||
maxConcurrent: 2,
|
||||
constraintKeyForTask: (task) => task.data.tenantId,
|
||||
constraintKeyForExecution: (task, input?) => task.data.tenantId,
|
||||
});
|
||||
manager.addConstraintGroup(tenantLimit);
|
||||
|
||||
@@ -829,15 +922,17 @@ const acmeTasks = manager.getTasksMetadataByLabel('tenantId', 'acme');
|
||||
| Option | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `name` | `string` | *required* | Constraint group identifier |
|
||||
| `constraintKeyForTask` | `(task) => string \| null` | *required* | Returns key for grouping, or `null` to skip |
|
||||
| `constraintKeyForExecution` | `(task, input?) => string \| null` | *required* | Returns key for grouping, or `null` to skip. Receives both the task and runtime input. |
|
||||
| `maxConcurrent` | `number` | `Infinity` | Max concurrent tasks per key |
|
||||
| `cooldownMs` | `number` | `0` | Minimum ms between completions per key |
|
||||
| `shouldExecute` | `(task, input?) => boolean \| Promise<boolean>` | — | Pre-execution check. Return `false` to skip; deferred resolves `undefined`. |
|
||||
|
||||
### TaskConstraintGroup Methods
|
||||
|
||||
| Method | Returns | Description |
|
||||
| --- | --- | --- |
|
||||
| `getConstraintKey(task)` | `string \| null` | Get the constraint key for a task |
|
||||
| `getConstraintKey(task, input?)` | `string \| null` | Get the constraint key for a task + input |
|
||||
| `checkShouldExecute(task, input?)` | `Promise<boolean>` | Run the `shouldExecute` callback (defaults to `true`) |
|
||||
| `canRun(key)` | `boolean` | Check if a slot is available |
|
||||
| `acquireSlot(key)` | `void` | Claim a running slot |
|
||||
| `releaseSlot(key)` | `void` | Release a slot and record completion time |
|
||||
@@ -884,6 +979,7 @@ const acmeTasks = manager.getTasksMetadataByLabel('tenantId', 'acme');
|
||||
import type {
|
||||
ITaskMetadata,
|
||||
ITaskExecutionReport,
|
||||
ITaskExecution,
|
||||
IScheduledTaskInfo,
|
||||
ITaskEvent,
|
||||
TTaskEventType,
|
||||
|
||||
Reference in New Issue
Block a user