diff --git a/src/deploy/functions/backend.ts b/src/deploy/functions/backend.ts index 02c057791ec..6d43388278c 100644 --- a/src/deploy/functions/backend.ts +++ b/src/deploy/functions/backend.ts @@ -23,6 +23,7 @@ export interface ScheduleTrigger { schedule?: string; timeZone?: string | null; retryConfig?: ScheduleRetryConfig | null; + attemptDeadlineSeconds?: number | null; } /** Something that has a ScheduleTrigger */ @@ -179,13 +180,20 @@ export function isValidMemoryOption(mem: unknown): mem is MemoryOptions { return allMemoryOptions.includes(mem as MemoryOptions); } -/** - * Is a given string a valid VpcEgressSettings? - */ export function isValidEgressSetting(egress: unknown): egress is VpcEgressSettings { return egress === "PRIVATE_RANGES_ONLY" || egress === "ALL_TRAFFIC"; } +export const MIN_ATTEMPT_DEADLINE_SECONDS = 15; +export const MAX_ATTEMPT_DEADLINE_SECONDS = 1800; // 30 mins + +/** + * Is a given number a valid attempt deadline? + */ +export function isValidAttemptDeadline(seconds: number): boolean { + return seconds >= MIN_ATTEMPT_DEADLINE_SECONDS && seconds <= MAX_ATTEMPT_DEADLINE_SECONDS; +} + /** Returns a human-readable name with MB or GB suffix for a MemoryOption (MB). */ export function memoryOptionDisplayName(option: MemoryOptions): string { return { diff --git a/src/deploy/functions/build.spec.ts b/src/deploy/functions/build.spec.ts index 64b604aa164..082bffd3596 100644 --- a/src/deploy/functions/build.spec.ts +++ b/src/deploy/functions/build.spec.ts @@ -224,6 +224,44 @@ describe("toBackend", () => { expect(endpointDef.func.serviceAccount).to.equal("service-account-1@"); } }); + + it("throws if attemptDeadlineSeconds is out of range", () => { + const desiredBuild: build.Build = build.of({ + func: { + platform: "gcfv2", + region: ["us-central1"], + project: "project", + runtime: "nodejs16", + entryPoint: "func", + scheduleTrigger: { + schedule: "every 1 minutes", + attemptDeadlineSeconds: 10, // Invalid: < 15 + }, + }, + }); + expect(() => build.toBackend(desiredBuild, {})).to.throw( + FirebaseError, + /attemptDeadlineSeconds must be between 15 and 1800 seconds/, + ); + + const desiredBuild2: build.Build = build.of({ + func: { + platform: "gcfv2", + region: ["us-central1"], + project: "project", + runtime: "nodejs16", + entryPoint: "func", + scheduleTrigger: { + schedule: "every 1 minutes", + attemptDeadlineSeconds: 1801, // Invalid: > 1800 + }, + }, + }); + expect(() => build.toBackend(desiredBuild2, {})).to.throw( + FirebaseError, + /attemptDeadlineSeconds must be between 15 and 1800 seconds/, + ); + }); }); describe("envWithType", () => { diff --git a/src/deploy/functions/build.ts b/src/deploy/functions/build.ts index c786a22c907..0a1362d78dd 100644 --- a/src/deploy/functions/build.ts +++ b/src/deploy/functions/build.ts @@ -147,6 +147,7 @@ export interface ScheduleTrigger { schedule: string | Expression; timeZone?: Field; retryConfig?: ScheduleRetryConfig | null; + attemptDeadlineSeconds?: Field; } export type HttpsTriggered = { httpsTrigger: HttpsTrigger }; @@ -603,6 +604,18 @@ function discoverTrigger(endpoint: Endpoint, region: string, r: Resolver): backe } else if (endpoint.scheduleTrigger.retryConfig === null) { bkSchedule.retryConfig = null; } + if (typeof endpoint.scheduleTrigger.attemptDeadlineSeconds !== "undefined") { + const attemptDeadlineSeconds = r.resolveInt(endpoint.scheduleTrigger.attemptDeadlineSeconds); + if ( + attemptDeadlineSeconds !== null && + !backend.isValidAttemptDeadline(attemptDeadlineSeconds) + ) { + throw new FirebaseError( + `attemptDeadlineSeconds must be between ${backend.MIN_ATTEMPT_DEADLINE_SECONDS} and ${backend.MAX_ATTEMPT_DEADLINE_SECONDS} seconds (inclusive).`, + ); + } + bkSchedule.attemptDeadlineSeconds = attemptDeadlineSeconds; + } return { scheduleTrigger: bkSchedule }; } else if ("taskQueueTrigger" in endpoint) { const taskQueueTrigger: backend.TaskQueueTrigger = {}; diff --git a/src/deploy/functions/runtimes/discovery/v1alpha1.spec.ts b/src/deploy/functions/runtimes/discovery/v1alpha1.spec.ts index 5aece13566a..e40104d9c6d 100644 --- a/src/deploy/functions/runtimes/discovery/v1alpha1.spec.ts +++ b/src/deploy/functions/runtimes/discovery/v1alpha1.spec.ts @@ -716,6 +716,7 @@ describe("buildFromV1Alpha", () => { maxRetrySeconds: 120, maxDoublings: 10, }, + attemptDeadlineSeconds: 300, }; const yaml: v1alpha1.WireManifest = { @@ -744,6 +745,7 @@ describe("buildFromV1Alpha", () => { maxRetrySeconds: "{{ params.RETRY_DURATION }}", maxDoublings: "{{ params.DOUBLINGS }}", }, + attemptDeadlineSeconds: "{{ params.ATTEMPT_DEADLINE }}", }; const yaml: v1alpha1.WireManifest = { diff --git a/src/deploy/functions/runtimes/discovery/v1alpha1.ts b/src/deploy/functions/runtimes/discovery/v1alpha1.ts index 8afaeb1f016..d2c2ca4e35e 100644 --- a/src/deploy/functions/runtimes/discovery/v1alpha1.ts +++ b/src/deploy/functions/runtimes/discovery/v1alpha1.ts @@ -222,6 +222,7 @@ function assertBuildEndpoint(ep: WireEndpoint, id: string): void { schedule: "Field", timeZone: "Field?", retryConfig: "object?", + attemptDeadlineSeconds: "Field?", }); if (ep.scheduleTrigger.retryConfig) { assertKeyTypes(prefix + ".scheduleTrigger.retryConfig", ep.scheduleTrigger.retryConfig, { @@ -377,6 +378,7 @@ function parseEndpointForBuild( } else if (ep.scheduleTrigger.retryConfig === null) { st.retryConfig = null; } + copyIfPresent(st, ep.scheduleTrigger, "attemptDeadlineSeconds"); triggered = { scheduleTrigger: st }; } else if (build.isTaskQueueTriggered(ep)) { const tq: build.TaskQueueTrigger = {}; diff --git a/src/gcp/cloudscheduler.spec.ts b/src/gcp/cloudscheduler.spec.ts index b2801ba11f6..42a38308d8d 100644 --- a/src/gcp/cloudscheduler.spec.ts +++ b/src/gcp/cloudscheduler.spec.ts @@ -283,5 +283,59 @@ describe("cloudscheduler", () => { }, }); }); + + it("should not copy attemptDeadlineSeconds for v1 endpoints", async () => { + expect( + await cloudscheduler.jobFromEndpoint( + { + ...V1_ENDPOINT, + scheduleTrigger: { + schedule: "every 1 minutes", + attemptDeadlineSeconds: 300, + }, + }, + "appEngineLocation", + "1234567", + ), + ).to.deep.equal({ + name: "projects/project/locations/appEngineLocation/jobs/firebase-schedule-id-region", + schedule: "every 1 minutes", + timeZone: "America/Los_Angeles", + pubsubTarget: { + topicName: "projects/project/topics/firebase-schedule-id-region", + attributes: { + scheduled: "true", + }, + }, + }); + }); + + it("should copy attemptDeadlineSeconds for v2 endpoints", async () => { + expect( + await cloudscheduler.jobFromEndpoint( + { + ...V2_ENDPOINT, + scheduleTrigger: { + schedule: "every 1 minutes", + attemptDeadlineSeconds: 300, + }, + }, + V2_ENDPOINT.region, + "1234567", + ), + ).to.deep.equal({ + name: "projects/project/locations/region/jobs/firebase-schedule-id-region", + schedule: "every 1 minutes", + timeZone: "UTC", + attemptDeadline: "300s", + httpTarget: { + uri: "https://my-uri.com", + httpMethod: "POST", + oidcToken: { + serviceAccountEmail: "1234567-compute@developer.gserviceaccount.com", + }, + }, + }); + }); }); }); diff --git a/src/gcp/cloudscheduler.ts b/src/gcp/cloudscheduler.ts index 8682a021117..4465806f26a 100644 --- a/src/gcp/cloudscheduler.ts +++ b/src/gcp/cloudscheduler.ts @@ -55,6 +55,7 @@ export interface Job { schedule: string; description?: string; timeZone?: string | null; + attemptDeadline?: string | null; // oneof target httpTarget?: HttpTarget; @@ -195,6 +196,9 @@ function needUpdate(existingJob: Job, newJob: Job): boolean { if (existingJob.timeZone !== newJob.timeZone) { return true; } + if (existingJob.attemptDeadline !== newJob.attemptDeadline) { + return true; + } if (newJob.retryConfig) { if (!existingJob.retryConfig) { return true; @@ -258,6 +262,15 @@ export async function jobFromEndpoint( ); } job.schedule = endpoint.scheduleTrigger.schedule; + if (endpoint.platform === "gcfv2" || endpoint.platform === "run") { + proto.convertIfPresent( + job, + endpoint.scheduleTrigger, + "attemptDeadline", + "attemptDeadlineSeconds", + nullsafeVisitor(proto.durationFromSeconds), + ); + } if (endpoint.scheduleTrigger.retryConfig) { job.retryConfig = {}; proto.copyIfPresent(