Skip to content

perf(api): bound habit-logs read with server-side ordering + row cap - #347

Merged
thomasluizon merged 3 commits into
mainfrom
fix/habit-logs-db-pagination
Jul 12, 2026
Merged

perf(api): bound habit-logs read with server-side ordering + row cap#347
thomasluizon merged 3 commits into
mainfrom
fix/habit-logs-db-pagination

Conversation

@thomasluizon

Copy link
Copy Markdown
Owner

What

GetHabitLogsQuery materialized a habit's entire log history for the 365-day lookback window, ordered it in memory, and returned it with no row cap. This pushes the lookback filter, newest-first ordering, and a 1000-row cap into the database, following the ISocialGraphReader pattern established in #344.

  • New IHabitLogReader (Orbit.Domain.Interfaces) + HabitLogReader (Orbit.Infrastructure.Persistence): query composition (Where(HabitId & Date >= since).OrderByDescending(Date).ThenByDescending(CreatedAtUtc).Take(limit)) runs server-side, backed by the existing IX_HabitLogs_HabitId_Date index.
  • Default 365-day lookback preserved (AppConstants.HabitLogsLookbackDays). Row cap AppConstants.MaxHabitLogsReturned = 1000.
  • Same-date rows now tie-break by CreatedAtUtc descending (was non-deterministic in-memory order) → stable ordering.

Behavior-preserving

  • The response DTO IReadOnlyList<HabitLogResponse> is unchanged → no consumer contract change (orbit-api-only, no packages/shared edits needed).
  • Behavior-preserving for realistic data: a daily habit tops out near 366 logs/year, well under the 1000-row cap. The cap only ever truncates the oldest rows of a pathological (multi-log-per-day, year-long) habit, keeping the newest.
  • The soft-delete query filter still applies (reads from context.HabitLogs), matching prior behavior.

Why the other audited files are untouched

The #243 audit note also named GetHabitScheduleQuery.cs and HabitScheduleFilters.cs. Verified against current main, those concerns are already mitigated by prior campaign work — touching them would regress it:

  • Schedule pagination — the handler already loads a trimmed graph, filters/paginates in memory (recurrence-rule scheduling can't be expressed in SQL), then re-hydrates only the page's subtree with the full graph (chore(nuclear-review): behavior-preserving structural review — orbit-api #253, "reduce habit query egress").
  • Instances array bound — already capped by the MaxRangeDays = 366 validator on the date span.
  • MapChildren recursion depth — already bounded by the MaxHabitDepth = 5 write-time guard in CreateSubHabitCommand. Flattening the nested-children structure would break the response DTO contract.

Tests

  • HabitLogReaderTests (7): habit scoping, lookback-window exclusion (inclusive boundary), newest-first ordering, same-date CreatedAtUtc tie-break, row cap keeps newest, and a >366-day span asserting lookback-then-cap and lookback-then-full-window.
  • GetHabitLogsQueryHandlerTests updated: verifies the reader is called with the correct cutoff (today − 365) and cap, that a not-found habit short-circuits the log read, and that reader ordering is preserved through mapping.
  • Full suites green: Application 2654, Infrastructure 1657.

Refs thomasluizon/orbit-ui-mobile#243

🤖 Generated with Claude Code

…243)

GetHabitLogsQuery materialized a habit's full log history for the
365-day lookback window and ordered it in memory with no row cap. Add
IHabitLogReader (Infrastructure) so the lookback filter, newest-first
ordering, and a 1000-row cap run server-side, backed by the existing
IX_HabitLogs_HabitId_Date index.

- Default 365-day lookback preserved; only the query composition moves
  to the DB. Same-date rows now tie-break by CreatedAtUtc descending
  (was non-deterministic), so ordering is stable.
- Response DTO (IReadOnlyList<HabitLogResponse>) is unchanged, so there
  is no consumer contract change. Behavior-preserving for realistic
  data: a daily habit tops out near 366 logs/year, well under the cap.
- Reader query composition is an internal static, unit-tested via
  LINQ-to-objects for habit scoping, lookback window, ordering, and the
  cap over a >366-day span.

The sibling concerns in the audit are already mitigated and left as-is
to avoid regressing prior campaign work: GetHabitScheduleQuery already
loads a trimmed graph, filters/paginates in memory (recurrence rules
cannot be expressed in SQL), then re-hydrates only the page's subtree
(#253); the schedule Instances array is bounded by the MaxRangeDays=366
validator and MapChildren recursion by the MaxHabitDepth=5 write guard.

Refs thomasluizon/orbit-ui-mobile#243

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: PR #347 — perf(api): bound habit-logs read with server-side ordering + row cap

Scope: PR #347 in thomasluizon/orbit-api
Recommendation: APPROVE

Summary

This PR pushes GetHabitLogsQuery's lookback filter, newest-first ordering, and a 1000-row cap into the database via a new IHabitLogReader/HabitLogReader pair, following the ISocialGraphReader pattern from #344. The response DTO is unchanged, the ownership check still gates the log read, and the change ships with thorough new unit test coverage (HabitLogReaderTests for the query-composition logic, updated GetHabitLogsQueryHandlerTests for the handler wiring). The diff is small, behavior-preserving, and directly addresses a real resource-exhaustion risk (previously unbounded in-memory materialization of a habit's full log history).

Findings

Critical

None.

High

None.

Medium

None.

Low / Info

  • [Info] SonarCloud's automated Quality Gate comment on this PR reports 72.2% coverage on new code (required ≥80%). This is a separate required check, not a rubric finding — flagging for visibility only since it may block merge independently of this review.

Subagents

Agent Verdict
security-reviewer PASS — [Authorize] intact on both HTTP and MCP entry points, UserId sourced from JWT claims (not client input), the pre-existing ownership check (h.Id == HabitId && h.UserId == UserId) still runs before the new reader is ever invoked and short-circuits on HabitNotFound, .AsNoTracking() used correctly, and the row cap/lookback window close a genuine prior DoS/resource-exhaustion risk.
contract-aligner N/A — no DTO, Controller route, or packages/shared file touched; response shape (HabitLogResponse) is byte-for-byte unchanged, confirmed by diff inspection.

Validation

Check Result
Build (dotnet) N/A in CI — separate required check
Tests (dotnet) N/A in CI — separate required check

Deferred — N/A dimensions & files not verdicted

  • Dimension 8 (DESIGN.md/AI-slop): N/A — no apps/* UI files in this diff (backend-only repo).
  • Dimension 9 (Parity) / 10 (i18n): N/A — frontend-only dimensions, no orbit-ui-mobile surface touched.
  • Dimension 14 (FEATURES.md parity): N/A — pure performance refactor with no user-facing behavior change (response DTO and semantics for realistic data volumes are unchanged per the PR description).
  • Every changed file (ServiceCollectionExtensions.cs, AppConstants.cs, GetHabitLogsQuery.cs, IHabitLogReader.cs, HabitLogReader.cs, both test files) received a verdict above. Nothing deferred.

What's good

  • Correctly follows the established ISocialGraphReader reader pattern instead of inventing a new one.
  • Same-date tie-break (CreatedAtUtc descending) fixes a latent non-determinism bug in the old in-memory OrderByDescending(Date)-only sort, not just a perf win.
  • Test coverage is genuinely thorough: boundary-inclusive lookback, cap-keeps-newest, a >366-day span exercising both lookback-then-cap and lookback-then-full-window paths, and a handler test asserting the reader is never called when the habit isn't found (verifying the ownership short-circuit survives the refactor).
  • AppConstants.HabitLogsLookbackDays/MaxHabitLogsReturned replace a handler-local magic constant, consistent with the DRY table in Orbit.Application/CLAUDE.md.

Recommendation

Approve as-is. No action required before merge from this review; the SonarCloud coverage gate (if it blocks merge) is outside this review's scope to resolve.

ReadRecentLogsAsync was only exercised through the LINQ-to-objects
BuildRecentLogs static, leaving the EF wrapper uncovered. Add
in-memory-context tests that also validate the real translation:
newest-first ordering, the lookback window, the row cap, habit
scoping, and exclusion of soft-deleted logs via the query filter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: PR #347 — perf(api): bound habit-logs read with server-side ordering + row cap

Recommendation: APPROVE

Summary

This PR pushes GetHabitLogsQuery's lookback filter, newest-first ordering, and a 1000-row cap into the database via a new IHabitLogReader/HabitLogReader pair, following the ISocialGraphReader pattern from #344. The response DTO is unchanged, the ownership check still gates the log read before the reader is ever invoked, and the change ships with thorough unit test coverage.

A prior review of this PR (commit ee5bde4) already approved with no Critical/High/Medium findings, flagging only an Info note that SonarCloud reported 72.2% new-code coverage (below the 80% gate). The one commit added since that review (fd1d3f6) is purely additive test coverage for the HabitLogReader EF wrapper — 3 new xUnit tests exercising ReadRecentLogsAsync against an in-memory DbContext (habit scoping + lookback + newest-first ordering, row-cap-keeps-newest, and soft-delete exclusion) plus a CreateInMemoryDbContext helper. No production code changed in this commit; it directly targets the previously-flagged coverage gap.

Findings

Critical

None.

High

None.

Medium

None.

Low / Info

None new. (Prior Info note on SonarCloud coverage is now addressed by the added DB-wrapper tests; re-verify the gate status independently since it's a separate required check.)

Subagents

Agent Verdict
security-reviewer PASS — ownership check (h.Id == HabitId && h.UserId == UserId) unchanged and still gates the new reader; HabitId is only reachable after that check succeeds, so no IDOR path. ReadRecentLogsAsync queries context.HabitLogs.AsNoTracking() with no IgnoreQueryFilters(), so the global soft-delete filter (HasQueryFilter(l => !l.IsDeleted)) still applies — now also explicitly covered by HabitLogReaderTests.ReadRecentLogsAsync_ExcludesSoftDeletedLogs. Fully parameterized LINQ, no raw SQL/injection surface. Lookback cutoff and row cap are both server-computed constants, not attacker-controlled — no new data-exposure surface. No new logging.

Validation

Check Result
Build (dotnet) N/A in CI — separate required check
Tests (dotnet) N/A in CI — separate required check

What's good

  • New tests exercise the real EF translation (in-memory DbContext), not just the LINQ-to-objects BuildRecentLogs static — closes the gap between "query composition is unit-tested" and "the actual DB wrapper method is exercised."
  • Soft-delete exclusion is now explicitly asserted at the reader level, not just assumed from the global query filter configuration.
  • Diff since last review is test-only — zero behavioral risk added.

Recommendation

Approve. No action required before merge from this review.

@sonarqubecloud

Copy link
Copy Markdown

@thomasluizon
thomasluizon merged commit 9f5b695 into main Jul 12, 2026
19 checks passed
@thomasluizon
thomasluizon deleted the fix/habit-logs-db-pagination branch July 12, 2026 17:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant