From 5f5a5e1eb93d245df211e22bdb819dfdc0fa4925 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Fri, 17 Apr 2026 23:11:23 -0400 Subject: [PATCH 1/2] feat(dashboard): GitHub cache tuning for My lists and repo signals Introduce a shorter mine policy for My pulls/issues/reviews on client and server, refresh mine aggregates without merging closed items back from cache, and shorten the signal poll fallback. Scope repo list and repo-filtered user search caches to repoMeta instead of global mine keys to cut spurious refetches and rate-limit churn. --- apps/dashboard/src/lib/github-cache-policy.ts | 5 ++ apps/dashboard/src/lib/github.functions.ts | 66 ++++++++++++++++--- apps/dashboard/src/lib/github.query.ts | 8 +-- .../src/lib/use-github-signal-stream.ts | 5 +- 4 files changed, 70 insertions(+), 14 deletions(-) diff --git a/apps/dashboard/src/lib/github-cache-policy.ts b/apps/dashboard/src/lib/github-cache-policy.ts index 3af82a3..882616f 100644 --- a/apps/dashboard/src/lib/github-cache-policy.ts +++ b/apps/dashboard/src/lib/github-cache-policy.ts @@ -11,6 +11,11 @@ export const githubCachePolicy = { staleTimeMs: 2 * 60 * 1000, gcTimeMs: 60 * 60 * 1000, }, + /** User-scoped "My pulls/issues/reviews" aggregates — shorter TTL + faster webhook alignment */ + mine: { + staleTimeMs: 30 * 1000, + gcTimeMs: 15 * 60 * 1000, + }, detail: { staleTimeMs: 30 * 1000, gcTimeMs: 10 * 60 * 1000, diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 1251ea9..c320ecf 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -641,6 +641,19 @@ function toMutationError(action: string, error: unknown): MutationResult { return { ok: false, error: `Failed to ${action}: Unknown error` }; } +/** When search is scoped to one repo, webhook `repoMeta` invalidates this cache; otherwise TTL only (global mine signals would misfire). */ +function revalidationSignalKeysForUserItemSearch(input: { + owner?: string; + repo?: string; +}): string[] { + const owner = input.owner?.trim(); + const repo = input.repo?.trim(); + if (owner && repo) { + return [githubRevalidationSignalKeys.repoMeta({ owner, repo })]; + } + return []; +} + export type PullFromRepoInput = { owner: string; repo: string; @@ -4459,6 +4472,27 @@ function mergeMyIssuesResults(results: MyIssuesResult[]): MyIssuesResult { }; } +/** Full refresh replaces cache so merged/closed items drop off; union only on partial timeout. */ +function mergeMyPullsCachedWithFresh( + existing: MyPullsResult, + fresh: MyPullsResult, +): MyPullsResult { + if (fresh.timedOut) { + return mergeMyPullsResults([existing, fresh]); + } + return fresh; +} + +function mergeMyIssuesCachedWithFresh( + existing: MyIssuesResult, + fresh: MyIssuesResult, +): MyIssuesResult { + if (fresh.timedOut) { + return mergeMyIssuesResults([existing, fresh]); + } + return fresh; +} + function buildSourceSearchQuery({ itemType, role, @@ -4492,14 +4526,14 @@ async function getMyPullsResult({ userId: context.session.user.id, resource: "pulls.mine.graphql.v2", params: { username }, - freshForMs: githubCachePolicy.list.staleTimeMs, + freshForMs: githubCachePolicy.mine.staleTimeMs, signalKeys: [ githubRevalidationSignalKeys.pullsMine, githubRevalidationSignalKeys.installationAccess, ], namespaceKeys: [githubRevalidationSignalKeys.pullsMine], cacheMode: "split", - merge: (existing, fresh) => mergeMyPullsResults([existing, fresh]), + merge: mergeMyPullsCachedWithFresh, fetcher: async () => { const deadlineAt = Date.now() + MY_SEARCH_TOTAL_TIMEOUT_MS; const sources = await getMySearchSources(context, username, deadlineAt); @@ -4683,14 +4717,14 @@ async function getMyIssuesResult({ userId: context.session.user.id, resource: "issues.mine.graphql.v2", params: { username }, - freshForMs: githubCachePolicy.list.staleTimeMs, + freshForMs: githubCachePolicy.mine.staleTimeMs, signalKeys: [ githubRevalidationSignalKeys.issuesMine, githubRevalidationSignalKeys.installationAccess, ], namespaceKeys: [githubRevalidationSignalKeys.issuesMine], cacheMode: "split", - merge: (existing, fresh) => mergeMyIssuesResults([existing, fresh]), + merge: mergeMyIssuesCachedWithFresh, fetcher: async () => { const deadlineAt = Date.now() + MY_SEARCH_TOTAL_TIMEOUT_MS; const sources = await getMySearchSources(context, username, deadlineAt); @@ -5257,7 +5291,10 @@ export const getPullsFromUser = createServerFn({ method: "GET" }) repo: data.repo, }, freshForMs: githubCachePolicy.list.staleTimeMs, - signalKeys: [githubRevalidationSignalKeys.pullsMine], + signalKeys: revalidationSignalKeysForUserItemSearch({ + owner: data.owner, + repo: data.repo, + }), request: (headers) => context.octokit.rest.search.issuesAndPullRequests({ q: buildUserSearchQuery({ @@ -5302,7 +5339,12 @@ export const getPullsFromRepo = createServerFn({ method: "GET" }) direction: data.direction ?? "desc", }, freshForMs: githubCachePolicy.list.staleTimeMs, - signalKeys: [githubRevalidationSignalKeys.pullsMine], + signalKeys: [ + githubRevalidationSignalKeys.repoMeta({ + owner: data.owner, + repo: data.repo, + }), + ], request: (headers) => context.octokit.rest.pulls.list({ owner: data.owner, @@ -5415,7 +5457,10 @@ export const getIssuesFromUser = createServerFn({ method: "GET" }) repo: data.repo, }, freshForMs: githubCachePolicy.list.staleTimeMs, - signalKeys: [githubRevalidationSignalKeys.issuesMine], + signalKeys: revalidationSignalKeysForUserItemSearch({ + owner: data.owner, + repo: data.repo, + }), request: (headers) => context.octokit.rest.search.issuesAndPullRequests({ q: buildUserSearchQuery({ @@ -5462,7 +5507,12 @@ export const getIssuesFromRepo = createServerFn({ method: "GET" }) direction: data.direction ?? "desc", }, freshForMs: githubCachePolicy.list.staleTimeMs, - signalKeys: [githubRevalidationSignalKeys.issuesMine], + signalKeys: [ + githubRevalidationSignalKeys.repoMeta({ + owner: data.owner, + repo: data.repo, + }), + ], request: (headers) => context.octokit.rest.issues.listForRepo({ owner: data.owner, diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts index e6cba7c..0500589 100644 --- a/apps/dashboard/src/lib/github.query.ts +++ b/apps/dashboard/src/lib/github.query.ts @@ -277,8 +277,8 @@ export function githubMyPullsQueryOptions(scope: GitHubQueryScope) { return queryOptions({ queryKey: githubQueryKeys.pulls.mine(scope), queryFn: () => getMyPulls(), - staleTime: githubCachePolicy.list.staleTimeMs, - gcTime: githubCachePolicy.list.gcTimeMs, + staleTime: githubCachePolicy.mine.staleTimeMs, + gcTime: githubCachePolicy.mine.gcTimeMs, meta: persistedMeta, }); } @@ -457,8 +457,8 @@ export function githubMyIssuesQueryOptions(scope: GitHubQueryScope) { return queryOptions({ queryKey: githubQueryKeys.issues.mine(scope), queryFn: () => getMyIssues(), - staleTime: githubCachePolicy.list.staleTimeMs, - gcTime: githubCachePolicy.list.gcTimeMs, + staleTime: githubCachePolicy.mine.staleTimeMs, + gcTime: githubCachePolicy.mine.gcTimeMs, meta: persistedMeta, }); } diff --git a/apps/dashboard/src/lib/use-github-signal-stream.ts b/apps/dashboard/src/lib/use-github-signal-stream.ts index 7732b6a..3ea189f 100644 --- a/apps/dashboard/src/lib/use-github-signal-stream.ts +++ b/apps/dashboard/src/lib/use-github-signal-stream.ts @@ -25,7 +25,8 @@ function isSignalMessage(data: unknown): data is SignalMessage { } const RECONNECT_DELAY_MS = 3_000; -const POLL_INTERVAL_MS = 5 * 60 * 1_000; +/** Fallback when WebSocket misses — keep "My" lists reasonably fresh */ +const POLL_INTERVAL_MS = 90 * 1_000; function getWebSocketUrl() { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; @@ -273,7 +274,7 @@ function useGitHubSignalPoll( pollTimer = setTimeout(pollSignals, POLL_INTERVAL_MS); } - // Seed timestamps immediately, then poll every 5 minutes + // Seed timestamps immediately, then poll on POLL_INTERVAL_MS void pollSignals(); return () => { From 8e903b998bd2b753130e50b7968a7658dc3b9e90 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Fri, 17 Apr 2026 23:17:59 -0400 Subject: [PATCH 2/2] fix(dashboard): decouple mine cache merge from timedOut telemetry CodeRabbit: timedOut is also set via hasRecentGitHubTimeouts(), so using it for cache union could resurrect stale rows after an unrelated timeout. Introduce partial when results.length < sources.length and use that for mergeMyPullsCachedWithFresh / mergeMyIssuesCachedWithFresh only. --- apps/dashboard/src/lib/github.functions.ts | 12 +++++++++--- apps/dashboard/src/lib/github.types.ts | 2 ++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index c320ecf..c287343 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -4472,12 +4472,12 @@ function mergeMyIssuesResults(results: MyIssuesResult[]): MyIssuesResult { }; } -/** Full refresh replaces cache so merged/closed items drop off; union only on partial timeout. */ +/** Full refresh replaces cache so merged/closed items drop off; union only when this fetch missed sources (`partial`). */ function mergeMyPullsCachedWithFresh( existing: MyPullsResult, fresh: MyPullsResult, ): MyPullsResult { - if (fresh.timedOut) { + if (fresh.partial) { return mergeMyPullsResults([existing, fresh]); } return fresh; @@ -4487,7 +4487,7 @@ function mergeMyIssuesCachedWithFresh( existing: MyIssuesResult, fresh: MyIssuesResult, ): MyIssuesResult { - if (fresh.timedOut) { + if (fresh.partial) { return mergeMyIssuesResults([existing, fresh]); } return fresh; @@ -4691,6 +4691,9 @@ async function getMyPullsResult({ if (forbiddenOrgs.length > 0) { data.forbiddenOrgs = [...new Set(forbiddenOrgs)]; } + if (results.length < sources.length) { + data.partial = true; + } if (timedOut || hasRecentGitHubTimeouts()) { data.timedOut = true; } @@ -4857,6 +4860,9 @@ async function getMyIssuesResult({ if (forbiddenOrgs.length > 0) { data.forbiddenOrgs = [...new Set(forbiddenOrgs)]; } + if (results.length < sources.length) { + data.partial = true; + } if (timedOut || hasRecentGitHubTimeouts()) { data.timedOut = true; } diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index 0fc3d17..118af65 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -123,6 +123,7 @@ export type MyPullsResult = { mentioned: PullSummary[]; involved: PullSummary[]; forbiddenOrgs?: string[]; + partial?: boolean; timedOut?: boolean; }; @@ -131,6 +132,7 @@ export type MyIssuesResult = { authored: IssueSummary[]; mentioned: IssueSummary[]; forbiddenOrgs?: string[]; + partial?: boolean; timedOut?: boolean; };