From a009e5ae329b2a01d8114bc92f80197c1829a804 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:50:59 +0100 Subject: [PATCH 1/2] fix: enhance health check functionality with detailed memory diagnostics and stable liveness probe --- app/api/health/route.ts | 61 ++++++++----------- lib/__tests__/health-route.test.ts | 98 ++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 35 deletions(-) create mode 100644 lib/__tests__/health-route.test.ts diff --git a/app/api/health/route.ts b/app/api/health/route.ts index ce374ac2..b7b1c52d 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,10 +1,21 @@ +import v8 from 'node:v8'; import { NextResponse } from 'next/server'; import { NextRequest } from 'next/server'; import { logger } from '@/lib/logger'; -// Health check thresholds -const MEMORY_WARNING_THRESHOLD = 0.85; // 85% heap usage -const MEMORY_CRITICAL_THRESHOLD = 0.95; // 95% heap usage +const MEMORY_WARNING_THRESHOLD = 0.85; +const MEMORY_CRITICAL_THRESHOLD = 0.95; + +function getHeapUsagePercent(heapUsed: number, heapTotal: number): number { + const heapSizeLimit = v8.getHeapStatistics().heap_size_limit; + const denominator = heapSizeLimit > 0 ? heapSizeLimit : heapTotal; + + if (denominator <= 0) { + return 0; + } + + return (heapUsed / denominator) * 100; +} interface HealthStatus { status: 'healthy' | 'degraded' | 'unhealthy'; @@ -14,6 +25,7 @@ interface HealthStatus { memory?: { heapUsed: number; heapTotal: number; + heapSizeLimit: number; rss: number; external: number; heapUsagePercent: number; @@ -27,14 +39,9 @@ interface HealthStatus { /** * Health check endpoint for container orchestration * - * GET /api/health - Basic health check (returns 200 OK or 503 Service Unavailable) - * GET /api/health?detailed=true - Detailed diagnostics with memory stats - * HEAD /api/health - Lightweight health check (status code only) - * - * Health status based on Node.js heap usage: - * - Healthy (200): < 85% heap usage - * - Degraded (200): 85-95% heap usage (warnings in detailed mode) - * - Unhealthy (503): > 95% heap usage + * GET /api/health - Liveness probe for container orchestration + * GET /api/health?detailed=true - Diagnostics with advisory memory warnings + * HEAD /api/health - Lightweight liveness probe (status code only) */ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; @@ -43,38 +50,31 @@ export async function GET(request: NextRequest) { try { const timestamp = new Date().toISOString(); const memUsage = process.memoryUsage(); - const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100; - - // Determine health status based on memory usage + const heapSizeLimit = v8.getHeapStatistics().heap_size_limit; + const heapUsagePercent = getHeapUsagePercent(memUsage.heapUsed, memUsage.heapTotal); let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy'; const warnings: string[] = []; - let httpStatus = 200; if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) { - status = 'unhealthy'; - httpStatus = 503; + status = 'degraded'; + warnings.push(`V8 heap usage is very high: ${heapUsagePercent.toFixed(1)}% of heap limit`); } else if (heapUsagePercent >= MEMORY_WARNING_THRESHOLD * 100) { status = 'degraded'; - warnings.push(`Memory usage high: ${heapUsagePercent.toFixed(1)}%`); + warnings.push(`V8 heap usage is high: ${heapUsagePercent.toFixed(1)}% of heap limit`); } - // Build response const response: HealthStatus = { - status, + status: detailed ? status : 'healthy', timestamp, }; - if (status === 'unhealthy') { - response.reason = `Memory usage critical: ${heapUsagePercent.toFixed(1)}%`; - } - - // Add detailed information if requested if (detailed) { response.uptime = process.uptime(); response.version = process.env.npm_package_version || '0.1.0'; response.memory = { heapUsed: memUsage.heapUsed, heapTotal: memUsage.heapTotal, + heapSizeLimit, rss: memUsage.rss, external: memUsage.external, heapUsagePercent: Number(heapUsagePercent.toFixed(2)), @@ -87,10 +87,8 @@ export async function GET(request: NextRequest) { } } - logger.info('Health check', { status, detailed }); - return NextResponse.json(response, { - status: httpStatus, + status: 200, headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Pragma': 'no-cache', @@ -116,13 +114,6 @@ export async function GET(request: NextRequest) { */ export async function HEAD() { try { - const memUsage = process.memoryUsage(); - const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100; - - if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) { - return new Response(null, { status: 503 }); - } - return new Response(null, { status: 200 }); } catch { return new Response(null, { status: 503 }); diff --git a/lib/__tests__/health-route.test.ts b/lib/__tests__/health-route.test.ts new file mode 100644 index 00000000..2daee56f --- /dev/null +++ b/lib/__tests__/health-route.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const loggerError = vi.fn(); + +vi.mock('next/server', () => ({ + NextResponse: { + json: (data: unknown, init?: { status?: number; headers?: unknown }) => ({ + status: init?.status ?? 200, + headers: init?.headers, + json: async () => data, + }), + }, +})); + +vi.mock('@/lib/logger', () => ({ + logger: { + error: loggerError, + }, +})); + +describe('health route', () => { + beforeEach(() => { + vi.restoreAllMocks(); + loggerError.mockReset(); + }); + + it('returns healthy for the basic liveness probe even when heap usage is high', async () => { + vi.spyOn(process, 'memoryUsage').mockReturnValue({ + rss: 120_000_000, + heapTotal: 45_000_000, + heapUsed: 43_000_000, + external: 8_000_000, + arrayBuffers: 1_000_000, + }); + + const { GET } = await import('@/app/api/health/route'); + const response = await GET({ nextUrl: new URL('http://localhost/api/health') } as never); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload).toMatchObject({ + status: 'healthy', + }); + expect(payload.warnings).toBeUndefined(); + }); + + it('returns degraded diagnostics in detailed mode without failing the probe', async () => { + vi.spyOn(process, 'memoryUsage').mockReturnValue({ + rss: 120_000_000, + heapTotal: 4_100_000_000, + heapUsed: 4_000_000_000, + external: 8_000_000, + arrayBuffers: 1_000_000, + }); + vi.spyOn(process, 'uptime').mockReturnValue(123.45); + + const { GET } = await import('@/app/api/health/route'); + const response = await GET({ nextUrl: new URL('http://localhost/api/health?detailed=true') } as never); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.status).toBe('degraded'); + expect(payload.memory).toMatchObject({ + heapUsed: 4_000_000_000, + heapTotal: 4_100_000_000, + rss: 120_000_000, + external: 8_000_000, + }); + expect(payload.memory.heapSizeLimit).toBeGreaterThan(0); + expect(payload.warnings).toEqual([ + expect.stringContaining('V8 heap usage is high'), + ]); + }); + + it('keeps HEAD as a stable liveness probe', async () => { + const { HEAD } = await import('@/app/api/health/route'); + const response = await HEAD(); + + expect(response.status).toBe(200); + }); + + it('returns 503 when collecting health diagnostics throws', async () => { + vi.spyOn(process, 'memoryUsage').mockImplementation(() => { + throw new Error('boom'); + }); + + const { GET } = await import('@/app/api/health/route'); + const response = await GET({ nextUrl: new URL('http://localhost/api/health') } as never); + const payload = await response.json(); + + expect(response.status).toBe(503); + expect(payload).toMatchObject({ + status: 'unhealthy', + reason: 'boom', + }); + expect(loggerError).toHaveBeenCalledWith('Health check failed', { error: 'boom' }); + }); +}); \ No newline at end of file From e7e07a38d70a9131dd1fa3674a6e36fb51f12020 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:57:17 +0100 Subject: [PATCH 2/2] fix: use native ARM runners instead of QEMU for Docker builds --- .github/workflows/docker-publish.yml | 96 +++++++++++++++++++++++----- 1 file changed, 79 insertions(+), 17 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 5f56fbf0..11a9654d 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -20,9 +20,20 @@ on: tags: ["v*.*.*"] workflow_dispatch: +env: + IMAGE_NAME: ghcr.io/${{ github.repository }} + jobs: - build-and-push: - runs-on: ubuntu-latest + build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} permissions: contents: read packages: write @@ -31,9 +42,6 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -48,21 +56,75 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: | - ghcr.io/${{ github.repository }} + images: ${{ env.IMAGE_NAME }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=${{ matrix.platform }} + cache-to: type=gha,mode=max,scope=${{ matrix.platform }} + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: build + permissions: + contents: read + packages: write + + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} tags: | type=raw,value=latest,enable={{is_default_branch}} type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha,prefix= - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}