Is your feature request related to a problem? Please describe.
Problem Statement
Currently, Trigger.dev provides fairness at the environment level through the FairQueueSelectionStrategy, but there's no way to ensure fair distribution of compute capacity across arbitrary identifiers (e.g., user IDs, organization IDs, tenant IDs) within a single environment.
This creates a significant problem for multi-tenant applications where one tenant could monopolize all available environment concurrency, starving other tenants of resources.
Why Existing Features Don't Solve This
1. concurrencyKey Creates Per-Tenant Queues, But No Fair Distribution
While concurrencyKey allows creating separate queues per tenant:
await myTask.trigger(data, {
queue: "my-queue",
concurrencyKey: userId // Creates separate queue per user
});
The problem: You cannot ensure fair distribution of environment capacity across all concurrency keys.
- If you set
concurrencyLimit: 10 on the queue, each concurrency key gets its own limit of 10
- With 100 users, this effectively becomes a limit of 1000 (100 users × 10 concurrent runs each)
- A single user triggering 1000 tasks will occupy the entire environment capacity, even if 99 other users are waiting
- The dequeue process doesn't rotate fairly between different concurrency keys - it processes queues based on age/priority only
Example scenario:
- Environment concurrency limit: 100
- Task queue with
concurrencyLimit: 10 and concurrencyKey: userId
- User A triggers 500 tasks
- User B triggers 10 tasks
Current behavior: User A's tasks fill all 100 environment slots, User B waits indefinitely
Expected behavior: Capacity should be distributed fairly across both users (e.g., 50/50 or round-robin)
2. Priority Doesn't Provide Fairness
The priority option allows individual runs to jump ahead in the queue:
await myTask.trigger(data, { priority: 1000 });
The problem: Priority is a time offset for ordering, not a fairness mechanism.
- It affects the order of dequeuing within a queue
- It doesn't prevent one tenant from monopolizing capacity
- You'd need to dynamically calculate priorities for every run to simulate fairness, which is:
- Complex and error-prone
- Requires external tracking of per-tenant run counts
- Still doesn't guarantee true fairness due to timing issues
- Doesn't work well with the checkpoint/resume system
Describe the solution you'd like to see
Proposed Solution: Fairness at Trigger Time
Add a new fairness option when triggering tasks that enables fair distribution of capacity across a specified group identifier:
// Simple usage - fair distribution by user
await myTask.trigger(data, {
fairness: {
groupId: userId, // The identifier to group by for fairness
strategy: "round-robin" // or "weighted" or "proportional"
}
});
// Advanced usage - fair distribution by organization with weighted strategy
await myTask.trigger(data, {
fairness: {
groupId: organizationId,
strategy: "weighted",
weight: organization.tier === "premium" ? 2 : 1 // Optional: custom weight
}
});
How it would work:
- Environment-level fairness (already exists): Distribute capacity fairly across environments
- NEW: GroupId-level fairness: Within each environment, distribute capacity fairly across different
groupId values
- Queue ordering: Within each group's allocation, respect priority and age-based ordering (existing behavior)
Strategies:
-
round-robin: Each groupId gets an equal chance regardless of queue depth
- User A (500 tasks) and User B (10 tasks) each get ~50 slots
-
weighted: Distribution based on custom weights
- Premium users get 2x capacity of free users
-
proportional: Distribution based on queue depth
- User A (500 tasks) gets ~83 slots, User B (100 tasks) gets ~17 slots
Implementation Considerations
This would likely involve:
-
Extending the dequeue logic to track and rotate through different groupId values
-
Similar to how FairQueueSelectionStrategy already handles environment-level fairness, but at a more granular level
-
The groupId would be stored in the message payload (similar to concurrencyKey)
-
At dequeue time, the system would:
- Group pending runs by their
groupId
- Apply the specified strategy to distribute available capacity
- Within each group's allocation, process by priority/age
-
Ensure compatibility with existing features:
- Works with or without
concurrencyKey
- Priority still respected within each group's allocation
- Checkpoints/resume work normally
- Global concurrency limits are still respected
Example Behavior
With round-robin fairness:
// User A triggers 500 tasks
for (let i = 0; i < 500; i++) {
await myTask.trigger({ userId: "user-a", data: i }, {
fairness: { groupId: "user-a", strategy: "round-robin" }
});
}
// User B triggers 50 tasks
for (let i = 0; i < 50; i++) {
await myTask.trigger({ userId: "user-b", data: i }, {
fairness: { groupId: "user-b", strategy: "round-robin" }
});
}
// User C triggers 200 tasks
for (let i = 0; i < 200; i++) {
await myTask.trigger({ userId: "user-c", data: i }, {
fairness: { groupId: "user-c", strategy: "round-robin" }
});
}
With environment limit of 100:
- Round-robin: ~33 slots for User A, ~33 for User B, ~34 for User C
- Proportional: ~67 slots for User A, ~7 for User B, ~26 for User C
Use Cases
-
Multi-tenant SaaS: Prevent one customer from affecting others' task processing
await processUserData.trigger(data, {
fairness: { groupId: user.organizationId, strategy: "round-robin" }
});
-
Freemium models: Give premium users more capacity
await generateReport.trigger(data, {
fairness: {
groupId: user.id,
strategy: "weighted",
weight: user.tier === "premium" ? 3 : 1
}
});
-
Department isolation: Ensure fair distribution across teams
await analyzeData.trigger(data, {
fairness: { groupId: department.id, strategy: "proportional" }
});
-
API rate limiting by customer: Fair processing of webhooks
await processWebhook.trigger(data, {
fairness: { groupId: apiKey.customerId, strategy: "round-robin" }
});
Benefits of Trigger-Time Configuration
- Flexibility: Can decide per-run whether to use fairness
- Dynamic grouping: GroupId can be determined at runtime
- No task definition changes: Works with existing tasks
- Explicit: Clear in the calling code that fairness is being used
- Granular control: Different strategies for different use cases
Describe alternate solutions
Alternative Workarounds (and why they're insufficient)
- ❌ Separate environments per tenant: Expensive, doesn't scale, defeats the purpose of multi-tenancy
- ❌ Manual priority calculation: Complex, error-prone, not true fairness
- ❌ Multiple queues with different limits: Still doesn't prevent monopolization
- ❌ External rate limiting: Adds latency, doesn't integrate with Trigger.dev's queue system
Additional information
If the team finds it necessary or feasible, I am willing to contribute.
Is your feature request related to a problem? Please describe.
Problem Statement
Currently, Trigger.dev provides fairness at the environment level through the
FairQueueSelectionStrategy, but there's no way to ensure fair distribution of compute capacity across arbitrary identifiers (e.g., user IDs, organization IDs, tenant IDs) within a single environment.This creates a significant problem for multi-tenant applications where one tenant could monopolize all available environment concurrency, starving other tenants of resources.
Why Existing Features Don't Solve This
1. concurrencyKey Creates Per-Tenant Queues, But No Fair Distribution
While
concurrencyKeyallows creating separate queues per tenant:The problem: You cannot ensure fair distribution of environment capacity across all concurrency keys.
concurrencyLimit: 10on the queue, each concurrency key gets its own limit of 10Example scenario:
concurrencyLimit: 10andconcurrencyKey: userIdCurrent behavior: User A's tasks fill all 100 environment slots, User B waits indefinitely
Expected behavior: Capacity should be distributed fairly across both users (e.g., 50/50 or round-robin)
2. Priority Doesn't Provide Fairness
The
priorityoption allows individual runs to jump ahead in the queue:The problem: Priority is a time offset for ordering, not a fairness mechanism.
Describe the solution you'd like to see
Proposed Solution: Fairness at Trigger Time
Add a new
fairnessoption when triggering tasks that enables fair distribution of capacity across a specified group identifier:How it would work:
groupIdvaluesStrategies:
round-robin: Each groupId gets an equal chance regardless of queue depthweighted: Distribution based on custom weightsproportional: Distribution based on queue depthImplementation Considerations
This would likely involve:
Extending the dequeue logic to track and rotate through different
groupIdvaluesSimilar to how
FairQueueSelectionStrategyalready handles environment-level fairness, but at a more granular levelThe
groupIdwould be stored in the message payload (similar toconcurrencyKey)At dequeue time, the system would:
groupIdEnsure compatibility with existing features:
concurrencyKeyExample Behavior
With round-robin fairness:
With environment limit of 100:
Use Cases
Multi-tenant SaaS: Prevent one customer from affecting others' task processing
Freemium models: Give premium users more capacity
Department isolation: Ensure fair distribution across teams
API rate limiting by customer: Fair processing of webhooks
Benefits of Trigger-Time Configuration
Describe alternate solutions
Alternative Workarounds (and why they're insufficient)
Additional information
If the team finds it necessary or feasible, I am willing to contribute.