From a6a25c64fa0c32f56374ffd9967dda58bace4c76 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Thu, 9 Jul 2026 17:18:55 -0700 Subject: [PATCH] feat(functions): Implement Task Queue Scopes Implemented Task Queue Scopes for Functions service in Dart. Introduced FunctionScope sealed class and updated taskQueue factory method. Handled kit fallback logic on 404 and legacy parameter deprecation warnings. --- .../lib/src/app/environment.dart | 7 + .../lib/src/functions/functions.dart | 8 +- .../lib/src/functions/functions_api.dart | 56 ++++ .../functions/functions_request_handler.dart | 49 +++- .../lib/src/functions/task_queue.dart | 97 ++++++- .../test/unit/functions/functions_test.dart | 268 ++++++++++++++++-- 6 files changed, 436 insertions(+), 49 deletions(-) diff --git a/packages/firebase_admin_sdk/lib/src/app/environment.dart b/packages/firebase_admin_sdk/lib/src/app/environment.dart index 10e0f9ec..1edcf12b 100644 --- a/packages/firebase_admin_sdk/lib/src/app/environment.dart +++ b/packages/firebase_admin_sdk/lib/src/app/environment.dart @@ -174,4 +174,11 @@ abstract class Environment { Zone.current[envSymbol] as Map? ?? Platform.environment; return env[cloudTasksEmulatorHost]; } + + /// Gets the Kit Instance ID if running within a kit. + static String? getKitInstanceId() { + final env = + Zone.current[envSymbol] as Map? ?? Platform.environment; + return env['FIREBASE_KIT_INSTANCE_ID']; + } } diff --git a/packages/firebase_admin_sdk/lib/src/functions/functions.dart b/packages/firebase_admin_sdk/lib/src/functions/functions.dart index 079e10ce..c0fce104 100644 --- a/packages/firebase_admin_sdk/lib/src/functions/functions.dart +++ b/packages/firebase_admin_sdk/lib/src/functions/functions.dart @@ -78,11 +78,17 @@ class Functions implements FirebaseService { /// final queue = functions.taskQueue('myFunction'); /// await queue.enqueue({'data': 'value'}); /// ``` - TaskQueue taskQueue(String functionName, {String? extensionId}) { + TaskQueue taskQueue( + String functionName, { + @Deprecated('Use scope instead. Will be removed in a future major version.') + String? extensionId, + FunctionScope? scope, + }) { return TaskQueue._( functionName: functionName, requestHandler: _requestHandler, extensionId: extensionId, + scope: scope, ); } diff --git a/packages/firebase_admin_sdk/lib/src/functions/functions_api.dart b/packages/firebase_admin_sdk/lib/src/functions/functions_api.dart index 36b69884..a2333149 100644 --- a/packages/firebase_admin_sdk/lib/src/functions/functions_api.dart +++ b/packages/firebase_admin_sdk/lib/src/functions/functions_api.dart @@ -174,3 +174,59 @@ class TaskOptions { /// Contains experimental features that may change in future releases. final TaskOptionsExperimental? experimental; } + +/// Represents the scope of a function in a task queue. +sealed class FunctionScope { + const FunctionScope._(); + + /// Targets a function co-deployed in the same codebase/deployment context. + const factory FunctionScope.current() = _CurrentFunctionScope; + + /// Targets a global function (e.g. without kit or extension namespacing). + const factory FunctionScope.global() = _GlobalFunctionScope; + + /// Targets a function deployed within a specific Firebase Extension. + const factory FunctionScope.extension(String extensionId) = + _ExtensionFunctionScope; +} + +class _GlobalFunctionScope extends FunctionScope { + const _GlobalFunctionScope() : super._(); + + @override + String toString() => 'FunctionScope.global()'; +} + +class _ExtensionFunctionScope extends FunctionScope { + const _ExtensionFunctionScope(this.extensionId) : super._(); + + final String extensionId; + + @override + String toString() => 'FunctionScope.extension($extensionId)'; +} + +class _KitFunctionScope extends FunctionScope { + const _KitFunctionScope(this.kit) : super._(); + + final String kit; + + @override + String toString() => 'FunctionScope.kit($kit)'; +} + +class _CurrentFunctionScope extends FunctionScope { + const _CurrentFunctionScope() : super._(); + + @override + String toString() => 'FunctionScope.current()'; +} + +class _ExtensionOrKitFunctionScope extends FunctionScope { + const _ExtensionOrKitFunctionScope(this.instance) : super._(); + + final String instance; + + @override + String toString() => 'FunctionScope.extensionOrKit($instance)'; +} diff --git a/packages/firebase_admin_sdk/lib/src/functions/functions_request_handler.dart b/packages/firebase_admin_sdk/lib/src/functions/functions_request_handler.dart index 00f754f8..41a8acb2 100644 --- a/packages/firebase_admin_sdk/lib/src/functions/functions_request_handler.dart +++ b/packages/firebase_admin_sdk/lib/src/functions/functions_request_handler.dart @@ -39,7 +39,7 @@ class FunctionsRequestHandler { Future enqueue( Map data, String functionName, - String? extensionId, + FunctionScope scope, TaskOptions? options, ) async { validateNonEmptyString(functionName, 'functionName'); @@ -54,17 +54,16 @@ class FunctionsRequestHandler { validateNonEmptyString(resources.resourceId, 'resourceId'); - // Apply extension ID prefix if provided - var queueId = resources.resourceId; - if (extensionId != null && extensionId.isNotEmpty) { - queueId = 'ext-$extensionId-$queueId'; - } + final (queueId, extensionOrKitId) = _resolveResourceId( + resources.resourceId, + scope, + ); // Build the task final task = _buildTask(data, resources, queueId, options); // Update task with proper authentication (OIDC token or Authorization header) - await _updateTaskAuth(task, await _httpClient.client, extensionId); + await _updateTaskAuth(task, await _httpClient.client, extensionOrKitId); final parent = _httpClient.buildTasksParent( projectId: resources.projectId!, @@ -94,7 +93,7 @@ class FunctionsRequestHandler { Future delete( String id, String functionName, - String? extensionId, + FunctionScope scope, ) async { validateNonEmptyString(functionName, 'functionName'); validateNonEmptyString(id, 'id'); @@ -117,11 +116,7 @@ class FunctionsRequestHandler { validateNonEmptyString(resources.resourceId, 'resourceId'); - // Apply extension ID prefix if provided - var queueId = resources.resourceId; - if (extensionId != null && extensionId.isNotEmpty) { - queueId = 'ext-$extensionId-$queueId'; - } + final (queueId, _) = _resolveResourceId(resources.resourceId, scope); // Build the full task name final taskName = _httpClient.buildTaskName( @@ -143,6 +138,28 @@ class FunctionsRequestHandler { }); } + (String resourceId, String? extensionOrKitId) _resolveResourceId( + String resourceId, + FunctionScope scope, + ) { + switch (scope) { + case _CurrentFunctionScope(): + final kitInstanceId = Environment.getKitInstanceId(); + if (kitInstanceId != null && kitInstanceId.isNotEmpty) { + return ('kit-$kitInstanceId-$resourceId', kitInstanceId); + } + return (resourceId, null); + case _GlobalFunctionScope(): + return (resourceId, null); + case _ExtensionFunctionScope(:final extensionId): + return ('ext-$extensionId-$resourceId', extensionId); + case _KitFunctionScope(:final kit): + return ('kit-$kit-$resourceId', kit); + case _ExtensionOrKitFunctionScope(:final instance): + return ('ext-$instance-$resourceId', instance); + } + } + /// Parses a resource name into its components. /// /// Supports: @@ -255,7 +272,7 @@ class FunctionsRequestHandler { Future _updateTaskAuth( tasks2.Task task, googleapis_auth.AuthClient authClient, - String? extensionId, + String? extensionOrKitId, ) async { final httpRequest = task.httpRequest!; @@ -271,7 +288,9 @@ class FunctionsRequestHandler { final isComputeEngine = _httpClient.app.options.credential?.serviceAccountCredentials == null; - if (extensionId != null && extensionId.isNotEmpty && isComputeEngine) { + if (extensionOrKitId != null && + extensionOrKitId.isNotEmpty && + isComputeEngine) { // Running as extension with ComputeEngine - use ID token with Authorization header. final idToken = authClient.credentials.idToken; if (idToken != null && idToken.isNotEmpty) { diff --git a/packages/firebase_admin_sdk/lib/src/functions/task_queue.dart b/packages/firebase_admin_sdk/lib/src/functions/task_queue.dart index aaa3c15d..6dd0330b 100644 --- a/packages/firebase_admin_sdk/lib/src/functions/task_queue.dart +++ b/packages/firebase_admin_sdk/lib/src/functions/task_queue.dart @@ -23,18 +23,44 @@ class TaskQueue { required String functionName, required FunctionsRequestHandler requestHandler, String? extensionId, + FunctionScope? scope, }) : _functionName = functionName, - _requestHandler = requestHandler, - _extensionId = extensionId { + _requestHandler = requestHandler { validateNonEmptyString(_functionName, 'functionName'); - if (_extensionId != null) { - validateString(_extensionId, 'extensionId'); + + if (extensionId != null && scope != null) { + throw ArgumentError('Cannot set both extensionId and scope.'); + } + + if (extensionId != null) { + validateString(extensionId, 'extensionId'); + _scope = extensionId.isEmpty + ? const FunctionScope.current() + : _ExtensionOrKitFunctionScope(extensionId); + } else { + _scope = scope ?? const FunctionScope.current(); + } + + if (_scope is _ExtensionOrKitFunctionScope) { + final instance = (_scope as _ExtensionOrKitFunctionScope).instance; + final kitInstanceId = Environment.getKitInstanceId(); + if (kitInstanceId != null && + kitInstanceId.isNotEmpty && + kitInstanceId == instance) { + _scope = _KitFunctionScope(instance); + print( + 'Targeting your own extension or kit no longer requires a second parameter, ' + 'which can have performance implications. Please change the call ' + "taskQueue('$_functionName', '$instance') to taskQueue('$_functionName') " + "or taskQueue('$_functionName', scope: FunctionScope.current())", + ); + } } } final String _functionName; final FunctionsRequestHandler _requestHandler; - final String? _extensionId; + late FunctionScope _scope; /// Enqueues a task with the given [data] payload. /// @@ -59,8 +85,28 @@ class TaskQueue { /// ``` /// /// Throws [FirebaseFunctionsAdminException] if the request fails. - Future enqueue(Map data, [TaskOptions? options]) { - return _requestHandler.enqueue(data, _functionName, _extensionId, options); + Future enqueue( + Map data, [ + TaskOptions? options, + ]) async { + final currentScope = _scope; + if (currentScope is! _ExtensionOrKitFunctionScope) { + await _requestHandler.enqueue(data, _functionName, currentScope, options); + return; + } + + try { + await _requestHandler.enqueue(data, _functionName, currentScope, options); + } on FirebaseFunctionsAdminException catch (err) { + if (err.errorCode != FunctionsClientErrorCode.notFound) { + rethrow; + } + final tempKitScope = _KitFunctionScope(currentScope.instance); + await _requestHandler.enqueue(data, _functionName, tempKitScope, options); + // Only upgrade the stateful scope to kit if the retry request succeeds + _scope = tempKitScope; + _logFallbackWarning(_functionName, currentScope.instance); + } } /// Deletes a task from the queue by its [id]. @@ -74,7 +120,40 @@ class TaskQueue { /// ``` /// /// Throws [FirebaseFunctionsAdminException] if the request fails. - Future delete(String id) { - return _requestHandler.delete(id, _functionName, _extensionId); + Future delete(String id) async { + try { + final currentScope = _scope; + if (currentScope is! _ExtensionOrKitFunctionScope) { + await _requestHandler.delete(id, _functionName, currentScope); + return; + } + + try { + await _requestHandler.delete(id, _functionName, currentScope); + } on FirebaseFunctionsAdminException catch (err) { + if (err.errorCode != FunctionsClientErrorCode.notFound) { + rethrow; + } + // Not found, try fallback to kit scope. + final tempKitScope = _KitFunctionScope(currentScope.instance); + await _requestHandler.delete(id, _functionName, tempKitScope); + // Only upgrade the stateful scope to kit if the retry request succeeds + _scope = tempKitScope; + _logFallbackWarning(_functionName, currentScope.instance); + } + } on FirebaseFunctionsAdminException catch (err) { + if (err.errorCode != FunctionsClientErrorCode.notFound) { + rethrow; + } + // Swallow notFound errors as delete is idempotent. + } + } + + void _logFallbackWarning(String functionName, String instance) { + print( + 'Targeting kit $instance with the legacy extensions API, ' + 'which has performance implications. Please change the call ' + "taskQueue('$functionName', '$instance') to taskQueue('$functionName')", + ); } } diff --git a/packages/firebase_admin_sdk/test/unit/functions/functions_test.dart b/packages/firebase_admin_sdk/test/unit/functions/functions_test.dart index 23e41932..5c7460d6 100644 --- a/packages/firebase_admin_sdk/test/unit/functions/functions_test.dart +++ b/packages/firebase_admin_sdk/test/unit/functions/functions_test.dart @@ -126,6 +126,7 @@ Future createTestAuthClient({ void main() { setUpAll(() { registerFallbackValue(FakeBaseRequest()); + registerFallbackValue(const FunctionScope.current()); }); // =========================================================================== @@ -203,7 +204,7 @@ void main() { () => mockHandler.enqueue( {'message': 'Hello, World!'}, 'helloWorld', - null, + any(), null, ), ).called(1); @@ -223,7 +224,7 @@ void main() { () => mockHandler.enqueue( {'message': 'Delayed task'}, 'helloWorld', - null, + any(), options, ), ).called(1); @@ -244,7 +245,7 @@ void main() { () => mockHandler.enqueue( {'message': 'Scheduled task'}, 'helloWorld', - null, + any(), options, ), ).called(1); @@ -264,7 +265,7 @@ void main() { () => mockHandler.enqueue( {'message': 'Task with ID'}, 'helloWorld', - null, + any(), options, ), ).called(1); @@ -282,12 +283,8 @@ void main() { await queue.enqueue({'data': 'test'}); verify( - () => mockHandler.enqueue( - {'data': 'test'}, - 'helloWorld', - 'my-extension', - null, - ), + () => + mockHandler.enqueue({'data': 'test'}, 'helloWorld', any(), null), ).called(1); }); @@ -319,7 +316,7 @@ void main() { await queue.delete('task-to-delete'); verify( - () => mockHandler.delete('task-to-delete', 'helloWorld', null), + () => mockHandler.delete('task-to-delete', 'helloWorld', any()), ).called(1); }); @@ -335,7 +332,7 @@ void main() { await queue.delete('task-id'); verify( - () => mockHandler.delete('task-id', 'helloWorld', 'my-extension'), + () => mockHandler.delete('task-id', 'helloWorld', any()), ).called(1); }); @@ -348,7 +345,7 @@ void main() { await queue.delete('non-existent-task'); verify( - () => mockHandler.delete('non-existent-task', 'helloWorld', null), + () => mockHandler.delete('non-existent-task', 'helloWorld', any()), ).called(1); }); @@ -407,7 +404,7 @@ void main() { group('enqueue validation', () { test('throws on empty function name', () { expect( - () => handler.enqueue({}, '', null, null), + () => handler.enqueue({}, '', const FunctionScope.current(), null), throwsA(isA()), ); }); @@ -417,7 +414,7 @@ void main() { () => handler.enqueue( {}, 'project/abc/locations/east/fname', - null, + const FunctionScope.current(), null, ), throwsA(isA()), @@ -426,14 +423,19 @@ void main() { test('throws on invalid function name with double slashes', () { expect( - () => handler.enqueue({}, '//', null, null), + () => handler.enqueue({}, '//', const FunctionScope.current(), null), throwsA(isA()), ); }); test('throws on function name with trailing slash', () { expect( - () => handler.enqueue({}, 'location/west/', null, null), + () => handler.enqueue( + {}, + 'location/west/', + const FunctionScope.current(), + null, + ), throwsA(isA()), ); }); @@ -442,49 +444,69 @@ void main() { group('delete validation', () { test('throws on empty task ID', () { expect( - () => handler.delete('', 'helloWorld', null), + () => handler.delete('', 'helloWorld', const FunctionScope.current()), throwsA(isA()), ); }); test('throws on empty function name', () { expect( - () => handler.delete('task-id', '', null), + () => handler.delete('task-id', '', const FunctionScope.current()), throwsA(isA()), ); }); test('throws on invalid task ID with special characters', () { expect( - () => handler.delete('task!', 'helloWorld', null), + () => handler.delete( + 'task!', + 'helloWorld', + const FunctionScope.current(), + ), throwsA(isA()), ); }); test('throws on invalid task ID with colons', () { expect( - () => handler.delete('id:0', 'helloWorld', null), + () => handler.delete( + 'id:0', + 'helloWorld', + const FunctionScope.current(), + ), throwsA(isA()), ); }); test('throws on invalid task ID with brackets', () { expect( - () => handler.delete('[1234]', 'helloWorld', null), + () => handler.delete( + '[1234]', + 'helloWorld', + const FunctionScope.current(), + ), throwsA(isA()), ); }); test('throws on invalid task ID with parentheses', () { expect( - () => handler.delete('(1234)', 'helloWorld', null), + () => handler.delete( + '(1234)', + 'helloWorld', + const FunctionScope.current(), + ), throwsA(isA()), ); }); test('throws on invalid task ID with slashes', () { expect( - () => handler.delete('invalid/task/id', 'helloWorld', null), + () => handler.delete( + 'invalid/task/id', + 'helloWorld', + const FunctionScope.current(), + ), throwsA(isA()), ); }); @@ -1339,4 +1361,202 @@ void main() { } }); }); + + group('Task Queue Scopes', () { + test('FunctionScope factory constructors create correct types', () { + expect(const FunctionScope.current(), isA()); + expect(const FunctionScope.global(), isA()); + expect(const FunctionScope.extension('my-ext'), isA()); + }); + + test( + 'throws ArgumentError when both extensionId and scope are provided', + () { + final mockHandler = MockRequestHandler(); + final functions = createFunctionsWithMockHandler(mockHandler); + expect( + () => functions.taskQueue( + 'helloWorld', + extensionId: 'my-ext', + scope: const FunctionScope.global(), + ), + throwsA(isA()), + ); + }, + ); + + test('warns when targeting own kit via legacy string', () { + final mockHandler = MockRequestHandler(); + final functions = createFunctionsWithMockHandler(mockHandler); + + final printedLogs = []; + runZoned( + () { + functions.taskQueue('helloWorld', extensionId: 'my-kit'); + }, + zoneValues: { + envSymbol: {'FIREBASE_KIT_INSTANCE_ID': 'my-kit'}, + }, + zoneSpecification: ZoneSpecification( + print: (self, parent, zone, line) { + printedLogs.add(line); + }, + ), + ); + + expect(printedLogs, hasLength(1)); + expect( + printedLogs.first, + contains( + "Targeting your own extension or kit no longer requires a second parameter, which can have performance implications. Please change the call taskQueue('helloWorld', 'my-kit') to taskQueue('helloWorld') or taskQueue('helloWorld', scope: FunctionScope.current())", + ), + ); + }); + + test('fallback to kit on 404 for enqueue', () async { + final mockHandler = MockRequestHandler(); + final functions = createFunctionsWithMockHandler(mockHandler); + final queue = functions.taskQueue( + 'helloWorld', + extensionId: 'other-inst', + ); + + var callCount = 0; + when(() => mockHandler.enqueue(any(), any(), any(), any())).thenAnswer(( + invocation, + ) async { + callCount++; + final scope = invocation.positionalArguments[2] as FunctionScope; + if (callCount == 1) { + expect( + scope.toString(), + equals('FunctionScope.extensionOrKit(other-inst)'), + ); + throw FirebaseFunctionsAdminException( + FunctionsClientErrorCode.notFound, + 'Queue not found', + ); + } else if (callCount == 2) { + expect(scope.toString(), equals('FunctionScope.kit(other-inst)')); + return; + } + }); + + final printedLogs = []; + await runZoned( + () async { + await queue.enqueue({'data': 'val'}); + }, + zoneSpecification: ZoneSpecification( + print: (self, parent, zone, line) { + printedLogs.add(line); + }, + ), + ); + + expect(callCount, equals(2)); + expect(printedLogs, hasLength(1)); + expect( + printedLogs.first, + contains( + "Targeting kit other-inst with the legacy extensions API, which has performance implications. Please change the call taskQueue('helloWorld', 'other-inst') to taskQueue('helloWorld')", + ), + ); + + // Verify subsequent enqueue goes directly to kit without fallback/retry or warning + callCount = 0; + printedLogs.clear(); + when(() => mockHandler.enqueue(any(), any(), any(), any())).thenAnswer(( + invocation, + ) async { + callCount++; + final scope = invocation.positionalArguments[2] as FunctionScope; + expect(scope.toString(), equals('FunctionScope.kit(other-inst)')); + return; + }); + + await runZoned( + () async { + await queue.enqueue({'data': 'val'}); + }, + zoneSpecification: ZoneSpecification( + print: (self, parent, zone, line) { + printedLogs.add(line); + }, + ), + ); + expect(callCount, equals(1)); + expect(printedLogs, isEmpty); + }); + + test('fallback to kit on 404 for delete', () async { + final mockHandler = MockRequestHandler(); + final functions = createFunctionsWithMockHandler(mockHandler); + final queue = functions.taskQueue( + 'helloWorld', + extensionId: 'other-inst', + ); + + var callCount = 0; + when(() => mockHandler.delete(any(), any(), any())).thenAnswer(( + invocation, + ) async { + callCount++; + final scope = invocation.positionalArguments[2] as FunctionScope; + if (callCount == 1) { + expect( + scope.toString(), + equals('FunctionScope.extensionOrKit(other-inst)'), + ); + throw FirebaseFunctionsAdminException( + FunctionsClientErrorCode.notFound, + 'Task or queue not found', + ); + } else if (callCount == 2) { + expect(scope.toString(), equals('FunctionScope.kit(other-inst)')); + return; + } + }); + + final printedLogs = []; + await runZoned( + () async { + await queue.delete('some-task'); + }, + zoneSpecification: ZoneSpecification( + print: (self, parent, zone, line) { + printedLogs.add(line); + }, + ), + ); + + expect(callCount, equals(2)); + expect(printedLogs, hasLength(1)); + + // Subsequent delete goes directly to kit + callCount = 0; + printedLogs.clear(); + when(() => mockHandler.delete(any(), any(), any())).thenAnswer(( + invocation, + ) async { + callCount++; + final scope = invocation.positionalArguments[2] as FunctionScope; + expect(scope.toString(), equals('FunctionScope.kit(other-inst)')); + return; + }); + + await runZoned( + () async { + await queue.delete('some-task'); + }, + zoneSpecification: ZoneSpecification( + print: (self, parent, zone, line) { + printedLogs.add(line); + }, + ), + ); + expect(callCount, equals(1)); + expect(printedLogs, isEmpty); + }); + }); }