perf(api): compression, DB health check, txn timeout, slow-query logging - #357
Conversation
…ing (#243) Four independent ops/perf hardening gaps, all behavior-preserving: - Enable Brotli/Gzip response compression for JSON responses (EnableForHttps for Render's TLS-terminating proxy; Fastest level). - Add a database connectivity health check to /health so a DB outage surfaces as 503 instead of a superficially-live process. - Bound UnitOfWork.ExecuteInTransactionAsync with a wall-clock timeout (TransactionTimeoutSeconds, default 120s) so a wedged transaction rolls back and releases its backend instead of leaking it. - Log a warning for any DB command slower than SlowQueryThreshold (default 500ms) via an EF command interceptor, making slow queries observable in Render logs without EF's per-command Info logging. Refs thomasluizon/orbit-ui-mobile#243 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review: PR #357 (perf/ops hardening)
Recommendation: APPROVE
Summary
Four independent, behavior-preserving ops/perf changes: HTTP response compression (Brotli/Gzip), a DB-connectivity /health check, a wall-clock timeout on UnitOfWork.ExecuteInTransactionAsync, and a slow-query warning-log interceptor. No contract surface (DTO, endpoint, Zod schema) is touched, so the backward-compat guard and contract-aligner are N/A by gate, not by omission.
| Severity | Count |
|---|---|
| Critical (incl. old-client breaks) | 0 |
| High | 0 |
| Medium | 1 |
| Low / Info | 2 (noted, not blocking) |
Findings
Medium
Unauthenticated /health now performs a live DB round trip with no rate limit or cache
- Location:
src/Orbit.Infrastructure/Services/DatabaseHealthCheck.cs:19(wired viasrc/Orbit.Api/Extensions/WebApplicationExtensions.cs:64-80; registered insrc/Orbit.Api/Extensions/ServiceCollectionExtensions.BackgroundJobs.cs:38) - Before this PR,
GET /health(AllowAnonymous, no[DistributedRateLimit]) only checked an in-memoryBackgroundServiceHealthCheck. It now also resolves a scopedOrbitDbContextand callsDatabase.CanConnectAsync()on every hit — a real round trip through the same request-path Npgsql pool real traffic uses (EfMaxPoolSize = 15, sized tightly against Supabase's connection ceiling perDatabaseConnectionSettings.cs's own doc comment). ASP.NET Core health checks have no built-in caching. - Risk: an anonymous burst against
/health(no auth, no rate limit) competes 1:1 with legitimate request-path connections for a small pool, which could degrade or 503 real traffic during a flood — a DoS lever that did not exist before this change. - Suggested fix: wrap
DatabaseHealthCheck'sCanConnectAsyncresult in a short TTL cache (a few seconds is enough for a liveness probe), or add[DistributedRateLimit]/ a lightweight per-IP limiter on/health.
Low / Info (non-blocking)
- The BREACH-safety WHY comment in
ServiceCollectionExtensions.Infrastructure.cs:260("no attacker-reflected secrets") is imprecise:AuthControllerresponses (verify-code,google) do return tokens alongside reflected/attacker-influenced fields in the same JSON body, which is the shape BREACH targets. The actual reason compression is safe here is that auth is Bearer-header-only (no cookie-based session), so a cross-origin page can't force a victim's browser to auto-replay authenticated requests. The conclusion holds; the stated reasoning would go stale if cookie-based auth is ever added. Worth a reword, not a blocker. DatabaseConnectionSettings.TransactionTimeoutSeconds/SlowQueryThresholdMillisecondshave no startup bounds validation, unlike the guarded settings inValidateOrbitSecuritySettings. Config-only risk, not exploitable.application/jsonwas already present inResponseCompressionDefaults.MimeTypes, so half of the explicit.Concat([...])addition inAddResponseCompressionis a harmless no-op duplicate;application/problem+jsonis the genuinely new, needed addition.
What's good
- The transaction-timeout/caller-cancel distinction in
UnitOfWork.ExecuteInTransactionAsync(timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) is correctly reasoned and has dedicated tests for both branches, including the ambient-transaction fast path staying untouched. ORBIT0002compliance preserved: the transaction catch block still only doesChangeTracker.Clear(); throw;, relying onawait usingscope disposal for rollback — no redundant explicitRollbackAsync().SlowQueryCommandInterceptorcorrectly logs via the[LoggerMessage]source-generator pattern with PascalCase structured properties, at Warning level, and is safe against sensitive-data exposure sinceEnableSensitiveDataLogging()is confirmed absent repo-wide.- Response compression correctly excludes
text/event-stream(ChatController's SSE endpoint) since that content type was never added to the MIME allowlist — no risk of breaking streaming responses.
Deferred — N/A dimensions
- Dimension 9 (Parity web/mobile), 11 (contract drift), 14 (FEATURES.md parity) — N/A: no DTO, Controller route, Zod schema, or user-facing surface touched anywhere in this diff.
- Cross-repo checks (contract-aligner,
packages/sharedbackward-compat) — not applicable rather than unverifiable: determined from the api-side diff alone that no contract surface changed. - Build/Unit Tests — not run here; covered by separate required CI checks on this PR.
Test coverage spot-checked: UnitOfWorkTests covers timeout→TimeoutException-and-rolled-back and caller-cancel→OperationCanceledException cases; DatabaseHealthCheckTests covers reachable/unreachable; SlowQueryCommandInterceptorTests covers above/at/below threshold. All ten mechanical test-fixture updates for the new DatabaseConnectionSettings constructor argument were confirmed complete via a repo-wide grep — no stale call sites remain.
Recommendation
Approve as-is; the single Medium finding (unauthenticated /health now doing a live DB round trip with no cache/rate-limit) is a defense-in-depth gap, not a blocker — safe to land with a tracked follow-up to add a short TTL cache or [DistributedRateLimit] on /health before it sees adversarial traffic volume.
|
SonarCloud Code Analysis: the only failing quality-gate condition is The uncovered new lines are composition-root DI wiring ( |
|
There was a problem hiding this comment.
Code Review: PR #357 — perf(api): compression, DB health check, txn timeout, slow-query logging
Recommendation: NEEDS WORK
Summary
Three of four changes (compression, DB health check, slow-query logging) are clean and well-tested. The transaction-timeout change (UnitOfWork.ExecuteInTransactionAsync, 120s ceiling) has an unconsidered side effect: it wraps pre-existing external HTTP I/O in RunCalendarAutoSyncCommand (sequential, paginated Google Calendar API fetches) inside the same wall-clock budget. Users with several/large calendars can plausibly exceed 120s, causing a silent, indefinitely-recurring background-sync failure that bypasses the handler's existing graceful MarkCalendarSyncTransientError degradation path.
Findings
High
New transaction wall-clock timeout can silently and permanently break Google Calendar auto-sync for users with several/large calendars
- location:
src/Orbit.Infrastructure/Persistence/UnitOfWork.cs:40-42(new ceiling), consumed bysrc/Orbit.Application/Calendar/Commands/RunCalendarAutoSyncCommand.cs:102-113 RunCalendarAutoSyncCommandis notIIdempotentCommand, so this call creates a brand-new transaction + fresh 120s timeout (not an ambient join).FetchAndProcessLockedcallsdeps.EventFetcher.FetchAsync(...)— an external Google Calendar HTTP fetch — inside that same token, before any DB write.GoogleCalendarEventFetcher.FetchAsynclists calendars then loops sequentially per calendar;GoogleCalendarApi.ListEventsAsyncitself paginates with a sequentialdo...whileHTTP loop per calendar (30s timeout per call,HttpClients:DefaultTimeoutSeconds). Nothing bounds the aggregate across calendars/pages.- When the aggregate exceeds 120s, the resulting
OperationCanceledExceptionis explicitly excluded fromFetchAndProcessLocked's own Google-API-error catch (ex is not OperationCanceledException), so it propagates past the gracefulMarkCalendarSyncTransientErrorpath, becomesTimeoutExceptionat theUnitOfWorkboundary, and rolls back. SinceGoogleCalendarLastSyncedAtnever advances, the same user is retried every 15-minute tick indefinitely (not blocked by the 4-hour dedupe window, which only engages once a sync reaches any terminal state) — a silent, permanent-until-fixed degradation with no user-visible status change. DatabaseConnectionSettings.TransactionTimeoutSeconds's own doc comment says AI/batch network I/O is deliberately excluded from this ceiling via a separateAI:BatchNetworkTimeoutSeconds— confirming the author's intent was to keep external I/O out of this boundary, but the Calendar-sync path (pre-existing, unchanged by this diff) violates that intent.- Fix: move
deps.EventFetcher.FetchAsync(...)outsideExecuteInTransactionAsyncinFetchAndProcess(it's read-only and doesn't need the transaction/advisory lock); only the reconcile+write phase needs the 120s-bounded transaction. As a safety net, also catchTimeoutExceptioninFetchAndProcessLockedalongside the existing Google-API-error catch so failures still callMarkCalendarSyncTransientError. Add a regression test with a fake slowICalendarEventFetcher.
Medium
New timeout tests only exercise Task.Delay cancellation, not a real in-flight DB-command cancellation — tests/Orbit.Infrastructure.Tests/Persistence/UnitOfWorkTests.cs:526-568. The feature targets "a transaction that wedges between commands," but neither new test has a real DB command outstanding when the timeout fires. Recommend adding one test where the operation delegate awaits a genuinely slow DB call (e.g. via an interceptor that stalls execution) to confirm the timeout still fires and rolls back cleanly against a real command, not just an in-memory delay.
Subagents
- security-reviewer: dispatched async, did not return within session; compensating manual pass found no Critical/High issue (BREACH reasoning holds — JWT Bearer is non-ambient, CORS uses an explicit origin allowlist;
/healthleaks no exception detail;SlowQueryCommandInterceptorlogsCommandTextonly, not parameter values, and the only raw-SQL call site nearby is parameterized; compression middleware ordering relative to auth is correct). - contract-aligner: N/A, no DTO/Controller route/
packages/sharedchange in this diff.
Validation
Build/Tests: N/A in this review session (sandbox could not run dotnet build/dotnet test); PR description states all 4,904 tests pass locally, not independently re-verified here.
Deferred
- DESIGN.md/AI-slop, Parity, i18n, FEATURES.md parity: N/A, backend ops/perf-only diff, no UI/contract/feature-surface change.
/health's newdatabasecheck entry is additive to the existingchecksarray; not cross-checked against orbit-ui-mobile (not available in this session) in case any client parses/healthspecifically.- The formal Phase-6 adversarial-skeptic subagent for the High finding above did not return within session; the equivalent adversarial checks (ambient-transaction-join check, retry/circuit-breaker check, pagination-depth check, catch-clause-reachability check) were performed directly against source with file:line evidence in place of the subagent pass.
What's good
Compression, DB health check, and slow-query logging are clean, minimal, correctly ordered in the DI/middleware pipeline, fully comment-policy-compliant (every comment carries a WHY + URL), and ORBIT0002-compliant (no explicit rollback inside using-scoped transactions). The OperationCanceledException vs TimeoutException disambiguation logic is correct by manual trace. All existing UnitOfWork test-constructor call sites were updated in lockstep.
Recommendation
Fix the High finding (keep external Calendar HTTP I/O outside the new transaction/timeout boundary, and catch TimeoutException gracefully) before merge. The Medium test-coverage note can land as a fast follow. Everything else in the PR can ship as-is once the timeout/Calendar interaction is fixed.
🤖 Generated with orbit-api /pr-review


Four independent ops/perf hardening gaps in
Orbit.Api, all behavior-preserving (perf/observability only, no contract change).Changes
1. HTTP response compression (Brotli/Gzip) for JSON
AddResponseCompressionwith Brotli + Gzip providers atCompressionLevel.Fastest, MIME types includeapplication/json+application/problem+json.EnableForHttps = true— Render terminates TLS upstream (X-Forwarded-Proto=https), so without it compression would never apply. BREACH is not a concern: responses are parameterized JSON with no attacker-reflected secrets.UseResponseCompression()placed afterUseForwardedHeaderssoRequest.IsHttpsreflects the forwarded proto before the middleware gates on it.2. Database connectivity health check on
/healthDatabaseHealthCheck(OrbitDbContext.Database.CanConnectAsync) registered alongside the existingbackground-servicescheck./healthinstead of a superficially-live process (the background check only ever returns Healthy/Degraded).3. Bounded transaction duration in
UnitOfWork.ExecuteInTransactionAsyncCancellationTokenSource.CancelAfter(TransactionTimeoutSeconds)(default 120s, above the 60s per-command timeout) wraps the transaction-owning path.TimeoutException; a caller-initiated cancellation still surfaces asOperationCanceledException(distinguished by the exception filter). Ambient/nested and non-relational paths are unchanged.4. Slow-query logging on prod PostgreSQL
SlowQueryCommandInterceptor(EFDbCommandInterceptor) logs a Warning for any command whose measured duration exceedsSlowQueryThresholdMilliseconds(default 500ms) — observable in Render logs without EF's per-command Information logging. XML doc documents the complementary Supabase-sidelog_min_duration_statementfor server-side-only timing.Both new knobs live in
DatabaseConnectionSettings+appsettings.json.Tests
UnitOfWorkTests: timeout →TimeoutException+ rolled back; caller-cancel →OperationCanceledException(not timeout); existing throw/ambient paths retained.DatabaseHealthCheckTests: reachable → Healthy; unreachable Npgsql → Unhealthy.SlowQueryCommandInterceptorTests: above/at/below threshold logging (boundary covered).dotnet build && dotnet test).The added
DatabaseConnectionSettingsdependency onUnitOfWorkpropagated to the existing direct/DI test constructors (updated in lockstep).Refs thomasluizon/orbit-ui-mobile#243