authorgravatar for adam.bogucki2018@gmail.comAdamulek123 <adam.bogucki2018@gmail.com> 2026-08-24 11:55:34+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 02:55:34-07:00
log229b05df042f896bbb44660c8524ffe222b85b8d
tree6aacdcbebce1abcc4e71f5dbc3817c9412ebfbf0
parentba30177b5a5e9b47885b59afcc4f00a8a7cc0efd
signature Signed by PGP key B5690EEEBB952194

fix(server): return fresh live pull request reads (#6472)


2 files changed, 51 insertions(+), 51 deletions(-)

apps/server/src/pullRequest/PullRequestService.test.ts+37
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import * as TestClock from "effect/testing/TestClock";
4import type {5import type {
5 OrchestrationProjectShell,6 OrchestrationProjectShell,
6 ProjectId,7 ProjectId,
...@@ -2292,6 +2293,42 @@ it.effect("answers a repeated listing from cache, and concurrent readers share o...@@ -2292,6 +2293,42 @@ it.effect("answers a repeated listing from cache, and concurrent readers share o
2292 }),2293 }),
2293);2294);
22942295
2296it.effect("returns the refreshed listing on the first read after its cache expires", () =>
2297 Effect.gen(function* () {
2298 let hostCalls = 0;
2299 const service = yield* makeService({
2300 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
2301 providers: [
2302 fakeProvider("github", {
2303 listChangeRequests: () => {
2304 hostCalls += 1;
2305 return Effect.succeed({
2306 items: [changeRequest(hostCalls, "2026-07-02T00:00:00Z")],
2307 truncated: false,
2308 continues: false,
2309 });
2310 },
2311 }),
2312 ],
2313 });
2314
2315 const first = yield* service.list({ state: "open" });
2316 assert.deepStrictEqual(
2317 first.entries.map((entry) => entry.number),
2318 [1],
2319 );
2320
2321 yield* TestClock.adjust("31 seconds");
2322 const refreshed = yield* service.list({ state: "open" });
2323
2324 assert.strictEqual(hostCalls, 2);
2325 assert.deepStrictEqual(
2326 refreshed.entries.map((entry) => entry.number),
2327 [2],
2328 );
2329 }),
2330);
2331
2295it.effect("a listing narrowed to some projects is its own cache entry", () =>2332it.effect("a listing narrowed to some projects is its own cache entry", () =>
2296 Effect.gen(function* () {2333 Effect.gen(function* () {
2297 const asked: ReadonlyArray<string>[] = [];2334 const asked: ReadonlyArray<string>[] = [];
apps/server/src/pullRequest/PullRequestService.ts+14-51
...@@ -102,16 +102,7 @@ const DIFF_CACHE_TTL = Duration.seconds(60);...@@ -102,16 +102,7 @@ const DIFF_CACHE_TTL = Duration.seconds(60);
102const COMMIT_DIFF_CACHE_TTL = Duration.minutes(10);102const COMMIT_DIFF_CACHE_TTL = Duration.minutes(10);
103/** Sized like the client's own stale time; a row's counts move only when somebody pushes. */103/** Sized like the client's own stale time; a row's counts move only when somebody pushes. */
104const LIST_STATS_CACHE_TTL = Duration.seconds(60);104const LIST_STATS_CACHE_TTL = Duration.seconds(60);
105/**105/** A diff can stay interactive while its next cached value is fetched off the critical path. */
106 * How long a cache's last success may still be served while a fresh read runs behind it.
107 * Bounded by how the page actually revalidates: clients re-read on mount and once a minute
108 * while open, and every one of those reads repopulates the cache in the background — so in
109 * steady use a "stale" answer is at most a refresh cycle old, and the window only stretches
110 * that far when nobody has looked at the page for minutes. An explicit refresh or a mutation
111 * bumps the epochs and skips held answers entirely.
112 */
113const LIST_STALE_WINDOW = Duration.minutes(10);
114const DETAIL_STALE_WINDOW = Duration.minutes(5);
115const DIFF_STALE_WINDOW = Duration.minutes(10);106const DIFF_STALE_WINDOW = Duration.minutes(10);
116/** How long one host's signed-in login is believed without asking its CLI again. */107/** How long one host's signed-in login is believed without asking its CLI again. */
117const VIEWER_CACHE_TTL = Duration.minutes(10);108const VIEWER_CACHE_TTL = Duration.minutes(10);
...@@ -1826,30 +1817,22 @@ export const make = Effect.gen(function* () {...@@ -1826,30 +1817,22 @@ export const make = Effect.gen(function* () {
1826 const runFork = Effect.runForkWith(context);1817 const runFork = Effect.runForkWith(context);
18271818
1828 /**1819 /**
1829 * Stale answers served while a fresh one is fetched behind them. Every read here leaves the1820 * The diff is not live-polled and is expensive enough to keep its stale-while-revalidate path.
1830 * process for a CLI whose wall clock is the host's — seconds on a good day, tens of them on a1821 * Explicit refreshes and mutations still strand held values through the reference epoch.
1831 * slow network — and the short cache windows below mean almost every page visit pays that
1832 * clock again. The last success per key is therefore held a while longer: a read inside the
1833 * window answers with it at once and refreshes the cache in the background, so the next read
1834 * is fresh without anyone having waited on it.
1835 *
1836 * Correctness leans on the epochs: an explicit refresh or a mutation bumps them, the epoch is
1837 * part of every key, and a held answer under the old key is simply never asked for again — so
1838 * "give me truly fresh" still means exactly that.
1839 */1822 */
1840 const staleWhileRevalidate = <A>(staleFor: Duration.Duration, capacity: number) => {1823 const staleDiff = (() => {
1841 const staleMs = Duration.toMillis(staleFor);1824 const staleMs = Duration.toMillis(DIFF_STALE_WINDOW);
1842 const held = new Map<string, { readonly at: number; readonly value: A }>();1825 const held = new Map<string, { readonly at: number; readonly value: PullRequestDiffResult }>();
1843 const record = (key: string, value: A) =>1826 const record = (key: string, value: PullRequestDiffResult) =>
1844 Effect.map(Clock.currentTimeMillis, (at) => {1827 Effect.map(Clock.currentTimeMillis, (at) => {
1845 held.delete(key);1828 held.delete(key);
1846 if (held.size >= capacity) {1829 if (held.size >= DIFF_CACHE_CAPACITY) {
1847 const oldest = held.keys().next().value;1830 const oldest = held.keys().next().value;
1848 if (oldest !== undefined) held.delete(oldest);1831 if (oldest !== undefined) held.delete(oldest);
1849 }1832 }
1850 held.set(key, { at, value });1833 held.set(key, { at, value });
1851 });1834 });
1852 return <E>(key: string, read: Effect.Effect<A, E>): Effect.Effect<A, E> => {1835 return <E>(key: string, read: Effect.Effect<PullRequestDiffResult, E>) => {
1853 const recorded = read.pipe(Effect.tap((value) => record(key, value)));1836 const recorded = read.pipe(Effect.tap((value) => record(key, value)));
1854 return Effect.flatMap(Clock.currentTimeMillis, (now) => {1837 return Effect.flatMap(Clock.currentTimeMillis, (now) => {
1855 const snapshot = held.get(key);1838 const snapshot = held.get(key);
...@@ -1860,7 +1843,7 @@ export const make = Effect.gen(function* () {...@@ -1860,7 +1843,7 @@ export const make = Effect.gen(function* () {
1860 return Effect.sync(() => runFork(Effect.ignore(recorded))).pipe(Effect.as(snapshot.value));1843 return Effect.sync(() => runFork(Effect.ignore(recorded))).pipe(Effect.as(snapshot.value));
1861 });1844 });
1862 };1845 };
1863 };1846 })();
18641847
1865 // Epochs are the invalidation mechanism: a key carries its scope's epoch, so bumping the1848 // Epochs are the invalidation mechanism: a key carries its scope's epoch, so bumping the
1866 // epoch strands every entry made under the old one — no enumerating a cache whose keys1849 // epoch strands every entry made under the old one — no enumerating a cache whose keys
...@@ -1946,10 +1929,6 @@ export const make = Effect.gen(function* () {...@@ -1946,10 +1929,6 @@ export const make = Effect.gen(function* () {
1946 timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_CACHE_TTL : Duration.zero),1929 timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_CACHE_TTL : Duration.zero),
1947 },1930 },
1948 );1931 );
1949 const staleList = staleWhileRevalidate<PullRequestListResult>(
1950 LIST_STALE_WINDOW,
1951 LIST_CACHE_CAPACITY,
1952 );
1953 const list: PullRequestService["Service"]["list"] = (input) => {1932 const list: PullRequestService["Service"]["list"] = (input) => {
1954 const key = JSON.stringify([1933 const key = JSON.stringify([
1955 listingsEpoch,1934 listingsEpoch,
...@@ -1976,7 +1955,7 @@ export const make = Effect.gen(function* () {...@@ -1976,7 +1955,7 @@ export const make = Effect.gen(function* () {
1976 ? null1955 ? null
1977 : Object.entries(input.cursors).toSorted(([left], [right]) => left.localeCompare(right)),1956 : Object.entries(input.cursors).toSorted(([left], [right]) => left.localeCompare(right)),
1978 ]);1957 ]);
1979 return staleList(key, Cache.get(listCache, key));1958 return Cache.get(listCache, key);
1980 };1959 };
19811960
1982 const detailCache = yield* Cache.makeWith(1961 const detailCache = yield* Cache.makeWith(
...@@ -1989,13 +1968,9 @@ export const make = Effect.gen(function* () {...@@ -1989,13 +1968,9 @@ export const make = Effect.gen(function* () {
1989 timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero),1968 timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero),
1990 },1969 },
1991 );1970 );
1992 const staleDetail = staleWhileRevalidate<PullRequestDetail>(
1993 DETAIL_STALE_WINDOW,
1994 DETAIL_CACHE_CAPACITY,
1995 );
1996 const detail: PullRequestService["Service"]["detail"] = (input) => {1971 const detail: PullRequestService["Service"]["detail"] = (input) => {
1997 const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]);1972 const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]);
1998 return staleDetail(key, Cache.get(detailCache, key));1973 return Cache.get(detailCache, key);
1999 };1974 };
20001975
2001 const activityCache = yield* Cache.makeWith(1976 const activityCache = yield* Cache.makeWith(
...@@ -2008,13 +1983,9 @@ export const make = Effect.gen(function* () {...@@ -2008,13 +1983,9 @@ export const make = Effect.gen(function* () {
2008 timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero),1983 timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero),
2009 },1984 },
2010 );1985 );
2011 const staleActivity = staleWhileRevalidate<PullRequestActivity>(
2012 DETAIL_STALE_WINDOW,
2013 DETAIL_CACHE_CAPACITY,
2014 );
2015 const activity: PullRequestService["Service"]["activity"] = (input) => {1986 const activity: PullRequestService["Service"]["activity"] = (input) => {
2016 const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]);1987 const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]);
2017 return staleActivity(key, Cache.get(activityCache, key));1988 return Cache.get(activityCache, key);
2018 };1989 };
20191990
2020 const diffCache = yield* Cache.makeWith(1991 const diffCache = yield* Cache.makeWith(
...@@ -2044,10 +2015,6 @@ export const make = Effect.gen(function* () {...@@ -2044,10 +2015,6 @@ export const make = Effect.gen(function* () {
2044 },2015 },
2045 },2016 },
2046 );2017 );
2047 const staleDiff = staleWhileRevalidate<PullRequestDiffResult>(
2048 DIFF_STALE_WINDOW,
2049 DIFF_CACHE_CAPACITY,
2050 );
2051 const diff: PullRequestService["Service"]["diff"] = (input) => {2018 const diff: PullRequestService["Service"]["diff"] = (input) => {
2052 const key = JSON.stringify([2019 const key = JSON.stringify([
2053 refEpoch(input),2020 refEpoch(input),
...@@ -2076,10 +2043,6 @@ export const make = Effect.gen(function* () {...@@ -2076,10 +2043,6 @@ export const make = Effect.gen(function* () {
2076 // shares between clients like every other read. Refs are sorted so one page's worth of rows2043 // shares between clients like every other read. Refs are sorted so one page's worth of rows
2077 // is one key however the client assembled them, and the listings epoch rides along so the2044 // is one key however the client assembled them, and the listings epoch rides along so the
2078 // refresh that forgets the listing forgets its decorations with it.2045 // refresh that forgets the listing forgets its decorations with it.
2079 const staleListStats = staleWhileRevalidate<PullRequestListStatsResult>(
2080 LIST_STALE_WINDOW,
2081 LIST_STATS_CACHE_CAPACITY,
2082 );
2083 const listStats: PullRequestService["Service"]["listStats"] = (input) => {2046 const listStats: PullRequestService["Service"]["listStats"] = (input) => {
2084 if (input.refs.length === 0) return Effect.succeed({ stats: [] });2047 if (input.refs.length === 0) return Effect.succeed({ stats: [] });
2085 const key = JSON.stringify([2048 const key = JSON.stringify([
...@@ -2090,7 +2053,7 @@ export const make = Effect.gen(function* () {...@@ -2090,7 +2053,7 @@ export const make = Effect.gen(function* () {
2090 `${left[0]} ${left[1]} ${left[2]}`.localeCompare(`${right[0]} ${right[1]} ${right[2]}`),2053 `${left[0]} ${left[1]} ${left[2]}`.localeCompare(`${right[0]} ${right[1]} ${right[2]}`),
2091 ),2054 ),
2092 ]);2055 ]);
2093 return staleListStats(key, Cache.get(listStatsCache, key));2056 return Cache.get(listStatsCache, key);
2094 };2057 };
20952058
2096 const invalidate: PullRequestService["Service"]["invalidate"] = (input) =>2059 const invalidate: PullRequestService["Service"]["invalidate"] = (input) =>