jinaga-server 3.7.4 → 3.8.0
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/.claude/skills/degrees-of-freedom/.openskills.json +7 -0
- package/.claude/skills/degrees-of-freedom/SKILL.md +18 -0
- package/.claude/skills/degrees-of-freedom/degrees-of-freedom-constitution.md +142 -0
- package/.claude/skills/night-shift-setup/.openskills.json +7 -0
- package/.claude/skills/night-shift-setup/SKILL.md +95 -0
- package/.claude/skills/night-shift-setup/configuring-a-repository.md +111 -0
- package/.claude/skills/night-shift-setup/creating-the-log.md +59 -0
- package/.claude/skills/night-shift-setup/creating-the-routines.md +80 -0
- package/.claude/skills/night-shift-setup/log-model.factual +971 -0
- package/.claude/skills/night-shift-worker/.openskills.json +7 -0
- package/.claude/skills/night-shift-worker/SKILL.md +199 -0
- package/.claude/skills/night-shift-worker/night-shift-log.md +72 -0
- package/.claude/skills/night-shift-worker/scripts/register-stack.sh +173 -0
- package/.claude/skills/night-shift-worker/stacking.md +57 -0
- package/.claude/skills/night-shift-worker/verifying-a-merged-fix.md +16 -0
- package/.claude/skills/refining-issues/.openskills.json +7 -0
- package/.claude/skills/refining-issues/SKILL.md +327 -0
- package/.claude/skills/refining-issues/references/worked-example.md +234 -0
- package/.github/workflows/main.yml +2 -2
- package/.night-shift/config.md +84 -0
- package/.night-shift/skills.md +17 -0
- package/README.md +21 -0
- package/dist/http/router.js +207 -80
- package/dist/http/router.js.map +1 -1
- package/dist/http/stream.d.ts +0 -1
- package/dist/http/stream.js +6 -17
- package/dist/http/stream.js.map +1 -1
- package/dist/jinaga-server.js +74 -7
- package/dist/jinaga-server.js.map +1 -1
- package/integration-test/jinaga-test/package-lock.json +4 -4
- package/integration-test/jinaga-test/package.json +1 -1
- package/package.json +2 -2
- package/src/http/router.ts +249 -99
- package/src/http/stream.ts +21 -36
- package/src/jinaga-server.ts +92 -4
- package/test/http/errorStatusMappingSpec.ts +351 -0
- package/test/http/streamLogVolumeSpec.ts +284 -0
- package/test/jinaga-server/poolConfigurationSpec.ts +147 -0
- package/test/jinaga-server/tracePoolSpec.ts +368 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
import { Trace, Tracer } from "jinaga";
|
|
3
|
+
import { Pool } from "pg";
|
|
4
|
+
|
|
5
|
+
import { tracePool } from "../../src/jinaga-server";
|
|
6
|
+
|
|
7
|
+
const windowMilliseconds = 10000;
|
|
8
|
+
|
|
9
|
+
// Jest's own fake timers cannot be installed on this version of Node, because
|
|
10
|
+
// they try to replace the read-only global `performance`. These stand in for
|
|
11
|
+
// the interval timer alone, which is all tracePool schedules, and they record
|
|
12
|
+
// whether it was unreferenced and whether it was cleared.
|
|
13
|
+
class FakeInterval {
|
|
14
|
+
public dueAt: number;
|
|
15
|
+
public cleared = false;
|
|
16
|
+
private referenced = true;
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
private readonly callback: () => void,
|
|
20
|
+
public readonly delay: number,
|
|
21
|
+
now: number
|
|
22
|
+
) {
|
|
23
|
+
this.dueAt = now + delay;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
ref(): this {
|
|
27
|
+
this.referenced = true;
|
|
28
|
+
return this;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
unref(): this {
|
|
32
|
+
this.referenced = false;
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
hasRef(): boolean {
|
|
37
|
+
return this.referenced;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
fire() {
|
|
41
|
+
this.callback();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class FakeClock {
|
|
46
|
+
private now = 0;
|
|
47
|
+
private readonly created: FakeInterval[] = [];
|
|
48
|
+
|
|
49
|
+
install() {
|
|
50
|
+
jest.spyOn(global, 'setInterval').mockImplementation(((callback: () => void, delay?: number) => {
|
|
51
|
+
const interval = new FakeInterval(callback, delay ?? 0, this.now);
|
|
52
|
+
this.created.push(interval);
|
|
53
|
+
return interval;
|
|
54
|
+
}) as any);
|
|
55
|
+
jest.spyOn(global, 'clearInterval').mockImplementation(((handle: FakeInterval) => {
|
|
56
|
+
handle.cleared = true;
|
|
57
|
+
}) as any);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
advance(milliseconds: number) {
|
|
61
|
+
const end = this.now + milliseconds;
|
|
62
|
+
for (;;) {
|
|
63
|
+
const due = this.live()
|
|
64
|
+
.filter(interval => interval.dueAt <= end)
|
|
65
|
+
.sort((a, b) => a.dueAt - b.dueAt)[0];
|
|
66
|
+
if (!due) {
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
this.now = due.dueAt;
|
|
70
|
+
due.dueAt = this.now + due.delay;
|
|
71
|
+
due.fire();
|
|
72
|
+
}
|
|
73
|
+
this.now = end;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
live(): FakeInterval[] {
|
|
77
|
+
return this.created.filter(interval => !interval.cleared);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
only(): FakeInterval {
|
|
81
|
+
expect(this.created).toHaveLength(1);
|
|
82
|
+
return this.created[0];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface Counts {
|
|
87
|
+
total: number;
|
|
88
|
+
idle: number;
|
|
89
|
+
waiting: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface MetricLine {
|
|
93
|
+
message: string;
|
|
94
|
+
measurements: { [key: string]: number };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
class RecordingTracer implements Tracer {
|
|
98
|
+
public readonly info_: string[] = [];
|
|
99
|
+
public readonly warnings: string[] = [];
|
|
100
|
+
public readonly errors: any[] = [];
|
|
101
|
+
public readonly metrics: MetricLine[] = [];
|
|
102
|
+
|
|
103
|
+
info(message: string): void {
|
|
104
|
+
this.info_.push(message);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
warn(message: string): void {
|
|
108
|
+
this.warnings.push(message);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
error(error: any): void {
|
|
112
|
+
this.errors.push(error);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
dependency<T>(name: string, data: string, operation: () => Promise<T>): Promise<T> {
|
|
116
|
+
return operation();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
metric(message: string, measurements: { [key: string]: number }): void {
|
|
120
|
+
this.metrics.push({ message, measurements });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
counter(name: string, value: number): void {
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
linesNamed(message: string): MetricLine[] {
|
|
127
|
+
return this.metrics.filter(m => m.message === message);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Stands in for a pg Pool. It reports the counts a test chooses and emits the
|
|
132
|
+
// events a real pool emits, so tracePool can be driven through a storm without
|
|
133
|
+
// a database.
|
|
134
|
+
class PoolDouble extends EventEmitter {
|
|
135
|
+
public totalCount = 0;
|
|
136
|
+
public idleCount = 0;
|
|
137
|
+
public waitingCount = 0;
|
|
138
|
+
public ended = false;
|
|
139
|
+
|
|
140
|
+
asPool(): Pool {
|
|
141
|
+
return this as unknown as Pool;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
acquire(counts: Counts) {
|
|
145
|
+
this.report(counts);
|
|
146
|
+
this.emit('acquire', {});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
release(counts: Counts) {
|
|
150
|
+
this.report(counts);
|
|
151
|
+
this.emit('release', undefined, {});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
connect(counts: Counts) {
|
|
155
|
+
this.report(counts);
|
|
156
|
+
this.emit('connect', {});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
remove(counts: Counts) {
|
|
160
|
+
this.report(counts);
|
|
161
|
+
this.emit('remove', {});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
fail(error: Error) {
|
|
165
|
+
this.emit('error', error, {});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private report(counts: Counts) {
|
|
169
|
+
this.totalCount = counts.total;
|
|
170
|
+
this.idleCount = counts.idle;
|
|
171
|
+
this.waitingCount = counts.waiting;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
describe('tracePool', () => {
|
|
176
|
+
let tracer: RecordingTracer;
|
|
177
|
+
let pool: PoolDouble;
|
|
178
|
+
let clock: FakeClock;
|
|
179
|
+
|
|
180
|
+
beforeEach(() => {
|
|
181
|
+
clock = new FakeClock();
|
|
182
|
+
clock.install();
|
|
183
|
+
tracer = new RecordingTracer();
|
|
184
|
+
Trace.configure(tracer);
|
|
185
|
+
pool = new PoolDouble();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
afterEach(() => {
|
|
189
|
+
jest.restoreAllMocks();
|
|
190
|
+
Trace.off();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should write one line for a window of a thousand acquires', () => {
|
|
194
|
+
tracePool(pool.asPool());
|
|
195
|
+
|
|
196
|
+
for (let i = 0; i < 1000; i++) {
|
|
197
|
+
pool.acquire({ total: 10, idle: 0, waiting: 0 });
|
|
198
|
+
pool.release({ total: 10, idle: 1, waiting: 0 });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
expect(tracer.metrics).toHaveLength(0);
|
|
202
|
+
|
|
203
|
+
clock.advance(windowMilliseconds);
|
|
204
|
+
|
|
205
|
+
expect(tracer.linesNamed('Postgres pool')).toHaveLength(1);
|
|
206
|
+
expect(tracer.metrics).toHaveLength(1);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should write no line per acquire', () => {
|
|
210
|
+
tracePool(pool.asPool());
|
|
211
|
+
|
|
212
|
+
pool.acquire({ total: 10, idle: 0, waiting: 0 });
|
|
213
|
+
pool.acquire({ total: 10, idle: 0, waiting: 1 });
|
|
214
|
+
|
|
215
|
+
expect(tracer.metrics).toHaveLength(0);
|
|
216
|
+
expect(tracer.linesNamed('Postgres acquired')).toHaveLength(0);
|
|
217
|
+
|
|
218
|
+
clock.advance(windowMilliseconds);
|
|
219
|
+
|
|
220
|
+
expect(tracer.linesNamed('Postgres acquired')).toHaveLength(0);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should carry the peak waiting count of a spike that came and went', () => {
|
|
224
|
+
tracePool(pool.asPool());
|
|
225
|
+
|
|
226
|
+
pool.acquire({ total: 10, idle: 0, waiting: 0 });
|
|
227
|
+
pool.acquire({ total: 10, idle: 0, waiting: 349 });
|
|
228
|
+
pool.release({ total: 10, idle: 0, waiting: 120 });
|
|
229
|
+
pool.release({ total: 10, idle: 3, waiting: 0 });
|
|
230
|
+
|
|
231
|
+
clock.advance(windowMilliseconds);
|
|
232
|
+
|
|
233
|
+
const lines = tracer.linesNamed('Postgres pool');
|
|
234
|
+
expect(lines).toHaveLength(1);
|
|
235
|
+
expect(lines[0].measurements).toEqual({
|
|
236
|
+
total: 10,
|
|
237
|
+
idle: 3,
|
|
238
|
+
waiting: 0,
|
|
239
|
+
peakWaiting: 349
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('should warn about a window whose peak waiting count was above zero', () => {
|
|
244
|
+
tracePool(pool.asPool());
|
|
245
|
+
|
|
246
|
+
pool.acquire({ total: 10, idle: 0, waiting: 349 });
|
|
247
|
+
pool.release({ total: 10, idle: 3, waiting: 0 });
|
|
248
|
+
|
|
249
|
+
clock.advance(windowMilliseconds);
|
|
250
|
+
|
|
251
|
+
expect(tracer.warnings).toHaveLength(1);
|
|
252
|
+
expect(tracer.warnings[0]).toContain('349');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('should not warn about a window that never had a request waiting', () => {
|
|
256
|
+
tracePool(pool.asPool());
|
|
257
|
+
|
|
258
|
+
pool.acquire({ total: 10, idle: 2, waiting: 0 });
|
|
259
|
+
|
|
260
|
+
clock.advance(windowMilliseconds);
|
|
261
|
+
|
|
262
|
+
expect(tracer.linesNamed('Postgres pool')).toHaveLength(1);
|
|
263
|
+
expect(tracer.warnings).toHaveLength(0);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should write nothing for a window with no acquires', () => {
|
|
267
|
+
tracePool(pool.asPool());
|
|
268
|
+
|
|
269
|
+
clock.advance(windowMilliseconds * 3);
|
|
270
|
+
|
|
271
|
+
expect(tracer.metrics).toHaveLength(0);
|
|
272
|
+
expect(tracer.warnings).toHaveLength(0);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('should write nothing for a window in which only a release happened', () => {
|
|
276
|
+
tracePool(pool.asPool());
|
|
277
|
+
|
|
278
|
+
pool.release({ total: 10, idle: 10, waiting: 0 });
|
|
279
|
+
|
|
280
|
+
clock.advance(windowMilliseconds);
|
|
281
|
+
|
|
282
|
+
expect(tracer.metrics).toHaveLength(0);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('should report a storm that stopped in the middle of a window', () => {
|
|
286
|
+
tracePool(pool.asPool());
|
|
287
|
+
|
|
288
|
+
for (let i = 0; i < 200; i++) {
|
|
289
|
+
pool.acquire({ total: 10, idle: 0, waiting: 349 });
|
|
290
|
+
}
|
|
291
|
+
pool.release({ total: 10, idle: 0, waiting: 0 });
|
|
292
|
+
|
|
293
|
+
// The storm is over, and nothing touches the pool again. Only the
|
|
294
|
+
// window's own close can report it.
|
|
295
|
+
clock.advance(windowMilliseconds);
|
|
296
|
+
|
|
297
|
+
const lines = tracer.linesNamed('Postgres pool');
|
|
298
|
+
expect(lines).toHaveLength(1);
|
|
299
|
+
expect(lines[0].measurements.peakWaiting).toBe(349);
|
|
300
|
+
|
|
301
|
+
clock.advance(windowMilliseconds * 2);
|
|
302
|
+
|
|
303
|
+
expect(tracer.linesNamed('Postgres pool')).toHaveLength(1);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should start each window with a fresh peak', () => {
|
|
307
|
+
tracePool(pool.asPool());
|
|
308
|
+
|
|
309
|
+
pool.acquire({ total: 10, idle: 0, waiting: 349 });
|
|
310
|
+
clock.advance(windowMilliseconds);
|
|
311
|
+
|
|
312
|
+
pool.acquire({ total: 10, idle: 4, waiting: 0 });
|
|
313
|
+
clock.advance(windowMilliseconds);
|
|
314
|
+
|
|
315
|
+
const lines = tracer.linesNamed('Postgres pool');
|
|
316
|
+
expect(lines).toHaveLength(2);
|
|
317
|
+
expect(lines[0].measurements.peakWaiting).toBe(349);
|
|
318
|
+
expect(lines[1].measurements.peakWaiting).toBe(0);
|
|
319
|
+
expect(tracer.warnings).toHaveLength(1);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should not hold the process open with the timer it schedules', () => {
|
|
323
|
+
tracePool(pool.asPool());
|
|
324
|
+
|
|
325
|
+
expect(clock.only().hasRef()).toBe(false);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('should stop reporting once the pool has ended', () => {
|
|
329
|
+
tracePool(pool.asPool());
|
|
330
|
+
|
|
331
|
+
pool.acquire({ total: 10, idle: 0, waiting: 0 });
|
|
332
|
+
clock.advance(windowMilliseconds);
|
|
333
|
+
expect(tracer.linesNamed('Postgres pool')).toHaveLength(1);
|
|
334
|
+
|
|
335
|
+
pool.acquire({ total: 10, idle: 0, waiting: 0 });
|
|
336
|
+
pool.ended = true;
|
|
337
|
+
|
|
338
|
+
clock.advance(windowMilliseconds * 5);
|
|
339
|
+
|
|
340
|
+
expect(tracer.linesNamed('Postgres pool')).toHaveLength(1);
|
|
341
|
+
expect(clock.live()).toHaveLength(0);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('should still report each physical connection as it is made and removed', () => {
|
|
345
|
+
tracePool(pool.asPool());
|
|
346
|
+
|
|
347
|
+
pool.connect({ total: 1, idle: 0, waiting: 0 });
|
|
348
|
+
pool.remove({ total: 0, idle: 0, waiting: 0 });
|
|
349
|
+
|
|
350
|
+
expect(tracer.linesNamed('Postgres connected')).toEqual([{
|
|
351
|
+
message: 'Postgres connected',
|
|
352
|
+
measurements: { total: 1, idle: 0, waiting: 0 }
|
|
353
|
+
}]);
|
|
354
|
+
expect(tracer.linesNamed('Postgres disconnected')).toEqual([{
|
|
355
|
+
message: 'Postgres disconnected',
|
|
356
|
+
measurements: { total: 0, idle: 0, waiting: 0 }
|
|
357
|
+
}]);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('should still report a pool error', () => {
|
|
361
|
+
tracePool(pool.asPool());
|
|
362
|
+
|
|
363
|
+
const error = new Error('connection terminated unexpectedly');
|
|
364
|
+
pool.fail(error);
|
|
365
|
+
|
|
366
|
+
expect(tracer.errors).toEqual([error]);
|
|
367
|
+
});
|
|
368
|
+
});
|