Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,4 @@ nbproject/
.serena/
.bob/
claudedocs
backlog/
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.PersistenceException;
import jakarta.persistence.TypedQuery;
import jakarta.transaction.Transactional;

Expand All @@ -22,6 +23,8 @@
import io.a2a.server.config.A2AConfigProvider;
import io.a2a.server.tasks.TaskStateProvider;
import io.a2a.server.tasks.TaskStore;
import io.a2a.server.tasks.TaskSerializationException;
import io.a2a.server.tasks.TaskPersistenceException;
import io.a2a.spec.Artifact;
import io.a2a.spec.ListTasksParams;
import io.a2a.spec.Message;
Expand Down Expand Up @@ -63,6 +66,7 @@ void initConfig() {
gracePeriodSeconds = Long.parseLong(configProvider.getValue(A2A_REPLICATION_GRACE_PERIOD_SECONDS));
}


@Transactional
@Override
public void save(Task task, boolean isReplicated) {
Expand All @@ -85,40 +89,59 @@ public void save(Task task, boolean isReplicated) {
}
} catch (JsonProcessingException e) {
LOGGER.error("Failed to serialize task with ID: {}", task.id(), e);
throw new RuntimeException("Failed to serialize task with ID: " + task.id(), e);
throw new TaskSerializationException(task.id(),
"Failed to serialize task for persistence", e);
} catch (PersistenceException e) {
LOGGER.error("Database save failed for task with ID: {}", task.id(), e);
throw new TaskPersistenceException(task.id(),
"Database save failed for task", e);
}
}

@Transactional
@Override
public Task get(String taskId) {
LOGGER.debug("Retrieving task with ID: {}", taskId);
JpaTask jpaTask = em.find(JpaTask.class, taskId);
if (jpaTask == null) {
LOGGER.debug("Task not found with ID: {}", taskId);
return null;
}

try {
Task task = jpaTask.getTask();
LOGGER.debug("Successfully retrieved task with ID: {}", taskId);
return task;
} catch (JsonProcessingException e) {
LOGGER.error("Failed to deserialize task with ID: {}", taskId, e);
throw new RuntimeException("Failed to deserialize task with ID: " + taskId, e);
JpaTask jpaTask = em.find(JpaTask.class, taskId);
if (jpaTask == null) {
LOGGER.debug("Task not found with ID: {}", taskId);
return null;
}

try {
Task task = jpaTask.getTask();
LOGGER.debug("Successfully retrieved task with ID: {}", taskId);
return task;
} catch (JsonProcessingException e) {
LOGGER.error("Failed to deserialize task with ID: {}", taskId, e);
throw new TaskSerializationException(taskId,
"Failed to deserialize task from database", e);
}

} catch (PersistenceException e) {
LOGGER.error("Database retrieval failed for task with ID: {}", taskId, e);
throw new TaskPersistenceException(taskId,
"Database retrieval failed for task", e);
}
}

@Transactional
@Override
public void delete(String taskId) {
LOGGER.debug("Deleting task with ID: {}", taskId);
JpaTask jpaTask = em.find(JpaTask.class, taskId);
if (jpaTask != null) {
em.remove(jpaTask);
LOGGER.debug("Successfully deleted task with ID: {}", taskId);
} else {
LOGGER.debug("Task not found for deletion with ID: {}", taskId);
try {
JpaTask jpaTask = em.find(JpaTask.class, taskId);
if (jpaTask != null) {
em.remove(jpaTask);
LOGGER.debug("Successfully deleted task with ID: {}", taskId);
} else {
LOGGER.debug("Task not found for deletion with ID: {}", taskId);
}
} catch (PersistenceException e) {
LOGGER.error("Database deletion failed for task with ID: {}", taskId, e);
throw new TaskPersistenceException(taskId,
"Database deletion failed for task", e);
}
}

Expand Down Expand Up @@ -231,14 +254,15 @@ public ListTasksResult list(ListTasksParams params) {
LOGGER.debug("Listing tasks with params: contextId={}, status={}, pageSize={}, pageToken={}",
params.contextId(), params.status(), params.pageSize(), params.pageToken());

// Parse pageToken once at the beginning
PageToken pageToken = PageToken.fromString(params.pageToken());
Instant tokenTimestamp = pageToken != null ? pageToken.timestamp() : null;
String tokenId = pageToken != null ? pageToken.id() : null;
try {
// Parse pageToken once at the beginning
PageToken pageToken = PageToken.fromString(params.pageToken());
Instant tokenTimestamp = pageToken != null ? pageToken.timestamp() : null;
String tokenId = pageToken != null ? pageToken.id() : null;

// Build dynamic JPQL query with WHERE clauses for filtering
StringBuilder queryBuilder = new StringBuilder("SELECT t FROM JpaTask t WHERE 1=1");
StringBuilder countQueryBuilder = new StringBuilder("SELECT COUNT(t) FROM JpaTask t WHERE 1=1");
// Build dynamic JPQL query with WHERE clauses for filtering
StringBuilder queryBuilder = new StringBuilder("SELECT t FROM JpaTask t WHERE 1=1");
StringBuilder countQueryBuilder = new StringBuilder("SELECT COUNT(t) FROM JpaTask t WHERE 1=1");

// Apply contextId filter using denormalized column
if (params.contextId() != null) {
Expand Down Expand Up @@ -311,16 +335,17 @@ public ListTasksResult list(ListTasksParams params) {
}
int totalSize = countQuery.getSingleResult().intValue();

// Deserialize tasks from JSON
List<Task> tasks = new ArrayList<>();
for (JpaTask jpaTask : jpaTasksPage) {
try {
tasks.add(jpaTask.getTask());
} catch (JsonProcessingException e) {
LOGGER.error("Failed to deserialize task with ID: {}", jpaTask.getId(), e);
throw new RuntimeException("Failed to deserialize task with ID: " + jpaTask.getId(), e);
// Deserialize tasks from JSON
List<Task> tasks = new ArrayList<>();
for (JpaTask jpaTask : jpaTasksPage) {
try {
tasks.add(jpaTask.getTask());
} catch (JsonProcessingException e) {
LOGGER.error("Failed to deserialize task with ID: {}", jpaTask.getId(), e);
throw new TaskSerializationException(jpaTask.getId(),
"Failed to deserialize task during list operation", e);
}
}
}

// Determine next page token (timestamp:ID of last task if there are more results)
// Format: "timestamp_millis:taskId" for keyset pagination
Expand All @@ -336,12 +361,22 @@ public ListTasksResult list(ListTasksParams params) {
int historyLength = params.getEffectiveHistoryLength();
boolean includeArtifacts = params.shouldIncludeArtifacts();

List<Task> transformedTasks = tasks.stream()
.map(task -> transformTask(task, historyLength, includeArtifacts))
.toList();

LOGGER.debug("Returning {} tasks out of {} total", transformedTasks.size(), totalSize);
return new ListTasksResult(transformedTasks, totalSize, transformedTasks.size(), nextPageToken);
List<Task> transformedTasks = tasks.stream()
.map(task -> transformTask(task, historyLength, includeArtifacts))
.toList();

LOGGER.debug("Returning {} tasks out of {} total", transformedTasks.size(), totalSize);
return new ListTasksResult(transformedTasks, totalSize, transformedTasks.size(), nextPageToken);

} catch (TaskSerializationException e) {
// Re-throw TaskSerializationException from inner loop
throw e;
} catch (PersistenceException e) {
Comment thread
kabir marked this conversation as resolved.
Outdated
// Database errors from query creation, execution, or count
LOGGER.error("Database query failed during list operation", e);
throw new TaskPersistenceException(null, // No single taskId for list operation
"Database query failed during list operation", e);
}
}

private Task transformTask(Task task, int historyLength, boolean includeArtifacts) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

import io.a2a.server.tasks.PushNotificationSender;
import io.a2a.server.tasks.TaskManager;
import io.a2a.server.tasks.TaskPersistenceException;
import io.a2a.server.tasks.TaskSerializationException;
import io.a2a.server.tasks.TaskStore;
import io.a2a.spec.A2AError;
import io.a2a.spec.A2AServerException;
Expand Down Expand Up @@ -41,6 +43,18 @@
* <b>Note:</b> This bean is eagerly initialized by {@link MainEventBusProcessorInitializer}
* to ensure the background thread starts automatically when the application starts.
* </p>
*
* <h2>Exception Handling</h2>
* TaskStore persistence failures are caught and handled gracefully:
* <ul>
* <li>{@link TaskSerializationException} - Data corruption or schema mismatch.
* Logged at ERROR level, distributed as {@link InternalError} to clients.</li>
* <li>{@link TaskPersistenceException} - Database/storage system failure.
* Logged at ERROR level, distributed as {@link InternalError} to clients.</li>
* </ul>
*
* <p>Processing continues after errors - the failed event is distributed as InternalError
* to all ChildQueues, and the MainEventBusProcessor continues consuming subsequent events.</p>
*/
@ApplicationScoped
public class MainEventBusProcessor implements Runnable {
Expand Down Expand Up @@ -293,11 +307,26 @@ private boolean updateTaskStore(String taskId, Event event, boolean isReplicated
LOGGER.debug("TaskStore updated via TaskManager.process() for task {}: {} (final: {}, replicated: {})",
taskId, event.getClass().getSimpleName(), isFinal, isReplicated);
return isFinal;

} catch (TaskSerializationException e) {
// Data corruption or schema mismatch - ALWAYS permanent
LOGGER.error("Task {} event serialization failed - data corruption detected: {}",
taskId, e.getMessage(), e);
throw new InternalError("Failed to serialize task " + taskId + ": " + e.getMessage());

} catch (TaskPersistenceException e) {
// Database/storage failure
LOGGER.error("Task {} event persistence failed: {}", taskId, e.getMessage(), e);
throw new InternalError("Storage failure for task " + taskId + ": " + e.getMessage());

} catch (InternalError e) {
// Already an InternalError from TaskManager validation - pass through
LOGGER.error("Error updating TaskStore via TaskManager for task {}", taskId, e);
// Rethrow to prevent distributing unpersisted event to clients
throw e;

} catch (Exception e) {
// Unexpected exception type - treat as permanent failure
LOGGER.error("Unexpected error updating TaskStore for task {}", taskId, e);
// Rethrow to prevent distributing unpersisted event to clients
throw new InternalError("TaskStore persistence failed: " + e.getMessage());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,62 @@
* <p>
* This is the default TaskStore used when no other implementation is provided.
* </p>
*
* <h2>Exception Behavior</h2>
* InMemoryTaskStore has minimal exception scenarios compared to database-backed implementations:
* <ul>
* <li><b>No TaskSerializationException:</b> Task objects are stored directly in memory without
* serialization. No JSON parsing or schema compatibility issues can occur.</li>
* <li><b>No TaskPersistenceException:</b> ConcurrentHashMap operations do not involve I/O,
* network, or transactional concerns. Standard put/get/remove operations are guaranteed
* to succeed under normal JVM operation.</li>
* <li><b>OutOfMemoryError (potential):</b> The only failure scenario is JVM heap exhaustion if
* too many tasks are stored. This is an {@link Error} (not Exception) and indicates a fatal
* system condition requiring JVM restart and capacity planning.</li>
* </ul>
*
* <h3>Design Rationale</h3>
* This implementation intentionally does NOT throw {@link TaskStoreException} or its subclasses
* because:
* <ul>
* <li>No serialization step exists - tasks stored as Java objects</li>
* <li>No I/O or network operations that can fail</li>
* <li>ConcurrentHashMap guarantees thread-safe operations without checked exceptions</li>
* <li>Memory exhaustion (OutOfMemoryError) is an unrecoverable system failure</li>
* </ul>
*
* <h3>Comparison to Database Implementations</h3>
* Database-backed implementations (e.g., JpaDatabaseTaskStore) throw exceptions for:
* <ul>
* <li>Serialization errors (JSON parsing, schema mismatches)</li>
* <li>Connection failures (network, timeouts)</li>
* <li>Transaction failures (deadlocks, constraint violations)</li>
* <li>Capacity issues (disk full, quota exceeded)</li>
* </ul>
* InMemoryTaskStore avoids all of these by operating entirely in-process.
*
* <h3>Memory Management Considerations</h3>
* Callers should monitor memory usage and implement task cleanup policies:
* <pre>{@code
* // Example: Delete finalized tasks older than 48 hours
* ListTasksParams params = new ListTasksParams.Builder()
* .statusTimestampBefore(Instant.now().minus(Duration.ofHours(48)))
* .build();
*
* List<Task> oldTasks = taskStore.list(params).tasks();
* oldTasks.stream()
* .filter(task -> task.status().state().isFinal())
* .forEach(task -> taskStore.delete(task.id()));
* }</pre>
*
* <h3>Thread Safety</h3>
* All operations are thread-safe via {@link ConcurrentHashMap}. Multiple threads can
* concurrently save, get, list, and delete tasks without synchronization. Last-write-wins
* semantics apply for concurrent {@code save()} calls to the same task ID.
*
* @see TaskStore for interface contract and exception documentation
* @see TaskStoreException for exception hierarchy (not thrown by this implementation)
* @see TaskStateProvider for queue lifecycle integration
*/
@ApplicationScoped
public class InMemoryTaskStore implements TaskStore, TaskStateProvider {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@
* Implementations should handle errors gracefully:
* <ul>
* <li>Log failures but don't throw exceptions (notifications are best-effort)</li>
* <li>Consider retry logic for transient failures</li>
* <li>Don't block on network I/O - execute asynchronously if needed</li>
* <li>Circuit breaker patterns for repeatedly failing endpoints</li>
* </ul>
Expand Down
Loading
Loading