From 1ed9a3707dcbade43e0acfe3159414461b564460 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Sat, 26 Sep 2026 12:57:19 +0530 Subject: [PATCH 1/9] fix(firestore): cache Firestore trigger client to reuse credentials --- src/firebase_functions/firestore_fn.py | 13 ++++++++- tests/test_firestore_fn.py | 40 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/firebase_functions/firestore_fn.py b/src/firebase_functions/firestore_fn.py index 5a2844b8..ea26576e 100644 --- a/src/firebase_functions/firestore_fn.py +++ b/src/firebase_functions/firestore_fn.py @@ -44,6 +44,8 @@ _event_type_updated_with_auth_context = "google.cloud.firestore.document.v1.updated.withAuthContext" _event_type_deleted_with_auth_context = "google.cloud.firestore.document.v1.deleted.withAuthContext" +_firestore_clients: dict[tuple[str, str], _firestore_v1.Client] = {} + @_dataclass.dataclass(frozen=True) class Event(_core.CloudEvent[_core.T]): @@ -142,7 +144,16 @@ def _firestore_endpoint_handler( if _DEFAULT_APP_NAME not in _apps: initialize_app() app = get_app() - firestore_client = _firestore_v1.Client(project=app.project_id, database=event_database) + + client_key = (app.project_id, event_database) + if client_key not in _firestore_clients: + _firestore_clients[client_key] = _firestore_v1.Client( + project=app.project_id, + database=event_database, + credentials=app.credential.get_credential(), + ) + firestore_client = _firestore_clients[client_key] + firestore_ref: DocumentReference = firestore_client.document(event_document) value_snapshot: DocumentSnapshot | None = None old_value_snapshot: DocumentSnapshot | None = None diff --git a/tests/test_firestore_fn.py b/tests/test_firestore_fn.py index 0e07b102..cbcf9d70 100644 --- a/tests/test_firestore_fn.py +++ b/tests/test_firestore_fn.py @@ -102,3 +102,43 @@ def init(): decorated_func(raw_event) self.assertEqual(hello, "world") + + def test_firestore_client_is_cached(self): + with patch.dict("sys.modules", mocked_modules): + from cloudevents.http import CloudEvent + from firebase_functions import firestore_fn + + firestore_fn._firestore_clients.clear() + + func = Mock(__name__="example_func") + attributes = { + "specversion": "1.0", + "type": firestore_fn._event_type_created, + "source": "https://example.com/testevent", + "time": "2023-03-11T13:25:37.403Z", + "subject": "test_subject", + "datacontenttype": "application/json", + "location": "projects/project-id/databases/(default)/documents/foo/{bar}", + "project": "project-id", + "namespace": "(default)", + "document": "foo/{bar}", + "database": "projects/project-id/databases/(default)", + "authtype": "unauthenticated", + "authid": "foo", + } + raw_event = CloudEvent(attributes=attributes, data=json.dumps({})) + decorated_func = firestore_fn.on_document_created(document="/foo/{bar}")(func) + + mock_client_cls = mocked_modules["google.cloud.firestore_v1"].Client + mock_client_cls.reset_mock() + + decorated_func(raw_event) + decorated_func(raw_event) + decorated_func(raw_event) + + self.assertEqual(mock_client_cls.call_count, 1) + mock_client_cls.assert_called_with( + project=mocked_modules["firebase_admin"].get_app().project_id, + database="projects/project-id/databases/(default)", + credentials=mocked_modules["firebase_admin"].get_app().credential.get_credential() + ) From 6e1efd27475f0bced22b8398bca358b52f02d50d Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Sat, 26 Sep 2026 13:04:54 +0530 Subject: [PATCH 2/9] fix(firestore): synchronize cached client initialization --- src/firebase_functions/firestore_fn.py | 14 +- tests/test_firestore_fn.py | 192 ++++++++++++++++++++++++- 2 files changed, 198 insertions(+), 8 deletions(-) diff --git a/src/firebase_functions/firestore_fn.py b/src/firebase_functions/firestore_fn.py index ea26576e..5147991a 100644 --- a/src/firebase_functions/firestore_fn.py +++ b/src/firebase_functions/firestore_fn.py @@ -18,6 +18,7 @@ # pylint: disable=protected-access import dataclasses as _dataclass import functools as _functools +import threading as _threading import typing as _typing import cloudevents.http as _ce @@ -45,6 +46,7 @@ _event_type_deleted_with_auth_context = "google.cloud.firestore.document.v1.deleted.withAuthContext" _firestore_clients: dict[tuple[str, str], _firestore_v1.Client] = {} +_firestore_clients_lock = _threading.Lock() @_dataclass.dataclass(frozen=True) @@ -147,11 +149,13 @@ def _firestore_endpoint_handler( client_key = (app.project_id, event_database) if client_key not in _firestore_clients: - _firestore_clients[client_key] = _firestore_v1.Client( - project=app.project_id, - database=event_database, - credentials=app.credential.get_credential(), - ) + with _firestore_clients_lock: + if client_key not in _firestore_clients: + _firestore_clients[client_key] = _firestore_v1.Client( + project=app.project_id, + database=event_database, + credentials=app.credential.get_credential(), + ) firestore_client = _firestore_clients[client_key] firestore_ref: DocumentReference = firestore_client.document(event_document) diff --git a/tests/test_firestore_fn.py b/tests/test_firestore_fn.py index cbcf9d70..281b147b 100644 --- a/tests/test_firestore_fn.py +++ b/tests/test_firestore_fn.py @@ -106,10 +106,11 @@ def init(): def test_firestore_client_is_cached(self): with patch.dict("sys.modules", mocked_modules): from cloudevents.http import CloudEvent + from firebase_functions import firestore_fn firestore_fn._firestore_clients.clear() - + func = Mock(__name__="example_func") attributes = { "specversion": "1.0", @@ -131,7 +132,7 @@ def test_firestore_client_is_cached(self): mock_client_cls = mocked_modules["google.cloud.firestore_v1"].Client mock_client_cls.reset_mock() - + decorated_func(raw_event) decorated_func(raw_event) decorated_func(raw_event) @@ -140,5 +141,190 @@ def test_firestore_client_is_cached(self): mock_client_cls.assert_called_with( project=mocked_modules["firebase_admin"].get_app().project_id, database="projects/project-id/databases/(default)", - credentials=mocked_modules["firebase_admin"].get_app().credential.get_credential() + credentials=mocked_modules["firebase_admin"].get_app().credential.get_credential(), + ) + + def test_firestore_client_is_cached_concurrent(self): + with patch.dict("sys.modules", mocked_modules): + import threading + + from cloudevents.http import CloudEvent + + from firebase_functions import firestore_fn + + firestore_fn._firestore_clients.clear() + + func = Mock(__name__="example_func") + attributes = { + "specversion": "1.0", + "type": firestore_fn._event_type_created, + "source": "https://example.com/testevent", + "time": "2023-03-11T13:25:37.403Z", + "subject": "test_subject", + "datacontenttype": "application/json", + "location": "projects/project-id/databases/(default)/documents/foo/{bar}", + "project": "project-id", + "namespace": "(default)", + "document": "foo/{bar}", + "database": "projects/project-id/databases/(default)", + "authtype": "unauthenticated", + "authid": "foo", + } + raw_event = CloudEvent(attributes=attributes, data=json.dumps({})) + decorated_func = firestore_fn.on_document_created(document="/foo/{bar}")(func) + + mock_client_cls = mocked_modules["google.cloud.firestore_v1"].Client + mock_client_cls.reset_mock() + + app = mocked_modules["firebase_admin"].get_app() + get_cred_mock = app.credential.get_credential + get_cred_mock.reset_mock() + + t1_in_critical_section = threading.Event() + t1_can_proceed = threading.Event() + + t2_in_critical_section = threading.Event() + t2_can_proceed = threading.Event() + + def get_credential_side_effect(*args, **kwargs): + if not t1_in_critical_section.is_set(): + t1_in_critical_section.set() + t1_can_proceed.wait() + else: + t2_in_critical_section.set() + t2_can_proceed.wait() + return "mock_cred" + + get_cred_mock.side_effect = get_credential_side_effect + + def thread_task(): + decorated_func(raw_event) + + t1 = threading.Thread(target=thread_task) + t2 = threading.Thread(target=thread_task) + + t1.start() + t1_in_critical_section.wait(timeout=5.0) + + t2.start() + raced = t2_in_critical_section.wait(timeout=0.5) + + t1_can_proceed.set() + t1.join() + + t2_can_proceed.set() + t2.join() + + get_cred_mock.side_effect = None + + self.assertFalse( + raced, + "Race condition detected! Multiple threads entered initialization simultaneously.", + ) + self.assertEqual(mock_client_cls.call_count, 1) + + def test_firestore_client_cache_isolation(self): + with patch.dict("sys.modules", mocked_modules): + from cloudevents.http import CloudEvent + + from firebase_functions import firestore_fn + + firestore_fn._firestore_clients.clear() + + func = Mock(__name__="example_func") + + def create_event(project: str, database: str): + attributes = { + "specversion": "1.0", + "type": firestore_fn._event_type_created, + "source": "https://example.com/testevent", + "time": "2023-03-11T13:25:37.403Z", + "subject": "test_subject", + "datacontenttype": "application/json", + "location": f"projects/{project}/databases/{database}/documents/foo/bar", + "project": project, + "namespace": "(default)", + "document": "foo/bar", + "database": f"projects/{project}/databases/{database}", + "authtype": "unauthenticated", + "authid": "foo", + } + return CloudEvent(attributes=attributes, data=json.dumps({})) + + decorated_func = firestore_fn.on_document_created(document="/foo/{bar}")(func) + + mock_client_cls = mocked_modules["google.cloud.firestore_v1"].Client + mock_client_cls.reset_mock() + + app = mocked_modules["firebase_admin"].get_app() + + # Project A, Database (default) + app.project_id = "project-A" + decorated_func(create_event("project-A", "(default)")) + + # Project A, Database (default) -> Cached + decorated_func(create_event("project-A", "(default)")) + + # Project B, Database (default) -> New client + app.project_id = "project-B" + decorated_func(create_event("project-B", "(default)")) + + # Project A, Database other -> New client + app.project_id = "project-A" + decorated_func(create_event("project-A", "other")) + + self.assertEqual(mock_client_cls.call_count, 3) + + def test_firestore_client_creation_failure_does_not_poison_cache(self): + with patch.dict("sys.modules", mocked_modules): + from cloudevents.http import CloudEvent + + from firebase_functions import firestore_fn + + firestore_fn._firestore_clients.clear() + + func = Mock(__name__="example_func") + attributes = { + "specversion": "1.0", + "type": firestore_fn._event_type_created, + "source": "https://example.com/testevent", + "time": "2023-03-11T13:25:37.403Z", + "subject": "test_subject", + "datacontenttype": "application/json", + "location": "projects/project-id/databases/(default)/documents/foo/bar", + "project": "project-id", + "namespace": "(default)", + "document": "foo/bar", + "database": "projects/project-id/databases/(default)", + "authtype": "unauthenticated", + "authid": "foo", + } + raw_event = CloudEvent(attributes=attributes, data=json.dumps({})) + decorated_func = firestore_fn.on_document_created(document="/foo/{bar}")(func) + + mock_client_cls = mocked_modules["google.cloud.firestore_v1"].Client + app = mocked_modules["firebase_admin"].get_app() + app.project_id = "project-id" + + class InitializationError(Exception): + pass + + mock_client_cls.side_effect = InitializationError("Initialization failed") + + with self.assertRaises(InitializationError, msg="Initialization failed"): + decorated_func(raw_event) + + self.assertNotIn( + ("project-id", "projects/project-id/databases/(default)"), + firestore_fn._firestore_clients, + ) + + mock_client_cls.side_effect = None + mock_client_cls.return_value = MagicMock() + + decorated_func(raw_event) + + self.assertIn( + ("project-id", "projects/project-id/databases/(default)"), + firestore_fn._firestore_clients, ) From 5662916df3ad2aa5379adad778e9106c42543d67 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Mon, 28 Sep 2026 23:03:55 +0530 Subject: [PATCH 3/9] fix(firestore): preserve emulator credentials behavior --- src/firebase_functions/firestore_fn.py | 13 ++++---- tests/test_firestore_fn.py | 41 ++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/firebase_functions/firestore_fn.py b/src/firebase_functions/firestore_fn.py index 5147991a..e2d065fc 100644 --- a/src/firebase_functions/firestore_fn.py +++ b/src/firebase_functions/firestore_fn.py @@ -18,6 +18,7 @@ # pylint: disable=protected-access import dataclasses as _dataclass import functools as _functools +import os as _os import threading as _threading import typing as _typing @@ -151,11 +152,13 @@ def _firestore_endpoint_handler( if client_key not in _firestore_clients: with _firestore_clients_lock: if client_key not in _firestore_clients: - _firestore_clients[client_key] = _firestore_v1.Client( - project=app.project_id, - database=event_database, - credentials=app.credential.get_credential(), - ) + client_kwargs = { + "project": app.project_id, + "database": event_database, + } + if not _os.environ.get("FIRESTORE_EMULATOR_HOST"): + client_kwargs["credentials"] = app.credential.get_credential() + _firestore_clients[client_key] = _firestore_v1.Client(**client_kwargs) firestore_client = _firestore_clients[client_key] firestore_ref: DocumentReference = firestore_client.document(event_document) diff --git a/tests/test_firestore_fn.py b/tests/test_firestore_fn.py index 281b147b..dea9d05e 100644 --- a/tests/test_firestore_fn.py +++ b/tests/test_firestore_fn.py @@ -328,3 +328,44 @@ class InitializationError(Exception): ("project-id", "projects/project-id/databases/(default)"), firestore_fn._firestore_clients, ) + + @patch.dict("os.environ", {"FIRESTORE_EMULATOR_HOST": "localhost:8080"}) + def test_firestore_client_emulator_skips_credentials(self): + with patch.dict("sys.modules", mocked_modules): + from cloudevents.http import CloudEvent + from firebase_functions import firestore_fn + + firestore_fn._firestore_clients.clear() + + func = Mock(__name__="example_func") + attributes = { + "specversion": "1.0", + "type": firestore_fn._event_type_created, + "source": "https://example.com/testevent", + "time": "2023-03-11T13:25:37.403Z", + "subject": "test_subject", + "datacontenttype": "application/json", + "location": "projects/project-id/databases/(default)/documents/foo/bar", + "project": "project-id", + "namespace": "(default)", + "document": "foo/bar", + "database": "projects/project-id/databases/(default)", + "authtype": "unauthenticated", + "authid": "foo", + } + raw_event = CloudEvent(attributes=attributes, data=json.dumps({})) + decorated_func = firestore_fn.on_document_created(document="/foo/{bar}")(func) + + mock_client_cls = mocked_modules["google.cloud.firestore_v1"].Client + mock_client_cls.reset_mock() + app = mocked_modules["firebase_admin"].get_app() + app.credential.get_credential.reset_mock() + + decorated_func(raw_event) + + self.assertEqual(mock_client_cls.call_count, 1) + mock_client_cls.assert_called_with( + project=app.project_id, + database="projects/project-id/databases/(default)", + ) + app.credential.get_credential.assert_not_called() From 2a178cae39f9fd82a12e1a70cd8c3c0c9d717c47 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Tue, 29 Sep 2026 16:33:11 +0530 Subject: [PATCH 4/9] test(firestore): address review feedback --- tests/test_firestore_fn.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/test_firestore_fn.py b/tests/test_firestore_fn.py index dea9d05e..d6bda4c9 100644 --- a/tests/test_firestore_fn.py +++ b/tests/test_firestore_fn.py @@ -3,6 +3,7 @@ """ import json +import os from unittest import TestCase from unittest.mock import MagicMock, Mock, patch @@ -103,7 +104,9 @@ def init(): self.assertEqual(hello, "world") + @patch.dict(os.environ, clear=False) def test_firestore_client_is_cached(self): + os.environ.pop("FIRESTORE_EMULATOR_HOST", None) with patch.dict("sys.modules", mocked_modules): from cloudevents.http import CloudEvent @@ -144,7 +147,9 @@ def test_firestore_client_is_cached(self): credentials=mocked_modules["firebase_admin"].get_app().credential.get_credential(), ) + @patch.dict(os.environ, clear=False) def test_firestore_client_is_cached_concurrent(self): + os.environ.pop("FIRESTORE_EMULATOR_HOST", None) with patch.dict("sys.modules", mocked_modules): import threading @@ -204,7 +209,10 @@ def thread_task(): t2 = threading.Thread(target=thread_task) t1.start() - t1_in_critical_section.wait(timeout=5.0) + self.assertTrue( + t1_in_critical_section.wait(timeout=5.0), + "Thread 1 failed to reach the critical section", + ) t2.start() raced = t2_in_critical_section.wait(timeout=0.5) @@ -223,7 +231,9 @@ def thread_task(): ) self.assertEqual(mock_client_cls.call_count, 1) + @patch.dict(os.environ, clear=False) def test_firestore_client_cache_isolation(self): + os.environ.pop("FIRESTORE_EMULATOR_HOST", None) with patch.dict("sys.modules", mocked_modules): from cloudevents.http import CloudEvent @@ -257,6 +267,8 @@ def create_event(project: str, database: str): mock_client_cls.reset_mock() app = mocked_modules["firebase_admin"].get_app() + original_project_id = app.project_id + self.addCleanup(lambda: setattr(app, "project_id", original_project_id)) # Project A, Database (default) app.project_id = "project-A" @@ -275,7 +287,9 @@ def create_event(project: str, database: str): self.assertEqual(mock_client_cls.call_count, 3) + @patch.dict(os.environ, clear=False) def test_firestore_client_creation_failure_does_not_poison_cache(self): + os.environ.pop("FIRESTORE_EMULATOR_HOST", None) with patch.dict("sys.modules", mocked_modules): from cloudevents.http import CloudEvent @@ -310,8 +324,9 @@ class InitializationError(Exception): pass mock_client_cls.side_effect = InitializationError("Initialization failed") + self.addCleanup(lambda: setattr(mock_client_cls, "side_effect", None)) - with self.assertRaises(InitializationError, msg="Initialization failed"): + with self.assertRaises(InitializationError): decorated_func(raw_event) self.assertNotIn( @@ -333,6 +348,7 @@ class InitializationError(Exception): def test_firestore_client_emulator_skips_credentials(self): with patch.dict("sys.modules", mocked_modules): from cloudevents.http import CloudEvent + from firebase_functions import firestore_fn firestore_fn._firestore_clients.clear() From ccb32374b71b36f7b378ba78830a2b4ea3ca9c95 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Thu, 1 Oct 2026 02:20:21 +0530 Subject: [PATCH 5/9] Fix emulator and concurrency issues based on PR review feedback --- src/firebase_functions/firestore_fn.py | 16 +- tests/test_firestore_fn.py | 476 +++++++++++-------------- 2 files changed, 224 insertions(+), 268 deletions(-) diff --git a/src/firebase_functions/firestore_fn.py b/src/firebase_functions/firestore_fn.py index e2d065fc..3bbde70f 100644 --- a/src/firebase_functions/firestore_fn.py +++ b/src/firebase_functions/firestore_fn.py @@ -148,16 +148,24 @@ def _firestore_endpoint_handler( initialize_app() app = get_app() - client_key = (app.project_id, event_database) + emulator_host = _os.environ.get("FIRESTORE_EMULATOR_HOST") + if emulator_host: + project_id = app.options.get("projectId") or event_project + credential = None + else: + project_id = app.project_id or event_project + credential = app.credential + + client_key = (project_id, event_database, emulator_host, credential) if client_key not in _firestore_clients: with _firestore_clients_lock: if client_key not in _firestore_clients: client_kwargs = { - "project": app.project_id, + "project": project_id, "database": event_database, } - if not _os.environ.get("FIRESTORE_EMULATOR_HOST"): - client_kwargs["credentials"] = app.credential.get_credential() + if credential is not None: + client_kwargs["credentials"] = credential.get_credential() _firestore_clients[client_key] = _firestore_v1.Client(**client_kwargs) firestore_client = _firestore_clients[client_key] diff --git a/tests/test_firestore_fn.py b/tests/test_firestore_fn.py index d6bda4c9..533d968b 100644 --- a/tests/test_firestore_fn.py +++ b/tests/test_firestore_fn.py @@ -4,384 +4,332 @@ import json import os +import threading from unittest import TestCase from unittest.mock import MagicMock, Mock, patch -mocked_modules = { - "google.cloud.firestore": MagicMock(), - "google.cloud.firestore_v1": MagicMock(), - "firebase_admin": MagicMock(), -} - class TestFirestore(TestCase): """ firestore_fn tests. """ - def test_firestore_endpoint_handler_calls_function_with_correct_args(self): - with patch.dict("sys.modules", mocked_modules): - from cloudevents.http import CloudEvent - - from firebase_functions.firestore_fn import ( - AuthEvent, - ) - from firebase_functions.firestore_fn import ( - _event_type_created_with_auth_context as event_type, - ) - from firebase_functions.firestore_fn import ( - _firestore_endpoint_handler as firestore_endpoint_handler, - ) - from firebase_functions.private import path_pattern - - func = Mock(__name__="example_func") + def setUp(self): + pass + + def _create_event(self, project: str = "project-id", database: str = "(default)"): + from cloudevents.http import CloudEvent + + from firebase_functions import firestore_fn + + attributes = { + "specversion": "1.0", + "type": firestore_fn._event_type_created, + "source": "https://example.com/testevent", + "time": "2023-03-11T13:25:37.403Z", + "subject": "test_subject", + "datacontenttype": "application/json", + "location": f"projects/{project}/databases/{database}/documents/foo/bar", + "project": project, + "namespace": "(default)", + "document": "foo/bar", + "database": database, + "authtype": "unauthenticated", + "authid": "foo", + } + return CloudEvent(attributes=attributes, data=json.dumps({})) - document_pattern = path_pattern.PathPattern("foo/{bar}") - attributes = { - "specversion": "1.0", - "type": event_type, - "source": "https://example.com/testevent", - "time": "2023-03-11T13:25:37.403Z", - "subject": "test_subject", - "datacontenttype": "application/json", - "location": "projects/project-id/databases/(default)/documents/foo/{bar}", - "project": "project-id", - "namespace": "(default)", - "document": "foo/{bar}", - "database": "projects/project-id/databases/(default)", - "authtype": "unauthenticated", - "authid": "foo", - } - raw_event = CloudEvent(attributes=attributes, data=json.dumps({})) + def test_firestore_endpoint_handler_calls_function_with_correct_args(self): + from cloudevents.http import CloudEvent + + from firebase_functions.firestore_fn import ( + AuthEvent, + ) + from firebase_functions.firestore_fn import ( + _event_type_created_with_auth_context as event_type, + ) + from firebase_functions.firestore_fn import ( + _firestore_endpoint_handler as firestore_endpoint_handler, + ) + from firebase_functions.private import path_pattern + + func = Mock(__name__="example_func") + document_pattern = path_pattern.PathPattern("foo/{bar}") + attributes = { + "specversion": "1.0", + "type": event_type, + "source": "https://example.com/testevent", + "time": "2023-03-11T13:25:37.403Z", + "subject": "test_subject", + "datacontenttype": "application/json", + "location": "projects/project-id/databases/(default)/documents/foo/{bar}", + "project": "project-id", + "namespace": "(default)", + "document": "foo/{bar}", + "database": "(default)", + "authtype": "unauthenticated", + "authid": "foo", + } + raw_event = CloudEvent(attributes=attributes, data=json.dumps({})) + + with ( + patch("firebase_functions.firestore_fn._firestore_v1.Client"), + patch("firebase_functions.firestore_fn.get_app") as mock_get_app, + ): + app = MagicMock() + app.options = {} + app.project_id = "project-id" + app.credential = MagicMock() + mock_get_app.return_value = app firestore_endpoint_handler( func=func, event_type=event_type, document_pattern=document_pattern, raw=raw_event ) - func.assert_called_once() + func.assert_called_once() - event = func.call_args.args[0] - self.assertIsNotNone(event) - self.assertIsInstance(event, AuthEvent) - self.assertEqual(event.auth_type, "unauthenticated") - self.assertEqual(event.auth_id, "foo") + event = func.call_args.args[0] + self.assertIsNotNone(event) + self.assertIsInstance(event, AuthEvent) + self.assertEqual(event.auth_type, "unauthenticated") + self.assertEqual(event.auth_id, "foo") def test_calls_init_function(self): - with patch.dict("sys.modules", mocked_modules): - from cloudevents.http import CloudEvent - - from firebase_functions import core, firestore_fn - - func = Mock(__name__="example_func") + from cloudevents.http import CloudEvent + + from firebase_functions import core, firestore_fn + + func = Mock(__name__="example_func") + hello = None + + @core.init + def init(): + nonlocal hello + hello = "world" + + attributes = { + "specversion": "1.0", + "type": firestore_fn._event_type_created, + "source": "https://example.com/testevent", + "time": "2023-03-11T13:25:37.403Z", + "subject": "test_subject", + "datacontenttype": "application/json", + "location": "projects/project-id/databases/(default)/documents/foo/{bar}", + "project": "project-id", + "namespace": "(default)", + "document": "foo/{bar}", + "database": "(default)", + "authtype": "unauthenticated", + "authid": "foo", + } + raw_event = CloudEvent(attributes=attributes, data=json.dumps({})) + + with ( + patch("firebase_functions.firestore_fn._firestore_v1.Client"), + patch("firebase_functions.firestore_fn.get_app") as mock_get_app, + ): + app = MagicMock() + app.options = {} + app.project_id = "project-id" + app.credential = MagicMock() + mock_get_app.return_value = app - hello = None - - @core.init - def init(): - nonlocal hello - hello = "world" - - attributes = { - "specversion": "1.0", - # pylint: disable=protected-access - "type": firestore_fn._event_type_created, - "source": "https://example.com/testevent", - "time": "2023-03-11T13:25:37.403Z", - "subject": "test_subject", - "datacontenttype": "application/json", - "location": "projects/project-id/databases/(default)/documents/foo/{bar}", - "project": "project-id", - "namespace": "(default)", - "document": "foo/{bar}", - "database": "projects/project-id/databases/(default)", - "authtype": "unauthenticated", - "authid": "foo", - } - raw_event = CloudEvent(attributes=attributes, data=json.dumps({})) decorated_func = firestore_fn.on_document_created(document="/foo/{bar}")(func) - decorated_func(raw_event) - self.assertEqual(hello, "world") + self.assertEqual(hello, "world") - @patch.dict(os.environ, clear=False) + @patch.dict(os.environ, {}, clear=False) def test_firestore_client_is_cached(self): os.environ.pop("FIRESTORE_EMULATOR_HOST", None) - with patch.dict("sys.modules", mocked_modules): - from cloudevents.http import CloudEvent - - from firebase_functions import firestore_fn + from firebase_functions import firestore_fn + + with ( + patch("firebase_functions.firestore_fn._firestore_v1.Client") as mock_client_cls, + patch("firebase_functions.firestore_fn.get_app") as mock_get_app, + ): + app = MagicMock() + app.options = {} + app.project_id = "project-id" + app.credential = MagicMock() + mock_get_app.return_value = app firestore_fn._firestore_clients.clear() func = Mock(__name__="example_func") - attributes = { - "specversion": "1.0", - "type": firestore_fn._event_type_created, - "source": "https://example.com/testevent", - "time": "2023-03-11T13:25:37.403Z", - "subject": "test_subject", - "datacontenttype": "application/json", - "location": "projects/project-id/databases/(default)/documents/foo/{bar}", - "project": "project-id", - "namespace": "(default)", - "document": "foo/{bar}", - "database": "projects/project-id/databases/(default)", - "authtype": "unauthenticated", - "authid": "foo", - } - raw_event = CloudEvent(attributes=attributes, data=json.dumps({})) + raw_event = self._create_event() decorated_func = firestore_fn.on_document_created(document="/foo/{bar}")(func) - mock_client_cls = mocked_modules["google.cloud.firestore_v1"].Client - mock_client_cls.reset_mock() - - decorated_func(raw_event) decorated_func(raw_event) decorated_func(raw_event) self.assertEqual(mock_client_cls.call_count, 1) - mock_client_cls.assert_called_with( - project=mocked_modules["firebase_admin"].get_app().project_id, - database="projects/project-id/databases/(default)", - credentials=mocked_modules["firebase_admin"].get_app().credential.get_credential(), + self.assertIn( + ("project-id", "(default)", None, app.credential), + firestore_fn._firestore_clients, ) - @patch.dict(os.environ, clear=False) + @patch.dict(os.environ, {}, clear=False) def test_firestore_client_is_cached_concurrent(self): os.environ.pop("FIRESTORE_EMULATOR_HOST", None) - with patch.dict("sys.modules", mocked_modules): - import threading - - from cloudevents.http import CloudEvent - - from firebase_functions import firestore_fn + from firebase_functions import firestore_fn + + with ( + patch("firebase_functions.firestore_fn._firestore_v1.Client") as mock_client_cls, + patch("firebase_functions.firestore_fn.get_app") as mock_get_app, + ): + app = MagicMock() + app.options = {} + app.project_id = "project-id" + app.credential = MagicMock() + mock_get_app.return_value = app firestore_fn._firestore_clients.clear() func = Mock(__name__="example_func") - attributes = { - "specversion": "1.0", - "type": firestore_fn._event_type_created, - "source": "https://example.com/testevent", - "time": "2023-03-11T13:25:37.403Z", - "subject": "test_subject", - "datacontenttype": "application/json", - "location": "projects/project-id/databases/(default)/documents/foo/{bar}", - "project": "project-id", - "namespace": "(default)", - "document": "foo/{bar}", - "database": "projects/project-id/databases/(default)", - "authtype": "unauthenticated", - "authid": "foo", - } - raw_event = CloudEvent(attributes=attributes, data=json.dumps({})) + raw_event = self._create_event() decorated_func = firestore_fn.on_document_created(document="/foo/{bar}")(func) - mock_client_cls = mocked_modules["google.cloud.firestore_v1"].Client - mock_client_cls.reset_mock() - - app = mocked_modules["firebase_admin"].get_app() - get_cred_mock = app.credential.get_credential - get_cred_mock.reset_mock() - - t1_in_critical_section = threading.Event() t1_can_proceed = threading.Event() - - t2_in_critical_section = threading.Event() t2_can_proceed = threading.Event() - def get_credential_side_effect(*args, **kwargs): - if not t1_in_critical_section.is_set(): - t1_in_critical_section.set() - t1_can_proceed.wait() - else: - t2_in_critical_section.set() - t2_can_proceed.wait() - return "mock_cred" + self.addCleanup(t1_can_proceed.set) + self.addCleanup(t2_can_proceed.set) - get_cred_mock.side_effect = get_credential_side_effect + call_count = 0 - def thread_task(): - decorated_func(raw_event) + def mock_client_init(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + t1_can_proceed.set() + t2_can_proceed.wait(timeout=5) + return MagicMock() - t1 = threading.Thread(target=thread_task) - t2 = threading.Thread(target=thread_task) + mock_client_cls.side_effect = mock_client_init + self.addCleanup(lambda: setattr(mock_client_cls, "side_effect", None)) + + def run_func(): + decorated_func(raw_event) + t1 = threading.Thread(target=run_func) t1.start() - self.assertTrue( - t1_in_critical_section.wait(timeout=5.0), - "Thread 1 failed to reach the critical section", - ) - t2.start() - raced = t2_in_critical_section.wait(timeout=0.5) + t1_can_proceed.wait(timeout=5) - t1_can_proceed.set() - t1.join() + t2 = threading.Thread(target=run_func) + t2.start() t2_can_proceed.set() - t2.join() - get_cred_mock.side_effect = None + t1.join(timeout=5) + t2.join(timeout=5) - self.assertFalse( - raced, - "Race condition detected! Multiple threads entered initialization simultaneously.", - ) self.assertEqual(mock_client_cls.call_count, 1) + self.assertIn( + ("project-id", "(default)", None, app.credential), + firestore_fn._firestore_clients, + ) - @patch.dict(os.environ, clear=False) + @patch.dict(os.environ, {}, clear=False) def test_firestore_client_cache_isolation(self): os.environ.pop("FIRESTORE_EMULATOR_HOST", None) - with patch.dict("sys.modules", mocked_modules): - from cloudevents.http import CloudEvent + from firebase_functions import firestore_fn - from firebase_functions import firestore_fn - - firestore_fn._firestore_clients.clear() + with ( + patch("firebase_functions.firestore_fn._firestore_v1.Client") as mock_client_cls, + patch("firebase_functions.firestore_fn.get_app") as mock_get_app, + ): + app = MagicMock() + app.options = {} + app.credential = MagicMock() + mock_get_app.return_value = app func = Mock(__name__="example_func") - - def create_event(project: str, database: str): - attributes = { - "specversion": "1.0", - "type": firestore_fn._event_type_created, - "source": "https://example.com/testevent", - "time": "2023-03-11T13:25:37.403Z", - "subject": "test_subject", - "datacontenttype": "application/json", - "location": f"projects/{project}/databases/{database}/documents/foo/bar", - "project": project, - "namespace": "(default)", - "document": "foo/bar", - "database": f"projects/{project}/databases/{database}", - "authtype": "unauthenticated", - "authid": "foo", - } - return CloudEvent(attributes=attributes, data=json.dumps({})) - decorated_func = firestore_fn.on_document_created(document="/foo/{bar}")(func) - mock_client_cls = mocked_modules["google.cloud.firestore_v1"].Client - mock_client_cls.reset_mock() - - app = mocked_modules["firebase_admin"].get_app() - original_project_id = app.project_id - self.addCleanup(lambda: setattr(app, "project_id", original_project_id)) - # Project A, Database (default) app.project_id = "project-A" - decorated_func(create_event("project-A", "(default)")) + decorated_func(self._create_event("project-A", "(default)")) # Project A, Database (default) -> Cached - decorated_func(create_event("project-A", "(default)")) + decorated_func(self._create_event("project-A", "(default)")) # Project B, Database (default) -> New client app.project_id = "project-B" - decorated_func(create_event("project-B", "(default)")) + decorated_func(self._create_event("project-B", "(default)")) # Project A, Database other -> New client app.project_id = "project-A" - decorated_func(create_event("project-A", "other")) + decorated_func(self._create_event("project-A", "other")) self.assertEqual(mock_client_cls.call_count, 3) - @patch.dict(os.environ, clear=False) + @patch.dict(os.environ, {}, clear=False) def test_firestore_client_creation_failure_does_not_poison_cache(self): os.environ.pop("FIRESTORE_EMULATOR_HOST", None) - with patch.dict("sys.modules", mocked_modules): - from cloudevents.http import CloudEvent - - from firebase_functions import firestore_fn - - firestore_fn._firestore_clients.clear() + from firebase_functions import firestore_fn + + with ( + patch("firebase_functions.firestore_fn._firestore_v1.Client") as mock_client_cls, + patch("firebase_functions.firestore_fn.get_app") as mock_get_app, + ): + app = MagicMock() + app.options = {} + app.project_id = "project-id" + app.credential = MagicMock() + mock_get_app.return_value = app func = Mock(__name__="example_func") - attributes = { - "specversion": "1.0", - "type": firestore_fn._event_type_created, - "source": "https://example.com/testevent", - "time": "2023-03-11T13:25:37.403Z", - "subject": "test_subject", - "datacontenttype": "application/json", - "location": "projects/project-id/databases/(default)/documents/foo/bar", - "project": "project-id", - "namespace": "(default)", - "document": "foo/bar", - "database": "projects/project-id/databases/(default)", - "authtype": "unauthenticated", - "authid": "foo", - } - raw_event = CloudEvent(attributes=attributes, data=json.dumps({})) + raw_event = self._create_event(project="project-id", database="(default)") decorated_func = firestore_fn.on_document_created(document="/foo/{bar}")(func) - mock_client_cls = mocked_modules["google.cloud.firestore_v1"].Client - app = mocked_modules["firebase_admin"].get_app() - app.project_id = "project-id" - class InitializationError(Exception): pass mock_client_cls.side_effect = InitializationError("Initialization failed") self.addCleanup(lambda: setattr(mock_client_cls, "side_effect", None)) - with self.assertRaises(InitializationError): + with self.assertRaisesRegex(InitializationError, "Initialization failed"): decorated_func(raw_event) self.assertNotIn( - ("project-id", "projects/project-id/databases/(default)"), + ("project-id", "(default)", None, app.credential), firestore_fn._firestore_clients, ) mock_client_cls.side_effect = None - mock_client_cls.return_value = MagicMock() - decorated_func(raw_event) self.assertIn( - ("project-id", "projects/project-id/databases/(default)"), + ("project-id", "(default)", None, app.credential), firestore_fn._firestore_clients, ) - @patch.dict("os.environ", {"FIRESTORE_EMULATOR_HOST": "localhost:8080"}) + @patch.dict(os.environ, {"FIRESTORE_EMULATOR_HOST": "localhost:8080"}, clear=False) def test_firestore_client_emulator_skips_credentials(self): - with patch.dict("sys.modules", mocked_modules): - from cloudevents.http import CloudEvent - - from firebase_functions import firestore_fn - - firestore_fn._firestore_clients.clear() + from firebase_functions import firestore_fn + + with ( + patch("firebase_functions.firestore_fn._firestore_v1.Client") as mock_client_cls, + patch("firebase_functions.firestore_fn.get_app") as mock_get_app, + ): + app = MagicMock() + app.options = {} + app.project_id = "project-id" + app.credential = MagicMock() + mock_get_app.return_value = app func = Mock(__name__="example_func") - attributes = { - "specversion": "1.0", - "type": firestore_fn._event_type_created, - "source": "https://example.com/testevent", - "time": "2023-03-11T13:25:37.403Z", - "subject": "test_subject", - "datacontenttype": "application/json", - "location": "projects/project-id/databases/(default)/documents/foo/bar", - "project": "project-id", - "namespace": "(default)", - "document": "foo/bar", - "database": "projects/project-id/databases/(default)", - "authtype": "unauthenticated", - "authid": "foo", - } - raw_event = CloudEvent(attributes=attributes, data=json.dumps({})) + raw_event = self._create_event() decorated_func = firestore_fn.on_document_created(document="/foo/{bar}")(func) - mock_client_cls = mocked_modules["google.cloud.firestore_v1"].Client - mock_client_cls.reset_mock() - app = mocked_modules["firebase_admin"].get_app() - app.credential.get_credential.reset_mock() - + decorated_func(raw_event) decorated_func(raw_event) self.assertEqual(mock_client_cls.call_count, 1) mock_client_cls.assert_called_with( - project=app.project_id, - database="projects/project-id/databases/(default)", + project="project-id", + database="(default)", ) app.credential.get_credential.assert_not_called() From c872209fe5b1bfaf746133def1621487e8230932 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Thu, 1 Oct 2026 09:01:02 +0530 Subject: [PATCH 6/9] Fix mypy linting error on _firestore_clients dict key --- src/firebase_functions/firestore_fn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/firebase_functions/firestore_fn.py b/src/firebase_functions/firestore_fn.py index 3bbde70f..e3f242d9 100644 --- a/src/firebase_functions/firestore_fn.py +++ b/src/firebase_functions/firestore_fn.py @@ -46,7 +46,7 @@ _event_type_updated_with_auth_context = "google.cloud.firestore.document.v1.updated.withAuthContext" _event_type_deleted_with_auth_context = "google.cloud.firestore.document.v1.deleted.withAuthContext" -_firestore_clients: dict[tuple[str, str], _firestore_v1.Client] = {} +_firestore_clients: dict[tuple[str, str, str | None, _typing.Any], _firestore_v1.Client] = {} _firestore_clients_lock = _threading.Lock() From 2d09c5ef0861fd228df6ddf5528d9026c5030d2e Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Thu, 1 Oct 2026 22:15:30 +0530 Subject: [PATCH 7/9] test: address PR review feedback for firestore caching --- tests/test_firestore_fn.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/test_firestore_fn.py b/tests/test_firestore_fn.py index 533d968b..ad196fac 100644 --- a/tests/test_firestore_fn.py +++ b/tests/test_firestore_fn.py @@ -15,7 +15,8 @@ class TestFirestore(TestCase): """ def setUp(self): - pass + from firebase_functions import firestore_fn + firestore_fn._firestore_clients.clear() def _create_event(self, project: str = "project-id", database: str = "(default)"): from cloudevents.http import CloudEvent @@ -154,8 +155,6 @@ def test_firestore_client_is_cached(self): app.credential = MagicMock() mock_get_app.return_value = app - firestore_fn._firestore_clients.clear() - func = Mock(__name__="example_func") raw_event = self._create_event() decorated_func = firestore_fn.on_document_created(document="/foo/{bar}")(func) @@ -164,6 +163,11 @@ def test_firestore_client_is_cached(self): decorated_func(raw_event) self.assertEqual(mock_client_cls.call_count, 1) + mock_client_cls.assert_called_once_with( + project="project-id", + database="(default)", + credentials=app.credential.get_credential(), + ) self.assertIn( ("project-id", "(default)", None, app.credential), firestore_fn._firestore_clients, @@ -184,8 +188,6 @@ def test_firestore_client_is_cached_concurrent(self): app.credential = MagicMock() mock_get_app.return_value = app - firestore_fn._firestore_clients.clear() - func = Mock(__name__="example_func") raw_event = self._create_event() decorated_func = firestore_fn.on_document_created(document="/foo/{bar}")(func) @@ -220,6 +222,9 @@ def run_func(): t2 = threading.Thread(target=run_func) t2.start() + # Ensure t2 actually contends on the lock while t1 is inside mock_client_init + import time + time.sleep(0.1) t2_can_proceed.set() t1.join(timeout=5) From 552f6e304178fd66cc2207a9885e4e79e8bf1fe6 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Thu, 1 Oct 2026 23:19:57 +0530 Subject: [PATCH 8/9] test: move import time to top of file --- tests/test_firestore_fn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_firestore_fn.py b/tests/test_firestore_fn.py index ad196fac..b158a4b5 100644 --- a/tests/test_firestore_fn.py +++ b/tests/test_firestore_fn.py @@ -5,6 +5,7 @@ import json import os import threading +import time from unittest import TestCase from unittest.mock import MagicMock, Mock, patch @@ -223,7 +224,6 @@ def run_func(): t2.start() # Ensure t2 actually contends on the lock while t1 is inside mock_client_init - import time time.sleep(0.1) t2_can_proceed.set() From 31177b6f5b721dca206db17d0cd7e90361ac9689 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Thu, 1 Oct 2026 23:53:33 +0530 Subject: [PATCH 9/9] chore: fix formatting --- README.md | 1 + tests/test_firestore_fn.py | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index d5e27e79..092bd314 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ If the official documentation doesn't help, try asking through our official supp from firebase_functions import db_fn from notify_users import api + @db_fn.on_value_created(reference="/posts/{post_id}") def new_post(event): print(f"Received new post with ID: {event.params.get('post_id')}") diff --git a/tests/test_firestore_fn.py b/tests/test_firestore_fn.py index b158a4b5..e32285b0 100644 --- a/tests/test_firestore_fn.py +++ b/tests/test_firestore_fn.py @@ -17,6 +17,7 @@ class TestFirestore(TestCase): def setUp(self): from firebase_functions import firestore_fn + firestore_fn._firestore_clients.clear() def _create_event(self, project: str = "project-id", database: str = "(default)"):