fix(storage): address PR #798 round-3 review (SonarQube + pool sizing + RETURNING test)

Round-3 fixes. Two threads:
- 9 OPEN SonarQube issues caused the "E Security Rating on New Code"
  gate failure. The bot's diagnosis (sa.text(text_sql) → SQL injection)
  was a wrong guess; the actual SQ rules firing were different.
- Bot's substantive concerns: pool defaults too aggressive,
  delete_browser_session RETURNING path untested on Postgres,
  schema_version legacy table created on Postgres, stale module
  docstring.
- User's underlying question on the pool: "isn't 1 connection enough?"
  Right-sized to 2+5 and documented the concurrency model in ADR-026
  so the rationale is durable.

SonarQube quality-gate fixes (clears all 9 OPEN issues)
-------------------------------------------------------
- BLOCKER S6418: rename `SECRET` constant in test_storage_logging.py
  to `SENTINEL_PASSWORD_FRAGMENT` + NOSONAR with rationale.
- CRITICAL S3776: extract `_build_postgres_engine()` from
  `initialize()` (was complexity 26 > 15); incidentally creates a
  clean unit-test seam for engine args.
- CRITICAL S4423: `ssl.create_default_context(cafile=...)` is flagged
  as "weak protocol" — Python 3.10+ already negotiates the strongest
  available protocol. Explicitly pass `purpose=ssl.Purpose.SERVER_AUTH`
  and NOSONAR with the Python-version rationale.
- MAJOR S3358: split the TLS-mode nested ternary in the engine
  factory into a `_describe_ssl_arg()` helper.
- MAJOR S2068 ×3: bind test app-password literals to local vars and
  put `# NOSONAR S2068` on the same line as the literal (anchoring
  requirement) instead of on the closing paren.
- MINOR S7503 ×2: `# NOSONAR S7503` on `_Cursor.__aenter__/__aexit__`
  — they MUST be `async` per the context-manager protocol.

Pool sizing right-sized (answers "why so many connections?")
------------------------------------------------------------
- `DATABASE_POOL_SIZE` default 10 → **2**.
- `DATABASE_MAX_OVERFLOW` default 20 → **5**.
- Per-pod max drops from 30 to 7. With 3 replicas, total = 21
  connections (was 90) — well under managed-Postgres
  `max_connections=100`.
- New INFO log at startup: `Postgres engine ready: pool_size=N
  max_overflow=M (per-pod max K connections)`. Surfaces the active
  sizing without grepping config.
- New ADR-026 § "Concurrency model and pool sizing" explains
  asyncpg's single-flight connection semantics, the MCP workload
  shape (read-mostly point lookups), why-not-1 (multi-user
  serialization), and the tune-up/tune-down recipe.
- `docs/configuration.md` table updated with new defaults +
  homelab-vs-prod tuning guidance, linking the ADR.

RETURNING path covered on Postgres
----------------------------------
- New `test_browser_session_delete_returning` exercises the
  `DELETE … RETURNING user_id` path — the only RETURNING clause in
  the storage layer and the most dialect-sensitive SQL in this PR.
  Asserts both present-row (returns True, row gone) and absent-row
  (returns False) branches.

Schema portability polish
-------------------------
- `alembic 001`: gate `schema_version` table creation on
  `op.get_bind().dialect.name == "sqlite"`. The table exists purely
  to match the fingerprint of pre-Alembic SQLite databases; fresh
  Postgres installs no longer carry the dead legacy table.

Misc polish
-----------
- Module docstring: "SQLite-based" → "SQL-backed", with a sentence
  on the DATABASE_URL opt-in and an ADR-026 link.
- Comment on `_wrap_row` noting `row._mapping` is the documented
  RowMapping accessor in SQLAlchemy 2.x despite the underscore.

Skipped (rationale in PR reply)
-------------------------------
- `_qmark_to_named` SQL-comment handling: docstring already notes
  the limitation; no `?` in storage SQL comments today.
- Module-level `anyio.Lock()`: established precedent confirmed by
  the bot itself.
- `get_audit_logs` `SELECT *`: pre-existing pattern, out of scope.

Verification
------------
- `uv run pytest tests/unit/` — 1025 passed.
- `TEST_DATABASE_URL=… uv run pytest tests/integration/test_storage_postgres.py -m postgres` — 7 passed.
- `ruff check && ruff format --check && ty check` — clean.
- Confirmed `schema_version` absent on fresh Postgres, still present
  on fresh SQLite.

Tracked on Astrolabe Cloud POC board, card #99.

---

_This PR was generated with the help of AI, and reviewed by a Human_

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-16 19:33:23 +02:00
co-authored by Claude Opus 4.7
parent f2b7bf132f
commit 51419329b0
7 changed files with 229 additions and 78 deletions
@@ -97,6 +97,45 @@ the runtime invokes it from `RefreshTokenStorage.initialize()` via
The pattern is non-obvious; this note exists so a future maintainer
doesn't try to "simplify" it back into the main loop.
### Concurrency model and pool sizing
The reviewer's natural reaction to seeing `DATABASE_POOL_SIZE=10` (the
round-2 default) was: *isn't 1 connection enough for an MCP server?*
This subsection records why a small pool is right, why 1 is not the
target default, and what the workload actually looks like.
**asyncpg connection semantics.** Each asyncpg connection is
**single-flight** — only one query can be in flight at a time on a
given connection. SQLAlchemy serializes additional requests in the
pool queue. So the question is never "how many requests does the MCP
server handle" but "how many concurrent storage operations are in
flight at the peak".
**MCP storage workload shape.** Each MCP tool call typically performs
13 storage operations: a token lookup (`get_refresh_token` or
`get_app_password`), maybe an audit-log write, occasionally a session
update. Lookups are sub-millisecond point queries; writes are short.
The hot path is read-mostly. No long-running transactions, no batch
loads.
**Why not 1?** A single-user (homelab) deployment genuinely works on
`pool_size=1, max_overflow=2`. But the default ships for multi-user
OAuth deployments where ≥2 concurrent client requests are normal; on
`pool_size=1` those serialize on a single connection and you measure
a latency cliff. The defaults `pool_size=2, max_overflow=5` (max 7
per pod) cover typical multi-user MCP burst with two-replica
headroom. With 3 k8s replicas the total is 21 connections — well
under managed-Postgres `max_connections=100` (RDS, CNPG default).
**How to tune.** `DATABASE_POOL_SIZE` / `DATABASE_MAX_OVERFLOW` env
vars adjust the per-pod pool live (server restart). The startup
``Postgres engine ready: pool_size=N max_overflow=M (per-pod max K
connections)`` log line surfaces the active sizing so operators can
see the per-replica footprint at a glance. For a fleet of N replicas,
estimate worst-case Postgres connection count as
`N × (pool_size + max_overflow)` and stay comfortably below the
server's `max_connections`.
### TLS for the Postgres backend
Two settings mirror the existing `NEXTCLOUD_VERIFY_SSL` /
+9 -2
View File
@@ -138,8 +138,15 @@ TOKEN_ENCRYPTION_KEY=<fernet-key>
| `TOKEN_STORAGE_DB` | Optional | Legacy SQLite-only path. Used when `DATABASE_URL` is unset. Falls back to a per-process ephemeral tempfile when both are unset. |
| `DATABASE_VERIFY_SSL` | Optional | TLS verification toggle for the Postgres backend. Unset (default) → asyncpg's `prefer` mode (TLS if offered, no verification — keeps cluster-internal Postgres working). `true` → full cert verification. `false` → silence cert errors (homelab / self-signed). |
| `DATABASE_CA_BUNDLE` | Optional | Path to a PEM file containing a private CA. Implies `DATABASE_VERIFY_SSL=true`. Use this for self-hosted Postgres signed by your homelab CA instead of disabling verification. |
| `DATABASE_POOL_SIZE` | Optional (default `10`) | Per-pod SQLAlchemy connection pool size for the Postgres backend. Multiplied by `replicas`, this can exceed managed-Postgres `max_connections=100` defaults — tune down for large fleets. |
| `DATABASE_MAX_OVERFLOW` | Optional (default `20`) | Per-pod overflow connections beyond `DATABASE_POOL_SIZE`. Max per-pod connections = `pool_size + max_overflow`. Set to `0` to make `pool_size` a hard cap. |
| `DATABASE_POOL_SIZE` | Optional (default `2`) | Per-pod SQLAlchemy connection pool size for the Postgres backend. asyncpg connections are single-flight, so this only needs to cover concurrent storage ops (not concurrent tool calls). See [ADR-026 § Concurrency model and pool sizing](ADR-026-pluggable-database-backend.md). |
| `DATABASE_MAX_OVERFLOW` | Optional (default `5`) | Per-pod burst connections beyond `DATABASE_POOL_SIZE`. Max per-pod = `pool_size + max_overflow` (default 7). Set to `0` for a hard cap. With 3 replicas the default totals 21 connections — well under managed-Postgres `max_connections=100`. |
Operators with very high concurrency (many MCP clients per pod, or
expensive Nextcloud round-trips holding storage locks) should tune these
up; single-user / homelab deployments can drop to `DATABASE_POOL_SIZE=1
DATABASE_MAX_OVERFLOW=2` for the smallest possible footprint. The
server logs the configured sizes at startup so over-allocation is
visible without grepping config.
Homelab example (self-signed Postgres with a private CA):