@pxp-nd/core 1.2.89 → 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 +94 -1
- package/dist/interfaces.d.ts +5 -0
- package/dist/lib/pxp/Controller.d.ts +5 -2
- package/dist/lib/pxp/Controller.js +76 -16
- package/dist/lib/pxp/Decorators.d.ts +3 -1
- package/dist/lib/pxp/Decorators.js +6 -1
- package/dist/lib/pxp/utils/DbTimeouts.d.ts +32 -0
- package/dist/lib/pxp/utils/DbTimeouts.js +77 -0
- 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,2 +1,95 @@
|
|
|
1
1
|
# PXP-ND core
|
|
2
|
-
- Core module for pxp-nd
|
|
2
|
+
- Core module for pxp-nd
|
|
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
|
+
|
|
22
|
+
## 1.2.90
|
|
23
|
+
|
|
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.
|
|
25
|
+
|
|
26
|
+
### `db.acquireTimeoutMs` (opcional)
|
|
27
|
+
|
|
28
|
+
Tiempo máximo, en milisegundos, que una ruta no-readonly espera para obtener una conexión del pool antes de fallar.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
new PxpApp({
|
|
32
|
+
// ...
|
|
33
|
+
db: { acquireTimeoutMs: 5000 },
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- Si vence, la request responde **503** con el mensaje `Database acquire timeout after N ms`.
|
|
38
|
+
- Solo se dispara cuando el pool está agotado; con conexiones libres no agrega latencia.
|
|
39
|
+
- `undefined` o `0` = comportamiento legacy (espera indefinida).
|
|
40
|
+
|
|
41
|
+
### Decorador `@Timeout(ms)` y opción de ruta `timeoutMs`
|
|
42
|
+
|
|
43
|
+
Límite de duración por ruta. Se puede usar el decorador o la opción en la definición de la ruta:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
@Post('/save')
|
|
47
|
+
@Timeout(30000)
|
|
48
|
+
async save(params: any) { /* ... */ }
|
|
49
|
+
|
|
50
|
+
// o bien
|
|
51
|
+
@Post('/save', { timeoutMs: 30000 })
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Al vencer el tiempo:
|
|
55
|
+
- La request responde **503**.
|
|
56
|
+
- La conexión física se destruye, por lo que MySQL hace rollback de la transacción abierta.
|
|
57
|
+
- El handler puede seguir ejecutándose en segundo plano, pero ya sin acceso a la base de datos.
|
|
58
|
+
- Se registra la línea de log `[pxp-core] timeout-abandoned <transactionCode>`.
|
|
59
|
+
|
|
60
|
+
Sin decorador ni `timeoutMs`, la ruta no tiene límite (igual que 1.2.89).
|
|
61
|
+
|
|
62
|
+
### Fix: fuga de conexiones si falla `startTransaction()`
|
|
63
|
+
|
|
64
|
+
Antes, si `startTransaction()` lanzaba un error, la conexión obtenida del pool nunca se liberaba y con el tiempo el pool quedaba agotado. Ahora la conexión se libera siempre.
|
|
65
|
+
|
|
66
|
+
### Clon del controlador por request (`controllerForRequest`)
|
|
67
|
+
|
|
68
|
+
Portado desde 1.2.89: cada request trabaja sobre un clon del controlador, de modo que requests concurrentes ya no se pisan `user`, `headers` ni `transactionCode`.
|
|
69
|
+
|
|
70
|
+
### Proyectos afectados
|
|
71
|
+
|
|
72
|
+
Dependen de core con `^1.2.x`, así que tomarán esta versión en su próximo `npm install`:
|
|
73
|
+
|
|
74
|
+
- tempo
|
|
75
|
+
- conductor-nd
|
|
76
|
+
- go-pxp-nd
|
|
77
|
+
- Api_Boa_ND
|
|
78
|
+
- electrica360_nd
|
|
79
|
+
- cleaners-pxp-nd
|
|
80
|
+
|
|
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`.
|
package/dist/interfaces.d.ts
CHANGED
|
@@ -49,5 +49,10 @@ export interface IConfigPxpApp {
|
|
|
49
49
|
expressJsonConfig?: IConfigExpress;
|
|
50
50
|
expressUrlEncodedConfig?: IConfigExpress;
|
|
51
51
|
permissionFunction?: boolean | ((user: any, transaction: string) => Promise<boolean>);
|
|
52
|
+
/** Database safety options. All optional; when omitted the behaviour is identical to <=1.2.89. */
|
|
53
|
+
db?: {
|
|
54
|
+
/** Max ms to wait for a pool connection before a non-readonly route fails with 503. Undefined/0 = wait forever (legacy). */
|
|
55
|
+
acquireTimeoutMs?: number;
|
|
56
|
+
};
|
|
52
57
|
}
|
|
53
58
|
export {};
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* Last modified : 2020-10-13 15:36:31
|
|
13
13
|
* Last modified : 2021-03-10 15:36:31 - Favio Figueroa
|
|
14
14
|
* Last modified : 2021-05-01 18:35:31 - Favio Figueroa
|
|
15
|
+
* Last modified : 2026-10-08 - Favio Figueroa - EF-23 per-request controller clone (from 1.2.89), startTransaction inside try
|
|
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)
|
|
15
18
|
*/
|
|
16
19
|
import { EntityManager } from 'typeorm';
|
|
17
20
|
import { Request, Response, NextFunction } from 'express';
|
|
@@ -37,8 +40,8 @@ export declare class Controller implements ControllerInterface {
|
|
|
37
40
|
private basicReadOnly;
|
|
38
41
|
constructor(module: string, Entity?: any, config?: IConfigPxpApp, io?: any);
|
|
39
42
|
private initializeRoutes;
|
|
40
|
-
genericMethodWrapper(params: Record<string, unknown>, req: Request, next: NextFunction, res: Response, methodName: string, dbsettings: string, readonly: boolean, permission: boolean, log: boolean, logConfig: {}, isHtml: boolean, isFile: boolean): Promise<void>;
|
|
41
|
-
ormMethodWrapper(params: Record<string, unknown>, req: Request | any, next: NextFunction, res: Response, methodName: string, readonly: boolean, permission?: boolean, log?: boolean, logConfig?: {}, isHtml?: boolean, isFile?: boolean): Promise<void>;
|
|
43
|
+
genericMethodWrapper(params: Record<string, unknown>, req: Request, next: NextFunction, res: Response, methodName: string, dbsettings: string, readonly: boolean, permission: boolean, log: boolean, logConfig: {}, isHtml: boolean, isFile: boolean, timeoutMs?: number): Promise<void>;
|
|
44
|
+
ormMethodWrapper(params: Record<string, unknown>, req: Request | any, next: NextFunction, res: Response, methodName: string, readonly: boolean, permission?: boolean, log?: boolean, logConfig?: {}, isHtml?: boolean, isFile?: boolean, timeoutMs?: number): Promise<void>;
|
|
42
45
|
list(params: Record<string, unknown>): Promise<unknown>;
|
|
43
46
|
add(params: Record<string, unknown>, manager: EntityManager): Promise<unknown>;
|
|
44
47
|
edit(params: Record<string, unknown>, manager: EntityManager): Promise<unknown>;
|
|
@@ -37,6 +37,9 @@ exports.Controller = void 0;
|
|
|
37
37
|
* Last modified : 2020-10-13 15:36:31
|
|
38
38
|
* Last modified : 2021-03-10 15:36:31 - Favio Figueroa
|
|
39
39
|
* Last modified : 2021-05-01 18:35:31 - Favio Figueroa
|
|
40
|
+
* Last modified : 2026-10-08 - Favio Figueroa - EF-23 per-request controller clone (from 1.2.89), startTransaction inside try
|
|
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)
|
|
40
43
|
*/
|
|
41
44
|
const typeorm_1 = require("typeorm");
|
|
42
45
|
const class_validator_1 = require("class-validator");
|
|
@@ -51,6 +54,7 @@ const ParseParams_1 = require("./middlewares/ParseParams");
|
|
|
51
54
|
const isReportMiddleware_1 = require("./middlewares/isReportMiddleware");
|
|
52
55
|
const pdf_1 = require("../reports/pdf");
|
|
53
56
|
const xlsx_1 = require("../reports/xlsx");
|
|
57
|
+
const DbTimeouts_1 = require("./utils/DbTimeouts");
|
|
54
58
|
// import * as entities from '@pxp-nd/entities';
|
|
55
59
|
// const pxpEntities: any = entities;
|
|
56
60
|
class Controller {
|
|
@@ -128,6 +132,8 @@ class Controller {
|
|
|
128
132
|
// get type return
|
|
129
133
|
let ishtml = Reflect.getMetadata('ishtml', this.constructor) || {};
|
|
130
134
|
let isfile = Reflect.getMetadata('isfile', this.constructor) || {};
|
|
135
|
+
// get request timeouts (@Timeout)
|
|
136
|
+
const timeouts = Reflect.getMetadata('timeout', this.constructor) || {};
|
|
131
137
|
const defaultConfig = Reflect.getMetadata('optionsRoute', this.constructor) || {};
|
|
132
138
|
// define basic routes
|
|
133
139
|
if (this.modelString !== '') {
|
|
@@ -146,6 +152,8 @@ class Controller {
|
|
|
146
152
|
const isHtmlMethod = ishtml[route.methodName] !== undefined ? ishtml[route.methodName] : (defaultConfig[route.methodName] ? defaultConfig[route.methodName].ishtml : false);
|
|
147
153
|
// is file
|
|
148
154
|
const isFileMethod = isfile[route.methodName] !== undefined ? isfile[route.methodName] : (defaultConfig[route.methodName] ? defaultConfig[route.methodName].isfile : false);
|
|
155
|
+
// request timeout (ms), undefined = no timeout
|
|
156
|
+
const timeoutMethod = timeouts[route.methodName] !== undefined ? timeouts[route.methodName] : (defaultConfig[route.methodName] ? defaultConfig[route.methodName].timeoutMs : undefined);
|
|
149
157
|
if (readOnlyMethod === null || readOnlyMethod === undefined) {
|
|
150
158
|
throw new index_1.PxpError(500, 'ReadOnly decorator was not defined for ' +
|
|
151
159
|
route.methodName +
|
|
@@ -175,14 +183,21 @@ class Controller {
|
|
|
175
183
|
.toLowerCase();
|
|
176
184
|
controllerForRequest.validated = false;
|
|
177
185
|
try {
|
|
178
|
-
await index_1.__(controllerForRequest.genericMethodWrapper(params, req, next, res, route.methodName, methodDbSettings, readOnlyMethod, false, logKey, logValue, isHtmlMethod, isFileMethod));
|
|
186
|
+
await index_1.__(controllerForRequest.genericMethodWrapper(params, req, next, res, route.methodName, methodDbSettings, readOnlyMethod, false, logKey, logValue, isHtmlMethod, isFileMethod, timeoutMethod));
|
|
179
187
|
}
|
|
180
188
|
catch (ex) {
|
|
181
189
|
const now = new Date();
|
|
182
190
|
const iniAt = req.start;
|
|
183
191
|
const endsAt = now.valueOf() - iniAt.valueOf();
|
|
184
|
-
|
|
185
|
-
|
|
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
|
+
}
|
|
186
201
|
index_1.errorMiddleware(ex, req, res);
|
|
187
202
|
}
|
|
188
203
|
});
|
|
@@ -209,23 +224,30 @@ class Controller {
|
|
|
209
224
|
.toLowerCase();
|
|
210
225
|
controllerForRequest.validated = false;
|
|
211
226
|
try {
|
|
212
|
-
await index_1.__(controllerForRequest.genericMethodWrapper(params, req, next, res, route.methodName, methodDbSettings, readOnlyMethod, permission[route.methodName], logKey, logValue, isHtmlMethod, isFileMethod));
|
|
227
|
+
await index_1.__(controllerForRequest.genericMethodWrapper(params, req, next, res, route.methodName, methodDbSettings, readOnlyMethod, permission[route.methodName], logKey, logValue, isHtmlMethod, isFileMethod, timeoutMethod));
|
|
213
228
|
}
|
|
214
229
|
catch (ex) {
|
|
215
230
|
const now = new Date();
|
|
216
231
|
const iniAt = req.start;
|
|
217
232
|
const endsAt = now.valueOf() - iniAt.valueOf();
|
|
218
|
-
|
|
219
|
-
|
|
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
|
+
}
|
|
220
242
|
index_1.errorMiddleware(ex, req, res);
|
|
221
243
|
}
|
|
222
244
|
});
|
|
223
245
|
}
|
|
224
246
|
});
|
|
225
247
|
}
|
|
226
|
-
async genericMethodWrapper(params, req, next, res, methodName, dbsettings, readonly, permission = true, log = true, logConfig = {}, isHtml, isFile) {
|
|
248
|
+
async genericMethodWrapper(params, req, next, res, methodName, dbsettings, readonly, permission = true, log = true, logConfig = {}, isHtml, isFile, timeoutMs) {
|
|
227
249
|
if (dbsettings === 'Orm') {
|
|
228
|
-
await index_1.__(this.ormMethodWrapper(params, req, next, res, methodName, readonly, permission, log, logConfig, isHtml, isFile));
|
|
250
|
+
await index_1.__(this.ormMethodWrapper(params, req, next, res, methodName, readonly, permission, log, logConfig, isHtml, isFile, timeoutMs));
|
|
229
251
|
}
|
|
230
252
|
else if (dbsettings === 'Procedure') {
|
|
231
253
|
await index_1.__(this.procedureMethodWrapper(params, next, res, methodName, readonly, permission, log, logConfig));
|
|
@@ -234,7 +256,8 @@ class Controller {
|
|
|
234
256
|
await this.sqlMethodWrapper(params, next, res, methodName, readonly, permission, log, logConfig);
|
|
235
257
|
}
|
|
236
258
|
}
|
|
237
|
-
async ormMethodWrapper(params, req, next, res, methodName, readonly, permission = true, log = true, logConfig = {}, isHtml = false, isFile = false) {
|
|
259
|
+
async ormMethodWrapper(params, req, next, res, methodName, readonly, permission = true, log = true, logConfig = {}, isHtml = false, isFile = false, timeoutMs) {
|
|
260
|
+
var _a;
|
|
238
261
|
let metResponse;
|
|
239
262
|
if (permission) {
|
|
240
263
|
if (this.user) {
|
|
@@ -250,26 +273,63 @@ class Controller {
|
|
|
250
273
|
else {
|
|
251
274
|
const connection = typeorm_1.getConnection(process.env.DB_WRITE_CONNECTION_NAME);
|
|
252
275
|
const queryRunner = connection.createQueryRunner();
|
|
253
|
-
// establish real database connection using our new query runner
|
|
254
|
-
|
|
255
|
-
|
|
276
|
+
// establish real database connection using our new query runner.
|
|
277
|
+
// `db` is added to IConfigPxpApp by a later change; without it this is a plain connect().
|
|
278
|
+
const acquireTimeoutMs = (_a = this.config.db) === null || _a === void 0 ? void 0 : _a.acquireTimeoutMs;
|
|
279
|
+
await index_1.__(DbTimeouts_1.connectWithTimeout(queryRunner, acquireTimeoutMs, this.transactionCode));
|
|
280
|
+
let timedOut = false;
|
|
281
|
+
let timer;
|
|
256
282
|
try {
|
|
257
|
-
|
|
283
|
+
// Start the transaction inside try so a failure here still releases the connection
|
|
284
|
+
await index_1.__(queryRunner.startTransaction());
|
|
285
|
+
const work = Promise.resolve(eval(`this.${methodName}(params, queryRunner.manager, res, req)`));
|
|
286
|
+
if (timeoutMs && timeoutMs > 0) {
|
|
287
|
+
metResponse = await Promise.race([
|
|
288
|
+
work,
|
|
289
|
+
new Promise((_, reject) => {
|
|
290
|
+
timer = setTimeout(() => {
|
|
291
|
+
timedOut = true;
|
|
292
|
+
reject(new DbTimeouts_1.DbTimeoutError('request', timeoutMs, this.transactionCode));
|
|
293
|
+
}, timeoutMs);
|
|
294
|
+
})
|
|
295
|
+
]);
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
metResponse = await work;
|
|
299
|
+
}
|
|
258
300
|
await queryRunner.commitTransaction();
|
|
259
301
|
}
|
|
260
302
|
catch (err) {
|
|
261
|
-
|
|
303
|
+
// On request timeout skip rollback: it would queue behind the abandoned statement and delay the 503 (destroy rolls back server-side).
|
|
304
|
+
if (!timedOut && queryRunner.isTransactionActive) {
|
|
305
|
+
try {
|
|
306
|
+
await queryRunner.rollbackTransaction();
|
|
307
|
+
}
|
|
308
|
+
catch (_) {
|
|
309
|
+
// keep the original error; the connection is released or destroyed below
|
|
310
|
+
}
|
|
311
|
+
}
|
|
262
312
|
throw err;
|
|
263
313
|
}
|
|
264
314
|
finally {
|
|
265
|
-
|
|
315
|
+
if (timer)
|
|
316
|
+
clearTimeout(timer);
|
|
317
|
+
if (timedOut) {
|
|
318
|
+
// The handler may keep running in background; its connection is destroyed so it cannot commit.
|
|
319
|
+
console.warn(`[pxp-core] timeout-abandoned ${this.transactionCode}`);
|
|
320
|
+
await DbTimeouts_1.destroyQueryRunner(queryRunner);
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
await index_1.__(queryRunner.release());
|
|
324
|
+
}
|
|
266
325
|
}
|
|
267
326
|
}
|
|
268
327
|
if (log) {
|
|
269
328
|
const now = new Date();
|
|
270
329
|
const iniAt = req.start;
|
|
271
330
|
const endsAt = now.valueOf() - iniAt.valueOf();
|
|
272
|
-
|
|
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)));
|
|
273
333
|
}
|
|
274
334
|
//PARA REPORTES
|
|
275
335
|
if (req.report && req.report.type === 'pdf') {
|
|
@@ -8,6 +8,7 @@ export declare interface IOptionsRoute {
|
|
|
8
8
|
dbSettings?: 'Procedure' | 'Orm' | 'Query';
|
|
9
9
|
authentication?: boolean;
|
|
10
10
|
log?: boolean | ILog;
|
|
11
|
+
timeoutMs?: number;
|
|
11
12
|
}
|
|
12
13
|
declare const Get: (path?: string, options?: IOptionsRoute) => (target: any, propertyKey: string) => void;
|
|
13
14
|
declare const Post: (path?: string, options?: IOptionsRoute) => (target: any, propertyKey: string) => void;
|
|
@@ -19,9 +20,10 @@ declare const Model: (model: string) => ClassDecorator;
|
|
|
19
20
|
declare const StoredProcedure: (storedProcedure: string) => ClassDecorator;
|
|
20
21
|
declare const Authentication: (authentication?: boolean) => (target: any, propertyKey: string) => void;
|
|
21
22
|
declare const ReadOnly: (ronly?: boolean) => (target: any, propertyKey: string) => void;
|
|
23
|
+
declare const Timeout: (ms: number) => (target: any, propertyKey: string) => void;
|
|
22
24
|
declare const IsHtml: (ishtml?: boolean) => (target: any, propertyKey: string) => void;
|
|
23
25
|
declare const IsFile: (isFile?: boolean) => (target: any, propertyKey: string) => void;
|
|
24
26
|
declare const DbSettings: (modelType: 'Procedure' | 'Orm' | 'Query') => (target: any, propertyKey: string) => void;
|
|
25
27
|
declare const Permission: (permission?: boolean) => (target: any, propertyKey: string) => void;
|
|
26
28
|
declare const Log: (log?: boolean, config?: Record<string, unknown>) => (target: any, propertyKey: string) => void;
|
|
27
|
-
export { Get, Post, Put, Delete, Patch, Route, Authentication, Log, Permission, DbSettings, ReadOnly, IsHtml, IsFile, Model, StoredProcedure };
|
|
29
|
+
export { Get, Post, Put, Delete, Patch, Route, Authentication, Log, Permission, DbSettings, ReadOnly, Timeout, IsHtml, IsFile, Model, StoredProcedure };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.StoredProcedure = exports.Model = exports.IsFile = exports.IsHtml = exports.ReadOnly = exports.DbSettings = exports.Permission = exports.Log = exports.Authentication = exports.Route = exports.Patch = exports.Delete = exports.Put = exports.Post = exports.Get = void 0;
|
|
3
|
+
exports.StoredProcedure = exports.Model = exports.IsFile = exports.IsHtml = exports.Timeout = exports.ReadOnly = exports.DbSettings = exports.Permission = exports.Log = exports.Authentication = exports.Route = exports.Patch = exports.Delete = exports.Put = exports.Post = exports.Get = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* Kplian Ltda 2020
|
|
6
6
|
*
|
|
@@ -13,6 +13,7 @@ exports.StoredProcedure = exports.Model = exports.IsFile = exports.IsHtml = expo
|
|
|
13
13
|
*
|
|
14
14
|
* Created at : 2020-06-13 18:09:48
|
|
15
15
|
* Last modified : 2020-09-17 18:29:51
|
|
16
|
+
* Last modified : 2026-10-08 - Favio Figueroa - EF-23 acquire timeout + @Timeout decorator
|
|
16
17
|
*/
|
|
17
18
|
const RouteDefinition_1 = require("./RouteDefinition");
|
|
18
19
|
;
|
|
@@ -102,6 +103,10 @@ const Authentication = (authentication = true) => (target, propertyKey) => setPr
|
|
|
102
103
|
exports.Authentication = Authentication;
|
|
103
104
|
const ReadOnly = (ronly = true) => (target, propertyKey) => setProperty(target, propertyKey)(ronly, 'readonly');
|
|
104
105
|
exports.ReadOnly = ReadOnly;
|
|
106
|
+
// Request timeout (ms) for a write transaction: on expiry the request fails with 503,
|
|
107
|
+
// the transaction is rolled back and the physical connection is destroyed.
|
|
108
|
+
const Timeout = (ms) => (target, propertyKey) => setProperty(target, propertyKey)(ms, 'timeout');
|
|
109
|
+
exports.Timeout = Timeout;
|
|
105
110
|
const IsHtml = (ishtml = false) => (target, propertyKey) => setProperty(target, propertyKey)(ishtml, 'ishtml');
|
|
106
111
|
exports.IsHtml = IsHtml;
|
|
107
112
|
const IsFile = (isFile = false) => (target, propertyKey) => setProperty(target, propertyKey)(isFile, 'isfile');
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kplian Ltda 2020
|
|
3
|
+
*
|
|
4
|
+
* MIT
|
|
5
|
+
*
|
|
6
|
+
* Database timeout helpers.
|
|
7
|
+
*
|
|
8
|
+
* @summary Connection acquire timeout, request timeout error and physical connection destroy.
|
|
9
|
+
* @author Favio Figueroa
|
|
10
|
+
*
|
|
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)
|
|
13
|
+
*/
|
|
14
|
+
import { QueryRunner } from 'typeorm';
|
|
15
|
+
import { PxpError } from '../PxpError';
|
|
16
|
+
export declare class DbTimeoutError extends PxpError {
|
|
17
|
+
kind: 'acquire' | 'request';
|
|
18
|
+
timeoutMs: number;
|
|
19
|
+
transactionCode: string | undefined;
|
|
20
|
+
constructor(kind: 'acquire' | 'request', ms: number, transactionCode?: string);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Destroys the physical driver connection behind the query runner (so it never
|
|
24
|
+
* goes back to the pool with an open transaction) and then releases the runner.
|
|
25
|
+
* mysql2 removes a destroyed connection from its pool on its own.
|
|
26
|
+
*/
|
|
27
|
+
export declare function destroyQueryRunner(queryRunner: QueryRunner): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Acquires the query runner connection with an upper bound.
|
|
30
|
+
* When ms is undefined or <= 0 this is exactly `await queryRunner.connect()`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function connectWithTimeout(queryRunner: QueryRunner, ms: number | undefined, transactionCode?: string): Promise<void>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.connectWithTimeout = exports.destroyQueryRunner = exports.DbTimeoutError = void 0;
|
|
4
|
+
const PxpError_1 = require("../PxpError");
|
|
5
|
+
class DbTimeoutError extends PxpError_1.PxpError {
|
|
6
|
+
constructor(kind, ms, transactionCode) {
|
|
7
|
+
super(503, `Database ${kind} timeout after ${ms} ms`);
|
|
8
|
+
this.kind = kind;
|
|
9
|
+
this.timeoutMs = ms;
|
|
10
|
+
this.transactionCode = transactionCode;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
exports.DbTimeoutError = DbTimeoutError;
|
|
14
|
+
/**
|
|
15
|
+
* Destroys the physical driver connection behind the query runner (so it never
|
|
16
|
+
* goes back to the pool with an open transaction) and then releases the runner.
|
|
17
|
+
* mysql2 removes a destroyed connection from its pool on its own.
|
|
18
|
+
*/
|
|
19
|
+
async function destroyQueryRunner(queryRunner) {
|
|
20
|
+
const databaseConnection = queryRunner.databaseConnection;
|
|
21
|
+
if (databaseConnection && typeof databaseConnection.destroy === 'function') {
|
|
22
|
+
databaseConnection.destroy();
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
await queryRunner.release();
|
|
26
|
+
}
|
|
27
|
+
catch (_) {
|
|
28
|
+
// the connection was already destroyed; release failures are expected here
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
exports.destroyQueryRunner = destroyQueryRunner;
|
|
32
|
+
/**
|
|
33
|
+
* Acquires the query runner connection with an upper bound.
|
|
34
|
+
* When ms is undefined or <= 0 this is exactly `await queryRunner.connect()`.
|
|
35
|
+
*/
|
|
36
|
+
async function connectWithTimeout(queryRunner, ms, transactionCode) {
|
|
37
|
+
if (!ms || ms <= 0) {
|
|
38
|
+
await queryRunner.connect();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
let timer;
|
|
42
|
+
let timedOut = false;
|
|
43
|
+
const connectPromise = queryRunner.connect();
|
|
44
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
45
|
+
timer = setTimeout(() => {
|
|
46
|
+
timedOut = true;
|
|
47
|
+
reject(new DbTimeoutError('acquire', ms, transactionCode));
|
|
48
|
+
}, ms);
|
|
49
|
+
});
|
|
50
|
+
try {
|
|
51
|
+
await Promise.race([connectPromise, timeoutPromise]);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
if (timedOut) {
|
|
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(() => { });
|
|
69
|
+
}
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
if (timer)
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
exports.connectWithTimeout = connectWithTimeout;
|
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
|
+
});
|