-
-
Notifications
You must be signed in to change notification settings - Fork 894
Concurrency self serve #2681
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Concurrency self serve #2681
Conversation
|
WalkthroughAdds 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:
Pre-merge checks and finishing touches❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (4)
🚧 Files skipped from review as they are similar to previous changes (1)
🧰 Additional context used🧠 Learnings (1)📓 Common learnings🧬 Code graph analysis (1)apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx (5)
⏰ 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)
🔇 Additional comments (7)
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.
Example instruction:
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. Comment |
There was a problem hiding this 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
tryCatchis 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
createProjectthrows custom error classes (e.g.,ExceededProjectLimitError).
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis 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
tryCatchenables graceful error handling for thecreateProjectcall below.apps/webapp/app/components/Feedback.tsx (2)
70-73: LGTM!The
handleOpenChangehandler correctly delegates to both the local state update and the optional callback using safe optional chaining.
26-26: LGTM!The
onOpenChangeprop 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 regressionBecause 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 / 3withgetDefaultEnvironmentConcurrencyLimitprovides 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.maximumConcurrencyLimitfrom the clamping operation is appropriate. Since environment limits are now derived from organization limits (viagetDefaultEnvironmentConcurrencyLimitduring creation), the environment'smaximumConcurrencyLimitis 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
concurrencyPathfunction 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
isManagedCloudfeature 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
sortOrderparameter makes the function more reusable while preserving the default behavior. The fallback toenvironmentSortOrderensures no breaking changes for existing callers.apps/webapp/app/components/primitives/Toast.tsx (1)
25-39:ephemeralnow defaults to permanent toasts.
Across the codebaseoptions.ephemeralis optional. With this ternary,undefinedbecomes 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 totruewhen no action is present.- const ephemeral = options.action ? false : options.ephemeral; + const ephemeral = options.action ? false : options.ephemeral ?? true;
apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx
Show resolved
Hide resolved
...outes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx
Outdated
Show resolved
Hide resolved
d948d24 to
36f6e48
Compare
There was a problem hiding this 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
actionadds a button the user needs to click, but we still defaultephemeraltotrue, so the toast disappears before they can interact (e.g., “Request more projects” in the new-project flow). Please defaultephemeraltofalsewhenever 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
createProjectcalls can both observeprojectCount < maximumProjectCountand create rows, leaving the org over its limit. Please wrap the check+create in a serializable transaction (or useSELECT … 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 thefeedbackPanelparam and avoid shadowing state.The
const open = …shadow still collides with theopenstate, and we’re coercing whatever string is in the query param intoFeedbackTypewithout 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 ouruseSearchParamshelper 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 forFeedback.Passing an empty fragment into
DialogTrigger(withasChild) 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
concurrentRunsfor a given environment (e.g. free tiers without staging). We previously flagged this; please fall back to the organization’smaximumConcurrencyLimit(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.maximumConcurrencyLimitis retrieved once so you can reuse it in both branches.
402-415: Surface concurrency add-on failures to callers.Returning
undefinedcovers “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.
newExtraonly sums the environments included in the request, so omitting an environment with existing extra capacity makeschangenegative 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
CallingdispatchEvent(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 callonChangedirectly) 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);
138-181: Preserve rounding logic when parents add handlers
Spreading...propsafter definingonBlur/onKeyDownoverwrites 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
updatedConcurrencyis 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
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis 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
tryCatchensures any thrown errors are caught and converted to a result type.
103-136: Verify email failure handling.Based on the
sendToPlainimplementation (apps/webapp/app/utils/plain.server.ts lines 11-58), it logs errors toconsole.errorand returnsundefinedrather than throwing exceptions. ThetryCatchwrapper will only catch thrown errors, not cases wheresendToPlaincompletes but fails to send the email (e.g., whenPLAIN_API_KEYis 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:
- Modify
sendToPlainto throw errors instead of logging and returning undefined- Have
sendToPlainreturn a result object and check it here- 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
canAddConcurrencycheck 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
orgMemberwith optional chainingThe
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
extraAllocatedatextraConcurrencyto prevent over-allocation- Compute
extraUnallocatedas the remaining purchased concurrency- Extract quota and pricing information from the plan
- Sort environments in a logical display order (PRODUCTION → STAGING → PREVIEW → DEVELOPMENT)
36f6e48 to
8d2dbbc
Compare
There was a problem hiding this 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
EnvironmentLabelimport 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 providedIf
options.ephemeralis 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
truewhenoptions.ephemeralis 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
createProjectcalls can both read a count belowmaximumProjectCountand proceed to create projects, resulting in more projects than allowed. Wrap the count+create sequence in a Prisma transaction withSERIALIZABLEisolation or useSELECT ... FOR UPDATEto 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
Feedbackcomponent wraps itsbuttonprop in a RadixDialogTriggerwithasChild, 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
Feedbackcomponent to conditionally render theDialogTriggeronly 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.
previousExtrasums extras for all environments, butnewExtraonly sums the submittedenvironments. If a client omits an environment that already has extra concurrency, its extra is preserved but excluded fromnewExtra, sochangeunderestimates the real increase and can let the org exceed its purchased concurrency. RecomputenewExtrafrom 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: Allowpurchaseactions to set total extra concurrency to 0.The schema currently requires
amount ≥ 1for bothpurchaseandquota-increase. When the user removes all purchased concurrency (modal sets total to0), the submission fails validation and the action never runs, blocking the “remove all extra concurrency” flow. Split the union members sopurchaseaccepts0whilequota-increasestill 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 orgmaximumConcurrencyLimitwhen plan limits are missing, not throw.On cloud,
getDefaultEnvironmentConcurrencyLimitcallsgetDefaultEnvironmentLimitFromPlanand throws"No plan found"when it returnsundefined(or other falsy), and also throws on!result.success. For downgraded/free plans that don’t define per-envconcurrentRunslimits, this will crash environment creation instead of behaving like the no‑client path, which already falls back toorganization.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
disabledwhile Line 68 uses the explicitdisabled={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 tightenedThe action and title extensions are consistent with
ToastMessageActionusage inToast.tsxand withButtonVariant/FeedbackType. One nit: the JSDoc onactionhas 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 explicitlyThe new vertical layout with
Header2+ dimmedParagraphand theActionslot 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 feedbackThe
Actioncomponent correctly handles bothlinkandhelpactions and usesEnvelopeIcon+Button/LinkButtonas 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:getPlansloader call is ineffective due totryCatchusage.
tryCatch(getPlans())returns a tuple[error, result], soplansis 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 internalgetPlanserror handling.
278-295: Give the allocation form its own Conform form id.Both
UpgradableandPurchaseConcurrencyModaluseuseForm({ id: "purchase-concurrency", ... }), but they represent different forms (allocatevspurchase/quota). Sharing the same Conform id can make validation andlastSubmissionstate 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
FormSchemaanduseActionDatacan stay as-is.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis 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 improvementDefaulting
ephemeralviaoptions?.ephemeral ?? trueafter spreadingoptionsensures that:
- plain toasts (no explicit options) remain ephemeral by default;
- explicit
falseis 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
awaitensures that any errors fromsetPlanare 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
concurrencyPathhelper follows the same pattern as existing path builders likebranchesPath, 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
isManagedCloudfeature 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
getDefaultEnvironmentConcurrencyLimitprovides 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
maximumConcurrencyLimitfrom the plan limit + requested extra, and only callsupdateEnvConcurrencyLimitswhen the environment isn’t paused. That sequencing is good from a consistency and side‑effects standpoint.
...outes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx
Show resolved
Hide resolved
There was a problem hiding this 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 fromManageConcurrencyPresenterThe
tryCatchbranch 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 negativeamountvaluesGood change here: deriving
newExtrafrom the authoritativeresult.environmentsplus therequestedmap 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
requestedentries you clamp withMath.max(0, requested.get(env.id)!), but when actually updating you useexistingEnvironment.planConcurrencyLimit + environment.amountwithout clamping. If callers ever send a negativeamount, the cap computation will treat it as0extra while the DB update can dropmaximumConcurrencyLimitbelow 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 >= 0at this service boundary (e.g. via zod on the action + a runtime guard here), or- reuse the same
targetExtravalue when computingnewConcurrency, 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 useschange, but error message talks abouttotalExtraThe guard correctly caps based on net
changevsextraUnallocatedConcurrency, but the error message is phrased in terms oftotalExtra, 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 typeThis service applies updates to any environment returned by
ManageConcurrencyPresenterwithout checking the environment type. From the presenter snippet,extraAllocatedConcurrencyexplicitly ignoresDEVELOPMENTenvironments (// 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
📒 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)
| // 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}.`, | ||
| }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 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:
- 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
extraUnallocatedConcurrencyand allow additional allocation, temporarily exceeding the purchased cap. - Concurrent writers / partial updates: multiple callers racing through this method will each compute
changeagainst the same stale snapshot, and you can end up with a combinedchangegreater than the pool. Also, if one of the per‑environment updates orupdateEnvConcurrencyLimitscalls 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
updatecalls inside athis._prisma.$transaction, keeping any external side effects (likeupdateEnvConcurrencyLimits) 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 tsLength of output: 133
🏁 Script executed:
#!/bin/bash
# Check for transaction usage in the allocation service
rg -n "\$transaction" --type ts -C 3 | head -100Length of output: 0
🏁 Script executed:
#!/bin/bash
# Search for the allocateConcurrency method definition
rg -n "allocateConcurrency" --type ts -C 2 | head -150Length of output: 778
🏁 Script executed:
#!/bin/bash
# Look for replica vs primary database patterns
rg -n "_replica" --type ts -C 2 | head -80Length 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 -20Length of output: 1182
🏁 Script executed:
#!/bin/bash
# Examine the specific file mentioned in review
fd "allocateConcurrency" --type fLength 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 fLength of output: 59
🏁 Script executed:
#!/bin/bash
# Broader search in webapp app directory
find apps/webapp/app -name "*allocateConcurrency*" -type fLength of output: 57
🏁 Script executed:
#!/bin/bash
# Look in services directories
find apps/webapp/app -path "*/services/*" -name "*.server.ts" | head -20Length 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/nullLength of output: 7822
🏁 Script executed:
#!/bin/bash
# More general search for ManageConcurrencyPresenter
rg "ManageConcurrencyPresenter" --type ts -lLength of output: 292
🏁 Script executed:
#!/bin/bash
# Search for "allocateConcurrency" more broadly
rg "allocateConcurrency" --type ts -lLength of output: 109
🏁 Script executed:
#!/bin/bash
# Check replica usage in the codebase
rg "\._replica" --type ts -l | head -10Length of output: 594
🏁 Script executed:
#!/bin/bash
# List top-level directories to understand structure
ls -d apps/*/app 2>/dev/null | head -10Length 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 -20Length 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 -lLength 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 v3Length of output: 840
🏁 Script executed:
#!/bin/bash
# Search for concurrency-related service files
find apps/webapp/app -name "*concurrency*" -o -name "*Concurrency*" | head -20Length of output: 913
🏁 Script executed:
#!/bin/bash
# Get directory structure of services
find apps/webapp/app -path "*/services/*" -type f | head -30Length of output: 1493
🏁 Script executed:
#!/bin/bash
# Look for any mention of "extraUnallocatedConcurrency"
rg "extraUnallocatedConcurrency" --type ts -lLength of output: 234
🏁 Script executed:
#!/bin/bash
# Search for ManageConcurrencyPresenter or similar presenter classes
rg "Presenter.*concurrency" --type ts -iLength of output: 923
🏁 Script executed:
#!/bin/bash
# Look at the directory structure more carefully
find apps/webapp/app -mindepth 1 -maxdepth 2 -type d | sortLength of output: 8679
🏁 Script executed:
#!/bin/bash
# View ManageConcurrencyPresenter to see what DB it reads from
cat apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.tsLength of output: 4072
🏁 Script executed:
#!/bin/bash
# Find updateEnvConcurrencyLimits implementation
rg -n "updateEnvConcurrencyLimits" --type ts -A 15 | head -80Length of output: 8628
🏁 Script executed:
#!/bin/bash
# View setConcurrencyAddOn service for comparison
cat apps/webapp/app/v3/services/setConcurrencyAddOn.server.tsLength 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 -100Length 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 -20Length of output: 3195
🏁 Script executed:
#!/bin/bash
# Look for where updateEnvConcurrencyLimits is defined
rg -n "export.*updateEnvConcurrencyLimits" --type tsLength of output: 89
🏁 Script executed:
#!/bin/bash
# Check runQueue.server.ts where updateEnvConcurrencyLimits is imported from
cat apps/webapp/app/v3/runQueue.server.tsLength 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 -60Length of output: 0
🏁 Script executed:
#!/bin/bash
# Check baseService to understand _replica and _prisma setup
cat apps/webapp/app/v3/services/baseService.server.tsLength 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 -100Length 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 -5Length 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 -150Length 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 -100Length 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 allocateLength 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 -150Length 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.tsLength 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 -iLength of output: 0
🏁 Script executed:
#!/bin/bash
# Check where the presenter aggregates extraUnallocatedConcurrency
rg -n "extraUnallocatedConcurrency\|extraAllocatedConcurrency" --type ts -B 3 -A 3 | head -120Length 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 -30Length of output: 0
🏁 Script executed:
#!/bin/bash
# Check if there's a dedicated concurrency budget table
rg -n "concurrency.*budget\|ConcurrencyBudget" --type ts -iLength 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 -nLength 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:
-
Replica lag: Between the check (from replica) and writes (to primary), another request can allocate the same budget if replication hasn't caught up.
-
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.
For Cloud this allows you to add concurrency to your environments by purchasing the add-on and then allocating concurrency.
It also adds a project limit (default 10) to organizations.