Skip to content

Conversation

@matt-aitken
Copy link
Member

@matt-aitken matt-aitken commented Nov 13, 2025

For Cloud this allows you to add concurrency to your environments by purchasing the add-on and then allocating concurrency.

CleanShot 2025-11-13 at 18 24 21@2x

It also adds a project limit (default 10) to organizations.

@changeset-bot
Copy link

changeset-bot bot commented Nov 13, 2025

⚠️ No Changeset found

Latest commit: d2be4b2

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 13, 2025

Walkthrough

Adds end-to-end concurrency management: new UI components (ConcurrencyIcon, InputNumberStepper), SideMenu entry and path builder, a full Remix concurrency route with loader/action, ManageConcurrencyPresenter, AllocateConcurrencyService and SetConcurrencyAddOnService, platform helpers for per-environment defaults and billing integration, updates to concurrency calculations in background worker and task triggers, toast/feedback URL integration, Prisma schema/migration adding Organization.maximumProjectCount with project-limit enforcement, storybook examples, and multiple supporting type and prop changes across models, services, presenters, and routes.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Areas requiring extra attention:

  • ManageConcurrencyPresenter.server.ts — plan limits, environment filtering, aggregation, and error paths
  • routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx — loader/action, zod schemas, allocation UI and modal flows, redirect/error messaging
  • v3 services: allocateConcurrency.server.ts and setConcurrencyAddOn.server.ts — validation, DB updates, side effects (updateEnvConcurrencyLimits), billing and email branches
  • services/platform.v3.server.ts — getDefaultEnvironmentConcurrencyLimit, setConcurrencyAddOn, setPlan tryCatch/result handling and runtime-aware defaults
  • Database migration and prisma schema change for maximumProjectCount and related project creation guard (ExceededProjectLimitError)
  • models/project.server.ts and models/organization.server.ts — project count enforcement and per-environment limit wiring
  • UI integrations: Toast.tsx Action handling, Feedback.tsx URL-driven open behavior and new onOpenChange prop, InputNumberStepper rounding/commit semantics and accessibility
  • Path/routing changes: pathBuilder.concurrencyPath, SideMenu wiring, removed redirect route — verify links and redirects
  • Concurrency calculations updated in createBackgroundWorker.server.ts and triggerTaskV1.server.ts — ensure runtime effects and clamping logic are correct
  • Storybook additions and new input variants — ensure examples and variant styling match component APIs

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Description check ❓ Inconclusive The PR description provides a clear explanation of the changes (concurrency self-serve with screenshot and project limit) but does not follow the required template structure with checklist, testing, and changelog sections. Follow the repository's PR template by including the required checklist, testing steps, changelog section, and other template elements to ensure consistency and completeness.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'Concurrency self serve' accurately reflects the main objective of enabling self-serve concurrency management for Cloud environments and organization project limits.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch concurrency-self-serve

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6ab5a07 and d2be4b2.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (4)
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx (2 hunks)
  • apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx (2 hunks)
  • apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx (9 hunks)
  • apps/webapp/package.json (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/webapp/package.json
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 2681
File: apps/webapp/app/services/platform.v3.server.ts:258-302
Timestamp: 2025-11-14T16:03:06.917Z
Learning: In `apps/webapp/app/services/platform.v3.server.ts`, the `getDefaultEnvironmentConcurrencyLimit` function intentionally throws an error (rather than falling back to org.maximumConcurrencyLimit) when the billing client returns undefined plan limits. This fail-fast behavior prevents users from receiving more concurrency than their plan entitles them to. The org.maximumConcurrencyLimit fallback is only for self-hosted deployments where no billing client exists.
🧬 Code graph analysis (1)
apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx (5)
apps/webapp/app/services/platform.v3.server.ts (1)
  • setPlan (339-400)
apps/webapp/app/utils/numberFormatter.ts (1)
  • formatCurrency (25-27)
apps/webapp/app/components/DefinitionTooltip.tsx (1)
  • DefinitionTip (5-33)
apps/webapp/app/components/primitives/Paragraph.tsx (1)
  • Paragraph (88-107)
apps/webapp/app/components/environments/EnvironmentLabel.tsx (1)
  • EnvironmentLabel (70-126)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (23)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (8, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (3, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (6, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (7, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (4, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (7, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (5, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (5, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (6, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (2, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (1, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (8, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (4, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (1, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (3, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (2, 8)
  • GitHub Check: units / packages / 🧪 Unit Tests: Packages (1, 1)
  • GitHub Check: e2e / 🧪 CLI v3 tests (windows-latest - pnpm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (windows-latest - npm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (ubuntu-latest - npm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (ubuntu-latest - pnpm)
  • GitHub Check: typecheck / typecheck
  • GitHub Check: Analyze (javascript-typescript)
🔇 Additional comments (7)
apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx (1)

86-86: LGTM! Clean integration of concurrency addon pricing.

The destructuring of addOnPricing from loader data and passing addOnPricing.concurrency to the PricingPlans component correctly wires through the new concurrency addon pricing feature to the billing settings UI.

Also applies to: 145-145

apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx (1)

48-48: LGTM! Consistent prop wiring.

The addition of addOnPricing and passing it to PricingPlans mirrors the implementation in the billing settings route, maintaining consistency across the codebase.

Also applies to: 60-60

apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx (5)

14-14: LGTM! Necessary imports for concurrency addon pricing.

The new imports (AddOnPricing, formatCurrency, EnvironmentLabel) are all properly utilized throughout the file to support the concurrency addon pricing feature.

Also applies to: 49-50


159-161: Minor improvement: Explicit await for better error context.

Adding await before setPlan makes the asynchronous flow more explicit and can provide slightly better stack traces for debugging. While functionally equivalent to returning the Promise directly, this is a minor improvement in code clarity.


232-232: LGTM! Type-safe prop threading.

The addition of concurrencyAddOnPricing to PricingPlansProps and its propagation to TierPro maintains type safety through the component hierarchy. This public API change correctly enables rendering of dynamic concurrency addon pricing in the Pro tier.

Also applies to: 242-242, 265-270


761-764: LGTM! Dynamic pricing string correctly computed.

The dynamic pricing string correctly converts centsPerStep to dollars (/ 100) and uses formatCurrency with rounding enabled for clean display. This replaces the previous static content with data-driven addon pricing information.


982-1017: Excellent UX enhancement with per-environment breakdown!

The enhanced tooltip content provides clear visibility into concurrency limits for each environment (PRODUCTION, STAGING, PREVIEW, DEVELOPMENT). Using EnvironmentLabel for consistent rendering and showing the expandable "+" indicator when canExceed is true gives users a comprehensive understanding of their concurrency allocation.

Tip

📝 Customizable high-level summaries are now available in beta!

You can now customize how CodeRabbit generates the high-level summary in your pull requests — including its content, structure, tone, and formatting.

  • Provide your own instructions using the high_level_summary_instructions setting.
  • Format the summary however you like (bullet lists, tables, multi-section layouts, contributor stats, etc.).
  • Use high_level_summary_in_walkthrough to move the summary from the description to the walkthrough section.

Example instruction:

"Divide the high-level summary into five sections:

  1. 📝 Description — Summarize the main change in 50–60 words, explaining what was done.
  2. 📓 References — List relevant issues, discussions, documentation, or related PRs.
  3. 📦 Dependencies & Requirements — Mention any new/updated dependencies, environment variable changes, or configuration updates.
  4. 📊 Contributor Summary — Include a Markdown table showing contributions:
    | Contributor | Lines Added | Lines Removed | Files Changed |
  5. ✔️ Additional Notes — Add any extra reviewer context.
    Keep each section concise (under 200 words) and use bullet or numbered lists for clarity."

Note: This feature is currently in beta for Pro-tier users, and pricing will be announced later.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (1)
apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts (1)

103-114: Good addition of error handling! Consider differentiating status codes.

The introduction of tryCatch is a positive change that prevents unhandled promise rejections. However, all errors currently return a 400 status code, which may not be semantically appropriate for all error types.

Consider differentiating status codes based on error type for better API semantics:

  const [error, project] = await tryCatch(
    createProject({
      organizationSlug: organization.slug,
      name: parsedBody.data.name,
      userId: authenticationResult.userId,
      version: "v3",
    })
  );

  if (error) {
-   return json({ error: error.message }, { status: 400 });
+   // Determine appropriate status code based on error type
+   let status = 400;
+   if (error.message.includes("permission")) {
+     status = 403;
+   } else if (error.message.includes("maximum number of projects")) {
+     status = 429; // or 402 for payment required
+   } else if (error.message.includes("Unable to create project")) {
+     status = 500;
+   }
+   return json({ error: error.message }, { status });
  }

Alternatively, you could check error types if createProject throws custom error classes (e.g., ExceededProjectLimitError).

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 892bed8 and 54ec3e4.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (30)
  • apps/webapp/app/assets/icons/ConcurrencyIcon.tsx (1 hunks)
  • apps/webapp/app/components/Feedback.tsx (3 hunks)
  • apps/webapp/app/components/navigation/SideMenu.tsx (4 hunks)
  • apps/webapp/app/components/primitives/Input.tsx (1 hunks)
  • apps/webapp/app/components/primitives/InputNumberStepper.tsx (1 hunks)
  • apps/webapp/app/components/primitives/Toast.tsx (5 hunks)
  • apps/webapp/app/models/message.server.ts (3 hunks)
  • apps/webapp/app/models/organization.server.ts (2 hunks)
  • apps/webapp/app/models/project.server.ts (2 hunks)
  • apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts (1 hunks)
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx (1 hunks)
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx (2 hunks)
  • apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx (4 hunks)
  • apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.concurrency.ts (1 hunks)
  • apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts (2 hunks)
  • apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency.ts (0 hunks)
  • apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx (1 hunks)
  • apps/webapp/app/routes/storybook.input-fields/route.tsx (1 hunks)
  • apps/webapp/app/routes/storybook.stepper/route.tsx (1 hunks)
  • apps/webapp/app/routes/storybook/route.tsx (1 hunks)
  • apps/webapp/app/services/platform.v3.server.ts (4 hunks)
  • apps/webapp/app/utils/environmentSort.ts (1 hunks)
  • apps/webapp/app/utils/pathBuilder.ts (1 hunks)
  • apps/webapp/app/v3/services/allocateConcurrency.server.ts (1 hunks)
  • apps/webapp/app/v3/services/createBackgroundWorker.server.ts (1 hunks)
  • apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts (1 hunks)
  • apps/webapp/app/v3/services/triggerTaskV1.server.ts (1 hunks)
  • apps/webapp/package.json (1 hunks)
  • internal-packages/database/prisma/migrations/20251113152235_maximum_project_count/migration.sql (1 hunks)
  • internal-packages/database/prisma/schema.prisma (1 hunks)
💤 Files with no reviewable changes (1)
  • apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency.ts
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-10-08T11:48:12.327Z
Learnt from: nicktrn
Repo: triggerdotdev/trigger.dev PR: 2593
File: packages/core/src/v3/workers/warmStartClient.ts:168-170
Timestamp: 2025-10-08T11:48:12.327Z
Learning: The trigger.dev runners execute only in Node 21 and 22 environments, so modern Node.js APIs like AbortSignal.any (introduced in v20.3.0) are supported.

Applied to files:

  • apps/webapp/package.json
📚 Learning: 2025-09-02T11:37:42.902Z
Learnt from: nicktrn
Repo: triggerdotdev/trigger.dev PR: 2461
File: packages/core/src/v3/runEngineWorker/supervisor/consumerPool.ts:315-317
Timestamp: 2025-09-02T11:37:42.902Z
Learning: In packages/core/src/v3/runEngineWorker/supervisor/scalingStrategies.ts, the ScalingStrategy base class already handles clamping to min/max bounds in the public calculateTargetCount method, and the individual strategy implementations handle rounding internally using Math.round, Math.floor, and Math.ceil as appropriate.

Applied to files:

  • apps/webapp/app/v3/services/createBackgroundWorker.server.ts
🧬 Code graph analysis (17)
apps/webapp/app/routes/storybook.stepper/route.tsx (2)
apps/webapp/app/components/primitives/Headers.tsx (2)
  • Header2 (52-70)
  • Header3 (72-90)
apps/webapp/app/components/primitives/InputNumberStepper.tsx (1)
  • InputNumberStepper (13-220)
apps/webapp/app/components/primitives/InputNumberStepper.tsx (1)
apps/webapp/app/utils/cn.ts (1)
  • cn (77-79)
apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts (4)
apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts (1)
  • ManageConcurrencyPresenter (33-132)
apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx (1)
  • action (133-211)
apps/webapp/app/services/platform.v3.server.ts (1)
  • setConcurrencyAddOn (402-416)
apps/webapp/app/utils/plain.server.ts (1)
  • sendToPlain (12-59)
apps/webapp/app/components/primitives/Toast.tsx (4)
apps/webapp/app/models/message.server.ts (1)
  • ToastMessageAction (13-25)
apps/webapp/app/components/primitives/Headers.tsx (1)
  • Header2 (52-70)
apps/webapp/app/components/primitives/Paragraph.tsx (1)
  • Paragraph (88-107)
apps/webapp/app/components/primitives/Buttons.tsx (2)
  • LinkButton (335-401)
  • Button (296-329)
apps/webapp/app/models/project.server.ts (1)
apps/webapp/app/db.server.ts (1)
  • prisma (101-101)
apps/webapp/app/components/Feedback.tsx (2)
apps/webapp/app/hooks/useSearchParam.ts (1)
  • useSearchParams (7-64)
apps/webapp/app/routes/resources.feedback.ts (1)
  • FeedbackType (21-21)
apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts (2)
apps/webapp/app/services/platform.v3.server.ts (3)
  • getCurrentPlan (193-228)
  • getDefaultEnvironmentLimitFromPlan (284-302)
  • getPlans (323-337)
apps/webapp/app/utils/environmentSort.ts (1)
  • sortEnvironments (15-35)
apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx (10)
apps/webapp/app/services/session.server.ts (1)
  • requireUserId (25-35)
apps/webapp/app/utils/pathBuilder.ts (3)
  • EnvironmentParamSchema (26-28)
  • concurrencyPath (466-472)
  • v3BillingPath (482-486)
apps/webapp/app/models/project.server.ts (1)
  • findProjectBySlug (136-147)
apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts (3)
  • ManageConcurrencyPresenter (33-132)
  • ConcurrencyResult (10-21)
  • EnvironmentWithConcurrency (23-31)
apps/webapp/app/services/platform.v3.server.ts (1)
  • getPlans (323-337)
apps/webapp/app/models/message.server.ts (2)
  • redirectWithErrorMessage (201-218)
  • redirectWithSuccessMessage (182-199)
apps/webapp/app/v3/services/allocateConcurrency.server.ts (1)
  • AllocateConcurrencyService (22-91)
apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts (1)
  • SetConcurrencyAddOnService (26-143)
apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx (1)
  • useCurrentPlan (22-29)
apps/webapp/app/hooks/useOrganizations.ts (1)
  • useOrganization (39-43)
apps/webapp/app/models/organization.server.ts (1)
apps/webapp/app/services/platform.v3.server.ts (1)
  • getDefaultEnvironmentConcurrencyLimit (258-282)
apps/webapp/app/v3/services/allocateConcurrency.server.ts (1)
apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts (1)
  • ManageConcurrencyPresenter (33-132)
apps/webapp/app/models/message.server.ts (2)
apps/webapp/app/components/primitives/Buttons.tsx (1)
  • ButtonVariant (166-166)
apps/webapp/app/routes/resources.feedback.ts (1)
  • FeedbackType (21-21)
apps/webapp/app/components/navigation/SideMenu.tsx (4)
apps/webapp/app/hooks/useFeatures.ts (1)
  • useFeatures (5-9)
apps/webapp/app/components/navigation/SideMenuItem.tsx (1)
  • SideMenuItem (7-53)
apps/webapp/app/assets/icons/ConcurrencyIcon.tsx (1)
  • ConcurrencyIcon (1-13)
apps/webapp/app/utils/pathBuilder.ts (1)
  • concurrencyPath (466-472)
apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts (1)
apps/webapp/app/models/project.server.ts (1)
  • createProject (26-134)
apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx (1)
apps/webapp/app/services/platform.v3.server.ts (1)
  • setPlan (339-400)
apps/webapp/app/services/platform.v3.server.ts (4)
apps/webapp/app/database-types.ts (1)
  • RuntimeEnvironmentType (49-54)
apps/webapp/app/db.server.ts (1)
  • $replica (103-106)
apps/webapp/app/models/message.server.ts (2)
  • redirectWithErrorMessage (201-218)
  • redirectWithSuccessMessage (182-199)
apps/webapp/app/utils/pathBuilder.ts (1)
  • newProjectPath (129-133)
apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx (3)
apps/webapp/app/components/primitives/Buttons.tsx (1)
  • LinkButton (335-401)
apps/webapp/app/utils/pathBuilder.ts (1)
  • concurrencyPath (466-472)
apps/webapp/app/assets/icons/ConcurrencyIcon.tsx (1)
  • ConcurrencyIcon (1-13)
apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx (4)
apps/webapp/app/models/project.server.ts (1)
  • ExceededProjectLimitError (19-24)
apps/webapp/app/models/message.server.ts (1)
  • redirectWithErrorMessage (201-218)
apps/webapp/app/utils/pathBuilder.ts (1)
  • newProjectPath (129-133)
apps/webapp/app/components/Feedback.tsx (1)
  • Feedback (29-177)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (23)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (6, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (7, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (8, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (1, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (5, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (4, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (2, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (8, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (7, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (3, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (1, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (6, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (2, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (4, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (3, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (5, 8)
  • GitHub Check: units / packages / 🧪 Unit Tests: Packages (1, 1)
  • GitHub Check: e2e / 🧪 CLI v3 tests (windows-latest - npm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (ubuntu-latest - npm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (ubuntu-latest - pnpm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (windows-latest - pnpm)
  • GitHub Check: typecheck / typecheck
  • GitHub Check: Analyze (javascript-typescript)
🔇 Additional comments (15)
apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts (1)

7-7: LGTM! Clean addition of error handling utility.

The import of tryCatch enables graceful error handling for the createProject call below.

apps/webapp/app/components/Feedback.tsx (2)

70-73: LGTM!

The handleOpenChange handler correctly delegates to both the local state update and the optional callback using safe optional chaining.


26-26: LGTM!

The onOpenChange prop addition is well-implemented:

  • Properly typed as an optional callback
  • Cleanly integrated without breaking existing usage
  • Enables parent components to respond to dialog state changes

Also applies to: 29-29, 76-76

apps/webapp/package.json (1)

117-117: Verify timeline and rationale for beta dependency with team.

The @trigger.dev/platform version 1.0.20-beta.2 is not found in public release information; the latest publicly available stable version is 1.0.15. While the beta is locked in your pnpm-lock.yaml (indicating intentional selection), there is no documentation in the codebase explaining why a pre-release was chosen over the stable version or when this will be upgraded to a stable release.

Production dependencies on pre-release versions introduce uncertainty around stability and breaking changes. Please verify with your team:

  • The rationale for selecting this beta version
  • Expected timeline for upgrading to a stable 1.0.20 release
  • Any known limitations or stability considerations with 1.0.20-beta.2
apps/webapp/app/routes/storybook.stepper/route.tsx (1)

21-66: Demo masked rounding regression

Because the stepper’s handleStep* isn’t emitting changes, these storybook examples don’t update their state when you click the buttons. Fixing the component resolves the demo, but make sure to rerun and confirm after the stepper fix lands.

internal-packages/database/prisma/schema.prisma (1)

207-207: Schema default must match migration

maximumProjectCount Int @default(10) matches the migration’s default, so no issue.

internal-packages/database/prisma/migrations/20251113152235_maximum_project_count/migration.sql (1)

1-3: LGTM! Clean migration adding project limit.

The migration adds a sensible organization-level project limit with a default of 10. The NOT NULL constraint with DEFAULT ensures existing organizations are handled correctly.

apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.concurrency.ts (1)

77-77: PREVIEW environments now share staging limit allocation.

This change treats PREVIEW environments the same as STAGING for concurrency limit allocation. Verify this is the intended behavior for your concurrency model.

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx (1)

416-423: LGTM! Clean navigation to dedicated concurrency management.

The replacement of the Feedback component with a LinkButton to the new concurrency page is a good UX improvement. The amber styling and ConcurrencyIcon clearly communicate the purpose.

apps/webapp/app/models/organization.server.ts (1)

99-108: Excellent refactoring to centralized limit calculation.

Replacing the hardcoded organization.maximumConcurrencyLimit / 3 with getDefaultEnvironmentConcurrencyLimit provides better flexibility. This function handles both plan-based limits (managed cloud) and organization-based limits (self-hosted), making the environment creation logic more maintainable.

apps/webapp/app/v3/services/createBackgroundWorker.server.ts (1)

362-365: Correct alignment with new concurrency allocation model.

Removing organization.maximumConcurrencyLimit from the clamping operation is appropriate. Since environment limits are now derived from organization limits (via getDefaultEnvironmentConcurrencyLimit during creation), the environment's maximumConcurrencyLimit is the authoritative cap. This change aligns with the allocation-based concurrency model introduced in this PR.

apps/webapp/app/utils/pathBuilder.ts (1)

466-472: LGTM! Clean path builder following established patterns.

The new concurrencyPath function is consistent with other path builders in the file and properly constructs the concurrency management route.

apps/webapp/app/components/navigation/SideMenu.tsx (1)

319-327: LGTM! Properly gated feature integration.

The Concurrency menu item is correctly gated behind the isManagedCloud feature flag and follows the established pattern for SideMenu items. The placement in the Manage section is appropriate.

apps/webapp/app/utils/environmentSort.ts (1)

15-22: Excellent flexibility enhancement with backward compatibility.

Adding the optional sortOrder parameter makes the function more reusable while preserving the default behavior. The fallback to environmentSortOrder ensures no breaking changes for existing callers.

apps/webapp/app/components/primitives/Toast.tsx (1)

25-39: ephemeral now defaults to permanent toasts.
Across the codebase options.ephemeral is optional. With this ternary, undefined becomes falsy, so any toast without an explicit flag (the common case) now sticks around for the full 24‑hour “permanent” duration. Can you double-check that this regression is intentional? If not, we should keep the old behaviour by defaulting to true when no action is present.

- const ephemeral = options.action ? false : options.ephemeral;
+ const ephemeral = options.action ? false : options.ephemeral ?? true;

@matt-aitken matt-aitken force-pushed the concurrency-self-serve branch from d948d24 to 36f6e48 Compare November 13, 2025 19:03
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/webapp/app/models/message.server.ts (1)

57-70: Keep toasts with actions onscreen.

The new action adds a button the user needs to click, but we still default ephemeral to true, so the toast disappears before they can interact (e.g., “Request more projects” in the new-project flow). Please default ephemeral to false whenever an action is present, unless the caller overrides it explicitly.

 export function setSuccessMessage(
   session: Session,
   message: string,
   options?: ToastMessageOptions
 ) {
+  const hasAction = Boolean(options?.action);
   session.flash("toastMessage", {
     message,
     type: "success",
     options: {
       ...options,
-      ephemeral: options?.ephemeral ?? true,
+      ephemeral: options?.ephemeral ?? (hasAction ? false : true),
     },
   } as ToastMessage);
 }
 
 export function setErrorMessage(session: Session, message: string, options?: ToastMessageOptions) {
+  const hasAction = Boolean(options?.action);
   session.flash("toastMessage", {
     message,
     type: "error",
     options: {
       ...options,
-      ephemeral: options?.ephemeral ?? true,
+      ephemeral: options?.ephemeral ?? (hasAction ? false : true),
     },
   } as ToastMessage);
 }

Without this, the new CTA can’t realistically be used.

♻️ Duplicate comments (8)
apps/webapp/app/models/project.server.ts (1)

57-68: Project cap check still races under load.

The count and the insert happen outside a transaction, so two concurrent createProject calls can both observe projectCount < maximumProjectCount and create rows, leaving the org over its limit. Please wrap the check+create in a serializable transaction (or use SELECT … FOR UPDATE) so only one request can pass the guard at a time.

-  const projectCount = await prisma.project.count({
-    where: {
-      organizationId: organization.id,
-      deletedAt: null,
-    },
-  });
-
-  if (projectCount >= organization.maximumProjectCount) {
-    throw new ExceededProjectLimitError(
-      `This organization has reached the maximum number of projects (${organization.maximumProjectCount}).`
-    );
-  }
-
-  const project = await prisma.project.create({
+  const project = await prisma.$transaction(
+    async (tx) => {
+      const projectCount = await tx.project.count({
+        where: {
+          organizationId: organization.id,
+          deletedAt: null,
+        },
+      });
+
+      if (projectCount >= organization.maximumProjectCount) {
+        throw new ExceededProjectLimitError(
+          `This organization has reached the maximum number of projects (${organization.maximumProjectCount}).`
+        );
+      }
+
+      return tx.project.create({
         data: {
           name,
           slug: uniqueProjectSlug,
@@
-    include: {
-      organization: {
-        include: {
-          members: true,
-        },
-      },
-    },
-  });
+        include: {
+          organization: {
+            include: {
+              members: true,
+            },
+          },
+        },
+      });
+    },
+    { isolationLevel: "Serializable" }
+  );

This ensures the limit can’t be exceeded even under bursty traffic.

apps/webapp/app/components/Feedback.tsx (1)

57-68: Validate the feedbackPanel param and avoid shadowing state.

The const open = … shadow still collides with the open state, and we’re coercing whatever string is in the query param into FeedbackType without validation. A crafted URL (e.g. ?feedbackPanel=foo) will push an invalid value into state, leaving the select without a matching option. Please keep the state name distinct and ensure the param is one of the known keys before using it; you can also lean on our useSearchParams helper to handle deletion without manual cloning, as mentioned previously.

-  useEffect(() => {
-    const open = searchParams.get("feedbackPanel");
-    if (open) {
-      setType(open as FeedbackType);
-      setOpen(true);
-      // Clone instead of mutating in place
-      const next = new URLSearchParams(searchParams);
-      next.delete("feedbackPanel");
-      setSearchParams(next);
-    }
-  }, [searchParams]);
+  useEffect(() => {
+    const feedbackPanelParam = searchParams.get("feedbackPanel");
+    if (feedbackPanelParam && feedbackPanelParam in feedbackTypeLabel) {
+      setType(feedbackPanelParam as FeedbackType);
+      setOpen(true);
+      const next = new URLSearchParams(searchParams);
+      next.delete("feedbackPanel");
+      setSearchParams(next);
+    }
+  }, [searchParams, setSearchParams]);

This keeps state consistent and prevents invalid types from slipping through.

apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx (1)

217-218: Provide a real trigger element for Feedback.

Passing an empty fragment into DialogTrigger (with asChild) still triggers Radix’s runtime error (“asChild expects a single child that can accept a ref”), so the page breaks when the component mounts. Please render an actual focusable element—even a visually hidden <button> works—so the dialog opens safely.

-          <Feedback button={<></>} />
+          <Feedback
+            button={<button type="button" className="sr-only" aria-hidden />}
+          />

This already came up in the previous review; we still need to address it so the page doesn’t crash.

apps/webapp/app/services/platform.v3.server.ts (2)

275-281: Add plan fallback instead of throwing when per-environment limits are absent.

This still throws “No plan found” whenever the billing plan omits concurrentRuns for a given environment (e.g. free tiers without staging). We previously flagged this; please fall back to the organization’s maximumConcurrencyLimit (same as the no-client branch) so environment creation doesn’t crash. For example:

-  const limit = getDefaultEnvironmentLimitFromPlan(environmentType, result);
-  if (!limit) throw new Error("No plan found");
+  const limit =
+    getDefaultEnvironmentLimitFromPlan(environmentType, result) ??
+    result.v3Subscription?.plan?.limits.concurrentRuns.number ??
+    org.maximumConcurrencyLimit;

Make sure org.maximumConcurrencyLimit is retrieved once so you can reuse it in both branches.


402-415: Surface concurrency add-on failures to callers.

Returning undefined covers “no client” and real platform errors, so callers can’t distinguish success from failure—exactly the issue we already discussed. Please throw or return a discriminated result that includes the platform error so the UI can react appropriately.

-export async function setConcurrencyAddOn(organizationId: string, amount: number) {
-  if (!client) return undefined;
+export async function setConcurrencyAddOn(
+  organizationId: string,
+  amount: number
+): Promise<{ success: true } | { success: false; reason: "no_client" | "platform_error"; error?: string }> {
+  if (!client) {
+    return { success: false, reason: "no_client" };
+  }
@@
-    if (!result.success) {
-      logger.error("Error setting concurrency add on - no success", { error: result.error });
-      return undefined;
-    }
-    return result;
+    if (!result.success) {
+      logger.error("Error setting concurrency add on - no success", { error: result.error });
+      return { success: false, reason: "platform_error", error: result.error };
+    }
+    return { success: true };
   } catch (e) {
     logger.error("Error setting concurrency add on - caught error", { error: e });
-    return undefined;
+    return {
+      success: false,
+      reason: "platform_error",
+      error: e instanceof Error ? e.message : String(e),
+    };
   }
 }

Update callers accordingly so they can handle the failure cases.

apps/webapp/app/v3/services/allocateConcurrency.server.ts (1)

41-86: Recompute total extra concurrency from all environments, not just the payload.

newExtra only sums the environments included in the request, so omitting an environment with existing extra capacity makes change negative and bypasses the guard while its previous allocation persists. This is the same bypass we warned about earlier. Derive the post-update total from the authoritative list, merging submitted overrides with existing data:

-    const newExtra = environments.reduce((acc, e) => e.amount + acc, 0);
-    const change = newExtra - previousExtra;
+    const requested = new Map(environments.map((e) => [e.id, Math.max(0, e.amount)]));
+    const newExtra = result.environments.reduce((acc, env) => {
+      const targetExtra = requested.has(env.id)
+        ? requested.get(env.id)!
+        : Math.max(0, env.maximumConcurrencyLimit - env.planConcurrencyLimit);
+      return acc + targetExtra;
+    }, 0);
+    const change = newExtra - previousExtra;

This keeps the guard honest even when the payload is partial.

apps/webapp/app/components/primitives/InputNumberStepper.tsx (2)

31-63: Stepper clicks never reach consumers
Calling dispatchEvent(new Event("change")) is swallowed by React’s synthetic event system, so parents never see updates when the +/- buttons are used. This breaks every controlled usage of the component. Emit an "input" event and mark it as simulated (or call onChange directly) so React processes it.

-    const event = new Event("change", { bubbles: true });
+    const event = new Event("input", { bubbles: true });
+    // @ts-expect-error React inspects this flag to avoid dedupe
+    event.simulated = true;
     inputRef.current.dispatchEvent(event);

(github.com)


138-181: Preserve rounding logic when parents add handlers
Spreading ...props after defining onBlur / onKeyDown overwrites your rounding handlers anytime a consumer passes its own callbacks. Extract those handlers first, then invoke them inside your logic. Otherwise rounding is silently disabled in common usage.

-  ...props
-  onBlur={(e) => {
-    if (round) commitRoundedFromInput();
-  }}
-  onKeyDown={(e) => {
+  const { onBlur: propsOnBlur, onKeyDown: propsOnKeyDown, ...rest } = props;
+  ...
+  onBlur={(e) => {
+    if (round) commitRoundedFromInput();
+    propsOnBlur?.(e);
+  }}
+  onKeyDown={(e) => {
     if (e.key === "Enter" && round) {
       e.preventDefault();
       commitRoundedFromInput();
     }
+    propsOnKeyDown?.(e);
   }}
+  {...rest}
🧹 Nitpick comments (1)
apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts (1)

48-83: Remove unnecessary optional chaining.

After verifying updatedConcurrency is not null/undefined at Line 51, the optional chaining at Line 58 (updatedConcurrency?.result) is unnecessary and creates inconsistent code.

Apply this diff:

       const updatedConcurrency = await setConcurrencyAddOn(organizationId, totalExtraConcurrency);
       if (!updatedConcurrency) {
         return {
           success: false,
           error: "Failed to update concurrency",
         };
       }

-      switch (updatedConcurrency?.result) {
+      switch (updatedConcurrency.result) {
         case "success": {
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 54ec3e4 and 36f6e48.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (30)
  • apps/webapp/app/assets/icons/ConcurrencyIcon.tsx (1 hunks)
  • apps/webapp/app/components/Feedback.tsx (3 hunks)
  • apps/webapp/app/components/navigation/SideMenu.tsx (4 hunks)
  • apps/webapp/app/components/primitives/Input.tsx (1 hunks)
  • apps/webapp/app/components/primitives/InputNumberStepper.tsx (1 hunks)
  • apps/webapp/app/components/primitives/Toast.tsx (5 hunks)
  • apps/webapp/app/models/message.server.ts (3 hunks)
  • apps/webapp/app/models/organization.server.ts (2 hunks)
  • apps/webapp/app/models/project.server.ts (2 hunks)
  • apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts (1 hunks)
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx (1 hunks)
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx (2 hunks)
  • apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx (4 hunks)
  • apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.concurrency.ts (1 hunks)
  • apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts (2 hunks)
  • apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency.ts (0 hunks)
  • apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx (1 hunks)
  • apps/webapp/app/routes/storybook.input-fields/route.tsx (1 hunks)
  • apps/webapp/app/routes/storybook.stepper/route.tsx (1 hunks)
  • apps/webapp/app/routes/storybook/route.tsx (1 hunks)
  • apps/webapp/app/services/platform.v3.server.ts (4 hunks)
  • apps/webapp/app/utils/environmentSort.ts (1 hunks)
  • apps/webapp/app/utils/pathBuilder.ts (1 hunks)
  • apps/webapp/app/v3/services/allocateConcurrency.server.ts (1 hunks)
  • apps/webapp/app/v3/services/createBackgroundWorker.server.ts (1 hunks)
  • apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts (1 hunks)
  • apps/webapp/app/v3/services/triggerTaskV1.server.ts (1 hunks)
  • apps/webapp/package.json (1 hunks)
  • internal-packages/database/prisma/migrations/20251113152235_maximum_project_count/migration.sql (1 hunks)
  • internal-packages/database/prisma/schema.prisma (1 hunks)
💤 Files with no reviewable changes (1)
  • apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency.ts
🚧 Files skipped from review as they are similar to previous changes (14)
  • apps/webapp/app/routes/storybook/route.tsx
  • apps/webapp/app/components/primitives/Input.tsx
  • internal-packages/database/prisma/schema.prisma
  • apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.concurrency.ts
  • apps/webapp/app/assets/icons/ConcurrencyIcon.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx
  • apps/webapp/package.json
  • apps/webapp/app/routes/storybook.input-fields/route.tsx
  • apps/webapp/app/utils/pathBuilder.ts
  • internal-packages/database/prisma/migrations/20251113152235_maximum_project_count/migration.sql
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx
  • apps/webapp/app/v3/services/triggerTaskV1.server.ts
  • apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx
  • apps/webapp/app/components/navigation/SideMenu.tsx
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-09-02T11:37:42.902Z
Learnt from: nicktrn
Repo: triggerdotdev/trigger.dev PR: 2461
File: packages/core/src/v3/runEngineWorker/supervisor/consumerPool.ts:315-317
Timestamp: 2025-09-02T11:37:42.902Z
Learning: In packages/core/src/v3/runEngineWorker/supervisor/scalingStrategies.ts, the ScalingStrategy base class already handles clamping to min/max bounds in the public calculateTargetCount method, and the individual strategy implementations handle rounding internally using Math.round, Math.floor, and Math.ceil as appropriate.

Applied to files:

  • apps/webapp/app/v3/services/createBackgroundWorker.server.ts
🧬 Code graph analysis (13)
apps/webapp/app/models/project.server.ts (1)
apps/webapp/app/db.server.ts (1)
  • prisma (101-101)
apps/webapp/app/models/message.server.ts (2)
apps/webapp/app/components/primitives/Buttons.tsx (1)
  • ButtonVariant (166-166)
apps/webapp/app/routes/resources.feedback.ts (1)
  • FeedbackType (21-21)
apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts (1)
apps/webapp/app/models/project.server.ts (1)
  • createProject (26-134)
apps/webapp/app/v3/services/allocateConcurrency.server.ts (1)
apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts (1)
  • ManageConcurrencyPresenter (33-132)
apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts (2)
apps/webapp/app/services/platform.v3.server.ts (3)
  • getCurrentPlan (193-228)
  • getDefaultEnvironmentLimitFromPlan (284-302)
  • getPlans (323-337)
apps/webapp/app/utils/environmentSort.ts (1)
  • sortEnvironments (15-35)
apps/webapp/app/services/platform.v3.server.ts (4)
apps/webapp/app/database-types.ts (1)
  • RuntimeEnvironmentType (49-54)
apps/webapp/app/db.server.ts (1)
  • $replica (103-106)
apps/webapp/app/models/message.server.ts (2)
  • redirectWithErrorMessage (201-218)
  • redirectWithSuccessMessage (182-199)
apps/webapp/app/utils/pathBuilder.ts (1)
  • newProjectPath (129-133)
apps/webapp/app/models/organization.server.ts (1)
apps/webapp/app/services/platform.v3.server.ts (1)
  • getDefaultEnvironmentConcurrencyLimit (258-282)
apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx (4)
apps/webapp/app/models/project.server.ts (1)
  • ExceededProjectLimitError (19-24)
apps/webapp/app/models/message.server.ts (1)
  • redirectWithErrorMessage (201-218)
apps/webapp/app/utils/pathBuilder.ts (1)
  • newProjectPath (129-133)
apps/webapp/app/components/Feedback.tsx (1)
  • Feedback (29-177)
apps/webapp/app/components/Feedback.tsx (2)
apps/webapp/app/hooks/useSearchParam.ts (1)
  • useSearchParams (7-64)
apps/webapp/app/routes/resources.feedback.ts (1)
  • FeedbackType (21-21)
apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts (4)
apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts (1)
  • ManageConcurrencyPresenter (33-132)
apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx (1)
  • action (133-211)
apps/webapp/app/services/platform.v3.server.ts (1)
  • setConcurrencyAddOn (402-416)
apps/webapp/app/utils/plain.server.ts (1)
  • sendToPlain (12-59)
apps/webapp/app/components/primitives/InputNumberStepper.tsx (1)
apps/webapp/app/utils/cn.ts (1)
  • cn (77-79)
apps/webapp/app/components/primitives/Toast.tsx (4)
apps/webapp/app/models/message.server.ts (1)
  • ToastMessageAction (13-25)
apps/webapp/app/components/primitives/Headers.tsx (1)
  • Header2 (52-70)
apps/webapp/app/components/primitives/Paragraph.tsx (1)
  • Paragraph (88-107)
apps/webapp/app/components/primitives/Buttons.tsx (2)
  • LinkButton (335-401)
  • Button (296-329)
apps/webapp/app/routes/storybook.stepper/route.tsx (2)
apps/webapp/app/components/primitives/Headers.tsx (2)
  • Header2 (52-70)
  • Header3 (72-90)
apps/webapp/app/components/primitives/InputNumberStepper.tsx (1)
  • InputNumberStepper (13-220)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (17)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (6, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (5, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (2, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (7, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (3, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (1, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (4, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (5, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (2, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (1, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (6, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (3, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (4, 8)
  • GitHub Check: units / packages / 🧪 Unit Tests: Packages (1, 1)
  • GitHub Check: e2e / 🧪 CLI v3 tests (windows-latest - npm)
  • GitHub Check: typecheck / typecheck
  • GitHub Check: e2e / 🧪 CLI v3 tests (windows-latest - pnpm)
🔇 Additional comments (5)
apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts (2)

1-43: LGTM! Clean error handling for presenter call.

The error handling for the presenter call is appropriate, and the use of tryCatch ensures any thrown errors are caught and converted to a result type.


103-136: Verify email failure handling.

Based on the sendToPlain implementation (apps/webapp/app/utils/plain.server.ts lines 11-58), it logs errors to console.error and returns undefined rather than throwing exceptions. The tryCatch wrapper will only catch thrown errors, not cases where sendToPlain completes but fails to send the email (e.g., when PLAIN_API_KEY is missing or API calls fail).

This means the service could return { success: true } even when the email was never sent.

Consider one of these approaches:

  1. Modify sendToPlain to throw errors instead of logging and returning undefined
  2. Have sendToPlain return a result object and check it here
  3. Accept that email failures are non-blocking and log them separately

Which behavior is intended for quota-increase requests when email delivery fails?

apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts (3)

1-50: LGTM! Solid plan retrieval and access control setup.

The plan retrieval with appropriate error handling and the canAddConcurrency check based on plan limits are well-structured.


74-108: LGTM! Environment filtering logic is sound.

The logic correctly:

  • Skips parent (branchable) environments
  • Only counts non-DEVELOPMENT environment overages toward allocated concurrency (as documented)
  • Filters DEVELOPMENT environments by user ownership
  • Safely handles null orgMember with optional chaining

The if (!limit) check at Line 84 appropriately skips environments with no concurrency limit, which is consistent with the concurrency management use case.


110-131: LGTM! Correct concurrency calculations and result assembly.

The calculations properly:

  • Cap extraAllocated at extraConcurrency to prevent over-allocation
  • Compute extraUnallocated as the remaining purchased concurrency
  • Extract quota and pricing information from the plan
  • Sort environments in a logical display order (PRODUCTION → STAGING → PREVIEW → DEVELOPMENT)

@matt-aitken matt-aitken force-pushed the concurrency-self-serve branch from 36f6e48 to 8d2dbbc Compare November 14, 2025 12:00
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/webapp/app/routes/storybook.input-fields/route.tsx (1)

2-2: Remove unused import.

The EnvironmentLabel import is no longer used after the removal of the second Input block mentioned in the summary.

Apply this diff to remove the unused import:

 import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
-import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
 import { Input } from "~/components/primitives/Input";
♻️ Duplicate comments (6)
apps/webapp/app/components/primitives/Toast.tsx (1)

25-40: Restore safe default for ephemeral when no explicit value is provided

If options.ephemeral is ever omitted (e.g., a toast not created via the helpers, or any future call site that forgets to set it), this line treats it as falsy and makes the toast effectively “permanent” for 24 h. That’s a surprising UX for plain toasts without actions.

You can make this more robust (and match the earlier feedback) by falling back to true when options.ephemeral is undefined:

-    const ephemeral = options.action ? false : options.ephemeral;
+    const ephemeral = options.action ? false : options.ephemeral ?? true;

This keeps action toasts non‑ephemeral while ensuring vanilla toasts still dismiss after the default duration.

apps/webapp/app/models/project.server.ts (1)

57-68: Race condition allows exceeding project limit.

Because the count check runs outside a transaction, concurrent createProject calls can both read a count below maximumProjectCount and proceed to create projects, resulting in more projects than allowed. Wrap the count+create sequence in a Prisma transaction with SERIALIZABLE isolation or use SELECT ... FOR UPDATE to ensure only one request can pass the guard at a time.

Apply this diff to fix the race condition:

+ const project = await prisma.$transaction(
+   async (tx) => {
-     const projectCount = await prisma.project.count({
+     const projectCount = await tx.project.count({
        where: {
          organizationId: organization.id,
          deletedAt: null,
        },
      });

      if (projectCount >= organization.maximumProjectCount) {
        throw new ExceededProjectLimitError(
          `This organization has reached the maximum number of projects (${organization.maximumProjectCount}).`
        );
      }

-     //ensure the slug is globally unique
-     const uniqueProjectSlug = `${slug(name)}-${nanoid(4)}`;
-     const projectWithSameSlug = await prisma.project.findFirst({
-       where: { slug: uniqueProjectSlug },
-     });
-
-     if (attemptCount > 100) {
-       throw new Error(`Unable to create project with slug ${uniqueProjectSlug} after 100 attempts`);
-     }
-
-     if (projectWithSameSlug) {
-       return createProject(
-         {
-           organizationSlug,
-           name,
-           userId,
-           version,
-         },
-         attemptCount + 1
-       );
-     }
-
-     const project = await prisma.project.create({
+     //ensure the slug is globally unique
+     const uniqueProjectSlug = `${slug(name)}-${nanoid(4)}`;
+     const projectWithSameSlug = await tx.project.findFirst({
+       where: { slug: uniqueProjectSlug },
+     });
+
+     if (attemptCount > 100) {
+       throw new Error(`Unable to create project with slug ${uniqueProjectSlug} after 100 attempts`);
+     }
+
+     if (projectWithSameSlug) {
+       throw new Error("Project slug collision detected");
+     }
+
+     return tx.project.create({
        data: {
          name,
          slug: uniqueProjectSlug,
          organization: {
            connect: {
              slug: organizationSlug,
            },
          },
          externalRef: `proj_${externalRefGenerator()}`,
          version: version === "v3" ? "V3" : "V2",
        },
        include: {
          organization: {
            include: {
              members: true,
            },
          },
        },
      });
+   },
+   { isolationLevel: "Serializable" }
+ );
+
+ if (!project) {
+   return createProject({ organizationSlug, name, userId, version }, attemptCount + 1);
+ }
apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx (1)

217-217: Empty fragment breaks Radix DialogTrigger.

The Feedback component wraps its button prop in a Radix DialogTrigger with asChild, which requires a single ref-able focusable child. Passing an empty fragment will throw a runtime error ("asChild expects a single child that can accept a ref") and break the page.

Apply this diff to provide a valid trigger element:

-          <Feedback button={<></>} />
+          <Feedback
+            button={<button type="button" className="sr-only" aria-label="Open feedback dialog" />}
+          />

Alternatively, if no visible trigger is desired, modify the Feedback component to conditionally render the DialogTrigger only when a valid button prop is provided.

apps/webapp/app/v3/services/allocateConcurrency.server.ts (1)

41-48: Fix extra concurrency totals so partial payloads can’t bypass the cap.

previousExtra sums extras for all environments, but newExtra only sums the submitted environments. If a client omits an environment that already has extra concurrency, its extra is preserved but excluded from newExtra, so change underestimates the real increase and can let the org exceed its purchased concurrency. Recompute newExtra from the authoritative list, overlaying the submitted amounts and clamping negatives:

-    const previousExtra = result.environments.reduce(
-      (acc, e) => Math.max(0, e.maximumConcurrencyLimit - e.planConcurrencyLimit) + acc,
-      0
-    );
-    const newExtra = environments.reduce((acc, e) => e.amount + acc, 0);
-    const change = newExtra - previousExtra;
+    const previousExtra = result.environments.reduce(
+      (acc, e) => Math.max(0, e.maximumConcurrencyLimit - e.planConcurrencyLimit) + acc,
+      0
+    );
+
+    const requested = new Map(environments.map((e) => [e.id, e.amount]));
+    const newExtra = result.environments.reduce((acc, env) => {
+      const targetExtra = requested.has(env.id)
+        ? Math.max(0, requested.get(env.id)!)
+        : Math.max(0, env.maximumConcurrencyLimit - env.planConcurrencyLimit);
+      return acc + targetExtra;
+    }, 0);
+
+    const change = newExtra - previousExtra;

This keeps the guard honest even if the client sends a partial payload.

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx (1)

116-131: Allow purchase actions to set total extra concurrency to 0.

The schema currently requires amount ≥ 1 for both purchase and quota-increase. When the user removes all purchased concurrency (modal sets total to 0), the submission fails validation and the action never runs, blocking the “remove all extra concurrency” flow. Split the union members so purchase accepts 0 while quota-increase still requires a positive amount:

-const FormSchema = z.discriminatedUnion("action", [
-  z.object({
-    action: z.enum(["purchase", "quota-increase"]),
-    amount: z.coerce.number().min(1, "Amount must be greater than 0"),
-  }),
-  z.object({
-    action: z.enum(["allocate"]),
+const FormSchema = z.discriminatedUnion("action", [
+  z.object({
+    action: z.literal("purchase"),
+    amount: z.coerce.number().min(0, "Amount must be 0 or more"),
+  }),
+  z.object({
+    action: z.literal("quota-increase"),
+    amount: z.coerce.number().min(1, "Amount must be greater than 0"),
+  }),
+  z.object({
+    action: z.literal("allocate"),
     // It will only update environments that are passed in
     environments: z.array(
       z.object({
         id: z.string(),
         amount: z.coerce.number().min(0, "Amount must be 0 or more"),
       })
     ),
   }),
 ]);

This matches the UI’s min={0} and supports full removal of purchased concurrency.

apps/webapp/app/services/platform.v3.server.ts (1)

258-282: Fall back to org maximumConcurrencyLimit when plan limits are missing, not throw.

On cloud, getDefaultEnvironmentConcurrencyLimit calls getDefaultEnvironmentLimitFromPlan and throws "No plan found" when it returns undefined (or other falsy), and also throws on !result.success. For downgraded/free plans that don’t define per-env concurrentRuns limits, this will crash environment creation instead of behaving like the no‑client path, which already falls back to organization.maximumConcurrencyLimit. Reuse that fallback in the error/missing cases:

export async function getDefaultEnvironmentConcurrencyLimit(
  organizationId: string,
  environmentType: RuntimeEnvironmentType
): Promise<number> {
-  if (!client) {
-    const org = await $replica.organization.findFirst({
-      where: {
-        id: organizationId,
-      },
-      select: {
-        maximumConcurrencyLimit: true,
-      },
-    });
-    if (!org) throw new Error("Organization not found");
-    return org.maximumConcurrencyLimit;
-  }
-
-  const result = await client.currentPlan(organizationId);
-  if (!result.success) throw new Error("Error getting current plan");
-
-  const limit = getDefaultEnvironmentLimitFromPlan(environmentType, result);
-  if (!limit) throw new Error("No plan found");
-
-  return limit;
+  const getOrgMax = async () => {
+    const org = await $replica.organization.findFirst({
+      where: { id: organizationId },
+      select: { maximumConcurrencyLimit: true },
+    });
+    if (!org) throw new Error("Organization not found");
+    return org.maximumConcurrencyLimit;
+  };
+
+  if (!client) {
+    return getOrgMax();
+  }
+
+  const result = await client.currentPlan(organizationId);
+  if (!result.success) {
+    logger.error("Error getting current plan - no success", {
+      organizationId,
+      error: result.error,
+    });
+    return getOrgMax();
+  }
+
+  const limit = getDefaultEnvironmentLimitFromPlan(environmentType, result);
+  if (!limit) {
+    return getOrgMax();
+  }
+
+  return limit;
}

This keeps env creation resilient when billing data is incomplete while still logging platform issues.

🧹 Nitpick comments (6)
apps/webapp/app/routes/storybook.stepper/route.tsx (1)

44-44: Consider consistent disabled prop syntax.

Line 44 uses the shorthand disabled while Line 68 uses the explicit disabled={true}. Both are functionally equivalent, but using a consistent style improves readability.

Apply this diff for consistency:

-            disabled
+            disabled={true}

Also applies to: 68-68

apps/webapp/app/models/message.server.ts (1)

13-32: ToastMessageAction/options shape looks solid; JSDoc wording could be tightened

The action and title extensions are consistent with ToastMessageAction usage in Toast.tsx and with ButtonVariant / FeedbackType. One nit: the JSDoc on action has a typo and reads a bit confusingly about the ephemeral behaviour.

Suggested tweak just for clarity:

-  /** This display a button and make it not ephemeral, unless ephemeral is explicitlyset to false */
+  /** Displays a button and by default makes the toast non‑ephemeral (unless `ephemeral` is explicitly set). */

This keeps the comment aligned with the UI logic while fixing the typo.

apps/webapp/app/components/primitives/Toast.tsx (2)

72-84: UI layout and title/action wiring look good; consider setting button type explicitly

The new vertical layout with Header2 + dimmed Paragraph and the Action slot is clean and matches the new toast metadata.

One small defensive tweak: the close button does not currently set type="button". If this ever ends up rendered inside a <form>, clicking it would submit the form.

Suggested change:

-        <button
+        <button
+          type="button"
           className="hover:bg-midnight-800 -mr-1 -mt-1 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright"
           onClick={() => toast.dismiss(t)}
         >

Also applies to: 85-90


96-139: Action component wiring is solid; consider preserving existing query params when opening feedback

The Action component correctly handles both link and help actions and uses EnvelopeIcon + Button/LinkButton as expected. One behavioural detail:

setSearchParams({
  feedbackPanel: feedbackType,
});

replaces the entire query string, which may drop any existing search params on the current page.

If you want to keep other params intact while toggling feedbackPanel, you could merge instead:

-      onClick={() => {
-        setSearchParams({
-          feedbackPanel: feedbackType,
-        });
-        toast.dismiss(toastId);
-      }}
+      onClick={() => {
+        setSearchParams((prev) => {
+          const params = new URLSearchParams(prev);
+          params.set("feedbackPanel", feedbackType);
+          return params;
+        });
+        toast.dismiss(toastId);
+      }}

This keeps all existing filters/sorting/etc. while still opening the feedback panel.

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx (2)

92-114: getPlans loader call is ineffective due to tryCatch usage.

tryCatch(getPlans()) returns a tuple [error, result], so plans is always truthy here and the 404 branch is never hit; the value also isn’t used. Either drop this call (the presenter already fetches plans) or check the actual result:

-  const plans = await tryCatch(getPlans());
-  if (!plans) {
-    throw new Response(null, { status: 404, statusText: "Plans not found" });
-  }
+  const plans = await getPlans();
+  if (!plans) {
+    throw new Response(null, { status: 404, statusText: "Plans not found" });
+  }

Or simply remove these lines if you’re happy to rely on ManageConcurrencyPresenter’s internal getPlans error handling.


278-295: Give the allocation form its own Conform form id.

Both Upgradable and PurchaseConcurrencyModal use useForm({ id: "purchase-concurrency", ... }), but they represent different forms (allocate vs purchase/quota). Sharing the same Conform id can make validation and lastSubmission state bleed between them. Using a distinct id for the allocation form keeps concerns separated:

-  const [form, { environments: formEnvironments }] = useForm({
-    id: "purchase-concurrency",
+  const [form, { environments: formEnvironments }] = useForm({
+    id: "allocate-concurrency",

The shared FormSchema and useActionData can stay as-is.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 36f6e48 and 8d2dbbc.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (30)
  • apps/webapp/app/assets/icons/ConcurrencyIcon.tsx (1 hunks)
  • apps/webapp/app/components/Feedback.tsx (3 hunks)
  • apps/webapp/app/components/navigation/SideMenu.tsx (4 hunks)
  • apps/webapp/app/components/primitives/Input.tsx (1 hunks)
  • apps/webapp/app/components/primitives/InputNumberStepper.tsx (1 hunks)
  • apps/webapp/app/components/primitives/Toast.tsx (5 hunks)
  • apps/webapp/app/models/message.server.ts (3 hunks)
  • apps/webapp/app/models/organization.server.ts (2 hunks)
  • apps/webapp/app/models/project.server.ts (2 hunks)
  • apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts (1 hunks)
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx (1 hunks)
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx (2 hunks)
  • apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx (4 hunks)
  • apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.concurrency.ts (1 hunks)
  • apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts (2 hunks)
  • apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency.ts (0 hunks)
  • apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx (1 hunks)
  • apps/webapp/app/routes/storybook.input-fields/route.tsx (1 hunks)
  • apps/webapp/app/routes/storybook.stepper/route.tsx (1 hunks)
  • apps/webapp/app/routes/storybook/route.tsx (1 hunks)
  • apps/webapp/app/services/platform.v3.server.ts (4 hunks)
  • apps/webapp/app/utils/environmentSort.ts (1 hunks)
  • apps/webapp/app/utils/pathBuilder.ts (1 hunks)
  • apps/webapp/app/v3/services/allocateConcurrency.server.ts (1 hunks)
  • apps/webapp/app/v3/services/createBackgroundWorker.server.ts (1 hunks)
  • apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts (1 hunks)
  • apps/webapp/app/v3/services/triggerTaskV1.server.ts (1 hunks)
  • apps/webapp/package.json (1 hunks)
  • internal-packages/database/prisma/migrations/20251113152235_maximum_project_count/migration.sql (1 hunks)
  • internal-packages/database/prisma/schema.prisma (1 hunks)
💤 Files with no reviewable changes (1)
  • apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency.ts
🚧 Files skipped from review as they are similar to previous changes (14)
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx
  • apps/webapp/app/components/primitives/Input.tsx
  • apps/webapp/app/v3/services/triggerTaskV1.server.ts
  • internal-packages/database/prisma/schema.prisma
  • apps/webapp/app/utils/environmentSort.ts
  • apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts
  • internal-packages/database/prisma/migrations/20251113152235_maximum_project_count/migration.sql
  • apps/webapp/app/components/Feedback.tsx
  • apps/webapp/app/routes/storybook/route.tsx
  • apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts
  • apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts
  • apps/webapp/package.json
  • apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.concurrency.ts
  • apps/webapp/app/components/primitives/InputNumberStepper.tsx
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-09-02T11:37:42.902Z
Learnt from: nicktrn
Repo: triggerdotdev/trigger.dev PR: 2461
File: packages/core/src/v3/runEngineWorker/supervisor/consumerPool.ts:315-317
Timestamp: 2025-09-02T11:37:42.902Z
Learning: In packages/core/src/v3/runEngineWorker/supervisor/scalingStrategies.ts, the ScalingStrategy base class already handles clamping to min/max bounds in the public calculateTargetCount method, and the individual strategy implementations handle rounding internally using Math.round, Math.floor, and Math.ceil as appropriate.

Applied to files:

  • apps/webapp/app/v3/services/createBackgroundWorker.server.ts
🧬 Code graph analysis (11)
apps/webapp/app/routes/storybook.stepper/route.tsx (2)
apps/webapp/app/components/primitives/Headers.tsx (2)
  • Header2 (52-70)
  • Header3 (72-90)
apps/webapp/app/components/primitives/InputNumberStepper.tsx (1)
  • InputNumberStepper (13-220)
apps/webapp/app/models/project.server.ts (1)
apps/webapp/app/db.server.ts (1)
  • prisma (101-101)
apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx (1)
apps/webapp/app/services/platform.v3.server.ts (1)
  • setPlan (339-400)
apps/webapp/app/components/primitives/Toast.tsx (4)
apps/webapp/app/models/message.server.ts (1)
  • ToastMessageAction (13-25)
apps/webapp/app/components/primitives/Headers.tsx (1)
  • Header2 (52-70)
apps/webapp/app/components/primitives/Paragraph.tsx (1)
  • Paragraph (88-107)
apps/webapp/app/components/primitives/Buttons.tsx (2)
  • LinkButton (335-401)
  • Button (296-329)
apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx (4)
apps/webapp/app/models/project.server.ts (1)
  • ExceededProjectLimitError (19-24)
apps/webapp/app/models/message.server.ts (1)
  • redirectWithErrorMessage (201-218)
apps/webapp/app/utils/pathBuilder.ts (1)
  • newProjectPath (129-133)
apps/webapp/app/components/Feedback.tsx (1)
  • Feedback (29-177)
apps/webapp/app/models/organization.server.ts (1)
apps/webapp/app/services/platform.v3.server.ts (1)
  • getDefaultEnvironmentConcurrencyLimit (258-282)
apps/webapp/app/models/message.server.ts (2)
apps/webapp/app/components/primitives/Buttons.tsx (1)
  • ButtonVariant (166-166)
apps/webapp/app/routes/resources.feedback.ts (1)
  • FeedbackType (21-21)
apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx (10)
apps/webapp/app/services/session.server.ts (1)
  • requireUserId (25-35)
apps/webapp/app/utils/pathBuilder.ts (3)
  • EnvironmentParamSchema (26-28)
  • concurrencyPath (466-472)
  • v3BillingPath (482-486)
apps/webapp/app/models/project.server.ts (1)
  • findProjectBySlug (136-147)
apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts (3)
  • ManageConcurrencyPresenter (33-132)
  • ConcurrencyResult (10-21)
  • EnvironmentWithConcurrency (23-31)
apps/webapp/app/services/platform.v3.server.ts (1)
  • getPlans (323-337)
apps/webapp/app/models/message.server.ts (2)
  • redirectWithErrorMessage (201-218)
  • redirectWithSuccessMessage (182-199)
apps/webapp/app/v3/services/allocateConcurrency.server.ts (1)
  • AllocateConcurrencyService (22-91)
apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts (1)
  • SetConcurrencyAddOnService (26-143)
apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx (1)
  • useCurrentPlan (22-29)
apps/webapp/app/hooks/useOrganizations.ts (1)
  • useOrganization (39-43)
apps/webapp/app/v3/services/allocateConcurrency.server.ts (1)
apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts (1)
  • ManageConcurrencyPresenter (33-132)
apps/webapp/app/components/navigation/SideMenu.tsx (4)
apps/webapp/app/hooks/useFeatures.ts (1)
  • useFeatures (5-9)
apps/webapp/app/components/navigation/SideMenuItem.tsx (1)
  • SideMenuItem (7-53)
apps/webapp/app/assets/icons/ConcurrencyIcon.tsx (1)
  • ConcurrencyIcon (1-13)
apps/webapp/app/utils/pathBuilder.ts (1)
  • concurrencyPath (466-472)
apps/webapp/app/services/platform.v3.server.ts (4)
apps/webapp/app/database-types.ts (1)
  • RuntimeEnvironmentType (49-54)
apps/webapp/app/db.server.ts (1)
  • $replica (103-106)
apps/webapp/app/models/message.server.ts (2)
  • redirectWithErrorMessage (201-218)
  • redirectWithSuccessMessage (182-199)
apps/webapp/app/utils/pathBuilder.ts (1)
  • newProjectPath (129-133)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (23)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (2, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (3, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (7, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (8, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (8, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (4, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (2, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (5, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (1, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (7, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (6, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (6, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (5, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (3, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (1, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (4, 8)
  • GitHub Check: units / packages / 🧪 Unit Tests: Packages (1, 1)
  • GitHub Check: e2e / 🧪 CLI v3 tests (ubuntu-latest - npm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (windows-latest - pnpm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (ubuntu-latest - pnpm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (windows-latest - npm)
  • GitHub Check: typecheck / typecheck
  • GitHub Check: Analyze (javascript-typescript)
🔇 Additional comments (11)
apps/webapp/app/routes/storybook.input-fields/route.tsx (1)

23-25: LGTM!

The new outline variant demonstrations are well-structured and align with the Input component's expanded variant support.

apps/webapp/app/routes/storybook.stepper/route.tsx (1)

1-75: LGTM!

The InputNumberStepper demonstrations effectively showcase various configurations including different step sizes, min/max bounds, disabled states, and control sizes. State management and onChange handlers are implemented correctly.

apps/webapp/app/models/message.server.ts (1)

57-59: Centralizing the ephemeral default in the helpers is a good improvement

Defaulting ephemeral via options?.ephemeral ?? true after spreading options ensures that:

  • plain toasts (no explicit options) remain ephemeral by default;
  • explicit false is respected; and
  • downstream consumers see a defined boolean.

This also makes the server the single source of truth for toast lifespan defaults, which simplifies the Toast UI logic.

Also applies to: 68-70

apps/webapp/app/assets/icons/ConcurrencyIcon.tsx (1)

1-13: LGTM!

The icon component follows the established pattern in the codebase and correctly forwards the className prop to the SVG element. The visual representation with filled and stroked circles effectively conveys the concurrency concept.

apps/webapp/app/v3/services/createBackgroundWorker.server.ts (1)

362-365: LGTM! Simplified concurrency limit calculation.

The change to clamp only against environment.maximumConcurrencyLimit (removing the organization-level limit) aligns with the new per-environment concurrency management approach introduced in this PR.

apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx (1)

156-158: LGTM! Improved error handling.

Adding await ensures that any errors from setPlan are properly caught within this function's error handling context, rather than potentially becoming unhandled promise rejections.

apps/webapp/app/utils/pathBuilder.ts (1)

466-472: LGTM!

The new concurrencyPath helper follows the same pattern as existing path builders like branchesPath, maintaining consistency across the codebase.

apps/webapp/app/components/navigation/SideMenu.tsx (1)

27-27: LGTM! Well-integrated feature-flagged menu item.

The new Concurrency menu item is properly gated behind the isManagedCloud feature flag and follows the established pattern for other menu items in the SideMenu component.

Also applies to: 47-47, 127-127, 319-327

apps/webapp/app/models/organization.server.ts (1)

99-100: LGTM! Dynamic concurrency limit calculation.

Replacing the hard-coded division by 3 with getDefaultEnvironmentConcurrencyLimit provides a more flexible, plan-aware approach to setting per-environment concurrency limits.

Also applies to: 108-108

apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx (1)

119-142: LGTM! Comprehensive error handling.

The enhanced error handling provides a good user experience by distinguishing between project limit errors (with a helpful action to request more projects) and other errors, ensuring users receive appropriate feedback.

apps/webapp/app/v3/services/allocateConcurrency.server.ts (1)

57-85: Env update loop and paused check look solid.

The per-environment update correctly validates the env id against the presenter result, updates maximumConcurrencyLimit from the plan limit + requested extra, and only calls updateEnvConcurrencyLimits when the environment isn’t paused. That sequencing is good from a consistency and side‑effects standpoint.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
apps/webapp/app/v3/services/allocateConcurrency.server.ts (4)

24-40: Preserving error context from ManageConcurrencyPresenter

The tryCatch branch currently collapses all presenter failures into "Unknown error", with no logging or differentiation between expected (e.g. no plan, pricing failure) and unexpected errors. This will make debugging customer issues around concurrency allocation quite painful.

Consider at least logging the underlying error with identifiers, and optionally mapping known presenter error types/messages to more specific user-facing errors, while retaining a generic fallback for unknown ones.


41-52: Bypass via omitted environments is fixed; double‑check handling of negative amount values

Good change here: deriving newExtra from the authoritative result.environments plus the requested map closes the previously reported hole where a client could omit environments with existing extra concurrency to sneak past the cap. This looks correct and aligns the guard with the real post‑update state. (This effectively implements the earlier review suggestion.)

One thing to verify: for requested entries you clamp with Math.max(0, requested.get(env.id)!), but when actually updating you use existingEnvironment.planConcurrencyLimit + environment.amount without clamping. If callers ever send a negative amount, the cap computation will treat it as 0 extra while the DB update can drop maximumConcurrencyLimit below the plan limit (or even below zero, depending on upstream validation).

If your intent is “extra above plan is never negative”, it would be safer to either:

  • enforce amount >= 0 at this service boundary (e.g. via zod on the action + a runtime guard here), or
  • reuse the same targetExtra value when computing newConcurrency, e.g.:
-    const requested = new Map(environments.map((e) => [e.id, e.amount]));
+    const requested = new Map(environments.map((e) => [e.id, Math.max(0, e.amount)]));
...
-      const targetExtra = requested.has(env.id)
-        ? Math.max(0, requested.get(env.id)!)
+      const targetExtra = requested.has(env.id)
+        ? requested.get(env.id)!
         : Math.max(0, env.maximumConcurrencyLimit - env.planConcurrencyLimit);
...
-      const newConcurrency = existingEnvironment.planConcurrencyLimit + environment.amount;
+      const targetExtra =
+        requested.get(existingEnvironment.id) ??
+        Math.max(0, existingEnvironment.maximumConcurrencyLimit - existingEnvironment.planConcurrencyLimit);
+      const newConcurrency = existingEnvironment.planConcurrencyLimit + targetExtra;

This keeps the concurrency math and the persisted state in lock‑step.


54-61: Guard condition uses change, but error message talks about totalExtra

The guard correctly caps based on net change vs extraUnallocatedConcurrency, but the error message is phrased in terms of totalExtra, which can be confusing: it reads like the user “requested 𝑥” when 𝑥 is actually the post‑change total, not the delta that exceeded the remaining pool.

For clearer feedback, consider wording the message in terms of the delta, e.g.:

- error: `You don't have enough unallocated concurrency available. You requested ${totalExtra} but only have ${result.extraUnallocatedConcurrency}.`,
+ error: `You don't have enough unallocated concurrency available. This change needs ${change} extra but only ${result.extraUnallocatedConcurrency} is unallocated.`,

or similar.


63-91: Service currently allows allocating extra concurrency to any environment type

This service applies updates to any environment returned by ManageConcurrencyPresenter without checking the environment type. From the presenter snippet, extraAllocatedConcurrency explicitly ignores DEVELOPMENT environments (// You can't spend money to increase DEV concurrency), which suggests concurrency add‑ons are meant only for non‑DEV envs.

If the caller can include DEV environments in environments, this method will:

  • happily update their maximumConcurrencyLimit, and
  • count their extra (via change) against the purchased pool even though DEV isn’t meant to consume add‑on capacity (or vice versa, depending on product intent).

To keep invariants tight, consider either:

  • enforcing allowed types here (e.g. reject or ignore environments of type DEVELOPMENT), or
  • clearly documenting and aligning presenter + service so DEV environments are consistently treated (either fully in or fully out of the add‑on accounting).
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8d2dbbc and 6ab5a07.

📒 Files selected for processing (2)
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx (1 hunks)
  • apps/webapp/app/v3/services/allocateConcurrency.server.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 2681
File: apps/webapp/app/services/platform.v3.server.ts:258-302
Timestamp: 2025-11-14T16:03:06.917Z
Learning: In `apps/webapp/app/services/platform.v3.server.ts`, the `getDefaultEnvironmentConcurrencyLimit` function intentionally throws an error (rather than falling back to org.maximumConcurrencyLimit) when the billing client returns undefined plan limits. This fail-fast behavior prevents users from receiving more concurrency than their plan entitles them to. The org.maximumConcurrencyLimit fallback is only for self-hosted deployments where no billing client exists.
📚 Learning: 2025-11-14T16:03:06.917Z
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 2681
File: apps/webapp/app/services/platform.v3.server.ts:258-302
Timestamp: 2025-11-14T16:03:06.917Z
Learning: In `apps/webapp/app/services/platform.v3.server.ts`, the `getDefaultEnvironmentConcurrencyLimit` function intentionally throws an error (rather than falling back to org.maximumConcurrencyLimit) when the billing client returns undefined plan limits. This fail-fast behavior prevents users from receiving more concurrency than their plan entitles them to. The org.maximumConcurrencyLimit fallback is only for self-hosted deployments where no billing client exists.

Applied to files:

  • apps/webapp/app/v3/services/allocateConcurrency.server.ts
🧬 Code graph analysis (1)
apps/webapp/app/v3/services/allocateConcurrency.server.ts (1)
apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts (1)
  • ManageConcurrencyPresenter (33-132)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (23)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (6, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (8, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (4, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (7, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (6, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (1, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (4, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (8, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (5, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (3, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (3, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (2, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (1, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (5, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (7, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (2, 8)
  • GitHub Check: units / packages / 🧪 Unit Tests: Packages (1, 1)
  • GitHub Check: e2e / 🧪 CLI v3 tests (ubuntu-latest - npm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (windows-latest - npm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (windows-latest - pnpm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (ubuntu-latest - pnpm)
  • GitHub Check: typecheck / typecheck
  • GitHub Check: Analyze (javascript-typescript)

Comment on lines +24 to +61
// fetch the current concurrency
const presenter = new ManageConcurrencyPresenter(this._prisma, this._replica);
const [error, result] = await tryCatch(
presenter.call({
userId,
projectId,
organizationId,
})
);

if (error) {
return {
success: false,
error: "Unknown error",
};
}

const previousExtra = result.environments.reduce(
(acc, e) => Math.max(0, e.maximumConcurrencyLimit - e.planConcurrencyLimit) + acc,
0
);
const requested = new Map(environments.map((e) => [e.id, e.amount]));
const newExtra = result.environments.reduce((acc, env) => {
const targetExtra = requested.has(env.id)
? Math.max(0, requested.get(env.id)!)
: Math.max(0, env.maximumConcurrencyLimit - env.planConcurrencyLimit);
return acc + targetExtra;
}, 0);
const change = newExtra - previousExtra;

const totalExtra = result.extraAllocatedConcurrency + change;

if (change > result.extraUnallocatedConcurrency) {
return {
success: false,
error: `You don't have enough unallocated concurrency available. You requested ${totalExtra} but only have ${result.extraUnallocatedConcurrency}.`,
};
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Potential over‑allocation due to replica reads and lack of atomicity

The allocation check is based on a snapshot from ManageConcurrencyPresenter, which reads from _replica and aggregates extraAllocatedConcurrency / extraUnallocatedConcurrency, while the subsequent updates use this._prisma.runtimeEnvironment.update on the primary and are executed outside any transaction.

Two implications:

  1. Replica lag: if another request has just allocated extra concurrency and that write hasn’t reached the replica yet, this service may still see stale extraUnallocatedConcurrency and allow additional allocation, temporarily exceeding the purchased cap.
  2. Concurrent writers / partial updates: multiple callers racing through this method will each compute change against the same stale snapshot, and you can end up with a combined change greater than the pool. Also, if one of the per‑environment updates or updateEnvConcurrencyLimits calls fails midway, some environments may be updated while the API returns a failure, leaving the system in a partially‑applied state.

Given this code is the enforcement point for a billed resource, it would be safer to make the cap check and writes atomic against the primary, for example by:

  • moving the aggregation for enforcement to the primary (or a dedicated “concurrency budget” table) and
  • wrapping the per‑environment update calls inside a this._prisma.$transaction, keeping any external side effects (like updateEnvConcurrencyLimits) outside the DB transaction but driven from the committed result.

I'll verify the review comment by examining the actual implementation and identifying whether the replica lag and atomicity concerns are valid. Let me start by inspecting the code.


🏁 Script executed:

#!/bin/bash
# Find and display the full allocateConcurrency method
find . -type f -name "allocateConcurrency.server.ts" -path "*/v3/services/*"

Length of output: 59


🏁 Script executed:

#!/bin/bash
# Also search more broadly for this file
find . -type f -name "*allocateConcurrency*"

Length of output: 59


🏁 Script executed:

#!/bin/bash
# Search for ManageConcurrencyPresenter
rg -n "class ManageConcurrencyPresenter" --type ts

Length of output: 133


🏁 Script executed:

#!/bin/bash
# Check for transaction usage in the allocation service
rg -n "\$transaction" --type ts -C 3 | head -100

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for the allocateConcurrency method definition
rg -n "allocateConcurrency" --type ts -C 2 | head -150

Length of output: 778


🏁 Script executed:

#!/bin/bash
# Look for replica vs primary database patterns
rg -n "_replica" --type ts -C 2 | head -80

Length of output: 6374


🏁 Script executed:

#!/bin/bash
# Find all files in v3 services directory
find . -path "*/v3/services/*" -type f -name "*.ts" 2>/dev/null | head -20

Length of output: 1182


🏁 Script executed:

#!/bin/bash
# Examine the specific file mentioned in review
fd "allocateConcurrency" --type f

Length of output: 57


🏁 Script executed:

#!/bin/bash
# List the webapp services directory structure
find apps/webapp/app -type d -name "services" -o -type d -name "v3"

Length of output: 171


🏁 Script executed:

#!/bin/bash
# Get full view of the alleged file
cat apps/webapp/app/v3/services/allocateConcurrency.server.ts 2>/dev/null || echo "File not found at that path"

Length of output: 2774


🏁 Script executed:

#!/bin/bash
# Try alternate path based on common patterns
find . -path "*webapp*" -name "allocateConcurrency*" -type f

Length of output: 59


🏁 Script executed:

#!/bin/bash
# Broader search in webapp app directory
find apps/webapp/app -name "*allocateConcurrency*" -type f

Length of output: 57


🏁 Script executed:

#!/bin/bash
# Look in services directories
find apps/webapp/app -path "*/services/*" -name "*.server.ts" | head -20

Length of output: 1046


🏁 Script executed:

#!/bin/bash
# Check what's in v3 directory
ls -la apps/webapp/app/v3/ 2>/dev/null || echo "v3 directory not found"

Length of output: 3606


🏁 Script executed:

#!/bin/bash
# Find all services files
find apps/webapp -name "*.server.ts" -path "*/services/*" 2>/dev/null

Length of output: 7822


🏁 Script executed:

#!/bin/bash
# More general search for ManageConcurrencyPresenter
rg "ManageConcurrencyPresenter" --type ts -l

Length of output: 292


🏁 Script executed:

#!/bin/bash
# Search for "allocateConcurrency" more broadly
rg "allocateConcurrency" --type ts -l

Length of output: 109


🏁 Script executed:

#!/bin/bash
# Check replica usage in the codebase
rg "\._replica" --type ts -l | head -10

Length of output: 594


🏁 Script executed:

#!/bin/bash
# List top-level directories to understand structure
ls -d apps/*/app 2>/dev/null | head -10

Length of output: 15


🏁 Script executed:

#!/bin/bash
# Try to find service files in webapp
find apps/webapp -type f -name "*.ts" | grep -i "service" | head -20

Length of output: 1024


🏁 Script executed:

#!/bin/bash
# Look for "allocate" related files
find apps/webapp -type f -name "*allocate*"

Length of output: 57


🏁 Script executed:

#!/bin/bash
# Search more broadly for concurrency allocation logic
rg "allocat.*[Cc]oncurrency" --type ts -l

Length of output: 234


🏁 Script executed:

#!/bin/bash
# Check the actual file path structure
ls -la apps/webapp/app/

Length of output: 1777


🏁 Script executed:

#!/bin/bash
# Look for v3 directory in services
find apps/webapp/app -type d | grep -i v3

Length of output: 840


🏁 Script executed:

#!/bin/bash
# Search for concurrency-related service files
find apps/webapp/app -name "*concurrency*" -o -name "*Concurrency*" | head -20

Length of output: 913


🏁 Script executed:

#!/bin/bash
# Get directory structure of services
find apps/webapp/app -path "*/services/*" -type f | head -30

Length of output: 1493


🏁 Script executed:

#!/bin/bash
# Look for any mention of "extraUnallocatedConcurrency"
rg "extraUnallocatedConcurrency" --type ts -l

Length of output: 234


🏁 Script executed:

#!/bin/bash
# Search for ManageConcurrencyPresenter or similar presenter classes
rg "Presenter.*concurrency" --type ts -i

Length of output: 923


🏁 Script executed:

#!/bin/bash
# Look at the directory structure more carefully
find apps/webapp/app -mindepth 1 -maxdepth 2 -type d | sort

Length of output: 8679


🏁 Script executed:

#!/bin/bash
# View ManageConcurrencyPresenter to see what DB it reads from
cat apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts

Length of output: 4072


🏁 Script executed:

#!/bin/bash
# Find updateEnvConcurrencyLimits implementation
rg -n "updateEnvConcurrencyLimits" --type ts -A 15 | head -80

Length of output: 8628


🏁 Script executed:

#!/bin/bash
# View setConcurrencyAddOn service for comparison
cat apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts

Length of output: 3899


🏁 Script executed:

#!/bin/bash
# Search for transaction patterns around concurrency updates
rg -n "\$transaction" --type ts -A 5 -B 5 | grep -A 5 -B 5 "concurrency\|environment" | head -100

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check if there's locking or atomic patterns
rg -n "lock|atomic|mutex" --type ts -i | grep -i "concurrency\|environment" | head -20

Length of output: 3195


🏁 Script executed:

#!/bin/bash
# Look for where updateEnvConcurrencyLimits is defined
rg -n "export.*updateEnvConcurrencyLimits" --type ts

Length of output: 89


🏁 Script executed:

#!/bin/bash
# Check runQueue.server.ts where updateEnvConcurrencyLimits is imported from
cat apps/webapp/app/v3/runQueue.server.ts

Length of output: 1473


🏁 Script executed:

#!/bin/bash
# Search for how replica is created and if there's replication lag documentation
rg -n "this\._replica\s*=" --type ts -B 3 -A 3 | head -60

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check baseService to understand _replica and _prisma setup
cat apps/webapp/app/v3/services/baseService.server.ts

Length of output: 1715


🏁 Script executed:

#!/bin/bash
# Look for any concurrent allocation attempt patterns or race conditions
rg -n "AllocateConcurrencyService\|allocate.*concurrency" --type ts -C 3 | head -100

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check if there are any constraints in Prisma schema on concurrency fields
find . -name "schema.prisma" -o -name "*.prisma" | head -5

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Search for any transaction wrappers in similar billing/quota services
rg -n "this\._prisma\.\$transaction" --type ts -A 10 | head -150

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look for error handling in the allocation loop
cat apps/webapp/app/v3/services/allocateConcurrency.server.ts | grep -A 50 "for (const environment"

Length of output: 864


🏁 Script executed:

#!/bin/bash
# Check if there are other places that modify runtimeEnvironment.maximumConcurrencyLimit
rg -n "maximumConcurrencyLimit" --type ts -C 2 | head -100

Length of output: 9151


🏁 Script executed:

#!/bin/bash
# Check if there are tests that show expected transaction behavior
find . -path "*test*" -name "*allocate*" -o -path "*test*" -name "*concurrency*" | grep -i allocate

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look at the complete setConcurrencyAddOn to see if it has same pattern
rg -n "class SetConcurrencyAddOnService\|async call" apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts -A 100 | head -150

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check the exact error handling in the for loop
sed -n '59,92p' apps/webapp/app/v3/services/allocateConcurrency.server.ts

Length of output: 989


🏁 Script executed:

#!/bin/bash
# Search for any replica consistency or lag handling
rg -n "replica.*lag\|replication.*lag\|eventual.consistency" --type ts -i

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check where the presenter aggregates extraUnallocatedConcurrency
rg -n "extraUnallocatedConcurrency\|extraAllocatedConcurrency" --type ts -B 3 -A 3 | head -120

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look for synchronization points or cache invalidation
rg -n "invalidate\|sync\|flush" --type ts -i | grep -i "concurrency\|replica" | head -30

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check if there's a dedicated concurrency budget table
rg -n "concurrency.*budget\|ConcurrencyBudget" --type ts -i

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Determine if multiple runtimeEnvironment updates could fail partially
sed -n '59,92p' apps/webapp/app/v3/services/allocateConcurrency.server.ts | cat -n

Length of output: 1227


Replica lag and concurrency race conditions in allocation check

The service reads from the replica to check available budget, then sequentially updates environments on the primary without atomicity. This creates two exploitable gaps:

  1. Replica lag: Between the check (from replica) and writes (to primary), another request can allocate the same budget if replication hasn't caught up.

  2. Partial updates: If any this._prisma.runtimeEnvironment.update() fails mid-loop, earlier updates persist while the API returns failure, leaving the system in an inconsistent state.

Wrap the per-environment updates in this._prisma.$transaction(), and move the aggregation check to the primary database to ensure the check and writes are atomic against the source of truth.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants