Skip to content

Add self-healing for stale activity materialized views - #794

Merged
Asherlc merged 4 commits into
mainfrom
claude/fix-missing-activities-h06If
Apr 8, 2026
Merged

Asherlc merged 4 commits into
mainfrom
claude/fix-missing-activities-h06If

Conversation

@Asherlc

@Asherlc Asherlc commented Apr 8, 2026

Copy link
Copy Markdown
Owner

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:

    • Added baseTableCount() method to check the actual count of activities in the base table
    • Added refreshActivityViews() method to refresh both v_activity and activity_summary materialized views with fallback from concurrent to standard refresh
  • Error Handling: Enhanced UI components across web and mobile to display error states:

    • Web: ActivityList component now accepts an error prop and displays error message
    • Mobile: ActivitiesScreen shows error message when query fails
    • Dashboard: Passes error state to ActivityList component
  • Test Coverage:

    • Added mocks for Sentry and logger in activity router tests
    • Added three new test cases covering: empty results with no base data, stale view detection and refresh, and genuine empty state
    • Updated existing test expectations to account for the new base table count check

Implementation Details

The self-healing mechanism only triggers when:

  1. The materialized view query returns zero results
  2. The base table contains data (count > 0)

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

claude added 2 commits April 8, 2026 19:15
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
Copilot AI review requested due to automatic review settings April 8, 2026 19:42
@github-actions

github-actions Bot commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Storybook preview for 20c07368 is ready: Open Storybook

This comment updates automatically on each PR push.

@github-actions

github-actions Bot commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Mobile Preview

Channel pr-794
Deep Link dofek://preview/pr-794
Commit c2e2c98

To test on device:

  1. Build and install the preview client: PREVIEW_CHANNEL=pr-794 npx expo prebuild --clean -p ios
  2. Or tap deep link on an existing preview build: dofek://preview/pr-794

Each PR gets its own channel. Build a preview client with PREVIEW_CHANNEL=pr-{N} to test.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 retry list queries (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 ActivityList and 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.

Comment thread packages/server/src/routers/activity.ts Outdated
Comment on lines +55 to +57
if (result.totalCount === 0) {
const baseCount = await repo.baseTableCount();
if (baseCount > 0) {

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
Comment on lines +338 to +345
/** 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;
}

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

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().

Copilot uses AI. Check for mistakes.
Comment on lines +70 to +73
} catch (refreshError) {
logger.error(`[activity] Failed to refresh stale views: ${refreshError}`);
Sentry.captureException(refreshError, {
tags: { userId: ctx.userId, context: "staleViewRefresh" },

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
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
@github-actions

github-actions Bot commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Mobile Preview

Channel pr-794
Deep Link dofek://preview/pr-794
Commit 75e8d09

To test on device:

  1. Build and install the preview client: PREVIEW_CHANNEL=pr-794 npx expo prebuild --clean -p ios
  2. Or tap deep link on an existing preview build: dofek://preview/pr-794

Each PR gets its own channel. Build a preview client with PREVIEW_CHANNEL=pr-{N} to test.

…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
@github-actions

github-actions Bot commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Mobile Preview

Channel pr-794
Deep Link dofek://preview/pr-794
Commit 20c0736

To test on device:

  1. Build and install the preview client: PREVIEW_CHANNEL=pr-794 npx expo prebuild --clean -p ios
  2. Or tap deep link on an existing preview build: dofek://preview/pr-794

Each PR gets its own channel. Build a preview client with PREVIEW_CHANNEL=pr-{N} to test.

@Asherlc
Asherlc merged commit 03132c3 into main Apr 8, 2026
54 checks passed
@Asherlc
Asherlc deleted the claude/fix-missing-activities-h06If branch April 8, 2026 20:16
@github-actions

github-actions Bot commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Preview torn down 🗑️
PR closed/merged

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.

3 participants