Skip to content

Commit 005e219

Browse files
authored
Merge pull request #55 from luis5tb/bugfix-adk-sessions
Bugfix adk sessions and configuration
2 parents f4e34a1 + 2f5df2f commit 005e219

8 files changed

Lines changed: 322 additions & 53 deletions

File tree

.env.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,18 @@ DATABASE_URL=sqlite+aiosqlite:///./lightspeed_agent.db
100100
# For PostgreSQL in production:
101101
# DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/lightspeed_agent
102102

103+
# -----------------------------------------------------------------------------
104+
# Session Configuration
105+
# -----------------------------------------------------------------------------
106+
# Session storage backend: "memory" (default) or "database"
107+
# "memory": Sessions stored in-memory (lost on restart, suitable for dev)
108+
# "database": Sessions persisted to PostgreSQL (requires SESSION_DATABASE_URL)
109+
SESSION_BACKEND=memory
110+
111+
# Session database URL (required when SESSION_BACKEND=database)
112+
# Use a SEPARATE database from DATABASE_URL for security isolation.
113+
# SESSION_DATABASE_URL=postgresql+asyncpg://sessions:password@localhost:5433/agent_sessions
114+
103115
# -----------------------------------------------------------------------------
104116
# Google Cloud Service Control (for usage reporting)
105117
# -----------------------------------------------------------------------------

deploy/cloudrun/service.yaml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,11 @@ spec:
126126
secretKeyRef:
127127
name: database-url
128128
key: latest
129-
# Session Database (required for session persistence)
130-
# If not set, uses in-memory storage (sessions lost on restart)
131-
# For production, always configure this for session persistence
129+
# Session backend: "database" for production persistence,
130+
# "memory" for in-memory (sessions lost on restart)
131+
- name: SESSION_BACKEND
132+
value: "database"
133+
# Session Database (required when SESSION_BACKEND=database)
132134
- name: SESSION_DATABASE_URL
133135
valueFrom:
134136
secretKeyRef:

deploy/podman/lightspeed-agent-configmap.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ data:
4040
AGENT_HOST: "0.0.0.0"
4141
AGENT_PORT: "8000"
4242

43+
# Session Configuration
44+
# Session backend: "memory" for dev (no persistence), "database" for persistent sessions
45+
# To use "database", ensure SESSION_DATABASE_URL is configured in the secret
46+
SESSION_BACKEND: "memory"
47+
4348
# Database Configuration
4449
# DATABASE_URL and passwords are stored in secrets (contain credentials)
4550
# Marketplace PostgreSQL (in marketplace-handler pod)

docs/configuration.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,12 @@ AGENT_PORT=8000
101101
| Variable | Default | Description |
102102
|----------|---------|-------------|
103103
| `DATABASE_URL` | `sqlite+aiosqlite:///./lightspeed_agent.db` | Marketplace database connection URL (orders, DCR clients, auth) |
104-
| `SESSION_DATABASE_URL` | (uses DATABASE_URL) | Session database URL for ADK sessions. Optional - for security isolation. |
104+
| `SESSION_BACKEND` | `memory` | Session storage backend: `memory` (in-memory, no persistence) or `database` (PostgreSQL, persistent) |
105+
| `SESSION_DATABASE_URL` | *(empty)* | Session database URL for ADK sessions. Required when `SESSION_BACKEND=database`. |
106+
107+
> **Note:** Setting `SESSION_BACKEND=database` without providing `SESSION_DATABASE_URL`
108+
> will cause a startup validation error. This is intentional to prevent running
109+
> production workloads without session persistence.
105110
106111
**SQLite (Development):**
107112

@@ -121,11 +126,14 @@ DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/lightspeed_agent
121126
DATABASE_URL=postgresql+asyncpg://user:password@/lightspeed_agent?host=/cloudsql/project:region:instance
122127
```
123128

124-
**Security Isolation (Optional):**
129+
**Security Isolation (Recommended for Production):**
125130

126-
For production deployments, you can use separate databases for marketplace data and agent sessions:
131+
For production deployments, use `SESSION_BACKEND=database` with a separate database for agent sessions:
127132

128133
```bash
134+
# Explicit database backend for sessions
135+
SESSION_BACKEND=database
136+
129137
# Shared marketplace database (orders, DCR clients, auth data)
130138
DATABASE_URL=postgresql+asyncpg://marketplace:pass@db:5432/marketplace
131139

@@ -138,6 +146,14 @@ This separation ensures:
138146
- Compromised agents can't access DCR credentials or order information
139147
- Different retention policies can be applied to sessions vs. marketplace data
140148

149+
**Switching to In-Memory Sessions:**
150+
151+
To disable database session persistence on a running deployment (e.g., for debugging),
152+
set `SESSION_BACKEND=memory` and redeploy:
153+
154+
- **Cloud Run:** Update the `SESSION_BACKEND` env var to `memory` and redeploy the service.
155+
- **Podman:** Update `SESSION_BACKEND` in the ConfigMap to `memory` and restart the pod.
156+
141157
### Dynamic Client Registration (DCR)
142158

143159
DCR allows Google Cloud Marketplace customers to automatically register as OAuth clients.
@@ -365,6 +381,7 @@ LOG_LEVEL=DEBUG
365381
LOG_FORMAT=text
366382
AGENT_LOGGING_DETAIL=detailed
367383
DATABASE_URL=sqlite+aiosqlite:///./dev.db
384+
SESSION_BACKEND=memory
368385
```
369386

370387
### Staging
@@ -376,6 +393,8 @@ SKIP_JWT_VALIDATION=false
376393
LOG_LEVEL=INFO
377394
LOG_FORMAT=json
378395
DATABASE_URL=postgresql+asyncpg://user:pass@staging-db:5432/insights
396+
SESSION_BACKEND=database
397+
SESSION_DATABASE_URL=postgresql+asyncpg://sessions:pass@staging-db:5432/sessions
379398
```
380399

381400
### Production
@@ -386,5 +405,6 @@ DEBUG=false
386405
SKIP_JWT_VALIDATION=false
387406
LOG_LEVEL=INFO
388407
LOG_FORMAT=json
389-
# DATABASE_URL from Secret Manager
408+
SESSION_BACKEND=database
409+
# DATABASE_URL and SESSION_DATABASE_URL from Secret Manager
390410
```

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ dependencies = [
3333
"aiosqlite>=0.20.0",
3434
# PostgreSQL drivers
3535
"asyncpg>=0.29.0", # Async driver for SQLAlchemy async operations
36-
"psycopg2-binary>=2.9.0", # Sync driver for ADK DatabaseSessionService
36+
"psycopg2-binary>=2.9.0", # Sync PostgreSQL driver (used by alembic migrations)
3737
# Google Cloud authentication (ADC for Procurement API calls)
3838
"google-auth>=2.0.0",
3939
"requests>=2.20.0", # Required by google.auth.transport.requests

src/lightspeed_agent/api/a2a/a2a_setup.py

Lines changed: 52 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
"""
77

88
import logging
9+
import re
910
from typing import Any
11+
from urllib.parse import urlparse
1012

1113
from a2a.server.apps import A2AFastAPIApplication
1214
from a2a.server.request_handlers import DefaultRequestHandler
@@ -28,61 +30,70 @@
2830
logger = logging.getLogger(__name__)
2931

3032

33+
def _normalize_db_url(url: str) -> str:
34+
"""Ensure a database URL uses an async driver for SQLAlchemy.
35+
36+
ADK's DatabaseSessionService requires ``create_async_engine``, so any
37+
synchronous PostgreSQL scheme must be replaced with ``postgresql+asyncpg``.
38+
"""
39+
scheme, remainder = url.split("://", 1)
40+
normalized_scheme = scheme.lower()
41+
42+
sync_postgres_schemes = {
43+
"postgres",
44+
"postgresql",
45+
"postgresql+psycopg",
46+
"postgresql+psycopg2",
47+
}
48+
if normalized_scheme in sync_postgres_schemes:
49+
return f"postgresql+asyncpg://{remainder}"
50+
51+
return url
52+
53+
3154
def _get_session_service() -> Any:
32-
"""Get the appropriate session service based on configuration.
55+
"""Get the appropriate session service based on SESSION_BACKEND setting.
56+
57+
Uses SESSION_BACKEND to determine the session storage:
58+
- ``"memory"``: InMemorySessionService (default, no persistence)
59+
- ``"database"``: DatabaseSessionService (requires SESSION_DATABASE_URL)
3360
34-
For production, uses DatabaseSessionService which persists sessions to PostgreSQL.
35-
For development, uses InMemorySessionService.
61+
When SESSION_BACKEND is ``"database"``, failures are raised immediately
62+
rather than silently falling back to in-memory, so misconfigurations are
63+
caught at startup.
3664
3765
Security Note:
38-
SESSION_DATABASE_URL must be explicitly set to use database persistence.
39-
This prevents accidental use of the marketplace database (DATABASE_URL)
40-
for session storage, ensuring:
41-
- Agents only have access to session data, not marketplace/auth data
42-
- Compromised agents can't access DCR credentials or order information
43-
- Different retention policies can be applied to sessions vs. marketplace data
66+
SESSION_DATABASE_URL should point to a separate database from
67+
DATABASE_URL to ensure agents only access session data, not
68+
marketplace/auth data.
4469
4570
Returns:
4671
Session service instance (DatabaseSessionService or InMemorySessionService).
4772
"""
4873
settings = get_settings()
4974

50-
# Only use database session service if SESSION_DATABASE_URL is explicitly set
51-
# Do NOT fall back to DATABASE_URL to avoid mixing session and marketplace data
52-
session_db_url = settings.session_database_url
75+
if settings.session_backend == "database":
76+
from google.adk.sessions import DatabaseSessionService
77+
78+
# SESSION_DATABASE_URL is guaranteed non-empty by the model validator
79+
db_url = _normalize_db_url(settings.session_database_url)
80+
81+
# Log which database is being used (without credentials)
82+
parsed = urlparse(db_url)
83+
db_host = parsed.hostname or parsed.query or "local"
84+
logger.info(
85+
"Using DatabaseSessionService for session persistence (host=%s)",
86+
db_host,
87+
)
5388

54-
# Use database session service for production (non-SQLite databases)
55-
if session_db_url and not session_db_url.startswith("sqlite"):
5689
try:
57-
from google.adk.sessions import DatabaseSessionService
58-
59-
# ADK's DatabaseSessionService uses synchronous SQLAlchemy,
60-
# so we need to convert the async URL to sync format
61-
db_url = session_db_url
62-
if "postgresql+asyncpg" in db_url:
63-
# Convert asyncpg URL to sync psycopg2 format
64-
db_url = db_url.replace("postgresql+asyncpg", "postgresql+psycopg2")
65-
elif "postgresql+aiopg" in db_url:
66-
db_url = db_url.replace("postgresql+aiopg", "postgresql+psycopg2")
67-
68-
# Log which database is being used (without credentials)
69-
db_host = db_url.split("@")[-1].split("/")[0] if "@" in db_url else "local"
70-
logger.info(
71-
"Using DatabaseSessionService for session persistence (host=%s)",
72-
db_host,
73-
)
7490
return DatabaseSessionService(db_url=db_url)
75-
except ImportError as e:
76-
logger.warning(
77-
"DatabaseSessionService not available (%s), falling back to InMemorySessionService",
78-
e,
79-
)
8091
except Exception as e:
81-
logger.warning(
82-
"Failed to initialize DatabaseSessionService (%s), "
83-
"falling back to InMemorySessionService",
84-
e,
85-
)
92+
# Sanitize error message to avoid leaking credentials from URLs
93+
sanitized_msg = re.sub(r"://[^@]+@", "://***@", str(e))
94+
raise RuntimeError(
95+
f"Failed to initialize DatabaseSessionService: {sanitized_msg}"
96+
) from None
8697

8798
logger.info("Using InMemorySessionService for session management")
8899
return InMemorySessionService() # type: ignore[no-untyped-call]

src/lightspeed_agent/config/settings.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,14 +233,22 @@ class Settings(BaseSettings):
233233
description="Maximum overflow connections beyond pool size",
234234
)
235235

236-
# Session database: stores ADK sessions, conversation history, memory
236+
# Session configuration: controls ADK session storage backend
237237
# Separate from marketplace DB for security isolation - each agent can have its own
238+
session_backend: Literal["memory", "database"] = Field(
239+
default="memory",
240+
description=(
241+
"Session storage backend. "
242+
"'memory' uses in-memory sessions (lost on restart). "
243+
"'database' uses DatabaseSessionService and requires SESSION_DATABASE_URL."
244+
),
245+
)
238246
session_database_url: str = Field(
239247
default="",
240248
description=(
241249
"Session database URL for ADK sessions."
242-
" If empty, uses DATABASE_URL."
243-
" For security isolation, use a separate database."
250+
" Required when SESSION_BACKEND=database."
251+
" For security isolation, use a separate database from DATABASE_URL."
244252
),
245253
)
246254

@@ -307,6 +315,17 @@ def _block_skip_jwt_in_production(self) -> "Settings":
307315
)
308316
return self
309317

318+
@model_validator(mode="after")
319+
def _validate_session_backend(self) -> "Settings":
320+
"""Ensure SESSION_DATABASE_URL is set when SESSION_BACKEND=database."""
321+
if self.session_backend == "database" and not self.session_database_url:
322+
raise ValueError(
323+
"SESSION_BACKEND=database requires SESSION_DATABASE_URL to be set. "
324+
"Provide a PostgreSQL connection URL, e.g.: "
325+
"SESSION_DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/sessions"
326+
)
327+
return self
328+
310329
# OpenTelemetry Configuration
311330
otel_enabled: bool = Field(
312331
default=False,

0 commit comments

Comments
 (0)