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); + }); + }); }