Skip to content

Commit d26d64a

Browse files
committed
[release] Add parallel functions deploy: pipelined uploads + concurrent build/activate, new deploy_functions MCP tool
1 parent d2c6b89 commit d26d64a

7 files changed

Lines changed: 898 additions & 122 deletions

File tree

packages/appwrite-utils-cli/src/cli/commands/databaseCommands.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { logger } from 'appwrite-utils-helpers';
1010
import { fetchAllDatabases } from "../../databases/methods.js";
1111
import { listBuckets } from "../../storage/methods.js";
1212
import { getFunction, downloadLatestFunctionDeployment } from "../../functions/methods.js";
13+
import { deployFunctionsBatch, type BatchDeployItem } from "../../functions/batchDeploy.js";
1314
import { wipeTableRows } from "../../collections/wipeOperations.js";
1415
import type { InteractiveCLI } from "../../interactiveCLI.js";
1516

@@ -216,12 +217,43 @@ export const databaseCommands = {
216217
true
217218
);
218219

220+
const controller = (cli as any).controller!;
221+
const allFunctions = controller.config?.functions || [];
222+
const items: BatchDeployItem[] = [];
219223
for (const func of functions) {
220-
try {
221-
await (cli as any).controller!.deployFunction(func.name);
222-
MessageFormatter.success(`Function ${func.name} deployed successfully`, { prefix: "Functions" });
223-
} catch (error) {
224-
MessageFormatter.error(`Failed to deploy function ${func.name}`, error instanceof Error ? error : new Error(String(error)), { prefix: "Functions" });
224+
const cfg = allFunctions.find(
225+
(f: any) => f?.$id === func.$id || f?.name === func.name
226+
);
227+
if (!cfg) {
228+
MessageFormatter.warning(
229+
`Function ${func.name} missing from loaded config; skipping.`,
230+
{ prefix: "Functions" }
231+
);
232+
continue;
233+
}
234+
items.push({
235+
functionName: cfg.name,
236+
functionConfig: cfg,
237+
configDirPath: controller.getAppwriteFolderPath?.() ?? controller.appwriteFolderPath,
238+
});
239+
}
240+
241+
if (items.length) {
242+
const results = await deployFunctionsBatch(
243+
controller.appwriteServer,
244+
items
245+
);
246+
const failed = results.filter((r) => r.status === "failed");
247+
if (failed.length) {
248+
MessageFormatter.warning(
249+
`${failed.length} of ${results.length} functions failed to deploy.`,
250+
{ prefix: "Functions" }
251+
);
252+
} else {
253+
MessageFormatter.success(
254+
`All ${results.length} selected functions deployed successfully.`,
255+
{ prefix: "Functions" }
256+
);
225257
}
226258
}
227259
}

packages/appwrite-utils-cli/src/cli/commands/functionCommands.ts

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
listSpecifications,
1515
} from "../../functions/methods.js";
1616
import { deployLocalFunction } from "../../functions/deployments.js";
17+
import { deployFunctionsBatch, type BatchDeployItem } from "../../functions/batchDeploy.js";
1718
import { discoverFnConfigs, mergeDiscoveredFunctions } from "../../functions/fnConfigDiscovery.js";
1819
import { addFunctionToYamlConfig, findYamlConfig } from "appwrite-utils-helpers";
1920
import { RuntimeSchema, type AppwriteFunction, type Runtime, type Specification } from "appwrite-utils";
@@ -160,6 +161,8 @@ export const functionCommands = {
160161
return;
161162
}
162163

164+
const batchItems: BatchDeployItem[] = [];
165+
163166
for (const functionConfig of functions) {
164167
if (!functionConfig) {
165168
MessageFormatter.error("Invalid function configuration", undefined, { prefix: "Functions" });
@@ -319,21 +322,37 @@ export const functionCommands = {
319322
return;
320323
}
321324

322-
try {
323-
await deployLocalFunction(
324-
(cli as any).controller.appwriteServer,
325-
effectiveConfig.name,
326-
{
327-
...effectiveConfig,
328-
dirPath: functionPath,
329-
},
330-
functionPath,
331-
yamlBaseDir
332-
);
333-
MessageFormatter.success("Function deployed successfully!", { prefix: "Functions" });
334-
} catch (error) {
335-
MessageFormatter.error("Failed to deploy function", error instanceof Error ? error : new Error(String(error)), { prefix: "Functions" });
336-
}
325+
batchItems.push({
326+
functionName: effectiveConfig.name,
327+
functionConfig: {
328+
...effectiveConfig,
329+
dirPath: functionPath,
330+
},
331+
functionPath,
332+
configDirPath: yamlBaseDir,
333+
});
334+
}
335+
336+
if (!batchItems.length) {
337+
MessageFormatter.warning("No deployable functions resolved.", { prefix: "Functions" });
338+
return;
339+
}
340+
341+
const results = await deployFunctionsBatch(
342+
(cli as any).controller.appwriteServer,
343+
batchItems
344+
);
345+
const failed = results.filter((r) => r.status === "failed");
346+
if (failed.length) {
347+
MessageFormatter.warning(
348+
`${failed.length} of ${results.length} functions failed to deploy.`,
349+
{ prefix: "Functions" }
350+
);
351+
} else {
352+
MessageFormatter.success(
353+
`All ${results.length} selected functions deployed successfully.`,
354+
{ prefix: "Functions" }
355+
);
337356
}
338357
},
339358

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import type { Client } from "node-appwrite";
2+
import pLimit from "p-limit";
3+
import { type AppwriteFunction } from "appwrite-utils";
4+
import { MessageFormatter } from "appwrite-utils-helpers";
5+
import {
6+
finalizeFunctionDeployment,
7+
prepareFunctionDeployment,
8+
uploadFunctionDeployment,
9+
} from "./deployments.js";
10+
import type { WaitForDeploymentOptions } from "./methods.js";
11+
12+
export interface BatchDeployItem {
13+
functionName: string;
14+
functionConfig: AppwriteFunction;
15+
functionPath?: string;
16+
configDirPath?: string;
17+
}
18+
19+
export interface BatchDeployResult {
20+
functionName: string;
21+
functionId: string;
22+
status: "ready" | "failed";
23+
deploymentId?: string;
24+
error?: Error;
25+
durationMs: number;
26+
}
27+
28+
export interface BatchDeployOptions {
29+
buildConcurrency?: number;
30+
pollOptions?: WaitForDeploymentOptions;
31+
}
32+
33+
/**
34+
* Pipelined multi-function deploy:
35+
* - Uploads sequentially (clean cli-progress bar UX, no createDeployment
36+
* rate-limit churn).
37+
* - As each upload completes, its wait+activate task is enqueued on a
38+
* pLimit(N) worker pool and starts running in parallel with the next
39+
* upload.
40+
* - At the end, all pending wait+activate tasks are awaited together via
41+
* Promise.allSettled so one bad build does not abort the rest.
42+
*
43+
* Returns a per-function result array suitable for both human-readable
44+
* summary logging and machine-readable MCP responses.
45+
*/
46+
export const deployFunctionsBatch = async (
47+
client: Client,
48+
items: BatchDeployItem[],
49+
options: BatchDeployOptions = {}
50+
): Promise<BatchDeployResult[]> => {
51+
if (items.length === 0) {
52+
return [];
53+
}
54+
55+
const buildConcurrency = options.buildConcurrency ?? 5;
56+
const limit = pLimit(buildConcurrency);
57+
const overallStart = Date.now();
58+
59+
type Pending = {
60+
functionName: string;
61+
functionId: string;
62+
startedAt: number;
63+
promise: Promise<BatchDeployResult>;
64+
};
65+
66+
const pending: Pending[] = [];
67+
// Upload failures we surface up-front; finalize failures come from settled.
68+
const earlyFailures: BatchDeployResult[] = [];
69+
70+
MessageFormatter.info(
71+
`Deploying ${items.length} function${items.length === 1 ? "" : "s"} (build concurrency: ${buildConcurrency})...`,
72+
{ prefix: "BatchDeploy" }
73+
);
74+
75+
for (const item of items) {
76+
const startedAt = Date.now();
77+
let prepared: Awaited<ReturnType<typeof prepareFunctionDeployment>>;
78+
let deploymentId: string;
79+
80+
try {
81+
MessageFormatter.progress(
82+
`[${item.functionName}] Preparing and uploading...`,
83+
{ prefix: "BatchDeploy" }
84+
);
85+
prepared = await prepareFunctionDeployment(
86+
client,
87+
item.functionName,
88+
item.functionConfig,
89+
item.functionPath,
90+
item.configDirPath
91+
);
92+
const uploaded = await uploadFunctionDeployment(
93+
client,
94+
prepared.functionId,
95+
prepared.deployPath,
96+
// Pass activate=true so Appwrite can auto-activate as soon as the
97+
// build finishes; finalizeFunctionDeployment also calls activate
98+
// explicitly afterwards to guarantee the final state.
99+
true,
100+
prepared.entrypoint,
101+
prepared.commands,
102+
prepared.ignored
103+
);
104+
deploymentId = uploaded.$id;
105+
MessageFormatter.success(
106+
`[${item.functionName}] Upload complete (deployment ${deploymentId}); build queued.`,
107+
{ prefix: "BatchDeploy" }
108+
);
109+
} catch (error) {
110+
const err = error instanceof Error ? error : new Error(String(error));
111+
MessageFormatter.error(
112+
`[${item.functionName}] Upload failed`,
113+
err,
114+
{ prefix: "BatchDeploy" }
115+
);
116+
earlyFailures.push({
117+
functionName: item.functionName,
118+
functionId: item.functionConfig.$id,
119+
status: "failed",
120+
error: err,
121+
durationMs: Date.now() - startedAt,
122+
});
123+
continue;
124+
}
125+
126+
const functionName = item.functionName;
127+
const functionId = prepared.functionId;
128+
const taskStartedAt = startedAt;
129+
130+
const promise = limit(async (): Promise<BatchDeployResult> => {
131+
try {
132+
const ready = await finalizeFunctionDeployment(
133+
client,
134+
functionId,
135+
deploymentId,
136+
options.pollOptions
137+
);
138+
return {
139+
functionName,
140+
functionId,
141+
status: "ready",
142+
deploymentId: ready.$id,
143+
durationMs: Date.now() - taskStartedAt,
144+
};
145+
} catch (error) {
146+
const err = error instanceof Error ? error : new Error(String(error));
147+
MessageFormatter.error(
148+
`[${functionName}] Build/activation failed`,
149+
err,
150+
{ prefix: "BatchDeploy" }
151+
);
152+
return {
153+
functionName,
154+
functionId,
155+
status: "failed",
156+
deploymentId,
157+
error: err,
158+
durationMs: Date.now() - taskStartedAt,
159+
};
160+
}
161+
});
162+
163+
pending.push({ functionName, functionId, startedAt: taskStartedAt, promise });
164+
}
165+
166+
// All uploads done; await the finalize tasks. Use allSettled even though
167+
// the inner promises swallow errors, so we never bubble an unexpected
168+
// rejection.
169+
const settled = await Promise.allSettled(pending.map((p) => p.promise));
170+
171+
const finalizeResults: BatchDeployResult[] = settled.map((s, idx) => {
172+
const slot = pending[idx];
173+
if (s.status === "fulfilled") {
174+
return s.value;
175+
}
176+
const err = s.reason instanceof Error ? s.reason : new Error(String(s.reason));
177+
return {
178+
functionName: slot.functionName,
179+
functionId: slot.functionId,
180+
status: "failed",
181+
error: err,
182+
durationMs: Date.now() - slot.startedAt,
183+
};
184+
});
185+
186+
const results: BatchDeployResult[] = [...earlyFailures, ...finalizeResults];
187+
188+
const readyCount = results.filter((r) => r.status === "ready").length;
189+
const failedCount = results.length - readyCount;
190+
const totalMs = Date.now() - overallStart;
191+
192+
MessageFormatter.info(
193+
`Batch deploy summary: ${readyCount} ready, ${failedCount} failed in ${(totalMs / 1000).toFixed(1)}s`,
194+
{ prefix: "BatchDeploy" }
195+
);
196+
197+
for (const r of results) {
198+
if (r.status === "ready") {
199+
MessageFormatter.success(
200+
` ✔ ${r.functionName} (${(r.durationMs / 1000).toFixed(1)}s) → ${r.deploymentId}`,
201+
{ prefix: "BatchDeploy" }
202+
);
203+
} else {
204+
const msg = r.error?.message ?? "Unknown error";
205+
const tail = msg.length > 500 ? msg.slice(-500) : msg;
206+
MessageFormatter.error(
207+
` ✘ ${r.functionName} (${(r.durationMs / 1000).toFixed(1)}s) — ${tail}`,
208+
undefined,
209+
{ prefix: "BatchDeploy" }
210+
);
211+
}
212+
}
213+
214+
return results;
215+
};

0 commit comments

Comments
 (0)