anote-server-libs 0.11.9 → 0.12.1
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/package.json +1 -1
- package/services/WithTransaction.js +74 -12
- package/services/WithTransaction.ts +109 -10
- package/services/utils.js +1 -0
- package/services/utils.ts +1 -0
package/package.json
CHANGED
|
@@ -1,14 +1,63 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.withTransactionConfig = void 0;
|
|
4
|
+
exports.requestTransactionGate = requestTransactionGate;
|
|
5
|
+
exports.releaseTransactionGate = releaseTransactionGate;
|
|
4
6
|
exports.withTransaction = withTransaction;
|
|
5
7
|
const utils_1 = require("./utils");
|
|
6
8
|
exports.withTransactionConfig = {
|
|
7
9
|
logQueries: false,
|
|
8
|
-
timeoutMillis: 15000
|
|
10
|
+
timeoutMillis: 15000,
|
|
11
|
+
activeCount: 0,
|
|
12
|
+
gateRequested: false,
|
|
13
|
+
gateActive: false,
|
|
14
|
+
gateQueue: [],
|
|
9
15
|
};
|
|
10
|
-
function
|
|
16
|
+
function requestTransactionGate() {
|
|
17
|
+
if (exports.withTransactionConfig.gateActive || exports.withTransactionConfig.gateRequested) {
|
|
18
|
+
return Promise.reject(new Error('Transaction gate already active or requested'));
|
|
19
|
+
}
|
|
20
|
+
exports.withTransactionConfig.gateRequested = true;
|
|
21
|
+
return new Promise((resolve, _reject) => {
|
|
22
|
+
const check = () => {
|
|
23
|
+
if (exports.withTransactionConfig.activeCount <= 1) {
|
|
24
|
+
exports.withTransactionConfig.gateActive = true;
|
|
25
|
+
exports.withTransactionConfig.gateRequested = false;
|
|
26
|
+
resolve();
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
setTimeout(check, 100);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
setTimeout(check, 50);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function releaseTransactionGate() {
|
|
36
|
+
exports.withTransactionConfig.gateActive = false;
|
|
37
|
+
exports.withTransactionConfig.gateRequested = false;
|
|
38
|
+
const queue = exports.withTransactionConfig.gateQueue.splice(0);
|
|
39
|
+
for (const entry of queue) {
|
|
40
|
+
entry.resolve();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function releaseAndDecrement(repo, dbClient) {
|
|
44
|
+
if (repo.db && dbClient) {
|
|
45
|
+
try {
|
|
46
|
+
dbClient.release();
|
|
47
|
+
}
|
|
48
|
+
catch (_) { }
|
|
49
|
+
}
|
|
50
|
+
exports.withTransactionConfig.activeCount = Math.max(0, exports.withTransactionConfig.activeCount - 1);
|
|
51
|
+
}
|
|
52
|
+
function withTransaction(repo, logger, previousMethod, lock, commitIfLost = true, idleInTransactionTimeout) {
|
|
11
53
|
return function (req, res, next) {
|
|
54
|
+
if (exports.withTransactionConfig.gateActive) {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
exports.withTransactionConfig.gateQueue.push({ resolve, reject });
|
|
57
|
+
}).then(() => {
|
|
58
|
+
return withTransaction(repo, logger, previousMethod, lock, commitIfLost)(req, res, next);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
12
61
|
let commit = true;
|
|
13
62
|
if (!commitIfLost) {
|
|
14
63
|
res.once('close', () => commit = false);
|
|
@@ -22,6 +71,7 @@ function withTransaction(repo, logger, previousMethod, lock, commitIfLost = true
|
|
|
22
71
|
res.write((0, utils_1.jsonStringify)(obj) || '{}');
|
|
23
72
|
endTerminator();
|
|
24
73
|
};
|
|
74
|
+
exports.withTransactionConfig.activeCount++;
|
|
25
75
|
const connectTimeoutHandler = setTimeout(() => {
|
|
26
76
|
logger.error('Error timed out getting a client, exiting...');
|
|
27
77
|
process.exit(22);
|
|
@@ -49,8 +99,7 @@ function withTransaction(repo, logger, previousMethod, lock, commitIfLost = true
|
|
|
49
99
|
if (!res.locals.dbClientCommited) {
|
|
50
100
|
res.locals.dbClientCommited = true;
|
|
51
101
|
(commit ? (repo.db ? dbClient.query('COMMIT') : dbClient.commit()) : Promise.reject(new Error('Client lost'))).catch((err) => err).then((err) => {
|
|
52
|
-
|
|
53
|
-
dbClient.release();
|
|
102
|
+
releaseAndDecrement(repo, dbClient);
|
|
54
103
|
if (!(err instanceof Error)) {
|
|
55
104
|
for (let i = 0; i < res.locals.dbClientOnCommit.length; i++) {
|
|
56
105
|
res.locals.dbClientOnCommit[i]();
|
|
@@ -64,7 +113,15 @@ function withTransaction(repo, logger, previousMethod, lock, commitIfLost = true
|
|
|
64
113
|
}
|
|
65
114
|
};
|
|
66
115
|
res.locals.dbClientOnCommit = [];
|
|
67
|
-
|
|
116
|
+
const beginPromise = repo.db ? dbClient.query('BEGIN') : dbClient.begin();
|
|
117
|
+
const idleTimeoutMs = Math.max(idleInTransactionTimeout, exports.withTransactionConfig.timeoutMillis);
|
|
118
|
+
let setupPromise = beginPromise;
|
|
119
|
+
if (idleInTransactionTimeout && idleTimeoutMs > 0 && repo.db) {
|
|
120
|
+
setupPromise = setupPromise.then(() => {
|
|
121
|
+
return dbClient.query(`SET LOCAL idle_in_transaction_session_timeout TO ${idleTimeoutMs}`);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return setupPromise.then(() => {
|
|
68
125
|
const finish = () => {
|
|
69
126
|
res.send = () => {
|
|
70
127
|
throw new Error('res.send() should not be used within transactions. Use res.json() instead.');
|
|
@@ -80,8 +137,7 @@ function withTransaction(repo, logger, previousMethod, lock, commitIfLost = true
|
|
|
80
137
|
}
|
|
81
138
|
}
|
|
82
139
|
(repo.db ? dbClient.query('ROLLBACK') : dbClient.rollback()).catch((err) => obj && obj.error && (obj.error.additionalInfo2 = { message: err.message })).then(() => {
|
|
83
|
-
|
|
84
|
-
dbClient.release();
|
|
140
|
+
releaseAndDecrement(repo, dbClient);
|
|
85
141
|
jsonTerminator(obj);
|
|
86
142
|
});
|
|
87
143
|
}
|
|
@@ -108,8 +164,7 @@ function withTransaction(repo, logger, previousMethod, lock, commitIfLost = true
|
|
|
108
164
|
logger.error('Uncaught 500 with no details...');
|
|
109
165
|
}
|
|
110
166
|
(repo.db ? dbClient.query('ROLLBACK') : dbClient.rollback()).catch(() => undefined).then(() => {
|
|
111
|
-
|
|
112
|
-
dbClient.release();
|
|
167
|
+
releaseAndDecrement(repo, dbClient);
|
|
113
168
|
endTerminator();
|
|
114
169
|
});
|
|
115
170
|
}
|
|
@@ -140,18 +195,25 @@ function withTransaction(repo, logger, previousMethod, lock, commitIfLost = true
|
|
|
140
195
|
additionalInfo: { message: err.message }
|
|
141
196
|
}
|
|
142
197
|
});
|
|
143
|
-
dbClient
|
|
198
|
+
releaseAndDecrement(repo, dbClient);
|
|
144
199
|
});
|
|
145
200
|
}
|
|
146
201
|
else {
|
|
147
202
|
finish();
|
|
148
203
|
}
|
|
149
204
|
}).catch((err) => {
|
|
150
|
-
dbClient
|
|
151
|
-
|
|
205
|
+
releaseAndDecrement(repo, dbClient);
|
|
206
|
+
logger.error('Error beginning transaction: %j', err);
|
|
207
|
+
res.status(500).json({
|
|
208
|
+
error: {
|
|
209
|
+
errorKey: 'internal.db',
|
|
210
|
+
additionalInfo: { message: err.message }
|
|
211
|
+
}
|
|
212
|
+
});
|
|
152
213
|
});
|
|
153
214
|
}).catch(err => {
|
|
154
215
|
clearTimeout(connectTimeoutHandler);
|
|
216
|
+
exports.withTransactionConfig.activeCount = Math.max(0, exports.withTransactionConfig.activeCount - 1);
|
|
155
217
|
res.status(500).json({
|
|
156
218
|
error: {
|
|
157
219
|
errorKey: 'internal.db',
|
|
@@ -11,11 +11,88 @@ export const enum SystemLock {
|
|
|
11
11
|
|
|
12
12
|
export const withTransactionConfig = {
|
|
13
13
|
logQueries: false,
|
|
14
|
-
timeoutMillis: 15000
|
|
14
|
+
timeoutMillis: 15000,
|
|
15
|
+
// Number of currently active transactions tracked for gating.
|
|
16
|
+
activeCount: 0,
|
|
17
|
+
// Whether a gate has been requested (waiting for activeCount to reach 0).
|
|
18
|
+
gateRequested: false,
|
|
19
|
+
// Whether the gate is active (new transactions are queued).
|
|
20
|
+
gateActive: false,
|
|
21
|
+
// Queue of pending transactions waiting for the gate to release.
|
|
22
|
+
gateQueue: [] as Array<{resolve: () => void; reject: (err: Error) => void}>,
|
|
15
23
|
};
|
|
16
24
|
|
|
17
|
-
|
|
18
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Request a transaction gate. Returns a Promise that resolves when all currently
|
|
27
|
+
* active transactions have completed (drained). While waiting and after resolution,
|
|
28
|
+
* new withTransaction calls will be queued until releaseTransactionGate() is called.
|
|
29
|
+
*
|
|
30
|
+
* Use this before starting a long-running operation to ensure no other transactions
|
|
31
|
+
* are killed by PostgreSQL's idle_in_transaction_session_timeout while the long
|
|
32
|
+
* operation holds locks or occupies the connection pool.
|
|
33
|
+
*
|
|
34
|
+
* @returns A Promise that resolves when the gate is acquired.
|
|
35
|
+
*/
|
|
36
|
+
export function requestTransactionGate(): Promise<void> {
|
|
37
|
+
if(withTransactionConfig.gateActive || withTransactionConfig.gateRequested) {
|
|
38
|
+
return Promise.reject(new Error('Transaction gate already active or requested'));
|
|
39
|
+
}
|
|
40
|
+
withTransactionConfig.gateRequested = true;
|
|
41
|
+
return new Promise<void>((resolve, _reject) => {
|
|
42
|
+
const check = () => {
|
|
43
|
+
// Allow the calling transaction to remain active (activeCount <= 1).
|
|
44
|
+
// When called from inside a withTransaction handler, the current
|
|
45
|
+
// transaction counts as 1. When called from outside (e.g. cron),
|
|
46
|
+
// activeCount may be 0.
|
|
47
|
+
if(withTransactionConfig.activeCount <= 1) {
|
|
48
|
+
withTransactionConfig.gateActive = true;
|
|
49
|
+
withTransactionConfig.gateRequested = false;
|
|
50
|
+
resolve();
|
|
51
|
+
} else {
|
|
52
|
+
// Poll every 100ms until other transactions drain
|
|
53
|
+
setTimeout(check, 100);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
// Small initial delay to let in-flight transactions settle
|
|
57
|
+
setTimeout(check, 50);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Release the transaction gate, allowing all queued transactions to proceed.
|
|
63
|
+
* Must be called after the long-running operation completes, in both success
|
|
64
|
+
* and error paths.
|
|
65
|
+
*/
|
|
66
|
+
export function releaseTransactionGate(): void {
|
|
67
|
+
withTransactionConfig.gateActive = false;
|
|
68
|
+
withTransactionConfig.gateRequested = false;
|
|
69
|
+
// Drain the queue — resolve all pending waiters
|
|
70
|
+
const queue = withTransactionConfig.gateQueue.splice(0);
|
|
71
|
+
for(const entry of queue) {
|
|
72
|
+
entry.resolve();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function releaseAndDecrement(repo: BaseModelRepository, dbClient: any) {
|
|
77
|
+
if(repo.db && dbClient) {
|
|
78
|
+
try { dbClient.release(); } catch (_) { /* already released */ }
|
|
79
|
+
}
|
|
80
|
+
withTransactionConfig.activeCount = Math.max(0, withTransactionConfig.activeCount - 1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function withTransaction(repo: BaseModelRepository, logger: Logger, previousMethod: (req: Request, res: Response, next: NextFunction) => void,
|
|
84
|
+
lock?: SystemLock, commitIfLost = true, idleInTransactionTimeout?: number): (req: Request, res: Response, next: NextFunction) => any {
|
|
85
|
+
return function(this: any, req: Request, res: Response, next: NextFunction): any {
|
|
86
|
+
// If gate is active, queue this request and retry when gate is released
|
|
87
|
+
if(withTransactionConfig.gateActive) {
|
|
88
|
+
return new Promise<void>((resolve, reject) => {
|
|
89
|
+
withTransactionConfig.gateQueue.push({ resolve, reject });
|
|
90
|
+
}).then(() => {
|
|
91
|
+
// Re-enter with gate now released
|
|
92
|
+
return withTransaction(repo, logger, previousMethod, lock, commitIfLost)(req, res, next);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
19
96
|
let commit = true;
|
|
20
97
|
if(!commitIfLost) {
|
|
21
98
|
res.once('close', () => commit = false);
|
|
@@ -26,6 +103,10 @@ export function withTransaction(repo: BaseModelRepository, logger: Logger, previ
|
|
|
26
103
|
res.write(jsonStringify(obj) || '{}');
|
|
27
104
|
endTerminator();
|
|
28
105
|
};
|
|
106
|
+
|
|
107
|
+
// Increment activeCount before attempting to connect to the database
|
|
108
|
+
withTransactionConfig.activeCount++;
|
|
109
|
+
|
|
29
110
|
const connectTimeoutHandler = setTimeout(() => {
|
|
30
111
|
// Timed out getting a client, restart worker or process...
|
|
31
112
|
logger.error('Error timed out getting a client, exiting...');
|
|
@@ -56,7 +137,7 @@ export function withTransaction(repo: BaseModelRepository, logger: Logger, previ
|
|
|
56
137
|
if(!res.locals.dbClientCommited) {
|
|
57
138
|
res.locals.dbClientCommited = true;
|
|
58
139
|
(commit ? (repo.db ? dbClient.query('COMMIT') : dbClient.commit()) : Promise.reject(new Error('Client lost'))).catch((err: any) => err).then((err: any) => {
|
|
59
|
-
|
|
140
|
+
releaseAndDecrement(repo, dbClient);
|
|
60
141
|
if(!(err instanceof Error)) {
|
|
61
142
|
for(let i = 0; i < res.locals.dbClientOnCommit.length; i++) {
|
|
62
143
|
res.locals.dbClientOnCommit[i]();
|
|
@@ -69,7 +150,18 @@ export function withTransaction(repo: BaseModelRepository, logger: Logger, previ
|
|
|
69
150
|
}
|
|
70
151
|
};
|
|
71
152
|
res.locals.dbClientOnCommit = [];
|
|
72
|
-
|
|
153
|
+
|
|
154
|
+
// Build the BEGIN + idle_in_transaction_session_timeout setup
|
|
155
|
+
const beginPromise = repo.db ? dbClient.query('BEGIN') : dbClient.begin();
|
|
156
|
+
const idleTimeoutMs = Math.max(idleInTransactionTimeout, withTransactionConfig.timeoutMillis);
|
|
157
|
+
let setupPromise: Promise<any> = beginPromise;
|
|
158
|
+
if(idleInTransactionTimeout && idleTimeoutMs > 0 && repo.db) {
|
|
159
|
+
setupPromise = setupPromise.then(() => {
|
|
160
|
+
return dbClient.query(`SET LOCAL idle_in_transaction_session_timeout TO ${idleTimeoutMs}`);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return setupPromise.then(() => {
|
|
73
165
|
const finish = () => {
|
|
74
166
|
res.send = () => {
|
|
75
167
|
throw new Error('res.send() should not be used within transactions. Use res.json() instead.');
|
|
@@ -84,7 +176,7 @@ export function withTransaction(repo: BaseModelRepository, logger: Logger, previ
|
|
|
84
176
|
}
|
|
85
177
|
}
|
|
86
178
|
(repo.db ? dbClient.query('ROLLBACK') : dbClient.rollback()).catch((err: any) => obj && obj.error && (obj.error.additionalInfo2 = {message: err.message})).then(() => {
|
|
87
|
-
|
|
179
|
+
releaseAndDecrement(repo, dbClient);
|
|
88
180
|
jsonTerminator(obj);
|
|
89
181
|
});
|
|
90
182
|
} else {
|
|
@@ -108,7 +200,7 @@ export function withTransaction(repo: BaseModelRepository, logger: Logger, previ
|
|
|
108
200
|
logger.error('Uncaught 500 with no details...');
|
|
109
201
|
}
|
|
110
202
|
(repo.db ? dbClient.query('ROLLBACK') : dbClient.rollback()).catch((): any => undefined).then(() => {
|
|
111
|
-
|
|
203
|
+
releaseAndDecrement(repo, dbClient);
|
|
112
204
|
endTerminator();
|
|
113
205
|
});
|
|
114
206
|
} else {
|
|
@@ -137,19 +229,26 @@ export function withTransaction(repo: BaseModelRepository, logger: Logger, previ
|
|
|
137
229
|
additionalInfo: {message: err.message}
|
|
138
230
|
}
|
|
139
231
|
});
|
|
140
|
-
dbClient
|
|
232
|
+
releaseAndDecrement(repo, dbClient); // Lock acquisition failed, release will also rollback
|
|
141
233
|
});
|
|
142
234
|
} else {
|
|
143
235
|
finish();
|
|
144
236
|
}
|
|
145
237
|
}).catch((err: any) => {
|
|
146
238
|
// Error beginning transaction
|
|
147
|
-
dbClient
|
|
148
|
-
|
|
239
|
+
releaseAndDecrement(repo, dbClient);
|
|
240
|
+
logger.error('Error beginning transaction: %j', err);
|
|
241
|
+
res.status(500).json({
|
|
242
|
+
error: {
|
|
243
|
+
errorKey: 'internal.db',
|
|
244
|
+
additionalInfo: {message: err.message}
|
|
245
|
+
}
|
|
246
|
+
});
|
|
149
247
|
});
|
|
150
248
|
}).catch(err => {
|
|
151
249
|
// Error connecting to database for other reason than timeout or beginning transaction
|
|
152
250
|
clearTimeout(connectTimeoutHandler);
|
|
251
|
+
withTransactionConfig.activeCount = Math.max(0, withTransactionConfig.activeCount - 1);
|
|
153
252
|
res.status(500).json({
|
|
154
253
|
error: {
|
|
155
254
|
errorKey: 'internal.db',
|
package/services/utils.js
CHANGED
|
@@ -236,6 +236,7 @@ function getHtml(template, translationKeyValue) {
|
|
|
236
236
|
const replacer = (_, key) => replacePlaceholders(translationKeyValue, key.split('.'), 0) ?? key;
|
|
237
237
|
template = template.replace(pattern, replacer);
|
|
238
238
|
template = template.replace(pattern, replacer);
|
|
239
|
+
template = template.replace(pattern, replacer);
|
|
239
240
|
template = template.replace(new RegExp('\\[\\[\\s*([a-zA-Z._0-9:]+)\\s*\\]\\]', 'g'), (_, key) => {
|
|
240
241
|
const keyParts = key.split(':');
|
|
241
242
|
return (replacePlaceholders(translationKeyValue, keyParts[1].split('.'), 0) || [])
|
package/services/utils.ts
CHANGED
|
@@ -217,6 +217,7 @@ function getHtml(template: string, translationKeyValue: Record<string, any>): st
|
|
|
217
217
|
const replacer = (_: string, key: string) => replacePlaceholders(translationKeyValue, key.split('.'), 0) ?? key;
|
|
218
218
|
template = template.replace(pattern, replacer);
|
|
219
219
|
template = template.replace(pattern, replacer);
|
|
220
|
+
template = template.replace(pattern, replacer);
|
|
220
221
|
template = template.replace(new RegExp('\\[\\[\\s*([a-zA-Z._0-9:]+)\\s*\\]\\]', 'g'), (_, key) => {
|
|
221
222
|
const keyParts = key.split(':');
|
|
222
223
|
return (replacePlaceholders(translationKeyValue, keyParts[1].split('.'), 0) || [])
|