Skip to content

Commit 73c6233

Browse files
caohy1988claude
andcommitted
fix(bigquery): address round-3 review — atomic append admission, single close-owned drain deadline
Resolves both round-3 P1 findings on PR google#1344: 1. Append admission is now atomic with lifecycle finalization: InvocationLifecycle.runIfActive executes the processor append inside the token's monitor, shared with markFinalized. A continuation that passed the processor-map gate can no longer get descheduled across finalization and then offer into a drained queue or record an after_close drop that the already-delivered final snapshot misses; finalization now waits for any in-flight admission (a short, non-blocking-offer critical section), and post-finalization admissions are refused and counted at the plugin level. 2. The explicit pre-close flush in invocation finalization is removed: it ran before close() published its deadline and consumed a separate full append budget, doubling the effective drain bound with multiple queued batches. closeAndFold now owns the complete drain under one shutdownTimeout deadline. Cleanup per review: the stale tombstone-residual javadoc on getOrCreateProcessorIfActive now describes the durable-token guarantee, and the TraceManager class javadoc no longer claims within-branch execution is sequential (tools run concurrently and use operation- identity spans). Tests: finalization blocks on an in-flight admission and refuses the next one (latch-based interleaving), and a two-batch never-completing drain finishes within a single shutdownTimeout bound. Focused BQAA suite 165 passed (previous: 163); full core 1,621 passed / 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2cdd638 commit 73c6233

3 files changed

Lines changed: 122 additions & 16 deletions

File tree

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

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,15 +89,36 @@ class PluginState {
8989
private final ConcurrentHashMap<String, InvocationLifecycle> lifecycles =
9090
new ConcurrentHashMap<>();
9191

92-
/** Terminal-state token for one invocation; see {@link #lifecycles}. */
92+
/**
93+
* Terminal-state token for one invocation; see {@link #lifecycles}.
94+
*
95+
* <p>Admission and finalization share this token's monitor: {@link #runIfActive} executes an
96+
* append admission atomically against {@link #markFinalized}, so a continuation cannot pass the
97+
* gate, get descheduled across finalization (processor close + final stats delivery), and then
98+
* append into a torn-down processor or record a loss the final snapshot has already missed.
99+
* Critical sections are short: the append side only holds the monitor through a non-blocking
100+
* queue offer.
101+
*/
93102
static final class InvocationLifecycle {
94-
private volatile boolean finalized;
103+
private boolean finalized;
104+
105+
/**
106+
* Runs {@code action} iff the invocation is not finalized, atomically with {@link
107+
* #markFinalized}. Returns whether the action ran.
108+
*/
109+
synchronized boolean runIfActive(Runnable action) {
110+
if (finalized) {
111+
return false;
112+
}
113+
action.run();
114+
return true;
115+
}
95116

96-
void markFinalized() {
117+
synchronized void markFinalized() {
97118
finalized = true;
98119
}
99120

100-
boolean isFinalized() {
121+
synchronized boolean isFinalized() {
101122
return finalized;
102123
}
103124
}
@@ -269,7 +290,18 @@ void appendRow(InvocationLifecycle lifecycle, String invocationId, Map<String, O
269290
"Dropping late analytics row: invocation " + invocationId + " is already finalized.");
270291
return;
271292
}
272-
processor.append(row);
293+
// Admit the row atomically with finalization: without this, a continuation could pass the
294+
// gate above, get descheduled while finalization closes the processor and delivers its final
295+
// stats, and then either offer into a drained queue or record an after_close drop the folded
296+
// snapshot has already missed.
297+
boolean admitted = lifecycle.runIfActive(() -> processor.append(row));
298+
if (!admitted) {
299+
droppedAfterFinalize.incrementAndGet();
300+
logger.warning(
301+
"Dropping late analytics row: invocation "
302+
+ invocationId
303+
+ " finalized during admission.");
304+
}
273305
}
274306

275307
/**
@@ -285,9 +317,9 @@ void appendRow(InvocationLifecycle lifecycle, String invocationId, Map<String, O
285317
* finalization subsequently removes and closes that same processor, and the late row is accounted
286318
* by {@link BatchProcessor#append}'s closed gate.
287319
*
288-
* <p>Residual limitation: the processed-invocations tombstone cache is bounded (size and TTL), so
289-
* a continuation arriving after its tombstone was evicted could still recreate a processor; such
290-
* processors are drained and closed at plugin-wide shutdown.
320+
* <p>Cache eviction is not a correctness hole: every continuation checks its captured durable
321+
* {@link InvocationLifecycle} token first, which — unlike the bounded tombstone cache — cannot be
322+
* evicted, so a finalized invocation's processor cannot be resurrected regardless of cache state.
291323
*/
292324
private @Nullable BatchProcessor getOrCreateProcessorIfActive(
293325
InvocationLifecycle lifecycle, String invocationId) {
@@ -405,8 +437,10 @@ Completable ensureInvocationCompleted(String invocationId) {
405437
markProcessed(invocationId);
406438
BatchProcessor processor = removeProcessor(invocationId);
407439
if (processor != null) {
408-
processor.flush();
409-
// Fold via the teardown callback: it fires when teardown ACTUALLY completes
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
410444
// (possibly after close() returns, if an in-flight flush owns the resources past
411445
// the deadline), so counters recorded by that last flush are never lost.
412446
processor.closeAndFold(this::foldStats);

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,15 @@
4444
* Ambient OpenTelemetry context is still consulted for the {@code trace_id} (and the invocation
4545
* root's {@code span_id}) so BigQuery rows stay joinable to Cloud Trace.
4646
*
47-
* <p>Span records are kept in per-branch stacks keyed by {@link InvocationContext#branch()}. Within
48-
* one branch execution is sequential, so per-branch LIFO is sound; concurrently scheduled {@code
49-
* ParallelAgent} branches (which share an invocation ID but carry distinct branch strings) never
50-
* touch each other's stacks, so a branch completing first can no longer pop another branch's span.
51-
* Pops additionally verify the record's {@code kind}, so an error callback firing without its
52-
* matching push cannot pop an unrelated record.
47+
* <p>Span records are kept in per-branch stacks keyed by {@link InvocationContext#branch()}.
48+
* Concurrently scheduled {@code ParallelAgent} branches (which share an invocation ID but carry
49+
* distinct branch strings) never touch each other's stacks, so a branch completing first can no
50+
* longer pop another branch's span. Within one branch, agent and model spans execute sequentially
51+
* and use top-of-stack semantics, but ADK executes an event's function calls CONCURRENTLY by
52+
* default: tool spans therefore carry an operation identity (the function-call ID) plus a parent
53+
* captured at push time, and are popped by identity rather than stack position. Pops additionally
54+
* verify the record's {@code kind}, so an error callback firing without its matching push cannot
55+
* pop an unrelated record.
5356
*/
5457
public final class TraceManager {
5558
private static final Logger logger = Logger.getLogger(TraceManager.class.getName());

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,4 +408,73 @@ public void manyCompletedInvocations_leaveNoRetainedProcessorsOrTraceManagers()
408408
assertTrue(pluginState.getBatchProcessors().isEmpty());
409409
assertTrue(pluginState.getTraceManagers().isEmpty());
410410
}
411+
412+
@Test
413+
public void appendRow_admissionIsAtomicWithFinalization() throws Exception {
414+
String invocationId = "inv-atomic";
415+
PluginState.InvocationLifecycle lifecycle = pluginState.getLifecycle(invocationId);
416+
417+
// Model a continuation inside the admission critical section (post token-gate, mid-append):
418+
// finalization must block on the token monitor until the admission completes, so the admitted
419+
// row is drained by close rather than stranded or double-counted after the final snapshot.
420+
java.util.concurrent.CountDownLatch inAdmission = new java.util.concurrent.CountDownLatch(1);
421+
Thread admitting =
422+
new Thread(
423+
() ->
424+
lifecycle.runIfActive(
425+
() -> {
426+
inAdmission.countDown();
427+
Uninterruptibles.sleepUninterruptibly(java.time.Duration.ofMillis(300));
428+
}));
429+
admitting.start();
430+
assertTrue(inAdmission.await(2, TimeUnit.SECONDS));
431+
432+
long start = System.nanoTime();
433+
pluginState.ensureInvocationCompleted(invocationId).blockingAwait();
434+
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
435+
admitting.join(2000);
436+
437+
assertTrue(
438+
"finalization must wait for the in-flight admission, took " + elapsedMs + "ms",
439+
elapsedMs >= 250);
440+
441+
// A continuation arriving after finalization is refused atomically and accounted.
442+
pluginState.appendRow(lifecycle, invocationId, ImmutableMap.of("event_type", "LLM_REQUEST"));
443+
assertEquals(1L, (long) pluginState.getDropStats().get("late_after_finalize"));
444+
}
445+
446+
@Test
447+
public void ensureInvocationCompleted_multiBatchDrain_boundedByOneShutdownTimeout()
448+
throws IOException {
449+
// Every queued batch must drain under ONE close-owned deadline; a pre-close flush would grant
450+
// the first batch a separate full append budget, doubling the effective bound.
451+
config = config.toBuilder().shutdownTimeout(Duration.ofMillis(500)).build();
452+
TestPluginState slowState =
453+
new TestPluginState(config) {
454+
@Override
455+
protected StreamWriter createWriter() {
456+
StreamWriter writer = mock(StreamWriter.class);
457+
// Appends never complete in time; each get() must be capped by the REMAINING budget.
458+
when(writer.append(any(ArrowRecordBatch.class)))
459+
.thenReturn(com.google.api.core.SettableApiFuture.create());
460+
return writer;
461+
}
462+
};
463+
String invocationId = "inv-multibatch";
464+
BatchProcessor processor = slowState.getBatchProcessor(invocationId);
465+
java.util.Map<String, Object> row1 = new java.util.HashMap<>();
466+
row1.put("event_type", "A");
467+
java.util.Map<String, Object> row2 = new java.util.HashMap<>();
468+
row2.put("event_type", "B");
469+
processor.queue.offer(row1);
470+
processor.queue.offer(row2);
471+
472+
long start = System.nanoTime();
473+
slowState.ensureInvocationCompleted(invocationId).blockingAwait();
474+
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
475+
476+
assertTrue(
477+
"multi-batch drain must fit one shutdownTimeout bound, took " + elapsedMs + "ms",
478+
elapsedMs < 1_200);
479+
}
411480
}

0 commit comments

Comments
 (0)