1717from __future__ import annotations
1818
1919from collections .abc import Callable
20+ from dataclasses import dataclass
2021from typing import Any
2122
2223from ..config import BigQueryTarget
2526from ..guards .cost_guard import CostGate , OverCeilingError
2627from ..guards .sql_guard import assert_select_only
2728from .base import (
29+ VALUE_DOMAIN_MIN_ROWS ,
2830 ColumnAggregate ,
2931 ColumnMeta ,
3032 ObjectMeta ,
6365_NESTED_FIELD_TYPES = {"RECORD" , "STRUCT" , "JSON" , "GEOGRAPHY" , "RANGE" , "INTERVAL" }
6466
6567
68+ @dataclass (frozen = True )
69+ class _EstimateComposition :
70+ """What a profile estimate is made of: measured dry-run scan versus
71+ escalation reserve.
72+
73+ The two halves answer different questions and a caller deciding whether to
74+ raise a budget needs them apart. The scan half is a dry-run of statements
75+ that will certainly run; the reserve half is headroom held for probes that
76+ may never be issued (see :meth:`BigQueryAdapter.profile_estimate`). Quoting
77+ only the sum is what left a refused nightly run unable to tell a warehouse
78+ that had grown from a release that had added a reserve (issue #299).
79+ """
80+
81+ scan_bytes : float
82+ scan_queries : int
83+ reserved_bytes : float
84+ reserved_queries : int
85+
86+
6687def _regexp_predicate (qcol : str , pattern : str ) -> str :
6788 # Raw string literal; REGEXP_CONTAINS matches substrings, so the shared
6889 # patterns' anchors make it a full match.
@@ -130,6 +151,10 @@ def __init__(
130151 self ._tables : dict [str , Any ] = {}
131152 self ._resolved_datasets : list [str ] | None = None
132153 self ._notes : dict [str , list [str ]] = {}
154+ # What the last profile estimate was made of, so the handshake and the
155+ # over-ceiling refusal can attribute the number they quote. Per command,
156+ # like `_notes`: one command builds at most one profile estimate.
157+ self ._composition : _EstimateComposition | None = None
133158
134159 # --- capabilities ---------------------------------------------------------
135160
@@ -684,18 +709,18 @@ def profile_estimate(
684709 what BigQuery actually bills, and an unfloored estimate would send the
685710 agent into a ladder of budget rejections.
686711
687- The total also reserves one floor per table for each of the two
688- possible escalation queries (``exact_distinct_counts`` for a
689- near-unique column, ``distinct_combination_counts`` for a composite
690- key candidate): whether either actually runs depends on the aggregate
691- batch's own approximate results, which do not exist yet at estimate
692- time, so there is no way to dry-run them here. Reserving their floor
693- unconditionally keeps this total a ceiling profiling will not exceed,
694- rather than a number a run that does escalate blows past -- the exact
695- gap this estimator used to leave open (issue #107). Skipped only when
696- a table is provably empty (``row_count == 0``); an unknown row count
697- (views, whose count is never known before the aggregate that reveals
698- it) still reserves, since escalation is not ruled out .
712+ The total also reserves one floor per table for each of the three
713+ escalation queries a profile may still issue after that batch
714+ (:meth:`exact_distinct_counts`, :meth:`value_domain_counts`,
715+ :meth:`distinct_combination_counts`). Whether any of them runs depends
716+ on the batch's own approximate results, which do not exist yet at
717+ estimate time, so there is no way to dry-run them here. Holding their
718+ floor keeps this total a ceiling profiling will not exceed rather than
719+ a number a run that does escalate blows past, which is the gap this
720+ estimator used to leave open (issue #107). See
721+ :meth:`_escalation_reserve` for what narrows each one, and
722+ :attr:`_composition` for how the reserve is reported apart from the
723+ scan it rides with .
699724
700725 Blob-type columns are excluded from the batches the same way
701726 ``explore.profile.profile`` excludes them from the scan itself
@@ -704,6 +729,9 @@ def profile_estimate(
704729
705730 blob_paths = include_blobs or set ()
706731 per_table : dict [str , float ] = {}
732+ scan_bytes = 0.0
733+ scan_queries = 0
734+ reserved_queries = 0
707735 for identifier in identifiers :
708736 meta , columns = self .table_metadata (identifier )
709737 if self ._unqueryable (identifier ):
@@ -732,21 +760,68 @@ def profile_estimate(
732760 sample_percent = sample_percent ,
733761 )
734762 try :
735- total + = max (self ._dry_run (sql ), float (_MIN_BILLED_BYTES ))
763+ batch = max (self ._dry_run (sql ), float (_MIN_BILLED_BYTES ))
736764 except self ._api_exceptions .BadRequest :
737765 self ._note (
738766 identifier ,
739767 "could not estimate an aggregate scan (dry-run failed); "
740768 "the object is skipped" ,
741769 )
742- if scan_columns and meta .row_count != 0 :
743- total += float (_MIN_BILLED_BYTES ) # exact_distinct_counts
744- total += float (_MIN_BILLED_BYTES ) # value_domain_counts
745- if len (scan_columns ) >= 2 :
746- total += float (_MIN_BILLED_BYTES ) # distinct_combination_counts
770+ continue
771+ total += batch
772+ scan_bytes += batch
773+ scan_queries += 1
774+ reserved = self ._escalation_reserve (meta , scan_columns )
775+ total += reserved * float (_MIN_BILLED_BYTES )
776+ reserved_queries += reserved
747777 per_table [identifier ] = total
778+ self ._composition = _EstimateComposition (
779+ scan_bytes = scan_bytes ,
780+ scan_queries = scan_queries ,
781+ reserved_bytes = reserved_queries * float (_MIN_BILLED_BYTES ),
782+ reserved_queries = reserved_queries ,
783+ )
748784 return sum (per_table .values ()), per_table
749785
786+ def _escalation_reserve (
787+ self , meta : ObjectMeta , scan_columns : list [ColumnMeta ]
788+ ) -> int :
789+ """How many escalation queries to hold a billing floor for on one table.
790+
791+ A reserve is dropped only where the probe's own guard already rules the
792+ query out from metadata alone, never as a guess about what is likely: an
793+ estimate that reserves for a query that cannot run is merely loose, while
794+ one that skips a query that can is the defect issue #107 closed. So each
795+ condition below mirrors one in ``explore.profile``, and moving one
796+ without the other is the bug to watch for.
797+
798+ - Nothing at all without a row count. All three probes return early on a
799+ falsy one, and a BigQuery view never has one: ``_object_meta`` nulls it
800+ and the aggregate's own ``COUNT(*)`` is read per batch, never written
801+ back to the object. So a view provably cannot escalate, and the reserve
802+ it used to hold was money no run could spend.
803+ - Nothing for columns BigQuery cannot count distinctly. Nested and
804+ repeated fields get no approximate distinct in the aggregate batch, and
805+ every probe's eligibility starts from one, so they can no more trigger
806+ an escalation than a blob column already excluded from the scan.
807+ - No value domain below :data:`VALUE_DOMAIN_MIN_ROWS` rows, which is what
808+ the probe's row-relative fraction implies once a domain needs at least
809+ one distinct value.
810+ - No composite probe below two countable columns, since a combination
811+ needs two members. This was already conditioned, but on the raw column
812+ count, which counts columns that can never join a pair.
813+ """
814+
815+ countable = [c for c in scan_columns if not self ._is_nested (c .data_type )]
816+ if not countable or not meta .row_count :
817+ return 0
818+ reserved = 1 # exact_distinct_counts
819+ if meta .row_count >= VALUE_DOMAIN_MIN_ROWS :
820+ reserved += 1 # value_domain_counts
821+ if len (countable ) >= 2 :
822+ reserved += 1 # distinct_combination_counts
823+ return reserved
824+
750825 def query_estimate (self , sql : str ) -> float :
751826 """The dry-run byte estimate for one firewall-approved query, floored to
752827 what BigQuery will actually bill (the per-referenced-table minimum), so
@@ -761,33 +836,65 @@ def describe_estimate(
761836 """The bytes-scanned handshake payload: names the per-query billing
762837 floor baked into every number here, so a small-table estimate reads as
763838 a trustworthy ceiling instead of one the actual bill will exceed
764- (issue #107). The escalation-reserve clause only applies to a
765- multi-table call (``per_table`` set, i.e. a profile-shaped estimate);
766- a single ad-hoc query or cluster sample has no such reserve to explain.
839+ (issue #107).
840+
841+ Deliberately a pure function of its arguments. What a *profile* estimate
842+ is made of is answered by :meth:`profile_reserve` instead, because this
843+ method also describes estimates that carry no reserve (an ad-hoc query,
844+ a mid-command verify checkpoint) and it cannot tell which it was handed.
767845 """
768846
769- note = (
770- f"BigQuery bills at least { _MIN_BILLED_BYTES :,} bytes (10 MB) per "
771- "query; every number here already reflects that floor"
772- )
773- if per_table :
774- note += (
775- ", including a reserve for the escalation queries a "
776- "multi-table profile may still add after its initial scan"
777- )
778847 data : dict [str , object ] = {
779848 "estimated_bytes" : estimate ,
780849 "hint" : (
781850 "review the estimate, then re-run with --confirm --budget "
782851 "<bytes> (the ceiling in bytes; 10000000000 is 10 GB, about "
783852 "$0.06 on-demand)"
784853 ),
785- "notes" : [note ],
854+ "notes" : [
855+ f"BigQuery bills at least { _MIN_BILLED_BYTES :,} bytes (10 MB) "
856+ "per query; every number here already reflects that floor"
857+ ],
786858 }
787859 if per_table :
788860 data ["per_table_bytes" ] = per_table
789861 return data
790862
863+ def profile_reserve (self , estimate : float ) -> dict | None :
864+ """How much of ``estimate`` is escalation reserve rather than measured
865+ scan, as a sentence plus the two numbers behind it. ``None`` when this
866+ command priced no profile, so there is nothing to attribute.
867+
868+ The confirm handshake and the over-ceiling refusal both reach for this,
869+ which is the point of it existing: a refusal that quotes a number
870+ without saying what it is made of leaves the operator to reconstruct
871+ the split from the spend ledger by hand, which is what issue #299
872+ reported doing. Only the command-level handshake asks, because only
873+ that estimate is the one :meth:`profile_estimate` built.
874+
875+ The remainder is described rather than the recorded scan half quoted,
876+ because a caller may have added statement estimates of its own to the
877+ total (``explore query`` prices an auto-profile and its statements in
878+ one handshake) and those are dry-run figures too.
879+ """
880+
881+ composition = self ._composition
882+ if composition is None or not composition .reserved_queries :
883+ return None
884+ return {
885+ "note" : (
886+ f"{ composition .reserved_bytes :,.0f} bytes of this estimate is "
887+ f"escalation reserve: { composition .reserved_queries } queries at "
888+ f"BigQuery's { _MIN_BILLED_BYTES :,} -byte per-query minimum, held "
889+ "for probes a profile may add after its aggregate scan and may "
890+ "never issue. The remaining "
891+ f"{ estimate - composition .reserved_bytes :,.0f} bytes is dry-run "
892+ "scan"
893+ ),
894+ "reserved_bytes" : composition .reserved_bytes ,
895+ "reserved_queries" : composition .reserved_queries ,
896+ }
897+
791898 def _min_billed_floor (self , sql : str ) -> float :
792899 """BigQuery bills at least ``_MIN_BILLED_BYTES`` per table a query
793900 references. The floor for one query is that minimum times its distinct
0 commit comments