Skip to content

Commit 5de0d93

Browse files
caohy1988claude
andcommitted
fix(bigquery): BQAA P1 preview blockers — branch-safe tracing, bounded lifecycle, boundary redaction, HITL pause/resume
Resolves the plugin-local P1 items from the BQAA preview readiness tracker (google#1316) across seven review rounds. Framework-level terminal agent/run error callbacks remain intentionally deferred to a separate framework API change. Tracing correctness: - TraceManager keeps ID-only span records (no plugin-owned OTel spans are created or exported; ambient trace IDs are still inherited) in per-branch stacks keyed by InvocationContext.branch(), with kind-checked pops, so concurrent ParallelAgent branches cannot pop each other's spans. Tool spans additionally carry an operation identity — the function-call ID when present, else a per-ToolContext synthetic ID (the framework materializes absent IDs as "") — plus a push-time parent, because ADK executes an event's function calls concurrently within one branch. Privacy boundary: - JsonFormatter.redactTree walks raw containers with sensitive-key redaction BEFORE any Jackson conversion and fails closed per leaf ("[UNSERIALIZABLE]"), and the assembled attributes tree passes through it at the output boundary. Session state is redacted before truncation so smartTruncate's whole-map textual fallback can never expose embedded secrets. Lifecycle and shutdown: - Per-invocation lifecycle tokens are durable (captured by every append continuation; immune to tombstone-cache eviction) and append admission is atomic with finalization via the token's monitor. - One absolute deadline bounds each shutdown operation end-to-end: pending-task waits, every processor drain, and executor termination share it; finalization is completion-ordered (cleanup runs before the returned Completable completes, since RxJava's doFinally notifies downstream first) with disposal covered by the same idempotent action. - BatchProcessor teardown is ownership-transferred (ReentrantLock; an in-flight flush past the deadline performs the teardown), appends are bounded (get(timeout) plus LimitExceededBehavior.ThrowException so Storage quota saturation cannot park append() for minutes), and the final drop-stat snapshot is delivered at true teardown completion. - Live StreamWriters are bounded by admission permits acquired BEFORE construction (each writer owns an internal client and a non-daemon append thread; MAX_LIVE_WRITERS <= closer queue capacity, so a constructed writer can never lose its cleanup owner). Detached closes run on a bounded plugin-owned closer service; at plugin shutdown the unstarted queue drains to a bounded reclaim owner without interrupting active closes. Rows refused at the cap are accounted ("writer_permit_exhausted"), alongside "after_close", "shutdown_timeout", "writer_create_error", and "late_after_finalize". Event contract: - Synthetic adk_request_* function calls emit HITL_*_REQUEST (not _COMPLETED); long-running calls emit pairable TOOL_PAUSED rows with pause_kind and function_call_id; user-message handling inspects FunctionResponse parts, routing HITL responses to HITL_*_COMPLETED (with pair keys, content.result on both producer paths, Python parity) and non-HITL responses to TOOL_COMPLETED with pair keys. Adds the TOOL_PAUSED view and pair-key columns on TOOL_COMPLETED. Tests: focused BQAA suite 173 passed (upstream baseline: 135); full core main profile 1,629 passed / 0 failed / 24 skipped. New regressions cover parallel-branch and concurrent id-less tool span ownership, zero exported spans, nested and fail-closed redaction (attributes and session state), atomic admission/finalization interleavings, durable tokens across cache eviction, single-deadline multi-batch / task-plus-drain / multi-processor shutdown bounds, completion-ordered cleanup, blocked-append and blocked-close teardown ownership, writer permit exhaustion, and shutdownNow-leftover reclaim. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 08f4fdc commit 5de0d93

11 files changed

Lines changed: 2686 additions & 297 deletions

File tree

core/src/main/java/com/google/adk/plugins/agentanalytics/BatchProcessor.java

Lines changed: 192 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import com.fasterxml.jackson.databind.JsonNode;
2525
import com.fasterxml.jackson.databind.node.ArrayNode;
2626
import com.fasterxml.jackson.databind.node.ObjectNode;
27+
import com.google.api.core.ApiFuture;
2728
import com.google.cloud.bigquery.storage.v1.AppendRowsResponse;
2829
import com.google.cloud.bigquery.storage.v1.Exceptions.AppendSerializationError;
2930
import com.google.cloud.bigquery.storage.v1.StreamWriter;
@@ -37,8 +38,12 @@
3738
import java.util.concurrent.BlockingQueue;
3839
import java.util.concurrent.LinkedBlockingQueue;
3940
import java.util.concurrent.ScheduledExecutorService;
41+
import java.util.concurrent.ScheduledFuture;
42+
import java.util.concurrent.TimeoutException;
4043
import java.util.concurrent.atomic.AtomicBoolean;
4144
import java.util.concurrent.atomic.AtomicLong;
45+
import java.util.concurrent.locks.ReentrantLock;
46+
import java.util.function.Consumer;
4247
import java.util.logging.Level;
4348
import java.util.logging.Logger;
4449
import org.apache.arrow.memory.BufferAllocator;
@@ -55,6 +60,7 @@
5560
import org.apache.arrow.vector.ipc.message.ArrowRecordBatch;
5661
import org.apache.arrow.vector.types.pojo.Field;
5762
import org.apache.arrow.vector.types.pojo.Schema;
63+
import org.jspecify.annotations.Nullable;
5864

5965
/** Handles asynchronous batching and writing of events to BigQuery. */
6066
class BatchProcessor implements AutoCloseable {
@@ -63,27 +69,53 @@ class BatchProcessor implements AutoCloseable {
6369
private final StreamWriter writer;
6470
private final int batchSize;
6571
private final Duration flushInterval;
72+
private final Duration shutdownTimeout;
6673
@VisibleForTesting final BlockingQueue<Map<String, Object>> queue;
6774
private final ScheduledExecutorService executor;
6875
@VisibleForTesting final BufferAllocator allocator;
69-
final AtomicBoolean flushLock = new AtomicBoolean(false);
76+
// Mutual exclusion for flush; a ReentrantLock (not a CAS flag) so close() can WAIT, bounded,
77+
// for an in-flight flush instead of guessing from queue emptiness.
78+
private final ReentrantLock flushMutex = new ReentrantLock();
79+
private final AtomicBoolean closed = new AtomicBoolean(false);
80+
// Set by close() before it waits for the flush mutex: whichever party releases the mutex last
81+
// (close, or an in-flight flush that outlived close's deadline) performs the actual teardown.
82+
private final AtomicBoolean teardownRequested = new AtomicBoolean(false);
83+
private final AtomicBoolean tornDown = new AtomicBoolean(false);
84+
// While closing, bounds every drain/in-flight append to the remaining close budget.
85+
private volatile @Nullable Instant closeDeadline;
86+
// Delivered the FINAL drop-stat snapshot when teardown actually completes; see closeAndFold.
87+
private volatile @Nullable Consumer<ImmutableMap<String, Long>> onFinalStats;
88+
// Owner-preserving detached close for the StreamWriter (see teardownOnce). PluginState provides
89+
// an implementation that is bounded, never blocks, and guarantees every writer's close
90+
// eventually runs (a StreamWriter owns an internal client and a NON-DAEMON append thread that
91+
// only ConnectionWorker.close() stops — abandoning one leaks process-level resources).
92+
private final Consumer<StreamWriter> writerCloser;
93+
// The periodic flush task; stored so per-invocation close() can cancel it instead of leaving a
94+
// scheduled task retaining this (closed) processor until plugin-wide shutdown.
95+
private volatile @Nullable ScheduledFuture<?> flushTask;
7096
private final Schema arrowSchema;
7197
private final VectorSchemaRoot root;
7298

7399
// Drop accounting so hosts can programmatically detect lost analytics rows.
74100
private final AtomicLong droppedQueueFull = new AtomicLong();
75101
private final AtomicLong droppedAppendError = new AtomicLong();
76102
private final AtomicLong droppedSerializationError = new AtomicLong();
103+
private final AtomicLong droppedAfterClose = new AtomicLong();
104+
private final AtomicLong droppedShutdownTimeout = new AtomicLong();
77105

78106
public BatchProcessor(
79107
StreamWriter writer,
80108
int batchSize,
81109
Duration flushInterval,
82110
int queueMaxSize,
83-
ScheduledExecutorService executor) {
111+
ScheduledExecutorService executor,
112+
Duration shutdownTimeout,
113+
Consumer<StreamWriter> writerCloser) {
84114
this.writer = writer;
115+
this.writerCloser = writerCloser;
85116
this.batchSize = batchSize;
86117
this.flushInterval = flushInterval;
118+
this.shutdownTimeout = shutdownTimeout;
87119
this.queue = new LinkedBlockingQueue<>(queueMaxSize);
88120
this.executor = executor;
89121
// It's safe to use Long.MAX_VALUE here as this is a top-level RootAllocator,
@@ -95,8 +127,7 @@ public BatchProcessor(
95127
}
96128

97129
public void start() {
98-
@SuppressWarnings("unused")
99-
var unused =
130+
this.flushTask =
100131
executor.scheduleWithFixedDelay(
101132
() -> {
102133
try {
@@ -111,19 +142,26 @@ public void start() {
111142
}
112143

113144
public void append(Map<String, Object> row) {
145+
if (closed.get()) {
146+
// The owning invocation has already been finalized; accept-and-drop with accounting rather
147+
// than silently enqueueing into a processor whose final drain has already run.
148+
droppedAfterClose.incrementAndGet();
149+
logger.warning("BatchProcessor is closed, dropping late event.");
150+
return;
151+
}
114152
if (!queue.offer(row)) {
115153
droppedQueueFull.incrementAndGet();
116154
logger.warning("BigQuery event queue is full, dropping event.");
117155
return;
118156
}
119-
if (queue.size() >= batchSize && !flushLock.get()) {
157+
if (queue.size() >= batchSize && !flushMutex.isLocked()) {
120158
executor.execute(this::flush);
121159
}
122160
}
123161

124162
public void flush() {
125-
// Acquire the flushLock. If another flush is already in progress, return immediately.
126-
if (!flushLock.compareAndSet(false, true)) {
163+
// Acquire the flush mutex. If another flush is already in progress, return immediately.
164+
if (!flushMutex.tryLock()) {
127165
return;
128166
}
129167
try {
@@ -145,7 +183,16 @@ public void flush() {
145183
}
146184
root.setRowCount(batch.size());
147185
try (ArrowRecordBatch recordBatch = new VectorUnloader(root).getRecordBatch()) {
148-
AppendRowsResponse result = writer.append(recordBatch).get();
186+
// Bound the append so one stuck Storage Write RPC cannot block the flush path (and,
187+
// during close(), the final drain) indefinitely.
188+
ApiFuture<AppendRowsResponse> appendFuture = writer.append(recordBatch);
189+
AppendRowsResponse result;
190+
try {
191+
result = appendFuture.get(appendTimeoutMillis(), MILLISECONDS);
192+
} catch (TimeoutException e) {
193+
appendFuture.cancel(true);
194+
throw e;
195+
}
149196
if (result.hasError()) {
150197
droppedAppendError.addAndGet(batch.size());
151198
logger.severe("BigQuery append error: " + result.getError().getMessage());
@@ -185,13 +232,32 @@ public void flush() {
185232
root.clear();
186233
}
187234
} finally {
188-
flushLock.set(false);
189-
if (queue.size() >= batchSize && !flushLock.get()) {
235+
flushMutex.unlock();
236+
// Deferred teardown: close() timed out waiting for this flush, transferring ownership of
237+
// the final resource teardown (and drop-stat delivery) to us.
238+
if (teardownRequested.get()) {
239+
teardownOnce();
240+
}
241+
if (queue.size() >= batchSize && !flushMutex.isLocked()) {
190242
executor.execute(this::flush);
191243
}
192244
}
193245
}
194246

247+
/**
248+
* Per-append deadline: normally {@code shutdownTimeout}; once close() has started, capped to the
249+
* remaining close budget so the final drain cannot exceed the caller's bound.
250+
*/
251+
private long appendTimeoutMillis() {
252+
long timeoutMillis = shutdownTimeout.toMillis();
253+
Instant deadline = this.closeDeadline;
254+
if (deadline != null) {
255+
long remaining = Duration.between(Instant.now(), deadline).toMillis();
256+
timeoutMillis = Math.max(1, Math.min(timeoutMillis, remaining));
257+
}
258+
return timeoutMillis;
259+
}
260+
195261
private void populateVector(FieldVector vector, int index, Object value) {
196262
if (value == null || (value instanceof JsonNode jsonNode && jsonNode.isNull())) {
197263
vector.setNull(index);
@@ -266,13 +332,113 @@ ImmutableMap<String, Long> getDropStats() {
266332
return ImmutableMap.of(
267333
"queue_full", droppedQueueFull.get(),
268334
"append_error", droppedAppendError.get(),
269-
"serialization_error", droppedSerializationError.get());
335+
"serialization_error", droppedSerializationError.get(),
336+
"after_close", droppedAfterClose.get(),
337+
"shutdown_timeout", droppedShutdownTimeout.get());
338+
}
339+
340+
/**
341+
* Closes the processor and delivers the FINAL drop-stat snapshot to {@code statsConsumer} when
342+
* teardown actually completes — which may be after this call returns, if an in-flight flush still
343+
* owns the resources when the shutdownTimeout deadline expires (ownership of the teardown then
344+
* transfers to that flush). This guarantees counters recorded by that last flush (e.g. its append
345+
* failure) are included in the delivered snapshot exactly once.
346+
*/
347+
void closeAndFold(Consumer<ImmutableMap<String, Long>> statsConsumer) {
348+
closeAndFold(statsConsumer, Instant.now().plus(shutdownTimeout));
349+
}
350+
351+
/**
352+
* Deadline-accepting variant: the caller passes ONE absolute deadline shared across a larger
353+
* shutdown operation (pending-task waits, sibling processors, executor termination), so this
354+
* processor's drain consumes only the remaining budget instead of restarting a fresh
355+
* shutdownTimeout.
356+
*/
357+
void closeAndFold(Consumer<ImmutableMap<String, Long>> statsConsumer, Instant deadline) {
358+
this.onFinalStats = statsConsumer;
359+
close(deadline);
270360
}
271361

272362
@Override
273363
public void close() {
274-
while (this.queue != null && !this.queue.isEmpty()) {
364+
close(Instant.now().plus(shutdownTimeout));
365+
}
366+
367+
private void close(Instant drainDeadline) {
368+
// Idempotent: ensureInvocationCompleted and plugin-wide shutdown may both close a processor.
369+
if (!closed.compareAndSet(false, true)) {
370+
return;
371+
}
372+
// Cancel the periodic flush task so a completed invocation does not leave a scheduled task
373+
// retaining this processor (and its writer) until plugin-wide shutdown.
374+
ScheduledFuture<?> task = this.flushTask;
375+
if (task != null) {
376+
task.cancel(false);
377+
}
378+
// Final drain, bounded by the caller's absolute deadline rather than looping until empty.
379+
// Publishing the deadline caps every drain/in-flight append to the remaining close budget.
380+
this.closeDeadline = drainDeadline;
381+
while (!this.queue.isEmpty() && Instant.now().isBefore(drainDeadline)) {
275382
this.flush();
383+
if (!this.queue.isEmpty()) {
384+
// Another thread may hold the flush mutex; back off briefly instead of spinning.
385+
try {
386+
Thread.sleep(10);
387+
} catch (InterruptedException e) {
388+
Thread.currentThread().interrupt();
389+
break;
390+
}
391+
}
392+
}
393+
int remaining = this.queue.size();
394+
if (remaining > 0) {
395+
droppedShutdownTimeout.addAndGet(remaining);
396+
this.queue.clear();
397+
logger.severe(
398+
"Dropping " + remaining + " rows: final drain did not complete within shutdownTimeout.");
399+
}
400+
// Teardown ownership: request it, then try to acquire the flush mutex within the remaining
401+
// budget. If acquired, no flush is active and we tear down here. If the wait expires, the
402+
// in-flight flush performs the teardown (and final stats delivery) when it releases the mutex
403+
// — resources are never destroyed underneath an active flush, and counters that flush records
404+
// are still included in the final snapshot.
405+
teardownRequested.set(true);
406+
boolean acquired = false;
407+
long waitMillis = Math.max(1, Duration.between(Instant.now(), drainDeadline).toMillis());
408+
try {
409+
acquired = flushMutex.tryLock(waitMillis, MILLISECONDS);
410+
} catch (InterruptedException e) {
411+
Thread.currentThread().interrupt();
412+
}
413+
if (acquired) {
414+
try {
415+
teardownOnce();
416+
} finally {
417+
flushMutex.unlock();
418+
}
419+
} else {
420+
logger.severe(
421+
"Deferring resource teardown to the in-flight flush: it did not release the flush mutex"
422+
+ " within shutdownTimeout.");
423+
// The flush may have released the mutex between the timed wait expiring and the request
424+
// flag becoming visible to it; re-check so the teardown is never lost.
425+
if (flushMutex.tryLock()) {
426+
try {
427+
teardownOnce();
428+
} finally {
429+
flushMutex.unlock();
430+
}
431+
}
432+
}
433+
}
434+
435+
/**
436+
* Tears down Arrow and writer resources and delivers the final drop-stat snapshot, exactly once,
437+
* regardless of whether close() or a deferred in-flight flush gets here first.
438+
*/
439+
private void teardownOnce() {
440+
if (!tornDown.compareAndSet(false, true)) {
441+
return;
276442
}
277443
if (this.allocator != null) {
278444
try {
@@ -289,10 +455,22 @@ public void close() {
289455
}
290456
}
291457
if (this.writer != null) {
458+
// StreamWriter.close() can block far beyond any shutdownTimeout (it joins the writer's
459+
// internal non-daemon append thread, then may wait minutes on its internal client and
460+
// callback pools). Delegate to the plugin-owned closer, which detaches the close without
461+
// ever abandoning the writer: cleanup ownership is guaranteed by writer admission permits
462+
// acquired before construction. No further work touches the writer here: teardown only
463+
// runs after appends have stopped (closed gate + flush-mutex ownership), and drop counters
464+
// are final below.
465+
writerCloser.accept(this.writer);
466+
}
467+
// Deliver the final snapshot only now, when no flush can mutate the counters anymore.
468+
Consumer<ImmutableMap<String, Long>> statsConsumer = this.onFinalStats;
469+
if (statsConsumer != null) {
292470
try {
293-
this.writer.close();
471+
statsConsumer.accept(getDropStats());
294472
} catch (RuntimeException e) {
295-
logger.log(Level.SEVERE, "Failed to close BigQuery writer", e);
473+
logger.log(Level.WARNING, "Failed to deliver final drop stats", e);
296474
}
297475
}
298476
}

0 commit comments

Comments
 (0)