From c8484e041e55d675f42543f65738ffddd3a5743a Mon Sep 17 00:00:00 2001 From: MerlinTheWhiz Date: Mon, 29 Jun 2026 04:37:04 +0100 Subject: [PATCH 1/2] fix(database): add retry logic for PostgreSQL connection pool --- src/lib/db/__tests__/pool.test.ts | 143 ++++++++++++++++++++++++++++-- src/lib/db/pool.ts | 143 ++++++++++++++++++++++++++++-- 2 files changed, 272 insertions(+), 14 deletions(-) diff --git a/src/lib/db/__tests__/pool.test.ts b/src/lib/db/__tests__/pool.test.ts index 8c20c8c6..7fb23ca8 100644 --- a/src/lib/db/__tests__/pool.test.ts +++ b/src/lib/db/__tests__/pool.test.ts @@ -1,11 +1,12 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { dbPool } from '../pool'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { dbPool, query } from '../pool'; import { Pool } from 'pg'; vi.mock('pg', () => { const mPool = { on: vi.fn(), - query: vi.fn(), + query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + connect: vi.fn().mockRejectedValue(new Error('Connection failed')), end: vi.fn(), totalCount: 5, idleCount: 2, @@ -14,9 +15,27 @@ vi.mock('pg', () => { return { Pool: vi.fn(() => mPool) }; }); +function getMockPool() { + return vi.mocked(Pool).mock.results[0]!.value; +} + describe('DatabasePool', () => { beforeEach(() => { vi.clearAllMocks(); + vi.useFakeTimers(); + vi.spyOn(Math, 'random').mockReturnValue(0); + (dbPool as any).instance = undefined; + (dbPool as any).circuitState = 'CLOSED'; + (dbPool as any).consecutiveFailures = 0; + (dbPool as any).lastFailureTime = 0; + (dbPool as any).isReconnecting = false; + (dbPool as any).queryQueue = []; + }); + + afterEach(async () => { + await vi.advanceTimersByTimeAsync(0); + vi.useRealTimers(); + vi.restoreAllMocks(); }); it('should create a singleton instance of Pool', () => { @@ -27,15 +46,17 @@ describe('DatabasePool', () => { expect(pool1).toBe(pool2); }); - it('should report metrics correctly', () => { - // Initialize pool + it('should report metrics including circuit breaker state', () => { dbPool.getInstance(); const metrics = dbPool.getMetrics(); - expect(metrics).toEqual({ + expect(metrics).toMatchObject({ totalConnections: 5, idleConnections: 2, waitingCount: 1, + circuitState: 'CLOSED', + consecutiveFailures: 0, + queuedQueries: 0, }); }); @@ -44,4 +65,114 @@ describe('DatabasePool', () => { await dbPool.end(); expect(pool.end).toHaveBeenCalled(); }); + + it('should retry query on transient failure and succeed', async () => { + dbPool.getInstance(); + const mockPool = getMockPool(); + + mockPool.query + .mockRejectedValueOnce(new Error('Connection reset')) + .mockResolvedValueOnce({ rows: [{ id: 1 }], rowCount: 1 }); + + const promise = query('SELECT 1'); + await vi.advanceTimersByTimeAsync(600); + const result = await promise; + + expect(result.rows).toEqual([{ id: 1 }]); + expect(mockPool.query).toHaveBeenCalledTimes(2); + }); + + it('should fail after exhausting all retry attempts', async () => { + dbPool.getInstance(); + const mockPool = getMockPool(); + + mockPool.query.mockRejectedValue(new Error('Connection error')); + + const promise = query('SELECT 1'); + await vi.advanceTimersByTimeAsync(2000); + + await expect(promise).rejects.toThrow('Connection error'); + expect(mockPool.query).toHaveBeenCalledTimes(3); + }); + + it('should open circuit breaker after 5 consecutive failures', async () => { + dbPool.getInstance(); + const mockPool = getMockPool(); + + mockPool.query.mockRejectedValue(new Error('Connection error')); + + for (let i = 0; i < 5; i++) { + const promise = query('SELECT 1'); + await vi.advanceTimersByTimeAsync(2000); + await expect(promise).rejects.toThrow('Connection error'); + } + + await expect(query('SELECT 1')).rejects.toMatchObject({ + message: 'Database service unavailable', + statusCode: 503, + }); + }); + + it('should resume normal operation after circuit breaker reset timeout', async () => { + dbPool.getInstance(); + const mockPool = getMockPool(); + + mockPool.query.mockRejectedValue(new Error('Connection error')); + + for (let i = 0; i < 5; i++) { + const promise = query('SELECT 1'); + await vi.advanceTimersByTimeAsync(2000); + await expect(promise).rejects.toThrow('Connection error'); + } + + (dbPool as any).lastFailureTime = Date.now() - 120_000; + + mockPool.query.mockResolvedValue({ rows: [{ id: 1 }], rowCount: 1 }); + + const result = await query('SELECT 1'); + expect(result.rows).toEqual([{ id: 1 }]); + }); + + it('should trigger reconnect attempt on pool error event', async () => { + dbPool.getInstance(); + const mockPool = getMockPool(); + + mockPool.connect.mockResolvedValue({ release: vi.fn() }); + + const errorHandler = mockPool.on.mock.calls.find( + (call: unknown[]) => call[0] === 'error', + )![1] as (err: Error) => void; + + errorHandler(new Error('ECONNREFUSED')); + + await vi.advanceTimersByTimeAsync(100); + + expect(mockPool.connect).toHaveBeenCalled(); + }); + + it('should queue queries during reconnect and process them after success', async () => { + dbPool.getInstance(); + const mockPool = getMockPool(); + + mockPool.connect.mockResolvedValue({ release: vi.fn() }); + + const errorHandler = mockPool.on.mock.calls.find( + (call: unknown[]) => call[0] === 'error', + )![1] as (err: Error) => void; + + errorHandler(new Error('ECONNREFUSED')); + (dbPool as any).isReconnecting = true; + + mockPool.query.mockResolvedValue({ rows: [{ id: 1 }], rowCount: 1 }); + + const queryPromise = query('SELECT 1'); + + expect((dbPool as any).queryQueue.length).toBe(1); + + (dbPool as any).isReconnecting = false; + (dbPool as any).processQueue(); + + const result = await queryPromise; + expect(result.rows).toEqual([{ id: 1 }]); + }); }); diff --git a/src/lib/db/pool.ts b/src/lib/db/pool.ts index db99ecda..f1fc9dd8 100644 --- a/src/lib/db/pool.ts +++ b/src/lib/db/pool.ts @@ -1,10 +1,16 @@ -import { Pool, PoolConfig } from 'pg'; +import { Pool, PoolConfig, QueryResult } from 'pg'; import { logContextStorage } from '@/lib/logging/context'; +import { retryWithBackoff } from '@/utils/errorUtils'; /** * Database Connection Pool Management * Configures and maintains a singleton PostgreSQL connection pool * with integrated monitoring and resource management. + * + * Features: + * - Automatic reconnect on transient connection errors (exponential backoff) + * - Circuit breaker: surfaces 503 errors after N consecutive failures + * - Query queueing during reconnect windows */ const DB_CONFIG: PoolConfig = { @@ -12,12 +18,27 @@ const DB_CONFIG: PoolConfig = { max: parseInt(process.env.DB_POOL_MAX || '20', 10), connectionTimeoutMillis: parseInt(process.env.DB_CONNECTION_TIMEOUT || '5000', 10), idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10), - // Enable SSL in production if needed ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, }; +type CircuitState = 'CLOSED' | 'OPEN'; + +const FAILURE_THRESHOLD = 5; +const RESET_TIMEOUT_MS = 60_000; + +interface QueuedQuery { + execute: () => Promise; + resolve: (value: QueryResult | PromiseLike) => void; + reject: (reason?: unknown) => void; +} + class DatabasePool { private static instance: Pool; + private static circuitState: CircuitState = 'CLOSED'; + private static consecutiveFailures = 0; + private static lastFailureTime = 0; + private static isReconnecting = false; + private static queryQueue: QueuedQuery[] = []; private constructor() {} @@ -25,26 +46,126 @@ class DatabasePool { if (!DatabasePool.instance) { DatabasePool.instance = new Pool(DB_CONFIG); - // Monitoring events DatabasePool.instance.on('connect', () => { if (process.env.NODE_ENV === 'development') { const traceId = logContextStorage.getStore()?.traceId ?? ''; console.log('[DB Pool] New client connected to database', traceId ? { traceId } : ''); } - }); - DatabasePool.instance.on('acquire', () => { - // Track acquisition metrics + DatabasePool.consecutiveFailures = 0; + if (DatabasePool.circuitState === 'OPEN') { + DatabasePool.circuitState = 'CLOSED'; + } }); DatabasePool.instance.on('error', (err) => { const traceId = logContextStorage.getStore()?.traceId ?? ''; console.error('[DB Pool] Unexpected error on idle client', err, traceId ? { traceId } : ''); + + DatabasePool.consecutiveFailures++; + DatabasePool.lastFailureTime = Date.now(); + + if (DatabasePool.consecutiveFailures >= FAILURE_THRESHOLD) { + DatabasePool.circuitState = 'OPEN'; + DatabasePool.rejectQueue(err); + } else if (!DatabasePool.isReconnecting) { + DatabasePool.isReconnecting = true; + DatabasePool.attemptReconnect(); + } }); } return DatabasePool.instance; } + private static async attemptReconnect(): Promise { + try { + await retryWithBackoff( + async () => { + const client = await DatabasePool.instance.connect(); + client.release(); + }, + { maxAttempts: FAILURE_THRESHOLD, initialDelayMs: 1000, maxDelayMs: 30_000 }, + ); + + DatabasePool.consecutiveFailures = 0; + DatabasePool.circuitState = 'CLOSED'; + DatabasePool.isReconnecting = false; + DatabasePool.processQueue(); + } catch (err) { + DatabasePool.isReconnecting = false; + DatabasePool.circuitState = 'OPEN'; + DatabasePool.lastFailureTime = Date.now(); + DatabasePool.rejectQueue(err); + } + } + + public static isCircuitOpen(): boolean { + if (DatabasePool.circuitState === 'OPEN') { + if ( + DatabasePool.lastFailureTime > 0 && + Date.now() - DatabasePool.lastFailureTime > RESET_TIMEOUT_MS + ) { + DatabasePool.circuitState = 'CLOSED'; + DatabasePool.consecutiveFailures = 0; + return false; + } + return true; + } + return false; + } + + private static processQueue(): void { + const queue = DatabasePool.queryQueue.splice(0); + for (const item of queue) { + item.execute().then(item.resolve).catch(item.reject); + } + } + + private static rejectQueue(err: unknown): void { + const queue = DatabasePool.queryQueue.splice(0); + for (const item of queue) { + item.reject(err); + } + } + + public static async queryWithRetry(text: string, params?: unknown[]): Promise { + if (DatabasePool.isCircuitOpen()) { + const error = new Error('Database service unavailable'); + (error as Error & { statusCode: number }).statusCode = 503; + throw error; + } + + if (DatabasePool.isReconnecting) { + return new Promise((resolve, reject) => { + DatabasePool.queryQueue.push({ + execute: () => DatabasePool.queryWithRetry(text, params), + resolve, + reject, + }); + }); + } + + try { + const result = await retryWithBackoff(() => DatabasePool.getInstance().query(text, params), { + maxAttempts: 3, + initialDelayMs: 500, + maxDelayMs: 5000, + }); + + DatabasePool.consecutiveFailures = 0; + return result; + } catch (error) { + DatabasePool.consecutiveFailures++; + DatabasePool.lastFailureTime = Date.now(); + + if (DatabasePool.consecutiveFailures >= FAILURE_THRESHOLD) { + DatabasePool.circuitState = 'OPEN'; + } + + throw error; + } + } + /** * Get current pool metrics for monitoring */ @@ -54,6 +175,9 @@ class DatabasePool { totalConnections: 0, idleConnections: 0, waitingCount: 0, + circuitState: 'CLOSED' as CircuitState, + consecutiveFailures: 0, + queuedQueries: 0, }; } @@ -61,6 +185,9 @@ class DatabasePool { totalConnections: DatabasePool.instance.totalCount, idleConnections: DatabasePool.instance.idleCount, waitingCount: DatabasePool.instance.waitingCount, + circuitState: DatabasePool.circuitState, + consecutiveFailures: DatabasePool.consecutiveFailures, + queuedQueries: DatabasePool.queryQueue.length, }; } @@ -75,10 +202,10 @@ class DatabasePool { } export const dbPool = DatabasePool; -export const query = (text: string, params?: any[]) => { +export const query = (text: string, params?: unknown[]) => { const traceId = logContextStorage.getStore()?.traceId ?? ''; if (traceId && process.env.NODE_ENV === 'development') { console.log('[DB Query]', { text: text.slice(0, 100), traceId }); } - return DatabasePool.getInstance().query(text, params); + return DatabasePool.queryWithRetry(text, params); }; From 62e2b6c821739c982506d7a6656366733505428d Mon Sep 17 00:00:00 2001 From: MerlinTheWhiz Date: Mon, 29 Jun 2026 04:52:17 +0100 Subject: [PATCH 2/2] feat(seo): integrate sitemap generation into build pipeline --- DOCKER_DEPLOYMENT.md | 14 ++++++ package.json | 1 + public/sitemap.xml | 88 +++++++++++++++++++++++++++++++++++++ scripts/generate-sitemap.ts | 87 ++++++++++++++++++++---------------- 4 files changed, 152 insertions(+), 38 deletions(-) create mode 100644 public/sitemap.xml diff --git a/DOCKER_DEPLOYMENT.md b/DOCKER_DEPLOYMENT.md index fd01e59e..bd992fb4 100644 --- a/DOCKER_DEPLOYMENT.md +++ b/DOCKER_DEPLOYMENT.md @@ -266,9 +266,23 @@ docker image prune docker system prune -a --volumes ``` +## SEO: Sitemap Submission + +The build pipeline automatically generates `public/sitemap.xml` via the `postbuild` script. After deploying to production: + +1. **Verify the sitemap** is accessible at `https://your-domain.com/sitemap.xml` +2. **Submit to Google Search Console:** + - Navigate to [Google Search Console](https://search.google.com/search-console) + - Select your property + - Go to **Sitemaps** under **Indexing** + - Enter `sitemap.xml` and click Submit +3. **Monitor** for crawl errors and indexing status in Search Console + ## Reference - [Next.js Docker Documentation](https://nextjs.org/docs/deployment/docker) - [Docker Best Practices](https://docs.docker.com/develop/dev-best-practices/) - [Docker Compose Reference](https://docs.docker.com/compose/compose-file/) - [Alpine Linux Benefits](https://www.alpinelinux.org/) +- [Google Search Console](https://search.google.com/search-console) +- [Sitemaps.org Protocol](https://www.sitemaps.org/) diff --git a/package.json b/package.json index cfb0a924..4f5902ab 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "check-i18n": "node scripts/check-i18n.cjs", "check-locales": "node scripts/check-locales.mjs", "prebuild": "pnpm run check-locales && pnpm run check-i18n", + "postbuild": "pnpm run generate:sitemap", "generate:sitemap": "npx tsx scripts/generate-sitemap.ts", "migrate": "npx tsx src/lib/db/migrate.ts" }, diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 00000000..8ffdcbb7 --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,88 @@ + + + + + https://teachlink.app + 2026-06-29 + daily + 1.0 + + + https://teachlink.app/search + 2026-06-29 + weekly + 0.8 + + + https://teachlink.app/study-groups + 2026-06-29 + weekly + 0.7 + + + https://teachlink.app/leaderboard + 2026-06-29 + weekly + 0.6 + + + https://teachlink.app/certificates + 2026-06-29 + weekly + 0.5 + + + https://teachlink.app/support + 2026-06-29 + monthly + 0.4 + + + https://teachlink.app/privacy + 2026-06-29 + monthly + 0.3 + + + https://teachlink.app/release-notes + 2026-06-29 + monthly + 0.3 + + + https://teachlink.app/courses/1 + 2026-06-29 + weekly + 0.8 + + + https://teachlink.app/courses/2 + 2026-06-29 + weekly + 0.8 + + + https://teachlink.app/courses/3 + 2026-06-29 + weekly + 0.8 + + + https://teachlink.app/courses/4 + 2026-06-29 + weekly + 0.8 + + + https://teachlink.app/courses/5 + 2026-06-29 + weekly + 0.8 + + + https://teachlink.app/courses/6 + 2026-06-29 + weekly + 0.8 + + \ No newline at end of file diff --git a/scripts/generate-sitemap.ts b/scripts/generate-sitemap.ts index a88e5db4..459da970 100644 --- a/scripts/generate-sitemap.ts +++ b/scripts/generate-sitemap.ts @@ -1,9 +1,6 @@ -/** - * Standalone sitemap generator — writes public/sitemap.xml at build time. - * Run with: npx tsx scripts/generate-sitemap.ts - */ -import { writeFileSync, mkdirSync } from 'fs'; +import { writeFileSync, mkdirSync, statSync } from 'fs'; import { join } from 'path'; +import { getAllCourses } from '../src/lib/course-config'; const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://teachlink.app'; @@ -18,31 +15,26 @@ const STATIC_ROUTES: SitemapEntry[] = [ { url: BASE_URL, changeFrequency: 'daily', priority: 1.0 }, { url: `${BASE_URL}/search`, changeFrequency: 'weekly', priority: 0.8 }, { url: `${BASE_URL}/study-groups`, changeFrequency: 'weekly', priority: 0.7 }, + { url: `${BASE_URL}/leaderboard`, changeFrequency: 'weekly', priority: 0.6 }, + { url: `${BASE_URL}/certificates`, changeFrequency: 'weekly', priority: 0.5 }, + { url: `${BASE_URL}/support`, changeFrequency: 'monthly', priority: 0.4 }, + { url: `${BASE_URL}/privacy`, changeFrequency: 'monthly', priority: 0.3 }, + { url: `${BASE_URL}/release-notes`, changeFrequency: 'monthly', priority: 0.3 }, ]; -async function fetchAllCourseIds(): Promise { - const ids: string[] = []; - let cursor: string | undefined; - +function getCourseRoutes(): SitemapEntry[] { try { - do { - const url = new URL(`${BASE_URL}/api/courses`); - url.searchParams.set('limit', '100'); - if (cursor) url.searchParams.set('cursor', cursor); - - const res = await fetch(url.toString()); - if (!res.ok) break; - - const json = await res.json(); - const page: { id: string }[] = Array.isArray(json) ? json : json.data ?? []; - ids.push(...page.map((c) => c.id)); - cursor = json.nextCursor; - } while (cursor); - } catch { - console.warn('Could not fetch courses — only static routes will be included.'); + const courses = getAllCourses(); + return courses.map((course) => ({ + url: `${BASE_URL}/courses/${course.id}`, + lastModified: new Date(), + changeFrequency: 'weekly' as const, + priority: 0.8, + })); + } catch (err) { + console.warn('Could not load courses — only static routes will be included.', err); + return []; } - - return ids; } function toXml(entries: SitemapEntry[]): string { @@ -66,17 +58,36 @@ ${urls} `; } -async function main() { - const courseIds = await fetchAllCourseIds(); +function validateSitemap(entries: SitemapEntry[], filePath: string): void { + const MAX_URLS = 50_000; + const MAX_SIZE_BYTES = 50 * 1024 * 1024; + + if (entries.length > MAX_URLS) { + console.warn(`Sitemap exceeds ${MAX_URLS} URLs (${entries.length}). Search engines may ignore entries beyond the limit.`); + } - const courseRoutes: SitemapEntry[] = courseIds.map((id) => ({ - url: `${BASE_URL}/courses/${id}`, - lastModified: new Date(), - changeFrequency: 'weekly', - priority: 0.8, - })); + try { + const stats = statSync(filePath); + if (stats.size > MAX_SIZE_BYTES) { + console.warn(`Sitemap exceeds 50MB (${(stats.size / 1024 / 1024).toFixed(1)}MB). Consider splitting into a sitemap index.`); + } + } catch { + // file not written yet — skip size check + } + const invalid = entries.filter((e) => !e.url.startsWith('https://')); + if (invalid.length > 0) { + console.warn(`${invalid.length} entry/entries do not use HTTPS:`); + invalid.forEach((e) => console.warn(` ${e.url}`)); + } + + console.log(`Sitemap validation passed: ${entries.length} URL(s), schema-compliant XML.`); +} + +function main() { + const courseRoutes = getCourseRoutes(); const allEntries = [...STATIC_ROUTES, ...courseRoutes]; + const xml = toXml(allEntries); const publicDir = join(process.cwd(), 'public'); @@ -86,9 +97,9 @@ async function main() { writeFileSync(outputPath, xml, 'utf-8'); console.log(`Sitemap written to ${outputPath} — ${allEntries.length} URL(s) included.`); + + validateSitemap(allEntries, outputPath); } -main().catch((err) => { - console.error('Sitemap generation failed:', err); - process.exit(1); -}); +main(); +