From 549e9b593903c97dddb952ef08f7ed370d879549 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 3 May 2026 11:21:58 +0800 Subject: [PATCH] perf(web): cache /acc/ and /user/ for 30s with tag invalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates the per-request DB hit when: - A user opens a tab they've recently visited (within 30s) - The 30s AutoRefresh fires while no mutations have happened - Multiple browser tabs are open and switch between Accounts/Users - A passing-by request races another request for the same data How it works: - web/lib/api.ts: fetchApi() now forwards the next.revalidate/tags options to Next.js's data cache. getAccounts/getUsers tag their responses ('accounts'/'users') with a 30-second freshness window. - web/app/actions.ts: every mutation (update/create/delete X 2 tables) calls revalidateTag() so the next GET for that table bypasses the cache and re-reads from MySQL. Stale data never lingers after a write. The cache lives in the cm-web Node process (per worker). For our 2-worker setup that's at most 2 cached copies; the next AutoRefresh tick after the 30s window expires triggers exactly one DB read per worker. If the operator manually clicks Refresh, that's a router.refresh which also re-fetches. Tradeoffs: - External DB writes (e.g., the cm99.net monitor inserting a row) won't appear in the dashboard until the 30s window elapses or a mutation happens. The previous behavior had a 30s ceiling too (auto-refresh interval), so the perceived freshness is unchanged. - Memory: each cached payload is a few KB to a few hundred KB. Trivial. If you want stricter freshness later, drop CACHE_REVALIDATE_SECONDS in web/lib/api.ts. If you want pagination on top of this, the cache key becomes per-URL automatically, so /acc/?offset=200 caches separately from /acc/?offset=0 — no further work needed. --- web/app/actions.ts | 14 ++++++++++++-- web/lib/api.ts | 27 +++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/web/app/actions.ts b/web/app/actions.ts index 68a954f..d3a79bb 100644 --- a/web/app/actions.ts +++ b/web/app/actions.ts @@ -1,14 +1,19 @@ "use server"; -import { revalidatePath } from "next/cache"; -import { fetchApi } from "@/lib/api"; +import { revalidatePath, revalidateTag } from "next/cache"; +import { ACCOUNTS_TAG, USERS_TAG, fetchApi } from "@/lib/api"; import type { AccUpdate, UserUpdate } from "@/lib/types"; export type ActionResult = { ok: true } | { ok: false; error: string }; +// Each mutation evicts the matching tag so the next GET bypasses the +// 30s data cache and re-reads from MySQL. revalidatePath then tells +// Next.js to re-render the dependent route on the next request. + export async function updateAccount(data: AccUpdate): Promise { try { await fetchApi("/update-acc-data", { method: "POST", body: data }); + revalidateTag(ACCOUNTS_TAG); revalidatePath("/"); return { ok: true }; } catch (err) { @@ -19,6 +24,7 @@ export async function updateAccount(data: AccUpdate): Promise { export async function updateUser(data: UserUpdate): Promise { try { await fetchApi("/update-user-data", { method: "POST", body: data }); + revalidateTag(USERS_TAG); revalidatePath("/users"); return { ok: true }; } catch (err) { @@ -29,6 +35,7 @@ export async function updateUser(data: UserUpdate): Promise { export async function createAccount(data: AccUpdate): Promise { try { await fetchApi("/create-acc-data", { method: "POST", body: data }); + revalidateTag(ACCOUNTS_TAG); revalidatePath("/"); return { ok: true }; } catch (err) { @@ -39,6 +46,7 @@ export async function createAccount(data: AccUpdate): Promise { export async function createUser(data: UserUpdate): Promise { try { await fetchApi("/create-user-data", { method: "POST", body: data }); + revalidateTag(USERS_TAG); revalidatePath("/users"); return { ok: true }; } catch (err) { @@ -49,6 +57,7 @@ export async function createUser(data: UserUpdate): Promise { export async function deleteAccount(username: string): Promise { try { await fetchApi("/delete-acc-data", { method: "POST", body: { username } }); + revalidateTag(ACCOUNTS_TAG); revalidatePath("/"); return { ok: true }; } catch (err) { @@ -59,6 +68,7 @@ export async function deleteAccount(username: string): Promise { export async function deleteUser(f_username: string): Promise { try { await fetchApi("/delete-user-data", { method: "POST", body: { f_username } }); + revalidateTag(USERS_TAG); revalidatePath("/users"); return { ok: true }; } catch (err) { diff --git a/web/lib/api.ts b/web/lib/api.ts index 8a97313..1909fdc 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -2,20 +2,35 @@ import type { Acc, User } from "./types"; const API_BASE_URL = process.env.API_BASE_URL ?? "http://api-server:3000"; +// How long the Next.js data cache holds an /acc/ or /user/ response +// before considering it stale. Tab switches and auto-refreshes within +// this window are served from memory — no api-server / MySQL round-trip. +// Mutations (update/create/delete) call revalidateTag() so the next +// request always sees fresh data after a write. +const CACHE_REVALIDATE_SECONDS = 30; + +export const ACCOUNTS_TAG = "accounts"; +export const USERS_TAG = "users"; + type FetchInit = { method?: "GET" | "POST"; body?: unknown; cache?: RequestCache; + next?: { revalidate?: number; tags?: string[] }; }; export async function fetchApi(path: string, options: FetchInit = {}): Promise { const url = `${API_BASE_URL}${path}`; - const init: RequestInit = { + const init: RequestInit & { next?: FetchInit["next"] } = { method: options.method ?? "GET", - cache: options.cache ?? "no-store", headers: options.body ? { "content-type": "application/json" } : undefined, body: options.body ? JSON.stringify(options.body) : undefined, }; + if (options.next) { + init.next = options.next; + } else { + init.cache = options.cache ?? "no-store"; + } const res = await fetch(url, init); if (!res.ok) { throw new Error(`API ${options.method ?? "GET"} ${path} failed: ${res.status}`); @@ -24,11 +39,15 @@ export async function fetchApi(path: string, options: FetchInit = {}): Promise { - const data = await fetchApi("/acc/"); + const data = await fetchApi("/acc/", { + next: { revalidate: CACHE_REVALIDATE_SECONDS, tags: [ACCOUNTS_TAG] }, + }); return data as Acc[]; } export async function getUsers(): Promise { - const data = await fetchApi("/user/"); + const data = await fetchApi("/user/", { + next: { revalidate: CACHE_REVALIDATE_SECONDS, tags: [USERS_TAG] }, + }); return data as User[]; }