[RGI-1558] Let the BigQuery tap log in safely, find its tables, and copy only what changed - #1
Conversation
Add dual auth support matching mk-tap-salesforce/mk-tap-hubspot pattern: - OAuth: uses client_id, client_secret, refresh_token to auto-refresh access tokens (same flow as Salesforce/HubSpot). DAG passes these from Argo connector config as env vars. - Service account: uses google_application_credentials JSON, with auto-fill for missing fields (type, token_uri) from Argo configs. - Uses user_supplied_client pattern to bypass sqlalchemy-bigquery's internal client creation, fixing project/dataset resolution. - client.py reuses connector's auth logic to avoid duplication. Tested locally: - OAuth refresh_token (tenant 6096): 10.7M events queried - Service account (tenant 6206): authenticated successfully Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
sqlalchemy-bigquery's get_multi_columns uses the schema (dataset) name as the project in the BigQuery API URL, causing 400 errors when user_supplied_client is used. Override discover_catalog_entries to use per-table get_columns which resolves the project correctly. Tested with tenant 426103 QA (rgip-e2e-test / qa_sandbox): - Discovery: 3 streams (accounts, contacts, events) - Data pull: 500 accounts, 1998 contacts, 50397 events Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Auto-detect TIMESTAMP columns during catalog discovery and select updated_at as the replication key. On subsequent runs, the tap extracts only records where updated_at >= last bookmark instead of the full table. Matches the incremental pattern used by tap-hubspot (lastmodifieddate) and tap-salesforce (SystemModstamp). Changes: - connector.py: detect timestamp columns, set replication_method to INCREMENTAL when updated_at is found - client.py: add WHERE clause to GCS batch export query with NULL handling for records missing updated_at - tap.py: add optional replication_key_column config override Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Changed WHERE clause from >= to > when filtering by replication key. The >= operator caused full-table re-extraction on every run when many records share the exact same replication key value (e.g. batch-inserted events where all records have the same updated_at timestamp). With >=, the bookmark never advances past the shared timestamp and all records matching it are re-pulled on every run. With >, each batch of records is extracted exactly once. Records at the exact bookmark boundary are not re-pulled, matching the behavior of tap-hubspot and tap-salesforce. Note: NULL handling is preserved via the OR IS NULL clause, so records with no replication key value continue to be included on every run. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… uniform batch timestamps singer-sdk's SQLStream.get_records() hardcodes >= for the replication key filter. When all source rows share a uniform updated_at (e.g., a batch-loaded events table with 50k rows all at the same microsecond), this causes all records to be re-pulled on every run because they all match updated_at >= bookmark. Override get_records() in BigQueryStream to use > instead of >=. After the first full pull, subsequent runs return 0 rows until new source data arrives with a newer updated_at. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
…en CI Fixes the two failures that have kept CI red on this branch. - connector: _create_bigquery_client parses google_application_credentials as JSON first and falls back to treating the value as a service account key file path, so both forms work as tap.py's setting description promises. Guards against a JSON scalar (e.g. a numeric path) reaching setdefault(). - client: get_records accepts context positionally (how singer-sdk calls it) and partition as a keyword-only alias (how the existing tests call it). - docs: the _build_extract_query log line and the replication_key_column description said ">=" while the generated SQL uses strict ">". Unit tests cannot build a real bigquery.Client - a key file path, a fake key JSON and ADC all fail without real credentials - so the existing test classes stub _create_bigquery_client in setUp. The auth selection it bypasses is covered directly by tests/test_connector_auth.py: JSON content, dict, file path, oauth refresh credentials, the missing-oauth- setting error, and the ADC fallback. README documents auth_type, client_id, client_secret, refresh_token, google_application_credentials (JSON or path), replication_key_column, filter_schemas and filter_tables, plus the replication key preference order and the strict-">" rationale and its boundary trade-off. Drops a duplicated --about dump. MeltanoLabs attribution retained. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@claude review |
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
There was a problem hiding this comment.
Pull request overview
Adds dual BigQuery authentication, reliable catalog discovery, and timestamp-based incremental replication.
Changes:
- Supports service-account, OAuth, and ADC authentication.
- Uses per-table discovery and automatically selects replication keys.
- Applies strict incremental filters to streaming and batch extraction paths.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
tap_bigquery/connector.py |
Implements authentication, discovery, and replication-key selection. |
tap_bigquery/client.py |
Adds incremental streaming and batch queries. |
tap_bigquery/tap.py |
Defines new configuration settings. |
tests/test_connector_auth.py |
Tests credential selection. |
tests/test_client.py |
Stubs credential-dependent clients. |
tests/test_core.py |
Stubs credential-dependent clients. |
README.md |
Documents authentication, settings, and replication. |
Suppressed comments (6)
tap_bigquery/connector.py:299
DATEandDATETIMEcolumns are marked as valid incremental keys, but both extraction paths compare them withTIMESTAMP('<bookmark>'). BigQuery rejectsDATE > TIMESTAMPandDATETIME > TIMESTAMPwith a type-signature error, so such discovered streams fail at runtime. Either restrict discovery toTIMESTAMPor retain each key's type and generate a matchingDATE/DATETIMEexpression in both query paths.
type_name = type(col_type).__name__.upper()
if type_name in ("TIMESTAMP", "DATETIME", "DATE"):
timestamp_columns.append(column_name)
tap_bigquery/connector.py:94
- OAuth credentials are scoped only for BigQuery, but the batch path later uses the same credentials with
GCSFileSystemto download and delete exported objects (client.py:159-169). Withgoogle_storage_bucketconfigured, OAuth runs will therefore receive Cloud Storage authorization failures even when IAM permissions are present. Request the Storage read/write scope when batch extraction is enabled, and ensure the refresh token was originally granted that scope.
scopes=["https://www.googleapis.com/auth/bigquery"],
tap_bigquery/connector.py:78
- Any value other than the exact string
oauthsilently falls through to service-account/ADC authentication. A typo such asauth_type: oauth2can therefore run under an unintended ambient identity instead of failing startup, despite the public setting advertising only two modes. Reject unsupported values before selecting credentials.
if auth_type == "oauth":
tap_bigquery/connector.py:116
- A documented and valid credentials path always reaches this branch, so logging that it is “not valid json” at warning level produces a misleading warning on every normal service-account startup. Treat the JSON parse miss as an expected path-selection branch and log it at debug level.
if creds_dict is None:
self.logger.warning(
"'google_application_credentials' not valid json trying path",
)
README.md:111
- This step is incomplete: the implementation also falls back to
FULL_TABLEwhen timestamp columns exist but none has a preferred name andreplication_key_columnis unset or invalid. Document the actual fallback condition so users do not expect an arbitrary timestamp column to be selected.
3. If the table has no timestamp column at all, the stream is `FULL_TABLE`.
tap_bigquery/client.py:116
- This bypasses
SQLConnector._connect(), which is where Singer SDK 0.46 enablesstream_results=True. On large row-by-row extracts, using the engine directly can allow result buffering and substantially increase memory use. Preserve the connector's streaming execution options.
with self.connector._engine.connect() as conn:
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| query = sqlalchemy.text( | ||
| f"SELECT * FROM {self.fully_qualified_name} " | ||
| f"WHERE {self.replication_key} > TIMESTAMP('{start_value}') " | ||
| f"OR {self.replication_key} IS NULL" | ||
| ) |
| if self.replication_key: | ||
| start_value = self.get_starting_replication_key_value(None) | ||
| if start_value: |
| def test_service_account_is_the_default_auth_type(self, mock_bigquery): | ||
| # given no auth_type configured | ||
| conn = connector(google_application_credentials="/tmp/creds.json") | ||
| # when a client is created with the default auth_type | ||
| conn._create_bigquery_client("service_account") | ||
|
|
||
| # expect service account handling, including the path fallback | ||
| mock_bigquery.Client.from_service_account_json.assert_called_once_with( | ||
| "/tmp/creds.json", | ||
| project="mock-project", | ||
| ) |
| where_clause = ( | ||
| f"WHERE {self.replication_key} > " | ||
| f"TIMESTAMP('{start_value}') " | ||
| f"OR {self.replication_key} IS NULL" |
|
|
||
| Supports two auth modes (controlled by auth_type config): | ||
| - service_account (default): uses google_application_credentials | ||
| - oauth: uses access_token passed from Argo via DAG env vars |
| exclude_schemas: t.Sequence[str] = (), | ||
| reflect_indices: bool = True, |
| Set `client_id`, `client_secret` and `refresh_token`. The tap builds refresh-capable | ||
| credentials, so access tokens are obtained and renewed automatically for the lifetime of | ||
| the refresh token — no pre-fetched access token is needed. All three settings are | ||
| required in this mode; if any is missing the tap fails at startup with a clear error. |
Row-by-row path (was raw SQL string): - Build the query with SQLAlchemy, mirroring SQLStream.get_records. It now selects only selected columns (deselected fields no longer leak into records) and orders by the replication key. is_sorted is True for INCREMENTAL streams, so unordered BigQuery results could raise InvalidStreamSortException during state increment. - Use connector._connect() rather than the engine directly, so stream_results=True still applies and large extracts are not buffered. - The bookmark is now a bound parameter typed from the column, so a DATE or DATETIME key is no longer compared against TIMESTAMP(), which BigQuery rejects with a type-signature error. EXPORT DATA path: - Advance the bookmark after a successful export. _sync_batches emits STATE but never calls _increment_stream_state, so the bookmark never moved and every run re-exported the same delta. A MAX(key) watermark is read before the export and committed after it succeeds: rows landing in between are re-exported next run rather than skipped. - Bind the bookmark as a named query parameter and backtick-quote the identifiers, instead of formatting both into the statement. - Drop the ExtractJobConfig that was built and never passed to query(). Auth: - Reject unsupported auth_type instead of silently falling through to ambient credentials on a typo like 'oauth2'; declare allowed_values. - Request the Cloud Storage scope for OAuth when google_storage_bucket is set, since the batch path reuses those credentials for GCSFileSystem. - The JSON-parse miss is the documented path form, so log it at debug rather than warning on every normal service-account startup. - Correct the create_engine docstring, which still described consuming an access token from Argo. Discovery: - Honour reflect_indices=False instead of always reflecting indexes. - Warn when replication_key_column names a column that cannot be used. Tests go from 12 to 35: replication key selection and its fallbacks, the reflect_indices contract, both query paths (strict >, NULL keys, ordering, selected columns, no interpolation of a quote-carrying bookmark), the DATE parameter type, the watermark query, and bookmark advancement. test_service_account_is_the_default_auth_type now exercises create_engine so it actually covers the default rather than duplicating another test. README documents OAuth's IAM and scope requirements, the real FULL_TABLE fallback condition, batch bookmark semantics, and that a replication key takes effect via the catalog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
| .where( | ||
| sqlalchemy.or_( | ||
| replication_key_col > start_value, | ||
| replication_key_col.is_(None), |
There was a problem hiding this comment.
this seems like a good way of handling missing key vals, but perhaps that should be handled upstream on the provider side ? it would suggest corrupted or incorrect data (eg no created/updated at/id) on their side which they should fix, rather than us pulling it repeatedly
There was a problem hiding this comment.
Agreed that a NULL key signals a data quality problem on the provider side, and I've made that visible rather than silent — but I'd argue against dropping the rows, because excluding them doesn't just stop the re-pulling, it means they're never extracted at all.
The case that worries me is a source that leaves updated_at NULL until a row is first updated, setting only created_at on insert. That's a common and entirely non-corrupt pattern. Since updated_at is first in our preference order, such a table would silently lose every never-updated row. Duplication is recoverable; that isn't.
So behaviour is unchanged, but as of 6687c19 we now count them and log one warning per run naming the count and the column:
1234 row(s) in 'dataset.table' have a NULL updated_at and are re-extracted on every run; the source should populate this column
The batch path gets the count from the watermark query via COUNTIF, so it costs no extra job. That gives us a number to take to the provider rather than an assertion, and if it stays high for a tenant we can escalate with evidence.
Happy to add a config flag to exclude them for a tenant where NULL genuinely means garbage — say the word if you'd rather have that now. COALESCE(updated_at, created_at) is a third option, but that's a semantics change I'd want decided separately.
| table.select() | ||
| .where( | ||
| sqlalchemy.or_( | ||
| replication_key_col > start_value, |
There was a problem hiding this comment.
I think BQ streams a snapshotted result of the query so in theory this should be a static dataset and handled corectly.
That said it might be worth including an upper boundas a safety measure, thereby avoiding the risk of endless jobs if I'm mistaken and there is situation where it keeps returning newly updated data
There was a problem hiding this comment.
You're right on the snapshot, and it turns out the suggestion still earns its place — just on the other path.
A BigQuery SELECT reads a consistent snapshot taken at job start, so this row-by-row query can't grow while it's being read. One job, one snapshot, finite result set — no endless-job risk. I've left it unbounded and added a comment saying why, so it doesn't read as an oversight later.
The batch path is different, and this is a genuine gap you've caught. It issues two jobs — SELECT MAX(key) for the watermark, then EXPORT DATA — so it sees two snapshots. I was committing the watermark as the new bookmark but not bounding the export with it, so rows landing between the two jobs got exported and re-exported next run. I'd documented that as deliberate at-least-once; bounding it is strictly better.
As of 6687c19:
WHERE (updated_at > @bookmark AND updated_at <= @watermark) OR updated_at IS NULLThe exported window now matches the committed bookmark exactly — no duplicates, no gap. It applies on the first run too, where there's no bookmark yet but still a ceiling.
One thing I considered and rejected for the row path: the SDK already computes a signpost (utc_now() for timestamp keys) that could serve as an upper bound. But it's our clock, not the source's — if the source stamps updated_at slightly ahead of us, rows get deferred a run for no benefit — and a DATE key compared against a datetime signpost reintroduces the type error fixed earlier in this PR. Not worth it on a path that's already snapshot-safe, but happy to revisit if you disagree.
Addresses Richard's review comments.
Upper bound (client.py): the stats query and the EXPORT DATA job are two
separate BigQuery jobs, so they read two separate snapshots. The watermark
is now also an upper bound on the export:
WHERE (key > @bookmark AND key <= @watermark) OR key IS NULL
so the exported window matches the bookmark committed afterwards exactly.
Rows committed between the two jobs are left for the next run instead of
being exported twice. Applies on the first run too, where there is no
bookmark yet but still a ceiling.
The row-by-row path deliberately keeps no upper bound: a single SELECT
reads a consistent snapshot taken at job start, so that result set cannot
grow while it is read. Noted in a comment so it does not read as an
oversight.
NULL replication keys (client.py): counted and reported in one warning per
run, naming the count and column, instead of the SDK's per-record log line.
The batch path gets the count from the existing stats query via COUNTIF, so
it costs no extra job. Behaviour is unchanged - such rows are still
extracted. Excluding them would drop them permanently, which is the worse
failure when a source leaves updated_at NULL until the first update; the
warning makes the data quality problem visible so it can be raised with the
provider.
Tests 35 -> 39: the bounded window, the first-run bound, the combined stats
query, and the warning.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
In one sentence
This makes our BigQuery copy-tool able to log in two different ways, find the customer's tables reliably, and copy only the rows that changed since last time instead of all of them.
Four words you'll need
Everything below is built from these. If they're already familiar, skip ahead.
updated_at: a timestamp the source updates whenever a row changes.Part 1 — Logging in
BigQuery needs to know who we are. Two ways to prove it:
A service account key — a JSON file with a private key in it. Like a robot's ID badge. This is the main path, because Google's RAPT security change now forces human OAuth logins to re-authenticate every few hours, which no unattended pipeline can survive.
OAuth — logging in as a human, using a refresh token. Worth explaining why this is here at all, since we just said it's fragile: the access token is what expires in an hour, and the refresh token is a long-lived voucher you trade for a fresh access token. Our old interim BigQuery connector was handed pre-fetched access tokens, so it broke every hour. This one holds the refresh token and mints new access tokens itself, so it keeps going.
If neither is configured, we fall back to whatever ambient credentials the machine has (Google calls this Application Default Credentials).
The
google_application_credentialssetting takes either the key JSON itself or a path to a file holding it. We try to read it as JSON; if that fails, we treat it as a filename.Part 2 — Finding the tables
To copy a table you first have to ask BigQuery what columns it has. The library we use has a bulk "describe all these tables at once" call, and it has a bug: it puts the dataset name where the project name goes in the URL, so every request 400s.
So we ask table by table instead. Slower, works.
Part 3 — Copying only what changed
Two things had to be decided.
Which column is the replication key? If you set
replication_key_column, that one. Otherwise we look for a timestamp column with a familiar name, preferring in order:updated_at,modified_at,last_modified,_sdc_batched_at,created_at. No match means we copy the whole table every time.What do we compare it against? Here's the interesting part. The obvious query is:
Greater-than-or-equal looks safer — you'd rather re-copy a row than miss one. But our source tables are loaded in batches, so fifty thousand rows can share the exact same
updated_at, down to the microsecond. The bookmark lands on that shared timestamp, and>=matches all fifty thousand again. Every run. Forever. The "incremental" copy is a full copy wearing a disguise.So we use a strict
>, plus rows where the key is empty:The honest cost of that choice is in "What to watch for" at the bottom.
What the review caught
Copilot reviewed the branch and found thirteen real problems. Rather than list them, here is what was actually broken, because several were the same root cause. Each is: what you'd expect, what actually happened, why.
1. The tap could crash on a perfectly valid config
Expected:
google_application_credentials: /secrets/key.jsonworks, since the docs promise "JSON content or path."Actual: crash. The code called
json.loads()on it unconditionally, and a file path is not JSON.Fix: try JSON, fall back to treating it as a path.
2. The tap could send rows out of order and then abort
Expected: rows come out, state gets saved.
Actual: possible mid-run crash on large tables.
Why: we'd hand-written the query as a SQL string with no
ORDER BY. BigQuery makes no promise about row order without one. Meanwhile the SDK marks incremental streams as "sorted" and checks each row's timestamp against the previous one — a row that goes backwards means "we can't trust the bookmark", and it throws. So the crash only appears once BigQuery decides to parallelise your query, which is to say on exactly the big tables you care about.Fix: build the query with SQLAlchemy instead of string-pasting, which brings back the
ORDER BYthe SDK expects.3. The same query leaked columns nobody asked for
If you deselect a column, it shouldn't be copied. Our hand-written query said
SELECT *. Same fix as #2 — the rebuilt query selects only selected columns.4. And it could exhaust memory
We opened the database connection directly rather than through the SDK's helper. That helper is the thing that sets "stream the results, don't buffer them." Without it, a big extract tries to hold the whole result set in memory. Same fix again — one rewrite closed #2, #3 and MeltanoLabs#4 together.
5. Batch mode re-exported the same rows forever
This is the one worth your attention.
When
google_storage_bucketis set, we don't copy row by row — we tell BigQuery to dump the table to a file in Cloud Storage, then download it. Much faster for large tables.Expected: first run exports everything, later runs export only what changed.
Actual: every run exported the same thing, forever. The bookmark never moved off its starting value.
Why: the SDK advances the bookmark inside the row-by-row loop. Batch mode never enters that loop — it hands over a file instead. So nothing ever updated the bookmark. It writes a state message, faithfully recording the unchanged value. Nothing errors. It just quietly does the full table every night.
Fix: before exporting, ask
SELECT MAX(updated_at). After the export and download succeed, save that as the new bookmark.Note the ordering: we read the ceiling before the export, not after. Rows that land in between get exported now and exported again next run. That's deliberate — duplicates are absorbed by de-duplication downstream, whereas reading the ceiling afterwards would silently skip those rows. Given a choice, copy twice rather than lose one.
6. A
DATEcolumn would have failed at runtimeWe accept
DATE,DATETIMEandTIMESTAMPcolumns as replication keys, but the query always compared againstTIMESTAMP('...'). BigQuery refuses to compare aDATEto aTIMESTAMP— it's a type error, not a silent coercion. Any customer whose date column got picked would have hit a hard failure.Fix: the bookmark now carries the same type as the column it's compared to.
7. The bookmark was pasted straight into SQL
The bookmark comes from a state file. We were formatting it directly into the query text, so a quote character in it could change what the query means. Same for column names.
Fix: the value is passed as a query parameter — handed to BigQuery separately from the SQL, so it can only ever be read as a value. Column names are backtick-quoted.
8. A typo in
auth_typesilently used the wrong identityWriting
oauth2instead ofoauthdidn't fail. It fell through to the service-account branch, and with no key configured, to whatever ambient credentials the machine had. You'd get a run under an unintended identity rather than an error.Fix: unknown values are rejected outright.
9. OAuth couldn't read its own export files
In batch mode we write files to Cloud Storage, download them, then delete them. But the OAuth login only ever asked Google for BigQuery permission, not Storage. Correct IAM roles, still denied — the token wasn't scoped for it.
Fix: request the Storage scope too when a bucket is configured. (Existing refresh tokens must already carry that scope; scopes can't be widened at refresh time. Noted in the README.)
10–13. Smaller things
reflect_indices=Falseasked us to skip index reflection; we reflected anyway. Now honoured.How we know it works
Tests went from 12 to 35, and CI passes on Python 3.9 through 3.12.
The new ones cover the things above that used to be untested: which column gets picked as the replication key and every way that can fall back; that both query paths use strict
>, keep theORDER BY, fetch only selected columns, and refuse to let a quote in the bookmark change the query; that aDATEkey produces aDATEparameter; and that batch mode actually moves the bookmark.One honest limitation: these are unit tests against a mocked BigQuery. They prove we generate the right SQL and make the right calls. They cannot prove BigQuery accepts it. A staging run is still needed — see below.
What to watch for
>has a real edge. A row committed after a sync but stamped with anupdated_atexactly equal to the bookmark is never picked up. There's no lookback window. We chose this over re-copying entire batches every run, but it's a trade, not a free win. If that edge matters for a given source, the fix is a small trailing lookback.pip_urlat@feat/mk-auth-layer. Merging here and deleting the branch breaks every MDI image build until that's re-pinned to a merged SHA or tag. Please coordinate.@claude reviewdoesn't work on this repo. Both attempts failed with empty credentials — the Claude Code action has noCLAUDE_CODE_OAUTH_TOKENsecret configured. Needs a repo admin.This branch is 2 commits behind
mainand has not been rebased.🤖 Generated with Claude Code