Skip to content

Commit 4f6ec9b

Browse files
authored
feat: add scheduler hook support (#11)
1 parent cf80d27 commit 4f6ec9b

File tree

12 files changed

+1025
-3
lines changed

12 files changed

+1025
-3
lines changed

example/basic/lib/main.dart

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,41 @@ void main(List<String> args) {
417417
},
418418
);
419419

420+
// ==========================================================================
421+
// Scheduler trigger examples
422+
// ==========================================================================
423+
424+
// Basic scheduled function - runs every day at midnight
425+
firebase.scheduler.onSchedule(
426+
schedule: '0 0 * * *',
427+
(event) async {
428+
print('Scheduled function triggered:');
429+
print(' Job Name: ${event.jobName}');
430+
print(' Schedule Time: ${event.scheduleTime}');
431+
// Perform daily cleanup, send reports, etc.
432+
},
433+
);
434+
435+
// Scheduled function with timezone and retry config
436+
firebase.scheduler.onSchedule(
437+
schedule: '0 9 * * 1-5',
438+
options: const ScheduleOptions(
439+
timeZone: TimeZone('America/New_York'),
440+
retryConfig: RetryConfig(
441+
retryCount: RetryCount(3),
442+
maxRetrySeconds: MaxRetrySeconds(60),
443+
minBackoffSeconds: MinBackoffSeconds(5),
444+
maxBackoffSeconds: MaxBackoffSeconds(30),
445+
),
446+
memory: Memory(MemoryOption.mb256),
447+
),
448+
(event) async {
449+
print('Weekday morning report:');
450+
print(' Executed at: ${event.scheduleDateTime}');
451+
// Generate and send morning reports
452+
},
453+
);
454+
420455
print('Functions registered successfully!');
421456
});
422457
}

lib/builder.dart

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ class _TypeCheckers {
3838
TypeChecker.fromRuntime(ff.PerformanceNamespace);
3939
static final identityNamespace =
4040
TypeChecker.fromRuntime(ff.IdentityNamespace);
41+
static final schedulerNamespace =
42+
TypeChecker.fromRuntime(ff.SchedulerNamespace);
4143
}
4244

4345
/// The main builder that generates functions.yaml.
@@ -186,6 +188,13 @@ class _FirebaseFunctionsVisitor extends RecursiveAstVisitor<void> {
186188
_extractIdentityFunction(node, methodName);
187189
}
188190

191+
// Check for Scheduler function declarations
192+
if (target != null && _isSchedulerNamespace(target)) {
193+
if (methodName == 'onSchedule') {
194+
_extractSchedulerFunction(node);
195+
}
196+
}
197+
189198
// Check for parameter definitions (top-level function calls with no target)
190199
if (target == null && _isParamDefinition(methodName)) {
191200
_extractParameterFromMethod(node, methodName);
@@ -339,6 +348,13 @@ class _FirebaseFunctionsVisitor extends RecursiveAstVisitor<void> {
339348
return _TypeCheckers.identityNamespace.isExactlyType(staticType);
340349
}
341350

351+
/// Checks if the target is firebase.scheduler.
352+
bool _isSchedulerNamespace(Expression target) {
353+
final staticType = target.staticType;
354+
if (staticType == null) return false;
355+
return _TypeCheckers.schedulerNamespace.isExactlyType(staticType);
356+
}
357+
342358
/// Checks if this is a parameter definition function.
343359
bool _isParamDefinition(String name) =>
344360
name == 'defineString' ||
@@ -639,6 +655,101 @@ class _FirebaseFunctionsVisitor extends RecursiveAstVisitor<void> {
639655
);
640656
}
641657

658+
/// Extracts a Scheduler function declaration.
659+
void _extractSchedulerFunction(MethodInvocation node) {
660+
// Extract schedule from named argument
661+
final scheduleArg = _findNamedArg(node, 'schedule');
662+
if (scheduleArg == null) return;
663+
664+
final schedule = _extractStringLiteral(scheduleArg);
665+
if (schedule == null) return;
666+
667+
// Generate function name from schedule (matching runtime behavior)
668+
final sanitized = schedule
669+
.replaceAll(' ', '_')
670+
.replaceAll('*', '')
671+
.replaceAll('/', '')
672+
.replaceAll('-', '')
673+
.replaceAll(',', '');
674+
final functionName = 'onSchedule_$sanitized';
675+
676+
// Extract options if present
677+
final optionsArg = _findNamedArg(node, 'options');
678+
String? timeZone;
679+
Map<String, dynamic>? retryConfig;
680+
681+
if (optionsArg is InstanceCreationExpression) {
682+
timeZone = _extractSchedulerTimeZone(optionsArg);
683+
retryConfig = _extractRetryConfig(optionsArg);
684+
}
685+
686+
endpoints[functionName] = _EndpointSpec(
687+
name: functionName,
688+
type: 'scheduler',
689+
schedule: schedule,
690+
timeZone: timeZone,
691+
retryConfig: retryConfig,
692+
options: optionsArg is InstanceCreationExpression ? optionsArg : null,
693+
variableToParamName: _variableToParamName,
694+
);
695+
}
696+
697+
/// Extracts timeZone from ScheduleOptions.
698+
String? _extractSchedulerTimeZone(InstanceCreationExpression node) {
699+
final timeZoneArg = node.argumentList.arguments
700+
.whereType<NamedExpression>()
701+
.where((e) => e.name.label.name == 'timeZone')
702+
.map((e) => e.expression)
703+
.firstOrNull;
704+
705+
if (timeZoneArg is InstanceCreationExpression) {
706+
// TimeZone('America/New_York')
707+
final args = timeZoneArg.argumentList.arguments;
708+
if (args.isNotEmpty && args.first is StringLiteral) {
709+
return (args.first as StringLiteral).stringValue;
710+
}
711+
}
712+
return null;
713+
}
714+
715+
/// Extracts RetryConfig from ScheduleOptions.
716+
Map<String, dynamic>? _extractRetryConfig(InstanceCreationExpression node) {
717+
final retryConfigArg = node.argumentList.arguments
718+
.whereType<NamedExpression>()
719+
.where((e) => e.name.label.name == 'retryConfig')
720+
.map((e) => e.expression)
721+
.firstOrNull;
722+
723+
if (retryConfigArg is! InstanceCreationExpression) return null;
724+
725+
final config = <String, dynamic>{};
726+
727+
for (final arg in retryConfigArg.argumentList.arguments) {
728+
if (arg is! NamedExpression) continue;
729+
730+
final fieldName = arg.name.label.name;
731+
final value = _extractRetryConfigValue(arg.expression);
732+
if (value != null) {
733+
config[fieldName] = value;
734+
}
735+
}
736+
737+
return config.isEmpty ? null : config;
738+
}
739+
740+
/// Extracts a value from a retry config option.
741+
dynamic _extractRetryConfigValue(Expression expression) {
742+
if (expression is InstanceCreationExpression) {
743+
final args = expression.argumentList.arguments;
744+
if (args.isNotEmpty) {
745+
final first = args.first;
746+
if (first is IntegerLiteral) return first.value;
747+
if (first is DoubleLiteral) return first.value;
748+
}
749+
}
750+
return null;
751+
}
752+
642753
/// Extracts a boolean field from an InstanceCreationExpression.
643754
bool? _extractBoolField(InstanceCreationExpression node, String fieldName) {
644755
final arg = node.argumentList.arguments
@@ -888,11 +999,14 @@ class _EndpointSpec {
888999
this.idToken,
8891000
this.accessToken,
8901001
this.refreshToken,
1002+
this.schedule,
1003+
this.timeZone,
1004+
this.retryConfig,
8911005
this.options,
8921006
this.variableToParamName = const {},
8931007
});
8941008
final String name;
895-
// 'https', 'callable', 'pubsub', 'firestore', 'database', 'alert', 'blocking'
1009+
// 'https', 'callable', 'pubsub', 'firestore', 'database', 'alert', 'blocking', 'scheduler'
8961010
final String type;
8971011
final String? topic; // For Pub/Sub functions
8981012
final String? firestoreEventType; // For Firestore: onDocumentCreated, etc.
@@ -908,6 +1022,9 @@ class _EndpointSpec {
9081022
final bool? idToken; // For Identity: pass ID token
9091023
final bool? accessToken; // For Identity: pass access token
9101024
final bool? refreshToken; // For Identity: pass refresh token
1025+
final String? schedule; // For Scheduler: cron expression
1026+
final String? timeZone; // For Scheduler: timezone
1027+
final Map<String, dynamic>? retryConfig; // For Scheduler: retry configuration
9111028
final InstanceCreationExpression? options;
9121029
final Map<String, String> variableToParamName;
9131030

@@ -1458,6 +1575,13 @@ String _generateYaml(
14581575
buffer.writeln(' - api: "identitytoolkit.googleapis.com"');
14591576
buffer.writeln(' reason: "Needed for auth blocking functions"');
14601577
}
1578+
// Add cloudscheduler API if there are scheduler functions
1579+
final hasSchedulerFunctions =
1580+
endpoints.values.any((e) => e.type == 'scheduler');
1581+
if (hasSchedulerFunctions) {
1582+
buffer.writeln(' - api: "cloudscheduler.googleapis.com"');
1583+
buffer.writeln(' reason: "Needed for scheduled functions"');
1584+
}
14611585
buffer.writeln();
14621586

14631587
// Generate endpoints section
@@ -1685,6 +1809,37 @@ String _generateYaml(
16851809
} else {
16861810
buffer.writeln(' options: {}');
16871811
}
1812+
} else if (endpoint.type == 'scheduler' && endpoint.schedule != null) {
1813+
buffer.writeln(' scheduleTrigger:');
1814+
buffer.writeln(' schedule: "${endpoint.schedule}"');
1815+
if (endpoint.timeZone != null) {
1816+
buffer.writeln(' timeZone: "${endpoint.timeZone}"');
1817+
}
1818+
if (endpoint.retryConfig != null && endpoint.retryConfig!.isNotEmpty) {
1819+
buffer.writeln(' retryConfig:');
1820+
final config = endpoint.retryConfig!;
1821+
if (config.containsKey('retryCount')) {
1822+
buffer.writeln(' retryCount: ${config['retryCount']}');
1823+
}
1824+
if (config.containsKey('maxRetrySeconds')) {
1825+
buffer.writeln(
1826+
' maxRetrySeconds: ${config['maxRetrySeconds']}',
1827+
);
1828+
}
1829+
if (config.containsKey('minBackoffSeconds')) {
1830+
buffer.writeln(
1831+
' minBackoffSeconds: ${config['minBackoffSeconds']}',
1832+
);
1833+
}
1834+
if (config.containsKey('maxBackoffSeconds')) {
1835+
buffer.writeln(
1836+
' maxBackoffSeconds: ${config['maxBackoffSeconds']}',
1837+
);
1838+
}
1839+
if (config.containsKey('maxDoublings')) {
1840+
buffer.writeln(' maxDoublings: ${config['maxDoublings']}');
1841+
}
1842+
}
16881843
}
16891844
}
16901845
}

lib/firebase_functions.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,5 +81,7 @@ export 'src/https/https.dart';
8181
export 'src/identity/identity.dart';
8282
// Pub/Sub triggers
8383
export 'src/pubsub/pubsub.dart';
84+
// Scheduler triggers
85+
export 'src/scheduler/scheduler.dart';
8486
// Core runtime
8587
export 'src/server.dart' show fireUp;

lib/src/firebase.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'firestore/firestore_namespace.dart';
1111
import 'https/https_namespace.dart';
1212
import 'identity/identity_namespace.dart';
1313
import 'pubsub/pubsub_namespace.dart';
14+
import 'scheduler/scheduler_namespace.dart';
1415

1516
/// Main Firebase Functions instance.
1617
///
@@ -85,6 +86,9 @@ class Firebase {
8586

8687
/// Identity Platform namespace.
8788
IdentityNamespace get identity => IdentityNamespace(this);
89+
90+
/// Scheduler namespace.
91+
SchedulerNamespace get scheduler => SchedulerNamespace(this);
8892
}
8993

9094
/// Extension for internal function registration.

lib/src/scheduler/options.dart

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import '../common/options.dart';
2+
3+
/// Options for scheduled functions.
4+
///
5+
/// Extends [GlobalOptions] with scheduler-specific configuration.
6+
class ScheduleOptions extends GlobalOptions {
7+
/// Creates schedule options.
8+
const ScheduleOptions({
9+
this.timeZone,
10+
this.retryConfig,
11+
super.concurrency,
12+
super.cpu,
13+
super.ingressSettings,
14+
super.invoker,
15+
super.labels,
16+
super.minInstances,
17+
super.maxInstances,
18+
super.memory,
19+
super.omit,
20+
super.preserveExternalChanges,
21+
super.region,
22+
super.secrets,
23+
super.serviceAccount,
24+
super.timeoutSeconds,
25+
super.vpcConnector,
26+
super.vpcConnectorEgressSettings,
27+
});
28+
29+
/// The timezone that the schedule executes in.
30+
///
31+
/// If not specified, defaults to UTC.
32+
///
33+
/// Example: `'America/New_York'`, `'Europe/London'`
34+
final TimeZone? timeZone;
35+
36+
/// Retry configuration for failed function executions.
37+
final RetryConfig? retryConfig;
38+
}
39+
40+
/// Retry configuration for scheduled functions.
41+
///
42+
/// Configures how Cloud Scheduler retries failed invocations.
43+
class RetryConfig {
44+
/// Creates a retry configuration.
45+
const RetryConfig({
46+
this.retryCount,
47+
this.maxRetrySeconds,
48+
this.minBackoffSeconds,
49+
this.maxBackoffSeconds,
50+
this.maxDoublings,
51+
});
52+
53+
/// The number of retry attempts for a failed run.
54+
///
55+
/// If set to 0, the job will not be retried on failure.
56+
final RetryCount? retryCount;
57+
58+
/// The time limit for retrying a failed job, in seconds.
59+
///
60+
/// After this time, no more retries will be attempted.
61+
final MaxRetrySeconds? maxRetrySeconds;
62+
63+
/// The minimum time to wait before retrying, in seconds.
64+
///
65+
/// Must be between 0 and 3600.
66+
final MinBackoffSeconds? minBackoffSeconds;
67+
68+
/// The maximum time to wait before retrying, in seconds.
69+
///
70+
/// Must be between 0 and 3600.
71+
final MaxBackoffSeconds? maxBackoffSeconds;
72+
73+
/// The maximum number of times that the backoff interval
74+
/// will be doubled before the interval starts increasing linearly.
75+
///
76+
/// After this many doublings, subsequent retries will increase
77+
/// the interval linearly according to the formula:
78+
/// `delay = maxBackoffSeconds + (attempt - maxDoublings) * maxBackoffSeconds`
79+
final MaxDoublings? maxDoublings;
80+
}
81+
82+
// Type aliases for scheduler-specific options
83+
84+
/// The timezone option type.
85+
typedef TimeZone = Option<String>;
86+
87+
/// The retry count option type.
88+
typedef RetryCount = DeployOption<int>;
89+
90+
/// The max retry seconds option type.
91+
typedef MaxRetrySeconds = DeployOption<int>;
92+
93+
/// The min backoff seconds option type.
94+
typedef MinBackoffSeconds = DeployOption<int>;
95+
96+
/// The max backoff seconds option type.
97+
typedef MaxBackoffSeconds = DeployOption<int>;
98+
99+
/// The max doublings option type.
100+
typedef MaxDoublings = DeployOption<int>;

0 commit comments

Comments
 (0)