Skip to content

Commit 6c00559

Browse files
authored
feat: add attemptDeadlineSeconds support to scheduled functions (#9464)
* feat: add attemptDeadlineSeconds support to scheduled functions * fix: address PR feedback on null handling * feat: Support and validate `attemptDeadlineSeconds` for GCFv2/Cloud Run scheduled functions.
1 parent 86e3888 commit 6c00559

File tree

7 files changed

+133
-3
lines changed

7 files changed

+133
-3
lines changed

src/deploy/functions/backend.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface ScheduleTrigger {
2323
schedule?: string;
2424
timeZone?: string | null;
2525
retryConfig?: ScheduleRetryConfig | null;
26+
attemptDeadlineSeconds?: number | null;
2627
}
2728

2829
/** Something that has a ScheduleTrigger */
@@ -179,13 +180,20 @@ export function isValidMemoryOption(mem: unknown): mem is MemoryOptions {
179180
return allMemoryOptions.includes(mem as MemoryOptions);
180181
}
181182

182-
/**
183-
* Is a given string a valid VpcEgressSettings?
184-
*/
185183
export function isValidEgressSetting(egress: unknown): egress is VpcEgressSettings {
186184
return egress === "PRIVATE_RANGES_ONLY" || egress === "ALL_TRAFFIC";
187185
}
188186

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

src/deploy/functions/build.spec.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,44 @@ 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+
});
227265
});
228266

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

src/deploy/functions/build.ts

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

152153
export type HttpsTriggered = { httpsTrigger: HttpsTrigger };
@@ -603,6 +604,18 @@ function discoverTrigger(endpoint: Endpoint, region: string, r: Resolver): backe
603604
} else if (endpoint.scheduleTrigger.retryConfig === null) {
604605
bkSchedule.retryConfig = null;
605606
}
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+
}
606619
return { scheduleTrigger: bkSchedule };
607620
} else if ("taskQueueTrigger" in endpoint) {
608621
const taskQueueTrigger: backend.TaskQueueTrigger = {};

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

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

721722
const yaml: v1alpha1.WireManifest = {
@@ -744,6 +745,7 @@ describe("buildFromV1Alpha", () => {
744745
maxRetrySeconds: "{{ params.RETRY_DURATION }}",
745746
maxDoublings: "{{ params.DOUBLINGS }}",
746747
},
748+
attemptDeadlineSeconds: "{{ params.ATTEMPT_DEADLINE }}",
747749
};
748750

749751
const yaml: v1alpha1.WireManifest = {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ function assertBuildEndpoint(ep: WireEndpoint, id: string): void {
222222
schedule: "Field<string>",
223223
timeZone: "Field<string>?",
224224
retryConfig: "object?",
225+
attemptDeadlineSeconds: "Field<number>?",
225226
});
226227
if (ep.scheduleTrigger.retryConfig) {
227228
assertKeyTypes(prefix + ".scheduleTrigger.retryConfig", ep.scheduleTrigger.retryConfig, {
@@ -377,6 +378,7 @@ function parseEndpointForBuild(
377378
} else if (ep.scheduleTrigger.retryConfig === null) {
378379
st.retryConfig = null;
379380
}
381+
copyIfPresent(st, ep.scheduleTrigger, "attemptDeadlineSeconds");
380382
triggered = { scheduleTrigger: st };
381383
} else if (build.isTaskQueueTriggered(ep)) {
382384
const tq: build.TaskQueueTrigger = {};

src/gcp/cloudscheduler.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,5 +283,59 @@ describe("cloudscheduler", () => {
283283
},
284284
});
285285
});
286+
287+
it("should not copy attemptDeadlineSeconds for v1 endpoints", async () => {
288+
expect(
289+
await cloudscheduler.jobFromEndpoint(
290+
{
291+
...V1_ENDPOINT,
292+
scheduleTrigger: {
293+
schedule: "every 1 minutes",
294+
attemptDeadlineSeconds: 300,
295+
},
296+
},
297+
"appEngineLocation",
298+
"1234567",
299+
),
300+
).to.deep.equal({
301+
name: "projects/project/locations/appEngineLocation/jobs/firebase-schedule-id-region",
302+
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",
308+
},
309+
},
310+
});
311+
});
312+
313+
it("should copy attemptDeadlineSeconds for v2 endpoints", async () => {
314+
expect(
315+
await cloudscheduler.jobFromEndpoint(
316+
{
317+
...V2_ENDPOINT,
318+
scheduleTrigger: {
319+
schedule: "every 1 minutes",
320+
attemptDeadlineSeconds: 300,
321+
},
322+
},
323+
V2_ENDPOINT.region,
324+
"1234567",
325+
),
326+
).to.deep.equal({
327+
name: "projects/project/locations/region/jobs/firebase-schedule-id-region",
328+
schedule: "every 1 minutes",
329+
timeZone: "UTC",
330+
attemptDeadline: "300s",
331+
httpTarget: {
332+
uri: "https://my-uri.com",
333+
httpMethod: "POST",
334+
oidcToken: {
335+
serviceAccountEmail: "1234567-compute@developer.gserviceaccount.com",
336+
},
337+
},
338+
});
339+
});
286340
});
287341
});

src/gcp/cloudscheduler.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export interface Job {
5555
schedule: string;
5656
description?: string;
5757
timeZone?: string | null;
58+
attemptDeadline?: string | null;
5859

5960
// oneof target
6061
httpTarget?: HttpTarget;
@@ -195,6 +196,9 @@ function needUpdate(existingJob: Job, newJob: Job): boolean {
195196
if (existingJob.timeZone !== newJob.timeZone) {
196197
return true;
197198
}
199+
if (existingJob.attemptDeadline !== newJob.attemptDeadline) {
200+
return true;
201+
}
198202
if (newJob.retryConfig) {
199203
if (!existingJob.retryConfig) {
200204
return true;
@@ -258,6 +262,15 @@ export async function jobFromEndpoint(
258262
);
259263
}
260264
job.schedule = endpoint.scheduleTrigger.schedule;
265+
if (endpoint.platform === "gcfv2" || endpoint.platform === "run") {
266+
proto.convertIfPresent(
267+
job,
268+
endpoint.scheduleTrigger,
269+
"attemptDeadline",
270+
"attemptDeadlineSeconds",
271+
nullsafeVisitor(proto.durationFromSeconds),
272+
);
273+
}
261274
if (endpoint.scheduleTrigger.retryConfig) {
262275
job.retryConfig = {};
263276
proto.copyIfPresent(

0 commit comments

Comments
 (0)