From e93879cb48ea58fa0da5536da7a077d194135f15 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Fri, 14 Nov 2025 08:08:46 -0800 Subject: [PATCH 1/3] feat: add attemptDeadlineSeconds support to scheduled functions --- src/deploy/functions/backend.ts | 1 + src/deploy/functions/build.ts | 6 +++++ .../runtimes/discovery/v1alpha1.spec.ts | 2 ++ .../functions/runtimes/discovery/v1alpha1.ts | 2 ++ src/gcp/cloudscheduler.spec.ts | 27 +++++++++++++++++++ src/gcp/cloudscheduler.ts | 9 +++++++ 6 files changed, 47 insertions(+) diff --git a/src/deploy/functions/backend.ts b/src/deploy/functions/backend.ts index 02c057791ec..5f432b1abc9 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 */ diff --git a/src/deploy/functions/build.ts b/src/deploy/functions/build.ts index c786a22c907..b2cc4e3b108 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,11 @@ function discoverTrigger(endpoint: Endpoint, region: string, r: Resolver): backe } else if (endpoint.scheduleTrigger.retryConfig === null) { bkSchedule.retryConfig = null; } + if (endpoint.scheduleTrigger.attemptDeadlineSeconds) { + bkSchedule.attemptDeadlineSeconds = r.resolveInt( + endpoint.scheduleTrigger.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..7f1a0b539de 100644 --- a/src/gcp/cloudscheduler.spec.ts +++ b/src/gcp/cloudscheduler.spec.ts @@ -283,5 +283,32 @@ describe("cloudscheduler", () => { }, }); }); + + it("should 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", + attemptDeadline: "300s", + pubsubTarget: { + topicName: "projects/project/topics/firebase-schedule-id-region", + attributes: { + scheduled: "true", + }, + }, + }); + }); }); }); diff --git a/src/gcp/cloudscheduler.ts b/src/gcp/cloudscheduler.ts index 8682a021117..4d485c4102e 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,11 @@ export async function jobFromEndpoint( ); } job.schedule = endpoint.scheduleTrigger.schedule; + if (endpoint.scheduleTrigger.attemptDeadlineSeconds) { + job.attemptDeadline = proto.durationFromSeconds( + endpoint.scheduleTrigger.attemptDeadlineSeconds, + ); + } if (endpoint.scheduleTrigger.retryConfig) { job.retryConfig = {}; proto.copyIfPresent( From 403d0f5560e016b5dd4b5a3da0bb724b2b85e291 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Fri, 14 Nov 2025 11:42:56 -0800 Subject: [PATCH 2/3] fix: address PR feedback on null handling --- src/deploy/functions/build.ts | 2 +- src/gcp/cloudscheduler.ts | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/deploy/functions/build.ts b/src/deploy/functions/build.ts index b2cc4e3b108..b0b9c6cf335 100644 --- a/src/deploy/functions/build.ts +++ b/src/deploy/functions/build.ts @@ -604,7 +604,7 @@ function discoverTrigger(endpoint: Endpoint, region: string, r: Resolver): backe } else if (endpoint.scheduleTrigger.retryConfig === null) { bkSchedule.retryConfig = null; } - if (endpoint.scheduleTrigger.attemptDeadlineSeconds) { + if (typeof endpoint.scheduleTrigger.attemptDeadlineSeconds !== "undefined") { bkSchedule.attemptDeadlineSeconds = r.resolveInt( endpoint.scheduleTrigger.attemptDeadlineSeconds, ); diff --git a/src/gcp/cloudscheduler.ts b/src/gcp/cloudscheduler.ts index 4d485c4102e..5a8f3123e27 100644 --- a/src/gcp/cloudscheduler.ts +++ b/src/gcp/cloudscheduler.ts @@ -262,11 +262,13 @@ export async function jobFromEndpoint( ); } job.schedule = endpoint.scheduleTrigger.schedule; - if (endpoint.scheduleTrigger.attemptDeadlineSeconds) { - job.attemptDeadline = proto.durationFromSeconds( - endpoint.scheduleTrigger.attemptDeadlineSeconds, - ); - } + proto.convertIfPresent( + job, + endpoint.scheduleTrigger, + "attemptDeadline", + "attemptDeadlineSeconds", + nullsafeVisitor(proto.durationFromSeconds), + ); if (endpoint.scheduleTrigger.retryConfig) { job.retryConfig = {}; proto.copyIfPresent( From aef0eb8487780817d658b4a13002f3181cacd0be Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Mon, 17 Nov 2025 14:37:53 -0800 Subject: [PATCH 3/3] feat: Support and validate `attemptDeadlineSeconds` for GCFv2/Cloud Run scheduled functions. --- src/deploy/functions/backend.ts | 13 +++++++--- src/deploy/functions/build.spec.ts | 38 ++++++++++++++++++++++++++++++ src/deploy/functions/build.ts | 13 +++++++--- src/gcp/cloudscheduler.spec.ts | 31 ++++++++++++++++++++++-- src/gcp/cloudscheduler.ts | 16 +++++++------ 5 files changed, 96 insertions(+), 15 deletions(-) diff --git a/src/deploy/functions/backend.ts b/src/deploy/functions/backend.ts index 5f432b1abc9..6d43388278c 100644 --- a/src/deploy/functions/backend.ts +++ b/src/deploy/functions/backend.ts @@ -180,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 b0b9c6cf335..0a1362d78dd 100644 --- a/src/deploy/functions/build.ts +++ b/src/deploy/functions/build.ts @@ -605,9 +605,16 @@ function discoverTrigger(endpoint: Endpoint, region: string, r: Resolver): backe bkSchedule.retryConfig = null; } if (typeof endpoint.scheduleTrigger.attemptDeadlineSeconds !== "undefined") { - bkSchedule.attemptDeadlineSeconds = r.resolveInt( - endpoint.scheduleTrigger.attemptDeadlineSeconds, - ); + 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) { diff --git a/src/gcp/cloudscheduler.spec.ts b/src/gcp/cloudscheduler.spec.ts index 7f1a0b539de..42a38308d8d 100644 --- a/src/gcp/cloudscheduler.spec.ts +++ b/src/gcp/cloudscheduler.spec.ts @@ -284,7 +284,7 @@ describe("cloudscheduler", () => { }); }); - it("should copy attemptDeadlineSeconds for v1 endpoints", async () => { + it("should not copy attemptDeadlineSeconds for v1 endpoints", async () => { expect( await cloudscheduler.jobFromEndpoint( { @@ -301,7 +301,6 @@ describe("cloudscheduler", () => { name: "projects/project/locations/appEngineLocation/jobs/firebase-schedule-id-region", schedule: "every 1 minutes", timeZone: "America/Los_Angeles", - attemptDeadline: "300s", pubsubTarget: { topicName: "projects/project/topics/firebase-schedule-id-region", attributes: { @@ -310,5 +309,33 @@ describe("cloudscheduler", () => { }, }); }); + + 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 5a8f3123e27..4465806f26a 100644 --- a/src/gcp/cloudscheduler.ts +++ b/src/gcp/cloudscheduler.ts @@ -262,13 +262,15 @@ export async function jobFromEndpoint( ); } job.schedule = endpoint.scheduleTrigger.schedule; - proto.convertIfPresent( - job, - endpoint.scheduleTrigger, - "attemptDeadline", - "attemptDeadlineSeconds", - nullsafeVisitor(proto.durationFromSeconds), - ); + 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(