fix(plugins): close BQAA fail-open privacy, GCS concurrency, and startup-loss gaps (#6356)#6360
fix(plugins): close BQAA fail-open privacy, GCS concurrency, and startup-loss gaps (#6356)#6360caohy1988 wants to merge 5 commits into
Conversation
…tup-loss gaps Addresses the four P1 findings and mechanical P2s from google#6356: - content_formatter failure fails closed: content is replaced with a [FORMATTER_FAILED] sentinel, never the unformatted payload; counted as formatter_failed in get_drop_stats(). - Final sanitizer pass over the complete assembled attributes tree immediately before serialization (covers state_delta, custom_tags, labels, nested keys, temp: scope), with truncation propagated into is_truncated. JSON-encoded string blobs are parsed and redacted when they mention a sensitive key. - HybridContentParser.parse()/_parse_content_object() take call-local trace_id/span_id; GCS object paths can no longer be overwritten by a concurrent event mutating the shared parser. - _ensure_schema_exists raises on table get/create failure (NotFound -> create and Conflict handling preserved); _ensure_started keeps _started=False, retries after bounded exponential backoff coalesced behind the setup lock, and rows lost meanwhile are counted as setup_unavailable. - enabled=False short-circuits _ensure_started (zero auth/client/table/ writer side effects from any entry point). - Runtime settings validated at construction (batch, flush, shutdown, queue, max_content_length, RetryConfig; max_retries < 0 previously skipped the write loop entirely). - Plugin-level drop counters merged into get_drop_stats(), surviving shutdown.
|
Fresh review at exact head Verdict: FAIL. Keep this draft. I found four P1 production issues, three P2 issues, and one deterministic red test.
Verification:
The cold-start latency deferral is reasonable and accurately disclosed. It is not part of this fail gate. |
…als, log leak, blob decode-first All eight findings from the PR google#6360 review at 257c51b: - P1-1: _ensure_schema_exists now runs on every setup attempt until one succeeds; the cached _schema no longer gates it. Retry test asserts the second get_table RPC. - P1-2: GCS object names gain a per-parse uid and content ordinal, so multiple messages in one request (same trace/span, restarting part index) can no longer collide. New multi-message regression test. - P1-3: the formatter-failure log carries only the exception class — no message, no exc_info — so a formatter that embeds content in its exception cannot leak it into application logs. Test asserts caplog. - P1-4: _sanitize_json_blob decodes FIRST ({ or [ prefixes), inspecting decoded keys recursively — JSON string escapes (access\u005ftoken) and arrays of credential objects are covered; unchanged strings return byte-for-byte. New escape/array regression test. - P2-5: shutdown() folds processor drop counters into the persistent plugin counters before clearing loop state. - P2-6: __setstate__ backfills _local_drop_counts/_setup_failures/ _setup_retry_at for pre-change pickles; __getstate__ resets retry state. - P2-7: _validate_runtime_config enforces integral counts and finite reals (NaN previously passed every ordered comparison; max_retries=NaN silently skipped the write loop). - CI: test_views_not_created_after_table_creation_failure updated to the intentional raise; parser-reuse test now pins the no-mutation contract. Also adds a recursion depth cap to _recursive_smart_truncate: id()-based cycle detection cannot catch graphs that manufacture new objects per duck-typed access (Mock-like), which the new final sanitizer pass exposed as an unbounded recursion.
|
Pushed
One additional fix the final sanitizer pass surfaced: Verification at |
|
Fresh review at exact head Verdict: FAIL. Keep this draft. The eight findings from the previous review are addressed and their regressions are green, but this fresh pass found three P1 and five P2 gaps.
Scope note: the cold-start latency deferral remains reasonable and is not part of this fail gate. However, the PR body says Verification at the unchanged head:
|
…ss, Mapping redaction, loss accounting All eight findings from the round-2 review at 7596701: - P1-1: _sanitize_json_blob tracks duplicate JSON members via an object_pairs_hook; blobs with duplicates are always reserialized, so an earlier duplicate secret member can no longer ride through the sanitized == parsed shortcut. - P1-2: _maybe_upgrade_schema re-raises update_table failures — a table verifiably missing required fields no longer lets _ensure_started mark the plugin ready with no readiness retry. Both pinned logged-not-raised tests updated to the new contract. - P1-3: the sanitizer walks collections.abc.Mapping (MappingProxyType, UserDict, ...) and emits plain sanitized dicts instead of stringifying them past key redaction. - P2-4: shutdown-timeout losses are counted — the cancelled worker counts its in-flight batch and shutdown() drains and counts queued rows under a new shutdown_timeout drop reason. - P2-5: RecursionError/MemoryError while parsing a JSON blob fails closed to [UNPARSEABLE_JSON_BLOB] instead of passing the row through unexamined (or dropping the whole row via the callback wrapper). - P2-6: _cleanup_stale_loop_states folds the dead processor's drop counters into the persistent plugin counters before deletion. - P2-7: __setstate__ validates the restored config; a legacy pickle with retry_config.max_retries=NaN now fails at restore instead of silently skipping the write loop. - P2-8: GCS object names use the full 128-bit uuid, and uploads pass if_generation_match=0 (create-only) so a collision fails loudly instead of rebinding an existing row to another event's bytes.
|
Pushed
Scope note taken: PR body now says On the verification-count discrepancy you flagged: agreed — my "324 passed" was this env (no optional-dep skips); your 318+6 skipped is the same 324 collected. At |
Fresh review, round 3 (
|
…abel-only readiness, cross-loop setup All six findings from the round-3 review at 9b79940: - P1-1: json.JSONDecodeError (not JSON) passes through; every OTHER ValueError from json.loads — e.g. integers over the interpreter digit limit on syntactically valid JSON — now fails closed to the [UNPARSEABLE_JSON_BLOB] sentinel, alongside RecursionError/MemoryError. - P1-2: _maybe_upgrade_schema re-raises update_table failures only when schema fields are actually missing; a label-only refresh failure logs and continues, since the table is write-compatible and the stale label retries next run. - P2: _ensure_started uses per-event-loop asyncio locks (dict guarded by a threading.Lock) — one shared asyncio.Lock is loop-bound and raised 'Non-thread-safe operation' with waiters stranded on other loops. Pickle/reset/fork paths updated for the new fields. - P2: _cleanup_stale_loop_states claims each state atomically via dict.pop before folding counters — read-fold-delete raced under concurrent cleanup, double-counting and raising KeyError. - P2: BatchProcessor.close() shares shutdown()'s drain/accounting, so rows stranded in the queue are counted under shutdown_timeout instead of silently discarded. - P3: the duplicate CancelledError handler is consolidated. The kept handler must RE-RAISE: asyncio.wait_for treats suppressed cancellation as normal completion, so a swallow-and-break variant silently disabled shutdown()'s drain branch (caught by the round-2 regression test). Six new regression tests, including a two-thread/two-loop startup test and the reviewer's big-int and label-only repros.
|
Pushed
Verification at |
Fresh review, round 4 (
|
…ize-gated parsing, cross-loop setup future All eight findings from the round-4 review at 5c5521d: - P1-1: every container-shaped ({ or [) parse failure now fails closed to [UNPARSEABLE_JSON_BLOB] — trailing garbage on valid credential JSON (incl. escaped keys) can no longer bypass redaction. Malformed-object, trailing-garbage, and escaped-key regressions added. - P1-2: _sanitize_json_blob enforces max_content_length BEFORE json.loads; over-limit container blobs fail closed without being materialized (test patches json.loads to prove it is never called). - P1-3: shared setup is coalesced across event loops/threads with a concurrent.futures.Future claimed under a briefly-held threading.Lock (never across an await); waiters await it via asyncio.wrap_future from their own loops. Exactly one _lazy_setup runs (regression asserts setup_calls == 1 across two threads); a failing owner leaves consistent _started/_startup_error/backoff state for all participants. This also removes the per-loop _setup_locks map entirely (P2: it retained every closed loop forever). - P2: tuple subclasses (namedtuples) normalize to plain lists in the sanitizer instead of raising TypeError and dropping the row. Duck-typed conversions (model_dump/dict/to_dict) now require progress to a real dict/list before recursing, so Mock-like graphs settle at the stringify fallback instead of churning to the depth cap. - P2: _loop_state_by_loop ownership changes are guarded by a threading.Lock; cleanup snapshots keys under the guard, evaluates is_closed() outside it, and claims each state atomically (regression: insertion during is_closed() no longer raises 'dictionary changed size during iteration'). - P2: the [MAX_DEPTH_EXCEEDED] replacement now reports is_truncated=True — it discards real payload, unlike [CIRCULAR_REFERENCE]. Row-level test included. - P2: retry delays are validated as finite and NON-negative; the long-supported max_retries=0/initial_delay=0/max_delay=0 config constructs again (pinned by test).
|
Pushed
PR body refreshed as requested: the removed "cheap hint check" description is replaced with the decode-first/fail-closed contract, and the coalescing claim now describes the cross-loop future (and is accurate again). Verification at |
Addresses the four P1 findings and the mechanical P2s from #6356 (BigQuery Agent Analytics: fail-open privacy, GCS concurrency, and startup-loss gaps).
P1 fixes
1.
content_formatterfailure now fails closed. A raising formatter replaces content with a[FORMATTER_FAILED]sentinel instead of falling back to the unformatted payload; the exception is logged without the payload and counted asformatter_failedinget_drop_stats(). The existing test that codified fail-open (asserting the original "Secret message" was written) now asserts fail-closed.2. Final sanitizer pass over the complete attributes tree.
_log_eventnow runs_recursive_smart_truncateover the fully assembled attributes immediately beforejson.dumps, covering the producers that copied values in directly (state_deltaviaextra_attributes,custom_tags, labels, nested structures,temp:-scoped keys). Truncation from this pass propagates intois_truncated. New:_sanitize_json_blobredacts sensitive keys inside JSON-encoded string blobs (e.g. cached credential JSON) — the #5112-adjacent gap. It decodes container-shaped ({/[) strings FIRST (raw-substring prefilters are bypassable via JSON escapes), enforcesmax_content_lengthbeforejson.loads, and fails closed to[UNPARSEABLE_JSON_BLOB]for anything container-shaped it cannot parse and verify (malformed/trailing-garbage/over-limit/too-deep blobs, duplicate members always reserialized). Producer-local sanitization is kept as an optimization; the final pass is the mandatory boundary.3. GCS offload paths are call-local.
HybridContentParser.parse()and_parse_content_object()taketrace_id/span_idas keyword arguments and build every object path from those call-local values._log_eventpasses them per call instead of mutating the shared parser. The constructor fields remain only as a backward-compatible default. Regression test: two overlapping two-part parses with a forced interleave assert 4 unique object paths with correct per-event ownership (previously: event A resumed with event B's identity and both wrotetrace-b/span-b_p1, the later upload overwriting the earlier).4. Table readiness is a startup requirement with bounded retry.
_ensure_schema_existsnow raises on table get/create failures (preservingNotFound→ create andConflict→ concurrent-creation handling) instead of logging and returning._ensure_startedrecords the failure, keeps_started=False, and retries on a later event after exponential backoff (2s → 60s cap). Setup is coalesced ACROSS event loops and threads via aconcurrent.futures.Futureclaimed under a briefly-heldthreading.Lock(waitersasyncio.wrap_futureit from their own loops), so exactly one_lazy_setupruns per attempt and a persistent outage costs one setup RPC per backoff window, not one per event. Rows arriving while setup is unavailable are counted assetup_unavailable.P2 hardening included
enabled=Falseis a hard no-op at the single choke point (_ensure_started), coveringbefore_run_callback,__aenter__, and_log_event: no ADC lookup, client creation, table RPCs, or background tasks. Test asserts zero side effects._validate_runtime_config):batch_size,batch_flush_interval,shutdown_timeout,queue_max_size,max_content_length, and allRetryConfigfields. Notablymax_retries < 0used to skip the write loop entirely, dropping every batch without one attempt.BatchProcessor: plugin-level drop counters (formatter_failed,setup_unavailable) merged intoget_drop_stats(); they survive shutdown and loop cleanup.Acceptance tests from #6356 covered here
content_formatternever writes the original content.temp:keys, and JSON-encoded secret blobs._started=False, records loss, and succeeds on a later retry (coalesced, backed off).enabled=Falsecauses zero auth/client/table/writer/background side effects through Runner callbacks and async context-manager use.Verification
TestIssue6356Hardeningclass (config validation, final-pass redaction incl. JSON blobs, concurrent-offload path isolation, setup retry, disabled-mode no-op, drop-stat persistence) + the rewritten formatter-failure test. Full-suite local run in progress — draft until it and CI confirm green.pyink --check/isort --check-onlyclean.Refs #6356 (kept open: the deferred cold-start latency P2 remains tracked there; closing is the maintainers' call on merge).