Skip to content

deleteSession() of the InMemorySessionService does not delete the user to session mapping #687

Closed

Description

@c0k0619

** Please make sure you read the contribution guide and file the issues in the rigth place. **
Contribution guide.

Describe the bug
The InMemorySessionService has the following structure for storing a session
// Structure: appName -> userId -> sessionId -> Session
private final ConcurrentMap<String, ConcurrentMap<String, ConcurrentMap<String, Session>>>
sessions;

When we do deleteSession(appName, userId, sessionId) it only removes the session but leaves back an empty map for the userId and appName

deleteSession()

What's deleted: sessionId → Session mapping
What's NOT deleted: Empty userId → {} map
What's NOT deleted: Empty appName → {} map ( This should still be fine )

Example:

BEFORE deletion:
sessions: {
"myApp": {
"user1": {
"ctx1": Session@1234
},
"user2": {
"ctx1": Session@1234
},
"user3": {
"ctx1": Session@1234
}
}
}

AFTER deletion:
sessions: {
"myApp": {
"user1": {} ← Empty map remains!
"user2": {} ← Empty map remains!
"user3": {} ← Empty map remains!
"user4": {} ← Empty map remains!
.
.
.
}
}

It can cause a potential MemoryLeak when there are a lot of users using the Agent

To Reproduce
Steps to reproduce the behavior:

  1. Install '...'
  2. Run '....'
  3. Open '....'
  4. See error

Expected behavior
An option to also delete the appName.userID map inside the InMemorySession

Screenshots
Before Deletion

Image

After Deletion

Image

Desktop (please complete the following information):

  • OS: [e.g. iOS]
  • Java version: 21
  • ADK version(see maven dependency): 0.4.0

Additional context
Add any other context about the problem here.

Activity

  1. prasadskarmarkar commented on Dec 20, 2025

    @prasadskarmarkar
    Contributor

    The Issue Is Valid, but the Impact Is Limited

    Yes — the code does behave exactly as described.

    What's Happening

    In InMemorySessionService.deleteSession() (around line 198), the session entry is removed, but the per-user map is left behind when it becomes empty:

    if (userSessionsMap != null) {
        userSessionsMap.remove(sessionId);
        // Empty userId -> {} map may remain
    }

    As a result, empty userId → {} maps can accumulate in memory over time.

    Impact Assessment

    This is better characterized as bounded memory waste, not a true memory leak:

    • Bounded growth: One empty map per unique userId
    • No unbounded accumulation: Growth stops once all distinct users are seen
    • Small footprint: ~300 bytes per empty map

    Approximate impact:

    • 10,000 users → ~3 MB

    While not ideal, the behavior is predictable and relatively minor.


    InMemorySessionService Is Not Production-Ready

    Per the class Javadoc (lines 45-47), this implementation is described as:

    "suitable for testing or single-node deployments where persistence is not required"

    This aligns with its current behavior and limitations:

    ❌ No persistence (sessions lost on restart)
    ❌ Single-node only (no horizontal scaling)
    ❌ No distributed locking
    ⚠️ Concurrent maps provide basic thread safety only

    This class is intended as a testing/development utility, not a production-grade session store.


    Production Direction: DatabaseSessionService

    For production deployments, the intended solution is DatabaseSessionService, which provides:

    ✅ Persistent storage
    ✅ Multi-node / horizontal scalability
    ✅ Proper transactional behavior with pessimistic locking
    ✅ No in-memory accumulation concerns
    ✅ Support for PostgreSQL, MySQL, H2, SQLite, and Spanner

    The database-backed implementation is currently under review here:
    #700

    Although that PR is still being finalized, it represents the correct long-term solution for production setups, and the in-memory implementation should not be treated as a scalable or durable alternative.

    This issue does not exist in the database-backed approach because sessions are stored as rows with composite keys — there are no "empty parent containers" to accumulate.


    Recommendation

    • Use InMemorySessionService only for local development and testing
    • Use DatabaseSessionService for any production deployment

    Would this approach address your use case?

  2. c0k0619 commented on Dec 23, 2025

    @c0k0619
    Author

    Hello @prasadskarmarkar

    Thanks for your reply.
    So the thing is we are planning to use the InMemorySessionService for Production as well, as we want to use our own Custom MemoryStore

    The plan is to use an interceptor design pattern to hydrate the session of the InMemorySession instead of using DBSession and then delete that session once the execution is completed.
    So the number of users in our case would be more.

    If you could add a new method or modify the existing method which gives us an option to also delete the appName.userID map inside the InMemorySession would be really helpful

  3. c0k0619 commented on Jan 7, 2026

    @c0k0619
    Author

    Hello @prasadskarmarkar

    Do we have an update on the above?

    Additionally, I'd like to clarify the expected approach for handling multi-turn conversations in Agent-to-Agent (A2A) scenarios:
    Current Understanding:

    From the Google ADK and A2A implementations, it appears that messages can only be sent with a single role type. This creates a challenge when propagating conversation context between agents.

    Specific Scenario:
    Consider the following flow:

    1. A conversation (convId123) has progressed through 5 exchanges (5 user utterances + 5 agent responses)
    2. On the 6th user utterance, Agent 1 determines it needs to invoke Agent 2 via A2A
    3. Agent 1 attempts to pass the full conversation context to Agent 2
    4. Issue: All historical messages (both original user utterances AND previous agent responses) are being sent with role=user
    5. Agent 2 cannot distinguish between:
    • Messages that were original user input
    • Messages that were previous agent responses

    Question:
    Should each agent independently load conversation context from a shared memory/context store rather than receiving it directly from the calling agent in the A2A Request ?

    Any guidance on the intended design pattern here would be greatly appreciated.

  4. prasadskarmarkar commented on Jan 7, 2026

    @prasadskarmarkar
    Contributor

    Shared Memory / Context Stores Are Not Recommended for A2A

    1. Violates A2A's Core Design Principle: Agent Independence

    The A2A protocol is explicitly designed for loosely coupled, distributed agent systems. Its core architectural assumptions include:

    • Agents may run on different servers, networks, or organizations
    • No assumption of shared infrastructure (databases, memory stores, file systems)
    • Agents may be third-party services outside your control
    • Interactions often cross organizational boundaries (e.g., your agent calling a Google-hosted agent)

    Introducing a shared memory or context store directly contradicts these principles.

    Why Shared Memory Breaks A2A

    Using shared memory tightly couples agents by requiring them to:

    • Have access to the same storage system
    • Agree on schemas and serialization formats
    • Handle synchronization and consistency
    • Manage authentication, authorization, and access control across organizations

    These requirements reintroduce strong coupling and shared state, which A2A intentionally avoids.

    Conclusion

    • Each agent should receive all required context explicitly via the A2A request, not by independently loading state from a shared store.
    • Relying on shared memory undermines A2A's goals of portability, interoperability, and organizational independence, defeating the purpose of the protocol.

    Also, all mesages would be sent with the role "user" that is by design. Is this causing any problems ? A2A remote agent should be able to intrepret the message properly, if you have provided proper prompts.

    A2A documentation for reference :

    https://a2a-protocol.org/latest/specification/#2-terminology
    Message: A communication turn between a client and a remote agent, having a role ("user" or "agent") and containing one or more Parts.

  5. c0k0619 commented on Jan 7, 2026

    @c0k0619
    Author

    Hello @prasadskarmarkar

    Thank you for your response

    "Also, all mesages would be sent with the role "user" that is by design. Is this causing any problems ? A2A remote agent should be able to intrepret the message properly, if you have provided proper prompts."

    If every message is sent to the remote agent with the role as User in a multi turn conversation, then while creating the prompt how do we differentiate between User Request and Previous Agent Response?

    For example:
    If remote Agent 1 sends an A2A request to remote Agent 2 with the following requestBody as pasted below.

    Now the User has already asked
    What is 1 + 1?
    What is 2 + 2?

    And the agent has responded with
    The answer is 2.
    The answer is 4.

    Now when the user is asking a third question: What is 3 + 3?
    and this request comes to a remote agent 2, when creating the prompt shouldn't it be able to differentiate between what were the previous questions asked by the User and what did the agent respond with?

    So that the LLM is not confused or doesn't hallucinate based on what has already been said and happened between the user and agents?

    Sample Request Body

    {
        "jsonrpc": "2.0",
        "id": "ID123456789",
        "method": "message/send",
        "params": {
            "message": {
                "role": "user",
                "parts": [
                    {
                        "text": "What is 1 + 1?",
                        "kind": "text"
                    },
                    {
                        "text": "The answer is 2.",
                        "kind": "text"
                    },
                    {
                        "text": "What is 2 + 2",
                        "kind": "text"
                    },
                    {
                        "text": "The answer is 4.",
                        "kind": "text"
                    },
                    {
                        "data": {
                            "args": {
                                "agent_name": "entry-agent"
                            },
                            "name": "transfer_to_agent"
                        },
                        "metadata": {
                            "type": "function_call"
                        },
                        "kind": "data"
                    },
                    {
                        "data": {
                            "response": {},
                            "name": "transfer_to_agent"
                        },
                        "metadata": {
                            "type": "function_response"
                        },
                        "kind": "data"
                    },
                    {
                        "text": "What is 3 + 3?",
                        "kind": "text"
                    }
                ],
                "messageId": "1",
                "contextId": "c",
                "kind": "message"
            },
            "metadata": {}
        }
    }
  6. prasadskarmarkar commented on Jan 8, 2026

    @prasadskarmarkar
    Contributor

    Your example is hypothetical.
    A2A calls are usually not intended to replay an entire multi-turn transcript verbatim to the remote agent as if it’s continuing the same chat session.

    In practice, the calling agent should treat the remote agent more like a specialist service: you send it the context it needs, in a shape it can reliably interpret, rather than forwarding raw conversational history.

    documentation for reference :
    https://a2a-protocol.org/latest/specification/#415-role

    We can modify the code to append metadata and explicitly add roles, but that is not currently supported in the A2A implementation. Unless there is a compelling and concrete requirement, it would not be appropriate to change the entire A2A message format just to accommodate this scenario.

  7. c0k0619 commented on Jan 8, 2026

    @c0k0619
    Author

    Understood @prasadskarmarkar and it does make sense, thank you so much for breaking it down for me. I really appreciate your patience.
    I had one last question to ask,

    So how do we approach this the right way?
    Do you mean to say when Remote Agent 1 calls Remote Agent 2 in a multi-turn conversation, is it something like the Remote Agent 1 has to summarize what has already happened between the user and the agent? and then send the summary along with the current user request to Remote Agent 2?

    Even in that case, won't the remote agent 2 need to distinguish between what's the summary and what's the current user request from the incoming A2A message Payload?

  8. prasadskarmarkar commented on Jan 8, 2026

    @prasadskarmarkar
    Contributor

    You can send summary or the raw multi-turn conversation to the A2A remote agent:
    "Conversation history:\n\nUser: What is 1 + 1?\nModel: The answer is 2.\n\nUser: What is 2 + 2?\nModel: The answer is 4.\n\nUser: What is 3 + 3?"

    However, as I mentioned earlier, the purpose of a remote agent is typically not to continue a multi-turn conversation.
    If you have a specific use case in mind, please share more details about what you're trying to accomplish, and I can provide more targeted guidance on the best approach.

  9. c0k0619 commented on Jan 8, 2026

    @c0k0619
    Author

    @prasadskarmarkar so the A2A Request from Agent 1 to Agent 2 will look something like this then?
    (As attached at the very end)
    Everything will still be sent with the role as User and we handle it in the Prompt?

    Context:
    So we are planing to create an AI Shopping Assistant which can do things like answer user questions about a particular item, or add things to cart or get user reviews, etc and it would be a Chat where in user can ask followup questions and hence the history is important.

    Now the assistant will talk to a set of agents in parallel or sequential to fulfill the user requests and hence there can be more number of remote agents involved

    {
        "jsonrpc": "2.0",
        "id": "ID123456789",
        "method": "message/send",
        "params": {
            "message": {
                "role": "user",
                "parts": [
                    {
                        "text": "Conversation history:\n\nUser: What is 1 + 1?\nModel: The answer is 2.\n\nUser: What is 2 + 2?\nModel: The answer is 4.\n\nUser: What is 3 + 3?",
                        "kind": "text"
                    },
                    {
                        "data": {
                            "response": {},
                            "name": "transfer_to_agent"
                        },
                        "metadata": {
                            "type": "function_response"
                        },
                        "kind": "data"
                    },
                    {
                        "text": "What is 4 + 4?",
                        "kind": "text"
                    }
                ],
                "messageId": "1",
                "contextId": "c",
                "kind": "message"
            },
            "metadata": {}
        }
    }
  10. self-assigned this
    on Jan 14, 2026
  11. added theissue type on Jan 14, 2026
  12. hemasekhar-p commented on Jan 23, 2026

    @hemasekhar-p
    Contributor

    Hi @c0k0619, Thank you for bringing this to our attention with such detail. After performing a deep analysis i am able to repro and I confirmed that while deleteSession correctly removes the Session object (the leaf node), it fails to prune the parent keys (userId and appName) from the nested ConcurrentHashMap structure. In a scenario with high unique user, this results in a "bounded memory leak" where thousands of empty ConcurrentHashMap objects remain in the JVM heap, as they are still reachable from the root sessions map.

    This seems to be a genuine bug and we will investigate it further. Mean time as a temporary solution, you can implement the bellow utility method and call it periodically to clear out the accumulated empty maps.

    public void pruneSessionService(InMemorySessionService service) {
            try {
                Field field = InMemorySessionService.class.getDeclaredField("sessions");
                field.setAccessible(true);
                ConcurrentMap<String, ConcurrentMap<String, ConcurrentMap<String, Session>>> sessions = 
                    (ConcurrentMap<String, ConcurrentMap<String, ConcurrentMap<String, Session>>>) field.get(service);
    
                // Manually remove empty user and app entries
                sessions.values().forEach(appMap -> {
                    appMap.entrySet().removeIf(userEntry -> userEntry.getValue().isEmpty());
                });
                sessions.entrySet().removeIf(appEntry -> appEntry.getValue().isEmpty());
                
                System.out.println("Memory Pruning Complete.");
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    
    
  13. c0k0619 commented on Jan 27, 2026

    @c0k0619
    Author

    Thank you @hemasekhar-p
    Please keep me posted on this

  14. c0k0619 commented on Jan 28, 2026

    @c0k0619
    Author

    @hemasekhar-p can you give us a rough timeline of by when this would be fixed?

  15. hemasekhar-p commented on Feb 12, 2026

    @hemasekhar-p
    Contributor

    Hi @c0k0619, this issue is under review. will keep you posted with updates here.

    Additional Context :
    The memory leak in InMemorySessionService is located within the deleteSession() method, which performs a shallow removal of the sessionId but fails to prune the parent userId and appName keys from the triple-nested ConcurrentHashMap. This results in empty maps remaining reachable in the JVM heap, preventing garbage collection and causing linear memory growth.

    To resolve this, the method must be updated to implement atomic hierarchical pruning using computeIfPresent to remove parent entries only when their child maps are verified as empty.

  16. hemasekhar-p commented on Feb 12, 2026

    @hemasekhar-p
    Contributor

    @sherryfox, Please have a look into it.

  17. added 2 commits that reference this issue on Feb 27, 2026
    dd8c4d1
    9ebdec6
  18. YuqiGuo105 commented on Feb 27, 2026

    @YuqiGuo105
    Contributor

    Fix submitted: PR #923

    #923


    The Problem (as reported)

    When deleteSession() removes a session, the empty parent ConcurrentHashMap objects are left behind:

    BEFORE: sessions = { myApp: { user1: { ctx1: Session@123 } } }
    AFTER:  sessions = { myApp: { user1: {} } }    ghost empty map leaks ~1KB
    

    With 1M users creating/deleting sessions: ~1GB leaked memory OutOfMemoryError


    The Fix

    Replace get() + remove() with atomic computeIfPresent() cleanup:

    sessions.computeIfPresent(appName, (app, appSessionsMap) -> {
        appSessionsMap.computeIfPresent(userId, (user, userSessionsMap) -> {
            userSessionsMap.remove(sessionId);
            return userSessionsMap.isEmpty() ? null : userSessionsMap; // auto-prune
        });
        return appSessionsMap.isEmpty() ? null : appSessionsMap; // auto-prune
    });

    Thread-safe (no extra locking needed)
    Empty maps automatically removed when lambda returns null
    No manual pruneSessionService() utility needed anymore


    Tests Added

    Test What it verifies
    deleteSession_cleansUpEmptyParentMaps Deleting the only session leaves sessions map completely empty
    deleteSession_doesNotRemoveUserMapWhenOtherSessionsExist Partial delete preserves remaining sessions

    All tests passing

    cc @hemasekhar-p @c0k0619 would appreciate a review!

  19. c0k0619 commented on Feb 27, 2026

    @c0k0619
    Author

    @hemasekhar-p @YuqiGuo105
    Should we not need something similar cleanup for userState & appState as well?

  20. YuqiGuo105 commented on Feb 28, 2026

    @YuqiGuo105
    Contributor

    @hemasekhar-p @YuqiGuo105 Should we not need something similar cleanup for userState & appState as well?

    Hi @c0k0619
    Thank you for raising this — that’s a great question.

    userState and appState are intentionally designed to persist across sessions:

    • sessions: Ephemeral conversation context, removed when the session is deleted

    • userState: User-specific preferences/history that should survive session deletion (user prefixed)

    • appState: App-wide configuration shared across all users (app prefixed)

    The memory leak fix is therefore scoped to sessions only. Deleting a session should not implicitly remove persistent user or application state, as that could lead to unexpected data loss when a user starts a new session.

    If explicit cleanup of userState or appState is needed, that would be better handled via a separate API (e.g., deleteUserState(appName, userId)), rather than as part of deleteSession().

    Hope this clarifies the design intent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions