Skip to content

Commit 5b5cfc7

Browse files
NicolappsConvex, Inc.
authored andcommitted
cli: Ask for confirmation before starting a deploy that deletes large indexes (#42545)
This asks the user for confirmation when deleting large indexes when running `npx convex deploy`: ``` ⚠️ This code push will delete the following indexes from your production deployment (http://127.0.0.1:8000): ⛔ messages.by_body body, _creationTime ⚠️ 101,723,891 documents → replaced by: messages.by_body body, author, _creationTime ⛔ messages.by_author author, _creationTime (staged) ⚠️ 101,723,891 documents ⛔ aaa:rateLimits.name name, key, _creationTime 2 documents ⛔ bbb:emails.by_resendId resendId, _creationTime 1 document ⛔ bbb:emails.by_status_segment status, segment, _creationTime 1 document ⛔ bbb:emails.by_finalizedAt finalizedAt, _creationTime 1 document The documents that are in the index won’t be deleted, but the index will need to be backfilled again if you want to restore it later. ? Delete these indexes? (y/N) ``` If the user is running the `deploy` command from a CI, here’s what appears instead: ``` ⚠️ This code push will delete the following indexes from your production deployment (http://127.0.0.1:8000): ⛔ aaa:rateLimits.name name, key, _creationTime ⚠️ 10,000,000 documents ⛔ bbb:emails.by_finalizedAt finalizedAt, _creationTime 1 document ⛔ bbb:emails.by_resendId resendId, _creationTime 1 document ⛔ bbb:emails.by_status_segment status, segment, _creationTime 1 document The documents that are in the index won’t be deleted, but the index will need to be backfilled again if you want to restore it later. ✖ To confirm the push: • run the deploy command in an interactive terminal • or run the deploy command with the --allow-deleting-indexes flag ``` This doesn't change the behavior when using `npx convex dev` or deploying to non-prod environments GitOrigin-RevId: abf47fbd04bb7e8ba962eb4b25aa96922df2f3fb
1 parent 287491d commit 5b5cfc7

File tree

11 files changed

+287
-6
lines changed

11 files changed

+287
-6
lines changed

npm-packages/convex/src/cli/codegen.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export const codegen = new Command("codegen")
5656
liveComponentSources: !!options.liveComponentSources,
5757
debugNodeApis: false,
5858
systemUdfs: !!options.systemUdfs,
59+
largeIndexDeletionCheck: "no verification", // `codegen` is a read-only operation
5960
codegenOnlyThisComponent: options.componentDir,
6061
});
6162
});

npm-packages/convex/src/cli/deploy.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,12 @@ deployment, e.g. ${CONVEX_DEPLOYMENT_ENV_VAR_NAME} or ${CONVEX_SELF_HOSTED_URL_V
7575
Same format as .env.local or .env files, and overrides them.`,
7676
),
7777
)
78+
.addOption(
79+
new Option("--allow-deleting-large-indexes")
80+
.hideHelp()
81+
.conflicts("preview-create")
82+
.conflicts("preview-name"),
83+
)
7884
.showHelpAfterError()
7985
.action(async (cmdOptions) => {
8086
const ctx = await oneoffContext(cmdOptions);
@@ -141,7 +147,11 @@ Same format as .env.local or .env files, and overrides them.`,
141147
},
142148
);
143149
} else {
144-
await deployToExistingDeployment(ctx, cmdOptions);
150+
await deployToExistingDeployment(ctx, {
151+
...cmdOptions,
152+
allowDeletingLargeIndexes:
153+
cmdOptions.allowDeletingLargeIndexes ?? false,
154+
});
145155
}
146156
});
147157

@@ -231,6 +241,7 @@ async function deployToNewPreviewDeployment(
231241
codegen: options.codegen === "enable",
232242
url: previewUrl,
233243
liveComponentSources: false,
244+
largeIndexDeletionCheck: "no verification", // fine for preview deployments
234245
};
235246
showSpinner(`Deploying to ${previewUrl}...`);
236247
await runPush(ctx, pushOptions);
@@ -271,6 +282,7 @@ async function deployToExistingDeployment(
271282
writePushRequest?: string | undefined;
272283
liveComponentSources?: boolean | undefined;
273284
envFile?: string | undefined;
285+
allowDeletingLargeIndexes: boolean;
274286
},
275287
) {
276288
const selectionWithinProject = deploymentSelectionWithinProjectFromOptions({
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import chalk from "chalk";
2+
import { Context } from "../../bundler/context.js";
3+
import {
4+
changeSpinner,
5+
logFinishedStep,
6+
logMessage,
7+
stopSpinner,
8+
} from "../../bundler/log.js";
9+
import { formatIndex } from "./indexes.js";
10+
import { promptYesNo } from "./utils/prompts.js";
11+
import { Span } from "./tracing.js";
12+
import { StartPushRequest } from "./deployApi/startPush.js";
13+
import { evaluatePush } from "./deploy2.js";
14+
import { DeveloperIndexConfig, IndexDiff } from "./deployApi/finishPush.js";
15+
import { runSystemQuery } from "./run.js";
16+
17+
const MIN_DOCUMENTS_FOR_INDEX_DELETE_WARNING = 10_000_000;
18+
19+
export async function checkForLargeIndexDeletion({
20+
ctx,
21+
span,
22+
request,
23+
options,
24+
askForConfirmation,
25+
}: {
26+
ctx: Context;
27+
span: Span;
28+
request: StartPushRequest;
29+
options: {
30+
url: string;
31+
deploymentName: string | null;
32+
adminKey: string;
33+
};
34+
askForConfirmation: boolean;
35+
}): Promise<void> {
36+
changeSpinner("Verifying that the push isn’t deleting large indexes...");
37+
38+
const { schemaChange } = await evaluatePush(ctx, span, request, options);
39+
40+
const indexDiffs = schemaChange.indexDiffs ?? {};
41+
const deletedIndexes = Object.entries(indexDiffs).flatMap(
42+
([componentDefinitionPath, indexDiff]) =>
43+
indexDiff.removed_indexes.map((index) => ({
44+
componentDefinitionPath,
45+
index,
46+
})),
47+
);
48+
49+
if (deletedIndexes.length === 0) {
50+
logFinishedStep("No indexes are deleted by this push");
51+
return;
52+
}
53+
54+
const tablesWithDeletedIndexes = [
55+
...new Set(
56+
deletedIndexes.map(
57+
({ componentDefinitionPath, index }) =>
58+
`${componentDefinitionPath}:${getTableName(index)}`,
59+
),
60+
),
61+
].map((str) => {
62+
const [componentDefinitionPath, table] = str.split(":");
63+
return { componentDefinitionPath, table };
64+
});
65+
changeSpinner("Checking whether the deleted indexes are on large tables...");
66+
const documentCounts = await Promise.all(
67+
tablesWithDeletedIndexes.map(
68+
async ({ componentDefinitionPath, table }) => ({
69+
componentDefinitionPath,
70+
table,
71+
count: (await runSystemQuery(ctx, {
72+
deploymentUrl: options.url,
73+
adminKey: options.adminKey,
74+
functionName: "_system/cli/tableSize:default",
75+
componentPath: componentDefinitionPath,
76+
args: { tableName: table },
77+
})) as number,
78+
}),
79+
),
80+
);
81+
const deletedIndexesWithDocumentsCount = deletedIndexes.map(
82+
({ componentDefinitionPath, index }) => ({
83+
componentDefinitionPath,
84+
index,
85+
count: documentCounts.find(
86+
(count) =>
87+
count.table === getTableName(index) &&
88+
count.componentDefinitionPath === componentDefinitionPath,
89+
)!.count,
90+
}),
91+
);
92+
93+
if (
94+
!deletedIndexesWithDocumentsCount.some(
95+
({ count }) => count >= MIN_DOCUMENTS_FOR_INDEX_DELETE_WARNING,
96+
)
97+
) {
98+
logFinishedStep("No large indexes are deleted by this push");
99+
return;
100+
}
101+
102+
logMessage(`⚠️ This code push will ${chalk.bold("delete")} the following ${deletedIndexesWithDocumentsCount.length === 1 ? "index" : "indexes"}
103+
from your production deployment (${options.url}):
104+
105+
${deletedIndexesWithDocumentsCount
106+
.map(({ componentDefinitionPath, index, count }) =>
107+
formatDeletedIndex(
108+
componentDefinitionPath,
109+
index,
110+
indexDiffs[componentDefinitionPath],
111+
count,
112+
),
113+
)
114+
.join("\n")}
115+
116+
The documents that are in the index won’t be deleted, but the index will need
117+
to be backfilled again if you want to restore it later.
118+
`);
119+
120+
if (!askForConfirmation) {
121+
logFinishedStep(
122+
"Proceeding with push since --allow-deleting-large-indexes is set",
123+
);
124+
return;
125+
}
126+
127+
if (!process.stdin.isTTY) {
128+
return ctx.crash({
129+
exitCode: 1,
130+
errorType: "fatal",
131+
printedMessage: `To confirm the push:
132+
• run the deploy command in an ${chalk.bold("interactive terminal")}
133+
• or run the deploy command with the ${chalk.bold("--allow-deleting-large-indexes")} flag`,
134+
});
135+
}
136+
137+
stopSpinner();
138+
if (
139+
!(await promptYesNo(ctx, {
140+
message: `Delete these ${deletedIndexesWithDocumentsCount.length === 1 ? "index" : "indexes"}?`,
141+
default: false,
142+
}))
143+
) {
144+
return ctx.crash({
145+
exitCode: 1,
146+
errorType: "fatal",
147+
printedMessage: `Canceling push`,
148+
});
149+
}
150+
151+
logFinishedStep("Proceeding with push.");
152+
}
153+
154+
function formatDeletedIndex(
155+
componentDefinitionPath: string,
156+
index: DeveloperIndexConfig,
157+
indexDiff: IndexDiff,
158+
documentsCount: number,
159+
) {
160+
const componentNameFormatted =
161+
componentDefinitionPath !== ""
162+
? `${chalk.gray(componentDefinitionPath)}:`
163+
: "";
164+
165+
const documentsCountFormatted =
166+
documentsCount >= MIN_DOCUMENTS_FOR_INDEX_DELETE_WARNING
167+
? ` ${chalk.yellowBright(`⚠️ ${documentsCount.toLocaleString()} documents`)}`
168+
: ` ${documentsCount.toLocaleString()} ${documentsCount === 1 ? "document" : "documents"}`;
169+
170+
const replacedBy = indexDiff.added_indexes.find((i) => i.name === index.name);
171+
const replacedByFormatted = replacedBy
172+
? `\n ${chalk.green("→ replaced by:")} ${formatIndex(replacedBy)}`
173+
: "";
174+
175+
return (
176+
"⛔ " +
177+
componentNameFormatted +
178+
formatIndex(index) +
179+
documentsCountFormatted +
180+
replacedByFormatted
181+
);
182+
}
183+
184+
function getTableName(index: DeveloperIndexConfig) {
185+
const [tableName, _indexName] = index.name.split(".");
186+
return tableName;
187+
}

npm-packages/convex/src/cli/lib/codegen.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
rootComponentApiCJS,
4444
} from "../codegen_templates/component_api.js";
4545
import { functionsDir } from "./utils/utils.js";
46+
import { LargeIndexDeletionCheck } from "./push.js";
4647

4748
export type CodegenOptions = {
4849
url?: string | undefined;
@@ -55,6 +56,7 @@ export type CodegenOptions = {
5556
liveComponentSources: boolean;
5657
debugNodeApis: boolean;
5758
systemUdfs: boolean;
59+
largeIndexDeletionCheck: LargeIndexDeletionCheck;
5860
codegenOnlyThisComponent?: string | undefined;
5961
};
6062

npm-packages/convex/src/cli/lib/components.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ import {
2121
waitForSchema,
2222
} from "./deploy2.js";
2323
import { version } from "../version.js";
24-
import { PushOptions, runNonComponentsPush } from "./push.js";
24+
import {
25+
LargeIndexDeletionCheck,
26+
PushOptions,
27+
runNonComponentsPush,
28+
} from "./push.js";
2529
import { ensureHasConvexDependency, functionsDir } from "./utils/utils.js";
2630
import {
2731
bundleDefinitions,
@@ -58,6 +62,7 @@ import {
5862
import { DeploymentSelection } from "./deploymentSelection.js";
5963
import { deploymentDashboardUrlPage } from "./dashboard.js";
6064
import { formatIndex } from "./indexes.js";
65+
import { checkForLargeIndexDeletion } from "./checkForLargeIndexDeletion.js";
6166

6267
async function findComponentRootPath(ctx: Context, functionsDir: string) {
6368
// Default to `.ts` but fallback to `.js` if not present.
@@ -182,6 +187,7 @@ async function startComponentsPushAndCodegen(
182187
codegen: boolean;
183188
liveComponentSources?: boolean;
184189
debugNodeApis: boolean;
190+
largeIndexDeletionCheck: LargeIndexDeletionCheck;
185191
codegenOnlyThisComponent?: string | undefined;
186192
},
187193
): Promise<StartPushResponse | null> {
@@ -380,6 +386,19 @@ async function startComponentsPushAndCodegen(
380386
}
381387
logStartPushSizes(parentSpan, startPushRequest);
382388

389+
if (options.largeIndexDeletionCheck !== "no verification") {
390+
await parentSpan.enterAsync("checkForLargeIndexDeletion", (span) =>
391+
checkForLargeIndexDeletion({
392+
ctx,
393+
span,
394+
request: startPushRequest,
395+
options,
396+
askForConfirmation:
397+
options.largeIndexDeletionCheck === "ask for confirmation",
398+
}),
399+
);
400+
}
401+
383402
changeSpinner("Uploading functions to Convex...");
384403
const startPushResponse = await parentSpan.enterAsync("startPush", (span) =>
385404
startPush(ctx, span, startPushRequest, options),

npm-packages/convex/src/cli/lib/deploy2.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
import { spawnSync } from "child_process";
1111
import { deploymentFetch, logAndHandleFetchError } from "./utils/utils.js";
1212
import {
13+
EvaluatePushResponse,
14+
evaluatePushResponse,
1315
schemaStatus,
1416
SchemaStatus,
1517
StartPushRequest,
@@ -61,6 +63,45 @@ export async function startPush(
6163
deploymentName: string | null;
6264
},
6365
): Promise<StartPushResponse> {
66+
const response = await pushCode(
67+
ctx,
68+
span,
69+
request,
70+
options,
71+
"/api/deploy2/start_push",
72+
);
73+
return startPushResponse.parse(response);
74+
}
75+
76+
export async function evaluatePush(
77+
ctx: Context,
78+
span: Span,
79+
request: StartPushRequest,
80+
options: {
81+
url: string;
82+
deploymentName: string | null;
83+
},
84+
): Promise<EvaluatePushResponse> {
85+
const response = await pushCode(
86+
ctx,
87+
span,
88+
request,
89+
options,
90+
"/api/deploy2/evaluate_push",
91+
);
92+
return evaluatePushResponse.parse(response);
93+
}
94+
95+
async function pushCode(
96+
ctx: Context,
97+
span: Span,
98+
request: StartPushRequest,
99+
options: {
100+
url: string;
101+
deploymentName: string | null;
102+
},
103+
endpoint: "/api/deploy2/start_push" | "/api/deploy2/evaluate_push",
104+
): Promise<unknown> {
64105
const custom = (_k: string | number, s: any) =>
65106
typeof s === "string" ? s.slice(0, 40) + (s.length > 40 ? "..." : "") : s;
66107
logVerbose(JSON.stringify(request, custom, 2));
@@ -74,9 +115,8 @@ export async function startPush(
74115
adminKey: request.adminKey,
75116
onError,
76117
});
77-
changeSpinner("Analyzing source code...");
78118
try {
79-
const response = await fetch("/api/deploy2/start_push", {
119+
const response = await fetch(endpoint, {
80120
body: await brotliCompress(ctx, JSON.stringify(request)),
81121
method: "POST",
82122
headers: {
@@ -85,7 +125,7 @@ export async function startPush(
85125
traceparent: span.encodeW3CTraceparent(),
86126
},
87127
});
88-
return startPushResponse.parse(await response.json());
128+
return await response.json();
89129
} catch (error: unknown) {
90130
return await handlePushConfigError(
91131
ctx,
@@ -333,6 +373,7 @@ export async function deployToDeployment(
333373
debug?: boolean | undefined;
334374
writePushRequest?: string | undefined;
335375
liveComponentSources?: boolean | undefined;
376+
allowDeletingLargeIndexes: boolean;
336377
},
337378
) {
338379
const { url, adminKey } = credentials;
@@ -352,6 +393,9 @@ export async function deployToDeployment(
352393
url,
353394
writePushRequest: options.writePushRequest,
354395
liveComponentSources: !!options.liveComponentSources,
396+
largeIndexDeletionCheck: options.allowDeletingLargeIndexes
397+
? "has confirmation"
398+
: "ask for confirmation",
355399
};
356400
showSpinner(`Deploying to ${url}...${options.dryRun ? " [dry run]" : ""}`);
357401
await runPush(ctx, pushOptions);

0 commit comments

Comments
 (0)