Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Split function deployments by region AND memory #4253

Merged
merged 6 commits into from
Mar 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
@@ -0,0 +1 @@
- Fixes bug where functions' memory configurations weren't preserved in batched function deploys (#4253).
10 changes: 4 additions & 6 deletions src/deploy/functions/release/fabricator.ts
Expand Up @@ -79,12 +79,12 @@ export class Fabricator {
totalTime: 0,
results: [],
};
const deployRegions = Object.values(plan).map(async (changes): Promise<void> => {
const results = await this.applyRegionalChanges(changes);
const deployChangesets = Object.values(plan).map(async (changes): Promise<void> => {
const results = await this.applyChangeset(changes);
summary.results.push(...results);
return;
});
const promiseResults = await utils.allSettled(deployRegions);
const promiseResults = await utils.allSettled(deployChangesets);

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

async applyRegionalChanges(
changes: planner.RegionalChanges
): Promise<Array<reporter.DeployResult>> {
async applyChangeset(changes: planner.Changeset): Promise<Array<reporter.DeployResult>> {
const deployResults: reporter.DeployResult[] = [];
const handle = async (
op: reporter.OperationType,
Expand Down
66 changes: 46 additions & 20 deletions src/deploy/functions/release/planner.ts
Expand Up @@ -10,39 +10,63 @@ export interface EndpointUpdate {
deleteAndRecreate?: backend.Endpoint;
}

export interface RegionalChanges {
export interface Changeset {
endpointsToCreate: backend.Endpoint[];
endpointsToUpdate: EndpointUpdate[];
endpointsToDelete: backend.Endpoint[];
}

export type DeploymentPlan = Record<string, RegionalChanges>;
export type DeploymentPlan = Record<string, Changeset>;

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

/** Calculate the changes needed for a given region. */
export function calculateRegionalChanges(
/** Calculate the changesets of given endpoints by grouping endpoints with keyFn. */
export function calculateChangesets(
want: Record<string, backend.Endpoint>,
have: Record<string, backend.Endpoint>,
keyFn: (e: backend.Endpoint) => string,
taeold marked this conversation as resolved.
Show resolved Hide resolved
options: Options
): RegionalChanges {
const endpointsToCreate = Object.keys(want)
.filter((id) => !have[id])
.map((id) => want[id]);

const endpointsToDelete = Object.keys(have)
.filter((id) => !want[id])
.filter((id) => options.deleteAll || isFirebaseManaged(have[id].labels || {}))
.map((id) => have[id]);

const endpointsToUpdate = Object.keys(want)
.filter((id) => have[id])
.map((id) => calculateUpdate(want[id], have[id]));
return { endpointsToCreate, endpointsToUpdate, endpointsToDelete };
): Record<string, Changeset> {
const toCreate = utils.groupBy(
Object.keys(want)
.filter((id) => !have[id])
.map((id) => want[id]),
keyFn
);

const toDelete = utils.groupBy(
Object.keys(have)
.filter((id) => !want[id])
.filter((id) => options.deleteAll || isFirebaseManaged(have[id].labels || {}))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem like new behavior, but it looks like options.deleteAll deletes every function on the project, not just Firebase managed functions. Is that intentional?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this option is only used in functions:delete where you can delete arbitrary function as long as you specify it's name correctly. I think that might be useful, though we might want to revisit that API.

.map((id) => have[id]),
keyFn
);

const toUpdate = utils.groupBy(
Object.keys(want)
.filter((id) => have[id])
.map((id) => calculateUpdate(want[id], have[id])),
(eu: EndpointUpdate) => keyFn(eu.endpoint)
);

const result: Record<string, Changeset> = {};
const keys = new Set([
...Object.keys(toCreate),
...Object.keys(toDelete),
...Object.keys(toUpdate),
]);
for (const key of keys) {
result[key] = {
endpointsToCreate: toCreate[key] || [],
endpointsToUpdate: toUpdate[key] || [],
endpointsToDelete: toDelete[key] || [],
};
}
return result;
}

/**
Expand Down Expand Up @@ -78,7 +102,7 @@ export function createDeploymentPlan(
have: backend.Backend,
options: Options = {}
): DeploymentPlan {
const deployment: DeploymentPlan = {};
let deployment: DeploymentPlan = {};
want = backend.matchingBackend(want, (endpoint) => {
return functionMatchesAnyGroup(endpoint, options.filters || []);
});
Expand All @@ -88,11 +112,13 @@ export function createDeploymentPlan(

const regions = new Set([...Object.keys(want.endpoints), ...Object.keys(have.endpoints)]);
for (const region of regions) {
deployment[region] = calculateRegionalChanges(
const changesets = calculateChangesets(
want.endpoints[region] || {},
have.endpoints[region] || {},
(e) => `${e.region}-${e.availableMemoryMb || "default"}`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we actually do heap tuning for all memory sizes. It might be worth talking to the cloud folks to see if we can merge deployments of <2GB

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked in and discovered that the environment variable is set per memory configuration:

https://github.com/GoogleCloudPlatform/buildpacks/blob/663180f2cf1fbfdee4a889f6c9d17ee2eb9091bf/cmd/nodejs/functions_framework/main.go#L166

Seems like they took the simplest path forward.

options
);
deployment = { ...deployment, ...changesets };
}

if (upgradedToGCFv2WithoutSettingConcurrency(want, have)) {
Expand Down
16 changes: 8 additions & 8 deletions src/test/deploy/functions/release/fabricator.spec.ts
Expand Up @@ -988,7 +988,7 @@ describe("Fabricator", () => {
const ep1 = endpoint({ httpsTrigger: {} }, { id: "A" });
const ep2 = endpoint({ httpsTrigger: {} }, { id: "B" });
const ep3 = endpoint({ httpsTrigger: {} }, { id: "C" });
const changes: planner.RegionalChanges = {
const changes: planner.Changeset = {
endpointsToCreate: [ep1, ep2],
endpointsToUpdate: [{ endpoint: ep3 }],
endpointsToDelete: [],
Expand All @@ -1014,19 +1014,19 @@ describe("Fabricator", () => {
const updateEndpoint = sinon.stub(fab, "updateEndpoint");
updateEndpoint.callsFake(fakeUpsert);

await fab.applyRegionalChanges(changes);
await fab.applyChangeset(changes);
});

it("handles errors and wraps them in results", async () => {
// when it hits a real API it will fail.
const ep = endpoint();
const changes: planner.RegionalChanges = {
const changes: planner.Changeset = {
endpointsToCreate: [ep],
endpointsToUpdate: [],
endpointsToDelete: [],
};

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

const results = await fab.applyRegionalChanges(changes);
const results = await fab.applyChangeset(changes);
const result = results.find((r) => r.endpoint.id === deleteEP.id);
expect(result?.error).to.be.instanceOf(reporter.AbortedDeploymentError);
expect(result?.durationMs).to.equal(0);
Expand All @@ -1053,7 +1053,7 @@ describe("Fabricator", () => {
const updateEP = endpoint({ httpsTrigger: {} }, { id: "B" });
const deleteEP = endpoint({ httpsTrigger: {} }, { id: "C" });
const update: planner.EndpointUpdate = { endpoint: updateEP };
const changes: planner.RegionalChanges = {
const changes: planner.Changeset = {
endpointsToCreate: [createEP],
endpointsToUpdate: [update],
endpointsToDelete: [deleteEP],
Expand All @@ -1066,7 +1066,7 @@ describe("Fabricator", () => {
const deleteEndpoint = sinon.stub(fab, "deleteEndpoint");
deleteEndpoint.resolves();

const results = await fab.applyRegionalChanges(changes);
const results = await fab.applyChangeset(changes);
expect(createEndpoint).to.have.been.calledWithMatch(createEP);
expect(updateEndpoint).to.have.been.calledWithMatch(update);
expect(deleteEndpoint).to.have.been.calledWith(deleteEP);
Expand Down
86 changes: 69 additions & 17 deletions src/test/deploy/functions/release/planner.spec.ts
Expand Up @@ -150,14 +150,16 @@ describe("planner", () => {
const have = { updated, deleted, pantheon };

// note: pantheon is not updated in any way
expect(planner.calculateRegionalChanges(want, have, {})).to.deep.equal({
endpointsToCreate: [created],
endpointsToUpdate: [
{
endpoint: updated,
},
],
endpointsToDelete: [deleted],
expect(planner.calculateChangesets(want, have, (e) => e.region, {})).to.deep.equal({
region: {
endpointsToCreate: [created],
endpointsToUpdate: [
{
endpoint: updated,
},
],
endpointsToDelete: [deleted],
},
});
});

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

// note: pantheon is deleted because we have deleteAll: true
expect(planner.calculateRegionalChanges(want, have, { deleteAll: true })).to.deep.equal({
endpointsToCreate: [created],
endpointsToUpdate: [
{
endpoint: updated,
},
],
endpointsToDelete: [deleted, pantheon],
expect(
planner.calculateChangesets(want, have, (e) => e.region, { deleteAll: true })
).to.deep.equal({
region: {
endpointsToCreate: [created],
endpointsToUpdate: [
{
endpoint: updated,
},
],
endpointsToDelete: [deleted, pantheon],
},
});
});
});

describe("createDeploymentPlan", () => {
it("groups deployment by region and memory", () => {
const region1mem1Created: backend.Endpoint = func("id1", "region1");
const region1mem1Updated: backend.Endpoint = func("id2", "region1");

const region2mem1Created: backend.Endpoint = func("id3", "region2");
const region2mem2Updated: backend.Endpoint = func("id4", "region2");
region2mem2Updated.availableMemoryMb = 512;
const region2mem2Deleted: backend.Endpoint = func("id5", "region2");
region2mem2Deleted.availableMemoryMb = 512;
region2mem2Deleted.labels = deploymentTool.labels();

const have = backend.of(region1mem1Updated, region2mem2Updated, region2mem2Deleted);
const want = backend.of(
region1mem1Created,
region1mem1Updated,
region2mem1Created,
region2mem2Updated
);

expect(planner.createDeploymentPlan(want, have, {})).to.deep.equal({
"region1-default": {
endpointsToCreate: [region1mem1Created],
endpointsToUpdate: [
{
endpoint: region1mem1Updated,
},
],
endpointsToDelete: [],
},
"region2-default": {
endpointsToCreate: [region2mem1Created],
endpointsToUpdate: [],
endpointsToDelete: [],
},
"region2-512": {
endpointsToCreate: [],
endpointsToUpdate: [
{
endpoint: region2mem2Updated,
},
],
endpointsToDelete: [region2mem2Deleted],
},
});
});

it("applies filters", () => {
const group1Created = func("g1-created", "region");
const group1Updated = func("g1-updated", "region");
Expand All @@ -201,7 +253,7 @@ describe("planner", () => {
const have = backend.of(group1Updated, group1Deleted, group2Updated, group2Deleted);

expect(planner.createDeploymentPlan(want, have, { filters: [["g1"]] })).to.deep.equal({
region: {
"region-default": {
endpointsToCreate: [group1Created],
endpointsToUpdate: [
{
Expand Down
33 changes: 33 additions & 0 deletions src/test/utils.spec.ts
Expand Up @@ -297,4 +297,37 @@ describe("utils", () => {
]);
});
});

describe("groupBy", () => {
it("should transform simple array by fn", () => {
const arr = [3.4, 1.2, 7.7, 2, 3.9];
expect(utils.groupBy(arr, Math.floor)).to.deep.equal({
1: [1.2],
2: [2],
3: [3.4, 3.9],
7: [7.7],
});
});

it("should transform array of objects by fn", () => {
const arr = [
{ id: 1, location: "us" },
{ id: 2, location: "us" },
{ id: 3, location: "asia" },
{ id: 4, location: "europe" },
{ id: 5, location: "asia" },
];
expect(utils.groupBy(arr, (obj) => obj.location)).to.deep.equal({
us: [
{ id: 1, location: "us" },
{ id: 2, location: "us" },
],
asia: [
{ id: 3, location: "asia" },
{ id: 5, location: "asia" },
],
europe: [{ id: 4, location: "europe" }],
});
});
});
});
18 changes: 18 additions & 0 deletions src/utils.ts
Expand Up @@ -620,3 +620,21 @@ export function assertIsStringOrUndefined(
});
}
}

/**
* Polyfill for groupBy.
*/
export function groupBy<T, K extends string | number | symbol>(
arr: T[],
f: (item: T) => K
): Record<K, T[]> {
return arr.reduce((result, item) => {
const key = f(item);
if (result[key]) {
result[key].push(item);
} else {
result[key] = [item];
}
return result;
}, {} as Record<K, T[]>);
}