chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
+487
View File
@@ -0,0 +1,487 @@
# @trigger.dev/redis-worker
## 4.5.3
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.5.3`
## 4.5.2
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.5.2`
## 4.5.1
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.5.1`
## 4.5.0
### Patch Changes
- Pipeline the per-entry `HGETALL` fetches in `MollifierBuffer.listEntriesForEnv`. The previous serial implementation issued one Redis round-trip per runId returned by `LRANGE`, which dominated stale-sweep wall-time at any meaningful backlog (at the sweep's default maxCount=1000, this is ~1000 RTTs per env per pass). Behaviour is unchanged — entries are still skipped when the entry hash has been torn down by a concurrent drainer ack/fail between the LRANGE and the HGETALL. ([#3752](https://github.com/triggerdotdev/trigger.dev/pull/3752))
- Make mollifier buffer and drainer internals configurable. `MollifierBuffer` now accepts `ackGraceTtlSeconds`, `maxRetriesPerRequest`, `reconnectStepMs`, and `reconnectMaxMs` options, and `MollifierDrainer` accepts `maxBackoffMs` and `backoffFloorMs`. All default to their previous hardcoded values, so existing behaviour is unchanged. ([#3822](https://github.com/triggerdotdev/trigger.dev/pull/3822))
- `MollifierDrainer` accepts a `drainBatchSize` option (default 1) that controls how many entries are popped per env per tick — in-flight handlers remain capped by the global `concurrency`. `MollifierBuffer` also gains `getDrainingCount()` / `listStaleDraining()`, backed by a new `mollifier:draining` ZSET maintained atomically with pop/ack/fail/requeue (observability-only). ([#3797](https://github.com/triggerdotdev/trigger.dev/pull/3797))
- Add MollifierBuffer and MollifierDrainer primitives for trigger burst smoothing. ([#3614](https://github.com/triggerdotdev/trigger.dev/pull/3614))
MollifierBuffer (`accept`, `pop`, `ack`, `requeue`, `fail`, `evaluateTrip`) is a per-env FIFO over Redis with atomic Lua transitions for status tracking. `evaluateTrip` is a sliding-window trip evaluator the webapp gate uses to detect per-env trigger bursts.
MollifierDrainer pops entries through a polling loop with a user-supplied handler. The loop survives transient Redis errors via capped exponential backoff (up to 5s), and per-env pop failures don't poison the rest of the batch — one env's blip is logged and counted as failed for that tick. Rotation is two-level: orgs at the top, envs within each org. The buffer maintains `mollifier:orgs` and `mollifier:org-envs:${orgId}` atomically with per-env queues, so the drainer walks orgs → envs directly without an in-memory cache. The `maxOrgsPerTick` option (default 500) caps how many orgs are scheduled per tick; for each picked org, one env is popped (rotating round-robin within the org). An org with N envs gets the same per-tick scheduling slot as an org with 1 env, so tenant-level drainage throughput is determined by org count rather than env count.
- Mollifier `mutateSnapshot` now enforces a tag cap: an `append_tags` patch carrying `maxTags` returns `"limit_exceeded"` (writing nothing) when the deduped tag count would exceed the limit, so a buffered run can't accumulate more tags via the tags API than the trigger validator allows at creation. ([#3756](https://github.com/triggerdotdev/trigger.dev/pull/3756))
- Add a `redis_worker.queue.oldest_message_age` observable gauge (unit `ms`, labeled `worker_name`) reporting the age of the oldest overdue message in each queue. This is a generic queue-stall signal: it stays at 0 while a queue drains healthily and rises only when due work sits undrained (e.g. a blocked dequeue, a dead consumer, or backpressure), even when no items are being processed. Orphaned queue entries are resolved against the items hash so they don't report a phantom stall. Also exposes `SimpleQueue.oldestMessageAge()`. ([#4086](https://github.com/triggerdotdev/trigger.dev/pull/4086))
- Updated dependencies:
- `@trigger.dev/core@4.5.0`
## 4.5.0-rc.7
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.5.0-rc.7`
## 4.5.0-rc.6
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.5.0-rc.6`
## 4.5.0-rc.5
### Patch Changes
- Make mollifier buffer and drainer internals configurable. `MollifierBuffer` now accepts `ackGraceTtlSeconds`, `maxRetriesPerRequest`, `reconnectStepMs`, and `reconnectMaxMs` options, and `MollifierDrainer` accepts `maxBackoffMs` and `backoffFloorMs`. All default to their previous hardcoded values, so existing behaviour is unchanged. ([#3822](https://github.com/triggerdotdev/trigger.dev/pull/3822))
- `MollifierDrainer` accepts a `drainBatchSize` option (default 1) that controls how many entries are popped per env per tick — in-flight handlers remain capped by the global `concurrency`. `MollifierBuffer` also gains `getDrainingCount()` / `listStaleDraining()`, backed by a new `mollifier:draining` ZSET maintained atomically with pop/ack/fail/requeue (observability-only). ([#3797](https://github.com/triggerdotdev/trigger.dev/pull/3797))
- Updated dependencies:
- `@trigger.dev/core@4.5.0-rc.5`
## 4.5.0-rc.4
### Minor Changes
- Mollifier buffer extensions: idempotency dedup, an atomic `mutateSnapshot` API, metadata CAS, claim primitives, and a `MollifierSnapshot` type. The buffer's Redis client now reconnects with jittered backoff so a fleet of clients doesn't stampede Redis in lockstep after a blip. ([#3752](https://github.com/triggerdotdev/trigger.dev/pull/3752))
- Add `onTerminalFailure` callback to `MollifierDrainerOptions` so the customer's run lands a SYSTEM_FAILURE PG row even when the drainer exhausts `maxAttempts` on a retryable PG error. Previously, retryable-error exhaustion called `buffer.fail()` directly, which atomically marks FAILED + DELs the entry hash with no PG write — silent data loss when PG was unreachable across the full retry budget. The callback fires before `buffer.fail()` on any terminal path (`cause: "non-retryable"` or `"max-attempts-exhausted"`); throwing a retryable error from the callback causes the drainer to requeue rather than fail. ([#3754](https://github.com/triggerdotdev/trigger.dev/pull/3754))
### Patch Changes
- Pipeline the per-entry `HGETALL` fetches in `MollifierBuffer.listEntriesForEnv`. The previous serial implementation issued one Redis round-trip per runId returned by `LRANGE`, which dominated stale-sweep wall-time at any meaningful backlog (at the sweep's default maxCount=1000, this is ~1000 RTTs per env per pass). Behaviour is unchanged — entries are still skipped when the entry hash has been torn down by a concurrent drainer ack/fail between the LRANGE and the HGETALL. ([#3752](https://github.com/triggerdotdev/trigger.dev/pull/3752))
- Mollifier `mutateSnapshot` now enforces a tag cap: an `append_tags` patch carrying `maxTags` returns `"limit_exceeded"` (writing nothing) when the deduped tag count would exceed the limit, so a buffered run can't accumulate more tags via the tags API than the trigger validator allows at creation. ([#3756](https://github.com/triggerdotdev/trigger.dev/pull/3756))
- Updated dependencies:
- `@trigger.dev/core@4.5.0-rc.4`
## 4.5.0-rc.3
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.5.0-rc.3`
## 4.5.0-rc.2
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.5.0-rc.2`
## 4.5.0-rc.1
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.5.0-rc.1`
## 4.5.0-rc.0
### Patch Changes
- Add MollifierBuffer and MollifierDrainer primitives for trigger burst smoothing. ([#3614](https://github.com/triggerdotdev/trigger.dev/pull/3614))
MollifierBuffer (`accept`, `pop`, `ack`, `requeue`, `fail`, `evaluateTrip`) is a per-env FIFO over Redis with atomic Lua transitions for status tracking. `evaluateTrip` is a sliding-window trip evaluator the webapp gate uses to detect per-env trigger bursts.
MollifierDrainer pops entries through a polling loop with a user-supplied handler. The loop survives transient Redis errors via capped exponential backoff (up to 5s), and per-env pop failures don't poison the rest of the batch — one env's blip is logged and counted as failed for that tick. Rotation is two-level: orgs at the top, envs within each org. The buffer maintains `mollifier:orgs` and `mollifier:org-envs:${orgId}` atomically with per-env queues, so the drainer walks orgs → envs directly without an in-memory cache. The `maxOrgsPerTick` option (default 500) caps how many orgs are scheduled per tick; for each picked org, one env is popped (rotating round-robin within the org). An org with N envs gets the same per-tick scheduling slot as an org with 1 env, so tenant-level drainage throughput is determined by org count rather than env count.
- Updated dependencies:
- `@trigger.dev/core@4.5.0-rc.0`
## 4.4.6
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.4.6`
## 4.4.5
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.4.5`
## 4.4.4
### Patch Changes
- Adapted the CLI API client to propagate the trigger source via http headers. ([#3241](https://github.com/triggerdotdev/trigger.dev/pull/3241))
- Updated dependencies:
- `@trigger.dev/core@4.4.4`
## 4.4.3
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.4.3`
## 4.4.2
### Patch Changes
- Fix slow batch queue processing by removing spurious cooloff on concurrency blocks and fixing a race condition where retry attempt counts were not atomically updated during message re-queue. ([#3079](https://github.com/triggerdotdev/trigger.dev/pull/3079))
- Updated dependencies:
- `@trigger.dev/core@4.4.2`
## 4.4.1
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.4.1`
## 4.4.0
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.4.0`
## 4.3.3
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.3.3`
## 4.3.2
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.3.2`
## 4.3.1
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.3.1`
## 4.3.0
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.3.0`
## 4.2.0
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.2.0`
## 4.1.2
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.1.2`
## 4.1.1
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.1.1`
## 4.1.0
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.1.0`
## 4.0.7
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.7`
## 4.0.6
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.6`
## 4.0.5
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.5`
## 4.0.4
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.4`
## 4.0.3
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.3`
## 4.0.2
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.2`
## 4.0.1
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.1`
## 4.0.0
### Major Changes
- Trigger.dev v4 release. Please see our upgrade to v4 docs to view the full changelog: https://trigger.dev/docs/upgrade-to-v4 ([#1869](https://github.com/triggerdotdev/trigger.dev/pull/1869))
### Patch Changes
- Now each worker gets it's own pLimit concurrency limiter, and we will only ever dequeue items where there is concurrency capacity, preventing incorrectly retried jobs due to visibility timeout expiry ([#2235](https://github.com/triggerdotdev/trigger.dev/pull/2235))
- Updated dependencies:
- `@trigger.dev/core@4.0.0`
## 4.0.0-v4-beta.28
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.28`
## 4.0.0-v4-beta.27
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.27`
## 4.0.0-v4-beta.26
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.26`
## 4.0.0-v4-beta.25
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.25`
## 4.0.0-v4-beta.24
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.24`
## 4.0.0-v4-beta.23
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.23`
## 4.0.0-v4-beta.22
### Patch Changes
- Now each worker gets it's own pLimit concurrency limiter, and we will only ever dequeue items where there is concurrency capacity, preventing incorrectly retried jobs due to visibility timeout expiry ([#2235](https://github.com/triggerdotdev/trigger.dev/pull/2235))
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.22`
## 4.0.0-v4-beta.21
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.21`
## 4.0.0-v4-beta.20
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.20`
## 4.0.0-v4-beta.19
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.19`
## 4.0.0-v4-beta.18
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.18`
## 4.0.0-v4-beta.17
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.17`
## 4.0.0-v4-beta.16
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.16`
## 4.0.0-v4-beta.15
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.15`
## 4.0.0-v4-beta.14
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.14`
## 4.0.0-v4-beta.13
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.13`
## 4.0.0-v4-beta.12
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.12`
## 4.0.0-v4-beta.11
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.11`
## 4.0.0-v4-beta.10
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.10`
## 4.0.0-v4-beta.9
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.9`
## 4.0.0-v4-beta.8
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.8`
## 4.0.0-v4-beta.7
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.7`
## 4.0.0-v4-beta.6
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.6`
## 4.0.0-v4-beta.5
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.5`
## 4.0.0-v4-beta.4
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.4`
## 4.0.0-v4-beta.3
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.3`
## 4.0.0-v4-beta.2
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.2`
## 4.0.0-v4-beta.1
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.1`
## 4.0.0-v4-beta.0
### Major Changes
- Trigger.dev v4 release. Please see our upgrade to v4 docs to view the full changelog: https://trigger.dev/docs/upgrade-to-v4 ([#1869](https://github.com/triggerdotdev/trigger.dev/pull/1869))
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.0.0-v4-beta.0`
+19
View File
@@ -0,0 +1,19 @@
# Redis Worker
`@trigger.dev/redis-worker` - custom Redis-based background job system. **This replaces graphile-worker/zodworker** for all new background job needs.
## Key Files
- `src/worker.ts` - Worker loop and job processing with concurrency control
- `src/queue.ts` - Redis-backed job queue abstraction
- `src/fair-queue/` - Fair dequeueing algorithm for queue selection
## Usage
Used by the webapp for background jobs (alerting, batch processing, common tasks) and by the run engine for TTL expiration and batch operations.
All new background jobs in the webapp should use redis-worker. Do NOT add new jobs to zodworker (`@internal/zodworker`) or graphile-worker.
## Testing
Uses ioredis. Tests use testcontainers for Redis.
+10
View File
@@ -0,0 +1,10 @@
# Redis worker
This is a simple worker that pulls tasks from a Redis queue (also in this package).
Features
- Configurable settings for concurrency and pull speed.
- Job payloads.
- A schema so only defined jobs can be added to the queue.
- The ability to have future dates for jobs.
+57
View File
@@ -0,0 +1,57 @@
{
"name": "@trigger.dev/redis-worker",
"version": "4.5.3",
"description": "Redis worker for trigger.dev",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/triggerdotdev/trigger.dev",
"directory": "packages/redis-worker"
},
"type": "module",
"files": [
"dist"
],
"scripts": {
"clean": "rimraf dist .turbo",
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit -p tsconfig.src.json",
"test": "vitest --sequence.concurrent=false --no-file-parallelism"
},
"dependencies": {
"@trigger.dev/core": "workspace:4.5.3",
"lodash.omit": "^4.5.0",
"nanoid": "^5.0.7",
"p-limit": "^6.2.0",
"seedrandom": "^3.0.5",
"zod": "3.25.76",
"cron-parser": "^4.9.0"
},
"devDependencies": {
"@internal/redis": "workspace:*",
"@internal/testcontainers": "workspace:*",
"@internal/tracing": "workspace:*",
"@types/lodash.omit": "^4.5.7",
"@types/seedrandom": "^3.0.8",
"rimraf": "6.0.1",
"tsup": "^8.4.0",
"tsx": "4.17.0"
},
"engines": {
"node": ">=18.20.0"
},
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
+130
View File
@@ -0,0 +1,130 @@
import { redisTest } from "@internal/testcontainers";
import { Logger } from "@trigger.dev/core/logger";
import { describe } from "node:test";
import { expect } from "vitest";
import { Worker, CronSchema } from "./worker.js";
import { setTimeout } from "node:timers/promises";
describe("Worker with cron", () => {
redisTest(
"process items on the cron schedule",
{ timeout: 180_000 },
async ({ redisContainer }) => {
const processedItems: CronSchema[] = [];
const worker = new Worker({
name: "test-worker",
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
catalog: {
cronJob: {
cron: "*/5 * * * * *", // Every 5 seconds
schema: CronSchema,
visibilityTimeoutMs: 5000,
retry: { maxAttempts: 3 },
jitterInMs: 100,
},
},
jobs: {
cronJob: async ({ payload }) => {
await setTimeout(30); // Simulate work
processedItems.push(payload);
},
},
concurrency: {
workers: 2,
tasksPerWorker: 3,
},
logger: new Logger("test", "debug"),
}).start();
await setTimeout(6_000);
expect(processedItems.length).toBeGreaterThanOrEqual(1);
const firstItem = processedItems[0];
expect(firstItem?.timestamp).toBeGreaterThan(0);
expect(firstItem?.lastTimestamp).toBeUndefined();
expect(firstItem?.cron).toBe("*/5 * * * * *");
await setTimeout(6_000);
expect(processedItems.length).toBeGreaterThanOrEqual(2);
const secondItem = processedItems[1];
expect(secondItem?.timestamp).toBeGreaterThan(firstItem!.timestamp);
expect(secondItem?.lastTimestamp).toBe(firstItem?.timestamp);
expect(secondItem?.cron).toBe("*/5 * * * * *");
await worker.stop();
}
);
redisTest(
"continues processing cron items even when job handler throws errors",
{ timeout: 180_000 },
async ({ redisContainer }) => {
const processedItems: CronSchema[] = [];
let executionCount = 0;
const worker = new Worker({
name: "test-worker-error",
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
catalog: {
cronJob: {
cron: "*/3 * * * * *", // Every 3 seconds
schema: CronSchema,
visibilityTimeoutMs: 5000,
retry: { maxAttempts: 1 }, // Only try once to fail faster
jitterInMs: 100,
},
},
jobs: {
cronJob: async ({ payload }) => {
executionCount++;
await setTimeout(30); // Simulate work
// Throw error on first and third execution
if (executionCount === 1 || executionCount === 3) {
throw new Error(`Simulated error on execution ${executionCount}`);
}
processedItems.push(payload);
},
},
concurrency: {
workers: 2,
tasksPerWorker: 3,
},
logger: new Logger("test", "debug"),
}).start();
// Wait long enough for 4 executions (12 seconds + buffer)
await setTimeout(14_000);
// Should have at least 4 executions total
expect(executionCount).toBeGreaterThanOrEqual(4);
// Should have 2 successful items (executions 2 and 4)
expect(processedItems.length).toBeGreaterThanOrEqual(2);
// Verify that some executions failed (execution count > successful count)
// This proves that errors occurred but cron scheduling continued
expect(executionCount).toBeGreaterThan(processedItems.length);
// Verify that successful executions still have correct structure
const firstSuccessful = processedItems[0];
expect(firstSuccessful?.timestamp).toBeGreaterThan(0);
expect(firstSuccessful?.cron).toBe("*/3 * * * * *");
await worker.stop();
}
);
});
@@ -0,0 +1,304 @@
import { createRedisClient, type Redis, type RedisOptions } from "@internal/redis";
import type {
ConcurrencyCheckResult,
ConcurrencyGroupConfig,
ConcurrencyState,
FairQueueKeyProducer,
QueueDescriptor,
} from "./types.js";
export interface ConcurrencyManagerOptions {
redis: RedisOptions;
keys: FairQueueKeyProducer;
groups: ConcurrencyGroupConfig[];
}
/**
* ConcurrencyManager handles multi-level concurrency tracking and limiting.
*
* Features:
* - Multiple concurrent concurrency groups (tenant, org, project, etc.)
* - Atomic reserve/release operations using Lua scripts
* - Efficient batch checking of all groups
*/
export class ConcurrencyManager {
private redis: Redis;
private keys: FairQueueKeyProducer;
private groups: ConcurrencyGroupConfig[];
private groupsByName: Map<string, ConcurrencyGroupConfig>;
constructor(private options: ConcurrencyManagerOptions) {
this.redis = createRedisClient(options.redis);
this.keys = options.keys;
this.groups = options.groups;
this.groupsByName = new Map(options.groups.map((g) => [g.name, g]));
this.#registerCommands();
}
// ============================================================================
// Public Methods
// ============================================================================
/**
* Check if a message can be processed given all concurrency constraints.
* Checks all configured groups and returns the first one at capacity.
*/
async canProcess(queue: QueueDescriptor): Promise<ConcurrencyCheckResult> {
for (const group of this.groups) {
const groupId = group.extractGroupId(queue);
const isAtCapacity = await this.isAtCapacity(group.name, groupId);
if (isAtCapacity) {
const state = await this.getState(group.name, groupId);
return {
allowed: false,
blockedBy: state,
};
}
}
return { allowed: true };
}
/**
* Reserve concurrency slots for a message across all groups.
* Atomic - either all groups are reserved or none.
*
* @returns true if reservation successful, false if any group is at capacity
*/
async reserve(queue: QueueDescriptor, messageId: string): Promise<boolean> {
// Build list of group keys and limits
const groupData = await Promise.all(
this.groups.map(async (group) => {
const groupId = group.extractGroupId(queue);
const limit = await group.getLimit(groupId);
return {
key: this.keys.concurrencyKey(group.name, groupId),
limit: limit || group.defaultLimit,
};
})
);
// Use Lua script for atomic multi-group reservation
// Pass keys as KEYS array so ioredis applies keyPrefix correctly
const keys = groupData.map((g) => g.key);
const limits = groupData.map((g) => g.limit.toString());
// Args order: messageId, ...limits (keys are passed separately)
const result = await this.redis.reserveConcurrency(keys.length, keys, messageId, ...limits);
return result === 1;
}
/**
* Release concurrency slots for a message across all groups.
*/
async release(queue: QueueDescriptor, messageId: string): Promise<void> {
const pipeline = this.redis.pipeline();
for (const group of this.groups) {
const groupId = group.extractGroupId(queue);
const key = this.keys.concurrencyKey(group.name, groupId);
pipeline.srem(key, messageId);
}
await pipeline.exec();
}
/**
* Release concurrency slots for multiple messages in a single pipeline.
* More efficient than calling release() multiple times.
*/
async releaseBatch(
messages: Array<{ queue: QueueDescriptor; messageId: string }>
): Promise<void> {
if (messages.length === 0) {
return;
}
const pipeline = this.redis.pipeline();
for (const { queue, messageId } of messages) {
for (const group of this.groups) {
const groupId = group.extractGroupId(queue);
const key = this.keys.concurrencyKey(group.name, groupId);
pipeline.srem(key, messageId);
}
}
await pipeline.exec();
}
/**
* Get current concurrency for a specific group.
*/
async getCurrentConcurrency(groupName: string, groupId: string): Promise<number> {
const key = this.keys.concurrencyKey(groupName, groupId);
return await this.redis.scard(key);
}
/**
* Get available capacity for a queue across all concurrency groups.
* Returns the minimum available capacity across all groups.
*/
async getAvailableCapacity(queue: QueueDescriptor): Promise<number> {
if (this.groups.length === 0) {
return 0;
}
// Build group data for parallel fetching
const groupData = this.groups.map((group) => ({
group,
groupId: group.extractGroupId(queue),
}));
// Fetch all current counts and limits in parallel
const [currents, limits] = await Promise.all([
Promise.all(
groupData.map(({ group, groupId }) =>
this.redis.scard(this.keys.concurrencyKey(group.name, groupId))
)
),
Promise.all(
groupData.map(({ group, groupId }) =>
group.getLimit(groupId).then((limit) => limit || group.defaultLimit)
)
),
]);
// Calculate minimum available capacity across all groups
let minCapacity = Infinity;
for (let i = 0; i < groupData.length; i++) {
const available = Math.max(0, limits[i]! - currents[i]!);
minCapacity = Math.min(minCapacity, available);
}
return minCapacity === Infinity ? 0 : minCapacity;
}
/**
* Get concurrency limit for a specific group.
*/
async getConcurrencyLimit(groupName: string, groupId: string): Promise<number> {
const group = this.groupsByName.get(groupName);
if (!group) {
throw new Error(`Unknown concurrency group: ${groupName}`);
}
return (await group.getLimit(groupId)) || group.defaultLimit;
}
/**
* Check if a group is at capacity.
*/
async isAtCapacity(groupName: string, groupId: string): Promise<boolean> {
const [current, limit] = await Promise.all([
this.getCurrentConcurrency(groupName, groupId),
this.getConcurrencyLimit(groupName, groupId),
]);
return current >= limit;
}
/**
* Get full state for a group.
*/
async getState(groupName: string, groupId: string): Promise<ConcurrencyState> {
const [current, limit] = await Promise.all([
this.getCurrentConcurrency(groupName, groupId),
this.getConcurrencyLimit(groupName, groupId),
]);
return {
groupName,
groupId,
current,
limit,
};
}
/**
* Get all active message IDs for a group.
*/
async getActiveMessages(groupName: string, groupId: string): Promise<string[]> {
const key = this.keys.concurrencyKey(groupName, groupId);
return await this.redis.smembers(key);
}
/**
* Force-clear concurrency for a group (use with caution).
* Useful for cleanup after crashes.
*/
async clearGroup(groupName: string, groupId: string): Promise<void> {
const key = this.keys.concurrencyKey(groupName, groupId);
await this.redis.del(key);
}
/**
* Remove a specific message from concurrency tracking.
* Useful for cleanup.
*/
async removeMessage(messageId: string, queue: QueueDescriptor): Promise<void> {
await this.release(queue, messageId);
}
/**
* Get configured group names.
*/
getGroupNames(): string[] {
return this.groups.map((g) => g.name);
}
/**
* Close the Redis connection.
*/
async close(): Promise<void> {
await this.redis.quit();
}
// ============================================================================
// Private Methods
// ============================================================================
#registerCommands(): void {
// Atomic multi-group reservation
// KEYS: concurrency set keys for each group (keyPrefix is applied by ioredis)
// ARGV[1]: messageId
// ARGV[2..n]: limits for each group (in same order as KEYS)
this.redis.defineCommand("reserveConcurrency", {
lua: `
local numGroups = #KEYS
local messageId = ARGV[1]
-- Check all groups first
for i = 1, numGroups do
local key = KEYS[i]
local limit = tonumber(ARGV[1 + i]) -- Limits start at ARGV[2]
local current = redis.call('SCARD', key)
if current >= limit then
return 0 -- At capacity
end
end
-- All groups have capacity, add message to all
for i = 1, numGroups do
local key = KEYS[i]
redis.call('SADD', key, messageId)
end
return 1
`,
});
}
}
// Extend Redis interface for custom commands
declare module "@internal/redis" {
interface RedisCommander<Context> {
reserveConcurrency(
numKeys: number,
keys: string[],
messageId: string,
...limits: string[]
): Promise<number>;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,175 @@
import type { FairQueueKeyProducer } from "./types.js";
/**
* Default key producer for the fair queue system.
* Uses a configurable prefix and standard key structure.
*
* Key structure:
* - Master queue: {prefix}:master:{shardId} (legacy, drain-only)
* - Dispatch index: {prefix}:dispatch:{shardId} (Level 1: tenantIds)
* - Tenant queue index: {prefix}:tenantq:{tenantId} (Level 2: queueIds)
* - Queue: {prefix}:queue:{queueId}
* - Queue items: {prefix}:queue:{queueId}:items
* - Concurrency: {prefix}:concurrency:{groupName}:{groupId}
* - In-flight: {prefix}:inflight:{shardId}
* - In-flight data: {prefix}:inflight:{shardId}:data
* - Worker queue: {prefix}:worker:{consumerId}
*/
export class DefaultFairQueueKeyProducer implements FairQueueKeyProducer {
private readonly prefix: string;
private readonly separator: string;
constructor(options: { prefix?: string; separator?: string } = {}) {
this.prefix = options.prefix ?? "fq";
this.separator = options.separator ?? ":";
}
// ============================================================================
// Master Queue Keys
// ============================================================================
masterQueueKey(shardId: number): string {
return this.#buildKey("master", shardId.toString());
}
// ============================================================================
// Queue Keys
// ============================================================================
queueKey(queueId: string): string {
return this.#buildKey("queue", queueId);
}
queueItemsKey(queueId: string): string {
return this.#buildKey("queue", queueId, "items");
}
// ============================================================================
// Concurrency Keys
// ============================================================================
concurrencyKey(groupName: string, groupId: string): string {
return this.#buildKey("concurrency", groupName, groupId);
}
// ============================================================================
// In-Flight Keys
// ============================================================================
inflightKey(shardId: number): string {
return this.#buildKey("inflight", shardId.toString());
}
inflightDataKey(shardId: number): string {
return this.#buildKey("inflight", shardId.toString(), "data");
}
// ============================================================================
// Worker Queue Keys
// ============================================================================
workerQueueKey(consumerId: string): string {
return this.#buildKey("worker", consumerId);
}
// ============================================================================
// Tenant Dispatch Keys (Two-Level Index)
// ============================================================================
dispatchKey(shardId: number): string {
return this.#buildKey("dispatch", shardId.toString());
}
tenantQueueIndexKey(tenantId: string): string {
return this.#buildKey("tenantq", tenantId);
}
// ============================================================================
// Dead Letter Queue Keys
// ============================================================================
deadLetterQueueKey(tenantId: string): string {
return this.#buildKey("dlq", tenantId);
}
deadLetterQueueDataKey(tenantId: string): string {
return this.#buildKey("dlq", tenantId, "data");
}
// ============================================================================
// Extraction Methods
// ============================================================================
/**
* Extract tenant ID from a queue ID.
* Default implementation assumes queue IDs are formatted as: tenant:{tenantId}:...
* Override this method for custom queue ID formats.
*/
extractTenantId(queueId: string): string {
const parts = queueId.split(this.separator);
// Expect format: tenant:{tenantId}:...
if (parts.length >= 2 && parts[0] === "tenant" && parts[1]) {
return parts[1];
}
// Fallback: return the first segment
return parts[0] ?? "";
}
/**
* Extract a group ID from a queue ID.
* Default implementation looks for pattern: {groupName}:{groupId}:...
* Override this method for custom queue ID formats.
*/
extractGroupId(groupName: string, queueId: string): string {
const parts = queueId.split(this.separator);
// Look for the group name in the queue ID parts
for (let i = 0; i < parts.length - 1; i++) {
if (parts[i] === groupName) {
const nextPart = parts[i + 1];
if (nextPart) {
return nextPart;
}
}
}
// Fallback: return an empty string
return "";
}
// ============================================================================
// Helper Methods
// ============================================================================
#buildKey(...parts: string[]): string {
return [this.prefix, ...parts].join(this.separator);
}
}
/**
* Key producer with custom extraction logic via callbacks.
* Useful when queue IDs don't follow a standard pattern.
*/
export class CallbackFairQueueKeyProducer extends DefaultFairQueueKeyProducer {
private readonly tenantExtractor: (queueId: string) => string;
private readonly groupExtractor: (groupName: string, queueId: string) => string;
constructor(options: {
prefix?: string;
separator?: string;
extractTenantId: (queueId: string) => string;
extractGroupId: (groupName: string, queueId: string) => string;
}) {
super({ prefix: options.prefix, separator: options.separator });
this.tenantExtractor = options.extractTenantId;
this.groupExtractor = options.extractGroupId;
}
override extractTenantId(queueId: string): string {
return this.tenantExtractor(queueId);
}
override extractGroupId(groupName: string, queueId: string): string {
return this.groupExtractor(groupName, queueId);
}
}
@@ -0,0 +1,257 @@
import { createRedisClient, type Redis, type RedisOptions } from "@internal/redis";
import { jumpHash } from "@trigger.dev/core/v3/serverOnly";
import type { FairQueueKeyProducer, QueueWithScore } from "./types.js";
export interface MasterQueueOptions {
redis: RedisOptions;
keys: FairQueueKeyProducer;
shardCount: number;
}
/**
* Master queue manages the top-level queue of queues.
*
* Features:
* - Sharding for horizontal scaling
* - Consistent hashing for queue-to-shard assignment
* - Queues scored by oldest message timestamp
*/
export class MasterQueue {
private redis: Redis;
private keys: FairQueueKeyProducer;
private shardCount: number;
constructor(private options: MasterQueueOptions) {
this.redis = createRedisClient(options.redis);
this.keys = options.keys;
this.shardCount = Math.max(1, options.shardCount);
this.#registerCommands();
}
// ============================================================================
// Public Methods
// ============================================================================
/**
* Get the shard ID for a queue.
* Uses consistent hashing based on queue ID.
*/
getShardForQueue(queueId: string): number {
return this.#hashToShard(queueId);
}
/**
* Add a queue to its master queue shard.
* Updates the score to the oldest message timestamp.
*
* @param queueId - The queue identifier
* @param oldestMessageTimestamp - Timestamp of the oldest message in the queue
*/
async addQueue(queueId: string, oldestMessageTimestamp: number): Promise<void> {
const shardId = this.getShardForQueue(queueId);
const masterKey = this.keys.masterQueueKey(shardId);
// Just use plain ZADD - it will add if not exists, or update if exists
// The score represents the oldest message timestamp
// We rely on the enqueue Lua scripts to set the correct score
await this.redis.zadd(masterKey, oldestMessageTimestamp, queueId);
}
/**
* Update a queue's score in the master queue.
* This is typically called after dequeuing to update to the new oldest message.
*
* @param queueId - The queue identifier
* @param newOldestTimestamp - New timestamp of the oldest message
*/
async updateQueueScore(queueId: string, newOldestTimestamp: number): Promise<void> {
const shardId = this.getShardForQueue(queueId);
const masterKey = this.keys.masterQueueKey(shardId);
await this.redis.zadd(masterKey, newOldestTimestamp, queueId);
}
/**
* Remove a queue from its master queue shard.
* Called when a queue becomes empty.
*
* @param queueId - The queue identifier
*/
async removeQueue(queueId: string): Promise<void> {
const shardId = this.getShardForQueue(queueId);
const masterKey = this.keys.masterQueueKey(shardId);
await this.redis.zrem(masterKey, queueId);
}
/**
* Get queues from a shard, ordered by oldest message (lowest score first).
*
* @param shardId - The shard to query
* @param limit - Maximum number of queues to return (default: 1000)
* @param maxScore - Maximum score (timestamp) to include (default: now)
*/
async getQueuesFromShard(
shardId: number,
limit: number = 1000,
maxScore?: number
): Promise<QueueWithScore[]> {
const masterKey = this.keys.masterQueueKey(shardId);
const score = maxScore ?? Date.now();
// Get queues with scores up to maxScore
const results = await this.redis.zrangebyscore(
masterKey,
"-inf",
score,
"WITHSCORES",
"LIMIT",
0,
limit
);
const queues: QueueWithScore[] = [];
for (let i = 0; i < results.length; i += 2) {
const queueId = results[i];
const scoreStr = results[i + 1];
if (queueId && scoreStr) {
queues.push({
queueId,
score: parseFloat(scoreStr),
tenantId: this.keys.extractTenantId(queueId),
});
}
}
return queues;
}
/**
* Get the number of queues in a shard.
*/
async getShardQueueCount(shardId: number): Promise<number> {
const masterKey = this.keys.masterQueueKey(shardId);
return await this.redis.zcard(masterKey);
}
/**
* Get total queue count across all shards.
*/
async getTotalQueueCount(): Promise<number> {
const counts = await Promise.all(
Array.from({ length: this.shardCount }, (_, i) => this.getShardQueueCount(i))
);
return counts.reduce((sum, count) => sum + count, 0);
}
/**
* Atomically add a queue to master queue only if queue has messages.
* Uses Lua script for atomicity.
*
* @param queueId - The queue identifier
* @param queueKey - The actual queue sorted set key
* @returns Whether the queue was added to the master queue
*/
async addQueueIfNotEmpty(queueId: string, queueKey: string): Promise<boolean> {
const shardId = this.getShardForQueue(queueId);
const masterKey = this.keys.masterQueueKey(shardId);
const result = await this.redis.addQueueIfNotEmpty(masterKey, queueKey, queueId);
return result === 1;
}
/**
* Atomically remove a queue from master queue only if queue is empty.
* Uses Lua script for atomicity.
*
* @param queueId - The queue identifier
* @param queueKey - The actual queue sorted set key
* @returns Whether the queue was removed from the master queue
*/
async removeQueueIfEmpty(queueId: string, queueKey: string): Promise<boolean> {
const shardId = this.getShardForQueue(queueId);
const masterKey = this.keys.masterQueueKey(shardId);
const result = await this.redis.removeQueueIfEmpty(masterKey, queueKey, queueId);
return result === 1;
}
/**
* Close the Redis connection.
*/
async close(): Promise<void> {
await this.redis.quit();
}
// ============================================================================
// Private Methods
// ============================================================================
/**
* Map queue ID to shard using Jump Consistent Hash.
* Provides better distribution than djb2 and minimal reshuffling when shard count changes.
*/
#hashToShard(queueId: string): number {
return jumpHash(queueId, this.shardCount);
}
#registerCommands(): void {
// Atomically add queue to master if it has messages
this.redis.defineCommand("addQueueIfNotEmpty", {
numberOfKeys: 2,
lua: `
local masterKey = KEYS[1]
local queueKey = KEYS[2]
local queueId = ARGV[1]
-- Check if queue has any messages
local count = redis.call('ZCARD', queueKey)
if count == 0 then
return 0
end
-- Get the oldest message timestamp (lowest score)
local oldest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES')
if #oldest == 0 then
return 0
end
local score = oldest[2]
-- Add to master queue with the oldest message score
redis.call('ZADD', masterKey, score, queueId)
return 1
`,
});
// Atomically remove queue from master if it's empty
this.redis.defineCommand("removeQueueIfEmpty", {
numberOfKeys: 2,
lua: `
local masterKey = KEYS[1]
local queueKey = KEYS[2]
local queueId = ARGV[1]
-- Check if queue is empty
local count = redis.call('ZCARD', queueKey)
if count > 0 then
return 0
end
-- Remove from master queue
redis.call('ZREM', masterKey, queueId)
return 1
`,
});
}
}
// Extend Redis interface for custom commands
declare module "@internal/redis" {
interface RedisCommander<Context> {
addQueueIfNotEmpty(masterKey: string, queueKey: string, queueId: string): Promise<number>;
removeQueueIfEmpty(masterKey: string, queueKey: string, queueId: string): Promise<number>;
}
}
@@ -0,0 +1,173 @@
import { calculateNextRetryDelay } from "@trigger.dev/core/v3";
import type { RetryOptions } from "@trigger.dev/core/v3/schemas";
/**
* RetryStrategy interface for pluggable retry logic.
*/
export interface RetryStrategy {
/**
* Calculate the next retry delay in milliseconds.
* Return null to indicate the message should be sent to DLQ.
*
* @param attempt - Current attempt number (1-indexed)
* @param error - Optional error from the failed attempt
* @returns Delay in milliseconds, or null to send to DLQ
*/
getNextDelay(attempt: number, error?: Error): number | null;
/**
* Maximum number of attempts before moving to DLQ.
*/
maxAttempts: number;
}
/**
* Exponential backoff retry strategy.
*
* Uses the same algorithm as @trigger.dev/core's calculateNextRetryDelay.
*/
export class ExponentialBackoffRetry implements RetryStrategy {
readonly maxAttempts: number;
private options: RetryOptions;
constructor(options?: Partial<RetryOptions>) {
this.options = {
maxAttempts: options?.maxAttempts ?? 12,
factor: options?.factor ?? 2,
minTimeoutInMs: options?.minTimeoutInMs ?? 1_000,
maxTimeoutInMs: options?.maxTimeoutInMs ?? 3_600_000, // 1 hour
randomize: options?.randomize ?? true,
};
this.maxAttempts = this.options.maxAttempts ?? 12;
}
getNextDelay(attempt: number, _error?: Error): number | null {
if (attempt >= this.maxAttempts) {
return null; // Send to DLQ
}
const delay = calculateNextRetryDelay(this.options, attempt);
return delay ?? null;
}
}
/**
* Fixed delay retry strategy.
*
* Always waits the same amount of time between retries.
*/
export class FixedDelayRetry implements RetryStrategy {
readonly maxAttempts: number;
private delayMs: number;
constructor(options: { maxAttempts: number; delayMs: number }) {
this.maxAttempts = options.maxAttempts;
this.delayMs = options.delayMs;
}
getNextDelay(attempt: number, _error?: Error): number | null {
if (attempt >= this.maxAttempts) {
return null; // Send to DLQ
}
return this.delayMs;
}
}
/**
* Linear backoff retry strategy.
*
* Delay increases linearly with each attempt.
*/
export class LinearBackoffRetry implements RetryStrategy {
readonly maxAttempts: number;
private baseDelayMs: number;
private maxDelayMs: number;
constructor(options: { maxAttempts: number; baseDelayMs: number; maxDelayMs?: number }) {
this.maxAttempts = options.maxAttempts;
this.baseDelayMs = options.baseDelayMs;
this.maxDelayMs = options.maxDelayMs ?? options.baseDelayMs * options.maxAttempts;
}
getNextDelay(attempt: number, _error?: Error): number | null {
if (attempt >= this.maxAttempts) {
return null; // Send to DLQ
}
const delay = this.baseDelayMs * attempt;
return Math.min(delay, this.maxDelayMs);
}
}
/**
* No retry strategy.
*
* Messages go directly to DLQ on first failure.
*/
export class NoRetry implements RetryStrategy {
readonly maxAttempts = 1;
getNextDelay(_attempt: number, _error?: Error): number | null {
return null; // Always send to DLQ
}
}
/**
* Immediate retry strategy.
*
* Retries immediately without any delay.
*/
export class ImmediateRetry implements RetryStrategy {
readonly maxAttempts: number;
constructor(maxAttempts: number) {
this.maxAttempts = maxAttempts;
}
getNextDelay(attempt: number, _error?: Error): number | null {
if (attempt >= this.maxAttempts) {
return null; // Send to DLQ
}
return 0; // Immediate retry
}
}
/**
* Custom retry strategy that uses a user-provided function.
*/
export class CustomRetry implements RetryStrategy {
readonly maxAttempts: number;
private calculateDelay: (attempt: number, error?: Error) => number | null;
constructor(options: {
maxAttempts: number;
calculateDelay: (attempt: number, error?: Error) => number | null;
}) {
this.maxAttempts = options.maxAttempts;
this.calculateDelay = options.calculateDelay;
}
getNextDelay(attempt: number, error?: Error): number | null {
if (attempt >= this.maxAttempts) {
return null;
}
return this.calculateDelay(attempt, error);
}
}
/**
* Default retry options matching @trigger.dev/core defaults.
*/
export const defaultRetryOptions: RetryOptions = {
maxAttempts: 12,
factor: 2,
minTimeoutInMs: 1_000,
maxTimeoutInMs: 3_600_000,
randomize: true,
};
/**
* Create an exponential backoff retry strategy with default options.
*/
export function createDefaultRetryStrategy(): RetryStrategy {
return new ExponentialBackoffRetry(defaultRetryOptions);
}
@@ -0,0 +1,116 @@
import type { FairScheduler, SchedulerContext, TenantQueues } from "./types.js";
/**
* Re-export scheduler types for convenience.
*/
export type { FairScheduler, SchedulerContext, TenantQueues };
/**
* Base class for scheduler implementations.
* Provides common utilities and default implementations.
*/
export abstract class BaseScheduler implements FairScheduler {
/**
* Select queues for processing from a master queue shard.
* Must be implemented by subclasses.
*/
abstract selectQueues(
masterQueueShard: string,
consumerId: string,
context: SchedulerContext
): Promise<TenantQueues[]>;
/**
* Called after processing a message to update scheduler state.
* Default implementation does nothing.
*/
async recordProcessed(_tenantId: string, _queueId: string): Promise<void> {
// Default: no state tracking
}
/**
* Called after processing multiple messages to update scheduler state.
* Batch variant for efficiency - reduces Redis calls when processing multiple messages.
* Default implementation does nothing.
*/
async recordProcessedBatch(_tenantId: string, _queueId: string, _count: number): Promise<void> {
// Default: no state tracking
}
/**
* Initialize the scheduler.
* Default implementation does nothing.
*/
async initialize(): Promise<void> {
// Default: no initialization needed
}
/**
* Cleanup scheduler resources.
* Default implementation does nothing.
*/
async close(): Promise<void> {
// Default: no cleanup needed
}
/**
* Helper to group queues by tenant.
*/
protected groupQueuesByTenant(
queues: Array<{ queueId: string; tenantId: string }>
): Map<string, string[]> {
const grouped = new Map<string, string[]>();
for (const { queueId, tenantId } of queues) {
const existing = grouped.get(tenantId) ?? [];
existing.push(queueId);
grouped.set(tenantId, existing);
}
return grouped;
}
/**
* Helper to convert grouped queues to TenantQueues array.
*/
protected toTenantQueuesArray(grouped: Map<string, string[]>): TenantQueues[] {
return Array.from(grouped.entries()).map(([tenantId, queues]) => ({
tenantId,
queues,
}));
}
/**
* Helper to filter out tenants at capacity.
*/
protected async filterAtCapacity(
tenants: TenantQueues[],
context: SchedulerContext,
groupName: string = "tenant"
): Promise<TenantQueues[]> {
const filtered: TenantQueues[] = [];
for (const tenant of tenants) {
const isAtCapacity = await context.isAtCapacity(groupName, tenant.tenantId);
if (!isAtCapacity) {
filtered.push(tenant);
}
}
return filtered;
}
}
/**
* Simple noop scheduler that returns empty results.
* Useful for testing or disabling scheduling.
*/
export class NoopScheduler extends BaseScheduler {
async selectQueues(
_masterQueueShard: string,
_consumerId: string,
_context: SchedulerContext
): Promise<TenantQueues[]> {
return [];
}
}
@@ -0,0 +1,454 @@
import { createRedisClient, type Redis } from "@internal/redis";
import { BaseScheduler } from "../scheduler.js";
import type {
DRRSchedulerConfig,
DispatchSchedulerContext,
FairQueueKeyProducer,
SchedulerContext,
TenantQueues,
QueueWithScore,
} from "../types.js";
/**
* Deficit Round Robin (DRR) Scheduler.
*
* DRR ensures fair processing across tenants by:
* - Allocating a "quantum" of credits to each tenant per round
* - Accumulating unused credits as "deficit"
* - Processing from tenants with available deficit
* - Capping deficit to prevent starvation
*
* Key improvements over basic implementations:
* - Atomic deficit operations using Lua scripts
* - Efficient iteration through tenants
* - Automatic deficit cleanup for inactive tenants
*/
export class DRRScheduler extends BaseScheduler {
private redis: Redis;
private keys: FairQueueKeyProducer;
private quantum: number;
private maxDeficit: number;
private masterQueueLimit: number;
private logger: NonNullable<DRRSchedulerConfig["logger"]>;
constructor(private config: DRRSchedulerConfig) {
super();
this.redis = createRedisClient(config.redis);
this.keys = config.keys;
this.quantum = config.quantum;
this.maxDeficit = config.maxDeficit;
this.masterQueueLimit = config.masterQueueLimit ?? 1000;
this.logger = config.logger ?? {
debug: () => {},
error: () => {},
};
this.#registerCommands();
}
// ============================================================================
// FairScheduler Implementation
// ============================================================================
/**
* Select queues for processing using DRR algorithm.
*
* Algorithm:
* 1. Get all queues from the master shard
* 2. Group by tenant
* 3. Filter out tenants at concurrency capacity
* 4. Add quantum to each tenant's deficit (atomically)
* 5. Select queues from tenants with deficit >= 1
* 6. Order tenants by deficit (highest first for fairness)
*/
async selectQueues(
masterQueueShard: string,
consumerId: string,
context: SchedulerContext
): Promise<TenantQueues[]> {
// Get all queues from the master shard
const queues = await this.#getQueuesFromShard(masterQueueShard);
if (queues.length === 0) {
return [];
}
// Group queues by tenant
const queuesByTenant = this.groupQueuesByTenant(
queues.map((q) => ({ queueId: q.queueId, tenantId: q.tenantId }))
);
// Get unique tenant IDs
const tenantIds = Array.from(queuesByTenant.keys());
// Add quantum to all active tenants atomically
const deficits = await this.#addQuantumToTenants(tenantIds);
// Build tenant data with deficits
const tenantData: Array<{
tenantId: string;
deficit: number;
queues: string[];
isAtCapacity: boolean;
}> = await Promise.all(
tenantIds.map(async (tenantId, index) => {
const isAtCapacity = await context.isAtCapacity("tenant", tenantId);
return {
tenantId,
deficit: deficits[index] ?? 0,
queues: queuesByTenant.get(tenantId) ?? [],
isAtCapacity,
};
})
);
// Filter out tenants at capacity or with no deficit
const eligibleTenants = tenantData.filter((t) => !t.isAtCapacity && t.deficit >= 1);
// Log tenants blocked by capacity
const blockedTenants = tenantData.filter((t) => t.isAtCapacity);
if (blockedTenants.length > 0) {
this.logger.debug("DRR: tenants blocked by concurrency", {
blockedCount: blockedTenants.length,
blockedTenants: blockedTenants.map((t) => t.tenantId),
});
}
// Sort by deficit (highest first for fairness)
eligibleTenants.sort((a, b) => b.deficit - a.deficit);
this.logger.debug("DRR: queue selection complete", {
totalQueues: queues.length,
totalTenants: tenantIds.length,
eligibleTenants: eligibleTenants.length,
topTenantDeficit: eligibleTenants[0]?.deficit,
});
// Convert to TenantQueues format
return eligibleTenants.map((t) => ({
tenantId: t.tenantId,
queues: t.queues,
}));
}
/**
* Select queues using the two-level tenant dispatch index.
*
* Algorithm:
* 1. ZRANGEBYSCORE on dispatch index (gets only tenants with queues - much smaller)
* 2. Add quantum to each tenant's deficit (atomically)
* 3. Check capacity as safety net (dispatch should only have tenants with capacity)
* 4. Select tenants with deficit >= 1, sorted by deficit (highest first)
* 5. For each tenant, fetch their queues from Level 2 index
*/
async selectQueuesFromDispatch(
dispatchShardKey: string,
consumerId: string,
context: DispatchSchedulerContext
): Promise<TenantQueues[]> {
// Level 1: Get tenants from dispatch index
const tenants = await this.#getTenantsFromDispatch(dispatchShardKey);
if (tenants.length === 0) {
return [];
}
const tenantIds = tenants.map((t) => t.tenantId);
// Add quantum to all active tenants atomically (1 Lua call)
const deficits = await this.#addQuantumToTenants(tenantIds);
// Build candidates sorted by deficit (highest first)
const candidates = tenantIds
.map((tenantId, index) => ({ tenantId, deficit: deficits[index] ?? 0 }))
.filter((t) => t.deficit >= 1);
candidates.sort((a, b) => b.deficit - a.deficit);
// Pick the first tenant with available capacity and fetch their queues.
// This keeps the scheduler cheap: O(1) in the common case where the
// highest-deficit tenant has capacity. The consumer loop iterates fast
// (1ms yield between rounds) so we cycle through tenants quickly.
for (const { tenantId, deficit } of candidates) {
const isAtCapacity = await context.isAtCapacity("tenant", tenantId);
if (isAtCapacity) continue;
// Limit queues fetched to what the tenant can actually process this round.
// deficit = max messages this tenant should process, so no point fetching
// more queues than that (each queue yields at least 1 message).
const queueLimit = Math.ceil(deficit);
const queues = await context.getQueuesForTenant(tenantId, queueLimit);
if (queues.length > 0) {
this.logger.debug("DRR dispatch: selected tenant", {
dispatchTenants: tenants.length,
candidates: candidates.length,
selectedTenant: tenantId,
deficit,
queueLimit,
queuesReturned: queues.length,
});
return [{ tenantId, queues: queues.map((q) => q.queueId) }];
}
}
return [];
}
/**
* Record that a message was processed from a tenant.
* Decrements the tenant's deficit.
*/
override async recordProcessed(tenantId: string, _queueId: string): Promise<void> {
await this.#decrementDeficit(tenantId);
}
/**
* Record that multiple messages were processed from a tenant.
* Decrements the tenant's deficit by count atomically.
*/
override async recordProcessedBatch(
tenantId: string,
_queueId: string,
count: number
): Promise<void> {
await this.#decrementDeficitBatch(tenantId, count);
}
override async close(): Promise<void> {
await this.redis.quit();
}
// ============================================================================
// Public Methods for Deficit Management
// ============================================================================
/**
* Get the current deficit for a tenant.
*/
async getDeficit(tenantId: string): Promise<number> {
const key = this.#deficitKey();
const value = await this.redis.hget(key, tenantId);
return value ? parseFloat(value) : 0;
}
/**
* Reset deficit for a tenant.
* Used when a tenant has no more active queues.
*/
async resetDeficit(tenantId: string): Promise<void> {
const key = this.#deficitKey();
await this.redis.hdel(key, tenantId);
}
/**
* Get all tenant deficits.
*/
async getAllDeficits(): Promise<Map<string, number>> {
const key = this.#deficitKey();
const data = await this.redis.hgetall(key);
const result = new Map<string, number>();
for (const [tenantId, value] of Object.entries(data)) {
result.set(tenantId, parseFloat(value));
}
return result;
}
// ============================================================================
// Private Methods
// ============================================================================
#deficitKey(): string {
// Use a fixed key for DRR deficit tracking
return `${this.keys.masterQueueKey(0).split(":")[0]}:drr:deficit`;
}
async #getTenantsFromDispatch(
dispatchKey: string
): Promise<Array<{ tenantId: string; score: number }>> {
const now = Date.now();
const results = await this.redis.zrangebyscore(
dispatchKey,
"-inf",
now,
"WITHSCORES",
"LIMIT",
0,
this.masterQueueLimit
);
const tenants: Array<{ tenantId: string; score: number }> = [];
for (let i = 0; i < results.length; i += 2) {
const tenantId = results[i];
const scoreStr = results[i + 1];
if (tenantId && scoreStr) {
tenants.push({
tenantId,
score: parseFloat(scoreStr),
});
}
}
return tenants;
}
async #getQueuesFromShard(shardKey: string): Promise<QueueWithScore[]> {
const now = Date.now();
const results = await this.redis.zrangebyscore(
shardKey,
"-inf",
now,
"WITHSCORES",
"LIMIT",
0,
this.masterQueueLimit
);
const queues: QueueWithScore[] = [];
for (let i = 0; i < results.length; i += 2) {
const queueId = results[i];
const scoreStr = results[i + 1];
if (queueId && scoreStr) {
queues.push({
queueId,
score: parseFloat(scoreStr),
tenantId: this.keys.extractTenantId(queueId),
});
}
}
return queues;
}
/**
* Add quantum to multiple tenants atomically.
* Returns the new deficit values.
*/
async #addQuantumToTenants(tenantIds: string[]): Promise<number[]> {
if (tenantIds.length === 0) {
return [];
}
const key = this.#deficitKey();
// Use Lua script for atomic quantum addition with capping
const results = await this.redis.drrAddQuantum(
key,
this.quantum.toString(),
this.maxDeficit.toString(),
...tenantIds
);
return results.map((r) => parseFloat(r));
}
/**
* Decrement deficit for a tenant atomically.
*/
async #decrementDeficit(tenantId: string): Promise<number> {
const key = this.#deficitKey();
// Use Lua script to decrement and ensure non-negative
const result = await this.redis.drrDecrementDeficit(key, tenantId);
return parseFloat(result);
}
/**
* Decrement deficit for a tenant by a count atomically.
*/
async #decrementDeficitBatch(tenantId: string, count: number): Promise<number> {
const key = this.#deficitKey();
// Use Lua script to decrement by count and ensure non-negative
const result = await this.redis.drrDecrementDeficitBatch(key, tenantId, count.toString());
return parseFloat(result);
}
#registerCommands(): void {
// Atomic quantum addition with capping for multiple tenants
this.redis.defineCommand("drrAddQuantum", {
numberOfKeys: 1,
lua: `
local deficitKey = KEYS[1]
local quantum = tonumber(ARGV[1])
local maxDeficit = tonumber(ARGV[2])
local results = {}
for i = 3, #ARGV do
local tenantId = ARGV[i]
-- Add quantum to deficit
local newDeficit = redis.call('HINCRBYFLOAT', deficitKey, tenantId, quantum)
newDeficit = tonumber(newDeficit)
-- Cap at maxDeficit
if newDeficit > maxDeficit then
redis.call('HSET', deficitKey, tenantId, maxDeficit)
newDeficit = maxDeficit
end
table.insert(results, tostring(newDeficit))
end
return results
`,
});
// Atomic deficit decrement with floor at 0
this.redis.defineCommand("drrDecrementDeficit", {
numberOfKeys: 1,
lua: `
local deficitKey = KEYS[1]
local tenantId = ARGV[1]
local newDeficit = redis.call('HINCRBYFLOAT', deficitKey, tenantId, -1)
newDeficit = tonumber(newDeficit)
-- Floor at 0
if newDeficit < 0 then
redis.call('HSET', deficitKey, tenantId, 0)
newDeficit = 0
end
return tostring(newDeficit)
`,
});
// Atomic deficit decrement by count with floor at 0
this.redis.defineCommand("drrDecrementDeficitBatch", {
numberOfKeys: 1,
lua: `
local deficitKey = KEYS[1]
local tenantId = ARGV[1]
local count = tonumber(ARGV[2])
local newDeficit = redis.call('HINCRBYFLOAT', deficitKey, tenantId, -count)
newDeficit = tonumber(newDeficit)
-- Floor at 0
if newDeficit < 0 then
redis.call('HSET', deficitKey, tenantId, 0)
newDeficit = 0
end
return tostring(newDeficit)
`,
});
}
}
// Extend Redis interface for custom commands
declare module "@internal/redis" {
interface RedisCommander<Context> {
drrAddQuantum(
deficitKey: string,
quantum: string,
maxDeficit: string,
...tenantIds: string[]
): Promise<string[]>;
drrDecrementDeficit(deficitKey: string, tenantId: string): Promise<string>;
drrDecrementDeficitBatch(deficitKey: string, tenantId: string, count: string): Promise<string>;
}
}
@@ -0,0 +1,7 @@
/**
* Scheduler implementations for the fair queue system.
*/
export { DRRScheduler } from "./drr.js";
export { WeightedScheduler } from "./weighted.js";
export { RoundRobinScheduler } from "./roundRobin.js";
@@ -0,0 +1,156 @@
import { createRedisClient, type Redis, type RedisOptions } from "@internal/redis";
import { BaseScheduler } from "../scheduler.js";
import type {
FairQueueKeyProducer,
SchedulerContext,
TenantQueues,
QueueWithScore,
} from "../types.js";
export interface RoundRobinSchedulerConfig {
redis: RedisOptions;
keys: FairQueueKeyProducer;
/** Maximum queues to fetch from master queue per iteration */
masterQueueLimit?: number;
}
/**
* Round Robin Scheduler.
*
* Simple scheduler that processes tenants in strict rotation order.
* Maintains a "last served" pointer in Redis to track position.
*
* Features:
* - Predictable ordering (good for debugging)
* - Fair rotation through all tenants
* - No weighting or bias
*/
export class RoundRobinScheduler extends BaseScheduler {
private redis: Redis;
private keys: FairQueueKeyProducer;
private masterQueueLimit: number;
constructor(private config: RoundRobinSchedulerConfig) {
super();
this.redis = createRedisClient(config.redis);
this.keys = config.keys;
this.masterQueueLimit = config.masterQueueLimit ?? 1000;
}
// ============================================================================
// FairScheduler Implementation
// ============================================================================
async selectQueues(
masterQueueShard: string,
consumerId: string,
context: SchedulerContext
): Promise<TenantQueues[]> {
const now = Date.now();
// Get all queues from master shard
const queues = await this.#getQueuesFromShard(masterQueueShard, now);
if (queues.length === 0) {
return [];
}
// Group queues by tenant
const queuesByTenant = new Map<string, string[]>();
const tenantOrder: string[] = [];
for (const queue of queues) {
if (!queuesByTenant.has(queue.tenantId)) {
queuesByTenant.set(queue.tenantId, []);
tenantOrder.push(queue.tenantId);
}
queuesByTenant.get(queue.tenantId)!.push(queue.queueId);
}
// Get last served index
const lastServedIndex = await this.#getLastServedIndex(masterQueueShard);
// Rotate tenant order based on last served
const rotatedTenants = this.#rotateArray(tenantOrder, lastServedIndex);
// Filter out tenants at capacity
const eligibleTenants: TenantQueues[] = [];
for (const tenantId of rotatedTenants) {
const isAtCapacity = await context.isAtCapacity("tenant", tenantId);
if (!isAtCapacity) {
const tenantQueues = queuesByTenant.get(tenantId) ?? [];
// Sort queues by age (oldest first based on original scores)
eligibleTenants.push({
tenantId,
queues: tenantQueues,
});
}
}
// Update last served index to the first eligible tenant
const firstEligible = eligibleTenants[0];
if (firstEligible) {
const firstTenantIndex = tenantOrder.indexOf(firstEligible.tenantId);
await this.#setLastServedIndex(masterQueueShard, firstTenantIndex + 1);
}
return eligibleTenants;
}
override async close(): Promise<void> {
await this.redis.quit();
}
// ============================================================================
// Private Methods
// ============================================================================
async #getQueuesFromShard(shardKey: string, maxScore: number): Promise<QueueWithScore[]> {
const results = await this.redis.zrangebyscore(
shardKey,
"-inf",
maxScore,
"WITHSCORES",
"LIMIT",
0,
this.masterQueueLimit
);
const queues: QueueWithScore[] = [];
for (let i = 0; i < results.length; i += 2) {
const queueId = results[i];
const scoreStr = results[i + 1];
if (queueId && scoreStr) {
queues.push({
queueId,
score: parseFloat(scoreStr),
tenantId: this.keys.extractTenantId(queueId),
});
}
}
return queues;
}
#lastServedKey(shardKey: string): string {
return `${shardKey}:rr:lastServed`;
}
async #getLastServedIndex(shardKey: string): Promise<number> {
const key = this.#lastServedKey(shardKey);
const value = await this.redis.get(key);
return value ? parseInt(value, 10) : 0;
}
async #setLastServedIndex(shardKey: string, index: number): Promise<void> {
const key = this.#lastServedKey(shardKey);
await this.redis.set(key, index.toString());
}
#rotateArray<T>(array: T[], startIndex: number): T[] {
if (array.length === 0) return [];
const normalizedIndex = startIndex % array.length;
return [...array.slice(normalizedIndex), ...array.slice(0, normalizedIndex)];
}
}
@@ -0,0 +1,429 @@
import { createRedisClient, type Redis } from "@internal/redis";
import seedrandom from "seedrandom";
import { BaseScheduler } from "../scheduler.js";
import type {
FairQueueKeyProducer,
SchedulerContext,
TenantQueues,
QueueWithScore,
WeightedSchedulerBiases,
WeightedSchedulerConfig,
} from "../types.js";
interface TenantConcurrency {
current: number;
limit: number;
}
interface TenantSnapshot {
tenantId: string;
concurrency: TenantConcurrency;
queues: Array<{ queueId: string; age: number }>;
}
interface QueueSnapshot {
id: string;
tenants: Map<string, TenantSnapshot>;
queues: Array<{ queueId: string; tenantId: string; age: number }>;
}
const defaultBiases: WeightedSchedulerBiases = {
concurrencyLimitBias: 0,
availableCapacityBias: 0,
queueAgeRandomization: 0,
};
/**
* Weighted Shuffle Scheduler.
*
* Uses weighted random selection to balance between:
* - Concurrency limit (higher limits get more weight)
* - Available capacity (tenants with more capacity get more weight)
* - Queue age (older queues get priority, with configurable randomization)
*
* Features:
* - Snapshot caching to reduce Redis calls
* - Configurable biases for fine-tuning
* - Maximum tenant count to limit iteration
*/
export class WeightedScheduler extends BaseScheduler {
private redis: Redis;
private keys: FairQueueKeyProducer;
private rng: seedrandom.PRNG;
private biases: WeightedSchedulerBiases;
private defaultTenantLimit: number;
private masterQueueLimit: number;
private reuseSnapshotCount: number;
private maximumTenantCount: number;
// Snapshot cache
private snapshotCache: Map<string, { snapshot: QueueSnapshot; reuseCount: number }> = new Map();
constructor(private config: WeightedSchedulerConfig) {
super();
this.redis = createRedisClient(config.redis);
this.keys = config.keys;
this.rng = seedrandom(config.seed);
this.biases = config.biases ?? defaultBiases;
this.defaultTenantLimit = config.defaultTenantConcurrencyLimit ?? 100;
this.masterQueueLimit = config.masterQueueLimit ?? 100;
this.reuseSnapshotCount = config.reuseSnapshotCount ?? 0;
this.maximumTenantCount = config.maximumTenantCount ?? 0;
}
// ============================================================================
// FairScheduler Implementation
// ============================================================================
async selectQueues(
masterQueueShard: string,
consumerId: string,
context: SchedulerContext
): Promise<TenantQueues[]> {
const snapshot = await this.#getOrCreateSnapshot(masterQueueShard, consumerId, context);
if (snapshot.queues.length === 0) {
return [];
}
// Shuffle tenants based on weights
const shuffledTenants = this.#shuffleTenantsByWeight(snapshot);
// Order queues within each tenant
return shuffledTenants.map((tenantId) => ({
tenantId,
queues: this.#orderQueuesForTenant(snapshot, tenantId),
}));
}
override async close(): Promise<void> {
this.snapshotCache.clear();
await this.redis.quit();
}
// ============================================================================
// Private Methods
// ============================================================================
async #getOrCreateSnapshot(
masterQueueShard: string,
consumerId: string,
context: SchedulerContext
): Promise<QueueSnapshot> {
const cacheKey = `${masterQueueShard}:${consumerId}`;
// Check cache
if (this.reuseSnapshotCount > 0) {
const cached = this.snapshotCache.get(cacheKey);
if (cached && cached.reuseCount < this.reuseSnapshotCount) {
this.snapshotCache.set(cacheKey, {
snapshot: cached.snapshot,
reuseCount: cached.reuseCount + 1,
});
return cached.snapshot;
}
}
// Create new snapshot
const snapshot = await this.#createSnapshot(masterQueueShard, context);
// Cache if enabled
if (this.reuseSnapshotCount > 0) {
this.snapshotCache.set(cacheKey, { snapshot, reuseCount: 0 });
}
return snapshot;
}
async #createSnapshot(
masterQueueShard: string,
context: SchedulerContext
): Promise<QueueSnapshot> {
const now = Date.now();
// Get queues from master shard
let rawQueues = await this.#getQueuesFromShard(masterQueueShard, now);
if (rawQueues.length === 0) {
return { id: crypto.randomUUID(), tenants: new Map(), queues: [] };
}
// Apply maximum tenant count if configured
if (this.maximumTenantCount > 0) {
rawQueues = this.#selectTopTenantQueues(rawQueues);
}
// Build tenant data
const tenantIds = new Set<string>();
const queuesByTenant = new Map<string, Array<{ queueId: string; age: number }>>();
for (const queue of rawQueues) {
tenantIds.add(queue.tenantId);
const tenantQueues = queuesByTenant.get(queue.tenantId) ?? [];
tenantQueues.push({
queueId: queue.queueId,
age: now - queue.score,
});
queuesByTenant.set(queue.tenantId, tenantQueues);
}
// Get concurrency for each tenant
const tenants = new Map<string, TenantSnapshot>();
for (const tenantId of tenantIds) {
const [current, limit] = await Promise.all([
context.getCurrentConcurrency("tenant", tenantId),
context.getConcurrencyLimit("tenant", tenantId),
]);
// Skip tenants at capacity
if (current >= limit) {
continue;
}
tenants.set(tenantId, {
tenantId,
concurrency: { current, limit },
queues: queuesByTenant.get(tenantId) ?? [],
});
}
// Build final queue list (only from non-capacity tenants)
const queues = rawQueues
.filter((q) => tenants.has(q.tenantId))
.map((q) => ({
queueId: q.queueId,
tenantId: q.tenantId,
age: now - q.score,
}));
return {
id: crypto.randomUUID(),
tenants,
queues,
};
}
async #getQueuesFromShard(shardKey: string, maxScore: number): Promise<QueueWithScore[]> {
const results = await this.redis.zrangebyscore(
shardKey,
"-inf",
maxScore,
"WITHSCORES",
"LIMIT",
0,
this.masterQueueLimit
);
const queues: QueueWithScore[] = [];
for (let i = 0; i < results.length; i += 2) {
const queueId = results[i];
const scoreStr = results[i + 1];
if (queueId && scoreStr) {
queues.push({
queueId,
score: parseFloat(scoreStr),
tenantId: this.keys.extractTenantId(queueId),
});
}
}
return queues;
}
#selectTopTenantQueues(queues: QueueWithScore[]): QueueWithScore[] {
// Group by tenant and calculate average age
const queuesByTenant = new Map<string, QueueWithScore[]>();
for (const queue of queues) {
const tenantQueues = queuesByTenant.get(queue.tenantId) ?? [];
tenantQueues.push(queue);
queuesByTenant.set(queue.tenantId, tenantQueues);
}
// Calculate average age per tenant
const tenantAges = Array.from(queuesByTenant.entries()).map(([tenantId, tQueues]) => {
const avgAge = tQueues.reduce((sum, q) => sum + q.score, 0) / tQueues.length;
return { tenantId, avgAge };
});
// Weighted shuffle to select top N tenants
const maxAge = Math.max(...tenantAges.map((t) => t.avgAge));
// Guard against division by zero: if maxAge is 0, assign equal weights
const weightedTenants =
maxAge === 0
? tenantAges.map((t) => ({
tenantId: t.tenantId,
weight: 1 / tenantAges.length,
}))
: tenantAges.map((t) => ({
tenantId: t.tenantId,
weight: t.avgAge / maxAge,
}));
const selectedTenants = new Set<string>();
let remaining = [...weightedTenants];
let totalWeight = remaining.reduce((sum, t) => sum + t.weight, 0);
while (selectedTenants.size < this.maximumTenantCount && remaining.length > 0) {
let random = this.rng() * totalWeight;
let index = 0;
while (random > 0 && index < remaining.length) {
const item = remaining[index];
if (item) {
random -= item.weight;
}
index++;
}
index = Math.max(0, index - 1);
const selected = remaining[index];
if (selected) {
selectedTenants.add(selected.tenantId);
totalWeight -= selected.weight;
remaining.splice(index, 1);
}
}
// Return queues only from selected tenants
return queues.filter((q) => selectedTenants.has(q.tenantId));
}
#shuffleTenantsByWeight(snapshot: QueueSnapshot): string[] {
const tenantIds = Array.from(snapshot.tenants.keys());
if (tenantIds.length === 0) {
return [];
}
const { concurrencyLimitBias, availableCapacityBias } = this.biases;
// If no biases, do simple shuffle
if (concurrencyLimitBias === 0 && availableCapacityBias === 0) {
return this.#shuffle(tenantIds);
}
// Calculate weights
const maxLimit = Math.max(
...tenantIds.map((id) => snapshot.tenants.get(id)!.concurrency.limit)
);
const weightedTenants = tenantIds.map((tenantId) => {
const tenant = snapshot.tenants.get(tenantId)!;
let weight = 1;
// Concurrency limit bias
if (concurrencyLimitBias > 0) {
// Guard against division by zero: if maxLimit is 0, treat normalizedLimit as 0
const normalizedLimit = maxLimit > 0 ? tenant.concurrency.limit / maxLimit : 0;
weight *= 1 + Math.pow(normalizedLimit * concurrencyLimitBias, 2);
}
// Available capacity bias
if (availableCapacityBias > 0) {
// Guard against division by zero: if limit is 0, treat as fully used (no bonus)
const usedPercentage =
tenant.concurrency.limit > 0 ? tenant.concurrency.current / tenant.concurrency.limit : 1;
const availableBonus = 1 - usedPercentage;
weight *= 1 + Math.pow(availableBonus * availableCapacityBias, 2);
}
return { tenantId, weight };
});
return this.#weightedShuffle(weightedTenants);
}
#orderQueuesForTenant(snapshot: QueueSnapshot, tenantId: string): string[] {
const tenant = snapshot.tenants.get(tenantId);
if (!tenant || tenant.queues.length === 0) {
return [];
}
const queues = [...tenant.queues];
const { queueAgeRandomization } = this.biases;
// Strict age-based ordering
if (queueAgeRandomization === 0) {
return queues.sort((a, b) => b.age - a.age).map((q) => q.queueId);
}
// Weighted random based on age
const maxAge = Math.max(...queues.map((q) => q.age));
// Guard against division by zero: if maxAge is 0, all queues have equal weight
const ageDenom = maxAge === 0 ? 1 : maxAge;
const weightedQueues = queues.map((q) => ({
queue: q,
weight: 1 + (q.age / ageDenom) * queueAgeRandomization,
}));
const result: string[] = [];
let remaining = [...weightedQueues];
let totalWeight = remaining.reduce((sum, q) => sum + q.weight, 0);
while (remaining.length > 0) {
let random = this.rng() * totalWeight;
let index = 0;
while (random > 0 && index < remaining.length) {
const item = remaining[index];
if (item) {
random -= item.weight;
}
index++;
}
index = Math.max(0, index - 1);
const selected = remaining[index];
if (selected) {
result.push(selected.queue.queueId);
totalWeight -= selected.weight;
remaining.splice(index, 1);
}
}
return result;
}
#shuffle<T>(array: T[]): T[] {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(this.rng() * (i + 1));
const temp = result[i];
const swapValue = result[j];
if (temp !== undefined && swapValue !== undefined) {
result[i] = swapValue;
result[j] = temp;
}
}
return result;
}
#weightedShuffle(items: Array<{ tenantId: string; weight: number }>): string[] {
const result: string[] = [];
let remaining = [...items];
let totalWeight = remaining.reduce((sum, item) => sum + item.weight, 0);
while (remaining.length > 0) {
let random = this.rng() * totalWeight;
let index = 0;
while (random > 0 && index < remaining.length) {
const item = remaining[index];
if (item) {
random -= item.weight;
}
index++;
}
index = Math.max(0, index - 1);
const selected = remaining[index];
if (selected) {
result.push(selected.tenantId);
totalWeight -= selected.weight;
remaining.splice(index, 1);
}
}
return result;
}
}
@@ -0,0 +1,765 @@
import type {
Attributes,
Counter,
Histogram,
Meter,
ObservableGauge,
Span,
SpanKind,
SpanOptions,
Tracer,
Context,
} from "@internal/tracing";
import { context, trace, SpanStatusCode, ROOT_CONTEXT } from "@internal/tracing";
/**
* Semantic attributes for fair queue messaging operations.
*/
export const FairQueueAttributes = {
QUEUE_ID: "fairqueue.queue_id",
TENANT_ID: "fairqueue.tenant_id",
MESSAGE_ID: "fairqueue.message_id",
SHARD_ID: "fairqueue.shard_id",
WORKER_QUEUE: "fairqueue.worker_queue",
CONSUMER_ID: "fairqueue.consumer_id",
ATTEMPT: "fairqueue.attempt",
CONCURRENCY_GROUP: "fairqueue.concurrency_group",
MESSAGE_COUNT: "fairqueue.message_count",
RESULT: "fairqueue.result",
} as const;
/**
* Standard messaging semantic attributes.
*/
export const MessagingAttributes = {
SYSTEM: "messaging.system",
OPERATION: "messaging.operation",
MESSAGE_ID: "messaging.message_id",
DESTINATION_NAME: "messaging.destination.name",
} as const;
/**
* FairQueue metrics collection.
*/
export interface FairQueueMetrics {
// Counters
messagesEnqueued: Counter;
messagesCompleted: Counter;
messagesFailed: Counter;
messagesRetried: Counter;
messagesToDLQ: Counter;
// Histograms
processingTime: Histogram;
queueTime: Histogram;
// Observable gauges (registered with callbacks)
queueLength: ObservableGauge;
masterQueueLength: ObservableGauge;
dispatchLength: ObservableGauge;
inflightCount: ObservableGauge;
dlqLength: ObservableGauge;
}
/**
* Options for creating FairQueue telemetry.
*/
export interface TelemetryOptions {
tracer?: Tracer;
meter?: Meter;
/** Custom name for metrics prefix */
name?: string;
}
/**
* Telemetry helper for FairQueue.
*
* Provides:
* - Span creation with proper attributes
* - Metric recording
* - Context propagation helpers
*/
export class FairQueueTelemetry {
private tracer?: Tracer;
private meter?: Meter;
private metrics?: FairQueueMetrics;
private name: string;
constructor(options: TelemetryOptions) {
this.tracer = options.tracer;
this.meter = options.meter;
this.name = options.name ?? "fairqueue";
if (this.meter) {
this.#initializeMetrics();
}
}
// ============================================================================
// Tracing
// ============================================================================
/**
* Create a traced span for an operation.
* Returns the result of the function, or throws any error after recording it.
*/
async trace<T>(
name: string,
fn: (span: Span) => Promise<T>,
options?: {
kind?: SpanKind;
attributes?: Attributes;
}
): Promise<T> {
if (!this.tracer) {
// No tracer, just execute the function with a no-op span
return fn(noopSpan);
}
const spanOptions: SpanOptions = {
kind: options?.kind,
attributes: {
[MessagingAttributes.SYSTEM]: this.name,
...options?.attributes,
},
};
return this.tracer.startActiveSpan(`${this.name}.${name}`, spanOptions, async (span) => {
try {
const result = await fn(span);
return result;
} catch (error) {
if (error instanceof Error) {
span.recordException(error);
} else {
span.recordException(new Error(String(error)));
}
throw error;
} finally {
span.end();
}
});
}
/**
* Synchronous version of trace.
*/
traceSync<T>(
name: string,
fn: (span: Span) => T,
options?: {
kind?: SpanKind;
attributes?: Attributes;
}
): T {
if (!this.tracer) {
return fn(noopSpan);
}
const spanOptions: SpanOptions = {
kind: options?.kind,
attributes: {
[MessagingAttributes.SYSTEM]: this.name,
...options?.attributes,
},
};
return this.tracer.startActiveSpan(`${this.name}.${name}`, spanOptions, (span) => {
try {
return fn(span);
} catch (error) {
if (error instanceof Error) {
span.recordException(error);
} else {
span.recordException(new Error(String(error)));
}
throw error;
} finally {
span.end();
}
});
}
// ============================================================================
// Metrics
// ============================================================================
/**
* Record a message enqueued.
*/
recordEnqueue(attributes?: Attributes): void {
this.metrics?.messagesEnqueued.add(1, attributes);
}
/**
* Record a batch of messages enqueued.
*/
recordEnqueueBatch(count: number, attributes?: Attributes): void {
this.metrics?.messagesEnqueued.add(count, attributes);
}
/**
* Record a message completed successfully.
*/
recordComplete(attributes?: Attributes): void {
this.metrics?.messagesCompleted.add(1, attributes);
}
/**
* Record a message processing failure.
*/
recordFailure(attributes?: Attributes): void {
this.metrics?.messagesFailed.add(1, attributes);
}
/**
* Record a message retry.
*/
recordRetry(attributes?: Attributes): void {
this.metrics?.messagesRetried.add(1, attributes);
}
/**
* Record a message sent to DLQ.
*/
recordDLQ(attributes?: Attributes): void {
this.metrics?.messagesToDLQ.add(1, attributes);
}
/**
* Record message processing time.
*
* @param durationMs - Processing duration in milliseconds
*/
recordProcessingTime(durationMs: number, attributes?: Attributes): void {
this.metrics?.processingTime.record(durationMs, attributes);
}
/**
* Record time a message spent waiting in queue.
*
* @param durationMs - Queue wait time in milliseconds
*/
recordQueueTime(durationMs: number, attributes?: Attributes): void {
this.metrics?.queueTime.record(durationMs, attributes);
}
/**
* Register observable gauge callbacks.
* Call this after FairQueue is initialized to register the gauge callbacks.
*/
registerGaugeCallbacks(callbacks: {
getQueueLength?: (queueId: string) => Promise<number>;
getMasterQueueLength?: (shardId: number) => Promise<number>;
getDispatchLength?: (shardId: number) => Promise<number>;
getInflightCount?: (shardId: number) => Promise<number>;
getDLQLength?: (tenantId: string) => Promise<number>;
shardCount?: number;
observedQueues?: string[];
observedTenants?: string[];
}): void {
if (!this.metrics) return;
// Queue length gauge
if (callbacks.getQueueLength && callbacks.observedQueues) {
const getQueueLength = callbacks.getQueueLength;
const queues = callbacks.observedQueues;
this.metrics.queueLength.addCallback(async (observableResult) => {
for (const queueId of queues) {
const length = await getQueueLength(queueId);
observableResult.observe(length, {
[FairQueueAttributes.QUEUE_ID]: queueId,
});
}
});
}
// Legacy master queue length gauge (draining, should trend to 0)
if (callbacks.getMasterQueueLength && callbacks.shardCount) {
const getMasterQueueLength = callbacks.getMasterQueueLength;
const shardCount = callbacks.shardCount;
this.metrics.masterQueueLength.addCallback(async (observableResult) => {
for (let shardId = 0; shardId < shardCount; shardId++) {
const length = await getMasterQueueLength(shardId);
observableResult.observe(length, {
[FairQueueAttributes.SHARD_ID]: shardId.toString(),
});
}
});
}
// Dispatch index length gauge (new two-level dispatch, tenant count per shard)
if (callbacks.getDispatchLength && callbacks.shardCount) {
const getDispatchLength = callbacks.getDispatchLength;
const shardCount = callbacks.shardCount;
this.metrics.dispatchLength.addCallback(async (observableResult) => {
for (let shardId = 0; shardId < shardCount; shardId++) {
const length = await getDispatchLength(shardId);
observableResult.observe(length, {
[FairQueueAttributes.SHARD_ID]: shardId.toString(),
});
}
});
}
// Inflight count gauge
if (callbacks.getInflightCount && callbacks.shardCount) {
const getInflightCount = callbacks.getInflightCount;
const shardCount = callbacks.shardCount;
this.metrics.inflightCount.addCallback(async (observableResult) => {
for (let shardId = 0; shardId < shardCount; shardId++) {
const count = await getInflightCount(shardId);
observableResult.observe(count, {
[FairQueueAttributes.SHARD_ID]: shardId.toString(),
});
}
});
}
// DLQ length gauge
if (callbacks.getDLQLength && callbacks.observedTenants) {
const getDLQLength = callbacks.getDLQLength;
const tenants = callbacks.observedTenants;
this.metrics.dlqLength.addCallback(async (observableResult) => {
for (const tenantId of tenants) {
const length = await getDLQLength(tenantId);
observableResult.observe(length, {
[FairQueueAttributes.TENANT_ID]: tenantId,
});
}
});
}
}
// ============================================================================
// Helper Methods
// ============================================================================
/**
* Create standard attributes for a message operation (for spans/traces).
* Use this for span attributes where high cardinality is acceptable.
*/
messageAttributes(params: {
queueId?: string;
tenantId?: string;
messageId?: string;
attempt?: number;
workerQueue?: string;
consumerId?: string;
}): Attributes {
const attrs: Attributes = {};
if (params.queueId) attrs[FairQueueAttributes.QUEUE_ID] = params.queueId;
if (params.tenantId) attrs[FairQueueAttributes.TENANT_ID] = params.tenantId;
if (params.messageId) attrs[FairQueueAttributes.MESSAGE_ID] = params.messageId;
if (params.attempt !== undefined) attrs[FairQueueAttributes.ATTEMPT] = params.attempt;
if (params.workerQueue) attrs[FairQueueAttributes.WORKER_QUEUE] = params.workerQueue;
if (params.consumerId) attrs[FairQueueAttributes.CONSUMER_ID] = params.consumerId;
return attrs;
}
/**
* Check if telemetry is enabled.
*/
get isEnabled(): boolean {
return !!this.tracer || !!this.meter;
}
/**
* Check if tracing is enabled.
*/
get hasTracer(): boolean {
return !!this.tracer;
}
/**
* Check if metrics are enabled.
*/
get hasMetrics(): boolean {
return !!this.meter;
}
// ============================================================================
// Private Methods
// ============================================================================
#initializeMetrics(): void {
if (!this.meter) return;
this.metrics = {
// Counters
messagesEnqueued: this.meter.createCounter(`${this.name}.messages.enqueued`, {
description: "Number of messages enqueued",
unit: "messages",
}),
messagesCompleted: this.meter.createCounter(`${this.name}.messages.completed`, {
description: "Number of messages completed successfully",
unit: "messages",
}),
messagesFailed: this.meter.createCounter(`${this.name}.messages.failed`, {
description: "Number of messages that failed processing",
unit: "messages",
}),
messagesRetried: this.meter.createCounter(`${this.name}.messages.retried`, {
description: "Number of message retries",
unit: "messages",
}),
messagesToDLQ: this.meter.createCounter(`${this.name}.messages.dlq`, {
description: "Number of messages sent to dead letter queue",
unit: "messages",
}),
// Histograms
processingTime: this.meter.createHistogram(`${this.name}.message.processing_time`, {
description: "Message processing time",
unit: "ms",
}),
queueTime: this.meter.createHistogram(`${this.name}.message.queue_time`, {
description: "Time message spent waiting in queue",
unit: "ms",
}),
// Observable gauges
queueLength: this.meter.createObservableGauge(`${this.name}.queue.length`, {
description: "Number of messages in a queue",
unit: "messages",
}),
masterQueueLength: this.meter.createObservableGauge(`${this.name}.master_queue.length`, {
description: "Number of queues in legacy master queue shard (draining)",
unit: "queues",
}),
dispatchLength: this.meter.createObservableGauge(`${this.name}.dispatch.length`, {
description: "Number of tenants in dispatch index shard",
unit: "tenants",
}),
inflightCount: this.meter.createObservableGauge(`${this.name}.inflight.count`, {
description: "Number of messages currently being processed",
unit: "messages",
}),
dlqLength: this.meter.createObservableGauge(`${this.name}.dlq.length`, {
description: "Number of messages in dead letter queue",
unit: "messages",
}),
};
}
}
// ============================================================================
// Batched Span Manager
// ============================================================================
/**
* State for tracking a consumer loop's batched span.
*/
export interface ConsumerLoopState {
/** Countdown of iterations before starting a new span */
perTraceCountdown: number;
/** When the current trace started */
traceStartedAt: Date;
/** The current batched span */
currentSpan?: Span;
/** The context for the current batched span */
currentSpanContext?: Context;
/** Number of iterations in the current span */
iterationsCount: number;
/** Total iterations across all spans */
totalIterationsCount: number;
/** Running duration in milliseconds for the current span */
runningDurationInMs: number;
/** Stats counters for the current span */
stats: Record<string, number>;
/** Flag to force span end on next iteration */
endSpanInNextIteration: boolean;
}
/**
* Configuration for the BatchedSpanManager.
*/
export interface BatchedSpanManagerOptions {
/** The tracer to use for creating spans */
tracer?: Tracer;
/** Name prefix for spans */
name: string;
/** Maximum iterations before rotating the span */
maxIterations: number;
/** Maximum seconds before rotating the span */
timeoutSeconds: number;
/** Optional callback to get dynamic attributes when starting a new batched span */
getDynamicAttributes?: () => Attributes;
}
/**
* Manages batched spans for consumer loops.
*
* This allows multiple iterations to be grouped into a single parent span,
* reducing the volume of spans while maintaining observability.
*/
export class BatchedSpanManager {
private tracer?: Tracer;
private name: string;
private maxIterations: number;
private timeoutSeconds: number;
private loopStates = new Map<string, ConsumerLoopState>();
private getDynamicAttributes?: () => Attributes;
constructor(options: BatchedSpanManagerOptions) {
this.tracer = options.tracer;
this.name = options.name;
this.maxIterations = options.maxIterations;
this.timeoutSeconds = options.timeoutSeconds;
this.getDynamicAttributes = options.getDynamicAttributes;
}
/**
* Initialize state for a consumer loop.
*/
initializeLoop(loopId: string): void {
this.loopStates.set(loopId, {
perTraceCountdown: this.maxIterations,
traceStartedAt: new Date(),
iterationsCount: 0,
totalIterationsCount: 0,
runningDurationInMs: 0,
stats: {},
endSpanInNextIteration: false,
});
}
/**
* Get the state for a consumer loop.
*/
getState(loopId: string): ConsumerLoopState | undefined {
return this.loopStates.get(loopId);
}
/**
* Increment a stat counter for a loop.
*/
incrementStat(loopId: string, statName: string, value: number = 1): void {
const state = this.loopStates.get(loopId);
if (state) {
state.stats[statName] = (state.stats[statName] ?? 0) + value;
}
}
/**
* Mark that the span should end on the next iteration.
*/
markForRotation(loopId: string): void {
const state = this.loopStates.get(loopId);
if (state) {
state.endSpanInNextIteration = true;
}
}
/**
* Check if the span should be rotated (ended and a new one started).
*/
shouldRotate(loopId: string): boolean {
const state = this.loopStates.get(loopId);
if (!state) return true;
return (
state.perTraceCountdown <= 0 ||
Date.now() - state.traceStartedAt.getTime() > this.timeoutSeconds * 1000 ||
state.currentSpanContext === undefined ||
state.endSpanInNextIteration
);
}
/**
* End the current span for a loop and record stats.
*/
endCurrentSpan(loopId: string): void {
const state = this.loopStates.get(loopId);
if (!state?.currentSpan) return;
// Record stats as span attributes
for (const [statName, count] of Object.entries(state.stats)) {
state.currentSpan.setAttribute(`stats.${statName}`, count);
}
state.currentSpan.end();
state.currentSpan = undefined;
state.currentSpanContext = undefined;
}
/**
* Start a new batched span for a loop.
*/
startNewSpan(loopId: string, attributes?: Attributes): void {
if (!this.tracer) return;
const state = this.loopStates.get(loopId);
if (!state) return;
// End any existing span first
this.endCurrentSpan(loopId);
// Calculate metrics from previous span period
const traceDurationInMs = state.traceStartedAt
? Date.now() - state.traceStartedAt.getTime()
: undefined;
const iterationsPerSecond =
traceDurationInMs && traceDurationInMs > 0
? state.iterationsCount / (traceDurationInMs / 1000)
: undefined;
// Get dynamic attributes if callback is provided
const dynamicAttributes = this.getDynamicAttributes?.() ?? {};
// Start new span
state.currentSpan = this.tracer.startSpan(
`${this.name}.consumerLoop`,
{
kind: 1, // SpanKind.CONSUMER
attributes: {
loop_id: loopId,
max_iterations: this.maxIterations,
timeout_seconds: this.timeoutSeconds,
previous_iterations: state.iterationsCount,
previous_duration_ms: traceDurationInMs,
previous_iterations_per_second: iterationsPerSecond,
total_iterations: state.totalIterationsCount,
...dynamicAttributes,
...attributes,
},
},
ROOT_CONTEXT
);
// Set up context for child spans
state.currentSpanContext = trace.setSpan(ROOT_CONTEXT, state.currentSpan);
// Reset counters
state.perTraceCountdown = this.maxIterations;
state.traceStartedAt = new Date();
state.iterationsCount = 0;
state.runningDurationInMs = 0;
state.stats = {};
state.endSpanInNextIteration = false;
}
/**
* Execute a function within the batched span context.
* Automatically handles span rotation and iteration tracking.
*/
async withBatchedSpan<T>(
loopId: string,
fn: (span: Span) => Promise<T>,
options?: {
iterationSpanName?: string;
attributes?: Attributes;
}
): Promise<T> {
let state = this.loopStates.get(loopId);
// Initialize state if not present
if (!state) {
this.initializeLoop(loopId);
state = this.loopStates.get(loopId)!;
}
// Check if we need to rotate the span
if (this.shouldRotate(loopId)) {
this.startNewSpan(loopId);
}
const startTime = performance.now();
try {
// If no tracer, just execute the function
if (!this.tracer || !state.currentSpanContext) {
return await fn(noopSpan);
}
// Execute within the batched span context
return await context.with(state.currentSpanContext, async () => {
// Create an iteration span within the batched span
const iterationSpanName = options?.iterationSpanName ?? "iteration";
return await this.tracer!.startActiveSpan(
`${this.name}.${iterationSpanName}`,
{
attributes: {
loop_id: loopId,
iteration: state.iterationsCount,
...options?.attributes,
},
},
async (iterationSpan) => {
try {
return await fn(iterationSpan);
} catch (error) {
if (error instanceof Error) {
iterationSpan.recordException(error);
state.currentSpan?.recordException(error);
}
iterationSpan.setStatus({ code: SpanStatusCode.ERROR });
state.endSpanInNextIteration = true;
throw error;
} finally {
iterationSpan.end();
}
}
);
});
} finally {
// Update iteration tracking
const duration = performance.now() - startTime;
state.runningDurationInMs += duration;
state.iterationsCount++;
state.totalIterationsCount++;
state.perTraceCountdown--;
}
}
/**
* Clean up state for a loop when it's stopped.
*/
cleanup(loopId: string): void {
this.endCurrentSpan(loopId);
this.loopStates.delete(loopId);
}
/**
* Clean up all loop states.
*/
cleanupAll(): void {
for (const loopId of this.loopStates.keys()) {
this.cleanup(loopId);
}
}
}
/**
* No-op span implementation for when telemetry is disabled.
*/
const noopSpan: Span = {
spanContext: () => ({
traceId: "",
spanId: "",
traceFlags: 0,
}),
setAttribute: () => noopSpan,
setAttributes: () => noopSpan,
addEvent: () => noopSpan,
addLink: () => noopSpan,
addLinks: () => noopSpan,
setStatus: () => noopSpan,
updateName: () => noopSpan,
end: () => {},
isRecording: () => false,
recordException: () => {},
};
/**
* No-op telemetry instance for when telemetry is disabled.
*/
export const noopTelemetry = new FairQueueTelemetry({});
@@ -0,0 +1,183 @@
import { createRedisClient, type Redis, type RedisOptions } from "@internal/redis";
import { jumpHash } from "@trigger.dev/core/v3/serverOnly";
import type { FairQueueKeyProducer, QueueWithScore } from "./types.js";
export interface TenantDispatchOptions {
redis: RedisOptions;
keys: FairQueueKeyProducer;
shardCount: number;
}
export interface TenantWithScore {
tenantId: string;
score: number;
}
/**
* TenantDispatch manages the two-level tenant dispatch index.
*
* Level 1 - Dispatch Index (per shard):
* Key: {prefix}:dispatch:{shardId}
* ZSET of tenantIds scored by oldest message timestamp across their queues.
* Only tenants with queues containing messages appear here.
*
* Level 2 - Per-Tenant Queue Index:
* Key: {prefix}:tenantq:{tenantId}
* ZSET of queueIds scored by oldest message timestamp in that queue.
*
* This replaces the flat master queue for new enqueues, isolating each tenant's
* queue backlog so the scheduler iterates tenants (not queues) at Level 1.
*/
export class TenantDispatch {
private redis: Redis;
private keys: FairQueueKeyProducer;
private shardCount: number;
constructor(private options: TenantDispatchOptions) {
this.redis = createRedisClient(options.redis);
this.keys = options.keys;
this.shardCount = Math.max(1, options.shardCount);
}
/**
* Get the dispatch shard ID for a tenant.
* Uses jump consistent hash on the tenant ID so each tenant
* always maps to exactly one dispatch shard.
*/
getShardForTenant(tenantId: string): number {
return jumpHash(tenantId, this.shardCount);
}
/**
* Get eligible tenants from a dispatch shard (Level 1).
* Returns tenants ordered by oldest message (lowest score first).
*/
async getTenantsFromShard(
shardId: number,
limit: number = 1000,
maxScore?: number
): Promise<TenantWithScore[]> {
const dispatchKey = this.keys.dispatchKey(shardId);
const score = maxScore ?? Date.now();
const results = await this.redis.zrangebyscore(
dispatchKey,
"-inf",
score,
"WITHSCORES",
"LIMIT",
0,
limit
);
const tenants: TenantWithScore[] = [];
for (let i = 0; i < results.length; i += 2) {
const tenantId = results[i];
const scoreStr = results[i + 1];
if (tenantId && scoreStr) {
tenants.push({
tenantId,
score: parseFloat(scoreStr),
});
}
}
return tenants;
}
/**
* Get queues for a specific tenant (Level 2).
* Returns queues ordered by oldest message (lowest score first).
*/
async getQueuesForTenant(
tenantId: string,
limit: number = 1000,
maxScore?: number
): Promise<QueueWithScore[]> {
const tenantQueueKey = this.keys.tenantQueueIndexKey(tenantId);
const score = maxScore ?? Date.now();
const results = await this.redis.zrangebyscore(
tenantQueueKey,
"-inf",
score,
"WITHSCORES",
"LIMIT",
0,
limit
);
const queues: QueueWithScore[] = [];
for (let i = 0; i < results.length; i += 2) {
const queueId = results[i];
const scoreStr = results[i + 1];
if (queueId && scoreStr) {
queues.push({
queueId,
score: parseFloat(scoreStr),
tenantId,
});
}
}
return queues;
}
/**
* Get the number of tenants in a dispatch shard.
*/
async getShardTenantCount(shardId: number): Promise<number> {
const dispatchKey = this.keys.dispatchKey(shardId);
return await this.redis.zcard(dispatchKey);
}
/**
* Get total tenant count across all dispatch shards.
* Note: tenants may appear in multiple shards, so this may overcount.
*/
async getTotalTenantCount(): Promise<number> {
const counts = await Promise.all(
Array.from({ length: this.shardCount }, (_, i) => this.getShardTenantCount(i))
);
return counts.reduce((sum, count) => sum + count, 0);
}
/**
* Get the number of queues for a tenant.
*/
async getTenantQueueCount(tenantId: string): Promise<number> {
const tenantQueueKey = this.keys.tenantQueueIndexKey(tenantId);
return await this.redis.zcard(tenantQueueKey);
}
/**
* Remove a tenant from a specific dispatch shard.
*/
async removeTenantFromShard(shardId: number, tenantId: string): Promise<void> {
const dispatchKey = this.keys.dispatchKey(shardId);
await this.redis.zrem(dispatchKey, tenantId);
}
/**
* Add a tenant to a dispatch shard with the given score.
*/
async addTenantToShard(shardId: number, tenantId: string, score: number): Promise<void> {
const dispatchKey = this.keys.dispatchKey(shardId);
await this.redis.zadd(dispatchKey, score, tenantId);
}
/**
* Remove a queue from a tenant's queue index.
*/
async removeQueueFromTenant(tenantId: string, queueId: string): Promise<void> {
const tenantQueueKey = this.keys.tenantQueueIndexKey(tenantId);
await this.redis.zrem(tenantQueueKey, queueId);
}
/**
* Close the Redis connection.
*/
async close(): Promise<void> {
await this.redis.quit();
}
}
@@ -0,0 +1,739 @@
import { describe, expect } from "vitest";
import { redisTest } from "@internal/testcontainers";
import { ConcurrencyManager } from "../concurrency.js";
import { DefaultFairQueueKeyProducer } from "../keyProducer.js";
import type { FairQueueKeyProducer, QueueDescriptor } from "../types.js";
describe("ConcurrencyManager", () => {
let keys: FairQueueKeyProducer;
describe("single group concurrency", () => {
redisTest(
"should allow processing when under limit",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new ConcurrencyManager({
redis: redisOptions,
keys,
groups: [
{
name: "tenant",
extractGroupId: (q) => q.tenantId,
getLimit: async () => 5,
defaultLimit: 5,
},
],
});
const queue: QueueDescriptor = {
id: "queue-1",
tenantId: "t1",
metadata: {},
};
const result = await manager.canProcess(queue);
expect(result.allowed).toBe(true);
await manager.close();
}
);
redisTest("should block when at capacity", { timeout: 10000 }, async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new ConcurrencyManager({
redis: redisOptions,
keys,
groups: [
{
name: "tenant",
extractGroupId: (q) => q.tenantId,
getLimit: async () => 5,
defaultLimit: 5,
},
],
});
const queue: QueueDescriptor = {
id: "queue-1",
tenantId: "t1",
metadata: {},
};
// Reserve 5 slots (the limit)
for (let i = 0; i < 5; i++) {
await manager.reserve(queue, `msg-${i}`);
}
const result = await manager.canProcess(queue);
expect(result.allowed).toBe(false);
expect(result.blockedBy?.groupName).toBe("tenant");
expect(result.blockedBy?.current).toBe(5);
expect(result.blockedBy?.limit).toBe(5);
await manager.close();
});
redisTest("should allow after release", { timeout: 15000 }, async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new ConcurrencyManager({
redis: redisOptions,
keys,
groups: [
{
name: "tenant",
extractGroupId: (q) => q.tenantId,
getLimit: async () => 5,
defaultLimit: 5,
},
],
});
const queue: QueueDescriptor = {
id: "queue-1",
tenantId: "t1",
metadata: {},
};
// Fill up
for (let i = 0; i < 5; i++) {
await manager.reserve(queue, `msg-${i}`);
}
// Should be blocked
let result = await manager.canProcess(queue);
expect(result.allowed).toBe(false);
// Release one
await manager.release(queue, "msg-0");
// Should be allowed now
result = await manager.canProcess(queue);
expect(result.allowed).toBe(true);
await manager.close();
});
});
describe("multi-group concurrency", () => {
redisTest("should check all groups", { timeout: 10000 }, async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new ConcurrencyManager({
redis: redisOptions,
keys,
groups: [
{
name: "tenant",
extractGroupId: (q) => q.tenantId,
getLimit: async () => 5,
defaultLimit: 5,
},
{
name: "organization",
extractGroupId: (q) => (q.metadata.orgId as string) ?? "default",
getLimit: async () => 10,
defaultLimit: 10,
},
],
});
const queue: QueueDescriptor = {
id: "queue-1",
tenantId: "t1",
metadata: { orgId: "org1" },
};
// Fill up org level (10)
for (let i = 0; i < 10; i++) {
await manager.reserve(queue, `msg-${i}`);
}
// Tenant is at 10, over limit of 5
// Org is at 10, at limit of 10
const result = await manager.canProcess(queue);
expect(result.allowed).toBe(false);
// Should be blocked by tenant first (checked first, limit 5)
expect(result.blockedBy?.groupName).toBe("tenant");
await manager.close();
});
redisTest(
"should block if any group is at capacity",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new ConcurrencyManager({
redis: redisOptions,
keys,
groups: [
{
name: "tenant",
extractGroupId: (q) => q.tenantId,
getLimit: async () => 5,
defaultLimit: 5,
},
{
name: "organization",
extractGroupId: (q) => (q.metadata.orgId as string) ?? "default",
getLimit: async () => 10,
defaultLimit: 10,
},
],
});
// Use different queue with different tenant but same org
const queue1: QueueDescriptor = {
id: "queue-1",
tenantId: "t1",
metadata: { orgId: "org1" },
};
const queue2: QueueDescriptor = {
id: "queue-2",
tenantId: "t2",
metadata: { orgId: "org1" }, // Same org
};
// Fill up org with messages from both tenants
for (let i = 0; i < 5; i++) {
await manager.reserve(queue1, `msg-t1-${i}`);
}
for (let i = 0; i < 5; i++) {
await manager.reserve(queue2, `msg-t2-${i}`);
}
// t1 tenant is at 5/5, org is at 10/10
let result = await manager.canProcess(queue1);
expect(result.allowed).toBe(false);
// t2 tenant is at 5/5
result = await manager.canProcess(queue2);
expect(result.allowed).toBe(false);
await manager.close();
}
);
});
describe("getAvailableCapacity", () => {
redisTest(
"should return available capacity for single group",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new ConcurrencyManager({
redis: redisOptions,
keys,
groups: [
{
name: "tenant",
extractGroupId: (q) => q.tenantId,
getLimit: async () => 10,
defaultLimit: 10,
},
],
});
const queue: QueueDescriptor = {
id: "queue-1",
tenantId: "t1",
metadata: {},
};
// Initial capacity should be full
let capacity = await manager.getAvailableCapacity(queue);
expect(capacity).toBe(10);
// Reserve 3 slots
await manager.reserve(queue, "msg-1");
await manager.reserve(queue, "msg-2");
await manager.reserve(queue, "msg-3");
// Capacity should be reduced
capacity = await manager.getAvailableCapacity(queue);
expect(capacity).toBe(7);
await manager.close();
}
);
redisTest(
"should return minimum capacity across multiple groups",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new ConcurrencyManager({
redis: redisOptions,
keys,
groups: [
{
name: "tenant",
extractGroupId: (q) => q.tenantId,
getLimit: async () => 5,
defaultLimit: 5,
},
{
name: "organization",
extractGroupId: (q) => (q.metadata.orgId as string) ?? "default",
getLimit: async () => 20,
defaultLimit: 20,
},
],
});
const queue: QueueDescriptor = {
id: "queue-1",
tenantId: "t1",
metadata: { orgId: "org1" },
};
// Initial capacity should be minimum (5 for tenant, 20 for org)
let capacity = await manager.getAvailableCapacity(queue);
expect(capacity).toBe(5);
// Reserve 3 slots
await manager.reserve(queue, "msg-1");
await manager.reserve(queue, "msg-2");
await manager.reserve(queue, "msg-3");
// Now tenant has 2 left, org has 17 left - minimum is 2
capacity = await manager.getAvailableCapacity(queue);
expect(capacity).toBe(2);
await manager.close();
}
);
redisTest(
"should return 0 when any group is at capacity",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new ConcurrencyManager({
redis: redisOptions,
keys,
groups: [
{
name: "tenant",
extractGroupId: (q) => q.tenantId,
getLimit: async () => 3,
defaultLimit: 3,
},
{
name: "organization",
extractGroupId: (q) => (q.metadata.orgId as string) ?? "default",
getLimit: async () => 10,
defaultLimit: 10,
},
],
});
const queue: QueueDescriptor = {
id: "queue-1",
tenantId: "t1",
metadata: { orgId: "org1" },
};
// Fill up tenant capacity
await manager.reserve(queue, "msg-1");
await manager.reserve(queue, "msg-2");
await manager.reserve(queue, "msg-3");
// Tenant is at 3/3, org is at 3/10
const capacity = await manager.getAvailableCapacity(queue);
expect(capacity).toBe(0);
await manager.close();
}
);
redisTest(
"should return 0 when no groups are configured",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new ConcurrencyManager({
redis: redisOptions,
keys,
groups: [],
});
const queue: QueueDescriptor = {
id: "queue-1",
tenantId: "t1",
metadata: {},
};
const capacity = await manager.getAvailableCapacity(queue);
expect(capacity).toBe(0);
await manager.close();
}
);
});
describe("atomic reservation", () => {
redisTest(
"should atomically reserve across groups",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new ConcurrencyManager({
redis: redisOptions,
keys,
groups: [
{
name: "tenant",
extractGroupId: (q) => q.tenantId,
getLimit: async () => 5,
defaultLimit: 5,
},
{
name: "organization",
extractGroupId: (q) => (q.metadata.orgId as string) ?? "default",
getLimit: async () => 10,
defaultLimit: 10,
},
],
});
const queue: QueueDescriptor = {
id: "queue-1",
tenantId: "t1",
metadata: { orgId: "org1" },
};
const result = await manager.reserve(queue, "msg-1");
expect(result).toBe(true);
const tenantCurrent = await manager.getCurrentConcurrency("tenant", "t1");
const orgCurrent = await manager.getCurrentConcurrency("organization", "org1");
expect(tenantCurrent).toBe(1);
expect(orgCurrent).toBe(1);
await manager.close();
}
);
redisTest(
"should not reserve if any group is at capacity",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new ConcurrencyManager({
redis: redisOptions,
keys,
groups: [
{
name: "tenant",
extractGroupId: (q) => q.tenantId,
getLimit: async () => 5,
defaultLimit: 5,
},
],
});
const queue: QueueDescriptor = {
id: "queue-1",
tenantId: "t1",
metadata: {},
};
// Fill up tenant
for (let i = 0; i < 5; i++) {
await manager.reserve(queue, `msg-${i}`);
}
// Try to reserve one more
const result = await manager.reserve(queue, "msg-extra");
expect(result).toBe(false);
// Should still be at 5
const current = await manager.getCurrentConcurrency("tenant", "t1");
expect(current).toBe(5);
await manager.close();
}
);
});
describe("get active messages", () => {
redisTest(
"should return all active message IDs",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new ConcurrencyManager({
redis: redisOptions,
keys,
groups: [
{
name: "tenant",
extractGroupId: (q) => q.tenantId,
getLimit: async () => 10,
defaultLimit: 10,
},
],
});
const queue: QueueDescriptor = {
id: "queue-1",
tenantId: "t1",
metadata: {},
};
await manager.reserve(queue, "msg-1");
await manager.reserve(queue, "msg-2");
await manager.reserve(queue, "msg-3");
const active = await manager.getActiveMessages("tenant", "t1");
expect(active).toHaveLength(3);
expect(active).toContain("msg-1");
expect(active).toContain("msg-2");
expect(active).toContain("msg-3");
await manager.close();
}
);
});
describe("clear group", () => {
redisTest(
"should clear all messages for a group",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new ConcurrencyManager({
redis: redisOptions,
keys,
groups: [
{
name: "tenant",
extractGroupId: (q) => q.tenantId,
getLimit: async () => 10,
defaultLimit: 10,
},
],
});
const queue: QueueDescriptor = {
id: "queue-1",
tenantId: "t1",
metadata: {},
};
await manager.reserve(queue, "msg-1");
await manager.reserve(queue, "msg-2");
await manager.clearGroup("tenant", "t1");
const current = await manager.getCurrentConcurrency("tenant", "t1");
expect(current).toBe(0);
await manager.close();
}
);
});
describe("get state", () => {
redisTest(
"should return full concurrency state",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new ConcurrencyManager({
redis: redisOptions,
keys,
groups: [
{
name: "tenant",
extractGroupId: (q) => q.tenantId,
getLimit: async () => 5,
defaultLimit: 5,
},
],
});
const queue: QueueDescriptor = {
id: "queue-1",
tenantId: "t1",
metadata: {},
};
await manager.reserve(queue, "msg-1");
await manager.reserve(queue, "msg-2");
const state = await manager.getState("tenant", "t1");
expect(state.groupName).toBe("tenant");
expect(state.groupId).toBe("t1");
expect(state.current).toBe(2);
expect(state.limit).toBe(5);
await manager.close();
}
);
});
describe("group names", () => {
redisTest(
"should return configured group names",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new ConcurrencyManager({
redis: redisOptions,
keys,
groups: [
{
name: "tenant",
extractGroupId: (q) => q.tenantId,
getLimit: async () => 5,
defaultLimit: 5,
},
{
name: "organization",
extractGroupId: (q) => (q.metadata.orgId as string) ?? "default",
getLimit: async () => 10,
defaultLimit: 10,
},
],
});
const names = manager.getGroupNames();
expect(names).toEqual(["tenant", "organization"]);
await manager.close();
}
);
});
describe("keyPrefix handling", () => {
redisTest(
"should correctly reserve and release with keyPrefix",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "myprefix" });
// Create manager with keyPrefix - this simulates real-world usage
const manager = new ConcurrencyManager({
redis: {
...redisOptions,
keyPrefix: "engine:batch-queue:",
},
keys,
groups: [
{
name: "tenant",
extractGroupId: (q) => q.tenantId,
getLimit: async () => 2,
defaultLimit: 2,
},
],
});
const queue: QueueDescriptor = {
id: "queue-1",
tenantId: "t1",
metadata: {},
};
// Reserve slots
const reserved1 = await manager.reserve(queue, "msg-1");
const reserved2 = await manager.reserve(queue, "msg-2");
expect(reserved1).toBe(true);
expect(reserved2).toBe(true);
// Should be at capacity
let result = await manager.canProcess(queue);
expect(result.allowed).toBe(false);
// Release one - this must use the SAME key as reserve (with keyPrefix)
await manager.release(queue, "msg-1");
// Should now be allowed - this proves reserve and release use the same key
result = await manager.canProcess(queue);
expect(result.allowed).toBe(true);
// Verify concurrency count is correct
const current = await manager.getCurrentConcurrency("tenant", "t1");
expect(current).toBe(1);
await manager.close();
}
);
redisTest(
"should handle reserve/release cycle multiple times with keyPrefix",
{ timeout: 15000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new ConcurrencyManager({
redis: {
...redisOptions,
keyPrefix: "myapp:",
},
keys,
groups: [
{
name: "tenant",
extractGroupId: (q) => q.tenantId,
getLimit: async () => 1, // Concurrency of 1
defaultLimit: 1,
},
],
});
const queue: QueueDescriptor = {
id: "queue-1",
tenantId: "t1",
metadata: {},
};
// Simulate processing multiple messages one at a time
for (let i = 0; i < 5; i++) {
const msgId = `msg-${i}`;
// Reserve
const reserved = await manager.reserve(queue, msgId);
expect(reserved).toBe(true);
// Should be at capacity now
const check = await manager.canProcess(queue);
expect(check.allowed).toBe(false);
// Release
await manager.release(queue, msgId);
// Should be free again
const checkAfter = await manager.canProcess(queue);
expect(checkAfter.allowed).toBe(true);
}
// Final state should be 0 concurrent
const current = await manager.getCurrentConcurrency("tenant", "t1");
expect(current).toBe(0);
await manager.close();
}
);
});
});
@@ -0,0 +1,459 @@
import { describe, expect } from "vitest";
import { redisTest } from "@internal/testcontainers";
import { createRedisClient } from "@internal/redis";
import { DRRScheduler } from "../schedulers/drr.js";
import { DefaultFairQueueKeyProducer } from "../keyProducer.js";
import type { FairQueueKeyProducer, SchedulerContext } from "../types.js";
describe("DRRScheduler", () => {
let keys: FairQueueKeyProducer;
describe("deficit management", () => {
redisTest(
"should initialize deficit to 0 for new tenants",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const scheduler = new DRRScheduler({
redis: redisOptions,
keys,
quantum: 5,
maxDeficit: 50,
});
const deficit = await scheduler.getDeficit("new-tenant");
expect(deficit).toBe(0);
await scheduler.close();
}
);
redisTest(
"should add quantum atomically with capping",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const redis = createRedisClient(redisOptions);
const scheduler = new DRRScheduler({
redis: redisOptions,
keys,
quantum: 5,
maxDeficit: 50,
});
// Setup: put queues in the master shard
const masterKey = keys.masterQueueKey(0);
const now = Date.now();
await redis.zadd(masterKey, now, "tenant:t1:queue:q1");
// Create context mock
const context: SchedulerContext = {
getCurrentConcurrency: async () => 0,
getConcurrencyLimit: async () => 100,
isAtCapacity: async () => false,
getQueueDescriptor: (queueId) => ({
id: queueId,
tenantId: keys.extractTenantId(queueId),
metadata: {},
}),
};
// Run multiple iterations to accumulate deficit
for (let i = 0; i < 15; i++) {
await scheduler.selectQueues(masterKey, "consumer-1", context);
}
// Deficit should be capped at maxDeficit (50)
const deficit = await scheduler.getDeficit("t1");
expect(deficit).toBeLessThanOrEqual(50);
await scheduler.close();
await redis.quit();
}
);
redisTest(
"should decrement deficit when processing",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const redis = createRedisClient(redisOptions);
const scheduler = new DRRScheduler({
redis: redisOptions,
keys,
quantum: 5,
maxDeficit: 50,
});
// Manually set some deficit
const deficitKey = `test:drr:deficit`;
await redis.hset(deficitKey, "t1", "10");
// Record processing
await scheduler.recordProcessed("t1", "queue:q1");
const deficit = await scheduler.getDeficit("t1");
expect(deficit).toBe(9);
await scheduler.close();
await redis.quit();
}
);
redisTest(
"should not go below 0 on decrement",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const redis = createRedisClient(redisOptions);
const scheduler = new DRRScheduler({
redis: redisOptions,
keys,
quantum: 5,
maxDeficit: 50,
});
const deficitKey = `test:drr:deficit`;
await redis.hset(deficitKey, "t1", "0.5");
await scheduler.recordProcessed("t1", "queue:q1");
const deficit = await scheduler.getDeficit("t1");
expect(deficit).toBe(0);
await scheduler.close();
await redis.quit();
}
);
redisTest(
"should decrement deficit by count when using recordProcessedBatch",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const redis = createRedisClient(redisOptions);
const scheduler = new DRRScheduler({
redis: redisOptions,
keys,
quantum: 5,
maxDeficit: 50,
});
// Manually set deficit
const deficitKey = `test:drr:deficit`;
await redis.hset(deficitKey, "t1", "15");
// Record batch processing of 7 messages
await scheduler.recordProcessedBatch("t1", "queue:q1", 7);
const deficit = await scheduler.getDeficit("t1");
expect(deficit).toBe(8);
await scheduler.close();
await redis.quit();
}
);
redisTest(
"should not go below 0 when recordProcessedBatch decrements more than available",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const redis = createRedisClient(redisOptions);
const scheduler = new DRRScheduler({
redis: redisOptions,
keys,
quantum: 5,
maxDeficit: 50,
});
// Manually set deficit to 3
const deficitKey = `test:drr:deficit`;
await redis.hset(deficitKey, "t1", "3");
// Record batch processing of 10 messages (more than deficit)
await scheduler.recordProcessedBatch("t1", "queue:q1", 10);
const deficit = await scheduler.getDeficit("t1");
expect(deficit).toBe(0);
await scheduler.close();
await redis.quit();
}
);
redisTest("should reset deficit for tenant", { timeout: 10000 }, async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const redis = createRedisClient(redisOptions);
const scheduler = new DRRScheduler({
redis: redisOptions,
keys,
quantum: 5,
maxDeficit: 50,
});
const deficitKey = `test:drr:deficit`;
await redis.hset(deficitKey, "t1", "25");
await scheduler.resetDeficit("t1");
const deficit = await scheduler.getDeficit("t1");
expect(deficit).toBe(0);
await scheduler.close();
await redis.quit();
});
});
describe("queue selection", () => {
redisTest(
"should return queues grouped by tenant",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const redis = createRedisClient(redisOptions);
const scheduler = new DRRScheduler({
redis: redisOptions,
keys,
quantum: 5,
maxDeficit: 50,
});
const masterKey = keys.masterQueueKey(0);
const now = Date.now();
// Add queues for different tenants (all timestamps in the past)
await redis.zadd(
masterKey,
now - 200,
"tenant:t1:queue:q1",
now - 100,
"tenant:t1:queue:q2",
now - 50,
"tenant:t2:queue:q1"
);
const context: SchedulerContext = {
getCurrentConcurrency: async () => 0,
getConcurrencyLimit: async () => 100,
isAtCapacity: async () => false,
getQueueDescriptor: (queueId) => ({
id: queueId,
tenantId: keys.extractTenantId(queueId),
metadata: {},
}),
};
const result = await scheduler.selectQueues(masterKey, "consumer-1", context);
// Should have both tenants
const tenantIds = result.map((r) => r.tenantId);
expect(tenantIds).toContain("t1");
expect(tenantIds).toContain("t2");
// t1 should have 2 queues
const t1 = result.find((r) => r.tenantId === "t1");
expect(t1?.queues).toHaveLength(2);
await scheduler.close();
await redis.quit();
}
);
redisTest(
"should filter out tenants at capacity",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const redis = createRedisClient(redisOptions);
const scheduler = new DRRScheduler({
redis: redisOptions,
keys,
quantum: 5,
maxDeficit: 50,
});
const masterKey = keys.masterQueueKey(0);
const now = Date.now();
await redis.zadd(
masterKey,
now - 100,
"tenant:t1:queue:q1",
now - 50,
"tenant:t2:queue:q1"
);
const context: SchedulerContext = {
getCurrentConcurrency: async () => 0,
getConcurrencyLimit: async () => 100,
isAtCapacity: async (_, groupId) => groupId === "t1", // t1 at capacity
getQueueDescriptor: (queueId) => ({
id: queueId,
tenantId: keys.extractTenantId(queueId),
metadata: {},
}),
};
const result = await scheduler.selectQueues(masterKey, "consumer-1", context);
// Only t2 should be returned
const tenantIds = result.map((r) => r.tenantId);
expect(tenantIds).not.toContain("t1");
expect(tenantIds).toContain("t2");
await scheduler.close();
await redis.quit();
}
);
redisTest(
"should skip tenants with insufficient deficit",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const redis = createRedisClient(redisOptions);
const scheduler = new DRRScheduler({
redis: redisOptions,
keys,
quantum: 5,
maxDeficit: 50,
});
const masterKey = keys.masterQueueKey(0);
const now = Date.now();
await redis.zadd(
masterKey,
now - 100,
"tenant:t1:queue:q1",
now - 50,
"tenant:t2:queue:q1"
);
// Set t1 deficit to 0 (no credits)
const deficitKey = `test:drr:deficit`;
await redis.hset(deficitKey, "t1", "0");
const context: SchedulerContext = {
getCurrentConcurrency: async () => 0,
getConcurrencyLimit: async () => 100,
isAtCapacity: async () => false,
getQueueDescriptor: (queueId) => ({
id: queueId,
tenantId: keys.extractTenantId(queueId),
metadata: {},
}),
};
// First call adds quantum to both tenants
// t1: 0 + 5 = 5, t2: 0 + 5 = 5
const result = await scheduler.selectQueues(masterKey, "consumer-1", context);
// Both should be returned (both have deficit >= 1 after quantum added)
const tenantIds = result.map((r) => r.tenantId);
expect(tenantIds).toContain("t1");
expect(tenantIds).toContain("t2");
await scheduler.close();
await redis.quit();
}
);
redisTest(
"should order tenants by deficit (highest first)",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const redis = createRedisClient(redisOptions);
const scheduler = new DRRScheduler({
redis: redisOptions,
keys,
quantum: 5,
maxDeficit: 50,
});
const masterKey = keys.masterQueueKey(0);
const now = Date.now();
await redis.zadd(
masterKey,
now - 300,
"tenant:t1:queue:q1",
now - 200,
"tenant:t2:queue:q1",
now - 100,
"tenant:t3:queue:q1"
);
// Set different deficits
const deficitKey = `test:drr:deficit`;
await redis.hset(deficitKey, "t1", "10");
await redis.hset(deficitKey, "t2", "30");
await redis.hset(deficitKey, "t3", "20");
const context: SchedulerContext = {
getCurrentConcurrency: async () => 0,
getConcurrencyLimit: async () => 100,
isAtCapacity: async () => false,
getQueueDescriptor: (queueId) => ({
id: queueId,
tenantId: keys.extractTenantId(queueId),
metadata: {},
}),
};
const result = await scheduler.selectQueues(masterKey, "consumer-1", context);
// Should be ordered by deficit: t2 (35), t3 (25), t1 (15)
// (original + quantum of 5)
expect(result[0]?.tenantId).toBe("t2");
expect(result[1]?.tenantId).toBe("t3");
expect(result[2]?.tenantId).toBe("t1");
await scheduler.close();
await redis.quit();
}
);
});
describe("get all deficits", () => {
redisTest("should return all tenant deficits", { timeout: 10000 }, async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const redis = createRedisClient(redisOptions);
const scheduler = new DRRScheduler({
redis: redisOptions,
keys,
quantum: 5,
maxDeficit: 50,
});
const deficitKey = `test:drr:deficit`;
await redis.hset(deficitKey, "t1", "10");
await redis.hset(deficitKey, "t2", "20");
await redis.hset(deficitKey, "t3", "30");
const deficits = await scheduler.getAllDeficits();
expect(deficits.get("t1")).toBe(10);
expect(deficits.get("t2")).toBe(20);
expect(deficits.get("t3")).toBe(30);
await scheduler.close();
await redis.quit();
});
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,182 @@
import { describe, expect, it } from "vitest";
import {
ExponentialBackoffRetry,
FixedDelayRetry,
LinearBackoffRetry,
NoRetry,
ImmediateRetry,
CustomRetry,
} from "../retry.js";
describe("RetryStrategy", () => {
describe("ExponentialBackoffRetry", () => {
it("should return increasing delays", () => {
const strategy = new ExponentialBackoffRetry({
maxAttempts: 5,
factor: 2,
minTimeoutInMs: 100,
maxTimeoutInMs: 10000,
randomize: false,
});
const delay1 = strategy.getNextDelay(1);
const delay2 = strategy.getNextDelay(2);
const delay3 = strategy.getNextDelay(3);
// Delays should increase
expect(delay1).not.toBeNull();
expect(delay2).not.toBeNull();
expect(delay3).not.toBeNull();
expect(delay2!).toBeGreaterThan(delay1!);
expect(delay3!).toBeGreaterThan(delay2!);
});
it("should return null when max attempts reached", () => {
const strategy = new ExponentialBackoffRetry({ maxAttempts: 3 });
expect(strategy.getNextDelay(1)).not.toBeNull();
expect(strategy.getNextDelay(2)).not.toBeNull();
expect(strategy.getNextDelay(3)).toBeNull();
});
it("should have correct maxAttempts", () => {
const strategy = new ExponentialBackoffRetry({ maxAttempts: 7 });
expect(strategy.maxAttempts).toBe(7);
});
});
describe("FixedDelayRetry", () => {
it("should return same delay for all attempts", () => {
const strategy = new FixedDelayRetry({ maxAttempts: 5, delayMs: 500 });
expect(strategy.getNextDelay(1)).toBe(500);
expect(strategy.getNextDelay(2)).toBe(500);
expect(strategy.getNextDelay(3)).toBe(500);
expect(strategy.getNextDelay(4)).toBe(500);
});
it("should return null when max attempts reached", () => {
const strategy = new FixedDelayRetry({ maxAttempts: 3, delayMs: 500 });
expect(strategy.getNextDelay(1)).toBe(500);
expect(strategy.getNextDelay(2)).toBe(500);
expect(strategy.getNextDelay(3)).toBeNull();
});
});
describe("LinearBackoffRetry", () => {
it("should return linearly increasing delays", () => {
const strategy = new LinearBackoffRetry({
maxAttempts: 5,
baseDelayMs: 100,
});
expect(strategy.getNextDelay(1)).toBe(100);
expect(strategy.getNextDelay(2)).toBe(200);
expect(strategy.getNextDelay(3)).toBe(300);
expect(strategy.getNextDelay(4)).toBe(400);
});
it("should cap at maxDelayMs", () => {
const strategy = new LinearBackoffRetry({
maxAttempts: 10,
baseDelayMs: 100,
maxDelayMs: 250,
});
expect(strategy.getNextDelay(1)).toBe(100);
expect(strategy.getNextDelay(2)).toBe(200);
expect(strategy.getNextDelay(3)).toBe(250);
expect(strategy.getNextDelay(5)).toBe(250);
});
it("should return null when max attempts reached", () => {
const strategy = new LinearBackoffRetry({
maxAttempts: 3,
baseDelayMs: 100,
});
expect(strategy.getNextDelay(3)).toBeNull();
});
});
describe("NoRetry", () => {
it("should always return null", () => {
const strategy = new NoRetry();
expect(strategy.getNextDelay(1)).toBeNull();
expect(strategy.getNextDelay(0)).toBeNull();
});
it("should have maxAttempts of 1", () => {
const strategy = new NoRetry();
expect(strategy.maxAttempts).toBe(1);
});
});
describe("ImmediateRetry", () => {
it("should return 0 delay for all attempts", () => {
const strategy = new ImmediateRetry(5);
expect(strategy.getNextDelay(1)).toBe(0);
expect(strategy.getNextDelay(2)).toBe(0);
expect(strategy.getNextDelay(4)).toBe(0);
});
it("should return null when max attempts reached", () => {
const strategy = new ImmediateRetry(3);
expect(strategy.getNextDelay(3)).toBeNull();
});
});
describe("CustomRetry", () => {
it("should use custom calculation function", () => {
const strategy = new CustomRetry({
maxAttempts: 5,
calculateDelay: (attempt) => attempt * attempt * 100,
});
expect(strategy.getNextDelay(1)).toBe(100);
expect(strategy.getNextDelay(2)).toBe(400);
expect(strategy.getNextDelay(3)).toBe(900);
expect(strategy.getNextDelay(4)).toBe(1600);
});
it("should pass error to calculation function", () => {
const errors: Error[] = [];
const strategy = new CustomRetry({
maxAttempts: 5,
calculateDelay: (_attempt, error) => {
if (error) errors.push(error);
return 100;
},
});
const testError = new Error("test error");
strategy.getNextDelay(1, testError);
expect(errors).toHaveLength(1);
expect(errors[0]).toBe(testError);
});
it("should return null when max attempts reached", () => {
const strategy = new CustomRetry({
maxAttempts: 3,
calculateDelay: () => 100,
});
expect(strategy.getNextDelay(3)).toBeNull();
});
it("should allow custom function to return null for DLQ", () => {
const strategy = new CustomRetry({
maxAttempts: 5,
calculateDelay: (attempt) => (attempt === 2 ? null : 100),
});
expect(strategy.getNextDelay(1)).toBe(100);
expect(strategy.getNextDelay(2)).toBeNull(); // Custom function says DLQ
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,916 @@
import { describe, expect } from "vitest";
import { redisTest } from "@internal/testcontainers";
import { createRedisClient } from "@internal/redis";
import { VisibilityManager, DefaultFairQueueKeyProducer } from "../index.js";
import type { FairQueueKeyProducer, ReclaimedMessageInfo } from "../types.js";
describe("VisibilityManager", () => {
let keys: FairQueueKeyProducer;
describe("heartbeat", () => {
redisTest(
"should return true when message exists in in-flight set",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new VisibilityManager({
redis: redisOptions,
keys,
shardCount: 1,
defaultTimeoutMs: 5000,
});
const redis = createRedisClient(redisOptions);
const queueId = "tenant:t1:queue:heartbeat-exists";
const queueKey = keys.queueKey(queueId);
const queueItemsKey = keys.queueItemsKey(queueId);
// Add a message to the queue
const messageId = "heartbeat-test-msg";
const storedMessage = {
id: messageId,
queueId,
tenantId: "t1",
payload: { id: 1, value: "test" },
timestamp: Date.now() - 1000,
attempt: 1,
};
await redis.zadd(queueKey, storedMessage.timestamp, messageId);
await redis.hset(queueItemsKey, messageId, JSON.stringify(storedMessage));
// Claim the message (moves it to in-flight set)
const claimResult = await manager.claim(
queueId,
queueKey,
queueItemsKey,
"consumer-1",
5000
);
expect(claimResult.claimed).toBe(true);
// Heartbeat should succeed since message is in-flight
const heartbeatResult = await manager.heartbeat(messageId, queueId, 5000);
expect(heartbeatResult).toBe(true);
await manager.close();
await redis.quit();
}
);
redisTest(
"should return false when message does not exist in in-flight set",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new VisibilityManager({
redis: redisOptions,
keys,
shardCount: 1,
defaultTimeoutMs: 5000,
});
// Heartbeat for a message that was never claimed
const heartbeatResult = await manager.heartbeat(
"non-existent-msg",
"tenant:t1:queue:non-existent",
5000
);
expect(heartbeatResult).toBe(false);
await manager.close();
}
);
redisTest(
"should return false after message is completed",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new VisibilityManager({
redis: redisOptions,
keys,
shardCount: 1,
defaultTimeoutMs: 5000,
});
const redis = createRedisClient(redisOptions);
const queueId = "tenant:t1:queue:heartbeat-after-complete";
const queueKey = keys.queueKey(queueId);
const queueItemsKey = keys.queueItemsKey(queueId);
// Add and claim a message
const messageId = "completed-msg";
const storedMessage = {
id: messageId,
queueId,
tenantId: "t1",
payload: { id: 1, value: "test" },
timestamp: Date.now() - 1000,
attempt: 1,
};
await redis.zadd(queueKey, storedMessage.timestamp, messageId);
await redis.hset(queueItemsKey, messageId, JSON.stringify(storedMessage));
const claimResult = await manager.claim(
queueId,
queueKey,
queueItemsKey,
"consumer-1",
5000
);
expect(claimResult.claimed).toBe(true);
// Heartbeat should work before complete
const heartbeatBefore = await manager.heartbeat(messageId, queueId, 5000);
expect(heartbeatBefore).toBe(true);
// Complete the message
await manager.complete(messageId, queueId);
// Heartbeat should fail after complete
const heartbeatAfter = await manager.heartbeat(messageId, queueId, 5000);
expect(heartbeatAfter).toBe(false);
await manager.close();
await redis.quit();
}
);
redisTest(
"should correctly update the deadline score",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new VisibilityManager({
redis: redisOptions,
keys,
shardCount: 1,
defaultTimeoutMs: 1000,
});
const redis = createRedisClient(redisOptions);
const queueId = "tenant:t1:queue:heartbeat-deadline";
const queueKey = keys.queueKey(queueId);
const queueItemsKey = keys.queueItemsKey(queueId);
// Add and claim a message with short timeout
const messageId = "deadline-test-msg";
const storedMessage = {
id: messageId,
queueId,
tenantId: "t1",
payload: { id: 1, value: "test" },
timestamp: Date.now() - 1000,
attempt: 1,
};
await redis.zadd(queueKey, storedMessage.timestamp, messageId);
await redis.hset(queueItemsKey, messageId, JSON.stringify(storedMessage));
// Claim with 1 second timeout
await manager.claim(queueId, queueKey, queueItemsKey, "consumer-1", 1000);
// Get initial deadline
const inflightKey = keys.inflightKey(0);
const member = `${messageId}:${queueId}`;
const initialScore = await redis.zscore(inflightKey, member);
expect(initialScore).not.toBeNull();
// Wait a bit
await new Promise((resolve) => setTimeout(resolve, 100));
// Extend deadline by 10 seconds
const beforeHeartbeat = Date.now();
const heartbeatSuccess = await manager.heartbeat(messageId, queueId, 10000);
expect(heartbeatSuccess).toBe(true);
// Check that deadline was extended
const newScore = await redis.zscore(inflightKey, member);
expect(newScore).not.toBeNull();
// New deadline should be approximately now + 10 seconds
const newDeadline = parseFloat(newScore!);
expect(newDeadline).toBeGreaterThan(parseFloat(initialScore!));
expect(newDeadline).toBeGreaterThanOrEqual(beforeHeartbeat + 10000);
// Allow some tolerance for execution time
expect(newDeadline).toBeLessThan(beforeHeartbeat + 11000);
await manager.close();
await redis.quit();
}
);
redisTest(
"should handle multiple consecutive heartbeats",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new VisibilityManager({
redis: redisOptions,
keys,
shardCount: 1,
defaultTimeoutMs: 1000,
});
const redis = createRedisClient(redisOptions);
const queueId = "tenant:t1:queue:multi-heartbeat";
const queueKey = keys.queueKey(queueId);
const queueItemsKey = keys.queueItemsKey(queueId);
// Add and claim a message
const messageId = "multi-heartbeat-msg";
const storedMessage = {
id: messageId,
queueId,
tenantId: "t1",
payload: { id: 1, value: "test" },
timestamp: Date.now() - 1000,
attempt: 1,
};
await redis.zadd(queueKey, storedMessage.timestamp, messageId);
await redis.hset(queueItemsKey, messageId, JSON.stringify(storedMessage));
await manager.claim(queueId, queueKey, queueItemsKey, "consumer-1", 1000);
// Multiple heartbeats should all succeed
for (let i = 0; i < 5; i++) {
const result = await manager.heartbeat(messageId, queueId, 1000);
expect(result).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 50));
}
// Message should still be in-flight
const inflightCount = await manager.getTotalInflightCount();
expect(inflightCount).toBe(1);
await manager.close();
await redis.quit();
}
);
});
describe("claimBatch", () => {
redisTest(
"should claim multiple messages atomically",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new VisibilityManager({
redis: redisOptions,
keys,
shardCount: 1,
defaultTimeoutMs: 5000,
});
const redis = createRedisClient(redisOptions);
const queueId = "tenant:t1:queue:claim-batch";
const queueKey = keys.queueKey(queueId);
const queueItemsKey = keys.queueItemsKey(queueId);
// Add multiple messages to the queue
for (let i = 1; i <= 5; i++) {
const messageId = `msg-${i}`;
const storedMessage = {
id: messageId,
queueId,
tenantId: "t1",
payload: { value: `test-${i}` },
timestamp: Date.now() - (6 - i) * 1000,
attempt: 1,
};
await redis.zadd(queueKey, storedMessage.timestamp, messageId);
await redis.hset(queueItemsKey, messageId, JSON.stringify(storedMessage));
}
// Claim batch of 3 messages
const claimed = await manager.claimBatch(queueId, queueKey, queueItemsKey, "consumer-1", 3);
expect(claimed).toHaveLength(3);
expect(claimed[0]!.messageId).toBe("msg-1");
expect(claimed[1]!.messageId).toBe("msg-2");
expect(claimed[2]!.messageId).toBe("msg-3");
// Verify messages are in in-flight set
const inflightCount = await manager.getTotalInflightCount();
expect(inflightCount).toBe(3);
// Verify messages are removed from queue
const remainingCount = await redis.zcard(queueKey);
expect(remainingCount).toBe(2);
await manager.close();
await redis.quit();
}
);
redisTest(
"should return empty array when queue is empty",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new VisibilityManager({
redis: redisOptions,
keys,
shardCount: 1,
defaultTimeoutMs: 5000,
});
const queueId = "tenant:t1:queue:empty";
const queueKey = keys.queueKey(queueId);
const queueItemsKey = keys.queueItemsKey(queueId);
const claimed = await manager.claimBatch(queueId, queueKey, queueItemsKey, "consumer-1", 5);
expect(claimed).toHaveLength(0);
await manager.close();
}
);
redisTest(
"should claim all available messages when queue has fewer than maxCount",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new VisibilityManager({
redis: redisOptions,
keys,
shardCount: 1,
defaultTimeoutMs: 5000,
});
const redis = createRedisClient(redisOptions);
const queueId = "tenant:t1:queue:partial-batch";
const queueKey = keys.queueKey(queueId);
const queueItemsKey = keys.queueItemsKey(queueId);
// Add only 3 messages to the queue
for (let i = 1; i <= 3; i++) {
const messageId = `msg-${i}`;
const storedMessage = {
id: messageId,
queueId,
tenantId: "t1",
payload: { value: `test-${i}` },
timestamp: Date.now() - (4 - i) * 1000,
attempt: 1,
};
await redis.zadd(queueKey, storedMessage.timestamp, messageId);
await redis.hset(queueItemsKey, messageId, JSON.stringify(storedMessage));
}
// Request 10 messages but only 3 exist
const claimed = await manager.claimBatch(
queueId,
queueKey,
queueItemsKey,
"consumer-1",
10
);
expect(claimed).toHaveLength(3);
expect(claimed[0]!.messageId).toBe("msg-1");
expect(claimed[1]!.messageId).toBe("msg-2");
expect(claimed[2]!.messageId).toBe("msg-3");
// Verify all messages are in in-flight set
const inflightCount = await manager.getTotalInflightCount();
expect(inflightCount).toBe(3);
// Verify queue is empty
const remainingCount = await redis.zcard(queueKey);
expect(remainingCount).toBe(0);
await manager.close();
await redis.quit();
}
);
redisTest(
"should skip corrupted messages and continue claiming valid ones",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new VisibilityManager({
redis: redisOptions,
keys,
shardCount: 1,
defaultTimeoutMs: 5000,
});
const redis = createRedisClient(redisOptions);
const queueId = "tenant:t1:queue:corrupted-batch";
const queueKey = keys.queueKey(queueId);
const queueItemsKey = keys.queueItemsKey(queueId);
// Add valid message 1
const storedMessage1 = {
id: "msg-1",
queueId,
tenantId: "t1",
payload: { value: "test-1" },
timestamp: Date.now() - 3000,
attempt: 1,
};
await redis.zadd(queueKey, storedMessage1.timestamp, "msg-1");
await redis.hset(queueItemsKey, "msg-1", JSON.stringify(storedMessage1));
// Add corrupted message 2 (invalid JSON)
await redis.zadd(queueKey, Date.now() - 2000, "msg-2");
await redis.hset(queueItemsKey, "msg-2", "not-valid-json{{{");
// Add valid message 3
const storedMessage3 = {
id: "msg-3",
queueId,
tenantId: "t1",
payload: { value: "test-3" },
timestamp: Date.now() - 1000,
attempt: 1,
};
await redis.zadd(queueKey, storedMessage3.timestamp, "msg-3");
await redis.hset(queueItemsKey, "msg-3", JSON.stringify(storedMessage3));
// Claim all 3 messages
const claimed = await manager.claimBatch(queueId, queueKey, queueItemsKey, "consumer-1", 5);
// Should only return the 2 valid messages
expect(claimed).toHaveLength(2);
expect(claimed[0]!.messageId).toBe("msg-1");
expect(claimed[1]!.messageId).toBe("msg-3");
// Corrupted message should have been removed from in-flight
// Valid messages should be in in-flight
const inflightCount = await manager.getTotalInflightCount();
expect(inflightCount).toBe(2);
// Queue should be empty (all messages processed or removed)
const remainingCount = await redis.zcard(queueKey);
expect(remainingCount).toBe(0);
await manager.close();
await redis.quit();
}
);
});
describe("releaseBatch", () => {
redisTest(
"should release multiple messages back to queue atomically",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new VisibilityManager({
redis: redisOptions,
keys,
shardCount: 1,
defaultTimeoutMs: 5000,
});
const redis = createRedisClient(redisOptions);
const queueId = "tenant:t1:queue:release-batch";
const queueKey = keys.queueKey(queueId);
const queueItemsKey = keys.queueItemsKey(queueId);
const tenantQueueIndexKey = keys.tenantQueueIndexKey("t1");
const dispatchKey = keys.dispatchKey(0);
// Add messages to queue and claim them
for (let i = 1; i <= 5; i++) {
const messageId = `msg-${i}`;
const storedMessage = {
id: messageId,
queueId,
tenantId: "t1",
payload: { value: `test-${i}` },
timestamp: Date.now() - (6 - i) * 1000,
attempt: 1,
};
await redis.zadd(queueKey, storedMessage.timestamp, messageId);
await redis.hset(queueItemsKey, messageId, JSON.stringify(storedMessage));
}
// Claim all 5 messages
const claimed = await manager.claimBatch(queueId, queueKey, queueItemsKey, "consumer-1", 5);
expect(claimed).toHaveLength(5);
// Verify all messages are in-flight
let inflightCount = await manager.getTotalInflightCount();
expect(inflightCount).toBe(5);
// Queue should be empty
let queueCount = await redis.zcard(queueKey);
expect(queueCount).toBe(0);
// Release messages 3, 4, 5 back to queue (batch release)
const messagesToRelease = claimed.slice(2);
await manager.releaseBatch(
messagesToRelease,
queueId,
queueKey,
queueItemsKey,
tenantQueueIndexKey,
dispatchKey,
"t1"
);
// Verify 2 messages still in-flight
inflightCount = await manager.getTotalInflightCount();
expect(inflightCount).toBe(2);
// Verify 3 messages back in queue
queueCount = await redis.zcard(queueKey);
expect(queueCount).toBe(3);
// Verify the correct messages are back in queue
const queueMembers = await redis.zrange(queueKey, 0, -1);
expect(queueMembers).toContain("msg-3");
expect(queueMembers).toContain("msg-4");
expect(queueMembers).toContain("msg-5");
await manager.close();
await redis.quit();
}
);
redisTest(
"should handle empty messages array",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new VisibilityManager({
redis: redisOptions,
keys,
shardCount: 1,
defaultTimeoutMs: 5000,
});
const queueId = "tenant:t1:queue:empty-release";
const queueKey = keys.queueKey(queueId);
const queueItemsKey = keys.queueItemsKey(queueId);
const tenantQueueIndexKey = keys.tenantQueueIndexKey("t1");
const dispatchKey = keys.dispatchKey(0);
// Should not throw when releasing empty array
await manager.releaseBatch(
[],
queueId,
queueKey,
queueItemsKey,
tenantQueueIndexKey,
dispatchKey,
"t1"
);
await manager.close();
}
);
redisTest(
"should update dispatch indexes with oldest message timestamp",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new VisibilityManager({
redis: redisOptions,
keys,
shardCount: 1,
defaultTimeoutMs: 5000,
});
const redis = createRedisClient(redisOptions);
const queueId = "tenant:t1:queue:dispatch-update";
const queueKey = keys.queueKey(queueId);
const queueItemsKey = keys.queueItemsKey(queueId);
const tenantQueueIndexKey = keys.tenantQueueIndexKey("t1");
const dispatchKey = keys.dispatchKey(0);
// Add and claim messages
const baseTime = Date.now();
for (let i = 1; i <= 3; i++) {
const messageId = `msg-${i}`;
const storedMessage = {
id: messageId,
queueId,
tenantId: "t1",
payload: { value: `test-${i}` },
timestamp: baseTime + i * 1000, // Different timestamps
attempt: 1,
};
await redis.zadd(queueKey, storedMessage.timestamp, messageId);
await redis.hset(queueItemsKey, messageId, JSON.stringify(storedMessage));
}
const claimed = await manager.claimBatch(queueId, queueKey, queueItemsKey, "consumer-1", 3);
// Release all messages back
await manager.releaseBatch(
claimed,
queueId,
queueKey,
queueItemsKey,
tenantQueueIndexKey,
dispatchKey,
"t1"
);
// Tenant queue index should have the queue with correct score
const tenantQueueScore = await redis.zscore(tenantQueueIndexKey, queueId);
expect(tenantQueueScore).not.toBeNull();
// Dispatch index should have the tenant
const dispatchScore = await redis.zscore(dispatchKey, "t1");
expect(dispatchScore).not.toBeNull();
await manager.close();
await redis.quit();
}
);
});
describe("reclaimTimedOut", () => {
redisTest(
"should return reclaimed message info with tenantId for concurrency release",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new VisibilityManager({
redis: redisOptions,
keys,
shardCount: 1,
defaultTimeoutMs: 100, // Very short timeout
});
const redis = createRedisClient(redisOptions);
const queueId = "tenant:t1:queue:reclaim-test";
const queueKey = keys.queueKey(queueId);
const queueItemsKey = keys.queueItemsKey(queueId);
const _tenantQueueIndexKey = keys.tenantQueueIndexKey("t1");
const dispatchKey = keys.dispatchKey(0);
// Add and claim a message
const messageId = "reclaim-msg";
const storedMessage = {
id: messageId,
queueId,
tenantId: "t1",
payload: { id: 1, value: "test" },
timestamp: Date.now() - 1000,
attempt: 1,
metadata: { orgId: "org-123" },
};
await redis.zadd(queueKey, storedMessage.timestamp, messageId);
await redis.hset(queueItemsKey, messageId, JSON.stringify(storedMessage));
// Claim with very short timeout
const claimResult = await manager.claim(
queueId,
queueKey,
queueItemsKey,
"consumer-1",
100
);
expect(claimResult.claimed).toBe(true);
// Wait for timeout to expire
await new Promise((resolve) => setTimeout(resolve, 150));
// Reclaim should return the message info
const reclaimedMessages = await manager.reclaimTimedOut(0, (qId) => ({
queueKey: keys.queueKey(qId),
queueItemsKey: keys.queueItemsKey(qId),
tenantQueueIndexKey: keys.tenantQueueIndexKey(keys.extractTenantId(qId)),
dispatchKey,
tenantId: keys.extractTenantId(qId),
}));
expect(reclaimedMessages).toHaveLength(1);
expect(reclaimedMessages[0]).toEqual({
messageId,
queueId,
tenantId: "t1",
metadata: { orgId: "org-123" },
});
// Verify message is back in queue
const queueCount = await redis.zcard(queueKey);
expect(queueCount).toBe(1);
// Verify message is back in queue with its original timestamp (not the deadline)
const queueMessages = await redis.zrange(queueKey, 0, -1, "WITHSCORES");
expect(queueMessages[0]).toBe(messageId);
expect(parseInt(queueMessages[1]!)).toBe(storedMessage.timestamp);
// Verify message is no longer in-flight
const inflightCount = await manager.getTotalInflightCount();
expect(inflightCount).toBe(0);
await manager.close();
await redis.quit();
}
);
redisTest(
"should return empty array when no messages have timed out",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new VisibilityManager({
redis: redisOptions,
keys,
shardCount: 1,
defaultTimeoutMs: 60000, // Long timeout
});
const redis = createRedisClient(redisOptions);
const queueId = "tenant:t1:queue:no-timeout";
const queueKey = keys.queueKey(queueId);
const queueItemsKey = keys.queueItemsKey(queueId);
const _tenantQueueIndexKey = keys.tenantQueueIndexKey("t1");
const dispatchKey = keys.dispatchKey(0);
// Add and claim a message with long timeout
const messageId = "long-timeout-msg";
const storedMessage = {
id: messageId,
queueId,
tenantId: "t1",
payload: { id: 1 },
timestamp: Date.now() - 1000,
attempt: 1,
};
await redis.zadd(queueKey, storedMessage.timestamp, messageId);
await redis.hset(queueItemsKey, messageId, JSON.stringify(storedMessage));
await manager.claim(queueId, queueKey, queueItemsKey, "consumer-1");
// Reclaim should return empty array (message hasn't timed out)
const reclaimedMessages = await manager.reclaimTimedOut(0, (qId) => ({
queueKey: keys.queueKey(qId),
queueItemsKey: keys.queueItemsKey(qId),
tenantQueueIndexKey: keys.tenantQueueIndexKey(keys.extractTenantId(qId)),
dispatchKey,
tenantId: keys.extractTenantId(qId),
}));
expect(reclaimedMessages).toHaveLength(0);
await manager.close();
await redis.quit();
}
);
redisTest(
"should reclaim multiple timed-out messages and return all their info",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new VisibilityManager({
redis: redisOptions,
keys,
shardCount: 1,
defaultTimeoutMs: 100,
});
const redis = createRedisClient(redisOptions);
const _tenantQueueIndexKey = keys.tenantQueueIndexKey("t1");
const dispatchKey = keys.dispatchKey(0);
// Add and claim messages for two different tenants
for (const tenant of ["t1", "t2"]) {
const queueId = `tenant:${tenant}:queue:multi-reclaim`;
const queueKey = keys.queueKey(queueId);
const queueItemsKey = keys.queueItemsKey(queueId);
const messageId = `msg-${tenant}`;
const storedMessage = {
id: messageId,
queueId,
tenantId: tenant,
payload: { id: 1 },
timestamp: Date.now() - 1000,
attempt: 1,
};
await redis.zadd(queueKey, storedMessage.timestamp, messageId);
await redis.hset(queueItemsKey, messageId, JSON.stringify(storedMessage));
await manager.claim(queueId, queueKey, queueItemsKey, "consumer-1", 100);
}
// Wait for timeout
await new Promise((resolve) => setTimeout(resolve, 150));
// Reclaim should return both messages
const reclaimedMessages = await manager.reclaimTimedOut(0, (qId) => ({
queueKey: keys.queueKey(qId),
queueItemsKey: keys.queueItemsKey(qId),
tenantQueueIndexKey: keys.tenantQueueIndexKey(keys.extractTenantId(qId)),
dispatchKey,
tenantId: keys.extractTenantId(qId),
}));
expect(reclaimedMessages).toHaveLength(2);
// Verify both tenants are represented
const tenantIds = reclaimedMessages.map((m: ReclaimedMessageInfo) => m.tenantId).sort();
expect(tenantIds).toEqual(["t1", "t2"]);
await manager.close();
await redis.quit();
}
);
redisTest(
"should use fallback tenantId extraction when message data is missing or corrupted",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new VisibilityManager({
redis: redisOptions,
keys,
shardCount: 1,
defaultTimeoutMs: 100,
});
const redis = createRedisClient(redisOptions);
const queueId = "tenant:t1:queue:fallback-test";
const queueKey = keys.queueKey(queueId);
const queueItemsKey = keys.queueItemsKey(queueId);
const _tenantQueueIndexKey = keys.tenantQueueIndexKey("t1");
const dispatchKey = keys.dispatchKey(0);
const inflightDataKey = keys.inflightDataKey(0);
// Add and claim a message
const messageId = "fallback-msg";
const storedMessage = {
id: messageId,
queueId,
tenantId: "t1",
payload: { id: 1 },
timestamp: Date.now() - 1000,
attempt: 1,
metadata: { orgId: "org-123" },
};
await redis.zadd(queueKey, storedMessage.timestamp, messageId);
await redis.hset(queueItemsKey, messageId, JSON.stringify(storedMessage));
// Claim the message
const claimResult = await manager.claim(
queueId,
queueKey,
queueItemsKey,
"consumer-1",
100
);
expect(claimResult.claimed).toBe(true);
// Corrupt the in-flight data by setting invalid JSON
await redis.hset(inflightDataKey, messageId, "not-valid-json{{{");
// Wait for timeout
await new Promise((resolve) => setTimeout(resolve, 150));
// Reclaim should still work using fallback extraction
const reclaimedMessages = await manager.reclaimTimedOut(0, (qId) => ({
queueKey: keys.queueKey(qId),
queueItemsKey: keys.queueItemsKey(qId),
tenantQueueIndexKey: keys.tenantQueueIndexKey(keys.extractTenantId(qId)),
dispatchKey,
tenantId: keys.extractTenantId(qId),
}));
expect(reclaimedMessages).toHaveLength(1);
expect(reclaimedMessages[0]).toEqual({
messageId,
queueId,
tenantId: "t1", // Extracted from queueId via fallback
metadata: {}, // Empty metadata since we couldn't parse the stored message
});
await manager.close();
await redis.quit();
}
);
});
});
@@ -0,0 +1,237 @@
import { describe, expect } from "vitest";
import { redisTest } from "@internal/testcontainers";
import { WorkerQueueManager } from "../workerQueue.js";
import { DefaultFairQueueKeyProducer } from "../keyProducer.js";
import type { FairQueueKeyProducer } from "../types.js";
describe("WorkerQueueManager", () => {
let keys: FairQueueKeyProducer;
describe("push and pop", () => {
redisTest(
"should push and pop a single message",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new WorkerQueueManager({
redis: redisOptions,
keys,
});
// Push a message
await manager.push("worker-1", "msg-1:queue-1");
// Pop should return the message
const result = await manager.pop("worker-1");
expect(result).not.toBeNull();
expect(result!.messageKey).toBe("msg-1:queue-1");
expect(result!.queueLength).toBe(0);
await manager.close();
}
);
redisTest(
"should push and pop messages in FIFO order",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new WorkerQueueManager({
redis: redisOptions,
keys,
});
// Push messages
await manager.push("worker-1", "msg-1:queue-1");
await manager.push("worker-1", "msg-2:queue-1");
await manager.push("worker-1", "msg-3:queue-1");
// Pop should return in FIFO order
let result = await manager.pop("worker-1");
expect(result!.messageKey).toBe("msg-1:queue-1");
expect(result!.queueLength).toBe(2);
result = await manager.pop("worker-1");
expect(result!.messageKey).toBe("msg-2:queue-1");
expect(result!.queueLength).toBe(1);
result = await manager.pop("worker-1");
expect(result!.messageKey).toBe("msg-3:queue-1");
expect(result!.queueLength).toBe(0);
// Queue should be empty
result = await manager.pop("worker-1");
expect(result).toBeNull();
await manager.close();
}
);
redisTest("should push batch of messages", { timeout: 10000 }, async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new WorkerQueueManager({
redis: redisOptions,
keys,
});
// Push batch
await manager.pushBatch("worker-1", ["msg-1:queue-1", "msg-2:queue-1", "msg-3:queue-1"]);
// Check length
const length = await manager.getLength("worker-1");
expect(length).toBe(3);
await manager.close();
});
});
describe("getLength", () => {
redisTest(
"should return correct queue length",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new WorkerQueueManager({
redis: redisOptions,
keys,
});
// Initially empty
let length = await manager.getLength("worker-1");
expect(length).toBe(0);
// Push messages
await manager.push("worker-1", "msg-1:queue-1");
await manager.push("worker-1", "msg-2:queue-1");
length = await manager.getLength("worker-1");
expect(length).toBe(2);
// Pop one
await manager.pop("worker-1");
length = await manager.getLength("worker-1");
expect(length).toBe(1);
await manager.close();
}
);
});
describe("peek", () => {
redisTest(
"should peek at messages without removing them",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new WorkerQueueManager({
redis: redisOptions,
keys,
});
// Push messages
await manager.push("worker-1", "msg-1:queue-1");
await manager.push("worker-1", "msg-2:queue-1");
// Peek
const messages = await manager.peek("worker-1");
expect(messages).toEqual(["msg-1:queue-1", "msg-2:queue-1"]);
// Messages should still be there
const length = await manager.getLength("worker-1");
expect(length).toBe(2);
await manager.close();
}
);
});
describe("remove", () => {
redisTest("should remove a specific message", { timeout: 10000 }, async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new WorkerQueueManager({
redis: redisOptions,
keys,
});
// Push messages
await manager.push("worker-1", "msg-1:queue-1");
await manager.push("worker-1", "msg-2:queue-1");
await manager.push("worker-1", "msg-3:queue-1");
// Remove the middle one
const removed = await manager.remove("worker-1", "msg-2:queue-1");
expect(removed).toBe(1);
// Check remaining
const messages = await manager.peek("worker-1");
expect(messages).toEqual(["msg-1:queue-1", "msg-3:queue-1"]);
await manager.close();
});
});
describe("clear", () => {
redisTest(
"should clear all messages from queue",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new WorkerQueueManager({
redis: redisOptions,
keys,
});
// Push messages
await manager.push("worker-1", "msg-1:queue-1");
await manager.push("worker-1", "msg-2:queue-1");
// Clear
await manager.clear("worker-1");
// Should be empty
const length = await manager.getLength("worker-1");
expect(length).toBe(0);
await manager.close();
}
);
});
describe("separate worker queues", () => {
redisTest(
"should maintain separate queues for different workers",
{ timeout: 10000 },
async ({ redisOptions }) => {
keys = new DefaultFairQueueKeyProducer({ prefix: "test" });
const manager = new WorkerQueueManager({
redis: redisOptions,
keys,
});
// Push to different worker queues
await manager.push("worker-1", "msg-1-1:queue-1");
await manager.push("worker-2", "msg-2-1:queue-1");
await manager.push("worker-1", "msg-1-2:queue-1");
await manager.push("worker-2", "msg-2-2:queue-1");
// Each worker should have its own messages
const worker1Messages = await manager.peek("worker-1");
expect(worker1Messages).toEqual(["msg-1-1:queue-1", "msg-1-2:queue-1"]);
const worker2Messages = await manager.peek("worker-2");
expect(worker2Messages).toEqual(["msg-2-1:queue-1", "msg-2-2:queue-1"]);
await manager.close();
}
);
});
});
@@ -0,0 +1,652 @@
import type { RedisOptions } from "@internal/redis";
import type { Logger } from "@trigger.dev/core/logger";
import type { Tracer, Meter } from "@internal/tracing";
import type { z } from "zod";
import type { RetryStrategy } from "./retry.js";
// ============================================================================
// Global Rate Limiter
// ============================================================================
/**
* Interface for a global rate limiter that limits processing across all consumers.
* When configured, consumers will check this before processing each message.
*/
export interface GlobalRateLimiter {
/**
* Check if processing is allowed under the rate limit.
* @returns Object with allowed flag and optional resetAt timestamp (ms since epoch)
*/
limit(): Promise<{ allowed: boolean; resetAt?: number }>;
}
// ============================================================================
// Core Queue Types
// ============================================================================
/**
* Descriptor for a queue in the fair queue system.
* Contains all the metadata needed to identify and route a queue.
*/
export interface QueueDescriptor {
/** Unique queue identifier */
id: string;
/** Tenant this queue belongs to */
tenantId: string;
/** Additional metadata for concurrency group extraction */
metadata: Record<string, unknown>;
}
/**
* A message in the queue with its metadata.
*/
export interface QueueMessage<TPayload = unknown> {
/** Unique message identifier */
id: string;
/** The queue this message belongs to */
queueId: string;
/** Message payload */
payload: TPayload;
/** Timestamp when message was enqueued */
timestamp: number;
/** Current attempt number (1-indexed, for retries) */
attempt: number;
/** Optional metadata */
metadata?: Record<string, unknown>;
}
/**
* Internal message format stored in Redis.
* Includes additional fields for tracking and routing.
*/
export interface StoredMessage<TPayload = unknown> {
/** Message ID */
id: string;
/** Queue ID */
queueId: string;
/** Tenant ID */
tenantId: string;
/** Message payload */
payload: TPayload;
/** Timestamp when enqueued */
timestamp: number;
/** Current attempt number */
attempt: number;
/** Worker queue to route to */
workerQueue?: string;
/** Additional metadata */
metadata?: Record<string, unknown>;
}
/**
* Queue with its score (oldest message timestamp) from the master queue.
*/
export interface QueueWithScore {
/** Queue identifier */
queueId: string;
/** Score (typically oldest message timestamp) */
score: number;
/** Tenant ID extracted from queue */
tenantId: string;
}
// ============================================================================
// Concurrency Types
// ============================================================================
/**
* Configuration for a concurrency group.
* Allows defining arbitrary levels of concurrency (tenant, org, project, etc.)
*/
export interface ConcurrencyGroupConfig {
/** Group name (e.g., "tenant", "organization", "project") */
name: string;
/** Extract the group ID from a queue descriptor */
extractGroupId: (queue: QueueDescriptor) => string;
/** Get the concurrency limit for a specific group ID */
getLimit: (groupId: string) => Promise<number>;
/** Default limit if not specified */
defaultLimit: number;
}
/**
* Current concurrency state for a group.
*/
export interface ConcurrencyState {
/** Group name */
groupName: string;
/** Group ID */
groupId: string;
/** Current active count */
current: number;
/** Configured limit */
limit: number;
}
/**
* Result of a concurrency check.
*/
export interface ConcurrencyCheckResult {
/** Whether processing is allowed */
allowed: boolean;
/** If not allowed, which group is blocking */
blockedBy?: ConcurrencyState;
}
// ============================================================================
// Visibility Types
// ============================================================================
/**
* Information about a reclaimed message from visibility timeout.
* Used to release concurrency after a message is returned to the queue.
*/
export interface ReclaimedMessageInfo {
/** Message ID */
messageId: string;
/** Queue ID */
queueId: string;
/** Tenant ID for concurrency release */
tenantId: string;
/** Additional metadata for concurrency group extraction */
metadata?: Record<string, unknown>;
}
// ============================================================================
// Scheduler Types
// ============================================================================
/**
* Queues grouped by tenant for the scheduler.
*/
export interface TenantQueues {
/** Tenant identifier */
tenantId: string;
/** Queue IDs belonging to this tenant, in priority order */
queues: string[];
}
/**
* Context provided to the scheduler for making decisions.
*/
export interface SchedulerContext {
/** Get current concurrency for a group */
getCurrentConcurrency(groupName: string, groupId: string): Promise<number>;
/** Get concurrency limit for a group */
getConcurrencyLimit(groupName: string, groupId: string): Promise<number>;
/** Check if a group is at capacity */
isAtCapacity(groupName: string, groupId: string): Promise<boolean>;
/** Get queue descriptor by ID */
getQueueDescriptor(queueId: string): QueueDescriptor;
}
/**
* Extended context for two-level dispatch scheduling.
*/
export interface DispatchSchedulerContext extends SchedulerContext {
/** Get queues for a specific tenant from the per-tenant queue index (Level 2) */
getQueuesForTenant(tenantId: string, limit?: number): Promise<QueueWithScore[]>;
}
/**
* Pluggable scheduler interface for fair queue selection.
*/
export interface FairScheduler {
/**
* Select queues for processing from a master queue shard.
* Returns queues grouped by tenant, ordered by the fairness algorithm.
*
* @param masterQueueShard - The master queue shard key
* @param consumerId - The consumer making the request
* @param context - Context for concurrency checks
* @returns Queues grouped by tenant in priority order
*/
selectQueues(
masterQueueShard: string,
consumerId: string,
context: SchedulerContext
): Promise<TenantQueues[]>;
/**
* Select queues using the two-level tenant dispatch index.
* Level 1: reads tenantIds from dispatch shard.
* Level 2: reads queueIds from per-tenant index.
* Optional - falls back to selectQueues with flat queue list if not implemented.
*/
selectQueuesFromDispatch?(
dispatchShardKey: string,
consumerId: string,
context: DispatchSchedulerContext
): Promise<TenantQueues[]>;
/**
* Called after processing a message to update scheduler state.
* Optional - not all schedulers need to track state.
*/
recordProcessed?(tenantId: string, queueId: string): Promise<void>;
/**
* Called after processing multiple messages to update scheduler state.
* Batch variant for efficiency - reduces Redis calls when processing multiple messages.
* Optional - falls back to calling recordProcessed multiple times if not implemented.
*/
recordProcessedBatch?(tenantId: string, queueId: string, count: number): Promise<void>;
/**
* Initialize the scheduler (called once on startup).
*/
initialize?(): Promise<void>;
/**
* Cleanup scheduler resources.
*/
close?(): Promise<void>;
}
// ============================================================================
// Visibility Timeout Types
// ============================================================================
/**
* An in-flight message being processed.
*/
export interface InFlightMessage<TPayload = unknown> {
/** Message ID */
messageId: string;
/** Queue ID */
queueId: string;
/** Message payload */
payload: TPayload;
/** When visibility timeout expires */
deadline: number;
/** Consumer that claimed this message */
consumerId: string;
}
/**
* Result of claiming a message.
*/
export interface ClaimResult<TPayload = unknown> {
/** Whether the claim was successful */
claimed: boolean;
/** The claimed message if successful */
message?: InFlightMessage<TPayload>;
}
// ============================================================================
// Key Producer Interface
// ============================================================================
/**
* Interface for generating Redis keys for the fair queue system.
* Implementations can customize key prefixes and structures.
*/
export interface FairQueueKeyProducer {
// Master queue keys
/** Get the master queue key for a shard */
masterQueueKey(shardId: number): string;
// Individual queue keys
/** Get the queue key for storing messages */
queueKey(queueId: string): string;
/** Get the queue items hash key */
queueItemsKey(queueId: string): string;
// Concurrency tracking keys
/** Get the concurrency set key for a group */
concurrencyKey(groupName: string, groupId: string): string;
// In-flight tracking keys
/** Get the in-flight sorted set key for a shard */
inflightKey(shardId: number): string;
/** Get the in-flight message data hash key */
inflightDataKey(shardId: number): string;
// Worker queue keys
/** Get the worker queue key for a consumer */
workerQueueKey(consumerId: string): string;
// Dead letter queue keys
/** Get the dead letter queue key for a tenant */
deadLetterQueueKey(tenantId: string): string;
/** Get the dead letter queue data hash key for a tenant */
deadLetterQueueDataKey(tenantId: string): string;
// Tenant dispatch keys (two-level index)
/** Get the dispatch index key for a shard (Level 1: tenantIds with capacity) */
dispatchKey(shardId: number): string;
/** Get the per-tenant queue index key (Level 2: queueIds for a tenant) */
tenantQueueIndexKey(tenantId: string): string;
// Extraction methods
/** Extract tenant ID from a queue ID */
extractTenantId(queueId: string): string;
/** Extract a specific group ID from a queue ID */
extractGroupId(groupName: string, queueId: string): string;
}
// ============================================================================
// FairQueue Options
// ============================================================================
/**
* Worker queue configuration options.
* Worker queues are always enabled - FairQueue routes messages to worker queues,
* and external consumers are responsible for consuming from those queues.
*/
export interface WorkerQueueOptions<TPayload = unknown> {
/**
* Function to resolve which worker queue a message should go to.
* This is called during the claim-and-push phase to determine the target queue.
*/
resolveWorkerQueue: (message: StoredMessage<TPayload>) => string;
}
/**
* Retry and dead letter queue configuration.
*/
export interface RetryOptions {
/** Retry strategy for failed messages */
strategy: RetryStrategy;
/** Whether to enable dead letter queue (default: true) */
deadLetterQueue?: boolean;
}
/**
* Queue cooloff configuration to avoid repeatedly polling concurrency-limited queues.
*/
export interface CooloffOptions {
/** Whether cooloff is enabled (default: true) */
enabled?: boolean;
/** Number of consecutive empty dequeues before entering cooloff (default: 10) */
threshold?: number;
/** Duration of cooloff period in milliseconds (default: 10000) */
periodMs?: number;
/** Maximum number of cooloff state entries before triggering cleanup (default: 1000) */
maxStatesSize?: number;
}
/**
* Options for creating a FairQueue instance.
*
* @typeParam TPayloadSchema - Zod schema for message payload validation
*/
export interface FairQueueOptions<TPayloadSchema extends z.ZodTypeAny = z.ZodUnknown> {
/** Redis connection options */
redis: RedisOptions;
/** Key producer for Redis keys */
keys: FairQueueKeyProducer;
/** Scheduler for fair queue selection */
scheduler: FairScheduler;
// Payload validation
/** Zod schema for message payload validation */
payloadSchema?: TPayloadSchema;
/** Whether to validate payloads on enqueue (default: false) */
validateOnEnqueue?: boolean;
// Sharding
/** Number of master queue shards (default: 1) */
shardCount?: number;
// Concurrency
/** Concurrency group configurations */
concurrencyGroups?: ConcurrencyGroupConfig[];
// Worker queue (required)
/**
* Worker queue configuration.
* FairQueue routes messages to worker queues; external consumers handle consumption.
*/
workerQueue: WorkerQueueOptions<z.infer<TPayloadSchema>>;
// Retry and DLQ
/** Retry and dead letter queue configuration */
retry?: RetryOptions;
// Visibility timeout
/** Visibility timeout in milliseconds (default: 30000) */
visibilityTimeoutMs?: number;
/** Heartbeat interval in milliseconds (default: visibilityTimeoutMs / 3) */
heartbeatIntervalMs?: number;
/** Interval for reclaiming timed-out messages (default: 5000) */
reclaimIntervalMs?: number;
// Consumers
/** Number of consumer loops to run (default: 1) */
consumerCount?: number;
/** Interval between consumer iterations in milliseconds (default: 100) */
consumerIntervalMs?: number;
/** Whether to start consumers on initialization (default: true) */
startConsumers?: boolean;
// Batch claiming
/** Maximum number of messages to claim in a single batch operation (default: 10) */
batchClaimSize?: number;
// Consumer tracing
/** Maximum iterations before starting a new trace span (default: 500) */
consumerTraceMaxIterations?: number;
/** Maximum seconds before starting a new trace span (default: 60) */
consumerTraceTimeoutSeconds?: number;
// Cooloff
/** Queue cooloff configuration */
cooloff?: CooloffOptions;
// Observability
/** Logger instance */
logger?: Logger;
/** OpenTelemetry tracer */
tracer?: Tracer;
/** OpenTelemetry meter */
meter?: Meter;
/** Name for metrics/tracing (default: "fairqueue") */
name?: string;
// Worker queue backpressure
/**
* Maximum number of items allowed in a worker queue before claiming pauses.
* When set, the claim phase checks worker queue depth and skips claiming if
* the queue is at or above this limit. This prevents unbounded worker queue
* growth which could cause visibility timeouts (claimed messages have a
* visibility timeout that ticks while they sit in the worker queue).
* Requires `workerQueueDepthCheckId` to know which queue to check.
* Disabled by default (0 = no limit).
*/
workerQueueMaxDepth?: number;
/**
* The worker queue ID to check depth against when workerQueueMaxDepth is set.
* Required when workerQueueMaxDepth > 0 and the system uses a single shared worker queue.
* If not set, depth checking is disabled even if workerQueueMaxDepth is set.
*/
workerQueueDepthCheckId?: string;
}
// ============================================================================
// Message Handler Types
// ============================================================================
/**
* Context passed to the message handler.
*/
export interface MessageHandlerContext<TPayload = unknown> {
/** The message being processed */
message: QueueMessage<TPayload>;
/** Queue descriptor */
queue: QueueDescriptor;
/** Consumer ID processing this message */
consumerId: string;
/** Extend the visibility timeout */
heartbeat(): Promise<boolean>;
/** Mark message as successfully processed */
complete(): Promise<void>;
/** Release message back to the queue for retry */
release(): Promise<void>;
/** Mark message as failed (triggers retry or DLQ) */
fail(error?: Error): Promise<void>;
}
/**
* Handler function for processing messages.
*/
export type MessageHandler<TPayload = unknown> = (
context: MessageHandlerContext<TPayload>
) => Promise<void>;
// ============================================================================
// Dead Letter Queue Types
// ============================================================================
/**
* A message in the dead letter queue.
*/
export interface DeadLetterMessage<TPayload = unknown> {
/** Message ID */
id: string;
/** Original queue ID */
queueId: string;
/** Tenant ID */
tenantId: string;
/** Message payload */
payload: TPayload;
/** Timestamp when moved to DLQ */
deadLetteredAt: number;
/** Number of attempts before DLQ */
attempts: number;
/** Last error message if available */
lastError?: string;
/** Original message timestamp */
originalTimestamp: number;
}
// ============================================================================
// Cooloff State Types
// ============================================================================
/**
* Cooloff state for a queue.
*/
export type QueueCooloffState =
| { tag: "normal"; consecutiveFailures: number }
| { tag: "cooloff"; expiresAt: number };
// ============================================================================
// Enqueue Options
// ============================================================================
/**
* Options for enqueueing a message.
*/
export interface EnqueueOptions<TPayload = unknown> {
/** Queue to add the message to */
queueId: string;
/** Tenant ID for the queue */
tenantId: string;
/** Message payload */
payload: TPayload;
/** Optional message ID (auto-generated if not provided) */
messageId?: string;
/** Optional timestamp (defaults to now) */
timestamp?: number;
/** Optional metadata for concurrency group extraction */
metadata?: Record<string, string>;
}
/**
* Options for enqueueing multiple messages.
*/
export interface EnqueueBatchOptions<TPayload = unknown> {
/** Queue to add messages to */
queueId: string;
/** Tenant ID for the queue */
tenantId: string;
/** Messages to enqueue */
messages: Array<{
payload: TPayload;
messageId?: string;
timestamp?: number;
}>;
/** Optional metadata for concurrency group extraction */
metadata?: Record<string, string>;
}
// ============================================================================
// DRR Scheduler Types
// ============================================================================
/**
* Configuration for the Deficit Round Robin scheduler.
*/
export interface DRRSchedulerConfig {
/** Credits allocated per tenant per round */
quantum: number;
/** Maximum accumulated deficit (prevents starvation) */
maxDeficit: number;
/** Maximum queues to fetch from master queue (default: 1000) */
masterQueueLimit?: number;
/** Redis options for state storage */
redis: RedisOptions;
/** Key producer */
keys: FairQueueKeyProducer;
/** Optional logger */
logger?: {
debug: (message: string, context?: Record<string, unknown>) => void;
error: (message: string, context?: Record<string, unknown>) => void;
};
}
// ============================================================================
// Weighted Scheduler Types
// ============================================================================
/**
* Bias configuration for weighted shuffle scheduler.
*/
export interface WeightedSchedulerBiases {
/**
* How much to bias towards tenants with higher concurrency limits.
* 0 = no bias, 1 = full bias based on limit differences
*/
concurrencyLimitBias: number;
/**
* How much to bias towards tenants with more available capacity.
* 0 = no bias, 1 = full bias based on available capacity
*/
availableCapacityBias: number;
/**
* Controls randomization of queue ordering within tenants.
* 0 = strict age-based ordering (oldest first)
* 1 = completely random ordering
* Values between 0-1 blend between age-based and random ordering
*/
queueAgeRandomization: number;
}
/**
* Configuration for the weighted shuffle scheduler.
*/
export interface WeightedSchedulerConfig {
/** Redis options */
redis: RedisOptions;
/** Key producer */
keys: FairQueueKeyProducer;
/** Default tenant concurrency limit */
defaultTenantConcurrencyLimit?: number;
/** Maximum queues to consider from master queue */
masterQueueLimit?: number;
/** Bias configuration */
biases?: WeightedSchedulerBiases;
/** Number of iterations to reuse a snapshot */
reuseSnapshotCount?: number;
/** Maximum number of tenants to consider */
maximumTenantCount?: number;
/** Random seed for reproducibility */
seed?: string;
/** Optional tracer */
tracer?: Tracer;
}
@@ -0,0 +1,881 @@
import { createRedisClient, type Redis, type RedisOptions } from "@internal/redis";
import { jumpHash } from "@trigger.dev/core/v3/serverOnly";
import type {
ClaimResult,
FairQueueKeyProducer,
InFlightMessage,
ReclaimedMessageInfo,
StoredMessage,
} from "./types.js";
export interface VisibilityManagerOptions {
redis: RedisOptions;
keys: FairQueueKeyProducer;
shardCount: number;
defaultTimeoutMs: number;
logger?: {
debug: (message: string, context?: Record<string, unknown>) => void;
error: (message: string, context?: Record<string, unknown>) => void;
};
}
/**
* VisibilityManager handles message visibility timeouts for safe message processing.
*
* Features:
* - Claim messages with visibility timeout
* - Heartbeat to extend timeout
* - Automatic reclaim of timed-out messages
* - Per-shard in-flight tracking
*
* Data structures:
* - In-flight sorted set: score = deadline timestamp, member = "{messageId}:{queueId}"
* - In-flight data hash: field = messageId, value = JSON message data
*/
export class VisibilityManager {
private redis: Redis;
private keys: FairQueueKeyProducer;
private shardCount: number;
private defaultTimeoutMs: number;
private logger: NonNullable<VisibilityManagerOptions["logger"]>;
constructor(private options: VisibilityManagerOptions) {
this.redis = createRedisClient(options.redis);
this.keys = options.keys;
this.shardCount = options.shardCount;
this.defaultTimeoutMs = options.defaultTimeoutMs;
this.logger = options.logger ?? {
debug: () => {},
error: () => {},
};
this.#registerCommands();
}
// ============================================================================
// Public Methods
// ============================================================================
/**
* Claim a message for processing.
* Moves the message from its queue to the in-flight set with a visibility timeout.
*
* @param queueId - The queue to claim from
* @param queueKey - The Redis key for the queue sorted set
* @param queueItemsKey - The Redis key for the queue items hash
* @param consumerId - ID of the consumer claiming the message
* @param timeoutMs - Visibility timeout in milliseconds
* @returns Claim result with the message if successful
*/
async claim<TPayload = unknown>(
queueId: string,
queueKey: string,
queueItemsKey: string,
consumerId: string,
timeoutMs?: number
): Promise<ClaimResult<TPayload>> {
const timeout = timeoutMs ?? this.defaultTimeoutMs;
const deadline = Date.now() + timeout;
const shardId = this.#getShardForQueue(queueId);
const inflightKey = this.keys.inflightKey(shardId);
const inflightDataKey = this.keys.inflightDataKey(shardId);
// Use Lua script to atomically:
// 1. Pop oldest message from queue
// 2. Add to in-flight set with deadline
// 3. Store message data
const result = await this.redis.claimMessage(
queueKey,
queueItemsKey,
inflightKey,
inflightDataKey,
queueId,
consumerId,
deadline.toString()
);
if (!result) {
return { claimed: false };
}
const [messageId, payloadJson] = result;
try {
const payload = JSON.parse(payloadJson) as TPayload;
const message: InFlightMessage<TPayload> = {
messageId,
queueId,
payload,
deadline,
consumerId,
};
this.logger.debug("Message claimed", {
messageId,
queueId,
consumerId,
deadline,
});
return { claimed: true, message };
} catch (error) {
// JSON parse error - message data is corrupted
this.logger.error("Failed to parse claimed message", {
messageId,
queueId,
error: error instanceof Error ? error.message : String(error),
});
// Remove the corrupted message from in-flight
await this.#removeFromInflight(shardId, messageId, queueId);
return { claimed: false };
}
}
/**
* Claim multiple messages for processing (batch claim).
* Moves up to maxCount messages from the queue to the in-flight set.
*
* @param queueId - The queue to claim from
* @param queueKey - The Redis key for the queue sorted set
* @param queueItemsKey - The Redis key for the queue items hash
* @param consumerId - ID of the consumer claiming the messages
* @param maxCount - Maximum number of messages to claim
* @param timeoutMs - Visibility timeout in milliseconds
* @returns Array of claimed messages
*/
async claimBatch<TPayload = unknown>(
queueId: string,
queueKey: string,
queueItemsKey: string,
consumerId: string,
maxCount: number,
timeoutMs?: number
): Promise<Array<InFlightMessage<TPayload>>> {
const timeout = timeoutMs ?? this.defaultTimeoutMs;
const deadline = Date.now() + timeout;
const shardId = this.#getShardForQueue(queueId);
const inflightKey = this.keys.inflightKey(shardId);
const inflightDataKey = this.keys.inflightDataKey(shardId);
// Use Lua script to atomically claim up to maxCount messages
const result = await this.redis.claimMessageBatch(
queueKey,
queueItemsKey,
inflightKey,
inflightDataKey,
queueId,
deadline.toString(),
maxCount.toString()
);
if (!result || result.length === 0) {
return [];
}
const messages: Array<InFlightMessage<TPayload>> = [];
// Results come in pairs: [messageId1, payload1, messageId2, payload2, ...]
for (let i = 0; i < result.length; i += 2) {
const messageId = result[i];
const payloadJson = result[i + 1];
// Skip if either value is missing
if (!messageId || !payloadJson) {
continue;
}
try {
const payload = JSON.parse(payloadJson) as TPayload;
messages.push({
messageId,
queueId,
payload,
deadline,
consumerId,
});
} catch (error) {
// JSON parse error - skip this message
this.logger.error("Failed to parse claimed message in batch", {
messageId,
queueId,
error: error instanceof Error ? error.message : String(error),
});
// Remove the corrupted message from in-flight
await this.#removeFromInflight(shardId, messageId, queueId);
}
}
if (messages.length > 0) {
this.logger.debug("Batch claimed messages", {
queueId,
consumerId,
count: messages.length,
deadline,
});
}
return messages;
}
/**
* Extend the visibility timeout for a message (heartbeat).
*
* @param messageId - The message ID
* @param queueId - The queue ID
* @param extendMs - Additional milliseconds to add to the deadline
* @returns true if the heartbeat was successful
*/
async heartbeat(messageId: string, queueId: string, extendMs: number): Promise<boolean> {
const shardId = this.#getShardForQueue(queueId);
const inflightKey = this.keys.inflightKey(shardId);
const member = this.#makeMember(messageId, queueId);
const newDeadline = Date.now() + extendMs;
// Use Lua script to atomically check existence and update score
// ZADD XX returns 0 even on successful updates, so we use a custom command
const result = await this.redis.heartbeatMessage(inflightKey, member, newDeadline.toString());
const success = result === 1;
if (success) {
this.logger.debug("Heartbeat successful", {
messageId,
queueId,
newDeadline,
});
}
return success;
}
/**
* Mark a message as successfully processed.
* Removes the message from in-flight tracking.
*
* @param messageId - The message ID
* @param queueId - The queue ID
*/
async complete(messageId: string, queueId: string): Promise<void> {
const shardId = this.#getShardForQueue(queueId);
await this.#removeFromInflight(shardId, messageId, queueId);
this.logger.debug("Message completed", {
messageId,
queueId,
});
}
/**
* Release a message back to its queue.
* Used when processing fails or consumer wants to retry later.
*
* @param messageId - The message ID
* @param queueId - The queue ID
* @param queueKey - The Redis key for the queue
* @param queueItemsKey - The Redis key for the queue items hash
* @param tenantQueueIndexKey - The Redis key for the tenant queue index (Level 2)
* @param dispatchKey - The Redis key for the dispatch index (Level 1)
* @param tenantId - The tenant ID
* @param score - Optional score for the message (defaults to now)
*/
async release<_TPayload = unknown>(
messageId: string,
queueId: string,
queueKey: string,
queueItemsKey: string,
tenantQueueIndexKey: string,
dispatchKey: string,
tenantId: string,
score?: number,
updatedData?: string
): Promise<void> {
const shardId = this.#getShardForQueue(queueId);
const inflightKey = this.keys.inflightKey(shardId);
const inflightDataKey = this.keys.inflightDataKey(shardId);
const member = this.#makeMember(messageId, queueId);
const messageScore = score ?? Date.now();
// Use Lua script to atomically:
// 1. Get message data from in-flight (or use updatedData if provided)
// 2. Remove from in-flight
// 3. Add back to queue
// 4. Update dispatch indexes to ensure queue is picked up
await this.redis.releaseMessage(
inflightKey,
inflightDataKey,
queueKey,
queueItemsKey,
tenantQueueIndexKey,
dispatchKey,
member,
messageId,
messageScore.toString(),
queueId,
updatedData ?? "",
tenantId
);
this.logger.debug("Message released", {
messageId,
queueId,
score: messageScore,
});
}
/**
* Release multiple messages back to their queue in a single operation.
* Used when processing fails or consumer wants to retry later.
* All messages must belong to the same queue.
*
* @param messages - Array of messages to release (must all have same queueId)
* @param queueId - The queue ID
* @param queueKey - The Redis key for the queue
* @param queueItemsKey - The Redis key for the queue items hash
* @param tenantQueueIndexKey - The Redis key for the tenant queue index (Level 2)
* @param dispatchKey - The Redis key for the dispatch index (Level 1)
* @param tenantId - The tenant ID
* @param score - Optional score for the messages (defaults to now)
*/
async releaseBatch(
messages: Array<{ messageId: string }>,
queueId: string,
queueKey: string,
queueItemsKey: string,
tenantQueueIndexKey: string,
dispatchKey: string,
tenantId: string,
score?: number
): Promise<void> {
if (messages.length === 0) {
return;
}
const shardId = this.#getShardForQueue(queueId);
const inflightKey = this.keys.inflightKey(shardId);
const inflightDataKey = this.keys.inflightDataKey(shardId);
const messageScore = score ?? Date.now();
// Build arrays of members and messageIds for the Lua script
const messageIds = messages.map((m) => m.messageId);
const members = messages.map((m) => this.#makeMember(m.messageId, queueId));
await this.redis.releaseMessageBatch(
inflightKey,
inflightDataKey,
queueKey,
queueItemsKey,
tenantQueueIndexKey,
dispatchKey,
messageScore.toString(),
queueId,
tenantId,
...members,
...messageIds
);
this.logger.debug("Batch messages released", {
queueId,
count: messages.length,
score: messageScore,
});
}
/**
* Reclaim timed-out messages from a shard.
* Returns messages to their original queues.
*
* @param shardId - The shard to check
* @param getQueueKeys - Function to get queue keys for a queue ID
* @returns Array of reclaimed message info for concurrency release
*/
async reclaimTimedOut(
shardId: number,
getQueueKeys: (queueId: string) => {
queueKey: string;
queueItemsKey: string;
tenantQueueIndexKey: string;
dispatchKey: string;
tenantId: string;
}
): Promise<ReclaimedMessageInfo[]> {
const inflightKey = this.keys.inflightKey(shardId);
const inflightDataKey = this.keys.inflightDataKey(shardId);
const now = Date.now();
// Get all messages past their deadline
const timedOut = await this.redis.zrangebyscore(
inflightKey,
"-inf",
now,
"WITHSCORES",
"LIMIT",
0,
100 // Process in batches
);
const reclaimedMessages: ReclaimedMessageInfo[] = [];
for (let i = 0; i < timedOut.length; i += 2) {
const member = timedOut[i];
const _deadlineScore = timedOut[i + 1]; // This is the visibility deadline, not the original timestamp
if (!member || !_deadlineScore) {
continue;
}
const { messageId, queueId } = this.#parseMember(member);
const { queueKey, queueItemsKey, tenantQueueIndexKey, dispatchKey, tenantId } =
getQueueKeys(queueId);
try {
// Get message data BEFORE releasing so we can extract tenantId for concurrency release
const dataJson = await this.redis.hget(inflightDataKey, messageId);
let storedMessage: StoredMessage | null = null;
if (dataJson) {
try {
storedMessage = JSON.parse(dataJson);
} catch {
// Ignore parse error, proceed with reclaim
}
}
// Re-add to queue with original timestamp to preserve priority
// Fall back to now if we can't get the original timestamp
const score = storedMessage?.timestamp ?? now;
await this.redis.releaseMessage(
inflightKey,
inflightDataKey,
queueKey,
queueItemsKey,
tenantQueueIndexKey,
dispatchKey,
member,
messageId,
score.toString(),
queueId,
"",
tenantId
);
// Track reclaimed message for concurrency release
// Always add to reclaimedMessages to avoid concurrency leaks
if (storedMessage) {
reclaimedMessages.push({
messageId,
queueId,
tenantId: storedMessage.tenantId,
metadata: storedMessage.metadata,
});
} else {
// Fallback: extract tenantId from queueId when message data is missing or corrupted
// This ensures concurrency is released even if we can't get the full metadata
this.logger.error("Missing or corrupted message data during reclaim, using fallback", {
messageId,
queueId,
});
reclaimedMessages.push({
messageId,
queueId,
tenantId: this.keys.extractTenantId(queueId),
metadata: {},
});
}
this.logger.debug("Reclaimed timed-out message", {
messageId,
queueId,
deadline: _deadlineScore,
});
} catch (error) {
this.logger.error("Failed to reclaim message", {
messageId,
queueId,
error: error instanceof Error ? error.message : String(error),
});
}
}
return reclaimedMessages;
}
/**
* Get all in-flight messages for a shard.
*/
async getInflightMessages(shardId: number): Promise<
Array<{
messageId: string;
queueId: string;
deadline: number;
}>
> {
const inflightKey = this.keys.inflightKey(shardId);
const results = await this.redis.zrange(inflightKey, 0, -1, "WITHSCORES");
const messages: Array<{ messageId: string; queueId: string; deadline: number }> = [];
for (let i = 0; i < results.length; i += 2) {
const member = results[i];
const deadlineStr = results[i + 1];
if (!member || !deadlineStr) {
continue;
}
const deadline = parseFloat(deadlineStr);
const { messageId, queueId } = this.#parseMember(member);
messages.push({ messageId, queueId, deadline });
}
return messages;
}
/**
* Get count of in-flight messages for a shard.
*/
async getInflightCount(shardId: number): Promise<number> {
const inflightKey = this.keys.inflightKey(shardId);
return await this.redis.zcard(inflightKey);
}
/**
* Get total in-flight count across all shards.
*/
async getTotalInflightCount(): Promise<number> {
const counts = await Promise.all(
Array.from({ length: this.shardCount }, (_, i) => this.getInflightCount(i))
);
return counts.reduce((sum, count) => sum + count, 0);
}
/**
* Close the Redis connection.
*/
async close(): Promise<void> {
await this.redis.quit();
}
// ============================================================================
// Private Methods
// ============================================================================
/**
* Map queue ID to shard using Jump Consistent Hash.
* Must use same algorithm as MasterQueue for consistency.
*/
#getShardForQueue(queueId: string): number {
return jumpHash(queueId, this.shardCount);
}
#makeMember(messageId: string, queueId: string): string {
return `${messageId}:${queueId}`;
}
#parseMember(member: string): { messageId: string; queueId: string } {
const colonIndex = member.indexOf(":");
if (colonIndex === -1) {
return { messageId: member, queueId: "" };
}
return {
messageId: member.substring(0, colonIndex),
queueId: member.substring(colonIndex + 1),
};
}
async #removeFromInflight(shardId: number, messageId: string, queueId: string): Promise<void> {
const inflightKey = this.keys.inflightKey(shardId);
const inflightDataKey = this.keys.inflightDataKey(shardId);
const member = this.#makeMember(messageId, queueId);
const pipeline = this.redis.pipeline();
pipeline.zrem(inflightKey, member);
pipeline.hdel(inflightDataKey, messageId);
await pipeline.exec();
}
#registerCommands(): void {
// Atomic claim: pop from queue, add to in-flight
this.redis.defineCommand("claimMessage", {
numberOfKeys: 4,
lua: `
local queueKey = KEYS[1]
local queueItemsKey = KEYS[2]
local inflightKey = KEYS[3]
local inflightDataKey = KEYS[4]
local queueId = ARGV[1]
local consumerId = ARGV[2]
local deadline = tonumber(ARGV[3])
-- Get oldest message from queue
local items = redis.call('ZRANGE', queueKey, 0, 0)
if #items == 0 then
return nil
end
local messageId = items[1]
-- Get message data
local payload = redis.call('HGET', queueItemsKey, messageId)
if not payload then
-- Message data missing, remove from queue and return nil
redis.call('ZREM', queueKey, messageId)
return nil
end
-- Remove from queue
redis.call('ZREM', queueKey, messageId)
redis.call('HDEL', queueItemsKey, messageId)
-- Add to in-flight set with deadline
local member = messageId .. ':' .. queueId
redis.call('ZADD', inflightKey, deadline, member)
-- Store message data for potential release
redis.call('HSET', inflightDataKey, messageId, payload)
return {messageId, payload}
`,
});
// Atomic batch claim: pop up to N messages from queue, add to in-flight
this.redis.defineCommand("claimMessageBatch", {
numberOfKeys: 4,
lua: `
local queueKey = KEYS[1]
local queueItemsKey = KEYS[2]
local inflightKey = KEYS[3]
local inflightDataKey = KEYS[4]
local queueId = ARGV[1]
local deadline = tonumber(ARGV[2])
local maxCount = tonumber(ARGV[3])
-- Get up to maxCount oldest messages from queue
local items = redis.call('ZRANGE', queueKey, 0, maxCount - 1)
if #items == 0 then
return {}
end
local results = {}
for i, messageId in ipairs(items) do
-- Get message data
local payload = redis.call('HGET', queueItemsKey, messageId)
if payload then
-- Remove from queue
redis.call('ZREM', queueKey, messageId)
redis.call('HDEL', queueItemsKey, messageId)
-- Add to in-flight set with deadline
local member = messageId .. ':' .. queueId
redis.call('ZADD', inflightKey, deadline, member)
-- Store message data for potential release
redis.call('HSET', inflightDataKey, messageId, payload)
-- Add to results
table.insert(results, messageId)
table.insert(results, payload)
else
-- Message data missing, remove from queue
redis.call('ZREM', queueKey, messageId)
end
end
return results
`,
});
// Atomic release: remove from in-flight, add back to queue, update dispatch indexes
this.redis.defineCommand("releaseMessage", {
numberOfKeys: 6,
lua: `
local inflightKey = KEYS[1]
local inflightDataKey = KEYS[2]
local queueKey = KEYS[3]
local queueItemsKey = KEYS[4]
local tenantQueueIndexKey = KEYS[5]
local dispatchKey = KEYS[6]
local member = ARGV[1]
local messageId = ARGV[2]
local score = tonumber(ARGV[3])
local queueId = ARGV[4]
local updatedData = ARGV[5]
local tenantId = ARGV[6]
-- Get message data from in-flight
local payload = redis.call('HGET', inflightDataKey, messageId)
if not payload then
-- Message not in in-flight or already released
return 0
end
-- Use updatedData if provided (e.g. incremented attempt count for retries),
-- otherwise use the original in-flight data
if updatedData and updatedData ~= "" then
payload = updatedData
end
-- Remove from in-flight
redis.call('ZREM', inflightKey, member)
redis.call('HDEL', inflightDataKey, messageId)
-- Add back to queue
redis.call('ZADD', queueKey, score, messageId)
redis.call('HSET', queueItemsKey, messageId, payload)
-- Update tenant queue index (Level 2) with queue's oldest message
local oldest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES')
if #oldest >= 2 then
redis.call('ZADD', tenantQueueIndexKey, oldest[2], queueId)
end
-- Update dispatch index (Level 1) with tenant's oldest across all queues
local tenantOldest = redis.call('ZRANGE', tenantQueueIndexKey, 0, 0, 'WITHSCORES')
if #tenantOldest >= 2 then
redis.call('ZADD', dispatchKey, tenantOldest[2], tenantId)
end
return 1
`,
});
// Atomic batch release: release multiple messages back to queue
this.redis.defineCommand("releaseMessageBatch", {
numberOfKeys: 6,
lua: `
local inflightKey = KEYS[1]
local inflightDataKey = KEYS[2]
local queueKey = KEYS[3]
local queueItemsKey = KEYS[4]
local tenantQueueIndexKey = KEYS[5]
local dispatchKey = KEYS[6]
local score = tonumber(ARGV[1])
local queueId = ARGV[2]
local tenantId = ARGV[3]
-- Remaining args are: members..., messageIds...
-- Calculate how many messages we have
local numMessages = (table.getn(ARGV) - 3) / 2
local membersStart = 4
local messageIdsStart = membersStart + numMessages
local releasedCount = 0
for i = 0, numMessages - 1 do
local member = ARGV[membersStart + i]
local messageId = ARGV[messageIdsStart + i]
-- Get message data from in-flight
local payload = redis.call('HGET', inflightDataKey, messageId)
if payload then
-- Remove from in-flight
redis.call('ZREM', inflightKey, member)
redis.call('HDEL', inflightDataKey, messageId)
-- Add back to queue
redis.call('ZADD', queueKey, score, messageId)
redis.call('HSET', queueItemsKey, messageId, payload)
releasedCount = releasedCount + 1
end
end
-- Update dispatch indexes (only once at the end)
if releasedCount > 0 then
-- Update tenant queue index (Level 2)
local oldest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES')
if #oldest >= 2 then
redis.call('ZADD', tenantQueueIndexKey, oldest[2], queueId)
end
-- Update dispatch index (Level 1)
local tenantOldest = redis.call('ZRANGE', tenantQueueIndexKey, 0, 0, 'WITHSCORES')
if #tenantOldest >= 2 then
redis.call('ZADD', dispatchKey, tenantOldest[2], tenantId)
end
end
return releasedCount
`,
});
// Atomic heartbeat: check if member exists and update score
// ZADD XX returns 0 even on successful updates (it counts new additions only)
// So we need to check existence first with ZSCORE
this.redis.defineCommand("heartbeatMessage", {
numberOfKeys: 1,
lua: `
local inflightKey = KEYS[1]
local member = ARGV[1]
local newDeadline = tonumber(ARGV[2])
-- Check if member exists in the in-flight set
local score = redis.call('ZSCORE', inflightKey, member)
if not score then
return 0
end
-- Update the deadline
redis.call('ZADD', inflightKey, 'XX', newDeadline, member)
return 1
`,
});
}
}
// Extend Redis interface for custom commands
declare module "@internal/redis" {
interface RedisCommander<Context> {
claimMessage(
queueKey: string,
queueItemsKey: string,
inflightKey: string,
inflightDataKey: string,
queueId: string,
consumerId: string,
deadline: string
): Promise<[string, string] | null>;
claimMessageBatch(
queueKey: string,
queueItemsKey: string,
inflightKey: string,
inflightDataKey: string,
queueId: string,
deadline: string,
maxCount: string
): Promise<string[]>;
releaseMessage(
inflightKey: string,
inflightDataKey: string,
queueKey: string,
queueItemsKey: string,
tenantQueueIndexKey: string,
dispatchKey: string,
member: string,
messageId: string,
score: string,
queueId: string,
updatedData: string,
tenantId: string
): Promise<number>;
releaseMessageBatch(
inflightKey: string,
inflightDataKey: string,
queueKey: string,
queueItemsKey: string,
tenantQueueIndexKey: string,
dispatchKey: string,
score: string,
queueId: string,
tenantId: string,
...membersAndMessageIds: string[]
): Promise<number>;
heartbeatMessage(inflightKey: string, member: string, newDeadline: string): Promise<number>;
}
}
@@ -0,0 +1,292 @@
import { createRedisClient, type Redis, type RedisOptions } from "@internal/redis";
import type { FairQueueKeyProducer } from "./types.js";
export interface WorkerQueueManagerOptions {
redis: RedisOptions;
keys: FairQueueKeyProducer;
logger?: {
debug: (message: string, context?: Record<string, unknown>) => void;
error: (message: string, context?: Record<string, unknown>) => void;
};
}
/**
* WorkerQueueManager handles the intermediate worker queue layer.
*
* This provides:
* - Low-latency message delivery via blocking pop (BLPOP)
* - Routing of messages to specific workers/consumers
* - Efficient waiting without polling
*
* Flow:
* 1. Master queue consumer claims message from message queue
* 2. Message key is pushed to worker queue
* 3. Worker queue consumer does blocking pop to receive message
*/
export class WorkerQueueManager {
private redis: Redis;
private keys: FairQueueKeyProducer;
private logger: NonNullable<WorkerQueueManagerOptions["logger"]>;
constructor(private options: WorkerQueueManagerOptions) {
this.redis = createRedisClient(options.redis);
this.keys = options.keys;
this.logger = options.logger ?? {
debug: () => {},
error: () => {},
};
this.#registerCommands();
}
// ============================================================================
// Public Methods
// ============================================================================
/**
* Push a message key to a worker queue.
* Called after claiming a message from the message queue.
*
* @param workerQueueId - The worker queue identifier
* @param messageKey - The message key to push (typically "messageId:queueId")
*/
async push(workerQueueId: string, messageKey: string): Promise<void> {
const workerQueueKey = this.keys.workerQueueKey(workerQueueId);
await this.redis.rpush(workerQueueKey, messageKey);
this.logger.debug("Pushed to worker queue", {
workerQueueId,
workerQueueKey,
messageKey,
});
}
/**
* Push multiple message keys to a worker queue.
*
* @param workerQueueId - The worker queue identifier
* @param messageKeys - The message keys to push
*/
async pushBatch(workerQueueId: string, messageKeys: string[]): Promise<void> {
if (messageKeys.length === 0) {
return;
}
const workerQueueKey = this.keys.workerQueueKey(workerQueueId);
await this.redis.rpush(workerQueueKey, ...messageKeys);
this.logger.debug("Pushed batch to worker queue", {
workerQueueId,
workerQueueKey,
count: messageKeys.length,
});
}
/**
* Blocking pop from a worker queue.
* Waits until a message is available or timeout expires.
*
* @param workerQueueId - The worker queue identifier
* @param timeoutSeconds - Maximum time to wait (0 = wait forever)
* @param signal - Optional abort signal to cancel waiting
* @returns The message key, or null if timeout
*/
async blockingPop(
workerQueueId: string,
timeoutSeconds: number,
signal?: AbortSignal
): Promise<string | null> {
const workerQueueKey = this.keys.workerQueueKey(workerQueueId);
// Create a separate client for blocking operation
// This is required because BLPOP blocks the connection
const blockingClient = this.redis.duplicate();
// Define cleanup outside try so it's accessible in finally
// This prevents listener accumulation on the AbortSignal
const cleanup = signal
? () => {
blockingClient.disconnect();
}
: null;
try {
// Set up abort handler
if (signal && cleanup) {
signal.addEventListener("abort", cleanup, { once: true });
if (signal.aborted) {
return null;
}
}
const result = await blockingClient.blpop(workerQueueKey, timeoutSeconds);
if (!result) {
return null;
}
// BLPOP returns [key, value]
const [, messageKey] = result;
this.logger.debug("Blocking pop received message", {
workerQueueId,
workerQueueKey,
messageKey,
});
return messageKey;
} catch (error) {
// Handle abort/disconnect
if (signal?.aborted) {
return null;
}
this.logger.error("Blocking pop error", {
workerQueueId,
error: error instanceof Error ? error.message : String(error),
});
throw error;
} finally {
// Always remove the listener to prevent accumulation on the AbortSignal
// (once: true only removes if abort fires, not on normal completion)
if (cleanup && signal) {
signal.removeEventListener("abort", cleanup);
}
await blockingClient.quit().catch(() => {
// Ignore quit errors (may already be disconnected)
});
}
}
/**
* Non-blocking pop from a worker queue.
*
* @param workerQueueId - The worker queue identifier
* @returns The message key and queue length, or null if empty
*/
async pop(workerQueueId: string): Promise<{ messageKey: string; queueLength: number } | null> {
const workerQueueKey = this.keys.workerQueueKey(workerQueueId);
const result = await this.redis.popWithLength(workerQueueKey);
if (!result) {
return null;
}
const [messageKey, queueLength] = result;
this.logger.debug("Non-blocking pop received message", {
workerQueueId,
workerQueueKey,
messageKey,
queueLength,
});
return { messageKey, queueLength: Number(queueLength) };
}
/**
* Get the current length of a worker queue.
*/
async getLength(workerQueueId: string): Promise<number> {
const workerQueueKey = this.keys.workerQueueKey(workerQueueId);
return await this.redis.llen(workerQueueKey);
}
/**
* Peek at all messages in a worker queue without removing them.
* Useful for debugging and tests.
*/
async peek(workerQueueId: string): Promise<string[]> {
const workerQueueKey = this.keys.workerQueueKey(workerQueueId);
return await this.redis.lrange(workerQueueKey, 0, -1);
}
/**
* Remove a specific message from the worker queue.
* Used when a message needs to be removed without processing.
*
* @param workerQueueId - The worker queue identifier
* @param messageKey - The message key to remove
* @returns Number of removed items
*/
async remove(workerQueueId: string, messageKey: string): Promise<number> {
const workerQueueKey = this.keys.workerQueueKey(workerQueueId);
return await this.redis.lrem(workerQueueKey, 0, messageKey);
}
/**
* Clear all messages from a worker queue.
*/
async clear(workerQueueId: string): Promise<void> {
const workerQueueKey = this.keys.workerQueueKey(workerQueueId);
await this.redis.del(workerQueueKey);
}
/**
* Close the Redis connection.
*/
async close(): Promise<void> {
await this.redis.quit();
}
// ============================================================================
// Private - Register Commands
// ============================================================================
/**
* Initialize custom Redis commands.
*/
#registerCommands(): void {
// Non-blocking pop with queue length
this.redis.defineCommand("popWithLength", {
numberOfKeys: 1,
lua: `
local workerQueueKey = KEYS[1]
-- Pop the first message
local messageKey = redis.call('LPOP', workerQueueKey)
if not messageKey then
return nil
end
-- Get remaining queue length
local queueLength = redis.call('LLEN', workerQueueKey)
return {messageKey, queueLength}
`,
});
}
/**
* Register custom commands on an external Redis client.
* Use this when initializing FairQueue with worker queues.
*/
registerCommands(redis: Redis): void {
redis.defineCommand("popWithLength", {
numberOfKeys: 1,
lua: `
local workerQueueKey = KEYS[1]
-- Pop the first message
local messageKey = redis.call('LPOP', workerQueueKey)
if not messageKey then
return nil
end
-- Get remaining queue length
local queueLength = redis.call('LLEN', workerQueueKey)
return {messageKey, queueLength}
`,
});
}
}
// Extend Redis interface for custom commands
declare module "@internal/redis" {
interface RedisCommander<Context> {
popWithLength(workerQueueKey: string): Promise<[string, string] | null>;
}
}
+7
View File
@@ -0,0 +1,7 @@
export * from "./queue.js";
export * from "./worker.js";
export * from "./utils.js";
// Fair Queue System
export * from "./fair-queue/index.js";
export * from "./mollifier/index.js";
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,444 @@
import { Logger } from "@trigger.dev/core/logger";
import type { MollifierBuffer } from "./buffer.js";
import type { BufferEntry } from "./schemas.js";
import { deserialiseSnapshot } from "./schemas.js";
export type MollifierDrainerHandler<TPayload> = (input: {
runId: string;
envId: string;
orgId: string;
payload: TPayload;
attempts: number;
createdAt: Date;
}) => Promise<void>;
// Invoked once per entry before `buffer.fail()` on any terminal path —
// non-retryable error OR retryable error after maxAttempts. Lets the caller
// land a SYSTEM_FAILURE PG row so the customer sees the run instead of it
// silently disappearing alongside the buffer entry. Throwing a retryable
// error from the callback causes the drainer to requeue rather than fail
// (so the PG write itself gets another chance once PG recovers); throwing
// anything else falls through to `buffer.fail()` to avoid an infinite loop
// on a genuinely bad payload.
export type MollifierDrainerTerminalFailureCause = "non-retryable" | "max-attempts-exhausted";
export type MollifierDrainerTerminalFailureHandler<TPayload> = (input: {
runId: string;
envId: string;
orgId: string;
payload: TPayload;
attempts: number;
createdAt: Date;
error: { code: string; message: string };
cause: MollifierDrainerTerminalFailureCause;
}) => Promise<void>;
export type MollifierDrainerOptions<TPayload> = {
buffer: MollifierBuffer;
handler: MollifierDrainerHandler<TPayload>;
onTerminalFailure?: MollifierDrainerTerminalFailureHandler<TPayload>;
concurrency: number;
maxAttempts: number;
isRetryable: (err: unknown) => boolean;
pollIntervalMs?: number;
// Cap on how many ORGS `runOnce` processes per tick. The drainer rotates
// through orgs at the top level and picks one env per org per tick, so
// the actual per-tick pop count is at most `maxOrgsPerTick`. Tune for
// "typical orgs with pending entries" rather than total system org
// count. Defaults to 500.
//
// The buffer maintains `mollifier:orgs` and `mollifier:org-envs:${orgId}`
// atomically with per-env queues, so the drainer can walk orgs → envs
// directly. An org with N envs gets the same per-tick scheduling slot
// as an org with 1 env — tenant-level drainage throughput is determined
// by org count, not env count.
maxOrgsPerTick?: number;
// Per-env per-tick pop cap. Default 1 preserves the original
// one-pop-per-env-per-tick behaviour. Setting it higher lets a single
// env drain at handler-parallelism speed: each tick the drainer pops
// up to `drainBatchSize` entries from the env's queue, then dispatches
// them all through the shared `concurrency`-bounded pLimit. For a
// single-env burst this turns N sequential ticks into one tick of N
// parallel handler calls, capped by `concurrency`. Org/env fairness
// still holds — each org still contributes exactly one env per tick.
//
// Memory: per-tick in-flight entries ≤ `maxOrgsPerTick × drainBatchSize`.
// Operators sizing this should ensure their PG pool / engine handler
// can sustain `concurrency` parallel writes; popping more than the
// handler can process per tick just queues entries in JS waiting on
// pLimit.
drainBatchSize?: number;
// Cap on the exponential backoff applied after consecutive `runOnce`
// errors. Defaults to 5000ms. The backoff base is `max(pollIntervalMs,
// backoffFloorMs)` and doubles per consecutive error up to this cap.
maxBackoffMs?: number;
// Floor for the exponential-backoff base, so a tiny `pollIntervalMs`
// doesn't collapse the backoff to near-zero on a sustained outage.
// Defaults to 100ms.
backoffFloorMs?: number;
logger?: Logger;
};
export type DrainResult = {
drained: number;
failed: number;
};
export class MollifierDrainer<TPayload = unknown> {
private readonly buffer: MollifierBuffer;
private readonly handler: MollifierDrainerHandler<TPayload>;
private readonly onTerminalFailure?: MollifierDrainerTerminalFailureHandler<TPayload>;
private readonly maxAttempts: number;
private readonly isRetryable: (err: unknown) => boolean;
private readonly pollIntervalMs: number;
private readonly maxOrgsPerTick: number;
private readonly drainBatchSize: number;
private readonly concurrency: number;
private readonly maxBackoffMs: number;
private readonly backoffFloorMs: number;
private readonly logger: Logger;
// Rotation state. `orgCursor` advances through the active-orgs list.
// Each org has its own internal cursor in `perOrgEnvCursors` for
// cycling through that org's envs. Both reset on `start()`.
private orgCursor = 0;
private perOrgEnvCursors = new Map<string, number>();
private isRunning = false;
private stopping = false;
private loopPromise: Promise<void> | null = null;
constructor(options: MollifierDrainerOptions<TPayload>) {
this.buffer = options.buffer;
this.handler = options.handler;
this.onTerminalFailure = options.onTerminalFailure;
this.maxAttempts = options.maxAttempts;
this.isRetryable = options.isRetryable;
this.pollIntervalMs = options.pollIntervalMs ?? 100;
this.maxOrgsPerTick = options.maxOrgsPerTick ?? 500;
this.drainBatchSize = Math.max(1, options.drainBatchSize ?? 1);
this.concurrency = Math.max(1, options.concurrency);
this.maxBackoffMs = options.maxBackoffMs ?? 5_000;
this.backoffFloorMs = Math.max(1, options.backoffFloorMs ?? 100);
this.logger = options.logger ?? new Logger("MollifierDrainer", "debug");
}
async runOnce(): Promise<DrainResult> {
const orgs = await this.buffer.listOrgs();
if (orgs.length === 0) return { drained: 0, failed: 0 };
const orgSlice = this.takeOrgSlice(orgs);
// Fan the per-org SMEMBERS out in a single pipelined round-trip. Serial
// awaits would otherwise add `orgSlice.length × RTT` of dead time before
// pops start — at the default `maxOrgsPerTick=500` and a ~1ms ElastiCache
// RTT that's a ~500ms per-tick floor. ioredis auto-pipelines concurrent
// commands into one batch, so the burst is cheap; SMEMBERS on a small set
// is O(N) per org and trivial at this scale. `Promise.all` preserves
// order, so the org→envs pairing below stays deterministic.
const envsByOrg = await Promise.all(orgSlice.map((orgId) => this.buffer.listEnvsForOrg(orgId)));
const targets: string[] = [];
for (let i = 0; i < orgSlice.length; i++) {
const orgId = orgSlice[i]!;
const envsForOrg = envsByOrg[i]!;
if (envsForOrg.length === 0) continue;
const envId = this.pickEnvForOrg(orgId, envsForOrg);
targets.push(envId);
}
if (targets.length === 0) return { drained: 0, failed: 0 };
// Worker-pool draining. We spawn up to `concurrency` workers; each
// worker repeatedly:
// 1. Picks the next env with budget remaining (round-robin),
// atomically claiming one slot of that env's per-tick budget.
// 2. Pops one entry and processes it.
// 3. Repeats until pickNextEnv returns null.
//
// This pattern gives us both invariants the prior two designs traded
// off:
// - Single-env bursts use the full `concurrency` budget. All
// workers can pull from one env, processing `concurrency` entries
// in parallel.
// - The number of entries in "popped-but-not-acked" (DRAINING)
// state at any moment is bounded by the worker count, i.e.
// `concurrency` — same blast radius as the pre-batch
// one-pop-per-env model. A process crash mid-tick strands at
// most `concurrency` entries for stale-sweep to recover, not
// `maxOrgsPerTick × drainBatchSize`.
//
// Fairness: pickNextEnv advances a cursor by 1 each successful pick,
// so workers round-robin across envs at the entry level. Combined
// with the per-env budget cap, an env contributes at most
// `drainBatchSize` entries per tick regardless of how many workers
// are free — a heavy env can't starve siblings within a tick.
const remaining = new Map<string, number>();
const skip = new Set<string>(); // envs with empty queue or pop failure this tick
for (const envId of targets) remaining.set(envId, this.drainBatchSize);
let cursor = 0;
const pickNextEnv = (): string | null => {
for (let i = 0; i < targets.length; i++) {
const idx = (cursor + i) % targets.length;
const envId = targets[idx]!;
if (skip.has(envId)) continue;
const r = remaining.get(envId) ?? 0;
if (r > 0) {
remaining.set(envId, r - 1);
cursor = (idx + 1) % targets.length;
return envId;
}
}
return null;
};
let drained = 0;
let failed = 0;
const worker = async (): Promise<void> => {
while (true) {
const envId = pickNextEnv();
if (envId === null) return;
let entry: BufferEntry | null;
try {
entry = await this.buffer.pop(envId);
} catch (err) {
// A pop failure on one env aborts that env's batch for this
// tick (don't keep hammering a broken Redis) and counts as
// exactly one failure — same as the pre-batch path on a pop
// blowup. Other envs continue.
//
// `pickNextEnv` decrements `remaining` before the pop settles,
// so multiple workers can race into the same env and all hit
// a throwing pop before the first catch lands. Guarding the
// failure increment on `!skip.has(envId)` keeps the per-env
// failure count at exactly one even under that race —
// matching the documented contract.
this.logger.error("MollifierDrainer.pop failed", { envId, err });
if (!skip.has(envId)) {
skip.add(envId);
failed += 1;
}
continue;
}
if (!entry) {
// Queue exhausted between scheduling and this pop. Mark the
// env skipped so siblings aren't held up by repeated empty pops.
skip.add(envId);
continue;
}
try {
const outcome = await this.processEntry(entry);
if (outcome === "drained") drained += 1;
else failed += 1;
} catch (err) {
this.logger.error("MollifierDrainer.processEntry failed", {
envId,
runId: entry.runId,
err,
});
failed += 1;
}
}
};
const totalBudget = targets.length * this.drainBatchSize;
const workerCount = Math.min(this.concurrency, totalBudget);
await Promise.all(Array.from({ length: workerCount }, () => worker()));
return { drained, failed };
}
start(): void {
if (this.isRunning) return;
this.isRunning = true;
this.stopping = false;
// Reset rotation state on each (re)start. A stop+start cycle means
// operator intent to "begin clean" — between-restart cursor drift
// would otherwise carry implicit state across what should look like
// a fresh boot.
this.orgCursor = 0;
this.perOrgEnvCursors = new Map();
this.loopPromise = this.loop();
}
// Signal the loop to exit (`stopping = true`) and wait for it. With no
// timeout, wait indefinitely for the in-flight `runOnce` and its handlers
// to settle — same semantic as FairQueue / BatchQueue's `stop()`. With a
// timeout, race the loop promise against a deadline so a hung handler
// can't wedge the process past its termination grace period.
async stop(options: { timeoutMs?: number } = {}): Promise<void> {
if (!this.isRunning || !this.loopPromise) return;
this.stopping = true;
if (options.timeoutMs == null) {
await this.loopPromise;
return;
}
// Hold the timer handle so we can clearTimeout() it after the race.
// Without this, when the loop wins the race, the discarded timer is
// still ref'd and pins the Node event loop for up to `timeoutMs`,
// delaying process shutdown by exactly the slack we were trying to
// bound. try/finally clears the handle in every exit path (loop-won,
// timeout-won, or exception).
const timeoutSentinel = Symbol("mollifier.stop.timeout");
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<typeof timeoutSentinel>((resolve) => {
timeoutHandle = setTimeout(() => resolve(timeoutSentinel), options.timeoutMs);
});
try {
const winner = await Promise.race([
this.loopPromise.then(() => "done" as const),
timeoutPromise,
]);
if (winner === timeoutSentinel) {
this.logger.warn(
"MollifierDrainer.stop: deadline exceeded; returning while loop iteration is in flight",
{ timeoutMs: options.timeoutMs }
);
}
} finally {
if (timeoutHandle) clearTimeout(timeoutHandle);
}
}
// Transient Redis errors (e.g. a connection blip in `listOrgs` /
// `listEnvsForOrg` / `pop`) must not kill the polling loop permanently.
// We log each `runOnce` failure, back off so we don't spin tight on a
// sustained outage, and resume. The loop only exits when `stop()` flips
// `stopping`.
private async loop(): Promise<void> {
try {
let consecutiveErrors = 0;
while (!this.stopping) {
try {
const result = await this.runOnce();
consecutiveErrors = 0;
if (result.drained === 0 && result.failed === 0) {
await this.delay(this.pollIntervalMs);
}
} catch (err) {
consecutiveErrors += 1;
this.logger.error("MollifierDrainer.runOnce failed; backing off", {
err,
consecutiveErrors,
});
await this.delay(this.backoffMs(consecutiveErrors));
}
}
} finally {
this.isRunning = false;
}
}
// Exponential backoff capped at 5s. Keeps the loop responsive after a
// brief blip while preventing a tight retry loop during a long Redis
// outage. 1 → 200ms, 2 → 400ms, 3 → 800ms, 4 → 1.6s, 5 → 3.2s, 6+ → 5s.
private backoffMs(consecutiveErrors: number): number {
const base = Math.max(this.pollIntervalMs, this.backoffFloorMs);
const capped = Math.min(base * 2 ** (consecutiveErrors - 1), this.maxBackoffMs);
return capped;
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Take up to `maxOrgsPerTick` orgs starting at the current cursor, with
// wrap-around. Cursor advances by 1 each tick so every org reaches
// every slot position (0..sliceSize-1) over a full cycle — no
// head-of-line bias within the slice. Orgs are sorted before slicing
// so rotation is deterministic regardless of Redis SET iteration order.
private takeOrgSlice(orgs: string[]): string[] {
const sorted = [...orgs].sort();
const n = sorted.length;
const sliceSize = Math.min(this.maxOrgsPerTick, n);
const start = this.orgCursor % n;
this.orgCursor = (this.orgCursor + 1) % Math.max(n, 1);
const end = start + sliceSize;
if (end <= n) return sorted.slice(start, end);
return [...sorted.slice(start), ...sorted.slice(0, end - n)];
}
// Pick one env from the org's active-envs list, rotating per org via
// the per-org cursor. Each org's cursor advances by 1 each visit, so
// an org with N envs cycles through them across N visits.
private pickEnvForOrg(orgId: string, envsForOrg: string[]): string {
const sorted = [...envsForOrg].sort();
const cursor = this.perOrgEnvCursors.get(orgId) ?? 0;
const idx = cursor % sorted.length;
this.perOrgEnvCursors.set(orgId, (cursor + 1) % sorted.length);
return sorted[idx]!;
}
private async processEntry(entry: BufferEntry): Promise<"drained" | "failed"> {
try {
const payload = deserialiseSnapshot<TPayload>(entry.payload);
await this.handler({
runId: entry.runId,
envId: entry.envId,
orgId: entry.orgId,
payload,
attempts: entry.attempts,
createdAt: entry.createdAt,
});
await this.buffer.ack(entry.runId);
return "drained";
} catch (err) {
const nextAttempts = entry.attempts + 1;
if (this.isRetryable(err) && nextAttempts < this.maxAttempts) {
await this.buffer.requeue(entry.runId);
this.logger.warn("MollifierDrainer: retryable error, requeued", {
runId: entry.runId,
attempts: nextAttempts,
});
return "failed";
}
const cause: MollifierDrainerTerminalFailureCause = this.isRetryable(err)
? "max-attempts-exhausted"
: "non-retryable";
const code = err instanceof Error ? err.name : "Unknown";
const message = err instanceof Error ? err.message : String(err);
// Run the terminal-failure callback BEFORE buffer.fail() so a
// SYSTEM_FAILURE PG row can land while the entry is still around to
// read from (and so we don't lose the run if the callback's own
// write itself needs a retry). If the callback throws a retryable
// error, requeue the entry instead of fail()ing — PG is still
// unreachable, give it another tick. Any other callback failure
// falls through to buffer.fail() so a genuinely bad snapshot
// doesn't loop forever.
if (this.onTerminalFailure) {
try {
await this.onTerminalFailure({
runId: entry.runId,
envId: entry.envId,
orgId: entry.orgId,
payload: deserialiseSnapshot<TPayload>(entry.payload),
attempts: nextAttempts,
createdAt: entry.createdAt,
error: { code, message },
cause,
});
} catch (writeErr) {
if (this.isRetryable(writeErr)) {
await this.buffer.requeue(entry.runId);
this.logger.warn("MollifierDrainer: terminal-failure callback retryable; requeued", {
runId: entry.runId,
attempts: nextAttempts,
writeErr,
});
return "failed";
}
this.logger.error("MollifierDrainer: terminal-failure callback failed", {
runId: entry.runId,
writeErr,
});
}
}
await this.buffer.fail(entry.runId, { code, message });
this.logger.error("MollifierDrainer: terminal failure", {
runId: entry.runId,
code,
message,
cause,
});
return "failed";
}
}
}
@@ -0,0 +1,28 @@
export {
MollifierBuffer,
type MollifierBufferOptions,
type SnapshotPatch,
type AcceptResult,
type MutateSnapshotResult,
type CasSetMetadataResult,
type IdempotencyClaimResult,
type IdempotencyLookupInput,
idempotencyLookupKeyFor,
makeIdempotencyClaimKey,
} from "./buffer.js";
export {
MollifierDrainer,
type MollifierDrainerOptions,
type MollifierDrainerHandler,
type MollifierDrainerTerminalFailureHandler,
type MollifierDrainerTerminalFailureCause,
type DrainResult,
} from "./drainer.js";
export {
BufferEntrySchema,
BufferEntryStatus,
BufferEntryError,
serialiseSnapshot,
deserialiseSnapshot,
type BufferEntry,
} from "./schemas.js";
@@ -0,0 +1,86 @@
import { z } from "zod";
export const BufferEntryStatus = z.enum(["QUEUED", "DRAINING", "FAILED"]);
export type BufferEntryStatus = z.infer<typeof BufferEntryStatus>;
export const BufferEntryError = z.object({
code: z.string(),
message: z.string(),
});
export type BufferEntryError = z.infer<typeof BufferEntryError>;
const stringToInt = z.string().transform((v, ctx) => {
const n = Number(v);
if (!Number.isInteger(n) || n < 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "expected non-negative integer string" });
return z.NEVER;
}
return n;
});
const stringToDate = z.string().transform((v, ctx) => {
const d = new Date(v);
if (Number.isNaN(d.getTime())) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "expected ISO date string" });
return z.NEVER;
}
return d;
});
const stringToBool = z
.union([z.literal("true"), z.literal("false")])
.transform((v) => v === "true");
const stringToError = z.string().transform((v, ctx) => {
try {
return BufferEntryError.parse(JSON.parse(v));
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "expected JSON-encoded BufferEntryError",
});
return z.NEVER;
}
});
export const BufferEntrySchema = z.object({
runId: z.string().min(1),
envId: z.string().min(1),
orgId: z.string().min(1),
payload: z.string(),
status: BufferEntryStatus,
attempts: stringToInt,
createdAt: stringToDate,
// Microsecond epoch of accept time, kept as a hash field for dwell
// metrics. Not a queue sort key (the queue is a FIFO LIST). Defaulted
// so an entry written by an accept Lua predating this field — or one
// surviving across the deploy that introduced it — still parses instead
// of being silently dropped on pop.
createdAtMicros: stringToInt.default("0"),
// Drainer-ack flag: `true` once the drainer has materialised this run
// into PG. The hash persists for a short grace TTL after ack so direct
// reads (retrieve, trace, etc.) still resolve while PG replica lag
// settles. Absent on pre-ack entries.
materialised: stringToBool.default("false"),
// Denormalised pointer to the Redis idempotency lookup key (set when
// the run was accepted with an idempotency key, empty otherwise). The
// ack Lua reads this to DEL the lookup atomically with marking the
// entry materialised.
idempotencyLookupKey: z.string().optional().default(""),
// Optimistic-lock counter for the snapshot's `metadata` field.
// Incremented atomically by the CAS metadata Lua. Matches the
// semantic of `TaskRun.metadataVersion` on the PG side (which the
// UpdateMetadataService uses for the same retry-on-conflict pattern).
metadataVersion: stringToInt.default("0"),
lastError: stringToError.optional(),
});
export type BufferEntry = z.infer<typeof BufferEntrySchema>;
export function serialiseSnapshot(snapshot: unknown): string {
return JSON.stringify(snapshot);
}
export function deserialiseSnapshot<T = unknown>(serialised: string): T {
return JSON.parse(serialised) as T;
}
+606
View File
@@ -0,0 +1,606 @@
import { redisTest } from "@internal/testcontainers";
import { describe } from "node:test";
import { expect } from "vitest";
import { z } from "zod";
import { SimpleQueue } from "./queue.js";
import { Logger } from "@trigger.dev/core/logger";
import { createRedisClient } from "@internal/redis";
describe("SimpleQueue", () => {
redisTest("enqueue/dequeue", { timeout: 20_000 }, async ({ redisContainer }) => {
const queue = new SimpleQueue({
name: "test-1",
schema: {
test: z.object({
value: z.number(),
}),
},
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
logger: new Logger("test", "log"),
});
try {
await queue.enqueue({ id: "1", job: "test", item: { value: 1 }, visibilityTimeoutMs: 2000 });
expect(await queue.size()).toBe(1);
await queue.enqueue({ id: "2", job: "test", item: { value: 2 }, visibilityTimeoutMs: 2000 });
expect(await queue.size()).toBe(2);
const [first] = await queue.dequeue(1);
if (!first) {
throw new Error("No item dequeued");
}
expect(first).toEqual(
expect.objectContaining({
id: "1",
job: "test",
item: { value: 1 },
visibilityTimeoutMs: 2000,
attempt: 0,
timestamp: expect.any(Date),
})
);
expect(await queue.size()).toBe(1);
expect(await queue.size({ includeFuture: true })).toBe(2);
await queue.ack(first.id);
expect(await queue.size({ includeFuture: true })).toBe(1);
const [second] = await queue.dequeue(1);
if (!second) {
throw new Error("No item dequeued");
}
expect(second).toEqual(
expect.objectContaining({
id: "2",
job: "test",
item: { value: 2 },
visibilityTimeoutMs: 2000,
attempt: 0,
timestamp: expect.any(Date),
})
);
await queue.ack(second.id);
expect(await queue.size({ includeFuture: true })).toBe(0);
} finally {
await queue.close();
}
});
redisTest("getJob", { timeout: 20_000 }, async ({ redisContainer }) => {
const queue = new SimpleQueue({
name: "test-1",
schema: {
test: z.object({
value: z.number(),
}),
},
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
logger: new Logger("test", "log"),
});
try {
await queue.enqueue({ id: "1", job: "test", item: { value: 1 }, visibilityTimeoutMs: 2000 });
const job1 = await queue.getJob("1");
expect(job1).toEqual(
expect.objectContaining({
id: "1",
job: "test",
item: { value: 1 },
visibilityTimeoutMs: 2000,
attempt: 0,
timestamp: expect.any(Date),
})
);
await queue.enqueue({ id: "2", job: "test", item: { value: 2 }, visibilityTimeoutMs: 2000 });
const job2 = await queue.getJob("2");
expect(job2).toEqual(
expect.objectContaining({
id: "2",
job: "test",
item: { value: 2 },
visibilityTimeoutMs: 2000,
attempt: 0,
timestamp: expect.any(Date),
})
);
const job3 = await queue.getJob("3");
expect(job3).toBeNull();
} finally {
await queue.close();
}
});
redisTest("no items", { timeout: 20_000 }, async ({ redisContainer }) => {
const queue = new SimpleQueue({
name: "test-1",
schema: {
test: z.object({
value: z.number(),
}),
},
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
logger: new Logger("test", "log"),
});
try {
const missOne = await queue.dequeue(1);
expect(missOne).toEqual([]);
await queue.enqueue({ id: "1", job: "test", item: { value: 1 }, visibilityTimeoutMs: 2000 });
const [hitOne] = await queue.dequeue(1);
expect(hitOne).toEqual(
expect.objectContaining({
id: "1",
job: "test",
item: { value: 1 },
visibilityTimeoutMs: 2000,
attempt: 0,
timestamp: expect.any(Date),
})
);
const missTwo = await queue.dequeue(1);
expect(missTwo).toEqual([]);
} finally {
await queue.close();
}
});
redisTest("future item", { timeout: 20_000 }, async ({ redisContainer }) => {
const queue = new SimpleQueue({
name: "test-1",
schema: {
test: z.object({
value: z.number(),
}),
},
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
logger: new Logger("test", "log"),
});
try {
await queue.enqueue({
id: "1",
job: "test",
item: { value: 1 },
availableAt: new Date(Date.now() + 50),
visibilityTimeoutMs: 2000,
attempt: 0,
});
const miss = await queue.dequeue(1);
expect(miss).toEqual([]);
await new Promise((resolve) => setTimeout(resolve, 50));
const [first] = await queue.dequeue();
expect(first).toEqual(
expect.objectContaining({
id: "1",
job: "test",
item: { value: 1 },
visibilityTimeoutMs: 2000,
attempt: 0,
timestamp: expect.any(Date),
})
);
} finally {
await queue.close();
}
});
redisTest("oldestMessageAge", { timeout: 20_000 }, async ({ redisContainer }) => {
const queue = new SimpleQueue({
name: "test-1",
schema: {
test: z.object({
value: z.number(),
}),
},
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
logger: new Logger("test", "log"),
});
try {
// empty queue → 0
expect(await queue.oldestMessageAge()).toBe(0);
// only a future-scheduled item → 0 (not yet overdue)
await queue.enqueue({
id: "future",
job: "test",
item: { value: 1 },
availableAt: new Date(Date.now() + 60_000),
visibilityTimeoutMs: 2000,
});
expect(await queue.oldestMessageAge()).toBe(0);
// an overdue item → age > 0
await queue.enqueue({
id: "overdue",
job: "test",
item: { value: 2 },
availableAt: new Date(Date.now() - 5_000),
visibilityTimeoutMs: 2000,
});
const age = await queue.oldestMessageAge();
expect(age).toBeGreaterThanOrEqual(5_000);
expect(age).toBeLessThan(60_000);
// an orphaned queue entry (no payload in the items hash), older than the
// real overdue item, must be ignored — it can't be dequeued, so it isn't a
// real stall. Age should still reflect the real overdue item (~5s), not the
// orphan's ~999s.
const redisClient = createRedisClient({
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
});
await redisClient.zadd(`{queue:test-1:}queue`, Date.now() - 999_000, "orphaned-id");
const ageWithOrphan = await queue.oldestMessageAge();
expect(ageWithOrphan).toBeGreaterThanOrEqual(5_000);
expect(ageWithOrphan).toBeLessThan(60_000);
// once dequeued, the item is invisible (future-scored) → back to 0 (the
// orphan is cleaned by the dequeue scan, the real item goes in-flight)
const [first] = await queue.dequeue(2);
expect(first?.id).toBe("overdue");
expect(await queue.oldestMessageAge()).toBe(0);
await redisClient.quit();
} finally {
await queue.close();
}
});
redisTest("invisibility timeout", { timeout: 20_000 }, async ({ redisContainer }) => {
const queue = new SimpleQueue({
name: "test-1",
schema: {
test: z.object({
value: z.number(),
}),
},
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
logger: new Logger("test", "log"),
});
try {
await queue.enqueue({ id: "1", job: "test", item: { value: 1 }, visibilityTimeoutMs: 1_000 });
const [first] = await queue.dequeue();
expect(first).toEqual(
expect.objectContaining({
id: "1",
job: "test",
item: { value: 1 },
visibilityTimeoutMs: 1_000,
attempt: 0,
timestamp: expect.any(Date),
})
);
const missImmediate = await queue.dequeue(1);
expect(missImmediate).toEqual([]);
await new Promise((resolve) => setTimeout(resolve, 1_000));
const [second] = await queue.dequeue();
expect(second).toEqual(
expect.objectContaining({
id: "1",
job: "test",
item: { value: 1 },
visibilityTimeoutMs: 1_000,
attempt: 0,
timestamp: expect.any(Date),
})
);
// Acknowledge the item and verify it's removed
await queue.ack(second!.id);
expect(await queue.size({ includeFuture: true })).toBe(0);
} finally {
await queue.close();
}
});
redisTest("dequeue multiple items", { timeout: 20_000 }, async ({ redisContainer }) => {
const queue = new SimpleQueue({
name: "test-1",
schema: {
test: z.object({
value: z.number(),
}),
},
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
logger: new Logger("test", "log"),
});
try {
await queue.enqueue({ id: "1", job: "test", item: { value: 1 }, visibilityTimeoutMs: 2000 });
await queue.enqueue({ id: "2", job: "test", item: { value: 2 }, visibilityTimeoutMs: 2000 });
await queue.enqueue({ id: "3", job: "test", item: { value: 3 }, visibilityTimeoutMs: 2000 });
expect(await queue.size()).toBe(3);
const dequeued = await queue.dequeue(2);
expect(dequeued).toHaveLength(2);
expect(dequeued[0]).toEqual(
expect.objectContaining({
id: "1",
job: "test",
item: { value: 1 },
visibilityTimeoutMs: 2000,
attempt: 0,
timestamp: expect.any(Date),
})
);
expect(dequeued[1]).toEqual(
expect.objectContaining({
id: "2",
job: "test",
item: { value: 2 },
visibilityTimeoutMs: 2000,
attempt: 0,
timestamp: expect.any(Date),
})
);
expect(await queue.size()).toBe(1);
expect(await queue.size({ includeFuture: true })).toBe(3);
if (!dequeued[0] || !dequeued[1]) {
throw new Error("No items dequeued");
}
await queue.ack(dequeued[0].id);
await queue.ack(dequeued[1].id);
expect(await queue.size({ includeFuture: true })).toBe(1);
const [last] = await queue.dequeue(1);
expect(last).toEqual(
expect.objectContaining({
id: "3",
job: "test",
item: { value: 3 },
visibilityTimeoutMs: 2000,
attempt: 0,
timestamp: expect.any(Date),
})
);
if (!last) {
throw new Error("No item dequeued");
}
await queue.ack(last.id);
expect(await queue.size({ includeFuture: true })).toBe(0);
} finally {
await queue.close();
}
});
redisTest("Dead Letter Queue", { timeout: 20_000 }, async ({ redisContainer }) => {
const queue = new SimpleQueue({
name: "test-dlq",
schema: {
test: z.object({
value: z.number(),
}),
},
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
logger: new Logger("test", "log"),
});
try {
// Enqueue an item
await queue.enqueue({ id: "1", job: "test", item: { value: 1 }, visibilityTimeoutMs: 2000 });
expect(await queue.size()).toBe(1);
expect(await queue.sizeOfDeadLetterQueue()).toBe(0);
// Move item to DLQ
await queue.moveToDeadLetterQueue("1", "Test error message");
expect(await queue.size()).toBe(0);
expect(await queue.sizeOfDeadLetterQueue()).toBe(1);
// Attempt to dequeue from the main queue should return empty
const dequeued = await queue.dequeue(1);
expect(dequeued).toEqual([]);
// Redrive item from DLQ
await queue.redriveFromDeadLetterQueue("1");
await new Promise((resolve) => setTimeout(resolve, 200));
expect(await queue.size()).toBe(1);
expect(await queue.sizeOfDeadLetterQueue()).toBe(0);
// Dequeue the redriven item
const [redrivenItem] = await queue.dequeue(1);
if (!redrivenItem) {
throw new Error("No item dequeued");
}
expect(redrivenItem).toEqual(
expect.objectContaining({
id: "1",
job: "test",
item: { value: 1 },
visibilityTimeoutMs: 2000,
attempt: 0,
timestamp: expect.any(Date),
})
);
// Acknowledge the item
await queue.ack(redrivenItem.id);
expect(await queue.size()).toBe(0);
expect(await queue.sizeOfDeadLetterQueue()).toBe(0);
} finally {
await queue.close();
}
});
redisTest("cleanup orphaned queue entries", { timeout: 20_000 }, async ({ redisContainer }) => {
const queue = new SimpleQueue({
name: "test-orphaned",
schema: {
test: z.object({
value: z.number(),
}),
},
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
logger: new Logger("test", "log"),
});
try {
// First, add a normal item
await queue.enqueue({ id: "1", job: "test", item: { value: 1 }, visibilityTimeoutMs: 2000 });
const redisClient = createRedisClient({
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
});
// Manually add an orphaned item to the queue (without corresponding hash entry)
await redisClient.zadd(`{queue:test-orphaned:}queue`, Date.now(), "orphaned-id");
// Verify both items are in the queue
expect(await queue.size()).toBe(2);
// Dequeue should process both items, but only return the valid one
// and clean up the orphaned entry
const dequeued = await queue.dequeue(2);
// Should only get the valid item
expect(dequeued).toHaveLength(1);
expect(dequeued[0]).toEqual(
expect.objectContaining({
id: "1",
job: "test",
item: { value: 1 },
visibilityTimeoutMs: 2000,
attempt: 0,
timestamp: expect.any(Date),
})
);
// The orphaned item should have been removed
expect(await queue.size({ includeFuture: true })).toBe(1);
// Verify the orphaned ID is no longer in the queue
const orphanedScore = await redisClient.zscore(`{queue:test-orphaned:}queue`, "orphaned-id");
expect(orphanedScore).toBeNull();
} finally {
await queue.close();
}
});
redisTest(
"enqueueOnce only enqueues the first message with a given ID",
{ timeout: 20_000 },
async ({ redisContainer }) => {
const queue = new SimpleQueue({
name: "test-once",
schema: {
test: z.object({
value: z.number(),
}),
},
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
logger: new Logger("test", "log"),
});
try {
const now = Date.now();
const availableAt1 = new Date(now + 1000);
const availableAt2 = new Date(now + 5000);
// First enqueueOnce should succeed
const first = await queue.enqueueOnce({
id: "unique-id",
job: "test",
item: { value: 1 },
visibilityTimeoutMs: 2000,
availableAt: availableAt1,
});
expect(first).toBe(true);
expect(await queue.size({ includeFuture: true })).toBe(1);
// Second enqueueOnce with same ID but different value and availableAt should do nothing
const second = await queue.enqueueOnce({
id: "unique-id",
job: "test",
item: { value: 999 },
visibilityTimeoutMs: 2000,
availableAt: availableAt2,
});
expect(second).toBe(false);
expect(await queue.size({ includeFuture: true })).toBe(1);
// Dequeue after 1s should get the original item, not the second
await new Promise((resolve) => setTimeout(resolve, 1100));
const [item] = await queue.dequeue(1);
expect(item).toBeDefined();
expect(item?.id).toBe("unique-id");
expect(item?.item).toEqual({ value: 1 });
// Should not be the second value
expect(item?.item).not.toEqual({ value: 999 });
} finally {
await queue.close();
}
}
);
});
+768
View File
@@ -0,0 +1,768 @@
import {
createRedisClient,
type Redis,
type Callback,
type RedisOptions,
type Result,
} from "@internal/redis";
import { Logger } from "@trigger.dev/core/logger";
import { nanoid } from "nanoid";
import type { z } from "zod";
export interface MessageCatalogSchema {
[key: string]: z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>;
}
export type MessageCatalogKey<TMessageCatalog extends MessageCatalogSchema> = keyof TMessageCatalog;
export type MessageCatalogValue<
TMessageCatalog extends MessageCatalogSchema,
TKey extends MessageCatalogKey<TMessageCatalog>,
> = z.infer<TMessageCatalog[TKey]>;
export type AnyMessageCatalog = MessageCatalogSchema;
export type QueueItem<TMessageCatalog extends MessageCatalogSchema> = {
id: string;
job: MessageCatalogKey<TMessageCatalog>;
item: MessageCatalogValue<TMessageCatalog, MessageCatalogKey<TMessageCatalog>>;
visibilityTimeoutMs: number;
attempt: number;
timestamp: Date;
deduplicationKey?: string;
};
export type AnyQueueItem = {
id: string;
job: string;
item: any;
visibilityTimeoutMs: number;
attempt: number;
timestamp: Date;
deduplicationKey?: string;
};
export class SimpleQueue<TMessageCatalog extends MessageCatalogSchema> {
name: string;
private redis: Redis;
private schema: TMessageCatalog;
private logger: Logger;
constructor({
name,
schema,
redisOptions,
logger,
}: {
name: string;
schema: TMessageCatalog;
redisOptions: RedisOptions;
logger?: Logger;
}) {
this.name = name;
this.logger = logger ?? new Logger("SimpleQueue", "debug");
this.redis = createRedisClient(
{
...redisOptions,
keyPrefix: `${redisOptions.keyPrefix ?? ""}{queue:${name}:}`,
retryStrategy(times) {
const delay = Math.min(times * 50, 1000);
return delay;
},
maxRetriesPerRequest: 20,
},
{
onError: (error) => {
this.logger.error(`RedisWorker queue redis client error:`, {
error,
keyPrefix: redisOptions.keyPrefix,
});
},
}
);
this.#registerCommands();
this.schema = schema;
}
async cancel(cancellationKey: string): Promise<void> {
await this.redis.set(`cancellationKey:${cancellationKey}`, "1", "EX", 60 * 60 * 24); // 1 day
}
async enqueue({
id,
job,
item,
attempt,
availableAt,
visibilityTimeoutMs,
cancellationKey,
}: {
id?: string;
job: MessageCatalogKey<TMessageCatalog>;
item: MessageCatalogValue<TMessageCatalog, MessageCatalogKey<TMessageCatalog>>;
attempt?: number;
availableAt?: Date;
visibilityTimeoutMs: number;
cancellationKey?: string;
}): Promise<void> {
try {
const score = availableAt ? availableAt.getTime() : Date.now();
const deduplicationKey = nanoid();
const serializedItem = JSON.stringify({
job,
item,
visibilityTimeoutMs,
attempt,
deduplicationKey,
});
const result = cancellationKey
? await this.redis.enqueueItemWithCancellationKey(
`queue`,
`items`,
`cancellationKey:${cancellationKey}`,
id ?? nanoid(),
score,
serializedItem
)
: await this.redis.enqueueItem(`queue`, `items`, id ?? nanoid(), score, serializedItem);
if (result !== 1) {
throw new Error("Enqueue operation failed");
}
} catch (e) {
this.logger.error(`SimpleQueue ${this.name}.enqueue(): error enqueuing`, {
queue: this.name,
error: e,
id,
item,
});
throw e;
}
}
async enqueueOnce({
id,
job,
item,
attempt,
availableAt,
visibilityTimeoutMs,
}: {
id: string;
job: MessageCatalogKey<TMessageCatalog>;
item: MessageCatalogValue<TMessageCatalog, MessageCatalogKey<TMessageCatalog>>;
attempt?: number;
availableAt?: Date;
visibilityTimeoutMs: number;
}): Promise<boolean> {
if (!id) {
throw new Error("enqueueOnce requires an id");
}
try {
const score = availableAt ? availableAt.getTime() : Date.now();
const deduplicationKey = nanoid();
const serializedItem = JSON.stringify({
job,
item,
visibilityTimeoutMs,
attempt,
deduplicationKey,
});
const result = await this.redis.enqueueItemOnce(`queue`, `items`, id, score, serializedItem);
// 1 if inserted, 0 if already exists
return result === 1;
} catch (e) {
this.logger.error(`SimpleQueue ${this.name}.enqueueOnce(): error enqueuing`, {
queue: this.name,
error: e,
id,
item,
});
throw e;
}
}
async dequeue(count: number = 1): Promise<Array<QueueItem<TMessageCatalog>>> {
const now = Date.now();
try {
const results = await this.redis.dequeueItems(`queue`, `items`, now, count);
if (!results || results.length === 0) {
return [];
}
const dequeuedItems: Array<QueueItem<TMessageCatalog>> = [];
for (const [id, serializedItem, score] of results) {
const parsedItem = JSON.parse(serializedItem) as any;
if (typeof parsedItem.job !== "string") {
this.logger.error(`Invalid item in queue`, { queue: this.name, id, item: parsedItem });
continue;
}
const timestamp = new Date(Number(score));
const schema = this.schema[parsedItem.job];
if (!schema) {
this.logger.error(`Invalid item in queue, schema not found`, {
queue: this.name,
id,
item: parsedItem,
job: parsedItem.job,
timestamp,
availableJobs: Object.keys(this.schema),
});
continue;
}
const validatedItem = schema.safeParse(parsedItem.item);
if (!validatedItem.success) {
this.logger.error("Invalid item in queue", {
queue: this.name,
id,
item: parsedItem,
errors: validatedItem.error,
attempt: parsedItem.attempt,
timestamp,
});
continue;
}
const visibilityTimeoutMs = parsedItem.visibilityTimeoutMs as number;
dequeuedItems.push({
id,
job: parsedItem.job,
item: validatedItem.data,
visibilityTimeoutMs,
attempt: parsedItem.attempt ?? 0,
timestamp,
deduplicationKey: parsedItem.deduplicationKey,
});
}
return dequeuedItems;
} catch (e) {
this.logger.error(`SimpleQueue ${this.name}.dequeue(): error dequeuing`, {
queue: this.name,
error: e,
count,
});
throw e;
}
}
async ack(id: string, deduplicationKey?: string): Promise<void> {
try {
const result = await this.redis.ackItem(`queue`, `items`, id, deduplicationKey ?? "");
if (result !== 1) {
this.logger.debug(
`SimpleQueue ${this.name}.ack(): ack operation returned ${result}. This means it was not removed from the queue.`,
{
queue: this.name,
id,
deduplicationKey,
result,
}
);
}
} catch (e) {
this.logger.error(`SimpleQueue ${this.name}.ack(): error acknowledging item`, {
queue: this.name,
error: e,
id,
deduplicationKey,
});
throw e;
}
}
async reschedule(id: string, availableAt: Date): Promise<void> {
await this.redis.zadd(`queue`, "XX", availableAt.getTime(), id);
}
async size({ includeFuture = false }: { includeFuture?: boolean } = {}): Promise<number> {
try {
if (includeFuture) {
// If includeFuture is true, return the total count of all items
return await this.redis.zcard(`queue`);
} else {
// If includeFuture is false, return the count of items available now
const now = Date.now();
return await this.redis.zcount(`queue`, "-inf", now);
}
} catch (e) {
this.logger.error(`SimpleQueue ${this.name}.size(): error getting queue size`, {
queue: this.name,
error: e,
includeFuture,
});
throw e;
}
}
/**
* Age (in ms) of the oldest *overdue* message — the oldest item whose scheduled
* time has already passed (score <= now). Returns 0 when the queue is empty or
* only holds future/delayed or in-flight (future-scored) items.
*
* Resolves the candidate against the `items` hash so orphaned `queue` entries
* (a member whose payload is missing — the same stale state `dequeueItems`
* cleans up) don't report a phantom stall for work that can't be dequeued. The
* Lua scans due items oldest-first and returns the first score whose payload
* still exists.
*
* This is the generic stall signal: it stays at 0 while a queue drains healthily
* and rises only when due work sits undrained (poison block, dead consumer,
* backpressure).
*/
async oldestMessageAge(): Promise<number> {
try {
const now = Date.now();
// -1 sentinel = nothing due, or every due entry is orphaned.
const score = Number(await this.redis.getOldestDueScore(`queue`, `items`, now));
if (!Number.isFinite(score) || score < 0) {
return 0;
}
return Math.max(0, now - score);
} catch (e) {
this.logger.error(`SimpleQueue ${this.name}.oldestMessageAge(): error getting oldest age`, {
queue: this.name,
error: e,
});
// Swallow: a transient Redis error must not break observable metric collection.
return 0;
}
}
async getJob(id: string): Promise<QueueItem<TMessageCatalog> | null> {
const result = await this.redis.getJob(`queue`, `items`, id);
if (!result) {
return null;
}
const [_, score, serializedItem] = result;
const item = JSON.parse(serializedItem) as QueueItem<TMessageCatalog>;
return {
id,
job: item.job,
item: item.item,
visibilityTimeoutMs: item.visibilityTimeoutMs,
attempt: item.attempt ?? 0,
timestamp: new Date(Number(score)),
deduplicationKey: item.deduplicationKey ?? undefined,
};
}
async moveToDeadLetterQueue(id: string, errorMessage: string): Promise<void> {
try {
this.logger.debug(`SimpleQueue ${this.name}.moveToDeadLetterQueue(): moving item to DLQ`, {
queue: this.name,
id,
errorMessage,
});
const result = await this.redis.moveToDeadLetterQueue(
`queue`,
`items`,
`dlq`,
`dlq:items`,
id,
errorMessage
);
if (result !== 1) {
throw new Error("Move to Dead Letter Queue operation failed");
}
} catch (e) {
this.logger.error(
`SimpleQueue ${this.name}.moveToDeadLetterQueue(): error moving item to DLQ`,
{
queue: this.name,
error: e,
id,
errorMessage,
}
);
throw e;
}
}
async sizeOfDeadLetterQueue(): Promise<number> {
try {
return await this.redis.zcard(`dlq`);
} catch (e) {
this.logger.error(`SimpleQueue ${this.name}.dlqSize(): error getting DLQ size`, {
queue: this.name,
error: e,
});
throw e;
}
}
async redriveFromDeadLetterQueue(id: string): Promise<void> {
try {
const result = await this.redis.redriveFromDeadLetterQueue(
`queue`,
`items`,
`dlq`,
`dlq:items`,
id
);
if (result !== 1) {
throw new Error("Redrive from Dead Letter Queue operation failed");
}
} catch (e) {
this.logger.error(
`SimpleQueue ${this.name}.redriveFromDeadLetterQueue(): error redriving item from DLQ`,
{
queue: this.name,
error: e,
id,
}
);
throw e;
}
}
async close(): Promise<void> {
await this.redis.quit();
}
#registerCommands() {
this.redis.defineCommand("enqueueItem", {
numberOfKeys: 2,
lua: `
local queue = KEYS[1]
local items = KEYS[2]
local id = ARGV[1]
local score = ARGV[2]
local serializedItem = ARGV[3]
redis.call('ZADD', queue, score, id)
redis.call('HSET', items, id, serializedItem)
return 1
`,
});
this.redis.defineCommand("enqueueItemWithCancellationKey", {
numberOfKeys: 3,
lua: `
local queue = KEYS[1]
local items = KEYS[2]
local cancellationKey = KEYS[3]
local id = ARGV[1]
local score = ARGV[2]
local serializedItem = ARGV[3]
-- if the cancellation key exists, return 1
if redis.call('EXISTS', cancellationKey) == 1 then
return 1
end
redis.call('ZADD', queue, score, id)
redis.call('HSET', items, id, serializedItem)
return 1
`,
});
this.redis.defineCommand("dequeueItems", {
numberOfKeys: 2,
lua: `
local queue = KEYS[1]
local items = KEYS[2]
local now = tonumber(ARGV[1])
local count = tonumber(ARGV[2])
local result = redis.call('ZRANGEBYSCORE', queue, '-inf', now, 'WITHSCORES', 'LIMIT', 0, count)
if #result == 0 then
return {}
end
local dequeued = {}
for i = 1, #result, 2 do
local id = result[i]
local score = tonumber(result[i + 1])
if score > now then
break
end
local serializedItem = redis.call('HGET', items, id)
if serializedItem then
local item = cjson.decode(serializedItem)
local visibilityTimeoutMs = tonumber(item.visibilityTimeoutMs)
local invisibleUntil = now + visibilityTimeoutMs
redis.call('ZADD', queue, invisibleUntil, id)
table.insert(dequeued, {id, serializedItem, score})
else
-- Remove the orphaned queue entry if no corresponding item exists
redis.call('ZREM', queue, id)
end
end
return dequeued
`,
});
this.redis.defineCommand("getOldestDueScore", {
numberOfKeys: 2,
lua: `
local queue = KEYS[1]
local items = KEYS[2]
local now = tonumber(ARGV[1])
-- Oldest-first scan of due items, bounded so a long prefix of orphans can't
-- make this O(n). Orphans are rare (dequeueItems removes them), so in the
-- common case this returns on the first iteration. Read-only: unlike
-- dequeueItems we don't ZREM orphans here — a metric probe must not mutate.
local result = redis.call('ZRANGEBYSCORE', queue, '-inf', now, 'WITHSCORES', 'LIMIT', 0, 100)
for i = 1, #result, 2 do
local id = result[i]
if redis.call('HEXISTS', items, id) == 1 then
return result[i + 1]
end
end
return -1
`,
});
this.redis.defineCommand("getJob", {
numberOfKeys: 2,
lua: `
local queue = KEYS[1]
local items = KEYS[2]
local jobId = ARGV[1]
local serializedItem = redis.call('HGET', items, jobId)
if serializedItem == false then
return nil
end
-- get the score from the queue sorted set
local score = redis.call('ZSCORE', queue, jobId)
return { jobId, score, serializedItem }
`,
});
this.redis.defineCommand("ackItem", {
numberOfKeys: 2,
lua: `
local queueKey = KEYS[1]
local itemsKey = KEYS[2]
local id = ARGV[1]
local deduplicationKey = ARGV[2]
-- Get the item from the hash
local item = redis.call('HGET', itemsKey, id)
if not item then
return -1
end
-- Only check deduplicationKey if a non-empty one was passed in
if deduplicationKey and deduplicationKey ~= "" then
local success, parsed = pcall(cjson.decode, item)
if success then
if parsed.deduplicationKey and parsed.deduplicationKey ~= deduplicationKey then
return 0
end
end
end
-- Remove from sorted set and hash
redis.call('ZREM', queueKey, id)
redis.call('HDEL', itemsKey, id)
return 1
`,
});
this.redis.defineCommand("moveToDeadLetterQueue", {
numberOfKeys: 4,
lua: `
local queue = KEYS[1]
local items = KEYS[2]
local dlq = KEYS[3]
local dlqItems = KEYS[4]
local id = ARGV[1]
local errorMessage = ARGV[2]
local item = redis.call('HGET', items, id)
if not item then
return 0
end
local parsedItem = cjson.decode(item)
parsedItem.errorMessage = errorMessage
local time = redis.call('TIME')
local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
redis.call('ZREM', queue, id)
redis.call('HDEL', items, id)
redis.call('ZADD', dlq, now, id)
redis.call('HSET', dlqItems, id, cjson.encode(parsedItem))
return 1
`,
});
this.redis.defineCommand("redriveFromDeadLetterQueue", {
numberOfKeys: 4,
lua: `
local queue = KEYS[1]
local items = KEYS[2]
local dlq = KEYS[3]
local dlqItems = KEYS[4]
local id = ARGV[1]
local item = redis.call('HGET', dlqItems, id)
if not item then
return 0
end
local parsedItem = cjson.decode(item)
parsedItem.errorMessage = nil
local time = redis.call('TIME')
local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
redis.call('ZREM', dlq, id)
redis.call('HDEL', dlqItems, id)
redis.call('ZADD', queue, now, id)
redis.call('HSET', items, id, cjson.encode(parsedItem))
return 1
`,
});
this.redis.defineCommand("enqueueItemOnce", {
numberOfKeys: 2,
lua: `
local queue = KEYS[1]
local items = KEYS[2]
local id = ARGV[1]
local score = ARGV[2]
local serializedItem = ARGV[3]
-- Only add if not exists
local added = redis.call('HSETNX', items, id, serializedItem)
if added == 1 then
redis.call('ZADD', queue, 'NX', score, id)
return 1
else
return 0
end
`,
});
}
}
declare module "@internal/redis" {
interface RedisCommander<Context> {
enqueueItem(
//keys
queue: string,
items: string,
//args
id: string,
score: number,
serializedItem: string,
callback?: Callback<number>
): Result<number, Context>;
enqueueItemWithCancellationKey(
//keys
queue: string,
items: string,
cancellationKey: string,
//args
id: string,
score: number,
serializedItem: string,
callback?: Callback<number>
): Result<number, Context>;
dequeueItems(
//keys
queue: string,
items: string,
//args
now: number,
count: number,
callback?: Callback<Array<[string, string, string]>>
): Result<Array<[string, string, string]>, Context>;
ackItem(
queue: string,
items: string,
id: string,
deduplicationKey: string,
callback?: Callback<number>
): Result<number, Context>;
redriveFromDeadLetterQueue(
queue: string,
items: string,
dlq: string,
dlqItems: string,
id: string,
callback?: Callback<number>
): Result<number, Context>;
moveToDeadLetterQueue(
queue: string,
items: string,
dlq: string,
dlqItems: string,
id: string,
errorMessage: string,
callback?: Callback<number>
): Result<number, Context>;
enqueueItemOnce(
queue: string,
items: string,
id: string,
score: number,
serializedItem: string,
callback?: Callback<number>
): Result<number, Context>;
getJob(
queue: string,
items: string,
id: string,
callback?: Callback<[string, string, string] | null>
): Result<[string, string, string] | null, Context>;
getOldestDueScore(
//keys
queue: string,
items: string,
//args
now: number,
callback?: Callback<string | number>
): Result<string | number, Context>;
}
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Check if an error is an AbortError.
*
* This handles both:
* - Custom abort errors created with `new Error("AbortError")` (sets .message)
* - Native Node.js AbortError from timers/promises (sets .name)
*/
export function isAbortError(error: unknown): boolean {
return error instanceof Error && (error.name === "AbortError" || error.message === "AbortError");
}
+630
View File
@@ -0,0 +1,630 @@
import { isolatedRedisTest as redisTest } from "@internal/testcontainers";
import { Logger } from "@trigger.dev/core/logger";
import { describe } from "node:test";
import { expect } from "vitest";
import { z } from "zod";
import { Worker } from "./worker.js";
import { createRedisClient } from "@internal/redis";
describe("Worker", () => {
redisTest("Process items that don't throw", { timeout: 30_000 }, async ({ redisContainer }) => {
const processedItems: number[] = [];
const worker = new Worker({
name: "test-worker",
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
catalog: {
testJob: {
schema: z.object({ value: z.number() }),
visibilityTimeoutMs: 5000,
retry: { maxAttempts: 3 },
},
},
jobs: {
testJob: async ({ payload }) => {
await new Promise((resolve) => setTimeout(resolve, 30)); // Simulate work
processedItems.push(payload.value);
},
},
concurrency: {
workers: 2,
tasksPerWorker: 3,
},
logger: new Logger("test", "log"),
}).start();
// Enqueue 10 items
for (let i = 0; i < 10; i++) {
await worker.enqueue({
id: `item-${i}`,
job: "testJob",
payload: { value: i },
visibilityTimeoutMs: 5000,
});
}
// Wait for items to be processed
await new Promise((resolve) => setTimeout(resolve, 2000));
expect(processedItems.length).toBe(10);
expect(new Set(processedItems).size).toBe(10); // Ensure all items were processed uniquely
});
redisTest(
"Process items that throw an error",
{ timeout: 30_000 },
async ({ redisContainer }) => {
const processedItems: number[] = [];
const hadAttempt = new Set<string>();
const worker = new Worker({
name: "test-worker",
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
catalog: {
testJob: {
schema: z.object({ value: z.number() }),
visibilityTimeoutMs: 5000,
retry: { maxAttempts: 3, minTimeoutInMs: 10, maxTimeoutInMs: 10 },
},
},
jobs: {
testJob: async ({ id, payload }) => {
if (!hadAttempt.has(id)) {
hadAttempt.add(id);
throw new Error("Test error");
}
await new Promise((resolve) => setTimeout(resolve, 30)); // Simulate work
processedItems.push(payload.value);
},
},
concurrency: {
workers: 2,
tasksPerWorker: 3,
},
pollIntervalMs: 50,
logger: new Logger("test", "error"),
}).start();
// Enqueue 10 items
for (let i = 0; i < 10; i++) {
await worker.enqueue({
id: `item-${i}`,
job: "testJob",
payload: { value: i },
visibilityTimeoutMs: 5000,
});
}
// Wait for items to be processed
await new Promise((resolve) => setTimeout(resolve, 500));
expect(processedItems.length).toBe(10);
expect(new Set(processedItems).size).toBe(10); // Ensure all items were processed uniquely
}
);
redisTest(
"Process an item that permanently fails and ends up in DLQ",
{ timeout: 30_000 },
async ({ redisContainer }) => {
const processedItems: number[] = [];
const failedItemId = "permanent-fail-item";
const worker = new Worker({
name: "test-worker",
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
catalog: {
testJob: {
schema: z.object({ value: z.number() }),
visibilityTimeoutMs: 1000,
retry: { maxAttempts: 3, minTimeoutInMs: 10, maxTimeoutInMs: 50 },
},
},
jobs: {
testJob: async ({ id, payload }) => {
if (id === failedItemId) {
throw new Error("Permanent failure");
}
processedItems.push(payload.value);
},
},
concurrency: {
workers: 1,
tasksPerWorker: 1,
},
pollIntervalMs: 50,
logger: new Logger("test", "error"),
}).start();
// Enqueue the item that will permanently fail
await worker.enqueue({
id: failedItemId,
job: "testJob",
payload: { value: 999 },
});
// Enqueue a normal item
await worker.enqueue({
id: "normal-item",
job: "testJob",
payload: { value: 1 },
});
// Wait for items to be processed and retried
await new Promise((resolve) => setTimeout(resolve, 1000));
// Check that the normal item was processed
expect(processedItems).toEqual([1]);
// Check that the failed item is in the DLQ
const dlqSize = await worker.queue.sizeOfDeadLetterQueue();
expect(dlqSize).toBe(1);
}
);
redisTest(
"Redrive an item from DLQ and process it successfully",
{ timeout: 30_000 },
async ({ redisContainer }) => {
const processedItems: number[] = [];
const failedItemId = "fail-then-redrive-item";
let attemptCount = 0;
const worker = new Worker({
name: "test-worker",
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
catalog: {
testJob: {
schema: z.object({ value: z.number() }),
visibilityTimeoutMs: 1000,
retry: { maxAttempts: 3, minTimeoutInMs: 10, maxTimeoutInMs: 50 },
},
},
jobs: {
testJob: async ({ id, payload }) => {
if (id === failedItemId && attemptCount < 3) {
attemptCount++;
throw new Error("Temporary failure");
}
processedItems.push(payload.value);
},
},
concurrency: {
workers: 1,
tasksPerWorker: 1,
},
pollIntervalMs: 50,
logger: new Logger("test", "error"),
}).start();
// Enqueue the item that will fail 3 times
await worker.enqueue({
id: failedItemId,
job: "testJob",
payload: { value: 999 },
});
// Wait for the item to be processed and moved to DLQ
await new Promise((resolve) => setTimeout(resolve, 1000));
// Check that the item is in the DLQ
let dlqSize = await worker.queue.sizeOfDeadLetterQueue();
expect(dlqSize).toBe(1);
// Create a Redis client to publish the redrive message
const redisClient = createRedisClient({
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
});
// Publish redrive message
await redisClient.publish("test-worker:redrive", JSON.stringify({ id: failedItemId }));
// Wait for the item to be redrived and processed
await new Promise((resolve) => setTimeout(resolve, 1000));
// Check that the item was processed successfully
expect(processedItems).toEqual([999]);
// Check that the DLQ is now empty
dlqSize = await worker.queue.sizeOfDeadLetterQueue();
expect(dlqSize).toBe(0);
await redisClient.quit();
}
);
redisTest(
"Should process a job with the same ID only once when rescheduled",
{ timeout: 30_000 },
async ({ redisContainer }) => {
const processedPayloads: string[] = [];
const worker = new Worker({
name: "test-worker",
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
catalog: {
testJob: {
schema: z.object({ value: z.string() }),
visibilityTimeoutMs: 5000,
retry: { maxAttempts: 3 },
},
},
jobs: {
testJob: async ({ payload }) => {
await new Promise((resolve) => setTimeout(resolve, 30)); // Simulate work
processedPayloads.push(payload.value);
},
},
concurrency: {
workers: 1,
tasksPerWorker: 1,
},
pollIntervalMs: 10, // Ensure quick polling to detect the scheduled item
logger: new Logger("test", "log"),
}).start();
// Unique ID to use for both enqueues
const testJobId = "duplicate-job-id";
// Enqueue the first item immediately
await worker.enqueue({
id: testJobId,
job: "testJob",
payload: { value: "first-attempt" },
availableAt: new Date(Date.now() + 50),
});
// Enqueue another item with the same ID but scheduled 50ms in the future
await worker.enqueue({
id: testJobId,
job: "testJob",
payload: { value: "second-attempt" },
availableAt: new Date(Date.now() + 50),
});
// Wait enough time for both jobs to be processed if they were going to be
await new Promise((resolve) => setTimeout(resolve, 300));
// Verify that only one job was processed (the second one should have replaced the first)
expect(processedPayloads.length).toBe(1);
// Verify that the second job's payload was the one processed
expect(processedPayloads[0]).toBe("second-attempt");
await worker.stop();
}
);
redisTest(
"Should process second job with same ID when enqueued during first job execution with future availableAt",
{ timeout: 30_000 },
async ({ redisContainer }) => {
const processedPayloads: string[] = [];
const jobStarted: string[] = [];
let firstJobCompleted = false;
const worker = new Worker({
name: "test-worker",
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
catalog: {
testJob: {
schema: z.object({ value: z.string() }),
visibilityTimeoutMs: 5000,
retry: { maxAttempts: 3 },
},
},
jobs: {
testJob: async ({ payload }) => {
// Record when the job starts processing
jobStarted.push(payload.value);
if (payload.value === "first-attempt") {
// First job takes a long time to process
await new Promise((resolve) => setTimeout(resolve, 1_000));
firstJobCompleted = true;
}
// Record when the job completes
processedPayloads.push(payload.value);
},
},
concurrency: {
workers: 1,
tasksPerWorker: 1,
},
pollIntervalMs: 10,
logger: new Logger("test", "log"),
}).start();
const testJobId = "long-running-job-id";
// Queue the first job
await worker.enqueue({
id: testJobId,
job: "testJob",
payload: { value: "first-attempt" },
});
// Verify initial queue size
const size1 = await worker.queue.size({ includeFuture: true });
expect(size1).toBe(1);
// Wait until we know the first job has started processing
while (jobStarted.length === 0) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
// Now that first job is running, queue second job with same ID
// Set availableAt to be 1.5 seconds in the future (after first job completes)
await worker.enqueue({
id: testJobId,
job: "testJob",
payload: { value: "second-attempt" },
availableAt: new Date(Date.now() + 1500),
});
// Verify queue size after second enqueue
const size2 = await worker.queue.size({ includeFuture: true });
const _size2Present = await worker.queue.size({ includeFuture: false });
expect(size2).toBe(1); // Should still be 1 as it's the same ID
// Wait for the first job to complete
while (!firstJobCompleted) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
// Check queue size right after first job completes
const _size3 = await worker.queue.size({ includeFuture: true });
const _size3Present = await worker.queue.size({ includeFuture: false });
// Wait long enough for the second job to become available and potentially run
await new Promise((resolve) => setTimeout(resolve, 2000));
// Final queue size
const _size4 = await worker.queue.size({ includeFuture: true });
const _size4Present = await worker.queue.size({ includeFuture: false });
// First job should have run
expect(processedPayloads).toContain("first-attempt");
// These assertions should fail - demonstrating the bug
// The second job should run after its availableAt time, but doesn't because
// the ack from the first job removed it from Redis entirely
expect(jobStarted).toContain("second-attempt");
expect(processedPayloads).toContain("second-attempt");
expect(processedPayloads.length).toBe(2);
await worker.stop();
}
);
redisTest(
"Should properly remove future-scheduled job after completion",
{ timeout: 30_000 },
async ({ redisContainer }) => {
const processedPayloads: string[] = [];
const worker = new Worker({
name: "test-worker",
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
catalog: {
testJob: {
schema: z.object({ value: z.string() }),
visibilityTimeoutMs: 5000,
retry: { maxAttempts: 3 },
},
},
jobs: {
testJob: async ({ payload }) => {
processedPayloads.push(payload.value);
},
},
concurrency: {
workers: 1,
tasksPerWorker: 1,
},
pollIntervalMs: 10,
logger: new Logger("test", "debug"), // Use debug to see all logs
}).start();
// Schedule a job 500ms in the future
await worker.enqueue({
id: "future-job",
job: "testJob",
payload: { value: "test" },
availableAt: new Date(Date.now() + 500),
});
// Verify it's in the future queue
const initialSize = await worker.queue.size();
const initialSizeWithFuture = await worker.queue.size({ includeFuture: true });
expect(initialSize).toBe(0);
expect(initialSizeWithFuture).toBe(1);
// Wait for job to be processed
await new Promise((resolve) => setTimeout(resolve, 1000));
// Verify job was processed
expect(processedPayloads).toContain("test");
// Verify queue is completely empty
const finalSize = await worker.queue.size();
const finalSizeWithFuture = await worker.queue.size({ includeFuture: true });
expect(finalSize).toBe(0);
expect(finalSizeWithFuture).toBe(0);
await worker.stop();
}
);
redisTest(
"Should properly remove immediate job after completion",
{ timeout: 30_000 },
async ({ redisContainer }) => {
const processedPayloads: string[] = [];
const worker = new Worker({
name: "test-worker",
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
catalog: {
testJob: {
schema: z.object({ value: z.string() }),
visibilityTimeoutMs: 5000,
retry: { maxAttempts: 3 },
},
},
jobs: {
testJob: async ({ payload }) => {
processedPayloads.push(payload.value);
},
},
concurrency: {
workers: 1,
tasksPerWorker: 1,
},
pollIntervalMs: 10,
logger: new Logger("test", "debug"), // Use debug to see all logs
}).start();
// Enqueue a job to run immediately
await worker.enqueue({
id: "immediate-job",
job: "testJob",
payload: { value: "test" },
});
// Verify it's in the present queue
const initialSize = await worker.queue.size();
const initialSizeWithFuture = await worker.queue.size({ includeFuture: true });
expect(initialSize).toBe(1);
expect(initialSizeWithFuture).toBe(1);
// Wait for job to be processed
await new Promise((resolve) => setTimeout(resolve, 1000));
// Verify job was processed
expect(processedPayloads).toContain("test");
// Verify queue is completely empty
const finalSize = await worker.queue.size();
const finalSizeWithFuture = await worker.queue.size({ includeFuture: true });
expect(finalSize).toBe(0);
expect(finalSizeWithFuture).toBe(0);
await worker.stop();
}
);
redisTest(
"Should allow cancelling a job before it's enqueued, but only if the enqueue.cancellationKey is provided",
{ timeout: 30_000 },
async ({ redisContainer }) => {
const processedPayloads: string[] = [];
const worker = new Worker({
name: "test-worker",
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
catalog: {
testJob: {
schema: z.object({ value: z.string() }),
visibilityTimeoutMs: 5000,
retry: { maxAttempts: 3 },
},
},
jobs: {
testJob: async ({ payload }) => {
processedPayloads.push(payload.value);
},
},
concurrency: {
workers: 1,
tasksPerWorker: 1,
},
pollIntervalMs: 10,
logger: new Logger("test", "debug"), // Use debug to see all logs
}).start();
// Enqueue a job to run immediately
await worker.enqueue({
id: "immediate-job",
job: "testJob",
payload: { value: "test" },
cancellationKey: "test-cancellation-key",
});
// Verify it's in the present queue
const initialSize = await worker.queue.size();
const initialSizeWithFuture = await worker.queue.size({ includeFuture: true });
expect(initialSize).toBe(1);
expect(initialSizeWithFuture).toBe(1);
// Wait for job to be processed
await new Promise((resolve) => setTimeout(resolve, 1000));
// Verify job was processed
expect(processedPayloads).toContain("test");
// Verify queue is completely empty
const finalSize = await worker.queue.size();
const finalSizeWithFuture = await worker.queue.size({ includeFuture: true });
expect(finalSize).toBe(0);
expect(finalSizeWithFuture).toBe(0);
// Now cancel a key
await worker.cancel("test-cancellation-key-2");
await worker.enqueue({
id: "immediate-job",
job: "testJob",
payload: { value: "test" },
cancellationKey: "test-cancellation-key-2",
});
// Verify it's not in the queue (since it's been cancelled)
const finalSize2 = await worker.queue.size();
expect(finalSize2).toBe(0);
const finalSize2WithFuture = await worker.queue.size({ includeFuture: true });
expect(finalSize2WithFuture).toBe(0);
await worker.stop();
}
);
});
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"],
"compilerOptions": {
"composite": true,
"target": "ES2019",
"lib": ["ES2019", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
"outDir": "dist",
"module": "Node16",
"moduleResolution": "Node16",
"moduleDetection": "force",
"verbatimModuleSyntax": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"preserveWatchOutput": true,
"skipLibCheck": true,
"strict": true,
"declaration": true
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../.configs/tsconfig.base.json",
"references": [
{
"path": "./tsconfig.src.json"
},
{
"path": "./tsconfig.test.json"
}
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"include": ["./src/**/*.ts"],
"compilerOptions": {
"isolatedDeclarations": false,
"composite": true,
"sourceMap": true,
"customConditions": ["@triggerdotdev/source"]
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"include": ["./test/**/*.ts"],
"references": [{ "path": "./tsconfig.src.json" }],
"compilerOptions": {
"isolatedDeclarations": false,
"composite": true,
"sourceMap": true,
"types": ["vitest/globals"]
}
}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
treeshake: true,
bundle: true,
minify: false,
noExternal: [
// Always bundle internal packages
/^@internal/,
// Always bundle ESM-only packages
"nanoid",
"p-limit",
],
banner: ({ format }) => {
if (format !== "esm") return;
return {
js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url || process.cwd() + '/index.js');`,
};
},
});
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vitest/config";
import { DurationShardingSequencer } from "@internal/testcontainers/sequencer";
export default defineConfig({
test: {
sequence: { sequencer: DurationShardingSequencer },
include: ["**/*.test.ts"],
globals: true,
// CI-only: absorbs timing races (real-clock waits vs worker poll interval) under shard CPU contention
retry: process.env.CI ? 2 : 0,
fileParallelism: false,
},
});