Skip to content

fix(firestore): cache Firestore trigger client - #312

Merged
IzaakGough merged 9 commits into
firebase:mainfrom
hardikkaurani:fix/firestore-client-caching
Oct 2, 2026
Merged

IzaakGough merged 9 commits into
firebase:mainfrom
hardikkaurani:fix/firestore-client-caching

Conversation

@hardikkaurani

Copy link
Copy Markdown
Contributor

Description

This PR fixes a severe performance overhead and unreliability issue when executing Firestore triggers, as reported in #309.

Currently, every time a Firestore function is triggered, a new google.cloud.firestore_v1.Client is instantiated without passing credentials. This causes the underlying Google auth client to invoke google.auth.default(), triggering a blocking request to the GCP Metadata Server to fetch Application Default Credentials (ADC). Under high concurrent load (e.g. Cloud Run scaling rapidly), the metadata server may timeout or drop requests, leading to DefaultCredentialsError and dropped events.

Fixes

  1. Client Caching: We now cache the initialized _firestore_v1.Client by (project_id, database) in a module-level dictionary to reuse the connection pool and client across function executions in the same container.
  2. Credential Re-use: We inject app.credential.get_credential() into the Client constructor. This prevents the client from attempting to resolve ADC via google.auth.default() on every instantiation, as firebase_admin has already successfully authenticated during initialize_app().

Testing

  • Added test_firestore_client_is_cached to tests/test_firestore_fn.py to ensure the Firestore client is instantiated exactly once per project-database combination and correctly utilizes app.credential.
  • Confirmed pytest and mypy tests pass locally.

Fixes #309.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces caching for Firestore clients in firestore_fn.py to reuse client instances across invocations, along with a corresponding unit test to verify the caching behavior. The review feedback suggests introducing a threading lock and implementing a double-checked locking pattern when initializing the cached clients to ensure thread safety under concurrent executions.

Comment thread src/firebase_functions/firestore_fn.py Outdated
_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] = {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure thread safety when initializing the Firestore client under concurrent executions (especially in 2nd gen functions where concurrency can be greater than 1), we should introduce a lock to synchronize client creation.

Suggested change
_firestore_clients: dict[tuple[str, str], _firestore_v1.Client] = {}
import threading as _threading
_firestore_clients: dict[tuple[str, str], _firestore_v1.Client] = {}
_firestore_clients_lock = _threading.Lock()

Comment thread src/firebase_functions/firestore_fn.py Outdated
@hardikkaurani

Copy link
Copy Markdown
Contributor Author

Thanks for catching this! I investigated the execution model and you are absolutely right: concurrent trigger invocations in second-gen Cloud Run could definitely race during the empty-cache check, causing multiple \Client\ instances to be created and blowing away the cache guarantee.

I've pushed a fix that addresses this:

  1. Added Synchronization: Introduced double-checked locking using a module-level \ hreading.Lock\ around the cache initialization, keeping the fast-path lock-free.
  2. Deterministic Regression Coverage: Added \ est_firestore_client_is_cached_concurrent\ using \ hreading.Event\ barriers to deterministically reproduce the exact race condition (verifying it fails without the lock and passes with it).
  3. Cache Isolation: Added \ est_firestore_client_cache_isolation\ to ensure (project, database)\ pairs still get isolated client instances.
  4. Failure Handling: Added \ est_firestore_client_creation_failure_does_not_poison_cache\ to ensure exceptions during \Client()\ initialization do not leave broken state in the cache.

The existing credentials caching behavior remains fully intact. Full test suite, ruff, and mypy are passing cleanly locally.

Comment thread src/firebase_functions/firestore_fn.py Outdated
@hardikkaurani

Copy link
Copy Markdown
Contributor Author

@IzaakGough The emulator credentials behavior has been updated as requested! get_credential() is now conditionally skipped when FIRESTORE_EMULATOR_HOST is set, preserving normal credential reuse in production. An emulator-specific test has been added to verify this. Please let me know if you need any further adjustments!

@IzaakGough IzaakGough left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. The change in firestore_fn.py looks right to me: the double-checked lock only publishes the dict entry after Client(...) returns, so a failed construction can't poison the cache, and leaving credentials=None under the emulator lets BaseClient substitute AnonymousCredentials. I checked that the concurrency test genuinely fails if the lock is removed.

A few things on the tests, plus the lint failure. Comments inline.

I ran the suite on a clean Python 3.12 venv with FIRESTORE_EMULATOR_HOST both set and unset, which is how I found the first two.

Comment thread tests/test_firestore_fn.py Outdated
Comment thread tests/test_firestore_fn.py Outdated
Comment thread tests/test_firestore_fn.py
Comment thread tests/test_firestore_fn.py
Comment thread tests/test_firestore_fn.py Outdated
Comment thread tests/test_firestore_fn.py Outdated
@hardikkaurani

Copy link
Copy Markdown
Contributor Author

@IzaakGough Thanks for the detailed review. I've addressed the requested test fixes, including explicit emulator environment control, concurrency barrier validation, mock/side-effect cleanup, and the lint issue. I ran the relevant tests with the emulator environment both set and unset (though my local Windows virtual environment hit a namespace package ImportError on google-cloud-firestore), and successfully validated the lint fixes with ruff check. The latest revision is pushed for review.

@IzaakGough IzaakGough left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@hardikkaurani

hardikkaurani commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks again for the review and approval, @IzaakGough. The requested changes are all addressed and the PR is ready from my side. I’ll leave it here for the repository’s normal merge process.

@wandamora wandamora left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just need to address some small changes to not break the emulator or tests, and some test cleanup suggestions.

Comment thread src/firebase_functions/firestore_fn.py Outdated
_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] = {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This won't affect production Cloud Run, but two local/test edge cases could occur if left as-is:

  1. app.project_id can be None: firebase_admin.App.project_id returns str | None (mypy only passes because firebase_admin is untyped). If app.project_id is None in an emulator/test setup, Client(project=None) defaults to "google-cloud-firestore-emulator" instead of event_project (line 139), pointing event.data.reference to the wrong project and raising ValueError in decode_dict when decoding DocumentReference fields.
  2. Stale client in tests: Because the key is only (app.project_id, event_database), toggling FIRESTORE_EMULATOR_HOST or re-initializing firebase_admin (delete_app -> initialize_app) across tests returns a stale Client unless callers manually clear private firestore_fn._firestore_clients.

Suggestion: Fall back to event_project and include the emulator/credential inputs in the key:

project_id = app.project_id or event_project
client_key = (project_id, event_database, _os.environ.get("FIRESTORE_EMULATOR_HOST"), app.credential)

Comment thread src/firebase_functions/firestore_fn.py Outdated
app = get_app()
firestore_client = _firestore_v1.Client(project=app.project_id, database=event_database)

client_key = (app.project_id, event_database)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug in emulator mode when FIREBASE_CONFIG does not contain projectId: Skipping app.credential.get_credential() on line 159 avoids an explicit credential fetch when FIRESTORE_EMULATOR_HOST is set, but accessing app.project_id on lines 151 and 156 can still trigger google.auth.default().

Specifically, firebase_admin.App._lookup_project_id() checks self._options.get('projectId') and then immediately accesses self._credential.project_id before checking GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT. When self._credential is ApplicationDefault, .project_id calls self._load_credential() -> google.auth.default(), raising DefaultCredentialsError in local/CI emulator environments without ADC.

When FIRESTORE_EMULATOR_HOST is set (or as a fallback), we can use event_project if app.options.get("projectId") is not set, avoiding ApplicationDefault._load_credential().

Comment thread tests/test_firestore_fn.py Outdated
firestore_fn._firestore_clients.clear()

func = Mock(__name__="example_func")
attributes = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reduce CloudEvent fixture duplication & use realistic database attribute:

  1. The attributes dictionary and CloudEvent setup is now repeated 6 times across this file, and firestore_fn._firestore_clients.clear() + mock resets are repeated in every test. Consider moving the cache/mock cleanup into setUp() and extracting a _create_event(self, project="project-id", database="(default)") helper on TestFirestore.
  2. Minor note on test data: Eventarc sends "database": "(default)" (the database ID, not "projects/project-id/databases/(default)"), because firestore_v1.BaseClient._database_string expands "projects/{project}/databases/{database}". Using "(default)" in the new tests better reflects runtime behavior.

Comment thread tests/test_firestore_fn.py Outdated
def test_firestore_client_is_cached_concurrent(self):
os.environ.pop("FIRESTORE_EMULATOR_HOST", None)
with patch.dict("sys.modules", mocked_modules):
import threading

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: import threading is a standard library import and doesn't depend on mocked_modules—move it to the top of the file.

Comment thread tests/test_firestore_fn.py Outdated
Comment on lines +203 to +226
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()
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.set()
t1.join()

t2_can_proceed.set()
t2.join()

get_cred_mock.side_effect = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because mocked_modules is shared across all test methods, if self.assertTrue(...) on line 212 fails (or an exception is raised before line 226), get_cred_mock.side_effect is never reset to None, which will cause subsequent tests in the suite to hang or fail. Register self.addCleanup(setattr, get_cred_mock, "side_effect", None) immediately after line 203 (and ensure t1_can_proceed.set() / t2_can_proceed.set() run in cleanup so daemon/worker threads don't hang the test runner on failure).

Comment thread tests/test_firestore_fn.py Outdated
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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add self.addCleanup(setattr, app, "project_id", original_project_id) before mutating app.project_id = "project-id" on line 321 (as done on line 271 in test_firestore_client_cache_isolation).

Comment thread tests/test_firestore_fn.py Outdated
firestore_fn._firestore_clients,
)

@patch.dict("os.environ", {"FIRESTORE_EMULATOR_HOST": "localhost:8080"})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: use @patch.dict(os.environ, ...) for consistency across the file.

Comment thread tests/test_firestore_fn.py
@hardikkaurani

Copy link
Copy Markdown
Contributor Author

@wandamora I have pushed the fixes you requested. The emulator ADC lookup has been bypassed, test global state poisoning has been resolved by properly mocking modules within specific tests, and concurrent test reliability has been verified. Let me know if there's anything else!

Comment thread src/firebase_functions/firestore_fn.py Outdated
t2 = threading.Thread(target=run_func)
t2.start()

t2_can_proceed.set()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling t2_can_proceed.set() immediately after t2.start() unblocks t1 before t2 is guaranteed to have started and reached _firestore_clients_lock (so t1 can finish and populate the cache before t2 reaches if client_key not in _firestore_clients).

To ensure t2 actually contends on the lock while t1 is inside mock_client_init, t1 should stay blocked on t2_can_proceed briefly (or until a second event in mock_client_init confirms t2 did not enter within a short timeout, as in the previous version of this test) before t2_can_proceed.set() is called.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed! I've introduced deterministic synchronization using \ hreading.Event\ barriers so that \ 2\ actually blocks on the outer lock while \ 1\ is inside the mocked init.

Comment thread tests/test_firestore_fn.py Outdated

func = Mock(__name__="example_func")
def setUp(self):
pass

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: setUp is currently a pass no-op. You can move firestore_fn._firestore_clients.clear() into setUp(self) (and remove the individual .clear() calls in each test).

@hardikkaurani hardikkaurani Oct 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done! I've refactored the tests to use proper addCleanup and patch contexts, and also removed the need to override sys.modules, ensuring complete isolation.

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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please keep the assert_called_with check here so we still verify that credentials=app.credential.get_credential() is passed to _firestore_v1.Client in non-emulator mode:

mock_client_cls.assert_called_once_with(
    project="project-id",
    database="(default)",
    credentials=app.credential.get_credential(),
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restored! The test now explicitly asserts that the correct credentials are passed to \Client\ in non-emulator mode.

@hardikkaurani

Copy link
Copy Markdown
Contributor Author

@wandamora the CI checks are now fully passing (there was a minor mypy type hint update I missed for the cache key, which is now fixed!). Ready whenever you are.

@wandamora wandamora left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for fixing the mypy error! It looks like my suggestions for tests/test_firestore_fn.py weren't included, so the 3 test updates (setUp, assert_called_once_with in test_firestore_client_is_cached, and the t2 synchronization in test_firestore_client_is_cached_concurrent) aren't pushed yet—could you push your changes to tests/test_firestore_fn.py?

@hardikkaurani

Copy link
Copy Markdown
Contributor Author

@wandamora the requested test fixes have been pushed! setUp clears the cache, assert_called_once_with verifies the emulator skip, and t2 now has proper synchronization logic.

@wandamora wandamora left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more nit and looks good.

Comment thread tests/test_firestore_fn.py Outdated
t2.start()

# Ensure t2 actually contends on the lock while t1 is inside mock_client_init
import time

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you move this to the top of the file?

@hardikkaurani

Copy link
Copy Markdown
Contributor Author

@wandamora @IzaakGough I've moved \import time\ to the top of the file as requested! Everything should be fully addressed now. Please let me know if there's anything else needed, or if this is good to go for merging whenever you have a chance. Thanks again for your time reviewing this!

@hardikkaurani

Copy link
Copy Markdown
Contributor Author

Hi @inlined @cabljac! All review feedback and suggestions from @wandamora and @IzaakGough have been addressed and approved, and formatting/lint/tests (
uff, \mypy, \pytest) are passing cleanly. Could you please take a look or approve the workflow run for merging when you get a chance? Thanks so much for your time!

@IzaakGough
IzaakGough merged commit cff3ee0 into firebase:main Oct 2, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

firestore_fn builds a new firestore_v1.Client (and calls google.auth.default()) on every event delivery

4 participants