Skip to content

Commit 0001855

Browse files
authored
feat: sync scheduled function timeout with attempt deadline (#9544)
Rollback #9464 Firebase CLI will now synchronizes Cloud Scheduler's attemptDeadline with the function's timeoutSeconds for v2 scheduled functions, capped at 1800 seconds. This is a change from original attempt where we tried to expose a new configuration for scheduled function that allowed users to directly set the attemptDeadline. We don't know why they would ever drift, so we'll simplify make sure the Scheduler's timeout is in sync with functions timeout. Note that Cloud Scheduler has an attempt deadline range of [15s, 1800s], and defaults to 180s. We floor at 180s to be safe, even if the function timeout is shorter. This is because GCF/Cloud Run will already terminate the function at its configured timeout, so Cloud Scheduler won't actually wait the full 180s unless GCF itself fails to respond. Setting it shorter than 180s might cause premature retries due to network latency.
1 parent fa737e4 commit 0001855

File tree

10 files changed

+117
-110
lines changed

10 files changed

+117
-110
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
- Fixed issue where MCP server didn't detect if iOS app uses Crashlytics in projects that use `project.pbxproj` (#9515)
2+
- Add logic to synchronize v2 scheduled function timeout with Cloud Schduler's attempt deadline (#9544)

src/deploy/functions/backend.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ export interface ScheduleTrigger {
2626
schedule?: string;
2727
timeZone?: string | null;
2828
retryConfig?: ScheduleRetryConfig | null;
29-
attemptDeadlineSeconds?: number | null;
3029
}
3130

3231
/** Something that has a ScheduleTrigger */
@@ -187,16 +186,6 @@ export function isValidEgressSetting(egress: unknown): egress is VpcEgressSettin
187186
return egress === "PRIVATE_RANGES_ONLY" || egress === "ALL_TRAFFIC";
188187
}
189188

190-
export const MIN_ATTEMPT_DEADLINE_SECONDS = 15;
191-
export const MAX_ATTEMPT_DEADLINE_SECONDS = 1800; // 30 mins
192-
193-
/**
194-
* Is a given number a valid attempt deadline?
195-
*/
196-
export function isValidAttemptDeadline(seconds: number): boolean {
197-
return seconds >= MIN_ATTEMPT_DEADLINE_SECONDS && seconds <= MAX_ATTEMPT_DEADLINE_SECONDS;
198-
}
199-
200189
/** Returns a human-readable name with MB or GB suffix for a MemoryOption (MB). */
201190
export function memoryOptionDisplayName(option: MemoryOptions): string {
202191
return {

src/deploy/functions/build.spec.ts

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -224,44 +224,6 @@ describe("toBackend", () => {
224224
expect(endpointDef.func.serviceAccount).to.equal("service-account-1@");
225225
}
226226
});
227-
228-
it("throws if attemptDeadlineSeconds is out of range", () => {
229-
const desiredBuild: build.Build = build.of({
230-
func: {
231-
platform: "gcfv2",
232-
region: ["us-central1"],
233-
project: "project",
234-
runtime: "nodejs16",
235-
entryPoint: "func",
236-
scheduleTrigger: {
237-
schedule: "every 1 minutes",
238-
attemptDeadlineSeconds: 10, // Invalid: < 15
239-
},
240-
},
241-
});
242-
expect(() => build.toBackend(desiredBuild, {})).to.throw(
243-
FirebaseError,
244-
/attemptDeadlineSeconds must be between 15 and 1800 seconds/,
245-
);
246-
247-
const desiredBuild2: build.Build = build.of({
248-
func: {
249-
platform: "gcfv2",
250-
region: ["us-central1"],
251-
project: "project",
252-
runtime: "nodejs16",
253-
entryPoint: "func",
254-
scheduleTrigger: {
255-
schedule: "every 1 minutes",
256-
attemptDeadlineSeconds: 1801, // Invalid: > 1800
257-
},
258-
},
259-
});
260-
expect(() => build.toBackend(desiredBuild2, {})).to.throw(
261-
FirebaseError,
262-
/attemptDeadlineSeconds must be between 15 and 1800 seconds/,
263-
);
264-
});
265227
});
266228

267229
describe("envWithType", () => {

src/deploy/functions/build.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,6 @@ export interface ScheduleTrigger {
147147
schedule: string | Expression<string>;
148148
timeZone?: Field<string>;
149149
retryConfig?: ScheduleRetryConfig | null;
150-
attemptDeadlineSeconds?: Field<number>;
151150
}
152151

153152
export type HttpsTriggered = { httpsTrigger: HttpsTrigger };
@@ -604,18 +603,6 @@ function discoverTrigger(endpoint: Endpoint, region: string, r: Resolver): backe
604603
} else if (endpoint.scheduleTrigger.retryConfig === null) {
605604
bkSchedule.retryConfig = null;
606605
}
607-
if (typeof endpoint.scheduleTrigger.attemptDeadlineSeconds !== "undefined") {
608-
const attemptDeadlineSeconds = r.resolveInt(endpoint.scheduleTrigger.attemptDeadlineSeconds);
609-
if (
610-
attemptDeadlineSeconds !== null &&
611-
!backend.isValidAttemptDeadline(attemptDeadlineSeconds)
612-
) {
613-
throw new FirebaseError(
614-
`attemptDeadlineSeconds must be between ${backend.MIN_ATTEMPT_DEADLINE_SECONDS} and ${backend.MAX_ATTEMPT_DEADLINE_SECONDS} seconds (inclusive).`,
615-
);
616-
}
617-
bkSchedule.attemptDeadlineSeconds = attemptDeadlineSeconds;
618-
}
619606
return { scheduleTrigger: bkSchedule };
620607
} else if ("taskQueueTrigger" in endpoint) {
621608
const taskQueueTrigger: backend.TaskQueueTrigger = {};

src/deploy/functions/runtimes/discovery/v1alpha1.spec.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -716,7 +716,6 @@ describe("buildFromV1Alpha", () => {
716716
maxRetrySeconds: 120,
717717
maxDoublings: 10,
718718
},
719-
attemptDeadlineSeconds: 300,
720719
};
721720

722721
const yaml: v1alpha1.WireManifest = {
@@ -745,7 +744,6 @@ describe("buildFromV1Alpha", () => {
745744
maxRetrySeconds: "{{ params.RETRY_DURATION }}",
746745
maxDoublings: "{{ params.DOUBLINGS }}",
747746
},
748-
attemptDeadlineSeconds: "{{ params.ATTEMPT_DEADLINE }}",
749747
};
750748

751749
const yaml: v1alpha1.WireManifest = {

src/deploy/functions/runtimes/discovery/v1alpha1.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,6 @@ function assertBuildEndpoint(ep: WireEndpoint, id: string): void {
222222
schedule: "Field<string>",
223223
timeZone: "Field<string>?",
224224
retryConfig: "object?",
225-
attemptDeadlineSeconds: "Field<number>?",
226225
});
227226
if (ep.scheduleTrigger.retryConfig) {
228227
assertKeyTypes(prefix + ".scheduleTrigger.retryConfig", ep.scheduleTrigger.retryConfig, {
@@ -378,7 +377,6 @@ function parseEndpointForBuild(
378377
} else if (ep.scheduleTrigger.retryConfig === null) {
379378
st.retryConfig = null;
380379
}
381-
copyIfPresent(st, ep.scheduleTrigger, "attemptDeadlineSeconds");
382380
triggered = { scheduleTrigger: st };
383381
} else if (build.isTaskQueueTriggered(ep)) {
384382
const tq: build.TaskQueueTrigger = {};

src/deploy/functions/validate.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as sinon from "sinon";
44
import { FirebaseError } from "../../error";
55
import * as fsutils from "../../fsutils";
66
import * as validate from "./validate";
7+
import * as utils from "../../utils";
78
import * as projectPath from "../../projectPath";
89
import * as secretManager from "../../gcp/secretManager";
910
import * as backend from "./backend";
@@ -463,6 +464,23 @@ describe("validate", () => {
463464

464465
expect(() => validate.endpointsAreValid(want)).to.not.throw();
465466
});
467+
468+
it("warns for scheduled functions with timeout > 1800s", () => {
469+
const logStub = sinon.stub(utils, "logLabeledWarning");
470+
try {
471+
const ep: backend.Endpoint = {
472+
...ENDPOINT_BASE,
473+
scheduleTrigger: {
474+
schedule: "every 1 minutes",
475+
},
476+
timeoutSeconds: 1801,
477+
};
478+
validate.endpointsAreValid(backend.of(ep));
479+
expect(logStub.calledOnce).to.be.true;
480+
} finally {
481+
logStub.restore();
482+
}
483+
});
466484
});
467485

468486
describe("endpointsAreUnqiue", () => {

src/deploy/functions/validate.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ import * as utils from "../../utils";
1010
import * as secrets from "../../functions/secrets";
1111
import { serviceForEndpoint } from "./services";
1212

13+
/**
14+
* Cloud Scheduler requires attempt deadline in range [15s, 30min] with default of 3min.
15+
* See https://cloud.google.com/scheduler/docs/reference/rest/v1/projects.locations.jobs#Job.FIELDS.attempt_deadline
16+
*/
17+
export const DEFAULT_V2_SCHEDULE_ATTEMPT_DEADLINE_SECONDS = 180;
18+
export const MAX_V2_SCHEDULE_ATTEMPT_DEADLINE_SECONDS = 1800;
19+
1320
function matchingIds(
1421
endpoints: backend.Endpoint[],
1522
filter: (endpoint: backend.Endpoint) => boolean,
@@ -28,11 +35,28 @@ const cpu = (endpoint: backend.Endpoint): number => {
2835
: endpoint.cpu ?? backend.memoryToGen2Cpu(mem(endpoint));
2936
};
3037

38+
function validateScheduledTimeout(ep: backend.Endpoint): void {
39+
if (
40+
backend.isScheduleTriggered(ep) &&
41+
ep.timeoutSeconds &&
42+
ep.timeoutSeconds > MAX_V2_SCHEDULE_ATTEMPT_DEADLINE_SECONDS
43+
) {
44+
utils.logLabeledWarning(
45+
"functions",
46+
`Scheduled function ${ep.id} has a timeout of ${ep.timeoutSeconds} seconds, ` +
47+
`which exceeds the maximum attempt deadline of ${MAX_V2_SCHEDULE_ATTEMPT_DEADLINE_SECONDS} seconds for Cloud Scheduler. ` +
48+
"This is probably not what you want! Having a timeout longer than the attempt deadline may lead to unexpected retries and multiple function executions. " +
49+
`The attempt deadline will be capped at ${MAX_V2_SCHEDULE_ATTEMPT_DEADLINE_SECONDS} seconds.`,
50+
);
51+
}
52+
}
53+
3154
/** Validate that the configuration for endpoints are valid. */
3255
export function endpointsAreValid(wantBackend: backend.Backend): void {
3356
const endpoints = backend.allEndpoints(wantBackend);
3457
functionIdsAreValid(endpoints);
3558
for (const ep of endpoints) {
59+
validateScheduledTimeout(ep);
3660
serviceForEndpoint(ep).validateTrigger(ep, wantBackend);
3761
}
3862

src/gcp/cloudscheduler.spec.ts

Lines changed: 54 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -246,34 +246,54 @@ describe("cloudscheduler", () => {
246246
});
247247

248248
it("should copy optional fields for v2 endpoints", async () => {
249+
const ep: backend.Endpoint = {
250+
...V2_ENDPOINT,
251+
scheduleTrigger: {
252+
schedule: "every 1 minutes",
253+
timeZone: "America/Los_Angeles",
254+
retryConfig: {
255+
maxDoublings: 2,
256+
maxBackoffSeconds: 20,
257+
minBackoffSeconds: 1,
258+
maxRetrySeconds: 60,
259+
},
260+
},
261+
};
262+
expect(await cloudscheduler.jobFromEndpoint(ep, "region", "1234567")).to.deep.equal({
263+
name: "projects/project/locations/region/jobs/firebase-schedule-id-region",
264+
schedule: "every 1 minutes",
265+
timeZone: "America/Los_Angeles",
266+
retryConfig: {
267+
maxDoublings: 2,
268+
maxBackoffDuration: "20s",
269+
minBackoffDuration: "1s",
270+
maxRetryDuration: "60s",
271+
},
272+
httpTarget: {
273+
uri: "https://my-uri.com",
274+
httpMethod: "POST",
275+
oidcToken: {
276+
serviceAccountEmail: "1234567-compute@developer.gserviceaccount.com",
277+
},
278+
},
279+
});
280+
});
281+
282+
it("should sync attemptDeadline with timeoutSeconds for v2 endpoints", async () => {
249283
expect(
250284
await cloudscheduler.jobFromEndpoint(
251285
{
252286
...V2_ENDPOINT,
253-
scheduleTrigger: {
254-
schedule: "every 1 minutes",
255-
timeZone: "America/Los_Angeles",
256-
retryConfig: {
257-
maxDoublings: 2,
258-
maxBackoffSeconds: 20,
259-
minBackoffSeconds: 1,
260-
maxRetrySeconds: 60,
261-
},
262-
},
287+
timeoutSeconds: 300,
263288
},
264289
V2_ENDPOINT.region,
265290
"1234567",
266291
),
267292
).to.deep.equal({
268293
name: "projects/project/locations/region/jobs/firebase-schedule-id-region",
269294
schedule: "every 1 minutes",
270-
timeZone: "America/Los_Angeles",
271-
retryConfig: {
272-
maxDoublings: 2,
273-
maxBackoffDuration: "20s",
274-
minBackoffDuration: "1s",
275-
maxRetryDuration: "60s",
276-
},
295+
timeZone: "UTC",
296+
attemptDeadline: "300s",
277297
httpTarget: {
278298
uri: "https://my-uri.com",
279299
httpMethod: "POST",
@@ -284,41 +304,39 @@ describe("cloudscheduler", () => {
284304
});
285305
});
286306

287-
it("should not copy attemptDeadlineSeconds for v1 endpoints", async () => {
307+
it("should cap attemptDeadline at 1800s for v2 endpoints", async () => {
288308
expect(
289309
await cloudscheduler.jobFromEndpoint(
290310
{
291-
...V1_ENDPOINT,
292-
scheduleTrigger: {
293-
schedule: "every 1 minutes",
294-
attemptDeadlineSeconds: 300,
295-
},
311+
...V2_ENDPOINT,
312+
// This is bad configuration.
313+
// Users are discouraged from setting timeout >1800s for scheduled functions.
314+
timeoutSeconds: 3600,
296315
},
297-
"appEngineLocation",
316+
V2_ENDPOINT.region,
298317
"1234567",
299318
),
300319
).to.deep.equal({
301-
name: "projects/project/locations/appEngineLocation/jobs/firebase-schedule-id-region",
320+
name: "projects/project/locations/region/jobs/firebase-schedule-id-region",
302321
schedule: "every 1 minutes",
303-
timeZone: "America/Los_Angeles",
304-
pubsubTarget: {
305-
topicName: "projects/project/topics/firebase-schedule-id-region",
306-
attributes: {
307-
scheduled: "true",
322+
timeZone: "UTC",
323+
attemptDeadline: "1800s",
324+
httpTarget: {
325+
uri: "https://my-uri.com",
326+
httpMethod: "POST",
327+
oidcToken: {
328+
serviceAccountEmail: "1234567-compute@developer.gserviceaccount.com",
308329
},
309330
},
310331
});
311332
});
312333

313-
it("should copy attemptDeadlineSeconds for v2 endpoints", async () => {
334+
it("should floor attemptDeadline at 180s for v2 endpoints", async () => {
314335
expect(
315336
await cloudscheduler.jobFromEndpoint(
316337
{
317338
...V2_ENDPOINT,
318-
scheduleTrigger: {
319-
schedule: "every 1 minutes",
320-
attemptDeadlineSeconds: 300,
321-
},
339+
timeoutSeconds: 60,
322340
},
323341
V2_ENDPOINT.region,
324342
"1234567",
@@ -327,7 +345,7 @@ describe("cloudscheduler", () => {
327345
name: "projects/project/locations/region/jobs/firebase-schedule-id-region",
328346
schedule: "every 1 minutes",
329347
timeZone: "UTC",
330-
attemptDeadline: "300s",
348+
attemptDeadline: "180s",
331349
httpTarget: {
332350
uri: "https://my-uri.com",
333351
httpMethod: "POST",

src/gcp/cloudscheduler.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ import { FirebaseError } from "../error";
44
import { logger } from "../logger";
55
import { cloudschedulerOrigin } from "../api";
66
import { Client } from "../apiv2";
7+
import { assertExhaustive, nullsafeVisitor } from "../functional";
8+
import {
9+
DEFAULT_V2_SCHEDULE_ATTEMPT_DEADLINE_SECONDS,
10+
MAX_V2_SCHEDULE_ATTEMPT_DEADLINE_SECONDS,
11+
} from "../deploy/functions/validate";
712
import * as backend from "../deploy/functions/backend";
813
import * as proto from "./proto";
914
import * as gce from "../gcp/computeEngine";
10-
import { assertExhaustive, nullsafeVisitor } from "../functional";
1115

1216
const VERSION = "v1";
1317
const DEFAULT_TIME_ZONE_V1 = "America/Los_Angeles";
@@ -263,13 +267,21 @@ export async function jobFromEndpoint(
263267
}
264268
job.schedule = endpoint.scheduleTrigger.schedule;
265269
if (endpoint.platform === "gcfv2" || endpoint.platform === "run") {
266-
proto.convertIfPresent(
267-
job,
268-
endpoint.scheduleTrigger,
269-
"attemptDeadline",
270-
"attemptDeadlineSeconds",
271-
nullsafeVisitor(proto.durationFromSeconds),
272-
);
270+
proto.convertIfPresent(job, endpoint, "attemptDeadline", "timeoutSeconds", (timeout) => {
271+
if (timeout === null) {
272+
return null;
273+
}
274+
// Cloud Scheduler has an attempt deadline range of [15s, 1800s], and defaults to 180s.
275+
// We floor at 180s to be safe, even if the function timeout is shorter.
276+
// This is because GCF/Cloud Run will already terminate the function at its configured timeout,
277+
// so Cloud Scheduler won't actually wait the full 180s unless GCF itself fails to respond.
278+
// Setting it shorter than 180s might cause premature retries due to network latency.
279+
const attemptDeadlineSeconds = Math.max(
280+
Math.min(timeout, MAX_V2_SCHEDULE_ATTEMPT_DEADLINE_SECONDS),
281+
DEFAULT_V2_SCHEDULE_ATTEMPT_DEADLINE_SECONDS,
282+
);
283+
return proto.durationFromSeconds(attemptDeadlineSeconds);
284+
});
273285
}
274286
if (endpoint.scheduleTrigger.retryConfig) {
275287
job.retryConfig = {};

0 commit comments

Comments
 (0)