Skip to content

Commit eda5baa

Browse files
committed
feat(functions): Add FunctionScope and update task_queue to support kit scopes
Added FunctionScope class, updated task_queue signature, and refactored TaskQueue to implement rollback-safe recursive fallback targeting kit queue when hitting a 404 response.
1 parent b40e738 commit eda5baa

2 files changed

Lines changed: 411 additions & 37 deletions

File tree

firebase_admin/functions.py

Lines changed: 172 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@
1919
from urllib import parse
2020
import re
2121
import os
22+
import warnings
2223
import json
2324
from base64 import b64encode
24-
from typing import Any, Optional, Dict
25+
from typing import Any, Optional, Dict, Union
2526
from dataclasses import dataclass
2627

2728
from google.auth.compute_engine import Credentials as ComputeEngineCredentials
@@ -38,12 +39,53 @@
3839
_FUNCTIONS_ATTRIBUTE = '_functions'
3940

4041
__all__ = [
42+
'FunctionScope',
4143
'TaskOptions',
4244

4345
'task_queue',
4446
]
4547

4648

49+
class FunctionScope:
50+
"""Defines the scope of a function in a task queue."""
51+
52+
def __init__(self, type_: str, instance_id: Optional[str] = None):
53+
if type_ not in (
54+
'current', 'global', 'extension', 'kit', 'extension_or_kit'):
55+
# "kit" is not released yet and "extension_or_kit" is an implementation detail,
56+
# so they are not included in the public list of options.
57+
raise ValueError(
58+
'type_ must be one of "current", "global", or "extension".'
59+
)
60+
if type_ in ('extension', 'kit', 'extension_or_kit'):
61+
if not isinstance(instance_id, str):
62+
raise ValueError('instance_id must be a string.')
63+
if instance_id == '':
64+
raise ValueError('instance_id must be a non-empty string.')
65+
else:
66+
if instance_id is not None:
67+
raise ValueError(
68+
f'instance_id must not be set for type "{type_}".'
69+
)
70+
self.type = type_
71+
self.instance_id = instance_id
72+
73+
@classmethod
74+
def current(cls):
75+
"""Creates a FunctionScope targeting the current extension or kit context."""
76+
return cls("current")
77+
78+
@classmethod
79+
def global_scope(cls):
80+
"""Creates a FunctionScope targeting a global function."""
81+
return cls("global")
82+
83+
@classmethod
84+
def extension(cls, extension_id: str):
85+
"""Creates a FunctionScope targeting a specific extension instance."""
86+
return cls("extension", instance_id=extension_id)
87+
88+
4789
_CLOUD_TASKS_API_RESOURCE_PATH = \
4890
'projects/{project_id}/locations/{location_id}/queues/{resource_id}/tasks'
4991
_CLOUD_TASKS_API_URL_FORMAT = \
@@ -77,8 +119,9 @@ def _get_functions_service(app) -> _FunctionsService:
77119

78120
def task_queue(
79121
function_name: str,
80-
extension_id: Optional[str] = None,
81-
app: Optional[App] = None
122+
scope: Optional[Union[str, FunctionScope]] = None,
123+
app: Optional[App] = None,
124+
**kwargs
82125
) -> TaskQueue:
83126
"""Creates a reference to a TaskQueue for a given function name.
84127
@@ -96,16 +139,31 @@ def task_queue(
96139
97140
Args:
98141
function_name: Name of the function.
99-
extension_id: Firebase extension ID (optional).
142+
scope: FunctionScope configuration or Firebase extension ID (optional).
100143
app: An App instance (optional).
144+
**kwargs: Backwards-compatible parameters (e.g., extension_id).
101145
102146
Returns:
103147
TaskQueue: A TaskQueue instance.
104148
105149
Raises:
106150
ValueError: If the input arguments are invalid.
107151
"""
108-
return _get_functions_service(app).task_queue(function_name, extension_id)
152+
extension_id = kwargs.pop('extension_id', None)
153+
if kwargs:
154+
raise TypeError(f"task_queue() got an unexpected keyword argument '{next(iter(kwargs))}'")
155+
156+
if extension_id is not None:
157+
if scope is not None:
158+
raise ValueError('Cannot set both scope and extension_id.')
159+
warnings.warn(
160+
'extension_id is deprecated. Use scope instead.',
161+
DeprecationWarning,
162+
stacklevel=2
163+
)
164+
scope = extension_id
165+
166+
return _get_functions_service(app).task_queue(function_name, scope)
109167

110168
class _FunctionsService:
111169
"""Service class that implements Firebase Functions functionality."""
@@ -125,10 +183,14 @@ def __init__(self, app: App):
125183

126184
self._http_client = _http_client.JsonHttpClient(credential=self._credential)
127185

128-
def task_queue(self, function_name: str, extension_id: Optional[str] = None) -> TaskQueue:
186+
def task_queue(
187+
self,
188+
function_name: str,
189+
scope: Optional[Union[str, FunctionScope]] = None
190+
) -> TaskQueue:
129191
"""Creates a TaskQueue instance."""
130192
return TaskQueue(
131-
function_name, extension_id, self._project_id, self._credential, self._http_client,
193+
function_name, scope, self._project_id, self._credential, self._http_client,
132194
self._emulator_host)
133195

134196
@classmethod
@@ -142,7 +204,7 @@ class TaskQueue:
142204
def __init__(
143205
self,
144206
function_name: str,
145-
extension_id: Optional[str],
207+
scope: Optional[Union[str, FunctionScope]],
146208
project_id,
147209
credential,
148210
http_client,
@@ -157,19 +219,78 @@ def __init__(
157219
self._http_client = http_client
158220
self._emulator_host = emulator_host
159221
self._function_name = function_name
160-
self._extension_id = extension_id
222+
223+
# Determine the scope
224+
if scope is None:
225+
self._scope = FunctionScope.current()
226+
elif isinstance(scope, str):
227+
_Validators.check_non_empty_string('scope', scope)
228+
self._scope = FunctionScope('extension_or_kit', scope)
229+
elif isinstance(scope, FunctionScope):
230+
self._scope = scope
231+
else:
232+
raise ValueError('scope must be a string or a FunctionScope object.')
233+
161234
# Parse resources from function_name
162235
self._resource = self._parse_resource_name(self._function_name, 'functions')
163236

164237
# Apply defaults and validate resource_id
165238
self._resource.project_id = self._resource.project_id or self._project_id
166239
self._resource.location_id = self._resource.location_id or _DEFAULT_LOCATION
167240
_Validators.check_non_empty_string('resource.resource_id', self._resource.resource_id)
168-
# Validate extension_id if provided and edit resources depending
169-
if self._extension_id is not None:
170-
_Validators.check_non_empty_string('extension_id', self._extension_id)
171-
self._resource.resource_id = f'ext-{self._extension_id}-{self._resource.resource_id}'
172241

242+
def _resolve_resource(self, scope: FunctionScope) -> tuple[Resource, Optional[str]]:
243+
"""Resolves the Resource object and extension/kit ID based on the scope."""
244+
resource_id = self._resource.resource_id
245+
extension_or_kit_id = None
246+
247+
if scope.type == 'current':
248+
kit_instance_id = os.environ.get('KIT_INSTANCE_ID')
249+
if kit_instance_id:
250+
resource_id = f'kit-{kit_instance_id}-{resource_id}'
251+
extension_or_kit_id = kit_instance_id
252+
elif scope.type == 'global':
253+
pass
254+
elif scope.type in ('extension', 'extension_or_kit'):
255+
resource_id = f'ext-{scope.instance_id}-{resource_id}'
256+
extension_or_kit_id = scope.instance_id
257+
elif scope.type == 'kit':
258+
resource_id = f'kit-{scope.instance_id}-{resource_id}'
259+
extension_or_kit_id = scope.instance_id
260+
261+
resolved = Resource(
262+
resource_id=resource_id,
263+
project_id=self._resource.project_id,
264+
location_id=self._resource.location_id
265+
)
266+
return resolved, extension_or_kit_id
267+
268+
def _log_fallback_warning(self, function_name: str, instance: str) -> None:
269+
"""Logs a warning when falling back from extension to kit."""
270+
warnings.warn(
271+
f"Targeting kit {instance} with the legacy extensions API, "
272+
"which has performance implications. Please change the call "
273+
f"task_queue('{function_name}', '{instance}') to "
274+
f"task_queue('{function_name}', FunctionScope('kit', '{instance}'))",
275+
UserWarning,
276+
stacklevel=3
277+
)
278+
279+
def _parse_enqueue_response(self, resp: dict, resolved_resource: Resource) -> str:
280+
"""Parses the enqueue response to extract the task ID."""
281+
if self._is_emulated():
282+
# Emulator returns a response with format {task: {name: <task_name>}}
283+
# The task name also has an extra '/' at the start compared to prod
284+
task_info = resp.get('task') or {}
285+
task_name = task_info.get('name')
286+
if task_name:
287+
task_name = task_name[1:]
288+
else:
289+
# Production returns a response with format {name: <task_name>}
290+
task_name = resp.get('name')
291+
task_resource = self._parse_resource_name(
292+
task_name, f'queues/{resolved_resource.resource_id}/tasks')
293+
return task_resource.resource_id
173294

174295
def enqueue(self, task_data: Any, opts: Optional[TaskOptions] = None) -> str:
175296
"""Creates a task and adds it to the queue. Tasks cannot be updated after creation.
@@ -188,32 +309,34 @@ def enqueue(self, task_data: Any, opts: Optional[TaskOptions] = None) -> str:
188309
Returns:
189310
str: The ID of the task relative to this queue.
190311
"""
191-
task = self._validate_task_options(task_data, self._resource, opts)
192-
emulator_url = self._get_emulator_url(self._resource)
193-
service_url = emulator_url or self._get_url(self._resource, _CLOUD_TASKS_API_URL_FORMAT)
194-
task_payload = self._update_task_payload(task, self._resource, self._extension_id)
312+
resolved_resource, ext_or_kit_id = self._resolve_resource(self._scope)
313+
task = self._validate_task_options(task_data, resolved_resource, opts)
314+
service_url = self._get_emulator_url(resolved_resource) or self._get_url(
315+
resolved_resource, _CLOUD_TASKS_API_URL_FORMAT)
316+
task_payload = self._update_task_payload(task, resolved_resource, ext_or_kit_id)
195317
try:
196318
resp = self._http_client.body(
197319
'post',
198320
url=service_url,
199321
headers=_FUNCTIONS_HEADERS,
200322
json={'task': task_payload.to_api_dict()}
201323
)
202-
if self._is_emulated():
203-
# Emulator returns a response with format {task: {name: <task_name>}}
204-
# The task name also has an extra '/' at the start compared to prod
205-
task_info = resp.get('task') or {}
206-
task_name = task_info.get('name')
207-
if task_name:
208-
task_name = task_name[1:]
209-
else:
210-
# Production returns a response with format {name: <task_name>}
211-
task_name = resp.get('name')
212-
task_resource = \
213-
self._parse_resource_name(task_name, f'queues/{self._resource.resource_id}/tasks')
214-
return task_resource.resource_id
324+
return self._parse_enqueue_response(resp, resolved_resource)
215325
except requests.exceptions.RequestException as error:
216-
raise _FunctionsService.handle_functions_error(error)
326+
is_404 = error.response is not None and error.response.status_code == 404
327+
if self._scope.type != 'extension_or_kit' or not is_404:
328+
raise _FunctionsService.handle_functions_error(error)
329+
330+
# Upgrade scope to kit and retry recursively, rolling back if it throws
331+
old_scope = self._scope
332+
self._scope = FunctionScope('kit', self._scope.instance_id)
333+
try:
334+
resp = self.enqueue(task_data, opts)
335+
self._log_fallback_warning(self._function_name, self._scope.instance_id)
336+
return resp
337+
except Exception:
338+
self._scope = old_scope
339+
raise
217340

218341
def delete(self, task_id: str) -> None:
219342
"""Deletes an enqueued task if it has not yet started.
@@ -229,19 +352,34 @@ def delete(self, task_id: str) -> None:
229352
ValueError: If the input arguments are invalid.
230353
"""
231354
_Validators.check_non_empty_string('task_id', task_id)
232-
emulator_url = self._get_emulator_url(self._resource)
355+
356+
resolved_resource, _ = self._resolve_resource(self._scope)
357+
emulator_url = self._get_emulator_url(resolved_resource)
233358
if emulator_url:
234359
service_url = emulator_url + f'/{task_id}'
235360
else:
236-
service_url = self._get_url(self._resource, _CLOUD_TASKS_API_URL_FORMAT + f'/{task_id}')
361+
service_url = self._get_url(
362+
resolved_resource, _CLOUD_TASKS_API_URL_FORMAT + f'/{task_id}')
237363
try:
238364
self._http_client.body(
239365
'delete',
240366
url=service_url,
241367
headers=_FUNCTIONS_HEADERS,
242368
)
243369
except requests.exceptions.RequestException as error:
244-
raise _FunctionsService.handle_functions_error(error)
370+
is_404 = error.response is not None and error.response.status_code == 404
371+
if not is_404:
372+
raise _FunctionsService.handle_functions_error(error)
373+
374+
if self._scope.type == 'extension_or_kit':
375+
old_scope = self._scope
376+
self._scope = FunctionScope('kit', self._scope.instance_id)
377+
try:
378+
self.delete(task_id)
379+
self._log_fallback_warning(self._function_name, self._scope.instance_id)
380+
except Exception:
381+
self._scope = old_scope
382+
raise
245383

246384

247385
def _parse_resource_name(self, resource_name: str, resource_id_key: str) -> Resource:

0 commit comments

Comments
 (0)