Skip to content
Open
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
17 changes: 12 additions & 5 deletions src/middleware/db-cache-fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* When Supabase queries fail, serve stale cached data instead of 500 errors.
* This improves availability during DB outages or network issues.
*/
import { createLogger, truncateErrorMessage } from "@percolator/shared";
import { createLogger, truncateErrorMessage, getNetwork } from "@percolator/shared";
import { Context } from "hono";

const logger = createLogger("api:db-cache-fallback");
Expand Down Expand Up @@ -53,10 +53,17 @@ export async function withDbCacheFallback<T>(
queryFn: () => Promise<T>,
c: Context
): Promise<DbCacheResult<T> | Response> {
// Suffix with the active network so this fallback cache can never serve a
// different network's data than the live query it's backing — every live
// query already filters by network at the DB layer, but the bare cacheKey
// strings callers pass in (e.g. "markets:all") carry no network dimension
// on their own.
const networkedKey = `${cacheKey}:${getNetwork()}`;

try {
// Try the query
const result = await queryFn();

// Evict oldest entries when at capacity
while (dbCache.size >= MAX_DB_CACHE_ENTRIES) {
const oldest = dbCache.keys().next().value;
Expand All @@ -65,7 +72,7 @@ export async function withDbCacheFallback<T>(
}

// Cache successful result
dbCache.set(cacheKey, {
dbCache.set(networkedKey, {
data: result,
timestamp: Date.now(),
});
Expand All @@ -76,9 +83,9 @@ export async function withDbCacheFallback<T>(
error: truncateErrorMessage(err instanceof Error ? err.message : String(err), 120),
cacheKey,
});

// Check if we have cached data
const cached = dbCache.get(cacheKey);
const cached = dbCache.get(networkedKey);

if (cached) {
const age = Date.now() - cached.timestamp;
Expand Down
40 changes: 40 additions & 0 deletions tests/middleware/db-cache-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ vi.mock("@percolator/shared", () => ({
debug: vi.fn(),
})),
truncateErrorMessage: vi.fn((s: string) => s),
getNetwork: vi.fn(() => "devnet"),
}));

import { withDbCacheFallback, clearDbCache } from "../../src/middleware/db-cache-fallback.js";
const { getNetwork } = await import("@percolator/shared");

function makeApp(handler: (c: any) => Promise<Response>) {
const app = new Hono();
Expand All @@ -22,6 +24,7 @@ function makeApp(handler: (c: any) => Promise<Response>) {
describe("withDbCacheFallback", () => {
beforeEach(() => {
clearDbCache();
vi.mocked(getNetwork).mockReturnValue("devnet" as any);
});

it("returns DbCacheResult on success with stale=false and ok=true", async () => {
Expand Down Expand Up @@ -98,4 +101,41 @@ describe("withDbCacheFallback", () => {
expect(res.headers.get("X-Cache-Status")).toBe("stale-fallback");
expect(res.headers.get("Warning")).toMatch(/^110 - "Response is Stale/);
});

it("does not serve a different network's stale cache after the configured network changes (BUG-006)", async () => {
// Seed the cache while serving devnet.
vi.mocked(getNetwork).mockReturnValue("devnet" as any);
const seedApp = makeApp(async (c) => {
const result = await withDbCacheFallback(
"test:network-switch",
async () => ({ network: "devnet-data" }),
c,
);
if (result instanceof Response) return result;
return c.json(result.data);
});
const seed = await seedApp.request("/test");
expect(seed.status).toBe(200);

// Simulate the deployment's configured network changing (e.g. redeploy/
// config flip) while the live query for the SAME bare cacheKey fails.
vi.mocked(getNetwork).mockReturnValue("mainnet" as any);
const fallbackApp = makeApp(async (c) => {
const result = await withDbCacheFallback(
"test:network-switch",
async () => {
throw new Error("DB down");
},
c,
);
if (result instanceof Response) return result;
return c.json(result.data);
});

const res = await fallbackApp.request("/test");
// Must NOT silently serve devnet's stale data under the mainnet
// context — there is no mainnet-keyed cache entry, so this must be a
// clean 503, not a 200 carrying the wrong network's data.
expect(res.status).toBe(503);
});
});