@pxp-nd/core 1.2.90 → 1.2.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/dist/lib/pxp/Controller.d.ts +1 -0
- package/dist/lib/pxp/Controller.js +21 -5
- package/dist/lib/pxp/utils/DbTimeouts.d.ts +1 -0
- package/dist/lib/pxp/utils/DbTimeouts.js +14 -2
- package/package.json +25 -3
- package/test/db-timeouts.test.ts +239 -0
- package/test/error-path.test.ts +145 -0
package/README.md
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
# PXP-ND core
|
|
2
2
|
- Core module for pxp-nd
|
|
3
3
|
|
|
4
|
+
## 1.2.91
|
|
5
|
+
|
|
6
|
+
Correcciones encontradas en las pruebas de carga con la base de datos caída. Sin cambios de API ni de configuración.
|
|
7
|
+
|
|
8
|
+
### A. La respuesta de error ya no se cuelga si falla el log
|
|
9
|
+
|
|
10
|
+
Antes, en ambos handlers de ruta (con y sin autenticación), el `insertLog` del error se esperaba antes de llamar a `errorMiddleware`. Con la base caída `insertLog` fallaba, la excepción escapaba del `catch`, nunca se enviaba la respuesta y la request quedaba colgada hasta el timeout del cliente/ALB (en el log: `unhandledRejection PxpError: connect ECONNREFUSED`).
|
|
11
|
+
|
|
12
|
+
Ahora el log de error va en su propio `try/catch`: `errorMiddleware` **siempre** responde con el error original. Si el log falla se escribe `[pxp-core] error log failed <mensaje>` en consola y la respuesta sale sin `logId`.
|
|
13
|
+
|
|
14
|
+
### B. El log de éxito ya no genera `unhandledRejection`
|
|
15
|
+
|
|
16
|
+
El `insertLog` del camino de éxito de `ormMethodWrapper` no se espera (fire-and-forget) y no tenía `.catch`: si fallaba, el rechazo quedaba sin manejar y en Node ≥15 sin handler mata el proceso. Ahora tiene `.catch` que escribe `[pxp-core] success log failed <mensaje>`. (Los wrappers `Procedure` y `Sql` no registran log, no aplica.)
|
|
17
|
+
|
|
18
|
+
### C. La conexión que llega tarde tras un acquire timeout vuelve al pool
|
|
19
|
+
|
|
20
|
+
Con `db.acquireTimeoutMs`, cuando la conexión llegaba después del timeout se destruía. Esa conexión está limpia (nunca se abrió transacción), así que destruirla obligaba a mysql2 a abrir otra y dejaba una cola residual (observado: health en 503 unos 6 s después de una ráfaga de 40 requests). Ahora se devuelve al pool con `queryRunner.release()`. El timeout de request (`@Timeout`) sigue destruyendo la conexión, porque ahí sí puede haber una transacción abierta.
|
|
21
|
+
|
|
4
22
|
## 1.2.90
|
|
5
23
|
|
|
6
24
|
Release de resiliencia de conexiones a base de datos. **Sin configurar nada, el comportamiento es idéntico a 1.2.89**: todas las opciones nuevas son opt-in.
|
|
@@ -61,3 +79,17 @@ Dependen de core con `^1.2.x`, así que tomarán esta versión en su próximo `n
|
|
|
61
79
|
- cleaners-pxp-nd
|
|
62
80
|
|
|
63
81
|
Sin configurar `db.acquireTimeoutMs` ni `@Timeout`/`timeoutMs`, ninguno de ellos cambia de comportamiento respecto a 1.2.89.
|
|
82
|
+
|
|
83
|
+
### Tests (`npm test`)
|
|
84
|
+
|
|
85
|
+
`test/db-timeouts.test.ts` (jest + ts-jest) prueba contra un MySQL **real y desechable**: comportamiento legado sin `db`, `db.acquireTimeoutMs` con un pool `write` de 1 conexión, y `@Timeout` (503, sin transacción abierta en `innodb_trx`, nada commiteado, el pool se recupera).
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
docker run --rm -d --name pxp-core-test-mysql -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE=pxptest -p 33306:3306 mysql:8
|
|
89
|
+
npm test
|
|
90
|
+
docker stop pxp-core-test-mysql
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Variables: `TEST_DB_HOST` (127.0.0.1), `TEST_DB_PORT` (33306), `TEST_DB_USER` (root), `TEST_DB_PASSWORD` (test), `TEST_DB_NAME` (pxptest). Usar solo una BD desechable: los tests crean las tablas `tsec_log` y `pxp_timeout_probe` y ocupan el pool con `SLEEP`, así que no apuntarlos a dev/beta/prod compartidas. Si la BD no responde, la suite se marca `skipped` (nunca falla por falta de BD).
|
|
94
|
+
|
|
95
|
+
`test/error-path.test.ts` no necesita MySQL: `insertLog` está mockeado para fallar y verifica que una ruta que lanza `PxpError(400)` responde 400 con el mensaje original (sin colgarse) y que el fallo del log de éxito no produce `unhandledRejection`.
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* Last modified : 2021-05-01 18:35:31 - Favio Figueroa
|
|
15
15
|
* Last modified : 2026-10-08 - Favio Figueroa - EF-23 per-request controller clone (from 1.2.89), startTransaction inside try
|
|
16
16
|
* Last modified : 2026-10-08 - Favio Figueroa - EF-23 acquire timeout + @Timeout decorator
|
|
17
|
+
* Last modified : 2026-10-08 - Favio Figueroa - EF-23 log failures never block or crash the response (1.2.91)
|
|
17
18
|
*/
|
|
18
19
|
import { EntityManager } from 'typeorm';
|
|
19
20
|
import { Request, Response, NextFunction } from 'express';
|
|
@@ -39,6 +39,7 @@ exports.Controller = void 0;
|
|
|
39
39
|
* Last modified : 2021-05-01 18:35:31 - Favio Figueroa
|
|
40
40
|
* Last modified : 2026-10-08 - Favio Figueroa - EF-23 per-request controller clone (from 1.2.89), startTransaction inside try
|
|
41
41
|
* Last modified : 2026-10-08 - Favio Figueroa - EF-23 acquire timeout + @Timeout decorator
|
|
42
|
+
* Last modified : 2026-10-08 - Favio Figueroa - EF-23 log failures never block or crash the response (1.2.91)
|
|
42
43
|
*/
|
|
43
44
|
const typeorm_1 = require("typeorm");
|
|
44
45
|
const class_validator_1 = require("class-validator");
|
|
@@ -188,8 +189,15 @@ class Controller {
|
|
|
188
189
|
const now = new Date();
|
|
189
190
|
const iniAt = req.start;
|
|
190
191
|
const endsAt = now.valueOf() - iniAt.valueOf();
|
|
191
|
-
|
|
192
|
-
|
|
192
|
+
// A failing error log (e.g. database down) must never prevent the response.
|
|
193
|
+
try {
|
|
194
|
+
res.logId = await index_1.__(index_1.insertLog('nouser', 'mac', req.ip, 'error', ex.tecMessage, controllerForRequest.module, controllerForRequest.transactionCode, '', // query
|
|
195
|
+
params, ex.stack, ex.statusCode, endsAt, logValue));
|
|
196
|
+
}
|
|
197
|
+
catch (logErr) {
|
|
198
|
+
// __ wraps driver errors in PxpError(500) with a generic message; the real cause is tecMessage.
|
|
199
|
+
console.error('[pxp-core] error log failed', logErr && (logErr.tecMessage || logErr.message));
|
|
200
|
+
}
|
|
193
201
|
index_1.errorMiddleware(ex, req, res);
|
|
194
202
|
}
|
|
195
203
|
});
|
|
@@ -222,8 +230,15 @@ class Controller {
|
|
|
222
230
|
const now = new Date();
|
|
223
231
|
const iniAt = req.start;
|
|
224
232
|
const endsAt = now.valueOf() - iniAt.valueOf();
|
|
225
|
-
|
|
226
|
-
|
|
233
|
+
// A failing error log (e.g. database down) must never prevent the response.
|
|
234
|
+
try {
|
|
235
|
+
res.logId = (await index_1.__(index_1.insertLog(controllerForRequest.user && controllerForRequest.user.username ? controllerForRequest.user.username : 'nouser', 'mac', req.ip, 'error', ex.tecMessage, controllerForRequest.module, controllerForRequest.transactionCode, '', // query
|
|
236
|
+
params, ex.stack, ex.statusCode, endsAt, logValue)));
|
|
237
|
+
}
|
|
238
|
+
catch (logErr) {
|
|
239
|
+
// __ wraps driver errors in PxpError(500) with a generic message; the real cause is tecMessage.
|
|
240
|
+
console.error('[pxp-core] error log failed', logErr && (logErr.tecMessage || logErr.message));
|
|
241
|
+
}
|
|
227
242
|
index_1.errorMiddleware(ex, req, res);
|
|
228
243
|
}
|
|
229
244
|
});
|
|
@@ -313,7 +328,8 @@ class Controller {
|
|
|
313
328
|
const now = new Date();
|
|
314
329
|
const iniAt = req.start;
|
|
315
330
|
const endsAt = now.valueOf() - iniAt.valueOf();
|
|
316
|
-
|
|
331
|
+
// Fire-and-forget: without the catch a failing log is an unhandledRejection (fatal on Node >= 15).
|
|
332
|
+
index_1.__(index_1.insertLog(this.user && this.user.username ? this.user.username : 'nouser', 'mac', req.ip, 'success', 'successful transaction', this.module, this.transactionCode, '', params, JSON.stringify(metResponse), '200', endsAt, logConfig)).catch(err => console.error('[pxp-core] success log failed', err && (err.tecMessage || err.message)));
|
|
317
333
|
}
|
|
318
334
|
//PARA REPORTES
|
|
319
335
|
if (req.report && req.report.type === 'pdf') {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @author Favio Figueroa
|
|
10
10
|
*
|
|
11
11
|
* Created at : 2026-10-08 - Favio Figueroa - EF-23 acquire timeout + @Timeout decorator
|
|
12
|
+
* Last modified : 2026-10-08 - Favio Figueroa - EF-23 late acquired connection is released to the pool, not destroyed (1.2.91)
|
|
12
13
|
*/
|
|
13
14
|
import { QueryRunner } from 'typeorm';
|
|
14
15
|
import { PxpError } from '../PxpError';
|
|
@@ -52,8 +52,20 @@ async function connectWithTimeout(queryRunner, ms, transactionCode) {
|
|
|
52
52
|
}
|
|
53
53
|
catch (err) {
|
|
54
54
|
if (timedOut) {
|
|
55
|
-
// A late connection
|
|
56
|
-
|
|
55
|
+
// A late connection is pristine (no transaction was started on it), so it is returned
|
|
56
|
+
// to the pool instead of destroyed: destroying it would force the driver to open a new
|
|
57
|
+
// one and keep a residual queue after a burst. destroyQueryRunner stays for the
|
|
58
|
+
// request-timeout path, where a transaction may still be open.
|
|
59
|
+
connectPromise
|
|
60
|
+
.then(async () => {
|
|
61
|
+
try {
|
|
62
|
+
await queryRunner.release();
|
|
63
|
+
}
|
|
64
|
+
catch (releaseErr) {
|
|
65
|
+
console.error('[pxp-core] late connection release failed', releaseErr && releaseErr.message);
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
.catch(() => { });
|
|
57
69
|
}
|
|
58
70
|
throw err;
|
|
59
71
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pxp-nd/core",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.91",
|
|
4
4
|
"description": "Contains main files for pxp-nd",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"prod:publish": "npm publish",
|
|
13
13
|
"start": "npm run build && node dist/index.js",
|
|
14
14
|
"dev": "tsc-watch --target es2017 --outDir ./dist --onSuccess \"node .\"",
|
|
15
|
-
"test": "
|
|
15
|
+
"test": "jest --runInBand"
|
|
16
16
|
},
|
|
17
17
|
"keywords": [
|
|
18
18
|
"pxp-nd"
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"@types/express-fileupload": "^1.1.6",
|
|
29
29
|
"@types/express-session": "^1.17.3",
|
|
30
30
|
"@types/figlet": "^1.5.1",
|
|
31
|
+
"@types/jest": "^27.5.2",
|
|
31
32
|
"@types/joi": "^17.2.3",
|
|
32
33
|
"@types/lodash": "^4.14.170",
|
|
33
34
|
"@types/node": "^15.3.1",
|
|
@@ -35,7 +36,10 @@
|
|
|
35
36
|
"@types/socket.io": "^3.0.2",
|
|
36
37
|
"@types/swagger-jsdoc": "^6.0.1",
|
|
37
38
|
"@types/swagger-ui-express": "^4.1.3",
|
|
39
|
+
"jest": "^27.5.1",
|
|
40
|
+
"mysql2": "^2.3.3",
|
|
38
41
|
"rimraf": "^3.0.2",
|
|
42
|
+
"ts-jest": "^27.1.5",
|
|
39
43
|
"tsc-watch": "^4.2.9",
|
|
40
44
|
"typescript": "^4.2.4"
|
|
41
45
|
},
|
|
@@ -61,5 +65,23 @@
|
|
|
61
65
|
"typeorm-store": "^2.0.0",
|
|
62
66
|
"xlsx": "^0.17.0"
|
|
63
67
|
},
|
|
64
|
-
"
|
|
68
|
+
"jest": {
|
|
69
|
+
"preset": "ts-jest",
|
|
70
|
+
"testEnvironment": "node",
|
|
71
|
+
"roots": [
|
|
72
|
+
"<rootDir>/test"
|
|
73
|
+
],
|
|
74
|
+
"testTimeout": 30000,
|
|
75
|
+
"globals": {
|
|
76
|
+
"ts-jest": {
|
|
77
|
+
"tsconfig": "<rootDir>/test/tsconfig.json"
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"moduleNameMapper": {
|
|
81
|
+
"^(\\.{1,2}/.*)\\.js$": "$1"
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"overrides": {
|
|
85
|
+
"@types/babel__traverse": "7.17.1"
|
|
86
|
+
}
|
|
65
87
|
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression and timeout tests for write transactions in ormMethodWrapper:
|
|
3
|
+
* 1. no `db` config and no @Timeout: legacy behaviour (connect -> transaction -> commit -> release)
|
|
4
|
+
* 2. `db.acquireTimeoutMs`: waiting for a busy pool fails with 503 and the pool recovers
|
|
5
|
+
* 3. `@Timeout(ms)`: the request fails with 503, no transaction stays open, nothing is committed
|
|
6
|
+
*
|
|
7
|
+
* Requires a REAL, disposable MySQL. Connection settings (defaults target a local throwaway container):
|
|
8
|
+
* TEST_DB_HOST (127.0.0.1) TEST_DB_PORT (33306) TEST_DB_USER (root) TEST_DB_PASSWORD (test) TEST_DB_NAME (pxptest)
|
|
9
|
+
* docker run --rm -d -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE=pxptest -p 33306:3306 mysql:8
|
|
10
|
+
* If the database is not reachable the whole suite is skipped (it never fails for lack of a database).
|
|
11
|
+
*
|
|
12
|
+
* Harness: a Controller subclass mounted on a bare express app. A full PxpApp is not used because
|
|
13
|
+
* its run() pulls in sessions, auth modules, swagger and dist/modules folder scanning, none of which
|
|
14
|
+
* is involved in the code under test (Controller.initializeRoutes + ormMethodWrapper).
|
|
15
|
+
*/
|
|
16
|
+
import 'reflect-metadata';
|
|
17
|
+
import { execFileSync } from 'child_process';
|
|
18
|
+
import http from 'http';
|
|
19
|
+
import { AddressInfo } from 'net';
|
|
20
|
+
import express from 'express';
|
|
21
|
+
import { createConnections, Connection, EntityManager, getConnection } from 'typeorm';
|
|
22
|
+
import { Controller } from '../src/lib/pxp/Controller';
|
|
23
|
+
import { Post, ReadOnly, Timeout } from '../src/lib/pxp/Decorators';
|
|
24
|
+
import { IConfigPxpApp } from '../src/interfaces';
|
|
25
|
+
import Log from '../src/entities/Log';
|
|
26
|
+
|
|
27
|
+
const DB = {
|
|
28
|
+
host: process.env.TEST_DB_HOST || '127.0.0.1',
|
|
29
|
+
port: Number(process.env.TEST_DB_PORT || 33306),
|
|
30
|
+
username: process.env.TEST_DB_USER || 'root',
|
|
31
|
+
password: process.env.TEST_DB_PASSWORD || 'test',
|
|
32
|
+
database: process.env.TEST_DB_NAME || 'pxptest',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Synchronous reachability probe so the suite can be declared with describe.skip.
|
|
36
|
+
const probeDb = (): string | null => {
|
|
37
|
+
const script = `
|
|
38
|
+
const mysql = require('mysql2');
|
|
39
|
+
const c = mysql.createConnection({ host: process.argv[1], port: +process.argv[2], user: process.argv[3],
|
|
40
|
+
password: process.argv[4], database: process.argv[5], connectTimeout: 3000 });
|
|
41
|
+
c.query('SELECT 1', (err) => { c.destroy(); if (err) { console.error(err.message); process.exit(1); } process.exit(0); });
|
|
42
|
+
`;
|
|
43
|
+
try {
|
|
44
|
+
execFileSync(process.execPath, ['-e', script, DB.host, String(DB.port), DB.username, DB.password, DB.database], {
|
|
45
|
+
cwd: __dirname,
|
|
46
|
+
stdio: 'pipe',
|
|
47
|
+
timeout: 10000,
|
|
48
|
+
});
|
|
49
|
+
return null;
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return String((e as any).stderr || (e as any).message).trim();
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const dbError = probeDb();
|
|
56
|
+
if (dbError) {
|
|
57
|
+
// eslint-disable-next-line no-console
|
|
58
|
+
console.warn(
|
|
59
|
+
`[db-timeouts.test] SKIPPED: test MySQL not reachable at ${DB.host}:${DB.port}/${DB.database} (${dbError}). ` +
|
|
60
|
+
'Start one with: docker run --rm -d -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE=pxptest -p 33306:3306 mysql:8'
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const describeDb = dbError ? describe.skip : describe;
|
|
64
|
+
|
|
65
|
+
class Slow extends Controller {
|
|
66
|
+
@Post()
|
|
67
|
+
@ReadOnly(false)
|
|
68
|
+
async hold(params: any, manager: EntityManager) {
|
|
69
|
+
await manager.query('SELECT SLEEP(?)', [Number(params.secs)]);
|
|
70
|
+
return { ok: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Writes a row first so the transaction is a real InnoDB transaction (visible in innodb_trx),
|
|
74
|
+
// then outlives its @Timeout.
|
|
75
|
+
@Post()
|
|
76
|
+
@ReadOnly(false)
|
|
77
|
+
@Timeout(500)
|
|
78
|
+
async fast(params: any, manager: EntityManager) {
|
|
79
|
+
await manager.query('INSERT INTO pxp_timeout_probe (marker) VALUES (?)', [String(params.marker)]);
|
|
80
|
+
await manager.query('SELECT SLEEP(2)');
|
|
81
|
+
return { ok: true };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const baseConfig: IConfigPxpApp = {
|
|
86
|
+
apiPrefix: '/api',
|
|
87
|
+
defaultDbSettings: 'Orm',
|
|
88
|
+
middlewares: [],
|
|
89
|
+
entities: {},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const startApp = async (config: IConfigPxpApp) => {
|
|
93
|
+
const app = express();
|
|
94
|
+
app.use(express.json());
|
|
95
|
+
app.use((req: any, _res, next) => {
|
|
96
|
+
req.start = new Date();
|
|
97
|
+
next();
|
|
98
|
+
});
|
|
99
|
+
const controller = new Slow('test', null, config);
|
|
100
|
+
app.use('/', controller.router);
|
|
101
|
+
const server = http.createServer(app);
|
|
102
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
103
|
+
const { port } = server.address() as AddressInfo;
|
|
104
|
+
return { server, base: `http://127.0.0.1:${port}/api/test/Slow` };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Plain http client (the jest node environment does not expose global fetch).
|
|
108
|
+
const call = (url: string, body: Record<string, unknown>) =>
|
|
109
|
+
new Promise<{ status: number; json: any; ms: number }>((resolve, reject) => {
|
|
110
|
+
const t0 = Date.now();
|
|
111
|
+
const payload = JSON.stringify(body);
|
|
112
|
+
const req = http.request(
|
|
113
|
+
url,
|
|
114
|
+
{ method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } },
|
|
115
|
+
(res) => {
|
|
116
|
+
let raw = '';
|
|
117
|
+
res.setEncoding('utf8');
|
|
118
|
+
res.on('data', (chunk) => (raw += chunk));
|
|
119
|
+
res.on('end', () => resolve({ status: res.statusCode as number, json: JSON.parse(raw), ms: Date.now() - t0 }));
|
|
120
|
+
}
|
|
121
|
+
);
|
|
122
|
+
req.on('error', reject);
|
|
123
|
+
req.end(payload);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
127
|
+
|
|
128
|
+
describeDb('ormMethodWrapper db timeouts (real MySQL)', () => {
|
|
129
|
+
let connections: Connection[] = [];
|
|
130
|
+
const servers: http.Server[] = [];
|
|
131
|
+
const timings: string[] = [];
|
|
132
|
+
|
|
133
|
+
beforeAll(async () => {
|
|
134
|
+
process.env.DB_WRITE_CONNECTION_NAME = 'write';
|
|
135
|
+
process.env.DB_LOG_CONNECTION_NAME = 'default';
|
|
136
|
+
connections = await createConnections([
|
|
137
|
+
{ name: 'default', type: 'mysql', ...DB, entities: [Log], synchronize: true, logging: false },
|
|
138
|
+
{ name: 'write', type: 'mysql', ...DB, entities: [], synchronize: false, logging: false, extra: { connectionLimit: 1 } },
|
|
139
|
+
]);
|
|
140
|
+
const def = getConnection('default');
|
|
141
|
+
await def.query(
|
|
142
|
+
'CREATE TABLE IF NOT EXISTS pxp_timeout_probe (id INT AUTO_INCREMENT PRIMARY KEY, marker VARCHAR(64) NOT NULL) ENGINE=InnoDB'
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
afterAll(async () => {
|
|
147
|
+
for (const s of servers) await new Promise((r) => s.close(r));
|
|
148
|
+
// ormMethodWrapper writes the success log fire-and-forget (not awaited); let in-flight inserts finish
|
|
149
|
+
// before closing the log connection, otherwise they reject unhandled and crash the runner.
|
|
150
|
+
await sleep(1000);
|
|
151
|
+
for (const c of connections) if (c.isConnected) await c.close();
|
|
152
|
+
// eslint-disable-next-line no-console
|
|
153
|
+
console.log(`[db-timeouts.test] timings:\n ${timings.join('\n ')}`);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('1. legacy: no db config, no @Timeout -> hold secs=1 returns 200 in ~1 s', async () => {
|
|
157
|
+
const { server, base } = await startApp({ ...baseConfig });
|
|
158
|
+
servers.push(server);
|
|
159
|
+
|
|
160
|
+
const r = await call(`${base}/hold`, { secs: 1 });
|
|
161
|
+
timings.push(`case1 hold(1): ${r.status} in ${r.ms} ms`);
|
|
162
|
+
|
|
163
|
+
expect(r.status).toBe(200);
|
|
164
|
+
expect(r.json).toEqual({ ok: true });
|
|
165
|
+
expect(r.ms).toBeGreaterThanOrEqual(950);
|
|
166
|
+
expect(r.ms).toBeLessThan(2000);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('2. db.acquireTimeoutMs=1000 -> second write on a busy 1-connection pool gets 503 in ~1 s; pool recovers', async () => {
|
|
170
|
+
const { server, base } = await startApp({ ...baseConfig, db: { acquireTimeoutMs: 1000 } });
|
|
171
|
+
servers.push(server);
|
|
172
|
+
|
|
173
|
+
const first = call(`${base}/hold`, { secs: 5 });
|
|
174
|
+
await sleep(200);
|
|
175
|
+
const second = await call(`${base}/hold`, { secs: 0 });
|
|
176
|
+
timings.push(`case2 second hold(0): ${second.status} in ${second.ms} ms -> ${second.json?.error?.message}`);
|
|
177
|
+
|
|
178
|
+
expect(second.status).toBe(503);
|
|
179
|
+
expect(second.json.error.message).toBe('Database acquire timeout after 1000 ms');
|
|
180
|
+
expect(second.ms).toBeGreaterThanOrEqual(950);
|
|
181
|
+
expect(second.ms).toBeLessThan(1800);
|
|
182
|
+
|
|
183
|
+
const firstRes = await first;
|
|
184
|
+
timings.push(`case2 first hold(5): ${firstRes.status} in ${firstRes.ms} ms`);
|
|
185
|
+
expect(firstRes.status).toBe(200);
|
|
186
|
+
expect(firstRes.ms).toBeGreaterThanOrEqual(4500);
|
|
187
|
+
|
|
188
|
+
// The late connection handed to the timed-out waiter is released back to the pool (not leaked): a new call succeeds.
|
|
189
|
+
const third = await call(`${base}/hold`, { secs: 0 });
|
|
190
|
+
timings.push(`case2 third hold(0): ${third.status} in ${third.ms} ms`);
|
|
191
|
+
expect(third.status).toBe(200);
|
|
192
|
+
expect(third.ms).toBeLessThan(1000);
|
|
193
|
+
|
|
194
|
+
const pool: any = (getConnection('write').driver as any).pool;
|
|
195
|
+
timings.push(
|
|
196
|
+
`case2 write pool after: all=${pool._allConnections.length} free=${pool._freeConnections.length} queued=${pool._connectionQueue.length}`
|
|
197
|
+
);
|
|
198
|
+
expect(pool._connectionQueue.length).toBe(0);
|
|
199
|
+
expect(pool._allConnections.length).toBeLessThanOrEqual(1);
|
|
200
|
+
expect(pool._freeConnections.length).toBe(pool._allConnections.length);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('3. @Timeout(500) -> 503 in ~0.5 s, no open transaction, nothing committed, pool recovers', async () => {
|
|
204
|
+
const { server, base } = await startApp({ ...baseConfig });
|
|
205
|
+
servers.push(server);
|
|
206
|
+
const warn = jest.spyOn(console, 'warn');
|
|
207
|
+
const marker = `t${Date.now()}`;
|
|
208
|
+
|
|
209
|
+
const r = await call(`${base}/fast`, { marker });
|
|
210
|
+
timings.push(`case3 fast: ${r.status} in ${r.ms} ms -> ${r.json?.error?.message}`);
|
|
211
|
+
|
|
212
|
+
expect(r.status).toBe(503);
|
|
213
|
+
expect(r.json.error.message).toBe('Database request timeout after 500 ms');
|
|
214
|
+
expect(r.ms).toBeGreaterThanOrEqual(450);
|
|
215
|
+
expect(r.ms).toBeLessThan(1200);
|
|
216
|
+
expect(warn).toHaveBeenCalledWith('[pxp-core] timeout-abandoned test.slow.fast');
|
|
217
|
+
warn.mockRestore();
|
|
218
|
+
|
|
219
|
+
const def = getConnection('default');
|
|
220
|
+
let open = -1;
|
|
221
|
+
const t0 = Date.now();
|
|
222
|
+
while (Date.now() - t0 < 3000) {
|
|
223
|
+
const [row] = await def.query('SELECT COUNT(*) AS n FROM information_schema.innodb_trx');
|
|
224
|
+
open = Number(row.n);
|
|
225
|
+
if (open === 0) break;
|
|
226
|
+
await sleep(100);
|
|
227
|
+
}
|
|
228
|
+
timings.push(`case3 innodb_trx open after ${Date.now() - t0} ms: ${open}`);
|
|
229
|
+
expect(open).toBe(0);
|
|
230
|
+
|
|
231
|
+
// MySQL rolled the abandoned transaction back server-side: the row was never committed.
|
|
232
|
+
const [committed] = await def.query('SELECT COUNT(*) AS n FROM pxp_timeout_probe WHERE marker = ?', [marker]);
|
|
233
|
+
expect(Number(committed.n)).toBe(0);
|
|
234
|
+
|
|
235
|
+
const after = await call(`${base}/hold`, { secs: 0 });
|
|
236
|
+
timings.push(`case3 hold(0) after: ${after.status} in ${after.ms} ms`);
|
|
237
|
+
expect(after.status).toBe(200);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error/log path tests for Controller route handlers. No database needed.
|
|
3
|
+
*
|
|
4
|
+
* `insertLog` (src/lib/pxp/utils/Security) is mocked to reject, simulating a log database that is down:
|
|
5
|
+
* 1. a route that throws PxpError(400) must still answer 400 with the original message, in both
|
|
6
|
+
* the unauthenticated and the authenticated route handler (before 1.2.91 the request hung forever)
|
|
7
|
+
* 2. a successful route must answer 200 and the failed fire-and-forget success log must not become
|
|
8
|
+
* an unhandledRejection
|
|
9
|
+
*
|
|
10
|
+
* Harness: same as db-timeouts.test.ts (Controller subclass mounted on a bare express app). All routes
|
|
11
|
+
* are @ReadOnly(true), so ormMethodWrapper never touches a connection.
|
|
12
|
+
*/
|
|
13
|
+
import 'reflect-metadata';
|
|
14
|
+
import http from 'http';
|
|
15
|
+
import { AddressInfo } from 'net';
|
|
16
|
+
import express from 'express';
|
|
17
|
+
|
|
18
|
+
jest.mock('../src/lib/pxp/utils/Security', () => ({
|
|
19
|
+
...jest.requireActual('../src/lib/pxp/utils/Security'),
|
|
20
|
+
insertLog: jest.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import { insertLog } from '../src/lib/pxp/utils/Security';
|
|
24
|
+
import { Controller } from '../src/lib/pxp/Controller';
|
|
25
|
+
import { Authentication, Post, ReadOnly } from '../src/lib/pxp/Decorators';
|
|
26
|
+
import { PxpError } from '../src/lib/pxp/PxpError';
|
|
27
|
+
import { IConfigPxpApp } from '../src/interfaces';
|
|
28
|
+
|
|
29
|
+
const insertLogMock = insertLog as jest.MockedFunction<typeof insertLog>;
|
|
30
|
+
|
|
31
|
+
class Broken extends Controller {
|
|
32
|
+
@Post()
|
|
33
|
+
@ReadOnly(true)
|
|
34
|
+
@Authentication(false)
|
|
35
|
+
async publicFail() {
|
|
36
|
+
throw new PxpError(400, 'public route failed on purpose');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@Post()
|
|
40
|
+
@ReadOnly(true)
|
|
41
|
+
async privateFail() {
|
|
42
|
+
throw new PxpError(400, 'private route failed on purpose');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@Post()
|
|
46
|
+
@ReadOnly(true)
|
|
47
|
+
async ok() {
|
|
48
|
+
return { ok: true };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const config: IConfigPxpApp = {
|
|
53
|
+
apiPrefix: '/api',
|
|
54
|
+
defaultDbSettings: 'Orm',
|
|
55
|
+
middlewares: [],
|
|
56
|
+
entities: {},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Plain http client with a hard deadline so a hanging handler fails the test instead of the runner.
|
|
60
|
+
const call = (url: string, deadlineMs = 2000) =>
|
|
61
|
+
new Promise<{ status: number; json: any; ms: number }>((resolve, reject) => {
|
|
62
|
+
const t0 = Date.now();
|
|
63
|
+
const payload = '{}';
|
|
64
|
+
const req = http.request(
|
|
65
|
+
url,
|
|
66
|
+
{ method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } },
|
|
67
|
+
(res) => {
|
|
68
|
+
let raw = '';
|
|
69
|
+
res.setEncoding('utf8');
|
|
70
|
+
res.on('data', (chunk) => (raw += chunk));
|
|
71
|
+
res.on('end', () => resolve({ status: res.statusCode as number, json: JSON.parse(raw), ms: Date.now() - t0 }));
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
req.setTimeout(deadlineMs, () => {
|
|
75
|
+
req.destroy(new Error(`no response within ${deadlineMs} ms (handler hung)`));
|
|
76
|
+
});
|
|
77
|
+
req.on('error', reject);
|
|
78
|
+
req.end(payload);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('Controller error path with a failing insertLog (no database)', () => {
|
|
82
|
+
let server: http.Server;
|
|
83
|
+
let base: string;
|
|
84
|
+
const unhandled: unknown[] = [];
|
|
85
|
+
const onUnhandled = (reason: unknown) => unhandled.push(reason);
|
|
86
|
+
let consoleError: jest.SpyInstance;
|
|
87
|
+
|
|
88
|
+
beforeAll(async () => {
|
|
89
|
+
process.on('unhandledRejection', onUnhandled);
|
|
90
|
+
const app = express();
|
|
91
|
+
app.use(express.json());
|
|
92
|
+
app.use((req: any, _res, next) => {
|
|
93
|
+
req.start = new Date();
|
|
94
|
+
next();
|
|
95
|
+
});
|
|
96
|
+
app.use('/', new Broken('test', null, config).router);
|
|
97
|
+
server = http.createServer(app);
|
|
98
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
99
|
+
const { port } = server.address() as AddressInfo;
|
|
100
|
+
base = `http://127.0.0.1:${port}/api/test/Broken`;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
insertLogMock.mockReset();
|
|
105
|
+
insertLogMock.mockRejectedValue(new Error('connect ECONNREFUSED 127.0.0.1:3306'));
|
|
106
|
+
consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterEach(() => consoleError.mockRestore());
|
|
110
|
+
|
|
111
|
+
afterAll(async () => {
|
|
112
|
+
process.removeListener('unhandledRejection', onUnhandled);
|
|
113
|
+
await new Promise((r) => server.close(r));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test.each([
|
|
117
|
+
['unauthenticated handler', 'publicFail', 'public route failed on purpose'],
|
|
118
|
+
['authenticated handler', 'privateFail', 'private route failed on purpose'],
|
|
119
|
+
])('%s: PxpError(400) is answered with 400 and the original message', async (_label, path, message) => {
|
|
120
|
+
const r = await call(`${base}/${path}`);
|
|
121
|
+
|
|
122
|
+
expect(r.status).toBe(400);
|
|
123
|
+
expect(r.json.error.code).toBe(400);
|
|
124
|
+
expect(r.json.error.message).toBe(message);
|
|
125
|
+
expect(r.json.error.logId).toBeUndefined();
|
|
126
|
+
expect(r.ms).toBeLessThan(2000);
|
|
127
|
+
expect(insertLogMock).toHaveBeenCalledTimes(1);
|
|
128
|
+
expect(insertLogMock.mock.calls[0][3]).toBe('error');
|
|
129
|
+
expect(consoleError).toHaveBeenCalledWith('[pxp-core] error log failed', 'connect ECONNREFUSED 127.0.0.1:3306');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('success path: 200 and the failed success log is caught (no unhandledRejection)', async () => {
|
|
133
|
+
const r = await call(`${base}/ok`);
|
|
134
|
+
|
|
135
|
+
expect(r.status).toBe(200);
|
|
136
|
+
expect(r.json).toEqual({ ok: true });
|
|
137
|
+
// let the rejected fire-and-forget log settle
|
|
138
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
139
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
140
|
+
expect(insertLogMock).toHaveBeenCalledTimes(1);
|
|
141
|
+
expect(insertLogMock.mock.calls[0][3]).toBe('success');
|
|
142
|
+
expect(consoleError).toHaveBeenCalledWith('[pxp-core] success log failed', 'connect ECONNREFUSED 127.0.0.1:3306');
|
|
143
|
+
expect(unhandled).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
});
|