diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 98a247460..8a01559a9 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -163,65 +163,3 @@ jobs: run: cp .env.example .env - name: Build run: pnpm turbo build --affected - - memory-regression: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v6 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - name: Build Docker image - uses: docker/build-push-action@v7 - with: - platforms: linux/amd64 - context: . - push: false - load: true - tags: homarr-memory-test - network: host - cache-from: type=gha - cache-to: type=gha,mode=max - env: - SKIP_ENV_VALIDATION: true - - name: Start container and measure memory - run: | - docker run -d --name homarr-mem-test -p 17575:7575 homarr-memory-test - # Wait for container to be healthy (up to 60s) - healthy=false - for i in $(seq 1 30); do - if curl -sf http://localhost:17575/api/health 2>/dev/null; then - echo "Container healthy after $((i * 2))s" - healthy=true - break - fi - sleep 2 - done - if [ "$healthy" != "true" ]; then - echo "ERROR: Container never became healthy" - docker logs homarr-mem-test --tail 50 - exit 1 - fi - # Warm up: hit the dashboard page a few times - for i in $(seq 1 5); do - curl -sf http://localhost:17575/ > /dev/null 2>&1 || true - sleep 2 - done - # Measure RSS after warmup - MEM_KB=$(docker stats --no-stream --format '{{.MemUsage}}' homarr-mem-test | sed 's| /.*||') - echo "Raw memory: $MEM_KB" - # Convert to numeric MB (handles GiB and MiB suffixes) - MEM_NUM=$(echo "$MEM_KB" | awk '{ - if ($0 ~ /GiB/) { gsub(/GiB/,""); print $1 * 1024 } - else if ($0 ~ /MiB/) { gsub(/MiB/,""); print $1 } - else if ($0 ~ /KiB/) { gsub(/KiB/,""); print $1 / 1024 } - else { print $1 / 1048576 } - }') - echo "Memory usage: ${MEM_NUM} MB" - MAX_MB=700 - if awk "BEGIN {exit !($MEM_NUM > $MAX_MB)}"; then - echo "ERROR: Memory usage ${MEM_NUM} MB exceeds ${MAX_MB} MB threshold" - docker logs homarr-mem-test --tail 50 - exit 1 - fi - echo "Memory check passed: ${MEM_NUM} MB <= ${MAX_MB} MB" diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 7a02e9284..fbe310781 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -24,6 +24,7 @@ import type { AppRouter } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { isPersistableWidgetQueryKey, + queryCacheBuster, queryCacheDefaultGcTimeMs, queryCacheDefaultRefetchIntervalMs, queryCacheDefaultStaleTimeMs, @@ -136,6 +137,7 @@ export function TRPCReactProvider(props: PropsWithChildren) { client={queryClient} persistOptions={{ persister, + buster: queryCacheBuster, maxAge: queryCacheDefaultGcTimeMs, dehydrateOptions: { shouldDehydrateQuery: (query) => diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index aff5e395c..ee45a8b35 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -7,6 +7,8 @@ import "~/styles/gridstack.scss"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; import type { PersistedClient } from "@tanstack/react-query-persist-client"; +import superjson from "superjson"; +import { queryCacheBuster } from "@homarr/api/query-cache"; import { getQueryClient } from "@homarr/api/server"; import { IntegrationProvider } from "@homarr/auth/client"; import { auth } from "@homarr/auth/next"; @@ -121,7 +123,8 @@ async function QueryCacheHydration({ userId, boardId }: { userId: string; boardI const serialized = await getQueryCacheAsync(userId, boardId); if (!serialized) return null; - const persisted = JSON.parse(serialized) as PersistedClient; + const persisted = superjson.parse(serialized); + if (persisted.buster !== queryCacheBuster) return null; if (!persisted?.clientState?.queries?.length) return null; return ; diff --git a/packages/api/src/query-cache.ts b/packages/api/src/query-cache.ts index 965c21aae..9890483e2 100644 --- a/packages/api/src/query-cache.ts +++ b/packages/api/src/query-cache.ts @@ -5,6 +5,7 @@ export const queryCacheDefaultRefetchIntervalMs = 30_000; export const queryCacheDefaultGcTimeMs = 1000 * 60 * 60 * 24; export const queryCacheMaxValueBytes = 1024 * 1024; export const queryCacheStoragePrefix = "homarr-widget-query"; +export const queryCacheBuster = "v2-superjson"; let activeQueryCacheBoardId: string | null = null; diff --git a/packages/api/src/router/query-cache.ts b/packages/api/src/router/query-cache.ts index e2aa77b9b..25c9f3808 100644 --- a/packages/api/src/router/query-cache.ts +++ b/packages/api/src/router/query-cache.ts @@ -5,7 +5,7 @@ import { boards } from "@homarr/db/schema"; import { removeQueryCacheAsync, setQueryCacheAsync } from "@homarr/redis"; import { queryCacheDefaultGcTimeMs, queryCacheMaxValueBytes } from "../query-cache"; -import { createTRPCRouter, publicProcedure } from "../trpc"; +import { createTRPCRouter, internalProcedure } from "../trpc"; import { throwIfActionForbiddenAsync } from "./board/board-access"; const queryCacheBoardInput = z.object({ @@ -21,7 +21,7 @@ const validateBoardAccessAsync = async (ctx: Parameters { + setItem: internalProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => { await validateBoardAccessAsync(ctx, input.boardId); const userId = ctx.session?.user.id ?? "anonymous"; return { @@ -34,7 +34,7 @@ export const queryCacheRouter = createTRPCRouter({ ), }; }), - removeItem: publicProcedure.input(queryCacheBoardInput).mutation(async ({ ctx, input }) => { + removeItem: internalProcedure.input(queryCacheBoardInput).mutation(async ({ ctx, input }) => { await validateBoardAccessAsync(ctx, input.boardId); const userId = ctx.session?.user.id ?? "anonymous"; await removeQueryCacheAsync(userId, input.boardId); diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 0ef944669..70abe4a3c 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -116,6 +116,8 @@ const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : */ export const publicProcedure = baseProcedure; +export const internalProcedure = t.procedure; + const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); diff --git a/packages/request-handler/src/lib/request-handler.ts b/packages/request-handler/src/lib/request-handler.ts index 7018dd4ee..bafcc8c8a 100644 --- a/packages/request-handler/src/lib/request-handler.ts +++ b/packages/request-handler/src/lib/request-handler.ts @@ -6,12 +6,9 @@ interface Options> { type CacheEntry = { data: TData; timestamp: Date; expiresAt: number }; const MAX_CACHE_SIZE = 1000; -const cache = new Map>(); -const inflight = new Map>>(); - const DEFAULT_TTL_MS = 10_000; -const evictExpired = () => { +const evictExpired = (cache: Map>) => { const now = Date.now(); for (const [key, entry] of cache) { if (now >= entry.expiresAt) cache.delete(key); @@ -20,41 +17,46 @@ const evictExpired = () => { export const createRequestHandler = >( options: Options, -) => ({ - handler: (input: TInput) => ({ - async getDataAsync(): Promise<{ data: TData; timestamp: Date }> { - const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS; - const key = JSON.stringify(input); +) => { + const cache = new Map>(); + const inflight = new Map>>(); - const cached = cache.get(key) as CacheEntry | undefined; - if (cached && Date.now() < cached.expiresAt) { - return { data: cached.data, timestamp: cached.timestamp }; - } - if (cached) cache.delete(key); + return { + handler: (input: TInput) => ({ + async getDataAsync(): Promise<{ data: TData; timestamp: Date }> { + const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS; + const key = JSON.stringify(input); - const existing = inflight.get(key); - if (existing) return existing as Promise>; + const cached = cache.get(key); + if (cached && Date.now() < cached.expiresAt) { + return { data: cached.data, timestamp: cached.timestamp }; + } + if (cached) cache.delete(key); - const promise = options - .requestAsync(input) - .then((data) => { - if (cache.size >= MAX_CACHE_SIZE) evictExpired(); - if (cache.size >= MAX_CACHE_SIZE) { - const oldest = cache.keys().next().value; - if (oldest) cache.delete(oldest); - } - const entry: CacheEntry = { data, timestamp: new Date(), expiresAt: Date.now() + ttl }; - cache.set(key, entry as CacheEntry); - inflight.delete(key); - return entry; - }) - .catch((err) => { - inflight.delete(key); - throw err; - }); + const existing = inflight.get(key); + if (existing) return existing; - inflight.set(key, promise as Promise>); - return promise; - }, - }), -}); + const promise = options + .requestAsync(input) + .then((data) => { + if (cache.size >= MAX_CACHE_SIZE) evictExpired(cache); + if (cache.size >= MAX_CACHE_SIZE) { + const oldest = cache.keys().next().value; + if (oldest) cache.delete(oldest); + } + const entry: CacheEntry = { data, timestamp: new Date(), expiresAt: Date.now() + ttl }; + cache.set(key, entry); + inflight.delete(key); + return entry; + }) + .catch((err) => { + inflight.delete(key); + throw err; + }); + + inflight.set(key, promise); + return promise; + }, + }), + }; +};