Repository navigation
Description
Problem
BigQueryAgentAnalyticsPlugin can write duplicate rows when its internal BigQuery Storage Write API request is retried after an ambiguous failure.
This concerns retries of the BigQuery write, not retries by the agent, model, or tools.
The plugin writes to BigQuery's _default stream. BatchProcessor._write_rows_with_retry constructs one AppendRowsRequest and resends the same serialized batch following transient errors or its 30-second
client-side timeout.
If BigQuery accepts the first append but its acknowledgement is lost or delayed, the plugin retries the batch. Because _default provides at-least-once delivery, BigQuery can accept it again and produce duplicate rows.
Relevant code:
Impact
We observed byte-identical duplicate analytics rows in production, including identical timestamps, trace/span IDs, token usage, status, and content.
The issue is rare. Over seven days, six duplicate LLM_RESPONSE rows inflated a naive SUM(usage.total) by 182,555 tokens, approximately 0.31% of the total. This can silently over-count tokens, costs, latency, and other metrics derived from the analytics table. However this was for a poc agent with few users and can easily inflate with more use.
Proposed fix
BigQuery does not allow append offsets on _default, and BigQueryWriteAsyncClient has no exactly_once option.
Exactly-once append behaviour requires an application-created COMMITTED stream with offsets:
- Create one committed stream per
BatchProcessor. - Include the current offset in each
AppendRowsRequest. - Retry an ambiguous append using the same stream and offset.
- Advance the offset after success.
- Treat
ALREADY_EXISTSat that offset as confirmation that the earlier
attempt succeeded. - Handle unexpected
OUT_OF_RANGEwithout blindly resending on a new stream.
A committed stream preserves immediate row visibility while making Storage Write API retries idempotent.
This fix would prevent duplicates caused by BigQuery write retries. It would not deduplicate genuinely separate events emitted by the agent or application.
For anyone familiar with Apache beam this is similar at a high level to their use_at_least_once=False option for BigQuery Storage Write API sinks. Beam provides the stream, offset, and durable checkpoint management behind that abstraction; because ADK uses BigQueryWriteAsyncClient directly, the plugin must manage its committed stream and append offsets itself.
References:
Activity
sanketpatil06 commentedon Jul 28, 2026
Hi @addenergyx , Thank you for providing these details. To help us fully understand the issue, could you please share a minimal reproducible example? This will allow us to recreate the problem exactly as you are experiencing it.
addenergyx commentedon Jul 29, 2026
Hi @sanketpatil06, thanks for looking into this. Sorry for the delay, took me abit to work out a way to reliably reproduce this issue. From my experience with our poc agent work, if you'd want to reproduce this with an actual agent you'd need a loop of it doing actions to create logs and hopefully eventually get a lost ack to cause a duplicate.
The race needs an append that BigQuery applies but whose acknowledgement the client never sees. Rather than wait for that, the harness injects it, forward the append to BigQuery, consume the real success response so the rows are genuinely committed, then raise
ServiceUnavailableinstead of returning it.Then drive
BatchProcessorwith it, append one row tagged with a unique id, andcount:
_write_rows_with_retry builds one AppendRowsRequest before the retry loop and resends that same object, and on _default it carries no offset — so the client can't tell "never applied" from "applied, ack lost", and BigQuery has nothing to deduplicate against, since _default is documented as at-least-once (https://docs.cloud.google.com/bigquery/docs/write-api-streaming) and permits duplicate destination rows.
i've started a fix in here #6466
haiyuan-eng-google commentedon Aug 5, 2026
I'm working on this issue for now, would keep you posted
caohy1988 commentedon Aug 5, 2026
Confirmed at HEAD.
_write_rows_with_retrybuilds theAppendRowsRequestonce and resends the identical batch on transient errors and on the 30s client-side timeout, against_defaultwith no offset — so an applied-but-unacked append duplicates rows exactly as your harness demonstrates. Thanks for the reliable repro; the ack-suppression wrapper is a faithful model of the race.Plan, in two parts:
1. Near-term (unconditional): per-row
event_iddedup key. Every row gets a UUID stamped before enqueue, so Storage Write API retry resends carry the identical id. That makes transport-level duplicates exactly identifiable (QUALIFY ROW_NUMBER() OVER (PARTITION BY event_id ...) = 1, or an opt-in deduplicating view). Existing tables pick the column up via the additive schema-upgrade path. This doesn't prevent duplicates, but it makes your token/cost SUMs correct immediately and at near-zero risk.2. Your PR #6466 as the opt-in exactly-once mode. The offset/stream design is right, and the ambiguity classification (
request_sent/in-band-rejection) andALREADY_EXISTS-as-confirmation handling are careful. Before we can merge it:cla/googlecheck is failing — that's a hard blocker, please complete the CLA.NOT_FOUNDisn't handled: if the committed stream is reclaimed/finalized externally, batches drop forever with no rotation (rotation currently only triggers onOUT_OF_RANGEdesync). Suggest treatingNOT_FOUNDas desync-equivalent → rotate.AlreadyExists/NotFoundas raisedgoogle.api_coreexceptions from the response iterator, not only as in-bandresponse.errorcodes; the current handling would poison instead of confirm on the raised form. Since you noted it hasn't run against live BigQuery yet, a live smoke test of the error-surfacing forms would settle this.Happy to review the updated PR — with those addressed I think this is mergeable as a default-off feature.
caohy1988 commentedon Aug 5, 2026
Follow-up after running #6466 locally:
TestExactlyOnceDelivery: 17 passed.That is a solid unit-test baseline, but it does not yet validate the Storage Write API’s live committed-stream behavior. Before merging, I recommend these gates:
AppendRowsResponse.errorstatuses and raisedgoogle.api_coreexceptions. In particular,AlreadyExistsfor the same offset should be treated as delivered;NotFound/invalidated stream andOutOfRangeneed explicit resynchronization or stream rotation rather than indefinite retry/drop behavior.I would also split the proposed per-row
event_idmitigation into a separate small change. It is useful for query-time deduplication, but it does not prevent duplicate writes and it introduces its own physical-schema/view contract. It should not be coupled to #6466’s transport semantics.The remaining administrative blocker on #6466 is the failing CLA check.
caohy1988 commentedon Aug 5, 2026
A few lifecycle notes to add to the #6466 review checklist (from tracing the PR against current HEAD):
_stream_finalizedflips ahead of thefinalize_write_streamcall, so a failed finalize is never retried. Impact is low (COMMITTED rows stay visible without finalization), but it deserves a code comment so nobody "fixes" it into a retry loop later.create_write_streamusage in the repo. Nothing else in the codebase exercises stream creation/finalization, so the mock-based unit tests are the only coverage until a live smoke test confirms the error-surfacing forms (raised gapic exceptions vs in-bandresponse.errorcodes) called out above.mock_write_clientfixture (tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py:140-156) already modelsappend_rowsas an async generator; a side-effect sequence ofasyncio.TimeoutErrorthen an in-band code-6 response reproduces the production race deterministically — the PR'sTestExactlyOnceDeliverycovers this, and the same pattern should guard the rotation path.None of these change the design; they're the merge-gate details alongside the CLA and the
NOT_FOUND-as-desync handling already flagged.caohy1988 commentedon Aug 5, 2026
Implementation follow-up: the independent implementation is now in draft PR caohy1988#12.
The draft keeps both promised layers:
event_id, stamped before enqueue so a transport retry preserves the ID; it is included in the physical schema, additive upgrade, and views;exactly_once_delivery, using loop-local COMMITTED streams and frozen batch offsets.The offset path handles both raised and in-band
ALREADY_EXISTSas delivery confirmation, treats raised/in-bandNOT_FOUNDandOUT_OF_RANGEas desynchronization, rotates before subsequent batches, does not replay an uncertain exhausted batch onto a new stream, and retries failed finalization on later shutdown/close paths.I also ran the live probe requested above against
test-project-0728-467323: offset 0 succeeded with code 0; resending offset 0 surfaced as an in-band response error code 6, not a raised exception. The temporary dataset/table was finalized and deleted. Unit coverage retains both forms because client/library behavior may differ.CLA handling: this implementation was written independently from the public issue design; it does not copy/cherry-pick #6466 and has no non-signer
Co-authored-bytrailer. The PR prose credits @addenergyx for the report and design discussion.fix(plugins): Make BigQuery retry duplicates identifiable
fix(plugins): Make BigQuery retry duplicates identifiable
feat(plugins): add offset-aware BigQuery delivery
fix(plugins): address BigQuery analytics review findings
addenergyx commentedon Aug 6, 2026
Thanks @caohy1988, and sorry for the slow reply. Really appreciate you picking this up! Happy to help review once it's ready
fix(plugins): address BigQuery analytics review findings
caohy1988 commentedon Aug 11, 2026
Correction / final status: this is fixed at upstream HEAD. The change merged to
mainon 2026-08-10 in 04b8b72 (feat(plugins): add BigQuery Agent Analytics delivery and termination observability), landing via the internal review + Copybara flow. My earlier note describing it as still pending on a fork draft was out of date — apologies for the confusion.What shipped (re-verified line-by-line at current HEAD), both layers:
event_id: assigned before enqueue, so a Storage Write API retry resends the identical id. It's in the physical schema (additive upgrade, schema v2) and all typed views — transport-level duplicates are now exactly identifiable (QUALIFY ROW_NUMBER() OVER (PARTITION BY event_id ...) = 1), which makes the token/cost SUMs from your report correct immediately in default mode.exactly_once_delivery(default off): one loop-local COMMITTED stream with explicit offsets. The batch offset is frozen before the retry loop; ambiguity is tracked per attempt, soALREADY_EXISTSat the batch's offset confirms delivery only after an ambiguous send, while a first-attemptALREADY_EXISTSdesynchronizes and rotates. Both raised gapic exceptions and in-bandresponse.errorcodes are handled forALREADY_EXISTS/NOT_FOUND/OUT_OF_RANGE; ambiguous retry exhaustion poisons the stream rather than guessing; an uncertain batch is never replayed onto a new stream; rotation is non-blocking with a 30s backoff and an explicitoffset_conflictdrop bucket; finalization is retried across shutdown/close within the caller's timeout budget. Quota and loss boundaries (CreateWriteStream on rotation; duplicate-free but not lossless) are documented on the config.This matches the committed-stream/offset design you proposed and reliably neutralizes the applied-but-unacked race your ack-suppression harness demonstrated — thanks for the report, the repro, and the design groundwork in #6466.
Availability: the merge postdates v2.6.3, so it ships in the next release; until then it's available from
main.Closing as fixed. @addenergyx if you get a chance to run your harness against
main(with and withoutexactly_once_delivery=True), confirmation from the original environment would be a great final validation.