Skip to content
Merged
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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# Changelog

## Unreleased

### @igojs/server

- **Added**: ordered shutdown on `SIGTERM` and `SIGINT`, installed by `app.run()`. Readiness answers 503 and igo waits `config.shutdownDelay` (`0` by default) so a load balancer can take the instance out, then the HTTP server closes while the requests in flight finish, then `config.onShutdown()` runs, then the databases and the cache are released. `config.shutdownTimeout` (10s) caps the whole thing, and a second signal exits immediately. Nothing is installed when `config.env === 'test'`.
- **Added**: `config.onShutdown`, an async callback invoked between the server closing and the database being released — the single place for a project to drain its own pools and flush its telemetry exporters, instead of a second `process.on('SIGTERM')` racing igo's. A rejection is logged and the shutdown carries on.
- **Added**: `app.shutdown()`, exported so a cron or a script, which has no signal to wait for, can release the pools when its work is done. Runs once, never rejects.
- **Added**: `app.server` and the new settings are declared in `index.d.ts`; `app.server` previously had no type at all.
- **Changed**: a failed request is one log line, not two. The error lands on the `request` line — `message` becomes the error and `stack` comes with it — instead of a separate line carrying the stack while the other carried the body and the response. Neither told the whole story. Applies to server-rendered routes as well as JSON ones. An error raised after the response was sent, or outside any request (`uncaughtException`, a CLI command), still gets its own line: there is no request line left to join.
- **Fixed**: the reason a 500 failed is no longer lost in production. The response body is deliberately emptied there, and the log recorded that empty body; `message` now carries the error itself, whatever the client was told.
- **Changed**: `body`, `query`, `params` and `response` are logged as JSON strings rather than nested objects. A collector that flattens nested fields turned one problem document into `response_status`, `response_title` and `response_type`, scattering it over as many columns as it had keys.
- **Changed**: `query` and `params` are logged whatever the status, when not empty — what was asked is part of reading a successful line, and neither weighs on the volume the way a body does. `body` and `response` remain on error lines only.

### @igojs/db

- **Added**: `dbs.close()` and `Db.close()` release the connection pools, through a new `closePool` on the MySQL and PostgreSQL drivers.
- **Changed**: `query()` on a closed database throws instead of recreating the pool. A query arriving after the shutdown — a forgotten timer, a late callback — would otherwise reopen what was just closed and keep the process alive.

## 6.2.5 - 2026-08-31

### @igojs/server
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export default defineConfig({
{ text: 'i18n', link: '/server/i18n' },
{ text: 'Error handling', link: '/server/errors' },
{ text: 'Logging', link: '/server/logging' },
{ text: 'Shutdown', link: '/server/shutdown' },
],
},
],
Expand Down
37 changes: 35 additions & 2 deletions docs/server/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,41 @@ Every request is logged once it completes:
```

The level follows the status: `error` at 5xx, `warn` at 4xx, `info` otherwise.
An error line also carries what the call failed with — `body`, `query`, `params`
and the `response` sent — redacted and truncated. A successful line does not.
`query` and `params` are there whenever they are not empty, whatever the status:
what was asked is part of reading a line, and neither weighs much.

An error line also carries `body` and the `response` sent, redacted and
truncated — a diagnosis needs the shape of an import, not its content. A
successful line carries neither, which would multiply the volume for little.

These four are logged as **JSON strings**, not nested objects, so a collector
that flattens nested fields cannot scatter one document over `response_status`,
`response_title` and `response_type`.

### An error is one line, not two

When a request fails, the error lands on that same line: the message becomes
the error, and `stack` comes with it.

```json
{"level":"error","message":"Error: connection refused to 10.0.0.5:3306",
"method":"POST","path":"/api/books","status":500,"duration_ms":7.2,
"body":"{\"title\":\"Dune\"}",
"response":"{\"type\":\"about:blank\",\"title\":\"Internal Server Error\",\"status\":500}",
"stack":"Error: connection refused…","trace_id":"4bf92f35…"}
```

One incident, one line, whether the route answers JSON or renders a page. The
stack and the body used to sit on separate lines, so neither told the whole
story.

`message` carries the error rather than the response body, which a 500 in
production deliberately empties: what the client is told is not what the log
needs.

Two cases still get a line of their own — an error raised after the response
was sent, since the request line is already written, and an error outside any
request (`uncaughtException`, a CLI command), which has no line to join.

`config.logrequests` takes `true`, `false`, or a **status floor**: `400` keeps
the errors and drops the successes. One line per request is the largest item in
Expand Down
108 changes: 108 additions & 0 deletions docs/server/shutdown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Shutdown

On `SIGTERM` and `SIGINT`, igo closes what the application holds instead of
letting the process die where it stands: the requests being served finish, the
database pools are released, and the project gets a callback to close its own
resources.

`app.run()` installs the handlers. Nothing else does — a CLI command or a
script has nothing to keep alive, and the test environment installs none at all,
or mocha would never get its hand back.

## Order

```
SIGTERM / SIGINT
1. readiness answers 503 the load balancer stops routing here
wait config.shutdownDelay
2. HTTP server closes no new connection, the current ones finish
3. config.onShutdown() the project's own shutdown
4. databases released
5. cache disconnected
```

The project callback runs **after** the server, so nothing is still being
served once it starts releasing what a request might need, and **before** the
database and the cache, which it may still want to use.

## The project callback

```js
// app/config.js
module.exports.init = (config) => {
config.onShutdown = async () => {
await browserPool.drain();
await stopTelemetry();
};
};
```

This is the single hook. A module with its own resources to release is called
from here rather than adding its own `process.on('SIGTERM')` — two handlers on
the same signal do not wait for each other, and whichever calls `process.exit()`
first takes the rest of the shutdown with it.

A rejection is logged and the shutdown carries on to the database and the cache:
a pool that failed to drain is no reason to lose the connections that would have
been released next.

## Settings

| | Default | |
|---|---|---|
| `config.shutdownDelay` | `0` | Between readiness answering 503 and the socket closing |
| `config.shutdownTimeout` | `10000` | Ceiling on the whole shutdown, after which the process exits 1 |

`shutdownDelay` is what gives a load balancer time to take the instance out
before it stops accepting connections. Without one it is dead time, hence the
`0` default; behind one, set it above the health check interval:

```js
config.shutdownDelay = 5000;
```

Both delays are spent before the process manager's own patience runs out, so
its kill timeout has to exceed their sum — pm2 defaults to 1600 ms, which is
below both:

```js
// ecosystem.config.js
module.exports = {
apps: [{
name: 'myapp',
kill_timeout: 20000, // > shutdownDelay + shutdownTimeout
}],
};
```

A second signal exits immediately with code 1, which is what a second `Ctrl-C`
is asking for.

## Without a server

A cron or a script that calls `app.configure()` has no signal to wait for, and
the database pool keeps the process alive once the work is done. Close it
explicitly:

```js
const { app } = require('@igojs/server');

await app.configure();
await doTheWork();
await app.shutdown();
```

`app.shutdown()` never rejects and runs once, whatever calls it.

## After the shutdown

A query issued after the databases are released — a forgotten `setInterval`, a
callback arriving late — is rejected rather than reopening the pool:

```
Error: Db 'main' is closed: the application is shutting down.
```

Reopening would keep the process alive past the shutdown that just closed it.
The error names the database, which is usually enough to find the timer nobody
cleared.
27 changes: 27 additions & 0 deletions packages/db/src/Db.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,42 @@ class Db {
}
this.driver = getDriver(this.config.driver);
this.connection = null;
this.closed = false;
this.config.migrations_dir = `sql/${this.name}`;
}

async init() {
const { config } = dependencies;
this.pool = await this.driver.createPool(this.config);
this.connection = null;
this.closed = false;
this.TEST_ENV = config.env === 'test';
}

async close() {
if (this.closed) {
return;
}
this.closed = true;
const { pool, connection } = this;
this.pool = null;
this.connection = null;
// a connection kept across queries is still checked out, and
// the pool would wait for it to come back before ending
if (connection) {
this.driver.release(connection);
}
if (pool) {
await this.driver.closePool(pool);
}
}

//
async getConnection() {
const { driver, pool, TEST_ENV } = this;
if (this.closed) {
throw new Error(`Db '${this.name}' is closed: the application is shutting down.`);
}
// if connection is in local storage
if (TEST_ENV && this.connection) {
// console.log('keep same connection');
Expand Down Expand Up @@ -98,6 +121,10 @@ class Db {
}
};

if (this.closed) {
throw new Error(`Db '${this.name}' is closed: the application is shutting down.`);
}

if (this.pool) {
return await runquery();
}
Expand Down
16 changes: 16 additions & 0 deletions packages/db/src/dbs.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,19 @@ module.exports.init = async () => {
// main is first database
module.exports.main = module.exports[config.databases[0]];
};

// close databases connections
module.exports.close = async () => {
const { config, logger } = dependencies;
for (const database of config.databases) {
const db = module.exports[database];
if (!db) {
continue;
}
try {
await db.close();
} catch (err) {
logger.error(`Could not close database '${database}': ${err.message}`);
}
}
};
5 changes: 5 additions & 0 deletions packages/db/src/drivers/mysql.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ module.exports.createPool = (dbconfig) => {
return mysql.createPool(_.pick(dbconfig, OPTIONS));
};

// close pool
module.exports.closePool = async (pool) => {
await pool.end();
};

// get connection
module.exports.getConnection = async (pool) => {
return await pool.getConnection();
Expand Down
5 changes: 5 additions & 0 deletions packages/db/src/drivers/postgresql.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ module.exports.createPool = (dbconfig) => {
return new Pool(dbconfig);
};

// close pool
module.exports.closePool = async (pool) => {
await pool.end();
};

// get connection
module.exports.getConnection = async (pool) => {
return await pool.connect();
Expand Down
23 changes: 23 additions & 0 deletions packages/server/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,21 @@ export interface Config {
mailcrashto?: string | string[];
/** false keeps the server alive after an uncaught exception a request already answered. */
exitOnUncaughtException: boolean;
/**
* Milliseconds between readiness answering 503 and the HTTP server closing,
* so a load balancer takes the instance out before it stops accepting
* connections. 0 by default; behind a load balancer, set it above its check
* interval.
*/
shutdownDelay: number;
/** Ceiling on the whole shutdown, after which the process exits anyway. */
shutdownTimeout: number;
/**
* Invoked once the HTTP server is closed and before the database and the
* cache are released — drain your own pools and flush your exporters here.
* A rejection is logged and the shutdown carries on.
*/
onShutdown?: (() => void | Promise<void>) | null;
loglevel: string;
/** 'json' for log collectors, 'human' for a terminal. */
logformat: 'json' | 'human';
Expand All @@ -115,6 +130,14 @@ export interface Config {
export declare const app: Express & {
configure(): Promise<void>;
run(configured?: () => void, started?: () => void): Promise<void>;
/**
* Closes the HTTP server, then config.onShutdown, then the databases and the
* cache. run() binds it to SIGTERM and SIGINT; call it directly from a script
* or a cron, which has no signal to wait for. Never rejects.
*/
shutdown(): Promise<void>;
/** Set by run() once the server is listening. */
server?: import('http').Server;
};

export declare const config: Config;
Expand Down
Loading
Loading