Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/chilly-plums-pause.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@trigger.dev/sdk": minor
"@trigger.dev/core": minor
---

Concurrency limits can now be paused and resumed, just like queues: `concurrencyLimits.pause(name)` stops every run holding the limit from being dequeued while keeping its configured bounds, and `concurrencyLimits.resume(name)` starts them again.

```ts
import { concurrencyLimits } from "@trigger.dev/sdk";

await concurrencyLimits.pause("openai");
await concurrencyLimits.resume("openai");
```
5 changes: 5 additions & 0 deletions .changeset/deprecate-queue-concurrency-mutations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---

Deprecate `queues.overrideConcurrencyLimit` and `queues.resetConcurrencyLimit`. These operate on the legacy model where a queue carried its own concurrency limit; declare concurrency with the task `concurrency` option and manage it with `concurrencyLimits.override` and `concurrencyLimits.reset` instead.
6 changes: 6 additions & 0 deletions .server-changes/concurrency-allocation-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---

Allocating extra concurrency to environments now requires billing permissions, the same as purchasing it. Allocation changes are also applied atomically, so simultaneous edits can no longer exceed your purchased concurrency.
5 changes: 5 additions & 0 deletions .server-changes/session-transcript-inspector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
area: webapp
type: feature
---
Download a session’s original saved transcript file from the session inspector.
60 changes: 39 additions & 21 deletions apps/webapp/app/components/queues/QueueControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export function QueuePauseResumeButton({
iconOnly = false,
withQueueName = false,
disabled = false,
noun = "queue",
}: {
/** The "id" here is a friendlyId */
queue: { id: string; name: string; paused: boolean };
Expand All @@ -48,14 +49,21 @@ export function QueuePauseResumeButton({
/** Render the full "Pause/Resume {name} queue" label instead of the short "Pause"/"Resume". */
withQueueName?: boolean;
disabled?: boolean;
/** What the row is called in every user-facing string: named concurrency limits pause through
* the same actions but their dialogs must say "limit". */
noun?: "queue" | "limit";
}) {
const [isOpen, setIsOpen] = useState(false);

const label = queue.paused
? `Resumes the "${queue.name}" queue so its runs can be dequeued again.`
: `Pauses all runs from being dequeued in the "${queue.name}" queue. Any executing runs will continue to run.`;
? noun === "limit"
? `Resumes the "${queue.name}" limit so runs holding it can be dequeued again.`
: `Resumes the "${queue.name}" queue so its runs can be dequeued again.`
: noun === "limit"
? `Pauses all runs holding the "${queue.name}" limit from being dequeued. Any executing runs will continue to run.`
: `Pauses all runs from being dequeued in the "${queue.name}" queue. Any executing runs will continue to run.`;

const tooltip = disabled ? "You don't have permission to manage queues" : label;
const tooltip = disabled ? `You don't have permission to manage ${noun}s` : label;

const trigger = showTooltip ? (
<div>
Expand Down Expand Up @@ -91,8 +99,8 @@ export function QueuePauseResumeButton({
? undefined
: withQueueName
? queue.paused
? "Resume this queue…"
: "Pause this queue…"
? `Resume this ${noun}…`
: `Pause this ${noun}…`
: queue.paused
? "Resume"
: "Pause"}
Expand All @@ -113,7 +121,7 @@ export function QueuePauseResumeButton({
leadingIconClassName={queue.paused ? "text-success" : "text-warning"}
title={
disabled
? "You don't have permission to manage queues"
? `You don't have permission to manage ${noun}s`
: queue.paused
? "Resume..."
: "Pause..."
Expand All @@ -127,12 +135,16 @@ export function QueuePauseResumeButton({
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{trigger}
<DialogContent>
<DialogHeader>{queue.paused ? "Resume queue?" : "Pause queue?"}</DialogHeader>
<DialogHeader>{queue.paused ? `Resume ${noun}?` : `Pause ${noun}?`}</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph>
{queue.paused
? `This will allow runs to be dequeued in the "${queue.name}" queue again.`
: `This will pause all runs from being dequeued in the "${queue.name}" queue. Any executing runs will continue to run.`}
? noun === "limit"
? `This will allow runs holding the "${queue.name}" limit to be dequeued again.`
: `This will allow runs to be dequeued in the "${queue.name}" queue again.`
: noun === "limit"
? `This will pause all runs holding the "${queue.name}" limit from being dequeued. Any executing runs will continue to run.`
: `This will pause all runs from being dequeued in the "${queue.name}" queue. Any executing runs will continue to run.`}
</Paragraph>
<Form method="post" onSubmit={() => setIsOpen(false)}>
<input
Expand All @@ -141,6 +153,7 @@ export function QueuePauseResumeButton({
value={queue.paused ? "queue-resume" : "queue-pause"}
/>
<input type="hidden" name="friendlyId" value={queue.id} />
<input type="hidden" name="noun" value={noun} />
<FormButtons
confirmButton={
<Button
Expand All @@ -149,7 +162,7 @@ export function QueuePauseResumeButton({
variant={queue.paused ? "primary/medium" : "danger/medium"}
LeadingIcon={queue.paused ? PlayIcon : PauseIcon}
>
{queue.paused ? "Resume queue" : "Pause queue"}
{queue.paused ? `Resume ${noun}` : `Pause ${noun}`}
</Button>
}
cancelButton={
Expand All @@ -172,6 +185,7 @@ export function QueueOverrideConcurrencyButton({
environmentConcurrencyLimit,
trigger,
disabled = false,
noun = "queue",
}: {
queue: {
id: string;
Expand All @@ -185,6 +199,9 @@ export function QueueOverrideConcurrencyButton({
* hover tooltip, for compact placements like the detail-page live blocks. */
trigger?: "menu-item" | "button" | "icon";
disabled?: boolean;
/** What the row is called in every user-facing string: named concurrency limits are overridden
* through the same actions but their dialogs must say "limit". */
noun?: "queue" | "limit";
}) {
const navigation = useNavigation();
const [isOpen, setIsOpen] = useState(false);
Expand Down Expand Up @@ -316,7 +333,7 @@ export function QueueOverrideConcurrencyButton({
</div>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
{disabled ? "You don't have permission to manage queues" : iconLabel}
{disabled ? `You don't have permission to manage ${noun}s` : iconLabel}
</TooltipContent>
</Tooltip>
</TooltipProvider>
Expand All @@ -343,10 +360,10 @@ export function QueueOverrideConcurrencyButton({
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[230px] text-xs">
{disabled
? "You don't have permission to manage queues"
? `You don't have permission to manage ${noun}s`
: hasTotal
? "Override this queue's per-key and total concurrency limits."
: "Give this queue its own concurrency limit instead of the environment default. Set it as a number or a percentage of the environment limit."}
? `Override this ${noun}'s per-key and total concurrency limits.`
: `Give this ${noun} its own concurrency limit instead of the environment default. Set it as a number or a percentage of the environment limit.`}
</TooltipContent>
</Tooltip>
</TooltipProvider>
Expand All @@ -356,7 +373,7 @@ export function QueueOverrideConcurrencyButton({
icon={AdjustmentsHorizontalIcon}
title={
disabled
? "You don't have permission to manage queues"
? `You don't have permission to manage ${noun}s`
: isOverridden
? "Edit override…"
: "Override limit…"
Expand All @@ -373,20 +390,20 @@ export function QueueOverrideConcurrencyButton({
{hasTotal ? (
isOverridden ? (
<Paragraph variant="small">
This queue's limits are currently overridden. Fill a field to change that limit
This {noun}'s limits are currently overridden. Fill a field to change that limit
(blank fields stay unchanged), or remove the override to restore the limits set in
code.
</Paragraph>
) : (
<Paragraph variant="small">
Override this queue's limits. Per key caps each concurrency key's pool, and total
Override this {noun}'s limits. Per key caps each concurrency key's pool, and total
caps runs across all keys together. Leave a field blank to keep that limit
unchanged.
</Paragraph>
)
) : isOverridden ? (
<Paragraph variant="small">
This queue's concurrency limit is currently overridden to {currentLimit}.
This {noun}'s concurrency limit is currently overridden to {currentLimit}.
{typeof queue.limits.perKey.base === "number" &&
` The original limit set in code was ${queue.limits.perKey.base}.`}{" "}
You can update the override or remove it to restore the{" "}
Expand All @@ -397,12 +414,13 @@ export function QueueOverrideConcurrencyButton({
</Paragraph>
) : (
<Paragraph variant="small">
Override this queue's concurrency limit. The current limit is {currentLimit}, which is
set {queue.limits.perKey.current !== null ? "in code" : "by the environment"}.
Override this {noun}'s concurrency limit. The current limit is {currentLimit}, which
is set {queue.limits.perKey.current !== null ? "in code" : "by the environment"}.
</Paragraph>
)}
<Form method="post" onSubmit={() => setIsOpen(false)} className="space-y-3">
<input type="hidden" name="friendlyId" value={queue.id} />
<input type="hidden" name="noun" value={noun} />
<input type="hidden" name="mode" value={hasTotal ? "bounds" : mode} />
{hasTotal ? (
<>
Expand Down Expand Up @@ -524,7 +542,7 @@ export function QueueOverrideConcurrencyButton({
<Hint className={limitOverCap ? "text-warning tabular-nums" : "tabular-nums"}>
{limitOverCap
? `Can't exceed the environment limit of ${environmentConcurrencyLimit}.`
: `The most concurrent runs this queue can use at once. It can't exceed the environment limit of ${environmentConcurrencyLimit}.`}
: `The most concurrent runs this ${noun} can use at once. It can't exceed the environment limit of ${environmentConcurrencyLimit}.`}
</Hint>
)}
</InputGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// @vitest-environment jsdom
import { createServer, type Server, type ServerResponse } from "node:http";
import { once } from "node:events";
import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, expect, test } from "vitest";
import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider";
import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider";
import { TranscriptDownloadButton } from "./TranscriptDownloadButton";

let server: Server | undefined;
let root: Root | undefined;
let container: HTMLDivElement;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

afterEach(async () => {
if (root) await act(async () => root?.unmount());
root = undefined;
container?.remove();
if (server) {
server.closeAllConnections();
await new Promise<void>((resolve, reject) =>
server!.close((error) => (error ? reject(error) : resolve()))
);
server = undefined;
}
});

async function serve(handler: (response: ServerResponse) => void) {
server = createServer((_request, response) => handler(response));
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") throw new Error("Expected TCP listener");
return `http://127.0.0.1:${address.port}/resources/transcript-download`;
}

async function render(resourcePath: string, initiallyAvailable: boolean) {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
await act(async () =>
root!.render(
createElement(
OperatingSystemContextProvider,
{ platform: "mac" },
createElement(
ShortcutsProvider,
null,
createElement(
"div",
null,
createElement("h1", null, "Session overview"),
createElement(TranscriptDownloadButton, { resourcePath, initiallyAvailable })
)
)
)
)
);
}

async function settleUntil(condition: () => boolean) {
const deadline = Date.now() + 2000;
while (!condition() && Date.now() < deadline) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
}
expect(condition()).toBe(true);
}

test("a seeded transcript uses a native attachment link without fetching the object", async () => {
let requests = 0;
const url = await serve((response) => {
requests++;
response.end();
});
await render(url, true);
const link = container.querySelector<HTMLAnchorElement>('a[aria-label="Download transcript"]');
expect(link?.href).toBe(url);
expect(link?.hasAttribute("download")).toBe(true);
expect(requests).toBe(0);
});

test("an HTML proxy error shows availability guidance and Retry recovers", async () => {
let attempts = 0;
const url = await serve((response) => {
if (++attempts === 1) {
response.writeHead(502, { "Content-Type": "text/html" });
response.end("<html>Bad gateway</html>");
} else {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ available: true }));
}
});
await render(url, false);
await settleUntil(() => !!container.querySelector('[role="alert"]'));
expect(container.querySelector('[role="alert"]')?.textContent).toBe(
"Could not check transcript availability."
);
expect(container.textContent).not.toContain("Unexpected token");
await act(async () => container.querySelector<HTMLButtonElement>("button")!.click());
await settleUntil(() => !!container.querySelector("a[download]"));
expect(attempts).toBe(2);
});

test("a pending availability request does not block the overview and a missing object stays hidden", async () => {
let pending: ServerResponse | undefined;
const url = await serve((response) => {
pending = response;
});
await render(url, false);
await settleUntil(() => !!pending);
expect(container.querySelector("h1")?.textContent).toBe("Session overview");
expect(container.querySelector("a")).toBeNull();
await act(async () => {
pending!.writeHead(200, { "Content-Type": "application/json" });
pending!.end(JSON.stringify({ available: false }));
await new Promise((resolve) => setTimeout(resolve, 20));
});
expect(container.textContent).toBe("Session overview");
});
Loading
Loading