Skip to content

Count Firestore query reads in the document read probe - #12281

Open
Git-on-my-level wants to merge 1 commit into
mainfrom
feat/firestore-query-read-attribution
Open

Count Firestore query reads in the document read probe#12281
Git-on-my-level wants to merge 1 commit into
mainfrom
feat/firestore-query-read-attribution

Conversation

@Git-on-my-level

@Git-on-my-level Git-on-my-level commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

What

Extends the existing Firestore read probe to count query reads, not just
single-document lookups. No call sites change.

Why

Cloud Firestore Read Ops is ~$538/day, and 91.4% of it is QUERY type
(1,039M docs/day). The instrument that covers QUERY today accounts for 596.5M
docs/day, and 587.1M of that is a single already-owned family. Netting out,
roughly **428M docs/day (
$210/day) is issued by code no metric names.**

The probe merged in firestore_document_probe.py wraps exactly
DocumentReference.get and Client.get_all — the LOOKUP / BatchGetDocuments
path. Its "structural rather than remembered" coverage claim therefore holds
only over the cheap 8.6% slice; the expensive 91.4% stays dark. There are ~307
.stream() call sites in backend/**.py and 5 record a family, so annotating
by hand is not a plan.

This is an attribution enabler, not a cut. It sizes and locates a cut. Precedent:
action_items_list was invisible until instrumented and turned out to be
$278/day.

SDK surface wrapped

Verified against the installed google-cloud-firestore 2.20.0 in the venv rather
than assumed:

Wrapped Why
Query.stream The real QUERY funnel. Each snapshot is one billed document.
AggregationQuery.stream .count() is used across conversations, action_items, chat, apps, folders, daily_summaries, x_posts. .get() materialises .stream().

Deliberately not wrapped, to avoid double counting:

  • Query.get — already calls self.stream() then list().
  • CollectionReference.get / .stream — both funnel through Query.stream.
    BaseCollectionReference.get/stream are NotImplementedError stubs.
  • AsyncQuery / AsyncAggregationQuery — zero uses in backend/.
  • VectorQueryfind_nearest() is never called here.

CollectionGroup subclasses Query, so collection-group streams are covered.
Transaction.get(query) calls query.stream(), so it is covered too.

Aggregations are billed by index-entry batch, not by matched document

Firestore charges an aggregation one read per batch of up to 1000 index
entries
, not one read per matched document. Counting matched documents would
overstate a large count() by up to 1000x and let cheap aggregations dominate a
counter whose entire purpose is attributing the billed read line. The probe
charges ceil(matched / 1000), floored at 1, and the tests pin that at the
1000/1001 boundary and at 2.5M matched.

Invariants held

  • Labels still go through collection_pattern() / _KNOWN_PATTERNS / other,
    so the label space is unchanged and no uid, document id, or query text can
    reach a label.
  • Streams stay lazy: snapshots are counted as they pass, never materialised into
    a list, so a caller's memory profile on a large result set is unchanged. This
    follows the existing get_all precedent.
  • Recording stays inside the existing try/except-and-log discipline; telemetry
    cannot break a read.
  • Installation is still idempotent via _installed and still no-ops on
    ImportError for the unit-test stubs.

Known type change

As with the existing get_all wrapper, a wrapped .stream() returns a plain
generator rather than StreamGenerator. Iteration is unchanged;
get_explain_metrics() would not survive, and this codebase never passes
explain_options.

Testing

backend/tests/unit/test_firestore_document_probe.py — 11 passed. Covers:
per-document counting with the right label, laziness (the underlying generator is
not consumed before the caller pulls), unknown collection reducing to other, a
raising recorder not propagating, aggregation batch billing, and idempotent
install.

Neighbouring suites were run file-by-file and match origin/main exactly:
test_firestore_read_site_attribution 11 passed, test_firestore_query_stream_retry
7 passed, test_firestore_di_seam 5 passed. test_bounded_firestore_list_reads
(2 failed) and test_firestore_query_contract (collection error) fail identically
on origin/main and are untouched by this change. Note that running the whole
-k "firestore or probe" selection in one process segfaults on origin/main too;
that is pre-existing and unrelated.

Failure-Class: none

Review in cubic

The probe wrapped only DocumentReference.get and Client.get_all, so it covered
the LOOKUP path -- 8.6% of the read bill. QUERY is 91.4%, and ~428M docs/day of
it is issued by code no metric names. Wrap Query.stream and
AggregationQuery.stream instead of annotating ~307 call sites.

Query.get and CollectionReference.get/stream funnel through Query.stream, so
wrapping them too would double count. Aggregations bill one read per batch of
up to 1000 index entries, not one per matched document, so charge the batches.

Streams stay lazy and labels still reduce through collection_pattern(), so
cardinality and caller memory profiles are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@kodjima33 kodjima33 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Solid instrumentation work, but this is an attribution enabler (feature), not a bug fix, so per policy it's approve-only regardless of CI.

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.

2 participants