Skip to content

Commit 94b3156

Browse files
authored
Split function deployments by region AND memory (#4253)
Today, Function deployments are made in batches of functions grouped by region. Combined w/ source token to re-used built containers for deployment, we get meaningful decrease in deploy time and in Cloud Build resource usage. Unfortunately, this setup has a peculiar bug; Google Cloud Functions bakes in the flag to expand heap size for appropriate for the memory configuration of a function (`--max_old_space_size`) on the container itself. That means that if you batch two functions with differing memory configuration (e.g. 256MB vs 4 GB), it's guaranteed that one function will have wrongly configured `--max_old_space_size` flag value (Follow #3716 (comment) for how we discovered this issue). This PR proposes batching function by region AND `availalbeMemoryMB` to fix this bug. We do this by refactoring `calculateRegionalChanges` to generate a collection of `Changeset` (previously known as `RegionalChanges`) that sub-divides functions in a region by their memory. In fact, we generalize the approach by allowing arbitrary subdivision by `keyFn` (e.g. `keyFn: (endpoint) => endpoint.availableMemoryMb`) as I anticipate that we will revisit this section of the code once I start working on "codebases". Fixes #3716
1 parent 524f98d commit 94b3156

File tree

7 files changed

+179
-51
lines changed

7 files changed

+179
-51
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Fixes bug where functions' memory configurations weren't preserved in batched function deploys (#4253).

src/deploy/functions/release/fabricator.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,12 @@ export class Fabricator {
7979
totalTime: 0,
8080
results: [],
8181
};
82-
const deployRegions = Object.values(plan).map(async (changes): Promise<void> => {
83-
const results = await this.applyRegionalChanges(changes);
82+
const deployChangesets = Object.values(plan).map(async (changes): Promise<void> => {
83+
const results = await this.applyChangeset(changes);
8484
summary.results.push(...results);
8585
return;
8686
});
87-
const promiseResults = await utils.allSettled(deployRegions);
87+
const promiseResults = await utils.allSettled(deployChangesets);
8888

8989
const errs = promiseResults
9090
.filter((r) => r.status === "rejected")
@@ -100,9 +100,7 @@ export class Fabricator {
100100
return summary;
101101
}
102102

103-
async applyRegionalChanges(
104-
changes: planner.RegionalChanges
105-
): Promise<Array<reporter.DeployResult>> {
103+
async applyChangeset(changes: planner.Changeset): Promise<Array<reporter.DeployResult>> {
106104
const deployResults: reporter.DeployResult[] = [];
107105
const handle = async (
108106
op: reporter.OperationType,

src/deploy/functions/release/planner.ts

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,39 +10,63 @@ export interface EndpointUpdate {
1010
deleteAndRecreate?: backend.Endpoint;
1111
}
1212

13-
export interface RegionalChanges {
13+
export interface Changeset {
1414
endpointsToCreate: backend.Endpoint[];
1515
endpointsToUpdate: EndpointUpdate[];
1616
endpointsToDelete: backend.Endpoint[];
1717
}
1818

19-
export type DeploymentPlan = Record<string, RegionalChanges>;
19+
export type DeploymentPlan = Record<string, Changeset>;
2020

2121
export interface Options {
2222
filters?: string[][];
2323
// If set to false, will delete only functions that are managed by firebase
2424
deleteAll?: boolean;
2525
}
2626

27-
/** Calculate the changes needed for a given region. */
28-
export function calculateRegionalChanges(
27+
/** Calculate the changesets of given endpoints by grouping endpoints with keyFn. */
28+
export function calculateChangesets(
2929
want: Record<string, backend.Endpoint>,
3030
have: Record<string, backend.Endpoint>,
31+
keyFn: (e: backend.Endpoint) => string,
3132
options: Options
32-
): RegionalChanges {
33-
const endpointsToCreate = Object.keys(want)
34-
.filter((id) => !have[id])
35-
.map((id) => want[id]);
36-
37-
const endpointsToDelete = Object.keys(have)
38-
.filter((id) => !want[id])
39-
.filter((id) => options.deleteAll || isFirebaseManaged(have[id].labels || {}))
40-
.map((id) => have[id]);
41-
42-
const endpointsToUpdate = Object.keys(want)
43-
.filter((id) => have[id])
44-
.map((id) => calculateUpdate(want[id], have[id]));
45-
return { endpointsToCreate, endpointsToUpdate, endpointsToDelete };
33+
): Record<string, Changeset> {
34+
const toCreate = utils.groupBy(
35+
Object.keys(want)
36+
.filter((id) => !have[id])
37+
.map((id) => want[id]),
38+
keyFn
39+
);
40+
41+
const toDelete = utils.groupBy(
42+
Object.keys(have)
43+
.filter((id) => !want[id])
44+
.filter((id) => options.deleteAll || isFirebaseManaged(have[id].labels || {}))
45+
.map((id) => have[id]),
46+
keyFn
47+
);
48+
49+
const toUpdate = utils.groupBy(
50+
Object.keys(want)
51+
.filter((id) => have[id])
52+
.map((id) => calculateUpdate(want[id], have[id])),
53+
(eu: EndpointUpdate) => keyFn(eu.endpoint)
54+
);
55+
56+
const result: Record<string, Changeset> = {};
57+
const keys = new Set([
58+
...Object.keys(toCreate),
59+
...Object.keys(toDelete),
60+
...Object.keys(toUpdate),
61+
]);
62+
for (const key of keys) {
63+
result[key] = {
64+
endpointsToCreate: toCreate[key] || [],
65+
endpointsToUpdate: toUpdate[key] || [],
66+
endpointsToDelete: toDelete[key] || [],
67+
};
68+
}
69+
return result;
4670
}
4771

4872
/**
@@ -78,7 +102,7 @@ export function createDeploymentPlan(
78102
have: backend.Backend,
79103
options: Options = {}
80104
): DeploymentPlan {
81-
const deployment: DeploymentPlan = {};
105+
let deployment: DeploymentPlan = {};
82106
want = backend.matchingBackend(want, (endpoint) => {
83107
return functionMatchesAnyGroup(endpoint, options.filters || []);
84108
});
@@ -88,11 +112,13 @@ export function createDeploymentPlan(
88112

89113
const regions = new Set([...Object.keys(want.endpoints), ...Object.keys(have.endpoints)]);
90114
for (const region of regions) {
91-
deployment[region] = calculateRegionalChanges(
115+
const changesets = calculateChangesets(
92116
want.endpoints[region] || {},
93117
have.endpoints[region] || {},
118+
(e) => `${e.region}-${e.availableMemoryMb || "default"}`,
94119
options
95120
);
121+
deployment = { ...deployment, ...changesets };
96122
}
97123

98124
if (upgradedToGCFv2WithoutSettingConcurrency(want, have)) {

src/test/deploy/functions/release/fabricator.spec.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -988,7 +988,7 @@ describe("Fabricator", () => {
988988
const ep1 = endpoint({ httpsTrigger: {} }, { id: "A" });
989989
const ep2 = endpoint({ httpsTrigger: {} }, { id: "B" });
990990
const ep3 = endpoint({ httpsTrigger: {} }, { id: "C" });
991-
const changes: planner.RegionalChanges = {
991+
const changes: planner.Changeset = {
992992
endpointsToCreate: [ep1, ep2],
993993
endpointsToUpdate: [{ endpoint: ep3 }],
994994
endpointsToDelete: [],
@@ -1014,19 +1014,19 @@ describe("Fabricator", () => {
10141014
const updateEndpoint = sinon.stub(fab, "updateEndpoint");
10151015
updateEndpoint.callsFake(fakeUpsert);
10161016

1017-
await fab.applyRegionalChanges(changes);
1017+
await fab.applyChangeset(changes);
10181018
});
10191019

10201020
it("handles errors and wraps them in results", async () => {
10211021
// when it hits a real API it will fail.
10221022
const ep = endpoint();
1023-
const changes: planner.RegionalChanges = {
1023+
const changes: planner.Changeset = {
10241024
endpointsToCreate: [ep],
10251025
endpointsToUpdate: [],
10261026
endpointsToDelete: [],
10271027
};
10281028

1029-
const results = await fab.applyRegionalChanges(changes);
1029+
const results = await fab.applyChangeset(changes);
10301030
expect(results[0].error).to.be.instanceOf(reporter.DeploymentError);
10311031
expect(results[0].error?.message).to.match(/create function/);
10321032
});
@@ -1036,13 +1036,13 @@ describe("Fabricator", () => {
10361036
// when it hits a real API it will fail.
10371037
const createEP = endpoint({ httpsTrigger: {} }, { id: "A" });
10381038
const deleteEP = endpoint({ httpsTrigger: {} }, { id: "B" });
1039-
const changes: planner.RegionalChanges = {
1039+
const changes: planner.Changeset = {
10401040
endpointsToCreate: [createEP],
10411041
endpointsToUpdate: [],
10421042
endpointsToDelete: [deleteEP],
10431043
};
10441044

1045-
const results = await fab.applyRegionalChanges(changes);
1045+
const results = await fab.applyChangeset(changes);
10461046
const result = results.find((r) => r.endpoint.id === deleteEP.id);
10471047
expect(result?.error).to.be.instanceOf(reporter.AbortedDeploymentError);
10481048
expect(result?.durationMs).to.equal(0);
@@ -1053,7 +1053,7 @@ describe("Fabricator", () => {
10531053
const updateEP = endpoint({ httpsTrigger: {} }, { id: "B" });
10541054
const deleteEP = endpoint({ httpsTrigger: {} }, { id: "C" });
10551055
const update: planner.EndpointUpdate = { endpoint: updateEP };
1056-
const changes: planner.RegionalChanges = {
1056+
const changes: planner.Changeset = {
10571057
endpointsToCreate: [createEP],
10581058
endpointsToUpdate: [update],
10591059
endpointsToDelete: [deleteEP],
@@ -1066,7 +1066,7 @@ describe("Fabricator", () => {
10661066
const deleteEndpoint = sinon.stub(fab, "deleteEndpoint");
10671067
deleteEndpoint.resolves();
10681068

1069-
const results = await fab.applyRegionalChanges(changes);
1069+
const results = await fab.applyChangeset(changes);
10701070
expect(createEndpoint).to.have.been.calledWithMatch(createEP);
10711071
expect(updateEndpoint).to.have.been.calledWithMatch(update);
10721072
expect(deleteEndpoint).to.have.been.calledWith(deleteEP);

src/test/deploy/functions/release/planner.spec.ts

Lines changed: 69 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -150,14 +150,16 @@ describe("planner", () => {
150150
const have = { updated, deleted, pantheon };
151151

152152
// note: pantheon is not updated in any way
153-
expect(planner.calculateRegionalChanges(want, have, {})).to.deep.equal({
154-
endpointsToCreate: [created],
155-
endpointsToUpdate: [
156-
{
157-
endpoint: updated,
158-
},
159-
],
160-
endpointsToDelete: [deleted],
153+
expect(planner.calculateChangesets(want, have, (e) => e.region, {})).to.deep.equal({
154+
region: {
155+
endpointsToCreate: [created],
156+
endpointsToUpdate: [
157+
{
158+
endpoint: updated,
159+
},
160+
],
161+
endpointsToDelete: [deleted],
162+
},
161163
});
162164
});
163165

@@ -172,19 +174,69 @@ describe("planner", () => {
172174
const have = { updated, deleted, pantheon };
173175

174176
// note: pantheon is deleted because we have deleteAll: true
175-
expect(planner.calculateRegionalChanges(want, have, { deleteAll: true })).to.deep.equal({
176-
endpointsToCreate: [created],
177-
endpointsToUpdate: [
178-
{
179-
endpoint: updated,
180-
},
181-
],
182-
endpointsToDelete: [deleted, pantheon],
177+
expect(
178+
planner.calculateChangesets(want, have, (e) => e.region, { deleteAll: true })
179+
).to.deep.equal({
180+
region: {
181+
endpointsToCreate: [created],
182+
endpointsToUpdate: [
183+
{
184+
endpoint: updated,
185+
},
186+
],
187+
endpointsToDelete: [deleted, pantheon],
188+
},
183189
});
184190
});
185191
});
186192

187193
describe("createDeploymentPlan", () => {
194+
it("groups deployment by region and memory", () => {
195+
const region1mem1Created: backend.Endpoint = func("id1", "region1");
196+
const region1mem1Updated: backend.Endpoint = func("id2", "region1");
197+
198+
const region2mem1Created: backend.Endpoint = func("id3", "region2");
199+
const region2mem2Updated: backend.Endpoint = func("id4", "region2");
200+
region2mem2Updated.availableMemoryMb = 512;
201+
const region2mem2Deleted: backend.Endpoint = func("id5", "region2");
202+
region2mem2Deleted.availableMemoryMb = 512;
203+
region2mem2Deleted.labels = deploymentTool.labels();
204+
205+
const have = backend.of(region1mem1Updated, region2mem2Updated, region2mem2Deleted);
206+
const want = backend.of(
207+
region1mem1Created,
208+
region1mem1Updated,
209+
region2mem1Created,
210+
region2mem2Updated
211+
);
212+
213+
expect(planner.createDeploymentPlan(want, have, {})).to.deep.equal({
214+
"region1-default": {
215+
endpointsToCreate: [region1mem1Created],
216+
endpointsToUpdate: [
217+
{
218+
endpoint: region1mem1Updated,
219+
},
220+
],
221+
endpointsToDelete: [],
222+
},
223+
"region2-default": {
224+
endpointsToCreate: [region2mem1Created],
225+
endpointsToUpdate: [],
226+
endpointsToDelete: [],
227+
},
228+
"region2-512": {
229+
endpointsToCreate: [],
230+
endpointsToUpdate: [
231+
{
232+
endpoint: region2mem2Updated,
233+
},
234+
],
235+
endpointsToDelete: [region2mem2Deleted],
236+
},
237+
});
238+
});
239+
188240
it("applies filters", () => {
189241
const group1Created = func("g1-created", "region");
190242
const group1Updated = func("g1-updated", "region");
@@ -201,7 +253,7 @@ describe("planner", () => {
201253
const have = backend.of(group1Updated, group1Deleted, group2Updated, group2Deleted);
202254

203255
expect(planner.createDeploymentPlan(want, have, { filters: [["g1"]] })).to.deep.equal({
204-
region: {
256+
"region-default": {
205257
endpointsToCreate: [group1Created],
206258
endpointsToUpdate: [
207259
{

src/test/utils.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,4 +297,37 @@ describe("utils", () => {
297297
]);
298298
});
299299
});
300+
301+
describe("groupBy", () => {
302+
it("should transform simple array by fn", () => {
303+
const arr = [3.4, 1.2, 7.7, 2, 3.9];
304+
expect(utils.groupBy(arr, Math.floor)).to.deep.equal({
305+
1: [1.2],
306+
2: [2],
307+
3: [3.4, 3.9],
308+
7: [7.7],
309+
});
310+
});
311+
312+
it("should transform array of objects by fn", () => {
313+
const arr = [
314+
{ id: 1, location: "us" },
315+
{ id: 2, location: "us" },
316+
{ id: 3, location: "asia" },
317+
{ id: 4, location: "europe" },
318+
{ id: 5, location: "asia" },
319+
];
320+
expect(utils.groupBy(arr, (obj) => obj.location)).to.deep.equal({
321+
us: [
322+
{ id: 1, location: "us" },
323+
{ id: 2, location: "us" },
324+
],
325+
asia: [
326+
{ id: 3, location: "asia" },
327+
{ id: 5, location: "asia" },
328+
],
329+
europe: [{ id: 4, location: "europe" }],
330+
});
331+
});
332+
});
300333
});

src/utils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,3 +620,21 @@ export function assertIsStringOrUndefined(
620620
});
621621
}
622622
}
623+
624+
/**
625+
* Polyfill for groupBy.
626+
*/
627+
export function groupBy<T, K extends string | number | symbol>(
628+
arr: T[],
629+
f: (item: T) => K
630+
): Record<K, T[]> {
631+
return arr.reduce((result, item) => {
632+
const key = f(item);
633+
if (result[key]) {
634+
result[key].push(item);
635+
} else {
636+
result[key] = [item];
637+
}
638+
return result;
639+
}, {} as Record<K, T[]>);
640+
}

0 commit comments

Comments
 (0)