jinaga-server 3.7.5 → 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.
Files changed (38) hide show
  1. package/.claude/skills/degrees-of-freedom/.openskills.json +7 -0
  2. package/.claude/skills/degrees-of-freedom/SKILL.md +18 -0
  3. package/.claude/skills/degrees-of-freedom/degrees-of-freedom-constitution.md +142 -0
  4. package/.claude/skills/night-shift-setup/.openskills.json +7 -0
  5. package/.claude/skills/night-shift-setup/SKILL.md +95 -0
  6. package/.claude/skills/night-shift-setup/configuring-a-repository.md +111 -0
  7. package/.claude/skills/night-shift-setup/creating-the-log.md +59 -0
  8. package/.claude/skills/night-shift-setup/creating-the-routines.md +80 -0
  9. package/.claude/skills/night-shift-setup/log-model.factual +971 -0
  10. package/.claude/skills/night-shift-worker/.openskills.json +7 -0
  11. package/.claude/skills/night-shift-worker/SKILL.md +199 -0
  12. package/.claude/skills/night-shift-worker/night-shift-log.md +72 -0
  13. package/.claude/skills/night-shift-worker/scripts/register-stack.sh +173 -0
  14. package/.claude/skills/night-shift-worker/stacking.md +57 -0
  15. package/.claude/skills/night-shift-worker/verifying-a-merged-fix.md +16 -0
  16. package/.claude/skills/refining-issues/.openskills.json +7 -0
  17. package/.claude/skills/refining-issues/SKILL.md +327 -0
  18. package/.claude/skills/refining-issues/references/worked-example.md +234 -0
  19. package/.github/workflows/main.yml +2 -2
  20. package/.night-shift/config.md +84 -0
  21. package/.night-shift/skills.md +17 -0
  22. package/README.md +21 -0
  23. package/dist/http/router.js +19 -23
  24. package/dist/http/router.js.map +1 -1
  25. package/dist/http/stream.d.ts +0 -1
  26. package/dist/http/stream.js +6 -17
  27. package/dist/http/stream.js.map +1 -1
  28. package/dist/jinaga-server.js +74 -7
  29. package/dist/jinaga-server.js.map +1 -1
  30. package/integration-test/jinaga-test/package-lock.json +4 -4
  31. package/integration-test/jinaga-test/package.json +1 -1
  32. package/package.json +2 -2
  33. package/src/http/router.ts +31 -39
  34. package/src/http/stream.ts +21 -36
  35. package/src/jinaga-server.ts +92 -4
  36. package/test/http/streamLogVolumeSpec.ts +284 -0
  37. package/test/jinaga-server/poolConfigurationSpec.ts +147 -0
  38. package/test/jinaga-server/tracePoolSpec.ts +368 -0
@@ -41,6 +41,13 @@ import { outputReadResultsStreaming } from "./output-formatters";
41
41
  import { arrayToResultStream, ResultStream } from "./result-stream";
42
42
  import { Stream } from "./stream";
43
43
 
44
+ // A streaming response reports itself twice: one line when the stream opens
45
+ // and one when it ends, carrying the messages sent, how long it lasted, and
46
+ // what ended it. Issue #192: the per-message and per-lifecycle-step lines this
47
+ // replaces made the log grow with traffic rather than with the number of events
48
+ // worth knowing about, and `STREAM DONE`'s message count already said what the
49
+ // per-message lines said one at a time. A plain GET says nothing at all, since
50
+ // Trace.dependency already records it. Failures still log.
44
51
  function getOrStream<U>(
45
52
  getMethod: ((req: RequestUser, params: { [key: string]: string }, query: qs.ParsedQs) => Promise<U | null>),
46
53
  streamMethod: ((req: RequestUser, params: { [key: string]: string }, query: qs.ParsedQs) => Promise<Stream<U> | null>)
@@ -49,75 +56,68 @@ function getOrStream<U>(
49
56
  const connectionId = Math.random().toString(36).substring(2, 10);
50
57
  const user = <RequestUser>(req as any).user;
51
58
  const accept = req.headers["accept"];
52
-
53
- console.log(`[HttpConnection:${connectionId}] New request - User: ${user?.id || 'anonymous'}, Accept: ${accept}, URL: ${req.url}`);
54
-
59
+
55
60
  if (accept && accept.indexOf("application/x-jinaga-feed-stream") >= 0) {
56
- console.log(`[HttpConnection:${connectionId}] STREAMING request detected`);
57
-
58
61
  streamMethod(user, req.params, req.query)
59
62
  .then(response => {
60
63
  if (!response) {
61
- console.log(`[HttpConnection:${connectionId}] Stream method returned null - sending 404`);
62
64
  sendRouteMiss(res);
63
65
  next();
64
66
  }
65
67
  else {
66
- console.log(`[HttpConnection:${connectionId}] Setting up streaming response`);
67
-
68
68
  res.type("application/x-jinaga-feed-stream");
69
69
  res.set("Connection", "keep-alive");
70
70
  res.set("Cache-Control", "no-cache");
71
71
  res.set("Access-Control-Allow-Origin", "*");
72
72
  res.flushHeaders();
73
-
74
- console.log(`[HttpConnection:${connectionId}] Headers flushed - setting up event handlers`);
75
-
73
+
74
+ const openedAt = Date.now();
75
+ console.log(`[HttpConnection:${connectionId}] Stream opened - User: ${user?.id || 'anonymous'}, URL: ${req.url}`);
76
+
76
77
  let clientDisconnected = false;
77
- let timeoutTriggered = false;
78
78
  let streamClosed = false;
79
-
79
+ // What closed the stream from outside, where something
80
+ // other than the feed itself closed it. One value
81
+ // rather than a flag per cause, because a stream ends
82
+ // exactly once and only the first cause is the reason.
83
+ let closedBy: "client disconnect" | "timeout" | null = null;
84
+
80
85
  // Client disconnect handler
81
86
  req.on("close", () => {
82
- console.log(`[HttpConnection:${connectionId}] CLIENT DISCONNECTED`);
83
87
  clientDisconnected = true;
84
88
  if (!streamClosed) {
85
- console.log(`[HttpConnection:${connectionId}] Closing stream due to client disconnect`);
86
- response.close();
89
+ closedBy = "client disconnect";
87
90
  streamClosed = true;
91
+ response.close();
88
92
  }
89
93
  });
90
-
94
+
91
95
  // Timeout handler
92
96
  const timeout = setTimeout(() => {
93
- console.log(`[HttpConnection:${connectionId}] TIMEOUT TRIGGERED (5 minutes)`);
94
- timeoutTriggered = true;
95
97
  if (!streamClosed) {
96
- console.log(`[HttpConnection:${connectionId}] Closing stream due to timeout`);
97
- response.close();
98
+ closedBy = "timeout";
98
99
  streamClosed = true;
100
+ response.close();
99
101
  }
100
102
  }, 5 * 60 * 1000);
101
-
103
+
102
104
  let messageCount = 0;
103
-
105
+
104
106
  response
105
107
  .next(data => {
106
108
  messageCount++;
107
109
  const writeStart = Date.now();
108
-
110
+
109
111
  if (clientDisconnected) {
110
112
  console.warn(`[HttpConnection:${connectionId}] Attempted to write to disconnected client - Message #${messageCount}`);
111
113
  return;
112
114
  }
113
-
115
+
114
116
  try {
115
117
  const jsonData = JSON.stringify(data) + "\n\n";
116
118
  res.write(jsonData);
117
119
  const writeDuration = Date.now() - writeStart;
118
-
119
- console.log(`[HttpConnection:${connectionId}] Message #${messageCount} sent - Size: ${jsonData.length} bytes, Duration: ${writeDuration}ms`);
120
-
120
+
121
121
  if (writeDuration > 50) {
122
122
  console.warn(`[HttpConnection:${connectionId}] SLOW write operation - Duration: ${writeDuration}ms`);
123
123
  }
@@ -126,24 +126,19 @@ function getOrStream<U>(
126
126
  }
127
127
  })
128
128
  .done(() => {
129
- console.log(`[HttpConnection:${connectionId}] STREAM DONE - Messages sent: ${messageCount}, Client disconnected: ${clientDisconnected}, Timeout triggered: ${timeoutTriggered}`);
130
-
131
129
  clearTimeout(timeout);
132
130
  streamClosed = true;
133
-
131
+
132
+ console.log(`[HttpConnection:${connectionId}] Stream ended - Messages sent: ${messageCount}, Duration: ${Date.now() - openedAt}ms, Reason: ${closedBy ?? "done"}`);
133
+
134
134
  try {
135
135
  if (!clientDisconnected) {
136
- console.log(`[HttpConnection:${connectionId}] Ending socket connection`);
137
136
  res.socket?.end();
138
- } else {
139
- console.log(`[HttpConnection:${connectionId}] Client already disconnected - skipping socket end`);
140
137
  }
141
138
  } catch (error) {
142
139
  console.error(`[HttpConnection:${connectionId}] ERROR ending socket: ${error}`);
143
140
  }
144
141
  });
145
-
146
- console.log(`[HttpConnection:${connectionId}] Stream handlers configured`);
147
142
  }
148
143
  })
149
144
  .catch(error => {
@@ -152,16 +147,13 @@ function getOrStream<U>(
152
147
  });
153
148
  }
154
149
  else {
155
- console.log(`[HttpConnection:${connectionId}] Regular GET request`);
156
150
  getMethod(user, req.params, req.query)
157
151
  .then(response => {
158
152
  if (!response) {
159
- console.log(`[HttpConnection:${connectionId}] Get method returned null - sending 404`);
160
153
  sendRouteMiss(res);
161
154
  next();
162
155
  }
163
156
  else {
164
- console.log(`[HttpConnection:${connectionId}] Sending JSON response`);
165
157
  res.type("json");
166
158
  res.send(JSON.stringify(response));
167
159
  next();
@@ -1,14 +1,18 @@
1
+ // This class logs nothing on its success path. A stream's lifetime is reported
2
+ // once by the request that opened it (see getOrStream in router.ts), and a line
3
+ // per feed, per replayed item, or per done handler says the same thing again
4
+ // once per unit of traffic. Issue #192: those per-item lines were 11.9% of a
5
+ // 2.8-million-line production log. Failures still log, because a failed handler
6
+ // is an event an operator cannot reconstruct from the summary.
1
7
  export class Stream<T> {
2
8
  private queue: T[] = [];
3
9
  private handlers: ((data: T) => void)[] = [];
4
10
  private doneHandlers: (() => void)[] = [];
5
11
  private closed = false;
6
12
  private readonly streamId: string;
7
- private feedCount = 0;
8
13
 
9
14
  constructor() {
10
15
  this.streamId = Math.random().toString(36).substring(2, 10);
11
- console.log(`[Stream:${this.streamId}] Created new stream`);
12
16
  }
13
17
 
14
18
  next(handler: (data: T) => void): Stream<T> {
@@ -16,21 +20,18 @@ export class Stream<T> {
16
20
  console.warn(`[Stream:${this.streamId}] Attempted to add handler to closed stream`);
17
21
  return this;
18
22
  }
19
-
23
+
20
24
  this.handlers.push(handler);
21
- const queueLength = this.queue.length;
22
- console.log(`[Stream:${this.streamId}] Added handler - Total handlers: ${this.handlers.length}, Queued items: ${queueLength}`);
23
-
25
+
24
26
  // Replay queued data to new handler
25
27
  this.queue.forEach((data, index) => {
26
28
  try {
27
29
  handler(data);
28
- console.log(`[Stream:${this.streamId}] Replayed queued item ${index + 1}/${queueLength} to new handler`);
29
30
  } catch (error) {
30
31
  console.error(`[Stream:${this.streamId}] Error replaying queued item ${index + 1}: ${error}`);
31
32
  }
32
33
  });
33
-
34
+
34
35
  return this;
35
36
  }
36
37
 
@@ -44,9 +45,8 @@ export class Stream<T> {
44
45
  }
45
46
  return this;
46
47
  }
47
-
48
+
48
49
  this.doneHandlers.push(handler);
49
- console.log(`[Stream:${this.streamId}] Added done handler - Total done handlers: ${this.doneHandlers.length}`);
50
50
  return this;
51
51
  }
52
52
 
@@ -55,70 +55,55 @@ export class Stream<T> {
55
55
  console.warn(`[Stream:${this.streamId}] Attempted to close already closed stream`);
56
56
  return;
57
57
  }
58
-
59
- console.log(`[Stream:${this.streamId}] CLOSING stream - Handlers: ${this.handlers.length}, Done handlers: ${this.doneHandlers.length}, Queue: ${this.queue.length}, Feed count: ${this.feedCount}`);
60
-
58
+
61
59
  this.closed = true;
62
-
60
+
63
61
  // Execute done handlers
64
- const doneHandlerCount = this.doneHandlers.length;
65
62
  this.doneHandlers.forEach((handler, index) => {
66
63
  try {
67
64
  handler();
68
- console.log(`[Stream:${this.streamId}] Executed done handler ${index + 1}/${doneHandlerCount}`);
69
65
  } catch (error) {
70
66
  console.error(`[Stream:${this.streamId}] Error in done handler ${index + 1}: ${error}`);
71
67
  }
72
68
  });
73
-
69
+
74
70
  // Clear all references
75
71
  this.queue = [];
76
72
  this.handlers = [];
77
73
  this.doneHandlers = [];
78
-
79
- console.log(`[Stream:${this.streamId}] Stream closed and cleaned up`);
80
74
  }
81
75
 
82
76
  feed(data: any): void {
83
77
  const feedStart = Date.now();
84
-
78
+
85
79
  if (this.closed) {
86
80
  console.warn(`[Stream:${this.streamId}] Attempted to feed data to closed stream - Data type: ${typeof data}`);
87
81
  return;
88
82
  }
89
-
90
- this.feedCount++;
83
+
91
84
  const handlerCount = this.handlers.length;
92
-
93
- console.log(`[Stream:${this.streamId}] FEEDING data - Feed #${this.feedCount}, Handlers: ${handlerCount}, Queue size before: ${this.queue.length}`);
94
-
85
+
95
86
  this.queue.push(data);
96
-
87
+
97
88
  // Notify all handlers
98
- let successCount = 0;
99
- let errorCount = 0;
100
-
101
89
  this.handlers.forEach((handler, index) => {
102
90
  try {
103
91
  const handlerStart = Date.now();
104
92
  handler(data);
105
93
  const handlerDuration = Date.now() - handlerStart;
106
- successCount++;
107
-
94
+
108
95
  if (handlerDuration > 50) {
109
96
  console.warn(`[Stream:${this.streamId}] SLOW handler - Handler ${index + 1}/${handlerCount}, Duration: ${handlerDuration}ms`);
110
97
  }
111
98
  } catch (error) {
112
- errorCount++;
113
99
  console.error(`[Stream:${this.streamId}] ERROR in handler ${index + 1}/${handlerCount}: ${error}`);
114
100
  }
115
101
  });
116
-
102
+
117
103
  const totalDuration = Date.now() - feedStart;
118
- console.log(`[Stream:${this.streamId}] Feed complete - Success: ${successCount}, Errors: ${errorCount}, Queue size after: ${this.queue.length}, Duration: ${totalDuration}ms`);
119
-
104
+
120
105
  if (totalDuration > 100) {
121
106
  console.warn(`[Stream:${this.streamId}] SLOW feed operation - Duration: ${totalDuration}ms`);
122
107
  }
123
108
  }
124
- }
109
+ }
@@ -253,22 +253,82 @@ function getPool(uriOrPool: string | Pool, pools: { [uri: string]: Pool; }): Poo
253
253
  }
254
254
  }
255
255
 
256
+ type PoolSettingDeclaration = {
257
+ variable: string,
258
+ defaultValue: number,
259
+ minimum: number
260
+ };
261
+
262
+ // The settings of a pool that this server builds for itself, each named by the
263
+ // environment variable that sets it. A pool the caller supplies is used as
264
+ // given, so none of these applies to it.
265
+ const poolSettingDeclarations = {
266
+ // A pool of zero connections can serve nothing, so one is the minimum.
267
+ max: { variable: "POSTGRES_POOL_MAX", defaultValue: 10, minimum: 1 },
268
+ idleTimeoutMillis: { variable: "POSTGRES_IDLE_TIMEOUT_MILLIS", defaultValue: 30000, minimum: 0 },
269
+ // Zero means wait forever for a connection to come free.
270
+ connectionTimeoutMillis: { variable: "POSTGRES_CONNECTION_TIMEOUT_MILLIS", defaultValue: 0, minimum: 0 }
271
+ } satisfies { [setting: string]: PoolSettingDeclaration };
272
+
273
+ type PoolSettings = {
274
+ [setting in keyof typeof poolSettingDeclarations]: number
275
+ };
276
+
277
+ // The one rule by which every pool setting is read from the environment. An
278
+ // absent or empty variable takes the default; anything that is not an integer
279
+ // at or above the setting's minimum is refused rather than resolved, so the
280
+ // environment either names a pool that can be built or no pool at all.
281
+ function readPoolSetting({ variable, defaultValue, minimum }: PoolSettingDeclaration): number {
282
+ const value = process.env[variable];
283
+ if (value === undefined || value.trim() === '') {
284
+ return defaultValue;
285
+ }
286
+ const parsed = /^\d+$/.test(value.trim()) ? parseInt(value.trim(), 10) : NaN;
287
+ if (isNaN(parsed) || parsed < minimum) {
288
+ throw new Error(`The environment variable ${variable} is set to ${JSON.stringify(value)}, which is not an integer of at least ${minimum}.`);
289
+ }
290
+ return parsed;
291
+ }
292
+
293
+ function readPoolSettings(): PoolSettings {
294
+ return {
295
+ max: readPoolSetting(poolSettingDeclarations.max),
296
+ idleTimeoutMillis: readPoolSetting(poolSettingDeclarations.idleTimeoutMillis),
297
+ connectionTimeoutMillis: readPoolSetting(poolSettingDeclarations.connectionTimeoutMillis)
298
+ };
299
+ }
300
+
256
301
  function createPool(postgresUri: string): Pool {
302
+ const settings = readPoolSettings();
257
303
  const postgresPool = new Pool({
258
304
  connectionString: postgresUri,
259
- idleTimeoutMillis: process.env.POSTGRES_IDLE_TIMEOUT_MILLIS ?
260
- parseInt(process.env.POSTGRES_IDLE_TIMEOUT_MILLIS) :
261
- 30000,
305
+ ...settings
262
306
  });
263
307
 
308
+ Trace.info(`Postgres pool created with max ${settings.max}, idleTimeoutMillis ${settings.idleTimeoutMillis}, connectionTimeoutMillis ${settings.connectionTimeoutMillis}`);
309
+
264
310
  tracePool(postgresPool);
265
311
 
266
312
  return postgresPool;
267
313
  }
268
314
 
315
+ const poolWindowMilliseconds = 10000;
316
+
269
317
  export function tracePool(postgresPool: Pool) {
318
+ // Pool pressure is reported once per window of time rather than once per
319
+ // checkout, so the output rate no longer follows the acquire rate and no
320
+ // longer peaks when the pool is saturated. The window carries the largest
321
+ // waiting count it saw, so a spike that comes and goes inside one window
322
+ // still appears in the log.
323
+ let acquiredInWindow = false;
324
+ let peakWaiting = 0;
325
+
270
326
  postgresPool.on('acquire', (client) => {
271
- Trace.metric('Postgres acquired', poolMetrics());
327
+ acquiredInWindow = true;
328
+ observeWaiting();
329
+ });
330
+ postgresPool.on('release', (err, client) => {
331
+ observeWaiting();
272
332
  });
273
333
  postgresPool.on('connect', (client) => {
274
334
  Trace.metric('Postgres connected', poolMetrics());
@@ -280,6 +340,34 @@ export function tracePool(postgresPool: Pool) {
280
340
  Trace.error(err);
281
341
  });
282
342
 
343
+ // The window closes on a timer so that a storm which stops in the middle
344
+ // of a window still reports its peak. The timer is unreferenced, so it
345
+ // does not hold the process open, and it stops once the pool has ended.
346
+ const windowTimer = setInterval(closeWindow, poolWindowMilliseconds);
347
+ windowTimer.unref?.();
348
+
349
+ function observeWaiting() {
350
+ if (postgresPool.waitingCount > peakWaiting) {
351
+ peakWaiting = postgresPool.waitingCount;
352
+ }
353
+ }
354
+
355
+ function closeWindow() {
356
+ if (postgresPool.ended) {
357
+ clearInterval(windowTimer);
358
+ return;
359
+ }
360
+ if (acquiredInWindow) {
361
+ const metrics = poolMetrics();
362
+ Trace.metric('Postgres pool', { ...metrics, peakWaiting });
363
+ if (peakWaiting > 0) {
364
+ Trace.warn(`Postgres pool saturated: ${peakWaiting} waiting at peak, ${metrics.waiting} waiting of ${metrics.total} at window close.`);
365
+ }
366
+ }
367
+ acquiredInWindow = false;
368
+ peakWaiting = 0;
369
+ }
370
+
283
371
  function poolMetrics() {
284
372
  return {
285
373
  total: postgresPool.totalCount,