perf(api): add composite indexes for SyncController.GetChangesV2 scans - #352
Conversation
GetChangesV2 fetches all rows changed since a timestamp for seven synced entities, filtering each by its owner key plus UpdatedAtUtc. None of those predicates had a supporting composite index, so every sync fell back to a scan filtered on UpdatedAtUtc. Add leading-column btree indexes matching each query's exact predicate: - Habits, Goals, Tags, Notifications, ChecklistTemplates: (UserId, UpdatedAtUtc) - HabitLogs: (HabitId, UpdatedAtUtc) -- filtered by HabitId IN (...), no UserId column - GoalProgressLogs: (GoalId, UpdatedAtUtc) -- filtered by GoalId IN (...), no UserId column ProcessedRequests.UserId needs no dedicated FK index: the composite unique index (UserId, IdempotencyKey, RequestType) already leads with UserId and serves the cascade, which is why EF's convention suppressed a duplicate. Migration uses CREATE INDEX IF NOT EXISTS / DROP INDEX IF EXISTS to stay idempotent across deploys (avoids the #338 42P07 class of failure). Behavior-preserving; performance only. 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 #352 — perf(api): add composite indexes for SyncController.GetChangesV2 scans
Recommendation: APPROVE
Summary
Narrowly-scoped, behavior-preserving performance PR: adds 7 composite btree indexes (via both the EF fluent-API in OrbitDbContext.cs and a matching idempotent raw-SQL migration) to support the exact predicates SyncController.GetChangesV2 (src/Orbit.Api/Controllers/SyncController.cs:146-190) uses for delta-sync — no controller code, DTOs, or endpoints changed. Every one of the 7 LINQ predicates in GetChangesV2 was traced against the added indexes and they match column-for-column and in the correct leading-column order; the "no UserId on HabitLog/GoalProgressLog" and "ProcessedRequests already covered by its composite unique index" claims in the PR body both check out against the actual entity/config code.
Findings
Critical
None.
High
None.
Medium
None.
Low / Info
- Migration uses plain
CREATE INDEX, notCONCURRENTLY(src/Orbit.Infrastructure/Migrations/20260712190945_AddSyncChangesV2Indexes.cs:14-46) — each of the 7 statements takes a SHARE lock, blocking writes during the build. This exactly matches established repo precedent (20260613001629_AdoptPerformanceIndexes.csuses the identical idiom) — not a regression, flagged for awareness only. - Some entities now carry 3 indexes all leading with the same column (e.g.
Goal:UserId/(UserId,IsDeleted)/(UserId,UpdatedAtUtc)inOrbitDbContext.cs:855-857) — partially redundant with the standalone single-column index, marginal write-amplification cost. Pre-existing pattern, not introduced by this diff; out of scope here.
Subagents
| Agent | Verdict |
|---|---|
| security-reviewer | PASS — no SQL injection (compile-time literal identifiers), no auth/authz surface touched, idempotency guard is standard practice |
| contract-aligner | N/A — no DTO/Controller route/packages/shared type changed |
What's good
- Exact predicate-to-index mapping traced and verified against the live
GetChangesV2LINQ. - Correctly identifies
HabitLog/GoalProgressLoghave noUserIdcolumn and indexes the actual FK used (HabitId/GoalId) instead of blindly following theUserIdpattern of the other 5 entities. ProcessedRequests"intentionally skipped" rationale is correct and locked in by a dedicated test, not just prose.- Migration follows the established idempotent
IF NOT EXISTS/IF EXISTSidiom, withDown()in exact reverse order ofUp(). - New
SyncChangesIndexConfigurationTests.csasserts via EF InMemory model inspection that each of the 7 synced entities declares the composite index in the exact expected column order — will catch future drift if query predicates change without the index following.
Validation
Build/Unit Tests/SonarCloud run as separate required CI checks for this PR (skipped here per workflow). PR body reports the full suite green and dotnet ef migrations has-pending-model-changes clean.
Recommendation
Merge as-is. No Critical or High findings. The two Info-level observations are pre-existing repo conventions, not regressions from this diff.



What
SyncController.GetChangesV2(src/Orbit.Api/Controllers/SyncController.cs:146) is the delta-sync endpoint: for each of seven synced entities it fetches every row changed since a client-supplied timestamp, filtering by the entity's owner key plusUpdatedAtUtc. None of those predicates had a supporting composite index, so each query degraded to a scan filtered onUpdatedAtUtc.This adds one leading-column btree index per entity, matching each query's exact predicate:
UserId == u && UpdatedAtUtc > since(UserId, UpdatedAtUtc)UserId == u && UpdatedAtUtc > since(UserId, UpdatedAtUtc)UserId == u && UpdatedAtUtc > since(UserId, UpdatedAtUtc)UserId == u && UpdatedAtUtc > since(UserId, UpdatedAtUtc)UserId == u && UpdatedAtUtc > since(UserId, UpdatedAtUtc)HabitId IN (...) && UpdatedAtUtc > since(HabitId, UpdatedAtUtc)GoalId IN (...) && UpdatedAtUtc > since(GoalId, UpdatedAtUtc)HabitLogsandGoalProgressLogshave noUserIdcolumn — GetChangesV2 fetches them by the parentHabitId/GoalIdset. The correct index keys off those FK columns, notUserId.ProcessedRequests.UserId — intentionally skipped (no dedicated FK index)
The audit flagged
ProcessedRequests.UserIdas lacking an FK index. It does not need one: the table already has the composite unique index(UserId, IdempotencyKey, RequestType), whose leading column isUserId, so it fully serves theON DELETE CASCADElookup. That is exactly why EF'sForeignKeyIndexConventiondid not auto-generate a standalone index there. Adding one would be a redundant write-amplifying duplicate. A test locks this rationale in.#338-safety
Every index is emitted as
CREATE INDEX IF NOT EXISTS(withDROP INDEX IF EXISTSonDown), matching the repo's post-#338 pattern inAdoptPerformanceIndexes. I grepped all ofsrc/Orbit.Infrastructure/Migrations/**— none of these index names or(col, UpdatedAtUtc)column combinations exist in any prior migration, andUpdatedAtUtcappears in zero existing indexes. So the migration cannot hit the42P07 relation already existsdeploy crash.Tests
SyncChangesIndexConfigurationTests— aTheoryasserting each synced entity declares the composite index in the exact column order (order matters for btree usability), plus aFactverifying theProcessedRequestcomposite index leads withUserId.dotnet ef migrations has-pending-model-changes→ clean (model ↔ snapshot in sync).Behavior-preserving; performance only.
Refs thomasluizon/orbit-ui-mobile#243