Skip to content

Commit 60d5e64

Browse files
authored
Merge pull request #2339 from useautumn/feat/migration-capacity-minimal
Migrations: shared queue
2 parents f25a7dd + 717e33b commit 60d5e64

25 files changed

Lines changed: 925 additions & 250 deletions

server/src/external/autumn/autumnCli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1123,6 +1123,7 @@ export class AutumnInt {
11231123
dry_run?: boolean;
11241124
only?: string[];
11251125
limit?: number;
1126+
/** @deprecated Migration concurrency is fleet-managed. */
11261127
concurrency?: number;
11271128
lazy_run?: boolean;
11281129
retry_item_statuses?: ("failed" | "skipped")[];

server/src/internal/migrations/v2/filters/customers/filterCustomers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export const filterCustomers = ({
7676
includeProcessed,
7777
batchSize,
7878
limit,
79+
afterInternalId,
7980
}: {
8081
ctx: AutumnContext;
8182
filter: CustomerFilter;
@@ -85,6 +86,7 @@ export const filterCustomers = ({
8586
includeProcessed?: IncludeProcessed;
8687
batchSize?: number;
8788
limit?: number;
89+
afterInternalId?: string;
8890
}): AsyncGenerator<CustomerRow[]> => {
8991
const args = buildArgs({ ctx, filter, checkpoint, search, customerFilters });
9092
const source = iterateOverFilterResults<CustomerRow>({
@@ -93,6 +95,7 @@ export const filterCustomers = ({
9395
buildRowsSelect({ args, includeProcessed, limit, afterInternalId }),
9496
batchSize:
9597
limit === undefined ? batchSize : Math.min(batchSize ?? limit, limit),
98+
afterInternalId,
9699
});
97100
return limit === undefined ? source : takeRows(source, limit);
98101
};

server/src/internal/migrations/v2/filters/iterateOverFilterResults.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ export async function* iterateOverFilterResults<
1313
db,
1414
buildSelect,
1515
batchSize = DEFAULT_BATCH_SIZE,
16+
afterInternalId,
1617
}: {
1718
db: { execute: (query: SQL) => Promise<unknown> };
1819
buildSelect: (args: { limit: number; afterInternalId?: string }) => SQL;
1920
batchSize?: number;
21+
afterInternalId?: string;
2022
}): AsyncGenerator<TRow[]> {
21-
let cursor: string | undefined;
23+
let cursor = afterInternalId;
2224
while (true) {
2325
const query = buildSelect({ limit: batchSize, afterInternalId: cursor });
2426
const rows = (await db.execute(query)) as unknown as TRow[];

server/src/internal/migrations/v2/filters/runFilter.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,22 @@ export const runFilter = async ({
2828
dryRun,
2929
kind,
3030
controls,
31+
includeCount = true,
32+
afterInternalId,
33+
batchSize,
3134
}: {
3235
ctx: AutumnContext;
3336
migration: MigrationRuntimeWithEventId;
3437
migrationRunId: string;
3538
dryRun: boolean;
3639
kind: RunScopeKind;
3740
controls?: MigrationRunControls;
41+
includeCount?: boolean;
42+
afterInternalId?: string;
43+
batchSize?: number;
3844
}): Promise<{
3945
kind: RunScopeKind;
40-
count: number;
46+
count: number | null;
4147
iterate: () => AsyncGenerator<RunScopeItem[]>;
4248
}> => {
4349
if (kind !== "customer")
@@ -56,12 +62,14 @@ export const runFilter = async ({
5662
controls,
5763
});
5864
const limit = controls?.limit ?? undefined;
59-
const count = await countCustomers({
60-
ctx,
61-
filter,
62-
checkpoint,
63-
limit,
64-
});
65+
const count = includeCount
66+
? await countCustomers({
67+
ctx,
68+
filter,
69+
checkpoint,
70+
limit,
71+
})
72+
: null;
6573

6674
ctx.logger.info("runFilter: customer scope resolved", {
6775
data: {
@@ -94,6 +102,8 @@ export const runFilter = async ({
94102
filter,
95103
checkpoint,
96104
limit,
105+
afterInternalId,
106+
batchSize,
97107
})) {
98108
yield batch.map(
99109
(row): RunScopeItem => ({

server/src/internal/migrations/v2/handlers/handleRunMigration.ts

Lines changed: 14 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ErrCode, makeScopeChecker, RecaseError, Scopes } from "@autumn/shared";
1+
import { ErrCode, RecaseError, Scopes } from "@autumn/shared";
22
import { auth } from "@trigger.dev/sdk/v3";
33
import { z } from "zod/v4";
44
import { createRoute } from "@/honoMiddlewares/routeHandler";
@@ -7,49 +7,39 @@ import { prepare } from "@/internal/migrations/v2/prepare/index.js";
77
import { migrationRepo } from "@/internal/migrations/v2/repos/index.js";
88
import { RETRYABLE_MIGRATION_ITEM_RUN_STATUSES } from "@/internal/migrations/v2/run/utils/retryItemStatuses.js";
99
import { shouldRunMigrationInline } from "@/internal/migrations/v2/utils/shouldRunMigrationInline.js";
10+
import {
11+
getMigrationTriggerOptions,
12+
MIGRATION_RUN_CUSTOMER_CONCURRENCY,
13+
} from "@/trigger/migrations/migrationTaskQueue.js";
1014
import {
1115
executeRunMigration,
1216
type RunMigrationPayload,
1317
runMigrationTask,
1418
} from "@/trigger/migrations/runMigrationTask.js";
1519

16-
const MAX_CONCURRENCY = 5;
17-
const SUPERUSER_MAX_CONCURRENCY = 100;
20+
const LEGACY_MAX_REQUESTED_CONCURRENCY = 100;
1821

1922
const RunMigrationBody = z.object({
2023
id: z.string(),
2124
dry_run: z.boolean().default(false),
2225
limit: z.number().int().min(1).optional(),
2326
only: z.array(z.string()).optional(),
27+
/** Accepted for backward compatibility; migration concurrency is fleet-managed. */
2428
concurrency: z
2529
.number()
2630
.int()
2731
.min(1)
28-
.max(SUPERUSER_MAX_CONCURRENCY)
29-
.optional(),
32+
.max(LEGACY_MAX_REQUESTED_CONCURRENCY)
33+
.optional()
34+
.describe("Deprecated: migration concurrency is fleet-managed"),
3035
retry_item_statuses: z
3136
.array(z.enum(RETRYABLE_MIGRATION_ITEM_RUN_STATUSES))
3237
.optional(),
33-
/** When true, claim a lazy run alongside the background sweeper. Customers
34-
* hit on the request path get migrated lazily via `runMigrationCustomerTask`
35-
* before the sweeper reaches them. Background and lazy run on the same
36-
* migration_run row — the claim is shared. */
38+
/** Lazy runs share one run row with the sweeper and enqueue request-path customer work.
39+
* Targeted `only` is incompatible because lazy matching happens on customer reads. */
3740
lazy_run: z.boolean().default(false),
3841
});
3942

40-
const getRunMigrationTriggerOptions = ({
41-
orgId,
42-
migrationId,
43-
isDev,
44-
}: {
45-
orgId: string;
46-
migrationId: string;
47-
isDev: boolean;
48-
}) => ({
49-
...(isDev ? { region: "eu-central-1" } : {}),
50-
concurrencyKey: `${orgId}:${migrationId}`,
51-
});
52-
5343
export const handleRunMigration = createRoute({
5444
scopes: [Scopes.Migrations.Write],
5545
body: RunMigrationBody,
@@ -60,23 +50,10 @@ export const handleRunMigration = createRoute({
6050
dry_run: dryRun,
6151
limit,
6252
only,
63-
concurrency,
6453
retry_item_statuses: retryItemStatuses,
6554
lazy_run: lazyRun,
6655
} = c.req.valid("json");
6756

68-
const { isSuperuser } = makeScopeChecker(ctx.scopes);
69-
const maxConcurrency = isSuperuser
70-
? SUPERUSER_MAX_CONCURRENCY
71-
: MAX_CONCURRENCY;
72-
if (concurrency !== undefined && concurrency > maxConcurrency) {
73-
throw new RecaseError({
74-
message: `Migration concurrency cannot exceed ${maxConcurrency}`,
75-
code: ErrCode.InvalidRequest,
76-
statusCode: 400,
77-
});
78-
}
79-
8057
const migration = await migrationRepo.find({ ctx, id });
8158

8259
if (!migration.operations)
@@ -119,7 +96,6 @@ export const handleRunMigration = createRoute({
11996
controls: {
12097
limit,
12198
only,
122-
concurrency,
12399
retryItemStatuses,
124100
},
125101
};
@@ -129,11 +105,7 @@ export const handleRunMigration = createRoute({
129105
}
130106
const handle = await runMigrationTask.trigger(
131107
payload,
132-
getRunMigrationTriggerOptions({
133-
orgId: ctx.org.id,
134-
migrationId: id,
135-
isDev,
136-
}),
108+
getMigrationTriggerOptions({ isDev }),
137109
);
138110
return { triggerRunId: handle.id };
139111
},
@@ -176,7 +148,7 @@ export const handleRunMigration = createRoute({
176148
migration_id: id,
177149
dry_run: dryRun,
178150
lazy_run: lazyRun,
179-
concurrency,
151+
concurrency: MIGRATION_RUN_CUSTOMER_CONCURRENCY,
180152
run_id: migrationRunId,
181153
trigger_run_id: triggerRunId,
182154
public_access_token: publicAccessToken,

server/src/internal/migrations/v2/lazy/checkPendingMigrationsForCustomer.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { FullCustomer, MigrationItemRunData } from "@autumn/shared";
22
import { customerFilterMatchesFullCustomer } from "@autumn/shared/api/customers/utils/match/index.js";
33
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
44
import { shouldRunMigrationInline } from "@/internal/migrations/v2/utils/shouldRunMigrationInline.js";
5+
import { MIGRATION_LAZY_TASK_PRIORITY_SECONDS } from "@/trigger/migrations/migrationTaskQueue.js";
56
import {
67
executeRunMigrationCustomer,
78
runMigrationCustomerTask,
@@ -80,8 +81,6 @@ export const checkPendingMigrationsForCustomer = async ({
8081
};
8182

8283
if (shouldRunMigrationInline()) {
83-
// Inline loses trigger.dev's concurrencyKey serialization; the
84-
// server-side item-run claim is the real authority either way.
8584
const inlineCtx = { ...ctx, insideTriggerTask: true };
8685
void executeRunMigrationCustomer({
8786
ctx: inlineCtx,
@@ -99,7 +98,7 @@ export const checkPendingMigrationsForCustomer = async ({
9998
}
10099

101100
await runMigrationCustomerTask.trigger(payload, {
102-
concurrencyKey: `${migration.internal_id}:${fullCustomer.internal_id}`,
101+
priority: MIGRATION_LAZY_TASK_PRIORITY_SECONDS,
103102
});
104103
}
105104
};
Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,54 @@
1+
import type { MigrationRunScheduler } from "../types/migrationRunScheduler.js";
12
import type { RunScopeItem } from "../types/runScope.js";
23

34
export type IterateScopeItemResult<T> =
45
| { status: "ok"; item: RunScopeItem; value: T }
56
| { status: "failed"; item: RunScopeItem; error: Error };
67

8+
export type IterateScopeCompletion = "exhausted" | "slice_complete" | "stopped";
9+
710
export type IterateScopeSummary<T> = {
811
processed: number;
912
succeeded: number;
1013
failed: number;
1114
results: IterateScopeItemResult<T>[];
15+
completion: IterateScopeCompletion;
16+
cursor: string | null;
1217
};
1318

14-
/**
15-
* Generic iteration over any kind-tagged scope iterator. Calls `perItem`
16-
* for every item, collecting per-item results into a summary.
17-
*
18-
* `concurrency` (default 1): max parallel `perItem` invocations. The
19-
* iterator's batch boundaries are preserved — work for batch N starts
20-
* only after the source yields it, but within and across batches up to
21-
* `concurrency` items run concurrently via a sliding worker pool.
22-
*
23-
* On error: keeps going with `onError: "continue"` (default), or rethrows
24-
* the first error with `onError: "throw"`. Either way every visited
25-
* item shows up in `results` so callers see the full audit trail.
26-
*/
19+
/** Iterates scope items; a scheduler forces sequential execution and ends between items.
20+
* Errors are collected by default or rethrown when `onError` is `throw`. */
2721
export const iterateScope = async <T>({
2822
iterate,
2923
perItem,
3024
onError = "continue",
3125
concurrency = 1,
26+
scheduler,
27+
shouldStop,
3228
}: {
3329
iterate: () => AsyncGenerator<RunScopeItem[]>;
3430
perItem: (item: RunScopeItem) => Promise<T>;
3531
onError?: "throw" | "continue";
3632
concurrency?: number;
33+
scheduler?: MigrationRunScheduler;
34+
shouldStop?: () => boolean;
3735
}): Promise<IterateScopeSummary<T>> => {
3836
const results: IterateScopeItemResult<T>[] = [];
3937
let succeeded = 0;
4038
let failed = 0;
41-
const maxParallel = Math.max(1, Math.floor(concurrency));
39+
const maxParallel = scheduler ? 1 : Math.max(1, Math.floor(concurrency));
40+
const sliceStartedAtMs = scheduler?.now();
41+
let hasProcessedScheduledItem = false;
42+
const summarize = (
43+
completion: IterateScopeCompletion,
44+
): IterateScopeSummary<T> => ({
45+
processed: succeeded + failed,
46+
succeeded,
47+
failed,
48+
results,
49+
completion,
50+
cursor: results[results.length - 1]?.item.internal_id ?? null,
51+
});
4252

4353
const runItem = async (item: RunScopeItem) => {
4454
try {
@@ -53,11 +63,23 @@ export const iterateScope = async <T>({
5363
}
5464
};
5565

66+
const scheduledSliceIsComplete = () =>
67+
scheduler !== undefined &&
68+
hasProcessedScheduledItem &&
69+
sliceStartedAtMs !== undefined &&
70+
scheduler.now() - sliceStartedAtMs >= scheduler.sliceDurationMs;
71+
5672
if (maxParallel === 1) {
5773
for await (const batch of iterate()) {
58-
for (const item of batch) await runItem(item);
74+
for (const item of batch) {
75+
if (shouldStop?.()) return summarize("stopped");
76+
if (scheduledSliceIsComplete()) return summarize("slice_complete");
77+
await runItem(item);
78+
hasProcessedScheduledItem = true;
79+
if (shouldStop?.()) return summarize("stopped");
80+
}
5981
}
60-
return { processed: succeeded + failed, succeeded, failed, results };
82+
return summarize("exhausted");
6183
}
6284

6385
const inflight = new Set<Promise<void>>();
@@ -70,13 +92,21 @@ export const iterateScope = async <T>({
7092

7193
for await (const batch of iterate()) {
7294
for (const item of batch) {
95+
if (shouldStop?.()) {
96+
await Promise.all(inflight);
97+
return summarize("stopped");
98+
}
7399
schedule(item);
74100
if (inflight.size >= maxParallel) {
75101
await Promise.race(inflight);
102+
if (shouldStop?.()) {
103+
await Promise.all(inflight);
104+
return summarize("stopped");
105+
}
76106
}
77107
}
78108
}
79109
await Promise.all(inflight);
80110

81-
return { processed: succeeded + failed, succeeded, failed, results };
111+
return summarize(shouldStop?.() ? "stopped" : "exhausted");
82112
};

0 commit comments

Comments
 (0)