Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions src/lib/top-vaults/client-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

function topVaults(generatedAt: string) {
return {
generated_at: generatedAt,
vaults: [],
core3_protocols: {},
curators: {}
};
}

function jsonResponse(generatedAt: string) {
return new Response(JSON.stringify(topVaults(generatedAt)), {
headers: { 'content-type': 'application/json' }
});
}

function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});

return { promise, resolve, reject };
}

async function importFreshClientCache() {
vi.resetModules();
return import('./client-cache');
}

describe('top vault client cache', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('reuses cached data for the same generated_at version', async () => {
const generatedAt = '2026-07-03T08:00:00.000Z';
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(generatedAt));
vi.stubGlobal('fetch', fetchMock);

const { fetchAllVaultData, hasVaultCache } = await importFreshClientCache();

await expect(fetchAllVaultData(generatedAt)).resolves.toMatchObject({ generated_at: generatedAt });
await expect(fetchAllVaultData(generatedAt)).resolves.toMatchObject({ generated_at: generatedAt });

expect(hasVaultCache(generatedAt)).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('refetches when the layout expects a newer generated_at version', async () => {
const firstGeneratedAt = '2026-07-03T08:00:00.000Z';
const nextGeneratedAt = '2026-07-03T09:00:00.000Z';
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(jsonResponse(firstGeneratedAt))
.mockResolvedValueOnce(jsonResponse(nextGeneratedAt));
vi.stubGlobal('fetch', fetchMock);

const { fetchAllVaultData, hasVaultCache } = await importFreshClientCache();

await fetchAllVaultData(firstGeneratedAt);
expect(hasVaultCache(nextGeneratedAt)).toBe(false);

await expect(fetchAllVaultData(nextGeneratedAt)).resolves.toMatchObject({ generated_at: nextGeneratedAt });

expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1][0]).toBe('/top-vaults/all-data');
});

it('retries without HTTP cache when the response is older than expected', async () => {
const oldGeneratedAt = '2026-07-03T08:00:00.000Z';
const expectedGeneratedAt = '2026-07-03T09:00:00.000Z';
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(jsonResponse(oldGeneratedAt))
.mockResolvedValueOnce(jsonResponse(expectedGeneratedAt));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
vi.stubGlobal('fetch', fetchMock);

const { fetchAllVaultData } = await importFreshClientCache();

await expect(fetchAllVaultData(expectedGeneratedAt)).resolves.toMatchObject({
generated_at: expectedGeneratedAt
});

expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1][0]).toBe('/top-vaults/all-data');
expect(fetchMock.mock.calls[1][1]).toEqual({ cache: 'reload' });
expect(warn).toHaveBeenCalledTimes(1);
});

it('does not let an older in-flight response overwrite newer cached data', async () => {
const oldGeneratedAt = '2026-07-03T08:00:00.000Z';
const newGeneratedAt = '2026-07-03T09:00:00.000Z';
const oldResponse = deferred<Response>();
const newResponse = deferred<Response>();
const fetchMock = vi
.fn<typeof fetch>()
.mockReturnValueOnce(oldResponse.promise)
.mockReturnValueOnce(newResponse.promise)
.mockResolvedValue(jsonResponse(newGeneratedAt));
vi.stubGlobal('fetch', fetchMock);

const { fetchAllVaultData, hasVaultCache } = await importFreshClientCache();

const oldRequest = fetchAllVaultData(oldGeneratedAt);
const newRequest = fetchAllVaultData(newGeneratedAt);

newResponse.resolve(jsonResponse(newGeneratedAt));
await expect(newRequest).resolves.toMatchObject({ generated_at: newGeneratedAt });
expect(hasVaultCache(newGeneratedAt)).toBe(true);

oldResponse.resolve(jsonResponse(oldGeneratedAt));
await expect(oldRequest).resolves.toMatchObject({ generated_at: newGeneratedAt });

await expect(fetchAllVaultData(newGeneratedAt)).resolves.toMatchObject({ generated_at: newGeneratedAt });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(hasVaultCache(newGeneratedAt)).toBe(true);
});
});
99 changes: 85 additions & 14 deletions src/lib/top-vaults/client-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,100 @@
import type { TopVaults } from './schemas';

let cached: TopVaults | null = null;
let inflight: Promise<TopVaults> | null = null;
let inflight: { expectedGeneratedAt: string | Date | null; promise: Promise<TopVaults> } | null = null;

// The incident this protects against was a transient listing/detail mismatch:
// the listing showed a 1M annualised return above 100% for the Peter Schiff
// vault, while the detail page showed about -23%. Current production data later
// converged to -23.3% in both places, which points to stale client or endpoint
// cache data rather than a formatter-specific bug.
function timestamp(generatedAt: string | Date | null | undefined): number | null {
if (!generatedAt) return null;

const value = new Date(generatedAt).getTime();
return Number.isFinite(value) ? value : null;
}

function isOlderThan(
generatedAt: string | Date | null | undefined,
expectedGeneratedAt: string | Date | null
): boolean {
const expected = timestamp(expectedGeneratedAt);
if (expected === null) return false;

const actual = timestamp(generatedAt);
return actual === null || actual < expected;
}

async function requestAllVaultData(cacheBust = false): Promise<TopVaults> {
// cache: 'reload' is only used after we have proof that a normal fetch returned
// a payload older than the layout's generatedAt. That keeps normal navigation
// fast while giving us a recovery path from stale HTTP cache entries.
const resp = await fetch('/top-vaults/all-data', cacheBust ? { cache: 'reload' } : undefined);
if (!resp.ok) throw new Error(`Failed to fetch vault data: ${resp.status}`);
return resp.json();
}

/**
* Fetch vault data, returning cached data immediately if available.
* Concurrent callers share the same in-flight request.
*/
export async function fetchAllVaultData(): Promise<TopVaults> {
if (cached) return cached;
if (inflight) return inflight;

inflight = (async () => {
const resp = await fetch('/top-vaults/all-data');
if (!resp.ok) throw new Error(`Failed to fetch vault data: ${resp.status}`);
const data: TopVaults = await resp.json();
export async function fetchAllVaultData(expectedGeneratedAt?: string | Date | null): Promise<TopVaults> {
const expected = expectedGeneratedAt ?? null;
// Reuse the in-memory payload only if it is at least as fresh as the route
// layout data. Without this check, a user could navigate from a stale listing
// to a freshly rendered detail page and see conflicting return metrics for the
// same vault until the tab was reloaded.
if (cached && !isOlderThan(cached.generated_at, expected)) return cached;
// Share an in-flight request only when it targets a version fresh enough for
// this caller. If a newer layout generatedAt arrives while an older request is
// running, start a newer request instead of waiting for known-stale data.
if (inflight && !isOlderThan(inflight.expectedGeneratedAt, expected)) return inflight.promise;

const promise = (async () => {
let data = await requestAllVaultData();

// A stale browser/intermediary cache can still return an older export.
// Detect that condition from the payload itself and retry once with an
// explicit cache reload.
if (isOlderThan(data.generated_at, expected)) {
console.warn(
`Vault data response ${data.generated_at} is older than expected ${expected}; retrying without HTTP cache.`
);
data = await requestAllVaultData(true);
}

// If this warning fires, the server-side cache or upstream export is still
// behind the route layout data. We keep the response so the UI can render,
// but the warning gives enough version context to debug a future mismatch.
if (isOlderThan(data.generated_at, expected)) {
console.warn(`Vault data response ${data.generated_at} is still older than expected ${expected}.`);
}

// If an older request completes after a newer request, do not let it
// regress the process-wide in-memory cache back to stale listing data.
// Return the fresher cached payload to the original caller as well; route
// components apply resolved payloads directly, so returning older data here
// could briefly repaint a listing with stale return metrics.
if (cached && isOlderThan(data.generated_at, cached.generated_at)) {
return cached;
}

cached = data;
inflight = null;
return data;
})();
})().finally(() => {
if (inflight?.promise === promise) inflight = null;
});

return inflight;
inflight = { expectedGeneratedAt: expected, promise };
return promise;
}

/** Whether cached data is already available (no fetch needed). */
export function hasVaultCache(): boolean {
return cached !== null;
export function hasVaultCache(expectedGeneratedAt?: string | Date | null): boolean {
const expected = expectedGeneratedAt ?? null;
// Loading skeletons should appear when the only cached payload is older than
// the layout's expected version, because that old payload could contain the
// exact stale listing values that caused the Peter Schiff mismatch.
return cached !== null && !isOlderThan(cached.generated_at, expected);
}
48 changes: 41 additions & 7 deletions src/routes/top-vaults/all-data/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,39 +6,73 @@ const compress = promisify(brotliCompress);

const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour

let brCache: { json: string; br: Uint8Array; expires: number } | null = null;
// This cache stores the already-serialised and Brotli-compressed listing payload.
// The original production symptom was a transient split-brain between the vault
// listing and an individual vault detail page:
// - /trading-view/vaults?q=Peter%20Schiff showed a 1M annualised return above 100%.
// - /trading-view/vaults/systemic-strategies-peter-schiff-s-vault showed -23%.
// By the time this was investigated, production had converged and both views
// showed -23.3%, with the source data reporting one_month_cagr_net
// -0.23331116202113666.
// The likely failure mode is that this endpoint had its own compressed payload
// cache, while detail SSR read getCachedTopVaults() directly. If the upstream
// top-vault export changed between those cache refreshes, the listing could keep
// serving an older compressed JSON string even after the detail page had newer
// vault data.
let brCache: { generatedAt: string; json: string; br: Uint8Array; expires: number; created: number } | null = null;

async function getCachedAllData(fetch: Fetch) {
const now = Date.now();
if (brCache && now < brCache.expires) return brCache;

const topVaults = await getCachedTopVaults(fetch);
const generatedAt = new Date(topVaults.generated_at).toISOString();

// Version the compressed response by the backend dataset timestamp, not just
// by wall-clock TTL. If getCachedTopVaults() has advanced to a new export,
// this endpoint must not keep returning a stale compressed body from the
// previous export, because listing pages consume this endpoint while detail
// pages consume getCachedTopVaults() through SSR.
if (brCache && brCache.generatedAt === generatedAt && now < brCache.expires) return brCache;

const jsonStr = JSON.stringify(topVaults);
const br = new Uint8Array(
await compress(new TextEncoder().encode(jsonStr), {
params: { [constants.BROTLI_PARAM_QUALITY]: 6 }
})
);

brCache = { json: jsonStr, br, expires: now + CACHE_TTL_MS };
brCache = { generatedAt, json: jsonStr, br, expires: now + CACHE_TTL_MS, created: now };
return brCache;
}

const cacheHeaders = {
'cache-control': 'public, max-age=3600',
// Do not rely on query-string cache keys for correctness. Cloudflare/R2 can
// normalise or strip query parameters for the underlying export, so the
// listing API uses a short HTTP freshness window and lets the client compare
// the response generated_at against the SSR layout generatedAt. Only a proven
// stale mismatch triggers a heavier cache: 'reload' retry.
'cache-control': 'public, max-age=300, stale-while-revalidate=300',
'content-type': 'application/json',
vary: 'Accept-Encoding'
};

export async function GET({ fetch, request }) {
const data = await getCachedAllData(fetch);
const acceptsBr = request.headers.get('accept-encoding')?.includes('br');
// These headers are intentionally public diagnostics. Future mismatch
// reports need to compare the listing payload version, its cache age, and the
// detail page SSR payload version without guessing which cache layer served
// each response.
const diagnosticHeaders = {
'x-top-vaults-generated-at': data.generatedAt,
'x-top-vaults-source': '/top-vaults/all-data',
'x-top-vaults-cache-age-seconds': Math.floor((Date.now() - data.created) / 1000).toString()
};

if (acceptsBr) {
return new Response(data.br as BodyInit, {
headers: { ...cacheHeaders, 'content-encoding': 'br' }
headers: { ...cacheHeaders, ...diagnosticHeaders, 'content-encoding': 'br' }
});
}

return new Response(data.json, { headers: cacheHeaders });
return new Response(data.json, { headers: { ...cacheHeaders, ...diagnosticHeaders } });
}
4 changes: 2 additions & 2 deletions src/routes/trading-view/vaults/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
import { MetaTags, JsonLd } from 'svelte-meta-tags';

let topVaults = $state<TopVaults>();
let loading = $state(!hasVaultCache());
let loading = $state(!hasVaultCache(page.data.generatedAt));

$effect(() => {
fetchAllVaultData()
fetchAllVaultData(page.data.generatedAt)
.then((data) => (topVaults = data))
.catch((e) => console.error('Failed to load vault data:', e))
.finally(() => (loading = false));
Expand Down
4 changes: 2 additions & 2 deletions src/routes/trading-view/vaults/all/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ All vaults listing including problematic/blacklisted vaults
import { MetaTags, JsonLd } from 'svelte-meta-tags';

let topVaults = $state<TopVaults>();
let loading = $state(!hasVaultCache());
let loading = $state(!hasVaultCache(page.data.generatedAt));

$effect(() => {
fetchAllVaultData()
fetchAllVaultData(page.data.generatedAt)
.then((data) => (topVaults = data))
.catch((e) => console.error('Failed to load vault data:', e))
.finally(() => (loading = false));
Expand Down
4 changes: 2 additions & 2 deletions src/routes/trading-view/vaults/blacklisted/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ Blacklisted vault listing.
import { MetaTags, JsonLd } from 'svelte-meta-tags';

let topVaults = $state<TopVaults>();
let loading = $state(!hasVaultCache());
let loading = $state(!hasVaultCache(page.data.generatedAt));

function isBlacklistedRankingVault(vault: TopVaults['vaults'][number]): boolean {
return isBlacklisted(vault) || vault.flags.includes('paused');
}

$effect(() => {
fetchAllVaultData()
fetchAllVaultData(page.data.generatedAt)
.then((data) => (topVaults = data))
.catch((e) => console.error('Failed to load vault data:', e))
.finally(() => (loading = false));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@

let topVaults = $state<TopVaults>();
let totalVaultCount = $state<number>();
let loading = $state(!hasVaultCache());
let loading = $state(!hasVaultCache(page.data.generatedAt));

$effect(() => {
fetchAllVaultData()
fetchAllVaultData(page.data.generatedAt)
.then((allData) => {
totalVaultCount = allData.vaults.length;
// Include vaults from all chains sharing this slug (e.g. HyperEVM + HyperCore)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ cumulative TVL on Y-axis — showing how TVL accumulates across yield tiers.
let { savingsRate, treasuryRate } = $derived(data);

let vaults = $state<VaultInfo[]>([]);
let vaultsLoading = $state(!hasVaultCache());
let vaultsLoading = $state(!hasVaultCache(page.data.generatedAt));

$effect(() => {
fetchAllVaultData()
fetchAllVaultData(page.data.generatedAt)
.then((allData) => (vaults = allData.vaults))
.catch((e) => console.error('Failed to load vault data:', e))
.finally(() => (vaultsLoading = false));
Expand Down
Loading
Loading