sutura-exec-bigquery¶
The public API of sutura-exec-bigquery, rendered from rustdoc JSON.
A Warehouse adapter over BigQuery: render the plan, push it down, map the rows back.
BigQuery is the first source in this repository that is a network service with its own
authorization, which is what makes it the interesting one: a file engine has nobody else to be,
and a dataset has grants that belong to somebody. Everything about that is declared here rather
than assumed - see identity, below.
What is built, and what is NOT¶
This crate does not contain an HTTP client, and that is a decision rather than an omission. What it contains is everything this adapter DECIDES:
- the credential match and the posture agreement, so a leg cannot execute as an identity nobody declared;
- the rendering, through
sutura-sqlinDialect::BigQuery, so no second set of quoting and placeholder decisions exists here; - the refusal of a federated leg, because there is no combiner above it;
- the value mapping, which is where a wrong number would come from;
- the boot pre-flight, which asks each dataset once - not once per model - whether it holds the
tables the bundle names, so a mistyped table name costs a boot refusal here as it already does
on a
filesdeployment rather than a failed answer for whoever asks first.
A limit of that mapping, stated because it decides what a time column on this source is:
transport::FieldType reads DATE and refuses TIMESTAMP and DATETIME - a timestamp arrives
as epoch-seconds text the Date arm cannot parse, so either comes back Unmapped and fails the
answer, which is the correct and loud outcome. A time column therefore has to be a DATE here.
The wire - one transport::JobTransport that speaks to the endpoint - is wire, behind
the default-off wire feature. docs/adr/0018 is the decision that produced it and prices what
it costs; the two reasons it was absent are answered rather than repealed:
- The dependency addition turned out to be zero new packages in
Cargo.lock, measured:ureqat the resolved version and features is already in the graph underlibduckdb-sys. The feature is default-off anyway, so which side of the build its TLS stack is compiled on stays a decision a composition root makes in a manifest line. - Nothing in CI can verify it; a developer's own project now has. On 2026-08-30 the three
#[ignore]d tests intests/acceptance.rspassed against a real dataset under a service-account key - the first statement this repository generated to be accepted byBigQuery. What that one is, exactly: one hand-builtSUMover a two-column fixture, so it says nothing about a join,COUNT(DISTINCT,CASE WHEN, aNULLIFratio orISOWEEK- and the last is one of the two constructsdocs/adr/0017measured the parse check to be blind about. The corpus-wide leg that record specifies istests/corpus.rs, beside it, behind the default-offfixturesfeature: it loads the example fixtures into four tables throughBigQueryWarehouse::load_fixture, runs the corpus questions, and compares its rows with the engine's for the same plan. That is where the join, the ratio andISOWEEKare reached.
So nothing here may be cited as an invariant. sutura-serve DOES link this adapter and dispatch
kind: bigquery behind its default-off bigquery feature - docs/adr/0017's second amendment
records the day the last not wired was spent, and the two sentences that used to stand here
said the opposite. What is still true is that a default build links none of it,
and the data_systems: axis of the golden matrix still gains no entry - and the reason for that
last one has changed rather than gone away. It was a cell that has never executed reads as
coverage; the corpus leg executes, so what keeps the entry out now is that a cell in that registry
runs inside just test and this one cannot: the nix sandbox has no network, so acceptance is a
nix run app and not a checks.* output.
Identity¶
BigQueryWarehouse::IMPERSONATION is PerSubjectCredential, which is what makes a source
executed as the asking subject representable here: the credential a broker mints for the asker is
carried as a Presented::SubjectToken and sent as this job's bearer, so the dataset evaluates
the statement under whoever that token is. The wire's own credential source stays for the
shared posture. Per-subject execution still needs a broker that mints a per-leg credential through
a token exchange - this crate performs no exchange, it presents one - and that broker lives beside
the composition root that links this adapter, which is the half docs/implementation-plan-bigquery.md
describes as not wired.
ONE of the two subject shapes, and the other is refused rather than degraded. The domain's
Presented::SubjectPrincipal is a principal the data system switches to on a connection the
DEPLOYMENT authenticated, and BigQuery has no such mechanism; it is the same POSTURE as a
subject token, so Presented::agrees_with passes it and only this adapter can say it has
nowhere to put it. BigQueryError::NoPrincipalSwitch is that refusal, and the reason it is a
refusal is the reason the whole-shape NoPlaceForASubject it replaced existed: a leg accepted
here would be submitted under the transport's own credential while provenance, read off this
source's posture, reported the answer as impersonated.
What no version of this is: a deployment where a served source executes as its asker.
sutura-serve refuses an impersonation-at-source bigquery entry by name, because no broker
that exchanges is attached to a served source yet - see that crate's build_bigquery.
Two things this adapter deliberately does not offer¶
No arbitrary SQL entry point. BigQueryWarehouse::execute takes an Executable and
renders the statement itself; transport::JobRequest::new is pub(crate), so there is no way to
hand a statement to a transport from outside this crate. The fixtures feature does not open
one: load_fixture takes a table name and a path, and crate::importer renders the statement
from names that parsed and cells that parsed - refusing, rather than escaping, a cell that could
close a literal.
No result caching. Under row-level security a query-keyed cache is a cross-user leak, and this is the first adapter where there would be row-level security to leak through.
enum BigQueryError¶
Why this data system could not answer.
Generic in the transport's own error, for the reason sutura_app::ServiceError is generic in the
adapter's: erasing it here would mean a caller that knows which transport is installed could no
longer tell a refused credential from a dropped connection. The chain still walks - the cause is
an owned #[source].
Variants¶
Endpoint- The endpoint did not answer.Render- The plan would not render.LegWithoutCombiner- A federated leg arrived, and there is nothing above it to combine legs.NoPrincipalSwitch- The leg presents a principal for the data system to switch to, and there is no such mechanism here.PresentedDisagreesWithPosture- The leg's credential and this source's declared posture do not agree.UnmappedType- A column came back as a type this adapter does not map.NotAnInteger- A cell declaredINT64did not parse as one.NotADouble- A cell declaredFLOAT64did not parse as one.NotABool- A cell declaredBOOLwas neithertruenorfalse.NotFinite- A double came back non-finite.NotADate- A cell declared as a date did not parse as one.RowWidth- A row had more or fewer cells than the schema had columns.Incomplete- The endpoint delivered a page whose row count is not what it reported as total.NoIdentityInTheAnswer- The identity read came back as something other than one row of one text cell.Shape- The result set could not be built.
Implements¶
Debug, Display, Error
struct BigQueryWarehouse¶
A BigQuery dataset, behind the Warehouse port.
Generic in its transport rather than holding a boxed one: there is one per process, it is chosen at
composition, and a generic keeps the transport's own error type visible in BigQueryError.
Methods¶
Removes one table from the connection's dataset.
The tidy half of per-run fixture cleanup. A run names its tables with a per-run suffix
(see tests/corpus.rs), so what it removes is its OWN tables and never a colleague's. The
crate::importer header states the guarantee half - every CREATE also carries a 24-hour
expiration, because panic = "abort" means a cancelled runner never reaches this method and
the expiration is what still cleans up after it.
It takes a table name and never a statement, for the same reason load_fixture does: the
statement is rendered from a name that parsed, and no arbitrary SQL entry point stays true.
Behind the same fixtures feature and in the same impl block, for the same two reasons.
Replaces one table in the connection's dataset with the rows of a committed fixture CSV.
The mirror of #78's PostgresWarehouse::load_csv, and it exists for the reason that one
does: a relational data system has to be GIVEN tables before a corpus can be run against it,
and the example models are files. The differences from the Postgres shape are in
crate::importer's header - there is no COPY, so the rows travel inside the statement and
every cell is re-rendered from a parsed value.
Behind the fixtures feature, so no shipped build holds it. Cargo.toml carries that
argument. What it buys over a #[cfg(test)] helper is that the acceptance leg is an
INTEGRATION target - a separate crate - which cannot reach a test-gated item here.
It takes a table name and a path and never a statement, which is what keeps no arbitrary SQL entry point true of this crate: the statement is rendered from names that parsed and cells that parsed.
In THIS impl block rather than in the module that renders the statement, because
clippy::multiple_inherent_impl is denied here and it is right to be: a type whose inherent
methods are spread over files is one whose surface nobody can read in one place.
Returns how many data rows the fixture carried, so a caller can assert the load moved what the file holds rather than trusting a green.
pub const fn new(source: SourceName, posture: SourcePosture, billing_project: ProjectId, default_dataset: DatasetId, transport: T) -> Self
Opens a dataset.
Every argument is required and none has a default, which is the shape the port asks for and the reason is different for each: a defaulted posture would be a claim about who a query runs as that nobody made, and a defaulted billing project would be a project somebody else pays for. The billing project is the caller's to supply because there is nothing to infer it from - it is a path segment of the request that submits a job, and a federated identity has no project of its own.
pub fn session_user(&self, presented: &Presented) -> Result<SessionUser, BigQueryError<<T as >::Error>>
Who this data system says the leg presenting presented is executing AS.
The observable for the claim this adapter's IMPERSONATION constant makes. A
Presented::SubjectToken rides as this job's own bearer, so what the endpoint resolves
that bearer to IS the identity the source executed under - and asking the source rather than
asserting it is the difference between evidence and a comment. docs/adr/0008 names
SESSION_USER() as the primitive; SESSION_USER is the only statement this can issue.
It goes through Self::deliverable like every other credential-taking method, so a leg
whose credential disagrees with the source's posture is refused here too rather than being
answered by a read that looks harmless.
The SessionUser answer redacts under Debug; explicit access and Display still
reveal it. Neither this read nor its return type establishes how the bearer was obtained.
Not part of the Warehouse port, and that is a decision rather than an omission. No
other adapter can answer it - sutura-exec-datafusion and sutura-exec-duckdb execute in
process under one identity, so a defaulted method would answer the process and read as
though it had asked. An inherent method is reachable by the one venue that needs it and by
nothing that federates.
Errors¶
BigQueryError::Endpoint where the endpoint did not answer,
BigQueryError::Incomplete where the page and the reported total disagree, and
BigQueryError::NoIdentityInTheAnswer where the answer is not one row of one text cell.
Nothing here quotes what came back: see that variant.
Implements¶
Debug, Warehouse
use SessionUser¶
An endpoint identity answer whose Debug never renders its contents.
Self::as_str and Display expose the unchanged answer. This is a Debug boundary,
not a restriction on intentional logging, nor validation or authentication of the identity.
use Dropped¶
What a DROP answers with: nothing, or why it did not happen.
Named for the same reason Loaded is - Result<(), FixtureNotLoaded<T::Error>> is over the
type_complexity threshold this workspace tightened, and the generic error is the point.
use FixtureNotLoaded¶
Why a fixture did not reach the dataset.
Two shapes rather than one, because a defect in a file in this repository and a refusal from the endpoint are different problems for whoever reads the failure: the first is fixed in a diff and the second is a grant, a quota or a dataset that is not there.
use FixtureNotUsable¶
Why a committed fixture cannot become a table.
Every variant is a defect in a file in this repository rather than an input to handle, which is why none of them carries the offending text: whoever sees one has the file.
use Loaded¶
What one load answers with: the row count, or why it did not happen.
Named because Result<usize, FixtureNotLoaded<T::Error>> is over the type_complexity threshold
this workspace tightened, and for the reason crate::Mapped is named: the generic error is the
point, and erasing it would lose which transport failed.
use StsCredential¶
One exchanged credential: a Google access token and the instant it stops being usable.
The deadline is carried beside the token - the whole point of docs/adr/0008's
Expiry - so a broker can compute one deadline for the whole
answer and nothing answers with a token that was already dead.
No PartialEq/Eq, because it holds a Secret - a derived == on credential material is a
timing oracle, the same reason the domain's Secret has no comparison.
use StsExchange¶
Exchanges one subject's token for a credential to a BigQuery source.
The narrow port that keeps WorkloadIdentityBroker testable without a network, for the same
reason crate::transport::JobTransport exists: everything the broker decides is exercised against
a fake, and the HTTP exchange is one implementor behind the wire feature. Nothing here takes a
&Warehouse or a deadline - it is as narrow as a broker's need.
use SystemClock¶
The wall clock: the shipping UnixClock.
What WorkloadIdentityBroker::empty hands a composition root, so wiring a served deployment
takes no clock argument and a test that wants a fixed instant says so through
WorkloadIdentityBroker::measured_against.
Not the only ambient time read on this path, and the distinction is the control's limit.
crate::wire::StsOverHttp::exchange reads its own clock to turn the provider's expires_in
into the deadline this floor then judges. So the floor's COMPARISON is deterministic; the path
it judges still has two clock reads in it, milliseconds apart in production and not the same
instant.
use UnixClock¶
Where this broker reads "now" for its expiry floor.
A port for the same reason StsExchange is one. Everything this broker DECIDES is
exercised against a fake, and the floor is one of the things it decides - so an ambient
SystemTime::now() inside WorkloadIdentityBroker::mint would make the outcome of every
broker-level test a function of the day it ran on. The instant is an input instead, and
A_FIXED_NOW in this file's suite records what that bought.
The narrower shape this is NOT. Every other time-dependent API in this workspace takes the
instant as a parameter - Expiry::passed_by, LegCredentials::still_usable_at,
Minted::agreeing_with, crate::wire::AccessTokens::bearer - and that is the better shape. It
is unavailable here because CredentialBroker::mint is a DOMAIN port signature carrying no
instant, and widening it reaches ten implementors across eight crates. A held clock is what an
adapter can do alone; the parameter is the follow-up.
use WorkloadIdentity¶
The setup one impersonating source needs from the settings tree, minus the borrowing.
Carried here rather than as a reference into configuration because an adapter may not depend on the settings tree. The composition root constructs one of these per source from the parsed declaration, which has already refused a value that is not usable.
use WorkloadIdentityBroker¶
A broker that mints a per-subject credential for impersonating sources and a declared witness for shared ones.
Module transport¶
The one thing this adapter needs from a BigQuery endpoint, as a port.
Why there is a port here at all, when the adapter is already behind one. Warehouse is the
domain's port and this crate implements it; this is a second, much narrower one inside the
adapter, and it buys two things that matter more than the indirection costs:
- Everything this adapter decides becomes testable without a network. The credential match, the posture agreement, the leg refusal, the rendering and the whole value mapping are exercised against a fake that returns rows, which is what ports get fakes, not mocked HTTP asks for.
- The dependency decision is isolated to one implementor. An outbound HTTP stack plus a
credential source is a real addition to a workspace that cross-compiles to musl and gates
licences exactly, and it arrives in exactly one place:
crate::wire, behind the crate's default-offwirefeature.docs/adr/0018prices it. The sentence that kept this seam empty for a release - nothing in this repository can verify a network client - is now half spent: nothing in CI can, and a developer's own project has. Three tests passed against a real dataset on 2026-08-30, over one hand-builtSUMrather than the corpus.
What is deliberately NOT here: a method that takes a string. The request carries a statement this crate rendered from a plan, and there is no entry point a caller could hand SQL to.
enum ParameterMode¶
How a request writes its bind parameters.
One variant, and it is a variant rather than an absence because the endpoint's own request body carries this as a field with two values: a query may use positional parameters or named ones and not both, so a transport has to state which it is sending rather than infer it from the text.
Positional is the one this adapter uses, and the decision is recorded on
sutura_sql::Dialect::placeholder_style: a rendered statement carries ? and a
sutura_sql::GeneratedQuery carries an ORDERED list of values with no names, because a
parameter's identity in a plan IS its position. Named parameters would need a name invented per
parameter, with nothing in the domain to invent it from.
Variants¶
Positional-?in the statement, an ordered array of values carrying no names beside it.
Implements¶
Clone, Copy, Debug, Eq, PartialEq
struct JobRequest¶
One query job, as this adapter asks for it.
Borrowed rather than owned throughout: it is built per call, handed to one transport, and dropped. A clone here would copy the statement for no reason.
Methods¶
The project this job is billed to.
The dataset the statement's unqualified table names resolve in.
This is why the generated statement needs no qualifying, and it is the reason the fourth dialect changed nothing about how a table is rendered: the endpoint's request carries a default dataset beside the SQL, so a bare backticked table name resolves there. The generator emits the same shape it emits for every other dialect.
The values, in the order the statement's placeholders take them.
The order is the contract, because ParameterMode::Positional means the endpoint pairs
the nth value with the nth ?. A transport that reordered this would send a different query.
The statement, with its values still absent from it.
The asking subject's own credential, where the leg carried one.
This is the half that makes a BigQuery source execute as the asker. A
Presented::SubjectToken carries the
credential a broker minted for the asking subject - an exchanged Google access token scoped
to that subject - and the transport sends it as its bearer for THIS job, so the endpoint
evaluates the statement under whoever the token says. None for the shared posture, whose
leg runs under the identity the transport itself already holds.
Implements¶
Debug
struct ProjectId¶
The project a job is billed to, as this adapter holds it.
A wrapper for the reason sutura_exec_datafusion::WorkingSet is one: a project id and a
dataset id are both text, and a call taking two &str in the wrong order compiles and is wrong.
Where the format is checked, and why it is checked twice. sutura_config::BillingProject
refuses an unusable value when the settings tree is READ, so a deployment fails at startup rather
than on its first question - that is a diagnostic job. This type refuses it again because THIS is
the crate whose transport interpolates it into a request path, and a check belongs where the risk
is. The two are not one copy of one rule: an adapter may not depend on the settings tree, so
sharing the type would be an adapter reaching into another adapter.
Ord is derived so the pre-flight can group by it, and the ordering it derives is the inner
string's: parse neither trims into a different value nor folds case, so the wrapper compares
exactly as the text it holds does and there is no invariant for the derive to disagree with.
Methods¶
The id, for building a request.
Parses a project id.
The accepted set is [a-z0-9-], which is what keeps the value inside one URL path segment: no
/, no ?, no #, no %, no whitespace, nothing non-ASCII. Length is NOT bounded here and is
bounded where the value is declared - this crate's job is that the value cannot escape a
request, and a too-short id is a diagnostic the settings tree already gives.
Implements¶
Clone, Debug, Eq, Ord, PartialEq, PartialOrd
struct DatasetId¶
The dataset unqualified table names resolve in, as this adapter holds it.
Ord for the reason ProjectId's is derived, plus one of its own: this type PRESERVES case, so
the derived ordering and the derived equality are the case-sensitive comparison a dataset id
really wants.
Methods¶
The id, for building a request.
Parses a dataset id.
[A-Za-z0-9_], case PRESERVED - a dataset id is case-sensitive, so folding it here would turn
a working declaration into a dataset that does not exist.
Implements¶
Clone, Debug, Eq, Ord, PartialEq, PartialOrd
struct DatasetAddress¶
One dataset, addressed the way a metadata read needs it: who pays, where it lives, and its id.
A named struct rather than loose arguments, for the reason ProjectId is a wrapper at all:
a call taking ids of the same shape in the wrong order compiles and is wrong. It is also the
grouping key the pre-flight uses, which is what the derived Ord is for.
THREE fields and not two, and the third one is a review finding rather than symmetry. The
first shape of this type carried the dataset's project only, and the listing then sent that as
the quota project - so a source declared billing_project: acme-analytics reading a model at
partner-data.shared.dim_region attributed the listing to partner-data, which the caller holds
no serviceusage.services.use on. It would have 403'd and become a permanent warning, while a
QUERY against the same table attributed to acme-analytics and worked. docs/adr/0018 states
the rule as carrying the source's declared billing project.
What that says about the newtype, written down because it is the interesting half: a wrapper
per id prevents an argument-ORDER mistake and permits a ROLE mistake - where the dataset lives
against who pays - and the role mistake is the one that happened. Two accessors named for their
roles is the fix that a single project field could not be.
Methods¶
The project whose quota and billing this read is attributed to: the SOURCE's, always.
Read into x-goog-user-project where the credential requires that header, which is the one
place the distinction from Self::project bites - see this type's own documentation.
The dataset's own id.
Addresses a dataset: the source's billing project, the dataset's own project, and its id.
The first two are equal for an unqualified model and differ for a cross-project one, which is exactly the case the role distinction exists for.
The project the dataset LIVES in, which is the one written into the request path.
Not the one the read is attributed to - Self::billed_to is.
Implements¶
Clone, Debug, Eq, Ord, PartialEq, PartialOrd
struct HeldTables¶
Every table one dataset holds, by the id it knows each under - and what the listing said about how many there were supposed to be.
A struct rather than the BTreeSet<String> alias it was, and the second field is the whole
reason. The set alone cannot tell an EMPTY dataset from a document whose shape the service
changed: both arrive as no ids at all, and the pre-flight reads no ids as every table is
absent. ListingTotal is what the two can be told apart by.
A readable total can expose a shortfall. An unreadable total beside zero readable IDs exposes
an inventory this adapter could not read, without supplying a count. ListingTotal::Unreported
still cannot distinguish an empty dataset from a changed document. Readable IDs dropped by name
filtering remain identified, so an ordinary dataset of unsupported names is not that finding.
Why it travels on the answer rather than being decided here, now that it IS decided on: the
decision needs the tables the BUNDLE names, and this port has never seen them - it answers about
a dataset. BigQueryWarehouse::preflight is where the two meet, and that is the layer that reads
this field. JobTransport::listing_was_refused shows the port CAN hold a decision on the layer
above's behalf, so the layer is a choice rather than a constraint; the reason it is this one is
that a verdict minted here would be one taken without half its input.
The set is still what the pre-flight asks with, and Self::holds is its only question;
Self::named is for a diagnostic and for a test, not for a count anything concludes from.
Methods¶
Whether the dataset holds a table under exactly this id.
Case-SENSITIVE, because GoogleSQL does not fold a table name - BigQueryWarehouse::preflight
carries the argument, and this is the call it makes.
Every id the listing named.
The ids a listing named, and what its own total said about them.
What the listing's own reported total said about the ids the same document carried.
Implements¶
Clone, Debug, Eq, PartialEq
enum ListingTotal¶
What a listing's own reported total said, against the entries of the same document whose table id it could read.
Four variants rather than an Option<u64>, because each says something different about what a
caller may conclude - a missing total and an unreadable one cannot carry the same decision.
A reader has to name the case, for the reason
sutura_domain::source::AnchorIdentity names NoneDeclared rather than answering None.
The comparison is against the entries that carried a table id this crate could READ - neither
the ids the listing NAMED nor the entries it merely counted, and each half of that is a wrong
claim avoided. An id outside usable_table_id's accepted set is DROPPED from the named set, and
BigQuery permits such an id, so comparing against the named set would report an ordinary
dataset as short of its own total. The entry count is the mistake the other way, and it was this
type's first shape: a document whose tableReference the service renamed or nested carries
entries and no readable id, which read Self::Accounted over no ids at all - the pre-flight
reporting every table absent while the cross-check read clean. An entry with no readable id is
the shape signal; an id usable_table_id rejected is the legitimate drop, and it still counts.
The same cross-check one document over is crate::BigQueryError::Incomplete, which compares
delivered against total on a query answer and REFUSES. Two vocabularies for one shape, named
here so a reader who greps one finds the other. This type carries the inventory evidence;
preflight decides whether it leaves a requested table unaccounted for and refuses through a value.
Variants¶
Unreported- The document carried no total at all, so an empty listing and an empty dataset are one value.Unreadable- It carried a total this crate could not read as a count.Accounted- It reported a total, and carried a readable table id for every table the total claims.Short- It reported MORE tables than the same document carried readable table ids for.
Implements¶
Clone, Copy, Debug, Eq, PartialEq
struct Shortfall¶
How far a listing fell short of its own reported total.
A parsed type and not two u64 fields on the variant, because the variant's fields were
PUBLIC and the invariant lived in an if one module away. Review reproduced
telekom/sutura#275 through that door on an unmutated tree: ListingTotal::Short { reported: 1,
identified: 5 } is constructible, HeldTables::of is a pub const fn, and the pre-flight's
subtraction then saturated to a shortfall of zero and fell back to reporting the bundle's tables
ABSENT - the exact defect being fixed, reachable through the public API. A type that forecloses a
zero shortfall is worth nothing while a constructor can route around it, so the door is closed
rather than documented.
Stored as identified plus a NonZeroU64 gap rather than the two totals, so
Self::unaccounted is a field read: the count that decides cannot be derived wrongly, and
Self::reported reconstructs exactly because the sum is the number Self::parse was given.
Methods¶
How many entries of the same document carried a table id this crate could read.
Parses a reported total against the ids of the same document this crate could read.
Errors¶
NotShort::Accounted where the total is not ABOVE the identified count - which is
ListingTotal::Accounted's case and belongs in that variant, not this one.
The total the document reported.
How many tables the total claims that no readable id accounted for. Never zero.
Implements¶
Clone, Copy, Debug, Eq, PartialEq
enum NotShort¶
Why a pair of counts is not a shortfall.
One variant, an enum for the reason every other error in this crate is one: a second reason has somewhere to go.
Variants¶
Accounted- The total is not above the ids the same document accounted for, so nothing is missing.
Implements¶
Clone, Copy, Debug, Display, Eq, Error, PartialEq
enum NamedResource¶
Which of this adapter's two resource names a refusal is about.
A variant rather than the &'static str this field used to be, because Structured Errors
says the variant is the contract and the message is not: a test can assert Self::Project
and a rename is then a compile error at the assertion, where a string compare kept passing while
asserting the old spelling. The core::fmt::Display impl is the one place the operator-facing
wording lives, so UnusableResourceName's sentences read exactly as they did.
Variants¶
Project- The project a job is billed to -ProjectId.Dataset- The dataset unqualified table names resolve in -DatasetId.
Implements¶
Clone, Copy, Debug, Display, Eq, PartialEq
enum UnusableResourceName¶
Why a resource name this adapter was handed is not usable.
Variants¶
Empty- Nothing was written, or only whitespace was.Character- A character that could leave the part of a request this value is written into.
Implements¶
Clone, Debug, Display, Eq, Error, PartialEq
enum FieldType¶
What the endpoint said a column is.
A closed set plus one named escape, rather than a passthrough of every type the endpoint can
return. Each variant here is a claim that this adapter maps that type to a domain value and has a
test saying so; Self::Unmapped carries the endpoint's own spelling so a type nobody mapped
produces an error NAMING it rather than a null.
Variants¶
Int64- A 64-bit integer.Float64- A double. Mapped throughReal, which refuses a non-finite value.Numeric- An exact decimal -NUMERICorBIGNUMERIC. Mapped to TEXT rather than to a double, so an exact total stays exact;sutura-exec-duckdbmaps its ownDecimalthe same way and for the same sentence.Bool- A boolean.String- Text.Date- A calendar date, as ISO text.Unmapped- A type this adapter does not map, under the name the endpoint used for it.
Methods¶
Decodes a type name the endpoint sends, into the closed vocabulary this adapter maps.
A query response spells the types the legacy way - INTEGER/FLOAT/BOOLEAN - while the
variants here are named after their modern spellings. The transport that reads an answer's
schema calls this, so which spellings become Int64 is decided HERE, where the value mapping
lives, and not in the unbuilt transport. A name nobody maps becomes Self::Unmapped under
the endpoint's own spelling, so an answer is refused NAMING it rather than answered as null.
Implements¶
Clone, Debug, Eq, PartialEq
struct Field¶
One column, as the endpoint described it.
Methods¶
What the endpoint said this column is.
The label a result column carries.
Names one column.
Implements¶
Clone, Debug, Eq, PartialEq
enum Cell¶
One cell, as the endpoint sent it.
Text or nothing, and that is the endpoint's shape rather than a simplification. A value in a
query response is a JSON string whatever its declared type is - an integer arrives as "250" - so
the mapping from text to a typed domain value is this adapter's work, and Field::kind is what
decides it. Modelling it as already-typed here would move that work into the transport, where the
fake and the real implementor would each have to do it and could disagree.
Variants¶
Null- JSONnull.Text- A value, as the endpoint spelled it.
Implements¶
Clone, Debug, Eq, PartialEq
struct JobRows¶
A job's result: what the columns are, the rows under them, and how many the job produced.
The count is part of the result, and that is what makes a partial answer not a result. The
endpoint's jobs.query answers one page - "as many results as can be contained within the
maximum permitted reply size" - and totalRows "can be more than the number of rows in this
single page". A first page, or an incomplete job's empty rows, is under the cap, not
truncated, and this adapter's rows refuses a delivered count that does not equal what the
endpoint reported as total - see super::BigQueryError::Incomplete.
Methods¶
The columns, in the order the statement projected them.
Assembles a result.
total_rows is what the endpoint reported as totalRows, which is present only when a job is
complete - so an incomplete job has no value to fill it with, and the transport has to error.
The rows on this page.
What the endpoint said the job's total is, which a delivered page is compared against.
Implements¶
Clone, Debug, Eq, PartialEq
trait JobTransport¶
A BigQuery endpoint, as narrow as this adapter's needs.
Two methods PUT A QUESTION TO THE ENDPOINT, because the port above it has two questions with
different costs: running a job reads data and is billed, and validating one does neither. The
endpoint really does distinguish them - its request body carries a dry-run flag, and a dry run
uses no slots and is not charged - which is what makes Warehouse::dry_run able to answer
PreFlight::Accepted honestly here rather than inheriting the port's NotAsked default.
Three more members are not that, and the count is spelled out because it has been wrong
twice. result_did_not_fit asks the implementor about a failure it already has and sends
nothing; list_tables sends a metadata read rather than a statement, which is what makes it
cheap enough for a boot check; and apply, behind the fixtures feature, is the second
statement-issuing method - present only in a build that loads fixtures, so no deployment can
reach it.
Module wire¶
The WIRE: one JobTransport that speaks to a BigQuery endpoint over HTTP.
This is the seam docs/adr/0017 left open, filled in by the decision docs/adr/0018 records.
Behind the crate's default-off wire feature, because what arrives with it is an outbound TLS
stack and two of the four release triples are musl; that manifest argument is on the ureq entry
in the workspace root and is not repeated here.
What this module claims, and what it does not¶
This HAS now been run against a real project, and that is new. On 2026-08-30 the three tests
in crates/sutura-exec-bigquery/tests/acceptance.rs passed against a real dataset from a
developer's machine, under a service-account key: the endpoint accepted a statement this repository
generated, answered it as one complete page, and the numbers were the fixture's. It is the first
time anything here has had a statement accepted by BigQuery.
What that does NOT establish, stated first because a green run invites the larger reading. It
is ONE hand-built SUM over a two-column fixture - no join, no COUNT(DISTINCT, no CASE WHEN,
no NULLIF ratio, no CAST(... AS FLOAT64), no ISOWEEK - and ISOWEEK plus DATE_TRUNC's
argument order are precisely the two things docs/adr/0017 MEASURED the parse check to be blind
about. The leg that record specifies is the corpus compared against the engine, and it is not
built. So: one statement accepted, not the corpus accepted.
What the suite beside this module proves is separate and unchanged: that this code builds the request it says it builds and reads the answer it says it reads, over documents that are not the service's.
So: one live statement is not a registered data system, and the data_systems: axis of the
golden matrix still gains no entry. sutura-serve DOES link this adapter and dispatch
kind: bigquery behind its default-off bigquery feature; the sentence that used to stand here
said it linked none, which docs/adr/0017's second amendment had already spent.
What this module decides, and every one of them is pinned by a TYPE or by a test¶
- A job is bounded in TIME and in MONEY, and neither bound is a constant here.
JobBoundscarries both,WireAgentcarries theJobBounds, andBigQueryWirecan only be built from aWireAgent- so there is no way to submit a job this deployment did not bound.jobTimeoutMsis what cancels a job at the service (timeoutMsalone does NOT: it bounds how long the client waits, and an expired one leaves the job running and billing), andmaximumBytesBilledis what stops a question scanning a petabyte - neither the row cap nor the one-page refusal bounds bytes scanned. - The time bound is ONE ABSOLUTE DEADLINE PER CALL, not a timeout per HTTP operation, and this
bullet exists because the earlier shape was the second thing while claiming the first. A single
call does a token exchange and then a job;
timeout_globalon the agent gave each of them a full budget of its own, so a review measured one ANSWER -dry_runthenexecute, two exchanges and two jobs - at four independent budgets against a transport whose own request timeout is thirty seconds.CallDeadlineis opened once insubmitand every operation below it gets only what is LEFT: the exchange's socket, the job's socket, and thetimeoutMs/jobTimeoutMsthe request carries. A budget spent before the job isWireError::DeadlineSpentrather than a send. The limit, because it is the half a type here cannot reach: neitherWarehousenorJobTransporttakes a deadline, so the two calls one answer makes cannot share one - an answer's worst case isQueryDeadline::CALLS_PER_ANSWERbudgets. That arithmetic is done once, inQueryDeadline::within_request_timeout, so a composition root gets a deadline that already fits inside the request timeout instead of a number it has to divide correctly. - One page or a refusal.
jobs.queryanswers one page, and completeness is stated astotalRowsbeside the rows rather than by the rows alone. The wire refuses apageToken(WireError::MoreThanOnePage) and a job that did not finish (WireError::NotComplete); the delivered count that is not the reported total is refused one port further out, in the adapter'sBigQueryWarehouse::rowsasBigQueryError::Incomplete-completehere compares nothing, it hands the rows and the total to the adapter - because toanswer()a first page would read as under the cap, not truncated, which is the exact row the row-cap invariant exists to hold. And a wide result now leaves as a REFUSAL rather than as a503, which is a correction to what this header used to say was the cost. It used to reach a caller asBigQueryError::Endpoint, which both transports answer as the status a dead endpoint produces - inviting a retry that returns the same page.ResultTooLargeis what it means, and the port can now say it:result_did_not_fitoncrate::transport::JobTransportanswers it forWireError::MoreThanOnePage, the adapter passes it up throughWarehouse::result_did_not_fit, and a caller gets413 result_too_largecarryingResultBound::Volume- a bound with no number, because the reply cap is the service's and it reports neither that nor the size of the reply that hit it.NotCompletedeliberately answersfalse: a job that ran out of time may finish on a retry. - The service's own result cache is turned OFF. Not for cost: an anchor that reproduces from a
cache has reproduced the cache, which is
differential.rs's own argument. And a cached answer under a shared identity is shared across every asker, so leaving it on would put the cross-user leak this crate refuses one layer below the code the per-subject step has to change. - The bearer's DESTINATION is a compile-time constant; its ROUTE is not, and the difference is
worth stating precisely because an earlier version of this header overstated it.
HOSTcannot be configured,https_onlyis on andmax_redirectsis0, so nothing a deployment writes can change which service receives the credential. What a deployment CAN change is the path:ureq's default config isProxy::try_from_env(), soHTTPS_PROXYroutes these requests through an egress proxy. That is left ON deliberately - an egress proxy is a real deployment shape here,docs/enterprise-mirrors.mdis the generic form of it - and it is safe because the tunnel is still TLS toHOSTverified against a compiled-in root set, so a proxy sees a hostname and no bytes. It is written out inWireAgent::pinnedrather than inherited, so it is a decision a reviewer can disagree with. - Failure is derived from the RESULT SHAPE and never from
errorsbeing non-empty. The endpoint documents that array as "the first errors or warnings encountered" and says entries "do not necessarily mean that the job has completed or was unsuccessful" - so refusing on it would decline successful queries that merely warned. What refuses isjobComplete, apageToken, an absenttotalRows, and a delivered count that is not the reported total - the last of those in the adapter (BigQueryWarehouse::rows,BigQueryError::Incomplete), not here; the reportedreasonis folded into whichever of those fires, because it is the best diagnostic available at that point. Seecomplete, and the limit stated there. - Refusal text is bounded and filtered, not discarded.
credential::boundedhandles thereason;EndpointMessageretains the free-textmessageand redacts it underDebugonly.Displayand cause-chain logging can still render the message.
What is deliberately absent¶
- Paging. A result bigger than one page is refused rather than assembled.
getQueryResultsneeds the job'slocationfor a dataset outside the two multi-regions, andSourcePlacement::BigQuerydeclares nolocation-docs/adr/0017says why that field is not in this repository yet and that the change adding the wire is the one that decides it. This change decides it by not needing it, and the cost is the refusal above. - A
locationon the request. Same reason, one size smaller. - Retries. A refused job comes back as
WireErrorand reaches a caller asBigQueryError::Endpoint, whose transport-facing status is a503. Retrying inside an adapter would spend a caller's request timeout on a decision the caller cannot see. - Surfacing a warning on a result that IS complete. There is nowhere to put it:
RowSethas no field for it and this crate has no logging dependency, so adding one for a line nobody has ever seen is a dependency decision this change does not take. Stated because a dropped warning is exactly the kind of absence that reads as "there were none".
struct WireAgent¶
The client every request in this crate goes through, with the four settings that matter PINNED BY THE TYPE rather than by a call site.
This newtype is the whole mechanism, and it exists because the previous shape was a convention.
The settings below used to live in a free function returning a bare ureq::Agent, and both
BigQueryWire::new and credential::ApplicationDefault::read accepted any agent - so a
composition root writing ureq::Agent::new_with_defaults() got redirects on, plaintext allowed
and no timeout, while every test passed because the tests all called the right function. A private
field with one constructor is what a newtype parses rather than validates asks for: if an
instance of this exists, the pins hold.
It also carries the JobBounds, so the deadline that shapes the socket timeout and the deadline
that goes into the request body are the same value. Two arguments could have disagreed.
Methods¶
What every job through this client is bounded by.
The one constructor, and every non-default setting below is a decision:
http_status_as_error(false), because the client's default turns a4xxinto an error and discards the body - and the body is where the endpoint says which refusal this is. Status is read explicitly instead, inrefusal.https_only(true), so a bearer token cannot leave over plaintext even if a URL somewhere loses its scheme.HOSTis alreadyhttps; this is the second lock.max_redirects(0), so the credential has no second host to reach.ureq-protoalso stripsauthorizationon a redirect, which was verified rather than assumed - so this is belt and braces, and the belt is ours.timeout_global, at the job's deadline plusCONNECT_MARGIN, so the socket cannot outlive the job it is waiting for by more than connection setup.max_response_header_size, because headers are read before the body's own limit applies.proxy(Proxy::try_from_env()), which is the client's own default WRITTEN OUT rather than inherited. An egress proxy is a legitimate deployment shape and the tunnel is still TLS toHOSTagainst a compiled-in root set, so what the environment chooses is the route and not the destination. The module header states that distinction, because a previous version of it claimed the stronger thing.
Implements¶
Clone, Debug
struct EndpointMessage¶
The endpoint's own message on a refusal: free text, and the one field here that can name an account.
A type rather than a String, because the rule it carries is about RENDERING and a rule about
rendering cannot be held at call sites. Display is the message; Debug is redacted. That is
the whole mechanism, and it is here because the alternative was asking fourteen acceptance legs
to remember which formatter they used.
Measured, which is why this exists. A leg ending .expect("the endpoint answered") formats
its error with Debug, and Debug walks the struct: on a real refusal that printed
Access Denied: ... permission: <an account> into a public workflow log. Ten of the fourteen
legs nix run .#bigquery-acceptance invokes were in exactly that shape, and the job's
::add-mask:: step covers the project, the dataset and the table - not an account.
Display keeps the message because a 400 with only a reason code is undiagnosable, which is
what docs/adr/0018 prices.
What this does NOT do, and the earlier wording here claimed otherwise. It said a caller
"has to ask for the sentence by name". It does not: WireError::Refused's own Display
interpolates detail, so anything that walks a cause chain and to_string()s each link renders
it. sutura_app::surface::cause_chain does exactly that, and its output reaches
tracing::error! in the HTTP and agent transports - reachable from a sutura-serve --features
bigquery deployment. That path is pre-existing and deliberate: this workspace flattens a
cause chain at the sink, and a deployment's own log is not the public workflow log this
redaction targets. So the scope of the control is exactly one thing - Debug, which is what
a panicking test leg prints into a world-readable CI log - and it is not a general answer to
where the endpoint's message may travel.
It is already bounded and stripped on the way in - see Self::bounded.
Methods¶
The message itself, for a caller that has decided it may render it.
Named rather than reached through Deref, which cargo xtask check-newtype-leaks refuses:
a wrapper you can forget you are holding is not a wrapper.
The endpoint's message, capped and stripped of anything that could forge a log line.
Infallible: an absent message is an empty one, which is honest - the status is what is guaranteed.
Implements¶
Clone, Debug, Display, Eq, PartialEq
enum WireError¶
Why the endpoint did not answer with rows.
Generic in the credential source's own error, for the reason crate::BigQueryError is generic
in this one: a caller that knows which credential source is installed can still tell a missing
file from a refused refresh, and erasing it here would be the information this whole chain of
generics exists to keep.
ureq::Error appears as a #[source] and never as a variant this type re-exports, which is
the shape Structured Errors asks for at a boundary: the variant is ours, the chain still walks,
and a caller who knows the transport can downcast. It is boxed because it is much larger than
every other variant and clippy::result_large_err is on.
Variants¶
Credential- No token could be produced, so nothing was sent.Expired- A token was produced and its deadline had already passed.NoClock- This process could not read a wall clock.DeadlineSpent- This call's budget was gone before the job could be submitted.RequestNotSerializable- The request could not be serialized.Unreachable- The endpoint was not reached.Unreadable- The endpoint answered and the answer could not be read.Refused- The endpoint refused.NotADocument- The answer was not the document a query response is.NotComplete- The job had not finished when the endpoint answered.MoreThanOnePage- The answer is one page of more than one.NoTotal- A complete job that stated no total.NotATotal- The total was not a number.NoSchema- A complete job with rows and no schema to read them against.NotAScalar- A cell that is neither a string nor a null.NotAListing- The answer to a table listing was not one. Distinct fromSelf::NotADocument, the same failure for a query answer: two documents, two shapes, and one message per request.UnusablePageToken- The service handed back a page token this transport will not write into a URL. Refused rather than filtered, andtables::usable_tokencarries the argument; the token travels throughbounded, which keeps a foreign string out of a log unbounded.ListingDidNotFinish- A dataset that did not finish listing inside the page bound. A failure rather than a short listing: this feeds these tables are absent, so a cut-off listing reports a table that is there as missing.
Implements¶
Debug, Display, Error
struct BigQueryWire¶
A BigQuery endpoint, reached over HTTP.
Generic in its credential source rather than holding a boxed one, for the reason
crate::BigQueryWarehouse is generic in its transport: there is one per process, it is chosen
at composition, and a generic keeps the source's own error type visible in WireError.
It holds a WireAgent and not a ureq::Agent, which is what makes the module header's claims
properties of this type rather than of whichever function a composition root happened to call.
Methods¶
Opens a transport.
The WireAgent is a parameter rather than something built here so it can be the same one
the credential source refreshes through - one connection pool, one set of pins, and one
JobBounds shared by the socket timeout and the request body.
Implements¶
Debug, JobTransport
use BytesBilledCeiling¶
The most a single job may be billed for scanning.
Sent as maximumBytesBilled, which is enforced at the service and is what makes it worth
more than a client-side check. A job that would exceed it FAILS and is not charged. Nothing else
in this repository bounds bytes scanned: LIMIT 10001 bounds rows RETURNED, the one-page refusal
bounds a page, and MAX_ANSWER_BYTES bounds what is read into memory - a question can satisfy
all three and still scan a partitioned table end to end.
use CallDeadline¶
The instant one call into this transport has to be finished by.
One absolute deadline for the whole of one call, rather than a timeout per HTTP operation - and
that distinction is the correction this type exists to carry. The previous shape put
timeout_global on the agent, so EVERY request through it got the full budget independently: a
single crate::transport::JobTransport::run does a token exchange and then a job, and both were allowed
deadline + CONNECT_MARGIN of their own. A review measured the consequence at the answer level -
four HTTP operations, each with its own budget, against a transport whose own request timeout is
thirty seconds - and the five-second overrun this module claimed was false.
So the budget is opened once per call and every operation gets only what is LEFT of it: the token
exchange, the socket the job waits on, and the timeoutMs and jobTimeoutMs the request carries -
which is what keeps the service cancelling at the instant the client stops waiting even when the
exchange spent half the budget first. When nothing is left, the refusal comes before the send.
A monotonic std::time::Instant and not a wall clock, because a wall clock can step and a
stepped deadline is either a job abandoned early or one that outlives its caller.
The limit, and it is the half this type cannot reach: one ANSWER calls the port twice -
Warehouse::dry_run and then Warehouse::execute - and neither Warehouse nor crate::transport::JobTransport
takes a deadline, so the two calls cannot share one. An answer's worst case is therefore
CALLS_PER_ANSWER budgets rather than one, which is exactly why
QueryDeadline::within_request_timeout exists: it does that arithmetic once so a composition root
cannot get it wrong. Carrying one deadline across the port is an architecture decision, not a
signature tweak.
use JobBounds¶
What every job this adapter submits is bounded by.
Two bounds in one value, because they are one decision: how much of a deployment's time and money
may one question spend. A struct rather than two arguments so a call site cannot supply one and
forget the other, and so super::WireAgent can carry them both.
use QueryDeadline¶
How long a job may run, and how long the client waits for its answer.
A newtype rather than a constant, because the value belongs to the deployment. The setting that
decides it is the one the transport in front of this service already uses -
server.request_timeout_seconds, which ships as 30 - and a constant in this file would be a second
copy of it that drifts the day somebody changes the first.
It is a SHARE of that setting rather than the setting itself, which review had to point out:
one answer makes Self::CALLS_PER_ANSWER calls and each pays CONNECT_MARGIN on top of its
own budget, so filling this with 30 gives a caller who waits 30 seconds a query that may still be
running. Self::within_request_timeout is the constructor that does the division, and it is the
one a composition root should reach for; Self::parse stays for a deployment stating a budget
outright.
use UnusableBound¶
Why a bound this adapter was handed is not usable.
use StsOverHttp¶
An StsExchange that talks to Google STS over HTTP.
Module bounds¶
What one job may spend: the TIME bound, the MONEY bound, and the per-call budget they are charged against.
Split out of wire.rs when that file reached the thousand-line limit cargo xtask max-lines
enforces and cannot exempt, at the cut document.rs already made once. The seam is the first
bullet of wire.rs's own header - a job is bounded in TIME and in MONEY, and neither bound is a
constant here - and everything in this file is one of those two bounds, the arithmetic that
keeps them inside the transport's own request timeout, or the refusal a deployment gets when the
number it wrote cannot be used. Nothing here opens a socket, holds a credential or builds a
document, which is what makes it the half a test reaches without a project.
Why each bound is shaped the way it is stays with the type; wire.rs's header is the one place
they are argued together.
struct QueryDeadline¶
How long a job may run, and how long the client waits for its answer.
A newtype rather than a constant, because the value belongs to the deployment. The setting that
decides it is the one the transport in front of this service already uses -
server.request_timeout_seconds, which ships as 30 - and a constant in this file would be a second
copy of it that drifts the day somebody changes the first.
It is a SHARE of that setting rather than the setting itself, which review had to point out:
one answer makes Self::CALLS_PER_ANSWER calls and each pays CONNECT_MARGIN on top of its
own budget, so filling this with 30 gives a caller who waits 30 seconds a query that may still be
running. Self::within_request_timeout is the constructor that does the division, and it is the
one a composition root should reach for; Self::parse stays for a deployment stating a budget
outright.
Methods¶
The whole budget, as a duration.
The deadline in milliseconds, which is the unit both request fields take.
saturating_mul rather than *, and it cannot saturate: Self::MAX_SECONDS times a
thousand is far inside u64. Written that way because a bound that could wrap is a bound that
could become zero, and zero is the one value Self::parse refuses.
Parses a deadline in whole seconds.
How long a socket may stay open for a call that has spent none of its budget yet.
The backstop on the agent rather than the bound that holds. What a single operation is
really allowed is CallDeadline::socket(left) over what is LEFT of the call's budget - see
CallDeadline, and see the module header for why a per-operation timeout was not enough. This
value is what the agent is configured with, so an operation that somehow reached the client
without an override is still bounded.
The largest deadline that keeps one ANSWER inside a transport's own request timeout.
The arithmetic a composition root would otherwise have to remember, and get wrong. The
number to fill this from is server.request_timeout_seconds, which ships as thirty; what a
caller wants is not that number but the share of it one call may spend, because an answer makes
Self::CALLS_PER_ANSWER calls and each pays CONNECT_MARGIN on top of its own budget. So
within_request_timeout(30) is ten seconds, and two calls of ten plus five is the thirty a
caller was promised.
A request timeout too short to leave anything is UnusableBound::NoBudget rather than a
silently clamped value, because a deployment whose timeout cannot fit a query wants to be told
so at startup.
Implements¶
Clone, Copy, Debug, Eq, PartialEq
struct BytesBilledCeiling¶
The most a single job may be billed for scanning.
Sent as maximumBytesBilled, which is enforced at the service and is what makes it worth
more than a client-side check. A job that would exceed it FAILS and is not charged. Nothing else
in this repository bounds bytes scanned: LIMIT 10001 bounds rows RETURNED, the one-page refusal
bounds a page, and MAX_ANSWER_BYTES bounds what is read into memory - a question can satisfy
all three and still scan a partitioned table end to end.
Methods¶
The ceiling, as the request body writes it.
Text, because the endpoint writes and reads 64-bit integers as JSON strings. A number here would be silently truncated to a double by a strict reader at the far end.
Parses a ceiling in bytes.
Implements¶
Clone, Copy, Debug, Eq, PartialEq
enum UnusableBound¶
Why a bound this adapter was handed is not usable.
Variants¶
Zero- Zero, which would refuse every question rather than bounding one.TooLarge- Above what the endpoint accepts, or above what a bound is for.NoBudget- A transport's request timeout too short to leave a job any budget at all.
Implements¶
Clone, Debug, Display, Eq, Error, PartialEq
struct CallDeadline¶
The instant one call into this transport has to be finished by.
One absolute deadline for the whole of one call, rather than a timeout per HTTP operation - and
that distinction is the correction this type exists to carry. The previous shape put
timeout_global on the agent, so EVERY request through it got the full budget independently: a
single crate::transport::JobTransport::run does a token exchange and then a job, and both were allowed
deadline + CONNECT_MARGIN of their own. A review measured the consequence at the answer level -
four HTTP operations, each with its own budget, against a transport whose own request timeout is
thirty seconds - and the five-second overrun this module claimed was false.
So the budget is opened once per call and every operation gets only what is LEFT of it: the token
exchange, the socket the job waits on, and the timeoutMs and jobTimeoutMs the request carries -
which is what keeps the service cancelling at the instant the client stops waiting even when the
exchange spent half the budget first. When nothing is left, the refusal comes before the send.
A monotonic std::time::Instant and not a wall clock, because a wall clock can step and a
stepped deadline is either a job abandoned early or one that outlives its caller.
The limit, and it is the half this type cannot reach: one ANSWER calls the port twice -
Warehouse::dry_run and then Warehouse::execute - and neither Warehouse nor crate::transport::JobTransport
takes a deadline, so the two calls cannot share one. An answer's worst case is therefore
CALLS_PER_ANSWER budgets rather than one, which is exactly why
QueryDeadline::within_request_timeout exists: it does that arithmetic once so a composition root
cannot get it wrong. Carrying one deadline across the port is an architecture decision, not a
signature tweak.
Methods¶
Opens a budget now.
Opens a budget that started at a named instant.
The canonical constructor, with Self::opened delegating to it, and it is public for one
reason: a caller cannot otherwise construct a budget that is already spent, so the refusal at
the end of one could not be reached from a test without sleeping through a real one.
What is left of the budget, or None when it is spent.
None rather than a zero duration, because zero means no timeout to the client underneath -
so handing it on would turn a spent budget into an unbounded wait, which is the opposite of what
this type is for.
How long a socket may stay open for an operation with left of the budget remaining: that,
plus connection setup.
Implements¶
Clone, Copy, Debug
struct JobBounds¶
What every job this adapter submits is bounded by.
Two bounds in one value, because they are one decision: how much of a deployment's time and money
may one question spend. A struct rather than two arguments so a call site cannot supply one and
forget the other, and so super::WireAgent can carry them both.
Methods¶
How long a job may run.
The most one job may be billed for scanning.
Names both bounds. Neither has a default, for the reason BigQueryWarehouse::new gives about
its own arguments: a defaulted deadline is a promise nobody made, and a defaulted ceiling is
money somebody else pays.
Implements¶
Clone, Copy, Debug, Eq, PartialEq
Module credential¶
Where the bearer token a job is submitted with comes from.
A second narrow port, for the reason JobTransport is one.
The wire needs two things from a credential - a token that is usable right now, and whether a
request carrying it has to name a quota project - and everything else about how a deployment
authenticates is somebody else's decision. So AccessTokens is those two things, and the
transport is generic in it.
It is a port on the first day rather than a String field, so which credential shape a
deployment holds is a choice of implementor. Bearer carries the deadline because a minted token
has one, and whether this adapter has anywhere for a subject's own credential to arrive is
declared by crate::BigQueryWarehouse's IMPERSONATION rather than by anything this port
decides - and that declaration is deliberately the only copy of the value. This sentence used to
carry a second copy and stated the OPPOSITE of it for three commits, on a published page;
check-guidance refuses the shape now, so the correction is to stop encoding the value rather
than to keep two copies in step.
What this is NOT, and the correction is review's rather than a hedge: this port is not yet the
seam at which per-subject execution arrives as merely another implementor. Three signatures say
so - Warehouse::execute takes a &Presented and BigQueryWarehouse reads it only to call
deliverable; JobTransport::run takes a JobRequest and nothing else; and AccessTokens::bearer
takes a clock and a budget. So an implementation behind this port cannot select a credential for
the presented subject and cannot tell two concurrent subjects apart. The step that builds
per-subject execution has to carry the leg's subject or its credential context through one of those
three interfaces, and which one is part of that change rather than something anticipated here.
docs/adr/0018 records it in the same words.
Two credential kinds, as one closed shape¶
Credential reads either of the two files a deployment can actually have, and which one it is
is a closed two-variant shape rather than a struct of Options. That matters for a reason
stronger than tidiness: a document carrying both a refresh token and a private key is
unrepresentable here, so there is no state in which it is ambiguous which flow will run or which
credential material was used.
| Kind | Who holds it | The exchange |
|---|---|---|
authorized_user |
a developer, from just gcloud-login |
trade a refresh token |
service_account |
CI, and a deployment | sign an assertion and trade that |
Both are SharedServiceUser and neither is a step towards per-subject execution. One identity
reaches the dataset for everybody who asks; on a laptop that identity is the developer and in CI it
is a service account. docs/adr/0017's amendment is where the decision to run the acceptance leg
in CI on the second kind lives.
The signing costs no new dependency, which was verified rather than assumed. ring is already
in the graph - it is ureq's and tokio-rustls's crypto provider - and it carries
RsaKeyPair::from_pkcs8 plus RSA_PKCS1_SHA256, which is exactly the primitive and exactly the
key encoding a service-account key uses. base64 is already resolved too. So docs/adr/0018's
446-to-446 measurement survives this, and the alternative that would have cost a package -
jsonwebtoken's use_pem, which pulls simple_asn1 because its DER path wants PKCS#1 while a
service-account key is PKCS#8 - was priced and refused.
What IS first-party here is the JWT's text, and that boundary is deliberate. ring computes the
signature; this module base64url-encodes two JSON documents and joins them with dots.
docs/adr/0014 draws exactly that line when it argues for hand-writing a metrics exposition format
and against hand-writing signature verification in the same breath: one is a text format, the other
is cryptography. And this side SIGNS rather than verifies, which is where algorithm confusion does
not live - the algorithm is a constant, not a field read off somebody else's document.
Two credential shapes exist and neither is built¶
- the metadata server, which is how a deployment on the provider's own compute gets a token
with no key at all. It is a plain unauthenticated
GETand would cost nothing in dependencies - the cheapest option available. It is out because nothing in this repository can verify it: it exists only inside that provider's network, so building it would add an unexercised code path to a module whose whole point is that it does not claim more than it has. - workload or workforce identity federation, which is the per-subject step and an architecture decision with an owner outside this repository.
Nothing is cached, and both the decision and its REASON were wrong once¶
A token is minted for every call into the endpoint. The decision stands; the paragraph that justified it did not, in two ways a review caught, and both are corrected here because the per-subject step will read this as the argument it inherits.
The cost, counted properly. sutura_app::answer calls Warehouse::dry_run and then
Warehouse::execute; each goes through the wire's submit, and each calls
AccessTokens::bearer. So one question is two token exchanges before its two job round
trips, and every anchor verified at boot is one more. The earlier wording - "one extra round
trip per job" - was half the real number and counted the wrong unit.
The reason, corrected. The earlier version said a token cache keyed by nothing is the
credential-shaped version of the result cache this crate refuses. That is true of a cache shared
across SUBJECTS and false here: a Credential is one identity, so a token held until its
not_after is keyed by exactly the thing that matters and leaks to nobody. The
std::sync::Mutex ban in clippy.toml is not an argument either - sutura-http's own key-set
cache holds a lock.
So the honest reason is the small one: it is not needed until it is measured. Minting is one
HTTPS round trip against a query that costs seconds and money, and the shape with nothing to
reuse is the shape that cannot get a credential is not reused past its expiry wrong - which is one
of the assertions the per-subject step owes. What that step must NOT inherit is a prohibition,
because caching per subject, keyed by subject, is a different question this paragraph does not
answer.
struct Bearer¶
A token usable now, and when it stops being usable.
The deadline travels with the token rather than beside it, so a caller cannot present one and
forget the other. Expiry is the domain's own vocabulary for this, which matters because the
per-subject step reports the same value through sutura_domain::audit::CallRecord.
Methods¶
When it stops being usable.
Names a token and its deadline.
The token, still opaque. A caller has to reach Secret::expose_secret to write it into a header, and
that call is greppable.
Implements¶
Clone, Debug
enum QuotaProject¶
Whether a request has to name the project whose quota and billing it is attributed to.
Two variants and no Option, because "the credential already says" is a real answer rather than a
missing one - which is the same argument Expiry makes for having no Option.
Variants¶
Required- State it on the request. An application-default credential is an END-USER credential, and the endpoint's own direct-REST guidance requires a quota project for one - without it a perfectly valid token is refused, with a message about user credentials not being supported that reads as an authentication fault and is not one.FromTheCredential- The credential carries its own project, so stating one would add a permission requirement -serviceusage.services.useon that project - that a service account holding only dataset grants does not have. So the header that MAKES the first kind work BREAKS the second, which is why this is a two-variant answer and not a constant on the request.
Implements¶
Clone, Copy, Debug, Eq, PartialEq
trait AccessTokens¶
Where the token a job is submitted with comes from.
The clock is a parameter for the reason Expiry::passed_by takes one: an implementor that reads
the wall clock itself cannot be tested against a deadline, and the expiry logic is the half of a
credential source most likely to be wrong in the direction nobody notices. Who reads the real clock
is the transport, once.
struct CredentialFile¶
The file a credential lives in.
A newtype rather than a PathBuf argument, because Self::well_known and Self::at are two
different claims - wherever this machine keeps it and this exact file - and a function taking a
path cannot tell which one it was handed.
Methods¶
This exact file.
The path, for reading it and for a message that says which file was wrong.
Wherever this machine keeps it.
Three places, in the order the tooling itself uses: the explicit variable, then the relocated
configuration directory, then the default under the user's home. HOME is read and no
fallback is invented - a process with no home directory has no well-known location, and
guessing one would be reading a credential from a path nobody chose.
Implements¶
Clone, Debug, Eq, PartialEq
struct NoWellKnownLocation¶
Why the well-known location could not be worked out.
One variant, and it carries no path: the refusal is that this machine named no home directory, and
the fix is to say where the file is with CredentialFile::at.
Implements¶
Clone, Debug, Display, Eq, Error, PartialEq
enum UnusableCredential¶
Why the file could not become a credential.
Every variant names what is wrong and none of them quotes a value from the file. A path is
carried where the fix is which file, a field NAME where the fix is what is missing, and the
credential's own type only after crate::wire::bounded has cut it to a fixed character set -
because a 16 KiB file can put 16 KiB of newlines there and this string reaches a log.
Variants¶
Unreadable- The file could not be opened or read.TooLarge- The file is larger than any credential document is.NotADocument- The file is not the JSON document this expects.UnknownKind- The file names a credential shape this build does not implement.Incomplete- A document missing one of the fields its own kind needs.AnotherUniverse- The credential was minted against a different service universe than the one this build talks to.UnreadableKey- The private key is not aPKCS#8PEM block holding a key this build can sign with.
Implements¶
Debug, Display, Error
enum KeyUnusable¶
How far a private key got before it was refused.
Three stages rather than one boolean, because the fix for each is a different thing. A missing
delimiter is a truncated or wrongly-encoded file; a body that is not base64 is a corrupted one; a
body that decodes and is not a key is a key of the wrong kind - a PKCS#1 block whose delimiters
somebody rewrote, an EC key, or a truncated DER. None of the three quotes anything.
Variants¶
NotAPemBlock- ThePKCS#8delimiters are absent, or there is nothing between them.NotBase64- The body between the delimiters is not base64.NotAKey- The body decodes and is not aPKCS#8RSA key this build can sign with.
Implements¶
Clone, Copy, Debug, Display, Eq, Error, PartialEq
enum TokenUnavailable¶
Why no token came back.
Variants¶
Unreachable- The token endpoint did not answer.Unreadable- The token endpoint answered, and the answer could not be read.Refused- The token endpoint refused.NotADocument- The answer was not the JSON document a token response is.NoToken- The answer carried no token.AlreadyExpired- The answer's own deadline had already passed when it arrived.DeadlineSpent- The call's budget was gone before the exchange could be attempted.Unsigned- The assertion could not be signed.NotSigned- The signature itself failed.
Implements¶
Debug, Display, Error
struct Credential¶
A credential this build can present, read from a file.
One type for both kinds, so a caller does not have to know which file it has. CI points
GOOGLE_APPLICATION_CREDENTIALS at a service-account key and a laptop has an
application-default login; both reach the endpoint through this.
Methods¶
Which kind this is, for a banner or a test. A fixed word, never the file's own text.
Which project this credential names, where it names one.
Some for a service-account key and None for an application-default login, which is the
difference the two files actually have. This is why CI configures no project variable: the
key carries it, so a second declaration would be a second answer to who pays that can
disagree with the first.
Reads a credential file, whichever of the two kinds it holds.
The agent is a WireAgent and not a ureq::Agent, which is the point of that newtype:
the exchange and the job then share one connection pool and one set of pins by construction,
rather than because two call sites happened to call the same builder. A previous version took
any agent, so a composition root could have exchanged a credential over a client with redirects
on and no timeout.
The two doctests below are the mechanism, not decoration. The first is the mistake the newtype exists to make impossible; the second is its compiling twin, so the failure is the missing pin rather than a typo in the example.
use sutura_exec_bigquery::wire::credential::{Credential, CredentialFile};
// A client with redirects on, plaintext allowed and no timeout. There is no way to hand it in.
let _ = Credential::read(&CredentialFile::at("/nonexistent"), ureq::Agent::new_with_defaults());
use sutura_exec_bigquery::wire::credential::{Credential, CredentialFile};
use sutura_exec_bigquery::wire::{BytesBilledCeiling, JobBounds, QueryDeadline, WireAgent};
let bounds = JobBounds::of(
QueryDeadline::parse(30).expect("a deadline"),
BytesBilledCeiling::parse(1024 * 1024).expect("a ceiling"),
);
// Compiles, and refuses at run time because the path is not there - which is the point: what
// the first example cannot get past is the TYPE, before any file is read.
let refused = Credential::read(&CredentialFile::at("/nonexistent"), WireAgent::pinned(bounds));
assert!(refused.is_err());
Implements¶
AccessTokens, Debug