Add self-healing for stale activity materialized views - #794
Conversation
When the activity list query returns zero results, check if the base fitness.activity table has data for the user. If it does, the materialized views (v_activity, activity_summary) are stale — refresh them and retry the query. This self-heals after crash recovery or failed view refreshes that leave views populated but outdated. Adds logging and Sentry alerts when stale views are detected. https://claude.ai/code/session_01Sye7TLmEeSX26DZYLUVkgL
The Dashboard and mobile activities screen silently showed "No recent activities" when the tRPC query errored (e.g., server 500, network failure). Now both platforms show a distinct "Failed to load activities" error message, matching the existing error pattern used elsewhere. https://claude.ai/code/session_01Sye7TLmEeSX26DZYLUVkgL
|
Storybook preview for This comment updates automatically on each PR push. |
Mobile Preview
To test on device:
|
There was a problem hiding this comment.
Pull request overview
Adds “self-healing” behavior for stale activity materialized views by detecting empty view results when underlying base-table data exists, refreshing the views, and retrying the query; also surfaces query failures in web/mobile UI.
Changes:
- Server: detect likely-stale activity views, refresh
fitness.v_activity+fitness.activity_summary, and retrylistqueries (with Sentry + logging). - Server: add repository helpers for base-table counting and view refresh with concurrent→non-concurrent fallback.
- Client: display an explicit “Failed to load activities.” error state in web
ActivityListand mobile activities screen, with web unit test coverage.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/web/src/pages/Dashboard.tsx | Passes query error state down to the activity list UI. |
| packages/web/src/components/ActivityList.tsx | Adds an error UI branch when activities fail to load. |
| packages/web/src/components/ActivityList.test.tsx | Adds test coverage for the new error state. |
| packages/server/src/routers/activity.ts | Implements stale-view detection, refresh, retry, and telemetry. |
| packages/server/src/routers/activity.test.ts | Adds tests for stale-view detection/refresh/retry paths. |
| packages/server/src/repositories/activity-repository.ts | Adds base-table counting and materialized-view refresh helpers. |
| packages/mobile/app/activities.tsx | Shows an error message when the activities query fails. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (result.totalCount === 0) { | ||
| const baseCount = await repo.baseTableCount(); | ||
| if (baseCount > 0) { |
There was a problem hiding this comment.
The stale-view check uses result.totalCount === 0 and then calls baseTableCount() (all-time count). This will produce false positives when the user has any historical activities but none within the requested days/endDate window (or when offset > 0 and the page is empty), causing expensive view refreshes on normal empty results. Consider (1) scoping the base-table count to the same time window as list and (2) only running the self-heal check on the first page (e.g., input.offset === 0 and result.items.length === 0).
| /** Count activities in the base table (not the materialized view) for this user. */ | ||
| async baseTableCount(): Promise<number> { | ||
| const rows = await this.query( | ||
| z.object({ count: z.coerce.number() }), | ||
| sql`SELECT count(*)::int AS count FROM fitness.activity WHERE user_id = ${this.userId}`, | ||
| ); | ||
| return rows[0]?.count ?? 0; | ||
| } |
There was a problem hiding this comment.
baseTableCount() counts all activities for the user, but list() filters by started_at > timestampWindowStart(input.endDate, input.days). As a result, users with only older activities will look “stale” and trigger view refreshes even when the materialized view is correct. Update this method (or add a windowed variant) to apply the same time-window filter used by list().
| } catch (refreshError) { | ||
| logger.error(`[activity] Failed to refresh stale views: ${refreshError}`); | ||
| Sentry.captureException(refreshError, { | ||
| tags: { userId: ctx.userId, context: "staleViewRefresh" }, |
There was a problem hiding this comment.
logger.error([activity] Failed to refresh stale views: ${refreshError}) will typically stringify to "Error: ..." and drop the stack trace. Since the winston formatter only prints message, consider logging refreshError instanceof Error ? refreshError.stack ?? refreshError.message : String(refreshError) so production logs contain actionable context.
Address CR feedback: - baseTableCount() now takes (endDate, days) params to apply the same time-window filter as list(), avoiding false-positive refreshes for users with only historical activities - Stale view check only runs on offset === 0 (first page), preventing expensive refreshes on legitimate empty later pages - Error logging now includes stack trace via refreshError.stack instead of toString() which drops actionable context https://claude.ai/code/session_01Sye7TLmEeSX26DZYLUVkgL
Mobile Preview
To test on device:
|
…ed views Adds an admin-only mutation that refreshes all 5 materialized views (v_activity, v_sleep, v_body_measurement, v_daily_metrics, activity_summary) with CONCURRENTLY fallback. Useful for recovering from stale views after crash recovery or failed sync refreshes. https://claude.ai/code/session_01Sye7TLmEeSX26DZYLUVkgL
Mobile Preview
To test on device:
|
|
Preview torn down 🗑️ |
Summary
Implements automatic detection and recovery from stale materialized views in the activity system. When a query returns no results but the base table contains data, the system now automatically refreshes the views and retries the query. This handles edge cases like crash recovery or failed view refresh operations.
Key Changes
Activity Router: Added self-healing logic that detects stale views by comparing materialized view results against base table counts. When staleness is detected, it logs a warning, captures the issue in Sentry, refreshes the views, and retries the query.
Activity Repository:
baseTableCount()method to check the actual count of activities in the base tablerefreshActivityViews()method to refresh bothv_activityandactivity_summarymaterialized views with fallback from concurrent to standard refreshError Handling: Enhanced UI components across web and mobile to display error states:
ActivityListcomponent now accepts anerrorprop and displays error messageActivitiesScreenshows error message when query failsTest Coverage:
Implementation Details
The self-healing mechanism only triggers when:
This prevents unnecessary refresh operations when there's genuinely no data. The refresh attempts concurrent refresh first (non-blocking) and falls back to standard refresh if that fails.
https://claude.ai/code/session_01Sye7TLmEeSX26DZYLUVkgL