Skip to content

Commit 4b0a1f7

Browse files
authored
fix(server): reconcile blocking result with TaskStore (a2aproject#991)
* fix(server): reconcile blocking result with TaskStore - Make reconciliation timeout configurable, add logging - Keep the existing four-argument constructor delegating to the original one-second reconciliation timeout, and cover the new configuration wiring and default value. Fixes a2aproject/A2A#2049
1 parent 9bb5aa5 commit 4b0a1f7

8 files changed

Lines changed: 181 additions & 14 deletions

File tree

‎docs/content/configuration.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ a2a.blocking.agent.timeout.seconds=30
4040

4141
# Timeout for event consumption in blocking calls (default: 5 seconds)
4242
a2a.blocking.consumption.timeout.seconds=5
43+
44+
# Timeout for TaskStore reconciliation polling in blocking calls (default: 1 second)
45+
a2a.blocking.reconciliation.timeout.seconds=1
4346
```
4447

4548
### Tuning Guidelines
@@ -48,6 +51,7 @@ a2a.blocking.consumption.timeout.seconds=5
4851
- **Resource Management**: The dedicated executor prevents streaming operations from competing with the ForkJoinPool.
4952
- **Concurrency**: In production with high concurrent streaming, increase pool sizes accordingly.
5053
- **Agent Timeouts**: LLM-based agents may need longer timeouts (60-120s) compared to simple agents.
54+
- **Reconciliation Timeout**: Increase if blocking calls fail with "Could not find a Task/Message" under heavy load or with slow TaskStore implementations.
5155

5256
## MicroProfile Config Integration
5357

‎integrations/microprofile-config/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ a2a.executor.max-pool-size=100
3737
# Timeout configuration
3838
a2a.blocking.agent.timeout.seconds=60
3939
a2a.blocking.consumption.timeout.seconds=10
40+
a2a.blocking.reconciliation.timeout.seconds=2
4041
```
4142

4243
**Environment variables:**

‎integrations/microprofile-config/src/test/java/org/a2aproject/sdk/integrations/microprofile/MicroProfileConfigProviderTest.java‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ public class MicroProfileConfigProviderTest {
2424
private static final String A2A_EXECUTOR_CORE_POOL_SIZE = "a2a.executor.core-pool-size";
2525
private static final String A2A_EXECUTOR_MAX_POOL_SIZE = "a2a.executor.max-pool-size";
2626
private static final String A2A_EXECUTOR_KEEP_ALIVE_SECONDS = "a2a.executor.keep-alive-seconds";
27+
private static final String A2A_BLOCKING_RECONCILIATION_TIMEOUT_SECONDS =
28+
"a2a.blocking.reconciliation.timeout.seconds";
2729

2830
@Inject
2931
A2AConfigProvider configProvider;
@@ -59,6 +61,12 @@ public void testGetValueAnotherDefault() {
5961
assertEquals("60", value, "Should fall back to default value");
6062
}
6163

64+
@Test
65+
public void testGetReconciliationTimeoutDefault() {
66+
String value = configProvider.getValue(A2A_BLOCKING_RECONCILIATION_TIMEOUT_SECONDS);
67+
assertEquals("1", value, "Should fall back to the reconciliation timeout default");
68+
}
69+
6270
@Test
6371
public void testGetOptionalValueFromMicroProfileConfig() {
6472
// Test optional value that exists in application.properties

‎server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandler.java‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,9 @@
142142
* <li><b>Blocking (configuration.blocking=true):</b> Client waits for first event or final task state</li>
143143
* <li><b>Streaming:</b> Client receives events as they arrive via reactive streams</li>
144144
* <li>Both modes support fire-and-forget (agent continues after client disconnect)</li>
145-
* <li>Configurable timeouts via {@code a2a.blocking.agent.timeout.seconds} and
146-
* {@code a2a.blocking.consumption.timeout.seconds}</li>
145+
* <li>Configurable timeouts via {@code a2a.blocking.agent.timeout.seconds},
146+
* {@code a2a.blocking.consumption.timeout.seconds}, and
147+
* {@code a2a.blocking.reconciliation.timeout.seconds}</li>
147148
* </ul>
148149
*
149150
* <h2>CDI Dependencies</h2>
@@ -189,6 +190,7 @@ public class DefaultRequestHandler implements RequestHandler {
189190

190191
private static final String A2A_BLOCKING_AGENT_TIMEOUT_SECONDS = "a2a.blocking.agent.timeout.seconds";
191192
private static final String A2A_BLOCKING_CONSUMPTION_TIMEOUT_SECONDS = "a2a.blocking.consumption.timeout.seconds";
193+
private static final String A2A_BLOCKING_RECONCILIATION_TIMEOUT_SECONDS = "a2a.blocking.reconciliation.timeout.seconds";
192194

193195
@Inject
194196
A2AConfigProvider configProvider;
@@ -215,6 +217,19 @@ public class DefaultRequestHandler implements RequestHandler {
215217
*/
216218
int consumptionCompletionTimeoutSeconds;
217219

220+
/**
221+
* Timeout in seconds for TaskStore reconciliation polling in blocking calls.
222+
* When the in-memory event capture is empty, the aggregator polls TaskStore
223+
* with a bounded timeout to handle the race where MainEventBusProcessor
224+
* has not yet persisted the task.
225+
* <p>
226+
* Property: {@code a2a.blocking.reconciliation.timeout.seconds}<br>
227+
* Default: 1 second<br>
228+
* Note: Property override requires a configurable {@link A2AConfigProvider} on the classpath
229+
* (e.g., MicroProfileConfigProvider in reference implementations).
230+
*/
231+
int reconciliationTimeoutSeconds;
232+
218233
// Fields set by constructor injection cannot be final. We need a noargs constructor for
219234
// Jakarta compatibility, and it seems that making fields set by constructor injection
220235
// final, is not proxyable in all runtimes
@@ -276,6 +291,8 @@ void initConfig() {
276291
configProvider.getValue(A2A_BLOCKING_AGENT_TIMEOUT_SECONDS));
277292
consumptionCompletionTimeoutSeconds = Integer.parseInt(
278293
configProvider.getValue(A2A_BLOCKING_CONSUMPTION_TIMEOUT_SECONDS));
294+
reconciliationTimeoutSeconds = Integer.parseInt(
295+
configProvider.getValue(A2A_BLOCKING_RECONCILIATION_TIMEOUT_SECONDS));
279296
}
280297

281298

@@ -291,6 +308,7 @@ public static DefaultRequestHandler create(AgentExecutor agentExecutor, TaskStor
291308
mainEventBusProcessor, executor, eventConsumerExecutor);
292309
handler.agentCompletionTimeoutSeconds = 5;
293310
handler.consumptionCompletionTimeoutSeconds = 2;
311+
handler.reconciliationTimeoutSeconds = 1;
294312

295313
return handler;
296314
}
@@ -377,7 +395,8 @@ public Task onCancelTask(CancelTaskParams params, ServerCallContext context) thr
377395
taskStore,
378396
null);
379397

380-
ResultAggregator resultAggregator = new ResultAggregator(taskManager, null, executor, eventConsumerExecutor);
398+
ResultAggregator resultAggregator = new ResultAggregator(taskManager, null, executor, eventConsumerExecutor,
399+
SECONDS.toNanos(reconciliationTimeoutSeconds));
381400

382401
EventQueue queue = queueManager.createOrTap(task.id());
383402
EventConsumer consumer = new EventConsumer(queue, eventConsumerExecutor);
@@ -450,7 +469,8 @@ public EventKind onMessageSend(MessageSendParams params, ServerCallContext conte
450469
// Create queue with real taskId (no tempId parameter needed)
451470
EventQueue queue = queueManager.createOrTap(queueTaskId);
452471
final java.util.concurrent.atomic.AtomicReference<@NonNull String> taskId = new java.util.concurrent.atomic.AtomicReference<>(queueTaskId);
453-
ResultAggregator resultAggregator = new ResultAggregator(mss.taskManager, null, executor, eventConsumerExecutor);
472+
ResultAggregator resultAggregator = new ResultAggregator(mss.taskManager, null, executor, eventConsumerExecutor,
473+
SECONDS.toNanos(reconciliationTimeoutSeconds));
454474

455475
// Default to blocking per A2A spec (returnImmediately defaults to false, meaning wait for completion)
456476
boolean returnImmediately = params.configuration() != null && Boolean.TRUE.equals(params.configuration().returnImmediately());
@@ -668,7 +688,8 @@ public Flow.Publisher<StreamingEventKind> onMessageSendStream(
668688
.taskId(taskId.get()).build(), version);
669689
}
670690

671-
ResultAggregator resultAggregator = new ResultAggregator(mss.taskManager, null, executor, eventConsumerExecutor);
691+
ResultAggregator resultAggregator = new ResultAggregator(mss.taskManager, null, executor, eventConsumerExecutor,
692+
SECONDS.toNanos(reconciliationTimeoutSeconds));
672693

673694
// Create consumer BEFORE starting agent - callback is registered inside registerAndExecuteAgentAsync
674695
EventConsumer consumer = new EventConsumer(queue, eventConsumerExecutor);
@@ -857,7 +878,8 @@ public Flow.Publisher<StreamingEventKind> onSubscribeToTask(TaskIdParams params,
857878
}
858879

859880
TaskManager taskManager = new TaskManager(task.id(), task.contextId(), taskStore, null);
860-
ResultAggregator resultAggregator = new ResultAggregator(taskManager, null, executor, eventConsumerExecutor);
881+
ResultAggregator resultAggregator = new ResultAggregator(taskManager, null, executor, eventConsumerExecutor,
882+
SECONDS.toNanos(reconciliationTimeoutSeconds));
861883
EventQueue queue = queueManager.tap(task.id());
862884
LOGGER.debug("onSubscribeToTask - tapped queue: {}", queue != null ? System.identityHashCode(queue) : "null");
863885

‎server-common/src/main/java/org/a2aproject/sdk/server/tasks/ResultAggregator.java‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import java.util.concurrent.CompletionException;
99
import java.util.concurrent.Executor;
1010
import java.util.concurrent.Flow;
11+
import java.util.concurrent.TimeUnit;
1112
import java.util.concurrent.atomic.AtomicBoolean;
1213
import java.util.concurrent.atomic.AtomicReference;
1314

@@ -28,17 +29,21 @@
2829

2930
public class ResultAggregator {
3031
private static final Logger LOGGER = LoggerFactory.getLogger(ResultAggregator.class);
32+
private static final long TASK_STORE_RECONCILIATION_POLL_MILLIS = 10;
3133

3234
private final TaskManager taskManager;
3335
private final Executor executor;
3436
private final Executor eventConsumerExecutor;
37+
private final long reconciliationTimeoutNanos;
3538
private volatile @Nullable Message message;
3639

37-
public ResultAggregator(TaskManager taskManager, @Nullable Message message, Executor executor, Executor eventConsumerExecutor) {
40+
public ResultAggregator(TaskManager taskManager, @Nullable Message message, Executor executor,
41+
Executor eventConsumerExecutor, long reconciliationTimeoutNanos) {
3842
this.taskManager = taskManager;
3943
this.message = message;
4044
this.executor = executor;
4145
this.eventConsumerExecutor = eventConsumerExecutor;
46+
this.reconciliationTimeoutNanos = reconciliationTimeoutNanos;
4247
}
4348

4449
public @Nullable EventKind getCurrentResult() {
@@ -235,7 +240,8 @@ else if (blocking) {
235240
Utils.rethrow(error);
236241
}
237242

238-
// Return Message if captured, otherwise Task if captured, otherwise fetch from TaskStore
243+
// Return Message if captured, otherwise Task if captured,
244+
// otherwise poll TaskStore with bounded timeout (blocking) or single read (non-blocking).
239245
EventKind eventKind = message.get();
240246
if (eventKind == null) {
241247
eventKind = capturedTask.get();
@@ -244,7 +250,7 @@ else if (blocking) {
244250
}
245251
}
246252
if (eventKind == null) {
247-
eventKind = taskManager.getTask();
253+
eventKind = reconcileTaskStore(blocking);
248254
if (LOGGER.isDebugEnabled() && eventKind instanceof Task t) {
249255
LOGGER.debug("Returning task from TaskStore: id={}, state={}", t.id(), t.status().state());
250256
}
@@ -259,6 +265,33 @@ else if (blocking) {
259265
consumptionCompletionFuture);
260266
}
261267

268+
private @Nullable Task reconcileTaskStore(boolean blocking) throws A2AError {
269+
Task task = taskManager.getTask();
270+
if (task != null || !blocking) {
271+
return task;
272+
}
273+
274+
LOGGER.debug("TaskStore reconciliation: task not found on first read, polling for up to {}ms",
275+
TimeUnit.NANOSECONDS.toMillis(reconciliationTimeoutNanos));
276+
277+
long deadline = System.nanoTime() + reconciliationTimeoutNanos;
278+
while (System.nanoTime() < deadline) {
279+
try {
280+
Thread.sleep(TASK_STORE_RECONCILIATION_POLL_MILLIS);
281+
} catch (InterruptedException e) {
282+
Thread.currentThread().interrupt();
283+
throw new InternalError("Interrupted while reconciling TaskStore for " + taskManager.getTaskId());
284+
}
285+
286+
task = taskManager.getTask();
287+
if (task != null) {
288+
LOGGER.debug("TaskStore reconciliation: task {} found after polling", task.id());
289+
return task;
290+
}
291+
}
292+
return null;
293+
}
294+
262295
private String taskIdForLogging() {
263296
Task task = taskManager.getTask();
264297
return task != null ? task.id() : "unknown";

‎server-common/src/main/resources/META-INF/a2a-defaults.properties‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ a2a.blocking.agent.timeout.seconds=30
1010
# Ensures TaskStore is fully updated before returning to client
1111
a2a.blocking.consumption.timeout.seconds=5
1212

13+
# Timeout for TaskStore reconciliation polling in blocking calls (seconds)
14+
# When in-memory event capture is empty, polls TaskStore with this bounded timeout
15+
a2a.blocking.reconciliation.timeout.seconds=1
16+
1317
# AsyncExecutorProducer - Thread pool configuration
1418
# Core pool size for async agent execution
1519
a2a.executor.core-pool-size=5

‎server-common/src/test/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandlerTest.java‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import static org.junit.jupiter.api.Assertions.assertNotNull;
77
import static org.junit.jupiter.api.Assertions.assertThrows;
88
import static org.junit.jupiter.api.Assertions.assertTrue;
9+
import static org.mockito.Mockito.mock;
10+
import static org.mockito.Mockito.when;
911

1012
import java.util.List;
1113
import java.util.Map;
@@ -20,6 +22,7 @@
2022
import org.a2aproject.sdk.server.ServerCallContext;
2123
import org.a2aproject.sdk.server.agentexecution.AgentExecutor;
2224
import org.a2aproject.sdk.server.agentexecution.RequestContext;
25+
import org.a2aproject.sdk.server.config.A2AConfigProvider;
2326
import org.a2aproject.sdk.server.events.EventQueue;
2427
import org.a2aproject.sdk.server.events.EventQueueItem;
2528
import org.a2aproject.sdk.server.events.EventQueueUtil;
@@ -146,6 +149,22 @@ protected interface AgentExecutorMethod {
146149
void invoke(RequestContext context, AgentEmitter agentEmitter) throws A2AError;
147150
}
148151

152+
@Test
153+
void testInitConfigReadsBlockingTimeouts() {
154+
A2AConfigProvider configProvider = mock(A2AConfigProvider.class);
155+
when(configProvider.getValue("a2a.blocking.agent.timeout.seconds")).thenReturn("30");
156+
when(configProvider.getValue("a2a.blocking.consumption.timeout.seconds")).thenReturn("5");
157+
when(configProvider.getValue("a2a.blocking.reconciliation.timeout.seconds")).thenReturn("7");
158+
159+
DefaultRequestHandler handler = new DefaultRequestHandler();
160+
handler.configProvider = configProvider;
161+
handler.initConfig();
162+
163+
assertEquals(30, handler.agentCompletionTimeoutSeconds);
164+
assertEquals(5, handler.consumptionCompletionTimeoutSeconds);
165+
assertEquals(7, handler.reconciliationTimeoutSeconds);
166+
}
167+
149168
/**
150169
* Test 1: Non-streaming AUTH_REQUIRED returns immediately while agent continues.
151170
* Verifies:

0 commit comments

Comments
 (0)