Skip to content

Commit a9e9731

Browse files
committed
Performance improvements targetting CPU usage reduction
1 parent e0874c2 commit a9e9731

15 files changed

Lines changed: 255 additions & 90 deletions

File tree

__mocks__/next-cache.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Vitest mock for next/cache.
3+
* unstable_cache is a transparent passthrough — no Next.js Data Cache in the test environment.
4+
*/
5+
export function unstable_cache<T extends (...args: unknown[]) => Promise<unknown>>(
6+
fn: T,
7+
_keyParts?: string[],
8+
_options?: { revalidate?: number; tags?: string[] }
9+
): T {
10+
return fn;
11+
}
12+
13+
export function revalidateTag(_tag: string): void {}
14+
export function revalidatePath(_path: string): void {}

app/api/challenge/route.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { NextResponse } from "next/server";
22
import { createServerClient } from "@supabase/ssr";
33
import { cookies } from "next/headers";
4-
import { generateChallenge, type ChallengeInput } from "@/lib/ai/challenge";
4+
import { generateChallenge, type ChallengeInput, type ChallengeOutput } from "@/lib/ai/challenge";
5+
import { buildAICacheKey, getAICachedResponse, setAICachedResponse } from "@/lib/ai/cache";
56

67
export const dynamic = "force-dynamic";
78

@@ -42,8 +43,22 @@ export async function POST(request: Request) {
4243
return NextResponse.json({ error: `Too many tools — max ${MAX_SLOTS}` }, { status: 400 });
4344
}
4445

46+
const cacheKey = buildAICacheKey([
47+
"challenge",
48+
[...body.filledSlots]
49+
.map((s) => s.toolName)
50+
.sort()
51+
.join(","),
52+
body.archetype,
53+
body.tier,
54+
]);
55+
56+
const cached = await getAICachedResponse<ChallengeOutput>(cacheKey);
57+
if (cached) return NextResponse.json(cached);
58+
4559
try {
4660
const result = await generateChallenge(body);
61+
void setAICachedResponse(cacheKey, result);
4762
return NextResponse.json(result);
4863
} catch (err) {
4964
const msg = err instanceof Error ? err.message : String(err);

app/api/feed/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,5 +121,9 @@ export async function GET(req: NextRequest) {
121121

122122
const next_cursor = hasMore ? (items[items.length - 1].detected_at as string) : null;
123123

124-
return NextResponse.json({ events, next_cursor } satisfies FeedResponse);
124+
const headers = savedOnly
125+
? undefined
126+
: { "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300" };
127+
128+
return NextResponse.json({ events, next_cursor } satisfies FeedResponse, { headers });
125129
}

app/api/pulse/events/route.ts

Lines changed: 87 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export const dynamic = "force-dynamic";
22

3+
import { unstable_cache } from "next/cache";
34
import { createClient } from "@supabase/supabase-js";
45
import { NextRequest, NextResponse } from "next/server";
56
import type { ToolEventType, ToolEventMetadata } from "@/lib/types";
@@ -48,6 +49,84 @@ function getAnonClient() {
4849
return createClient(url, anon);
4950
}
5051

52+
const fetchSignals = unstable_cache(
53+
async (sortedIds: string[]): Promise<ToolRiskSignal[]> => {
54+
const db = getAnonClient();
55+
if (!db)
56+
return sortedIds.map((id) => ({
57+
tool_id: id,
58+
signal: null,
59+
event_type: null,
60+
detected_at: null,
61+
metadata: null,
62+
}));
63+
64+
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
65+
66+
const { data: events, error } = await db
67+
.from("tool_events")
68+
.select("tool_id, type, detected_at, metadata")
69+
.in("tool_id", sortedIds)
70+
.gte("detected_at", thirtyDaysAgo)
71+
.order("detected_at", { ascending: false });
72+
73+
if (error)
74+
return sortedIds.map((id) => ({
75+
tool_id: id,
76+
signal: null,
77+
event_type: null,
78+
detected_at: null,
79+
metadata: null,
80+
}));
81+
82+
const signalMap = new Map<string, ToolRiskSignal>();
83+
84+
for (const ev of events ?? []) {
85+
const signal = classifyEvent(
86+
ev.type as ToolEventType,
87+
(ev.metadata ?? {}) as ToolEventMetadata
88+
);
89+
if (!signal) continue;
90+
91+
const existing = signalMap.get(ev.tool_id);
92+
if (!existing) {
93+
signalMap.set(ev.tool_id, {
94+
tool_id: ev.tool_id as string,
95+
signal,
96+
event_type: ev.type as ToolEventType,
97+
detected_at: ev.detected_at as string,
98+
metadata: (ev.metadata ?? {}) as ToolEventMetadata,
99+
});
100+
} else {
101+
const existingPriority = SIGNAL_PRIORITY.indexOf(existing.signal!);
102+
const newPriority = SIGNAL_PRIORITY.indexOf(signal);
103+
if (newPriority < existingPriority) {
104+
signalMap.set(ev.tool_id, {
105+
tool_id: ev.tool_id as string,
106+
signal,
107+
event_type: ev.type as ToolEventType,
108+
detected_at: ev.detected_at as string,
109+
metadata: (ev.metadata ?? {}) as ToolEventMetadata,
110+
});
111+
}
112+
}
113+
}
114+
115+
return sortedIds.map(
116+
(id) =>
117+
signalMap.get(id) ?? {
118+
tool_id: id,
119+
signal: null,
120+
event_type: null,
121+
detected_at: null,
122+
metadata: null,
123+
}
124+
);
125+
},
126+
["pulse-signals"],
127+
{ revalidate: 300 }
128+
);
129+
51130
export async function POST(req: NextRequest) {
52131
let body: unknown;
53132
try {
@@ -64,84 +143,21 @@ export async function POST(req: NextRequest) {
64143
);
65144
}
66145

67-
const db = getAnonClient();
68-
if (!db) {
69-
const signals: ToolRiskSignal[] = tool_ids.map((id) => ({
70-
tool_id: id as string,
71-
signal: null,
72-
event_type: null,
73-
detected_at: null,
74-
metadata: null,
75-
}));
76-
return NextResponse.json({ signals } satisfies EventsResponse);
77-
}
146+
const sortedIds = [...tool_ids].sort() as string[];
147+
const signals = await fetchSignals(sortedIds);
78148

79-
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
80-
81-
const { data: events, error } = await db
82-
.from("tool_events")
83-
.select("tool_id, type, detected_at, metadata")
84-
.in("tool_id", tool_ids)
85-
.gte("detected_at", thirtyDaysAgo)
86-
.order("detected_at", { ascending: false });
87-
88-
if (error) {
89-
const signals: ToolRiskSignal[] = tool_ids.map((id) => ({
90-
tool_id: id as string,
91-
signal: null,
92-
event_type: null,
93-
detected_at: null,
94-
metadata: null,
95-
}));
96-
return NextResponse.json({ signals } satisfies EventsResponse);
97-
}
98-
99-
// Build signal map: for each tool, keep the highest-priority signal
100-
const signalMap = new Map<string, ToolRiskSignal>();
101-
102-
for (const ev of events ?? []) {
103-
const signal = classifyEvent(
104-
ev.type as ToolEventType,
105-
(ev.metadata ?? {}) as ToolEventMetadata
106-
);
107-
if (!signal) continue;
108-
109-
const existing = signalMap.get(ev.tool_id);
110-
if (!existing) {
111-
signalMap.set(ev.tool_id, {
112-
tool_id: ev.tool_id as string,
113-
signal,
114-
event_type: ev.type as ToolEventType,
115-
detected_at: ev.detected_at as string,
116-
metadata: (ev.metadata ?? {}) as ToolEventMetadata,
117-
});
118-
} else {
119-
// Replace only if the new signal has higher priority
120-
const existingPriority = SIGNAL_PRIORITY.indexOf(existing.signal!);
121-
const newPriority = SIGNAL_PRIORITY.indexOf(signal);
122-
if (newPriority < existingPriority) {
123-
signalMap.set(ev.tool_id, {
124-
tool_id: ev.tool_id as string,
125-
signal,
126-
event_type: ev.type as ToolEventType,
127-
detected_at: ev.detected_at as string,
128-
metadata: (ev.metadata ?? {}) as ToolEventMetadata,
129-
});
130-
}
131-
}
132-
}
133-
134-
const signals: ToolRiskSignal[] = tool_ids.map((id) => {
135-
return (
149+
// Re-order signals to match the original request order
150+
const signalMap = new Map(signals.map((s) => [s.tool_id, s]));
151+
const ordered: ToolRiskSignal[] = tool_ids.map(
152+
(id) =>
136153
signalMap.get(id as string) ?? {
137154
tool_id: id as string,
138155
signal: null,
139156
event_type: null,
140157
detected_at: null,
141158
metadata: null,
142159
}
143-
);
144-
});
160+
);
145161

146-
return NextResponse.json({ signals } satisfies EventsResponse);
162+
return NextResponse.json({ signals: ordered } satisfies EventsResponse);
147163
}

app/api/roast/route.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { NextResponse } from "next/server";
22
import { createServerClient } from "@supabase/ssr";
33
import { cookies } from "next/headers";
4-
import { generateRoast, type RoastInput } from "@/lib/ai/roast";
4+
import { generateRoast, type RoastInput, type RoastOutput } from "@/lib/ai/roast";
5+
import { buildAICacheKey, getAICachedResponse, setAICachedResponse } from "@/lib/ai/cache";
56

67
export const dynamic = "force-dynamic";
78

@@ -42,8 +43,19 @@ export async function POST(request: Request) {
4243
return NextResponse.json({ error: `Too many tools — max ${MAX_TOOLS}` }, { status: 400 });
4344
}
4445

46+
const cacheKey = buildAICacheKey([
47+
"roast",
48+
[...body.tools].sort().join(","),
49+
body.tier,
50+
String(body.roastnessLevel ?? 3),
51+
]);
52+
53+
const cached = await getAICachedResponse<RoastOutput>(cacheKey);
54+
if (cached) return NextResponse.json(cached);
55+
4556
try {
4657
const result = await generateRoast(body);
58+
void setAICachedResponse(cacheKey, result);
4759
return NextResponse.json(result);
4860
} catch (err) {
4961
const msg = err instanceof Error ? err.message : String(err);

app/compare/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
export const revalidate = 3600;
2+
13
import { getTools } from "@/lib/data/tools";
24
import { getRelationships } from "@/lib/data/relationships";
35
import CompareClient from "./CompareClient";

app/explore/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
export const revalidate = 86400;
2+
13
import { Suspense } from "react";
24
import { Metadata } from "next";
35
import ExploreGraph from "@/components/graph/ExploreGraph";

app/stacks/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
export const revalidate = 86400;
2+
13
import { redirect } from "next/navigation";
24
import { loadStacksData } from "@/lib/data-loaders";
35
import StacksClient from "./StacksClient";

lib/ai/cache.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { createClient } from "@supabase/supabase-js";
2+
import { createHash } from "crypto";
3+
4+
function getClient() {
5+
const url = process.env.NEXT_PUBLIC_POSTGRES_SUPABASE_URL;
6+
const key = process.env.POSTGRES_SUPABASE_SERVICE_ROLE_KEY;
7+
if (!url || !key) return null;
8+
return createClient(url, key, { auth: { persistSession: false } });
9+
}
10+
11+
/**
12+
* Returns a SHA-256 hex string over the joined parts.
13+
* Use to build deterministic cache keys from variable-length inputs.
14+
*/
15+
export function buildAICacheKey(parts: string[]): string {
16+
return createHash("sha256").update(parts.join("|")).digest("hex");
17+
}
18+
19+
export async function getAICachedResponse<T>(cacheKey: string): Promise<T | null> {
20+
const db = getClient();
21+
if (!db) return null;
22+
const { data, error } = await db
23+
.from("ai_response_cache")
24+
.select("response")
25+
.eq("cache_key", cacheKey)
26+
.gt("expires_at", new Date().toISOString())
27+
.maybeSingle();
28+
if (error || !data) return null;
29+
return data.response as T;
30+
}
31+
32+
export async function setAICachedResponse<T>(
33+
cacheKey: string,
34+
response: T,
35+
ttlSeconds = 86400
36+
): Promise<void> {
37+
const db = getClient();
38+
if (!db) return;
39+
await db.from("ai_response_cache").upsert(
40+
{
41+
cache_key: cacheKey,
42+
response,
43+
expires_at: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
44+
},
45+
{ onConflict: "cache_key" }
46+
);
47+
}

lib/data/relationships.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
1+
import { unstable_cache } from "next/cache";
12
import { supabase } from "@/lib/db";
23
import type { Relationship } from "@/lib/types";
34
import relationshipsJson from "@/data/relationships.json";
45

56
const fallback = relationshipsJson as Relationship[];
67

8+
const _getRelationships = unstable_cache(
9+
async (): Promise<Relationship[]> => {
10+
if (!supabase) return fallback;
11+
const { data, error } = await supabase.from("relationships").select("*");
12+
if (error || !data?.length) return fallback;
13+
return data as Relationship[];
14+
},
15+
["relationships-all"],
16+
{ revalidate: 3600, tags: ["relationships"] }
17+
);
18+
719
export async function getRelationships(): Promise<Relationship[]> {
8-
if (!supabase) return fallback;
9-
const { data, error } = await supabase.from("relationships").select("*");
10-
if (error || !data?.length) return fallback;
11-
return data as Relationship[];
20+
return _getRelationships();
1221
}
1322

1423
export async function getRelationshipsByTool(toolId: string): Promise<Relationship[]> {

0 commit comments

Comments
 (0)