Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,3 @@ jobs:
run: npm test --if-present

# Workflow run retention settings
retention-days: 30
1 change: 0 additions & 1 deletion .github/workflows/contract-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,3 @@ jobs:
release_token: ${{ secrets.GITHUB_TOKEN }}

# Workflow run retention settings
retention-days: 90
1 change: 0 additions & 1 deletion .github/workflows/dapp-ipfs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,3 @@ jobs:
echo "- URL: ${{ steps.storacha.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"

# Workflow run retention settings
retention-days: 30
1 change: 0 additions & 1 deletion .github/workflows/secrets-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,3 @@ jobs:
run: ./scripts/check-k8s-secrets.sh

# Workflow run retention settings
retention-days: 30
4 changes: 4 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ app.use(
})
);

// Modified 2026-10-05: keep public pool monitoring outside DB-dependent rate limiting.
// Bypass-token checks and rate-limit violation logging can await the exhausted pool.
app.get('/health/db', HealthController.getDatabaseHealth);

// Global rate limiting — organization-tier based, always on
app.use(
tieredOrganizationRateLimit({
Expand Down
104 changes: 88 additions & 16 deletions backend/src/controllers/__tests__/healthController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,31 @@ jest.mock('../../config/env.js', () => ({
},
}));

jest.mock('../../config/database.js', () => ({
pool: {
query: jest.fn(),
totalCount: 0,
idleCount: 0,
waitingCount: 0,
options: { max: 10 },
},
}));

jest.mock('../../utils/logger.js', () => ({
__esModule: true,
default: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));

import { HealthController, healthConfig } from '../healthController.js';
import pg from 'pg';
import { pool } from '../../config/database.js';
import logger from '../../utils/logger.js';
import { Redis } from 'ioredis';
import { StellarService } from '../../services/stellarService.js';

// Setup Mock for pg
jest.mock('pg', () => {
const mPool = { query: jest.fn() };
return { Pool: jest.fn(() => mPool) };
});

// Setup Mock for ioredis
jest.mock('ioredis', () => {
const mRedis = { ping: jest.fn() };
Expand All @@ -37,8 +51,10 @@ jest.mock('../../services/stellarService', () => ({
}));

const app = express();
const dbPool = pool as any;
app.get('/health', HealthController.getHealthStatus);
app.get('/health/live', HealthController.getLiveness);
app.get('/health/db', HealthController.getDatabaseHealth);

describe('HealthController GET /health/live', () => {
it('returns 200 OK with liveness status', async () => {
Expand All @@ -55,12 +71,10 @@ describe('HealthController GET /health/live', () => {
});

describe('HealthController GET /health', () => {
let pool: any;
let redisClient: any;
let mockServer: any;

beforeEach(() => {
pool = new pg.Pool();
redisClient = new Redis();
mockServer = { feeStats: jest.fn() };
(StellarService.getServer as jest.Mock).mockReturnValue(mockServer);
Expand All @@ -72,7 +86,7 @@ describe('HealthController GET /health', () => {
});

it('returns 200 OK when all dependencies are healthy', async () => {
pool.query.mockResolvedValueOnce({ rows: [] });
dbPool.query.mockResolvedValueOnce({ rows: [] });
redisClient.ping.mockResolvedValueOnce('PONG');
mockServer.feeStats.mockResolvedValueOnce({});

Expand All @@ -86,7 +100,7 @@ describe('HealthController GET /health', () => {
});

it('returns 503 Degraded when Postgres goes down', async () => {
pool.query.mockRejectedValueOnce(new Error('Connection forced closed'));
dbPool.query.mockRejectedValueOnce(new Error('Connection forced closed'));
redisClient.ping.mockResolvedValueOnce('PONG');
mockServer.feeStats.mockResolvedValueOnce({});

Expand All @@ -101,7 +115,7 @@ describe('HealthController GET /health', () => {
});

it('returns 503 Degraded when Redis fails', async () => {
pool.query.mockResolvedValueOnce({ rows: [] });
dbPool.query.mockResolvedValueOnce({ rows: [] });
redisClient.ping.mockRejectedValueOnce(new Error('Redis timeout'));
mockServer.feeStats.mockResolvedValueOnce({});

Expand All @@ -116,7 +130,7 @@ describe('HealthController GET /health', () => {
});

it('returns 503 Degraded when Horizon fails', async () => {
pool.query.mockResolvedValueOnce({ rows: [] });
dbPool.query.mockResolvedValueOnce({ rows: [] });
redisClient.ping.mockResolvedValueOnce('PONG');
mockServer.feeStats.mockRejectedValueOnce(new Error('Horizon unreachable'));

Expand All @@ -134,7 +148,7 @@ describe('HealthController GET /health', () => {
it('returns 503 Degraded when Postgres query times out', async () => {
healthConfig.timeoutMs = 50; // set timeout to 50ms
// Mock db query to hang (resolve after 200ms)
pool.query.mockReturnValue(new Promise((resolve) => setTimeout(resolve, 200)));
dbPool.query.mockReturnValue(new Promise((resolve) => setTimeout(resolve, 200)));
redisClient.ping.mockResolvedValueOnce('PONG');
mockServer.feeStats.mockResolvedValueOnce({});

Expand All @@ -148,7 +162,7 @@ describe('HealthController GET /health', () => {

it('returns 503 Degraded when Redis ping times out', async () => {
healthConfig.timeoutMs = 50; // set timeout to 50ms
pool.query.mockResolvedValueOnce({ rows: [] });
dbPool.query.mockResolvedValueOnce({ rows: [] });
// Mock redis ping to hang
redisClient.ping.mockReturnValue(new Promise((resolve) => setTimeout(resolve, 200)));
mockServer.feeStats.mockResolvedValueOnce({});
Expand All @@ -163,7 +177,7 @@ describe('HealthController GET /health', () => {

it('returns 503 Degraded when Horizon feeStats times out', async () => {
healthConfig.timeoutMs = 50; // set timeout to 50ms
pool.query.mockResolvedValueOnce({ rows: [] });
dbPool.query.mockResolvedValueOnce({ rows: [] });
redisClient.ping.mockResolvedValueOnce('PONG');
// Mock horizon feeStats to hang
mockServer.feeStats.mockReturnValue(new Promise((resolve) => setTimeout(resolve, 200)));
Expand All @@ -176,3 +190,61 @@ describe('HealthController GET /health', () => {
expect(response.body.dependencies.horizon.error).toContain('Horizon feeStats timeout');
});
});


describe('HealthController GET /health/db', () => {
beforeEach(() => {
dbPool.totalCount = 5;
dbPool.idleCount = 2;
dbPool.waitingCount = 0;
dbPool.options.max = 10;
jest.clearAllMocks();
});

it('returns live application pool statistics', async () => {
const response = await request(app).get('/health/db');

expect(response.status).toBe(200);
expect(response.body.status).toBe('ok');
expect(response.body.pool).toEqual({
active: 3,
idle: 2,
waiting: 0,
total: 5,
max: 10,
utilization: 30,
});
});

it('logs a warning when active pool utilization exceeds 80 percent', async () => {
dbPool.totalCount = 9;
dbPool.idleCount = 0;

const response = await request(app).get('/health/db');

expect(response.status).toBe(200);
expect(response.body.status).toBe('warning');
expect(response.body.pool.utilization).toBe(90);
expect(logger.warn).toHaveBeenCalledWith(
'Database connection pool utilization above 80%',
response.body.pool,
);
});

it('returns 503 and logs an error when the pool is exhausted', async () => {
dbPool.totalCount = 10;
dbPool.idleCount = 0;
dbPool.waitingCount = 2;

const response = await request(app).get('/health/db');

expect(response.status).toBe(503);
expect(response.body.status).toBe('exhausted');
expect(response.body.pool.active).toBe(10);
expect(response.body.pool.waiting).toBe(2);
expect(logger.error).toHaveBeenCalledWith(
'Database connection pool exhausted',
response.body.pool,
);
});
});
53 changes: 50 additions & 3 deletions backend/src/controllers/healthController.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { Request, Response } from 'express';
import pg from 'pg';
import { Redis } from 'ioredis';
import { config } from '../config/env.js';
import { pool } from '../config/database.js';
import logger from '../utils/logger.js';
import { StellarService } from '../services/stellarService.js';

const pool = new pg.Pool({ connectionString: config.DATABASE_URL });

export const redis: Redis | null = config.REDIS_URL
? new Redis(config.REDIS_URL, {
maxRetriesPerRequest: 1,
Expand Down Expand Up @@ -39,6 +38,19 @@ export interface LivenessReport {
environment: string;
}

export interface DatabasePoolHealthReport {
status: 'ok' | 'warning' | 'exhausted';
timestamp: string;
pool: {
active: number;
idle: number;
waiting: number;
total: number;
max: number;
utilization: number;
};
}

export const healthConfig = {
timeoutMs: 5000,
};
Expand All @@ -59,6 +71,41 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, errorMessage: st
}

export class HealthController {
static getDatabaseHealth(req: Request, res: Response): void {
const total = pool.totalCount;
const idle = pool.idleCount;
const waiting = pool.waitingCount;
const active = Math.max(0, total - idle);
const max = pool.options.max ?? 10;
const utilization = max > 0 ? (active / max) * 100 : 0;
const exhausted = active >= max || (waiting > 0 && total >= max);

const report: DatabasePoolHealthReport = {
status: exhausted ? 'exhausted' : utilization > 80 ? 'warning' : 'ok',
timestamp: new Date().toISOString(),
pool: {
active,
idle,
waiting,
total,
max,
utilization: Number(utilization.toFixed(1)),
},
};

if (exhausted) {
logger.error('Database connection pool exhausted', report.pool);
res.status(503).json(report);
return;
}

if (utilization > 80) {
logger.warn('Database connection pool utilization above 80%', report.pool);
}

res.status(200).json(report);
}

static getLiveness(req: Request, res: Response): void {
const timestamp = new Date().toISOString();
const uptime = process.uptime();
Expand Down