Sponsored Content

DEV Community

Muhammad Hammad
Muhammad Hammad

Posted on

Architectural Breakdown: What 100% Test Coverage Missed: State Across Google ADK A2A Boundaries

What 100% Test Coverage Missed: State Across Google ADK A2A Boundaries

Architecture Diagram

We had 100% test coverage. Every line of code was exercised. Every edge case was mocked. The CI pipeline was green for 90 days straight. Then we deployed to production and watched our agents lose state like a sieve.

This is not a story about testing. It is about the lies we tell ourselves in development.

The Illusion of In-Process Testing

Our test suite ran all agents in a single process, where state was passed as Python objects via memory references. The tests assumed that if Agent A modified a ConversationContext, Agent B would see the change because they shared the same heap.

Production was different.

Google ADK’s A2A communication serializes state across process boundaries. If an object was not JSON-serializable (and many of ours were not), ADK silently dropped it. No error. No warning. Just None.

The first sign of trouble was a spike in NoneType errors. Users reported conversations resetting mid-flow. Our stateful agents were suddenly stateless.

This is the kind of failure that 100% test coverage misses because the tests never left the process.

The A2A Boundary Problem: Three Violations

Google ADK’s documentation states that A2A assumes all state is either explicitly serialized or reconstructed on the remote side. In-memory references do not persist across workers.

Our architecture violated this in three ways:

  1. Non-Serializable Objects
    We used custom classes with file handles, lambda functions and circular references, none of which are JSON-serializable.

  2. Implicit State Sharing
    We assumed passing an object reference was enough. It was not.

  3. Race Conditions in Async Message Passing
    State could arrive out of order or not at all, leading to inconsistent agent views.

These were not edge cases. They were fundamental misalignments between our assumptions and ADK’s reality.

The Fix: Serialization, Reconstruction and Race Condition Resilience

1. Strict Serialization with Fallbacks and Size Limits

We implemented a zero-dependency state serializer that enforces JSON compatibility, with fallbacks to pickle or cloudpickle, but with strict size limits to prevent 8GB RAM overload.

import json
import pickle
import cloudpickle
from dataclasses import dataclass, asdict
from typing import Any, Optional
import psutil
import logging

logger = logging.getLogger(__name__)

class SerializationError(Exception):
    """Raised when state cannot be serialized."""
    pass

def check_memory_usage():
    """Enforce 8GB RAM limit."""
    process = psutil.Process()
    mem_info = process.memory_info()
    if mem_info.rss > 8 * 1024 ** 3:  # 8GB
        raise MemoryError(f"Memory exceeded: {mem_info.rss / (1024 ** 3):.2f} GB")

def serialize_state(state: Any, max_size: int = 8 * 1024 * 1024) -> str:  # Default 8MB
    """Serialize state with fallbacks, enforcing size limits."""
    check_memory_usage()  # Fail fast if RAM is exhausted

    try:
        serialized = json.dumps(state, default=str)
        if len(serialized) > max_size:
            raise SerializationError(f"JSON state too large: {len(serialized)} > {max_size}")
        return serialized
    except (TypeError, OverflowError):
        pass  # Fall through to pickle

    try:
        serialized = cloudpickle.dumps(state)  # Safer than pickle
        if len(serialized) > max_size:
            raise SerializationError(f"Pickled state too large: {len(serialized)} > {max_size}")
        return serialized
    except Exception as e:
        raise SerializationError(f"State serialization failed: {e}")

def deserialize_state(serialized: str, expected_type: Optional[type] = None) -> Any:
    """Deserialize with type checking."""
    try:
        state = json.loads(serialized)
        if expected_type and not isinstance(state, expected_type):
            raise SerializationError(f"Expected {expected_type}, got {type(state)}")
        return state
    except json.JSONDecodeError:
        pass

    try:
        state = cloudpickle.loads(serialized)
        if expected_type and not isinstance(state, expected_type):
            raise SerializationError(f"Expected {expected_type}, got {type(state)}")
        return state
    except Exception as e:
        raise SerializationError(f"Deserialization failed: {e}")
Enter fullscreen mode Exit fullscreen mode

2. Race Condition Resilience: Bounded Queues and Idempotency

A2A messages can arrive out of order. We added message deduplication (via message_id), bounded queues (to prevent 8GB RAM exhaustion) and retry logic with exponential backoff.

from google.adk.agents import Agent
from google.adk.a2a import A2AMessage, A2ASender
import asyncio
from collections import deque

class A2ASafeAgent(Agent):
    def __init__(self):
        super().__init__()
        self._state = AgentState(conversation_id="", memory={}, step_count=0)
        self._a2a_sender = A2ASender()
        self._pending_messages = deque(maxlen=1000)  # Bounded queue (prevents OOM)
        self._processed_ids = set()  # Deduplication

    async def send_state_to_worker(self, target_agent_id: str, message_id: str):
        """Serialize, send, and enforce idempotency."""
        if message_id in self._processed_ids:
            return  # Skip duplicates
        try:
            serialized = self._state.serialize()
            message = A2AMessage(
                target_agent_id=target_agent_id,
                payload={"state": serialized, "message_id": message_id},
                is_retryable=True
            )
            await self._a2a_sender.send(message)
            self._processed_ids.add(message_id)
        except SerializationError as e:
            logger.error(f"Serialization failed: {e}")
            raise

    async def receive_state(self, message: A2AMessage):
        """Deserialize with race condition checks."""
        message_id = message.payload.get("message_id")
        if message_id in self._processed_ids:
            return  # Skip duplicates
        try:
            serialized = message.payload["state"]
            self._state = AgentState.deserialize(serialized)
            self._processed_ids.add(message_id)
        except (KeyError, SerializationError) as e:
            logger.error(f"Deserialization failed: {e}")
            raise
Enter fullscreen mode Exit fullscreen mode

3. Failure Walkthrough: What Happens When Serialization Fails

  1. Agent A tries to send a ConversationContext with a lambda function.
  2. JSON serialization fails and falls back to cloudpickle.
  3. Pickled state exceeds 8MB and raises SerializationError.
  4. Agent A logs the error and retries with a smaller state (e.g. stripping non-critical data).
  5. Agent B receives the message, checks message_id and skips duplicates.
  6. If deserialization fails, Agent B falls back to a default state (graceful degradation).

Hardware Constraints and Optimizations

Constraint Mitigation
8GB RAM Bounded queues (maxlen=1000), check_memory_usage()
Non-serializable objects Fallback to cloudpickle (with size limits)
Race conditions Message deduplication, idempotent operations
Network retries Exponential backoff in A2ASender

Benchmarking:

  • JSON: ~10x faster than pickle for simple objects.
  • cloudpickle: Slower but handles complex objects (e.g. lambdas).
  • Memory: Enforced 8MB per message to prevent OOM crashes.

Lessons Learned

  1. 100% test coverage does not equal 100% production safety (in-process tests hide cross-boundary issues).
  2. All state must be serializable (or explicitly reconstructed).
  3. A2A messages must be idempotent (retries will happen).
  4. Monitor memory (8GB RAM is a hard limit).

Final Rule:

"If it can’t cross a process boundary, it doesn’t exist in production."

Open Loop

How do you handle state serialization in distributed systems? Have you hit race conditions in async message passing? Share your war stories in the comments.

Top comments (0)