Skip to content

Commit b941ac7

Browse files
caohy1988claude
andcommitted
fix(bigquery): address round-4 review — shared shutdown deadline, collision-proof tool identity, HITL content parity
Resolves both round-4 P1 findings and the P2 on PR google#1344: 1. One absolute deadline per shutdown operation: ensureInvocationCompleted computes its deadline at subscription (Completable.defer) and shares it between the pending-task wait and the processor drain; plugin-wide close() shares one deadline across the task wait, every processor's drain, and both executor terminations. BatchProcessor gains deadline-accepting closeAndFold/close variants so each drain consumes only the remaining budget — a stuck parse plus a queued processor, or N stuck processors, are all bounded by a single shutdownTimeout. 2. Collision-proof tool operation identity: the framework materializes an absent function-call ID as "", so two concurrent id-less calls collided and cross-popped. toolOperationId now uses the real ID when non-empty, else a synthetic ID keyed by ToolContext instance identity (weak-keys cache; the same instance flows through before/after/error callbacks of one call). hitlPairKeys also filters empty-string IDs, which are useless as join keys. 3. HITL completion content normalized to "result" on the event path, matching the user-message path and the Python plugin, so one event type has one queryable content shape. Tests: concurrent id-less tools keep span ownership out of completion order (plugin-level, span-id pairing per tool); multi-batch drain bound tightened to <900ms so the old two-deadline behavior fails; new pending-task-plus-drain and multi-stuck-processor cases assert the shared bound; HITL event-path completion asserts content.result. Focused BQAA suite 168 passed (previous: 165); full core 1,624 passed / 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 73c6233 commit b941ac7

5 files changed

Lines changed: 221 additions & 34 deletions

File tree

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -338,12 +338,26 @@ ImmutableMap<String, Long> getDropStats() {
338338
* failure) are included in the delivered snapshot exactly once.
339339
*/
340340
void closeAndFold(Consumer<ImmutableMap<String, Long>> statsConsumer) {
341+
closeAndFold(statsConsumer, Instant.now().plus(shutdownTimeout));
342+
}
343+
344+
/**
345+
* Deadline-accepting variant: the caller passes ONE absolute deadline shared across a larger
346+
* shutdown operation (pending-task waits, sibling processors, executor termination), so this
347+
* processor's drain consumes only the remaining budget instead of restarting a fresh
348+
* shutdownTimeout.
349+
*/
350+
void closeAndFold(Consumer<ImmutableMap<String, Long>> statsConsumer, Instant deadline) {
341351
this.onFinalStats = statsConsumer;
342-
close();
352+
close(deadline);
343353
}
344354

345355
@Override
346356
public void close() {
357+
close(Instant.now().plus(shutdownTimeout));
358+
}
359+
360+
private void close(Instant drainDeadline) {
347361
// Idempotent: ensureInvocationCompleted and plugin-wide shutdown may both close a processor.
348362
if (!closed.compareAndSet(false, true)) {
349363
return;
@@ -354,9 +368,8 @@ public void close() {
354368
if (task != null) {
355369
task.cancel(false);
356370
}
357-
// Final drain, bounded by shutdownTimeout rather than looping until empty. Publishing the
358-
// deadline caps every drain/in-flight append to the remaining close budget.
359-
Instant drainDeadline = Instant.now().plus(shutdownTimeout);
371+
// Final drain, bounded by the caller's absolute deadline rather than looping until empty.
372+
// Publishing the deadline caps every drain/in-flight append to the remaining close budget.
360373
this.closeDeadline = drainDeadline;
361374
while (!this.queue.isEmpty() && Instant.now().isBefore(drainDeadline)) {
362375
this.flush();

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

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@
5353
import com.google.cloud.bigquery.TableInfo;
5454
import com.google.cloud.bigquery.TimePartitioning;
5555
import com.google.common.annotations.VisibleForTesting;
56+
import com.google.common.cache.Cache;
57+
import com.google.common.cache.CacheBuilder;
5658
import com.google.common.collect.ImmutableList;
5759
import com.google.common.collect.ImmutableMap;
5860
import com.google.common.collect.ImmutableSet;
@@ -74,6 +76,7 @@
7476
import java.util.Map;
7577
import java.util.Optional;
7678
import java.util.Set;
79+
import java.util.UUID;
7780
import java.util.concurrent.CompletableFuture;
7881
import java.util.concurrent.TimeUnit;
7982
import java.util.logging.Level;
@@ -418,6 +421,29 @@ private static String resolveAgentName(
418421
return eventData.flatMap(EventData::fallbackAgentName).orElse("unknown");
419422
}
420423

424+
// Synthetic operation identities for tool calls whose function-call ID is absent: the framework
425+
// materializes ToolContext.functionCallId as "" when the model omitted the ID, so two concurrent
426+
// id-less calls would collide on "" and cross-pop each other's spans. The same ToolContext
427+
// instance flows through the before/after/error callbacks of one call, so it keys a unique
428+
// synthetic ID; weakKeys gives identity semantics plus GC-based cleanup for calls whose
429+
// completion callback never fires.
430+
private final Cache<ToolContext, String> syntheticToolCallIds =
431+
CacheBuilder.newBuilder().weakKeys().build();
432+
433+
/**
434+
* Operation identity for a tool call's span: the real function-call ID when present and
435+
* non-empty, else a per-ToolContext synthetic ID (see {@link #syntheticToolCallIds}).
436+
*/
437+
private String toolOperationId(ToolContext toolContext) {
438+
String id = toolContext.functionCallId().orElse("");
439+
if (!id.isEmpty()) {
440+
return id;
441+
}
442+
return syntheticToolCallIds
443+
.asMap()
444+
.computeIfAbsent(toolContext, tc -> "tool-ctx-" + UUID.randomUUID());
445+
}
446+
421447
/**
422448
* Pause/resume pair keys for a HITL completion row: {@code pause_kind} derived from the synthetic
423449
* call name and, when the response carries one, the {@code function_call_id} that joins the
@@ -427,7 +453,8 @@ private static ImmutableMap<String, Object> hitlPairKeys(
427453
String hitlName, Optional<String> functionCallId) {
428454
ImmutableMap.Builder<String, Object> keys = ImmutableMap.builder();
429455
keys.put("pause_kind", HITL_PAUSE_KIND_MAP.getOrDefault(hitlName, "tool"));
430-
functionCallId.ifPresent(id -> keys.put("function_call_id", id));
456+
// The framework materializes an absent ID as "", which is useless as a join key.
457+
functionCallId.filter(id -> !id.isEmpty()).ifPresent(id -> keys.put("function_call_id", id));
431458
return keys.buildOrThrow();
432459
}
433460

@@ -719,8 +746,10 @@ public Maybe<Event> onEventCallback(InvocationContext invocationContext, Event e
719746
logEvent(
720747
hitlEvent + "_COMPLETED",
721748
invocationContext,
749+
// "result" matches the Python plugin's HITL completion content on BOTH
750+
// producer paths, so one event type has one queryable content shape.
722751
ImmutableMap.of(
723-
"tool", hitlResponse.name().get(), "response", truncatedResult.node()),
752+
"tool", hitlResponse.name().get(), "result", truncatedResult.node()),
724753
truncatedResult.isTruncated(),
725754
Optional.of(
726755
EventData.builder()
@@ -1076,8 +1105,7 @@ public Maybe<Map<String, Object>> beforeToolCallback(
10761105
TraceManager.SpanRecord toolSpan =
10771106
state
10781107
.getTraceManager(toolContext.invocationContext().invocationId())
1079-
.pushSpanRecord(
1080-
toolContext.invocationContext(), "tool", toolContext.functionCallId().orElse(null));
1108+
.pushSpanRecord(toolContext.invocationContext(), "tool", toolOperationId(toolContext));
10811109
EventData.Builder startingData = EventData.builder().setSpanIdOverride(toolSpan.spanId());
10821110
if (toolSpan.parentSpanId() != null) {
10831111
startingData.setParentSpanIdOverride(toolSpan.parentSpanId());
@@ -1105,8 +1133,7 @@ public Maybe<Map<String, Object>> afterToolCallback(
11051133
TraceManager traceManager =
11061134
state.getTraceManager(toolContext.invocationContext().invocationId());
11071135
Optional<RecordData> popped =
1108-
traceManager.popSpan(
1109-
toolContext.invocationContext(), "tool", toolContext.functionCallId().orElse(null));
1136+
traceManager.popSpan(toolContext.invocationContext(), "tool", toolOperationId(toolContext));
11101137
TruncationResult truncationResult = smartTruncate(result, config.maxContentLength());
11111138
ImmutableMap<String, Object> contentMap =
11121139
ImmutableMap.of(
@@ -1148,8 +1175,7 @@ public Maybe<Map<String, Object>> onToolErrorCallback(
11481175
TraceManager traceManager =
11491176
state.getTraceManager(toolContext.invocationContext().invocationId());
11501177
Optional<RecordData> popped =
1151-
traceManager.popSpan(
1152-
toolContext.invocationContext(), "tool", toolContext.functionCallId().orElse(null));
1178+
traceManager.popSpan(toolContext.invocationContext(), "tool", toolOperationId(toolContext));
11531179

11541180
TruncationResult truncationResult = smartTruncate(toolArgs, config.maxContentLength());
11551181
ImmutableMap<String, Object> contentMap =

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

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@
5252
import java.util.logging.Logger;
5353
import org.jspecify.annotations.Nullable;
5454
import org.threeten.bp.Duration;
55-
import org.threeten.bp.Instant;
5655

5756
/** Manages state for the BigQueryAgentAnalyticsPlugin. */
5857
class PluginState {
@@ -402,6 +401,19 @@ void addPendingTask(String invocationId, CompletableFuture<Void> task) {
402401
}
403402

404403
Completable ensureInvocationCompleted(String invocationId) {
404+
// ONE absolute deadline for the whole finalization: waiting for pending tasks and draining
405+
// the processor share it, so shutdownTimeout is the total bound rather than restarting per
406+
// phase (a stuck parse consuming one full timeout must not grant the drain another).
407+
// Deferred so the budget starts at subscription, not assembly.
408+
return Completable.defer(
409+
() -> {
410+
java.time.Instant finalizeDeadline =
411+
java.time.Instant.now().plus(config.shutdownTimeout());
412+
return finalizeInvocation(invocationId, finalizeDeadline);
413+
});
414+
}
415+
416+
private Completable finalizeInvocation(String invocationId, java.time.Instant finalizeDeadline) {
405417
Set<CompletableFuture<Void>> tasks = pendingTasks.get(invocationId);
406418
Completable tasksState = Completable.complete();
407419
if (tasks != null && !tasks.isEmpty()) {
@@ -437,13 +449,13 @@ Completable ensureInvocationCompleted(String invocationId) {
437449
markProcessed(invocationId);
438450
BatchProcessor processor = removeProcessor(invocationId);
439451
if (processor != null) {
440-
// closeAndFold owns the COMPLETE drain under one shutdownTimeout deadline. An
441-
// explicit flush() here would run before the close deadline is published and
442-
// consume a separate full append budget, doubling the effective bound. Folding
443-
// happens via the teardown callback, which fires when teardown ACTUALLY completes
444-
// (possibly after close() returns, if an in-flight flush owns the resources past
445-
// the deadline), so counters recorded by that last flush are never lost.
446-
processor.closeAndFold(this::foldStats);
452+
// closeAndFold drains under the SAME absolute deadline the pending-task wait
453+
// consumed from, so the total finalization is bounded by one shutdownTimeout.
454+
// Folding happens via the teardown callback, which fires when teardown ACTUALLY
455+
// completes (possibly after close() returns, if an in-flight flush owns the
456+
// resources past the deadline), so counters recorded by that last flush are never
457+
// lost.
458+
processor.closeAndFold(this::foldStats, finalizeDeadline);
447459
}
448460
TraceManager traceManager = removeTraceManager(invocationId);
449461
if (traceManager != null) {
@@ -494,6 +506,15 @@ ImmutableMap<String, Long> getDropStats() {
494506
}
495507

496508
Completable close() {
509+
// ONE absolute deadline for the whole plugin shutdown: the pending-task wait, every
510+
// processor's drain, and executor termination all consume from the same shutdownTimeout
511+
// budget, so total shutdown is bounded by one timeout rather than one per phase/processor.
512+
// Deferred so the budget starts at subscription, not assembly.
513+
return Completable.defer(
514+
() -> closeInternal(java.time.Instant.now().plus(config.shutdownTimeout())));
515+
}
516+
517+
private Completable closeInternal(java.time.Instant closeDeadline) {
497518
ImmutableList<CompletableFuture<Void>> tasks =
498519
pendingTasks.values().stream().flatMap(Set::stream).collect(toImmutableList());
499520
Completable tasksState = Completable.complete();
@@ -519,8 +540,9 @@ Completable close() {
519540
}
520541
lifecycles.clear();
521542
for (BatchProcessor processor : getBatchProcessors()) {
522-
// Fold via the teardown callback; see ensureInvocationCompleted.
523-
processor.closeAndFold(this::foldStats);
543+
// Fold via the teardown callback; each drain consumes only the REMAINING shared
544+
// budget, so N processors cannot take N timeouts.
545+
processor.closeAndFold(this::foldStats, closeDeadline);
524546
}
525547
for (TraceManager traceManager : getTraceManagers()) {
526548
traceManager.clearStack();
@@ -538,13 +560,14 @@ Completable close() {
538560
try {
539561
executor.shutdown();
540562
offloadExecutor.shutdown();
541-
long totalTimeoutMillis = config.shutdownTimeout().toMillis();
542-
Instant startTime = Instant.now();
543-
if (!executor.awaitTermination(totalTimeoutMillis, MILLISECONDS)) {
563+
long remainingMillis =
564+
java.time.Duration.between(java.time.Instant.now(), closeDeadline).toMillis();
565+
if (remainingMillis <= 0
566+
|| !executor.awaitTermination(remainingMillis, MILLISECONDS)) {
544567
executor.shutdownNow();
545568
}
546-
long elapsedTimeMillis = Duration.between(startTime, Instant.now()).toMillis();
547-
long remainingMillis = totalTimeoutMillis - elapsedTimeMillis;
569+
remainingMillis =
570+
java.time.Duration.between(java.time.Instant.now(), closeDeadline).toMillis();
548571
if (remainingMillis > 0) {
549572
if (!offloadExecutor.awaitTermination(remainingMillis, MILLISECONDS)) {
550573
offloadExecutor.shutdownNow();

core/src/test/java/com/google/adk/plugins/agentanalytics/BigQueryAgentAnalyticsPluginTest.java

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,14 +1037,22 @@ public void afterToolCallback_populatesCorrectFields() throws Exception {
10371037
ImmutableMap<String, Object> toolArgs = ImmutableMap.of("arg1", "value1");
10381038
ImmutableMap<String, Object> result = ImmutableMap.of("res1", "value2");
10391039

1040-
// Establish the invocation before pushing the tool span, so afterToolCallback's
1041-
// ensureInvocationSpan does not clear the pushed span as stale state from a prior invocation.
1040+
// Mirror the production flow: beforeToolCallback pushes the tool span with the SAME
1041+
// operation identity (from the ToolContext) that afterToolCallback pops with.
10421042
state.getTraceManager("invocation_id").ensureInvocationSpan(mockInvocationContext);
1043-
state.getTraceManager("invocation_id").pushSpan(mockInvocationContext, "tool_request");
1043+
plugin.beforeToolCallback(mockTool, toolArgs, mockToolContext).blockingSubscribe();
10441044
plugin.afterToolCallback(mockTool, toolArgs, mockToolContext, result).blockingSubscribe();
10451045

1046-
Map<String, Object> row = state.getBatchProcessor("invocation_id").queue.poll();
1047-
assertNotNull("Row not found in queue", row);
1046+
CompletableFuture.allOf(
1047+
state
1048+
.getPendingTasksForInvocation("invocation_id")
1049+
.toArray(new CompletableFuture<?>[0]))
1050+
.join();
1051+
Map<String, Object> row;
1052+
do {
1053+
row = state.getBatchProcessor("invocation_id").queue.poll();
1054+
assertNotNull("TOOL_COMPLETED row not found in queue", row);
1055+
} while (!"TOOL_COMPLETED".equals(row.get("event_type")));
10481056
assertEquals("TOOL_COMPLETED", row.get("event_type"));
10491057
assertEquals("agent_name", row.get("agent"));
10501058
ObjectNode contentMap = (ObjectNode) row.get("content");
@@ -2034,6 +2042,61 @@ public void onEventCallback_hitlFunctionResponse_completionCarriesPairKeys() thr
20342042
JsonNode attributes = (JsonNode) completed.get("attributes");
20352043
assertEquals("hitl_confirmation", attributes.get("pause_kind").asText());
20362044
assertEquals("fc-7", attributes.get("function_call_id").asText());
2045+
// Content key parity: both HITL completion producer paths (event and user-message) use
2046+
// "result", matching the Python plugin, so one event type has one queryable content shape.
2047+
JsonNode content = (JsonNode) completed.get("content");
2048+
assertNotNull("content.result must be present on the event path", content.get("result"));
2049+
}
2050+
2051+
@Test
2052+
public void concurrentIdLessTools_keepSpanOwnership() throws Exception {
2053+
// The framework materializes an absent function-call ID as "" — two concurrent id-less calls
2054+
// must not collide on it and cross-pop each other's spans.
2055+
BaseTool toolA = mock(BaseTool.class);
2056+
when(toolA.name()).thenReturn("tool_a");
2057+
BaseTool toolB = mock(BaseTool.class);
2058+
when(toolB.name()).thenReturn("tool_b");
2059+
ToolContext contextA = mock(ToolContext.class);
2060+
when(contextA.invocationContext()).thenReturn(mockInvocationContext);
2061+
when(contextA.functionCallId()).thenReturn(Optional.of(""));
2062+
ToolContext contextB = mock(ToolContext.class);
2063+
when(contextB.invocationContext()).thenReturn(mockInvocationContext);
2064+
when(contextB.functionCallId()).thenReturn(Optional.of(""));
2065+
2066+
state.getTraceManager("invocation_id").ensureInvocationSpan(mockInvocationContext);
2067+
plugin.beforeToolCallback(toolA, ImmutableMap.of(), contextA).blockingSubscribe();
2068+
plugin.beforeToolCallback(toolB, ImmutableMap.of(), contextB).blockingSubscribe();
2069+
// A completes FIRST even though B's record sits above it.
2070+
plugin
2071+
.afterToolCallback(toolA, ImmutableMap.of(), contextA, ImmutableMap.of())
2072+
.blockingSubscribe();
2073+
plugin
2074+
.afterToolCallback(toolB, ImmutableMap.of(), contextB, ImmutableMap.of())
2075+
.blockingSubscribe();
2076+
2077+
CompletableFuture.allOf(
2078+
state
2079+
.getPendingTasksForInvocation("invocation_id")
2080+
.toArray(new CompletableFuture<?>[0]))
2081+
.join();
2082+
Map<String, String> startingSpanByTool = new HashMap<>();
2083+
Map<String, String> completedSpanByTool = new HashMap<>();
2084+
Map<String, Object> row;
2085+
while ((row = state.getBatchProcessor("invocation_id").queue.poll()) != null) {
2086+
String tool = ((JsonNode) row.get("content")).get("tool").asText();
2087+
if ("TOOL_STARTING".equals(row.get("event_type"))) {
2088+
startingSpanByTool.put(tool, (String) row.get("span_id"));
2089+
} else if ("TOOL_COMPLETED".equals(row.get("event_type"))) {
2090+
completedSpanByTool.put(tool, (String) row.get("span_id"));
2091+
}
2092+
}
2093+
2094+
// Each tool's completion row references ITS OWN starting span, not the sibling's.
2095+
assertEquals(startingSpanByTool.get("tool_a"), completedSpanByTool.get("tool_a"));
2096+
assertEquals(startingSpanByTool.get("tool_b"), completedSpanByTool.get("tool_b"));
2097+
assertFalse(
2098+
"sibling id-less tools must not share a span",
2099+
startingSpanByTool.get("tool_a").equals(startingSpanByTool.get("tool_b")));
20372100
}
20382101

20392102
@Test

0 commit comments

Comments
 (0)