Skip to content

feat: Per-ID Fairness for Queue Processing #2617

Description

@LeoKaynan

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:

  1. Environment-level fairness (already exists): Distribute capacity fairly across environments
  2. NEW: GroupId-level fairness: Within each environment, distribute capacity fairly across different groupId values
  3. 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:

  1. Extending the dequeue logic to track and rotate through different groupId values

  2. Similar to how FairQueueSelectionStrategy already handles environment-level fairness, but at a more granular level

  3. The groupId would be stored in the message payload (similar to concurrencyKey)

  4. 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
  5. 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

  1. Flexibility: Can decide per-run whether to use fairness
  2. Dynamic grouping: GroupId can be determined at runtime
  3. No task definition changes: Works with existing tasks
  4. Explicit: Clear in the calling code that fairness is being used
  5. Granular control: Different strategies for different use cases

Describe alternate solutions

Alternative Workarounds (and why they're insufficient)

  1. Separate environments per tenant: Expensive, doesn't scale, defeats the purpose of multi-tenancy
  2. Manual priority calculation: Complex, error-prone, not true fairness
  3. Multiple queues with different limits: Still doesn't prevent monopolization
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions