Skip to content

Commit a5ea6be

Browse files
authored
Merge pull request #655 from codex-team/master
Update prod
2 parents 245d3f5 + 6352808 commit a5ea6be

4 files changed

Lines changed: 238 additions & 27 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hawk.api",
3-
"version": "1.5.1",
3+
"version": "1.5.2",
44
"main": "index.ts",
55
"license": "BUSL-1.1",
66
"scripts": {

src/index.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,8 @@ class HawkAPI {
248248

249249
await redis.initialize();
250250

251+
this.registerShutdownHandlers(redis);
252+
251253
/**
252254
* Setup shared factories for SSO and GitHub integration routes
253255
* These endpoints don't require per-request DataLoaders isolation,
@@ -293,6 +295,31 @@ class HawkAPI {
293295
});
294296
});
295297
}
298+
299+
/**
300+
* Closes HTTP, Mongo and Redis connections on SIGINT/SIGTERM.
301+
*
302+
* @param redis - Redis helper to close on shutdown
303+
*/
304+
private registerShutdownHandlers(redis: RedisHelper): void {
305+
let shuttingDown = false;
306+
307+
const shutdown = async (signal: NodeJS.Signals): Promise<void> => {
308+
if (shuttingDown) {
309+
return;
310+
}
311+
shuttingDown = true;
312+
console.log(`[Shutdown] ${signal} received, closing connections`);
313+
314+
await new Promise<void>((resolve) => this.httpServer.close(() => resolve()));
315+
await Promise.allSettled([mongo.closeConnections(), redis.close()]);
316+
317+
process.exit(0);
318+
};
319+
320+
process.once('SIGINT', shutdown);
321+
process.once('SIGTERM', shutdown);
322+
}
296323
}
297324

298325
export default HawkAPI;

src/mongo.ts

Lines changed: 124 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ import { setupMongoMetrics, withMongoMetrics } from './metrics';
55
const hawkDBUrl = process.env.MONGO_HAWK_DB_URL || 'mongodb://localhost:27017/hawk';
66
const eventsDBUrl = process.env.MONGO_EVENTS_DB_URL || 'mongodb://localhost:27017/events';
77

8+
const reconnectTries = Number(process.env.MONGO_RECONNECT_TRIES) || 60;
9+
const reconnectInterval = Number(process.env.MONGO_RECONNECT_INTERVAL) || 1000;
10+
11+
/**
12+
* serverSelectionTimeoutMS bounds how long an op waits for an available
13+
* server — without it queries hang forever during an outage.
14+
*/
15+
const connectionConfig: MongoClientOptions = withMongoMetrics({
16+
serverSelectionTimeoutMS: 10000,
17+
socketTimeoutMS: 45000,
18+
retryWrites: true,
19+
retryReads: true,
20+
});
21+
822
/**
923
* Connections to Hawk databases
1024
*/
@@ -52,40 +66,124 @@ export const mongoClients: MongoClients = {
5266
};
5367

5468
/**
55-
* Common params for all connections
69+
* Connects to the given URL, retrying with a fixed interval up to
70+
* MONGO_RECONNECT_TRIES times before giving up.
71+
*
72+
* @param name - logical name for logging
73+
* @param url - MongoDB connection string
74+
* @returns connected client
5675
*/
76+
async function connectWithRetry(name: string, url: string): Promise<MongoClient> {
77+
let lastError = 'unknown error';
78+
79+
for (let attempt = 1; attempt <= reconnectTries; attempt++) {
80+
const client = new MongoClient(url, connectionConfig);
81+
82+
try {
83+
await client.connect();
84+
console.log(`[Mongo:${name}] connected`);
85+
86+
return client;
87+
} catch (err) {
88+
await client.close().catch(() => undefined);
89+
90+
lastError = (err as Error)?.message ?? String(err);
91+
console.warn(`[Mongo:${name}] attempt ${attempt}/${reconnectTries} failed: ${lastError}`);
92+
93+
if (attempt < reconnectTries) {
94+
await new Promise((resolve) => setTimeout(resolve, reconnectInterval));
95+
}
96+
}
97+
}
98+
99+
throw new Error(`[Mongo:${name}] failed after ${reconnectTries} attempts: ${lastError}`);
100+
}
101+
57102
/**
58-
* Common params for all connections
59-
* Note: useNewUrlParser and useUnifiedTopology are deprecated in mongodb 6.x and removed
103+
* Logs and reports heartbeat failures / recoveries once per transition.
104+
*
105+
* @param name - logical name for logging
106+
* @param client - connected client to observe
60107
*/
61-
const connectionConfig: MongoClientOptions = withMongoMetrics({});
108+
function watchConnection(name: string, client: MongoClient): void {
109+
let healthy = true;
110+
111+
client.on('serverHeartbeatFailed', (event) => {
112+
if (!healthy) {
113+
return;
114+
}
115+
healthy = false;
116+
const message = (event.failure as Error)?.message ?? 'heartbeat failed';
117+
118+
console.error(`[Mongo:${name}] connection lost: ${message}`);
119+
HawkCatcher.send(new Error(`MongoDB ${name} connection lost: ${message}`));
120+
});
121+
122+
client.on('serverHeartbeatSucceeded', () => {
123+
if (healthy) {
124+
return;
125+
}
126+
healthy = true;
127+
console.log(`[Mongo:${name}] connection recovered`);
128+
});
129+
}
62130

63131
/**
64-
* Setups connections to the databases (hawk api and events databases)
132+
* Connects to both databases with bounded retry. The driver auto-recovers
133+
* from transient failures on already-open clients, so retries here cover
134+
* the initial handshake only.
135+
*
136+
* @returns promise resolved when both clients are connected
65137
*/
66138
export async function setupConnections(): Promise<void> {
67-
try {
68-
const [hawkMongoClient, eventsMongoClient] = await Promise.all([
69-
MongoClient.connect(hawkDBUrl, connectionConfig),
70-
MongoClient.connect(eventsDBUrl, connectionConfig),
71-
]);
72-
73-
mongoClients.hawk = hawkMongoClient;
74-
mongoClients.events = eventsMongoClient;
75-
76-
databases.hawk = hawkMongoClient.db();
77-
databases.events = eventsMongoClient.db();
78-
79-
/**
80-
* Log and and measure MongoDB metrics
81-
*/
82-
setupMongoMetrics(hawkMongoClient);
83-
setupMongoMetrics(eventsMongoClient);
84-
} catch (e) {
85-
/** Catch start Mongo errors */
86-
HawkCatcher.send(e as Error);
87-
throw e;
139+
const results = await Promise.allSettled([
140+
connectWithRetry('hawk', hawkDBUrl),
141+
connectWithRetry('events', eventsDBUrl),
142+
]);
143+
144+
const failure = results.find((r): r is PromiseRejectedResult => r.status === 'rejected');
145+
146+
if (failure) {
147+
/** Close any clients that did connect so we don't leak sockets */
148+
await Promise.allSettled(
149+
results.map((r) => (r.status === 'fulfilled' ? r.value.close() : Promise.resolve()))
150+
);
151+
HawkCatcher.send(failure.reason as Error);
152+
throw failure.reason;
88153
}
154+
155+
const hawkClient = (results[0] as PromiseFulfilledResult<MongoClient>).value;
156+
const eventsClient = (results[1] as PromiseFulfilledResult<MongoClient>).value;
157+
158+
mongoClients.hawk = hawkClient;
159+
mongoClients.events = eventsClient;
160+
databases.hawk = hawkClient.db();
161+
databases.events = eventsClient.db();
162+
163+
/**
164+
* Log and measure MongoDB metrics, then observe heartbeats for outage logs
165+
*/
166+
setupMongoMetrics(hawkClient);
167+
setupMongoMetrics(eventsClient);
168+
watchConnection('hawk', hawkClient);
169+
watchConnection('events', eventsClient);
170+
}
171+
172+
/**
173+
* Closes both clients. Call from SIGTERM/SIGINT for graceful shutdown.
174+
*
175+
* @returns promise resolved once both clients are closed
176+
*/
177+
export async function closeConnections(): Promise<void> {
178+
await Promise.allSettled([
179+
mongoClients.hawk?.close(),
180+
mongoClients.events?.close(),
181+
]);
182+
183+
mongoClients.hawk = null;
184+
mongoClients.events = null;
185+
databases.hawk = null;
186+
databases.events = null;
89187
}
90188

91189
/**

test/mongo.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
const connectMock = jest.fn();
2+
const closeMock = jest.fn().mockResolvedValue(undefined);
3+
4+
jest.mock('mongodb', () => ({
5+
MongoClient: jest.fn().mockImplementation(() => ({
6+
connect: connectMock,
7+
close: closeMock,
8+
db: jest.fn().mockReturnValue({ databaseName: 'test' }),
9+
on: jest.fn(),
10+
})),
11+
}));
12+
13+
jest.mock('@hawk.so/nodejs', () => ({
14+
__esModule: true,
15+
default: { send: jest.fn() },
16+
}));
17+
18+
jest.mock('../src/metrics', () => ({
19+
setupMongoMetrics: jest.fn(),
20+
withMongoMetrics: (options: Record<string, unknown>): Record<string, unknown> => options,
21+
}));
22+
23+
/**
24+
* Loads a fresh copy of src/mongo with the given retry env vars applied.
25+
*
26+
* @param tries - value for MONGO_RECONNECT_TRIES
27+
* @returns the freshly required mongo module
28+
*/
29+
function loadMongo(tries: number): typeof import('../src/mongo') {
30+
jest.resetModules();
31+
process.env.MONGO_RECONNECT_TRIES = String(tries);
32+
process.env.MONGO_RECONNECT_INTERVAL = '1';
33+
34+
return require('../src/mongo');
35+
}
36+
37+
describe('mongo connection', () => {
38+
beforeEach(() => {
39+
jest.clearAllMocks();
40+
jest.spyOn(console, 'log').mockImplementation(() => undefined);
41+
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
42+
jest.spyOn(console, 'error').mockImplementation(() => undefined);
43+
});
44+
45+
afterEach(() => {
46+
jest.restoreAllMocks();
47+
});
48+
49+
test('retries on failure and connects when a later attempt succeeds', async () => {
50+
connectMock
51+
.mockRejectedValueOnce(new Error('down'))
52+
.mockRejectedValueOnce(new Error('down'))
53+
.mockResolvedValue(undefined);
54+
55+
const mongo = loadMongo(3);
56+
57+
await expect(mongo.setupConnections()).resolves.toBeUndefined();
58+
expect(mongo.databases.hawk).not.toBeNull();
59+
expect(mongo.databases.events).not.toBeNull();
60+
});
61+
62+
test('rejects after exhausting MONGO_RECONNECT_TRIES attempts', async () => {
63+
connectMock.mockRejectedValue(new Error('down'));
64+
65+
const mongo = loadMongo(3);
66+
67+
await expect(mongo.setupConnections()).rejects.toThrow(/failed after 3 attempts/);
68+
/** 3 tries per client, hawk + events run in parallel */
69+
expect(connectMock).toHaveBeenCalledTimes(6);
70+
});
71+
72+
test('closeConnections closes clients and nulls exported handles', async () => {
73+
connectMock.mockResolvedValue(undefined);
74+
75+
const mongo = loadMongo(3);
76+
77+
await mongo.setupConnections();
78+
await mongo.closeConnections();
79+
80+
expect(closeMock).toHaveBeenCalledTimes(2);
81+
expect(mongo.databases.hawk).toBeNull();
82+
expect(mongo.databases.events).toBeNull();
83+
expect(mongo.mongoClients.hawk).toBeNull();
84+
expect(mongo.mongoClients.events).toBeNull();
85+
});
86+
});

0 commit comments

Comments
 (0)