Skip to content

Commit 98ebf1e

Browse files
luis5tbclaude
andcommitted
feat: add SESSION_BACKEND setting to make session storage configurable
Add an explicit SESSION_BACKEND env var ("memory" or "database") to control the ADK session service instead of inferring it from SESSION_DATABASE_URL. When set to "database", SESSION_DATABASE_URL is required (validated at startup) and init failures are raised immediately rather than silently falling back to in-memory. Updates deployment templates (Cloud Run defaults to "database", Podman to "memory") and documentation with instructions on switching backends. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4266527 commit 98ebf1e

7 files changed

Lines changed: 158 additions & 63 deletions

File tree

.env.example

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

93+
# -----------------------------------------------------------------------------
94+
# Session Configuration
95+
# -----------------------------------------------------------------------------
96+
# Session storage backend: "memory" (default) or "database"
97+
# "memory": Sessions stored in-memory (lost on restart, suitable for dev)
98+
# "database": Sessions persisted to PostgreSQL (requires SESSION_DATABASE_URL)
99+
SESSION_BACKEND=memory
100+
101+
# Session database URL (required when SESSION_BACKEND=database)
102+
# Use a SEPARATE database from DATABASE_URL for security isolation.
103+
# SESSION_DATABASE_URL=postgresql+asyncpg://sessions:password@localhost:5433/agent_sessions
104+
93105
# -----------------------------------------------------------------------------
94106
# Google Cloud Service Control (for usage reporting)
95107
# -----------------------------------------------------------------------------

deploy/cloudrun/service.yaml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,11 @@ spec:
122122
secretKeyRef:
123123
name: database-url
124124
key: latest
125-
# Session Database (required for session persistence)
126-
# If not set, uses in-memory storage (sessions lost on restart)
127-
# For production, always configure this for session persistence
125+
# Session backend: "database" for production persistence,
126+
# "memory" for in-memory (sessions lost on restart)
127+
- name: SESSION_BACKEND
128+
value: "database"
129+
# Session Database (required when SESSION_BACKEND=database)
128130
- name: SESSION_DATABASE_URL
129131
valueFrom:
130132
secretKeyRef:

deploy/podman/lightspeed-agent-configmap.yaml

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

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

docs/configuration.md

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

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

123-
**Security Isolation (Optional):**
128+
**Security Isolation (Recommended for Production):**
124129

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

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

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

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

142158
DCR allows Google Cloud Marketplace customers to automatically register as OAuth clients.
@@ -361,6 +377,7 @@ LOG_LEVEL=DEBUG
361377
LOG_FORMAT=text
362378
AGENT_LOGGING_DETAIL=detailed
363379
DATABASE_URL=sqlite+aiosqlite:///./dev.db
380+
SESSION_BACKEND=memory
364381
```
365382

366383
### Staging
@@ -372,6 +389,8 @@ SKIP_JWT_VALIDATION=false
372389
LOG_LEVEL=INFO
373390
LOG_FORMAT=json
374391
DATABASE_URL=postgresql+asyncpg://user:pass@staging-db:5432/insights
392+
SESSION_BACKEND=database
393+
SESSION_DATABASE_URL=postgresql+asyncpg://sessions:pass@staging-db:5432/sessions
375394
```
376395

377396
### Production
@@ -382,5 +401,6 @@ DEBUG=false
382401
SKIP_JWT_VALIDATION=false
383402
LOG_LEVEL=INFO
384403
LOG_FORMAT=json
385-
# DATABASE_URL from Secret Manager
404+
SESSION_BACKEND=database
405+
# DATABASE_URL and SESSION_DATABASE_URL from Secret Manager
386406
```

src/lightspeed_agent/api/a2a/a2a_setup.py

Lines changed: 28 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -52,60 +52,48 @@ def _normalize_db_url(url: str) -> str:
5252

5353

5454
def _get_session_service() -> Any:
55-
"""Get the appropriate session service based on configuration.
55+
"""Get the appropriate session service based on SESSION_BACKEND setting.
5656
57-
For production, uses DatabaseSessionService which persists sessions to PostgreSQL.
58-
For development, uses InMemorySessionService.
57+
Uses SESSION_BACKEND to determine the session storage:
58+
- ``"memory"``: InMemorySessionService (default, no persistence)
59+
- ``"database"``: DatabaseSessionService (requires SESSION_DATABASE_URL)
60+
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.
5964
6065
Security Note:
61-
SESSION_DATABASE_URL must be explicitly set to use database persistence.
62-
This prevents accidental use of the marketplace database (DATABASE_URL)
63-
for session storage, ensuring:
64-
- Agents only have access to session data, not marketplace/auth data
65-
- Compromised agents can't access DCR credentials or order information
66-
- 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.
6769
6870
Returns:
6971
Session service instance (DatabaseSessionService or InMemorySessionService).
7072
"""
7173
settings = get_settings()
7274

73-
# Only use database session service if SESSION_DATABASE_URL is explicitly set
74-
# Do NOT fall back to DATABASE_URL to avoid mixing session and marketplace data
75-
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+
)
7688

77-
# Use database session service for production (non-SQLite databases)
78-
if session_db_url and not session_db_url.startswith("sqlite"):
7989
try:
80-
from google.adk.sessions import DatabaseSessionService
81-
82-
# ADK's DatabaseSessionService uses async SQLAlchemy
83-
# (create_async_engine), so ensure the URL uses an async driver
84-
db_url = _normalize_db_url(session_db_url)
85-
86-
# Log which database is being used (without credentials)
87-
parsed = urlparse(db_url)
88-
db_host = parsed.hostname or parsed.query or "local"
89-
logger.info(
90-
"Using DatabaseSessionService for session persistence (host=%s)",
91-
db_host,
92-
)
9390
return DatabaseSessionService(db_url=db_url)
94-
except ImportError as e:
95-
logger.warning(
96-
"DatabaseSessionService not available (%s), falling back to InMemorySessionService",
97-
e,
98-
)
9991
except Exception as e:
10092
# Sanitize error message to avoid leaking credentials from URLs
101-
sanitized_msg = re.sub(
102-
r"://[^@]+@", "://***@", str(e)
103-
)
104-
logger.warning(
105-
"Failed to initialize DatabaseSessionService (%s), "
106-
"falling back to InMemorySessionService",
107-
sanitized_msg,
108-
)
93+
sanitized_msg = re.sub(r"://[^@]+@", "://***@", str(e))
94+
raise RuntimeError(
95+
f"Failed to initialize DatabaseSessionService: {sanitized_msg}"
96+
) from None
10997

11098
logger.info("Using InMemorySessionService for session management")
11199
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
@@ -216,14 +216,22 @@ class Settings(BaseSettings):
216216
description="Maximum overflow connections beyond pool size",
217217
)
218218

219-
# Session database: stores ADK sessions, conversation history, memory
219+
# Session configuration: controls ADK session storage backend
220220
# Separate from marketplace DB for security isolation - each agent can have its own
221+
session_backend: Literal["memory", "database"] = Field(
222+
default="memory",
223+
description=(
224+
"Session storage backend. "
225+
"'memory' uses in-memory sessions (lost on restart). "
226+
"'database' uses DatabaseSessionService and requires SESSION_DATABASE_URL."
227+
),
228+
)
221229
session_database_url: str = Field(
222230
default="",
223231
description=(
224232
"Session database URL for ADK sessions."
225-
" If empty, uses DATABASE_URL."
226-
" For security isolation, use a separate database."
233+
" Required when SESSION_BACKEND=database."
234+
" For security isolation, use a separate database from DATABASE_URL."
227235
),
228236
)
229237

@@ -292,6 +300,17 @@ def _block_skip_jwt_in_production(self) -> "Settings":
292300
)
293301
return self
294302

303+
@model_validator(mode="after")
304+
def _validate_session_backend(self) -> "Settings":
305+
"""Ensure SESSION_DATABASE_URL is set when SESSION_BACKEND=database."""
306+
if self.session_backend == "database" and not self.session_database_url:
307+
raise ValueError(
308+
"SESSION_BACKEND=database requires SESSION_DATABASE_URL to be set. "
309+
"Provide a PostgreSQL connection URL, e.g.: "
310+
"SESSION_DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/sessions"
311+
)
312+
return self
313+
295314
# OpenTelemetry Configuration
296315
otel_enabled: bool = Field(
297316
default=False,

tests/test_a2a.py

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ def test_cloudsql_host_logged_correctly(self, caplog):
316316
"/agent_sessions?host=/cloudsql/project:region:instance"
317317
)
318318
mock_settings = MagicMock()
319+
mock_settings.session_backend = "database"
319320
mock_settings.session_database_url = cloudsql_url
320321

321322
mock_db_session = MagicMock()
@@ -341,6 +342,7 @@ def test_standard_host_logged_correctly(self, caplog):
341342
"""Test that a standard PostgreSQL host is logged correctly."""
342343
standard_url = "postgresql+asyncpg://user:pass@db.example.com:5432/mydb"
343344
mock_settings = MagicMock()
345+
mock_settings.session_backend = "database"
344346
mock_settings.session_database_url = standard_url
345347

346348
mock_db_session = MagicMock()
@@ -360,13 +362,14 @@ def test_standard_host_logged_correctly(self, caplog):
360362

361363
assert "host=db.example.com" in caplog.text
362364

363-
def test_credentials_not_leaked_on_init_failure(self, caplog):
364-
"""Test that database credentials are sanitized in error logs."""
365+
def test_credentials_not_leaked_on_init_failure(self):
366+
"""Test that database credentials are sanitized in error messages."""
365367
db_url = (
366368
"postgresql+asyncpg://sessions:8dnL1i3eo4GtqwUpKKhNVA@"
367369
"/agent_sessions?host=/cloudsql/project:region:instance"
368370
)
369371
mock_settings = MagicMock()
372+
mock_settings.session_backend = "database"
370373
mock_settings.session_database_url = db_url
371374

372375
error_msg = (
@@ -376,6 +379,10 @@ def test_credentials_not_leaked_on_init_failure(self, caplog):
376379
)
377380

378381
with (
382+
pytest.raises(
383+
RuntimeError,
384+
match=r"Failed to initialize DatabaseSessionService",
385+
) as exc_info,
379386
patch(
380387
"lightspeed_agent.api.a2a.a2a_setup.get_settings",
381388
return_value=mock_settings,
@@ -384,25 +391,41 @@ def test_credentials_not_leaked_on_init_failure(self, caplog):
384391
"google.adk.sessions.DatabaseSessionService",
385392
side_effect=RuntimeError(error_msg),
386393
),
387-
caplog.at_level(logging.WARNING),
394+
):
395+
_get_session_service()
396+
397+
# Password must not appear in the raised error
398+
assert "8dnL1i3eo4GtqwUpKKhNVA" not in str(exc_info.value)
399+
400+
# But the sanitized URL structure should still be present for debugging
401+
assert "://***@" in str(exc_info.value)
402+
403+
def test_memory_backend_used_when_configured(self, caplog):
404+
"""Test that InMemorySessionService is used when SESSION_BACKEND=memory."""
405+
mock_settings = MagicMock()
406+
mock_settings.session_backend = "memory"
407+
408+
with (
409+
patch(
410+
"lightspeed_agent.api.a2a.a2a_setup.get_settings",
411+
return_value=mock_settings,
412+
),
413+
caplog.at_level(logging.INFO),
388414
):
389415
service = _get_session_service()
390416

391-
# Should fall back to InMemorySessionService
392417
from google.adk.sessions import InMemorySessionService
393418

394419
assert isinstance(service, InMemorySessionService)
420+
assert "InMemorySessionService" in caplog.text
395421

396-
# Password must not appear in logs
397-
assert "8dnL1i3eo4GtqwUpKKhNVA" not in caplog.text
398-
399-
# But the sanitized URL structure should still be present for debugging
400-
assert "://***@" in caplog.text
401-
402-
def test_fallback_to_inmemory_when_no_url(self, caplog):
403-
"""Test that InMemorySessionService is used when no session URL is set."""
422+
def test_memory_backend_ignores_database_url(self, caplog):
423+
"""Test that SESSION_BACKEND=memory ignores SESSION_DATABASE_URL."""
404424
mock_settings = MagicMock()
405-
mock_settings.session_database_url = ""
425+
mock_settings.session_backend = "memory"
426+
mock_settings.session_database_url = (
427+
"postgresql+asyncpg://user:pass@host:5432/sessions"
428+
)
406429

407430
with (
408431
patch(
@@ -417,3 +440,29 @@ def test_fallback_to_inmemory_when_no_url(self, caplog):
417440

418441
assert isinstance(service, InMemorySessionService)
419442
assert "InMemorySessionService" in caplog.text
443+
444+
def test_database_backend_returns_database_service(self):
445+
"""Test that SESSION_BACKEND=database returns DatabaseSessionService."""
446+
mock_settings = MagicMock()
447+
mock_settings.session_backend = "database"
448+
mock_settings.session_database_url = (
449+
"postgresql+asyncpg://user:pass@host:5432/sessions"
450+
)
451+
452+
mock_db_session = MagicMock()
453+
454+
with (
455+
patch(
456+
"lightspeed_agent.api.a2a.a2a_setup.get_settings",
457+
return_value=mock_settings,
458+
),
459+
patch(
460+
"google.adk.sessions.DatabaseSessionService",
461+
mock_db_session,
462+
),
463+
):
464+
_get_session_service()
465+
466+
mock_db_session.assert_called_once_with(
467+
db_url="postgresql+asyncpg://user:pass@host:5432/sessions"
468+
)

0 commit comments

Comments
 (0)