Skip to content
Open
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
6 changes: 6 additions & 0 deletions .server-changes/bulk-replay-region-override.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

Add an "Override region" option to the bulk replay action so replayed runs can be routed to a chosen region, defaulting to keeping each run in its original region.
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton";
import { Select, SelectItem } from "~/components/primitives/Select";
import { type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
Expand All @@ -51,44 +52,56 @@ import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/m
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { CreateBulkActionPresenter } from "~/presenters/v3/CreateBulkActionPresenter.server";
import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
import { RUNS_BULK_INSPECTOR_UI_SEARCH_PARAMS } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/shouldRevalidateRunsList";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { requireUser, requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { EnvironmentParamSchema, v3BulkActionPath } from "~/utils/pathBuilder";
import { BulkActionService } from "~/v3/services/bulk/BulkActionV2.server";

export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const user = await requireUser(request);

const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);

const project = await findProjectBySlug(organizationSlug, projectParam, userId);
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response("Not Found", { status: 404 });
}

const environment = await findEnvironmentBySlug(project.id, envParam, userId);
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
throw new Response("Not Found", { status: 404 });
}

const presenter = new CreateBulkActionPresenter();
const data = await presenter.call({
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
request,
});
const [data, regionsResult] = await Promise.all([
presenter.call({
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
request,
}),
new RegionsPresenter().call({
userId: user.id,
projectSlug: projectParam,
isAdmin: user.admin || user.isImpersonating,
}),
]);

return typedjson(data);
return typedjson({ ...data, regions: regionsResult.regions });
}

export const CreateBulkActionSearchParams = z.object({
mode: BulkActionMode.default("filter"),
action: BulkActionAction.default("cancel"),
});

// Sentinel for the "Override region" dropdown meaning "keep each run's original
// region". Normalized to `undefined` in the action so the service never sees it.
const REPLAY_REGION_NO_OVERRIDE_VALUE = "__no_override__";

export const CreateBulkActionPayload = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("selected"),
Expand All @@ -99,13 +112,15 @@ export const CreateBulkActionPayload = z.discriminatedUnion("mode", [
return [];
}, z.array(z.string())),
title: z.string().optional(),
region: z.string().optional(),
failedRedirect: z.string(),
emailNotification: z.preprocess((value) => value === "on", z.boolean()),
}),
z.object({
mode: z.literal("filter"),
action: BulkActionAction,
title: z.string().optional(),
region: z.string().optional(),
failedRedirect: z.string(),
emailNotification: z.preprocess((value) => value === "on", z.boolean()),
}),
Expand Down Expand Up @@ -138,6 +153,12 @@ export async function action({ params, request }: ActionFunctionArgs) {
return redirectWithErrorMessage("/", request, "Invalid bulk action");
}

// "Don't override" keeps each run's original region — drop it so it isn't
// stored as a real override.
if (submission.value.region === REPLAY_REGION_NO_OVERRIDE_VALUE) {
submission.value.region = undefined;
}

const service = new BulkActionService();
const [error, result] = await tryCatch(
service.create(
Expand Down Expand Up @@ -212,6 +233,23 @@ export function CreateBulkActionInspector({
const impactedCountElement =
mode === "selected" ? selectedItems.size : <EstimatedCount count={data?.count} />;

// Region is a replay-only override and only applies to deployed environments.
// The default keeps each run in its original region so a bulk action spanning
// multiple regions doesn't silently re-route runs.
const regions = data?.regions ?? [];
const showRegion =
action === "replay" && environment.type !== "DEVELOPMENT" && regions.length > 1;
const regionItems = [
{ value: REPLAY_REGION_NO_OVERRIDE_VALUE, label: "Don't override", isDefault: false },
...regions.map((r) => ({
// masterQueue is the region routing key the replay resolves against
// (WorkerGroupService matches regionOverride on masterQueue); name is display only.
value: r.masterQueue,
label: r.description ? `${r.name} — ${r.description}` : r.name,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
isDefault: r.isDefault,
})),
Comment thread
matt-aitken marked this conversation as resolved.
];

return (
<Form
method="post"
Expand Down Expand Up @@ -342,6 +380,34 @@ export function CreateBulkActionInspector({
/>
</RadioGroup>
</InputGroup>
{showRegion && (
<InputGroup>
<Label htmlFor="region">Override region</Label>
{/* Our Select primitive uses Ariakit, which treats value={undefined}
as uncontrolled and keeps stale state when switching environments.
The key forces a remount so it reinitializes with the default value. */}
<Select
key={`bulk-region-${environment.id}`}
name="region"
variant="tertiary/medium"
dropdownIcon
items={regionItems}
defaultValue={REPLAY_REGION_NO_OVERRIDE_VALUE}
text={(value) => regionItems.find((r) => r.value === value)?.label}
>
{regionItems.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
{r.isDefault ? " (default)" : ""}
</SelectItem>
))}
</Select>
<Hint>
By default each run is replayed in its original region. Select a region to run
them all there instead.
</Hint>
</InputGroup>
)}
<InputGroup>
<Label>Preview</Label>
<BulkActionFilterSummary
Expand Down
13 changes: 12 additions & 1 deletion apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export class BulkActionService extends BaseService {
) {
const filters = await getFilters(payload, request);

// Region is a replay-only override that re-routes the replayed runs. It's
// stored alongside the run-list filters under a dedicated key so it isn't
// mistaken for a `regions` selection filter when the params are parsed.
const replayRegion = payload.action === "replay" ? payload.region : undefined;
const params = replayRegion ? { ...filters, replayRegion } : filters;

// Count the runs that will be affected by the bulk action
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(organizationId, "standard");
const runsRepository = new RunsRepository({
Expand All @@ -61,7 +67,7 @@ export class BulkActionService extends BaseService {
userId,
name: payload.title,
type: payload.action === "cancel" ? BulkActionType.CANCEL : BulkActionType.REPLAY,
params: filters,
params,
queryName: "bulk_action_v1",
totalCount: count,
completionNotification:
Expand Down Expand Up @@ -141,6 +147,10 @@ export class BulkActionService extends BaseService {
// 2. Parse the params
const rawParams = group.params && typeof group.params === "object" ? group.params : {};
const finalizeRun = "finalizeRun" in rawParams && (rawParams as any).finalizeRun === true;
const replayRegion =
"replayRegion" in rawParams && typeof (rawParams as any).replayRegion === "string"
? (rawParams as any).replayRegion
: undefined;
const filters = parseRunListInputOptions({
organizationId: group.project.organizationId,
projectId: group.projectId,
Expand Down Expand Up @@ -248,6 +258,7 @@ export class BulkActionService extends BaseService {
replayService.call(run, {
bulkActionId: bulkActionId,
triggerSource: "dashboard",
region: replayRegion,
Comment thread
matt-aitken marked this conversation as resolved.
})
);
if (error) {
Expand Down
Loading