@naturalcycles/backend-lib 9.83.0 → 9.84.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.
@@ -10,13 +10,16 @@ import type { Integer, NonNegativeInteger, NumberOfMilliseconds, NumberOfPercent
10
10
  * expect p50/mean near 0 on a healthy service, instead of the ~`resolution` baseline
11
11
  * reported by older interval-based sampling.
12
12
  *
13
- * @experimental
13
+ * Beware of CPU-throttled processes (e.g. idle CloudRun instances with request-based billing):
14
+ * the interval timer fires late there, and the time the process spent runnable-but-unscheduled
15
+ * is recorded as event loop delay. Use `intervalMs` to detect such intervals.
14
16
  */
15
17
  export declare class EventLoopMonitor {
16
18
  constructor(cfg?: EventLoopMonitorCfg);
17
19
  private interval;
18
20
  private eld;
19
21
  private lastElu;
22
+ private lastReportedAt;
20
23
  /**
21
24
  * Undefined until the first interval has completed.
22
25
  */
@@ -53,15 +56,15 @@ export interface EventLoopStats {
53
56
  */
54
57
  elu: NumberOfPercent;
55
58
  /**
56
- * Number of times gc ran during the last `measureInterval`
59
+ * Number of times gc ran during the last interval
57
60
  */
58
61
  gcCount: NonNegativeInteger;
59
62
  /**
60
- * Total time spent on gc during the last `measureInterval`
63
+ * Total time spent on gc during the last interval
61
64
  */
62
65
  gcTotalTime: NumberOfMilliseconds;
63
66
  /**
64
- * CPU time spent on GC in the last `measureInterval`.
67
+ * CPU time spent on GC in the last interval, relative to `intervalMs`.
65
68
  * In "basis points", 100 basis points == 1%.
66
69
  * E.g 125 means 1.25% of CPU spent on GC.
67
70
  * It's reported in BPS to be able to do more accurate aggregate reporting
@@ -69,4 +72,12 @@ export interface EventLoopStats {
69
72
  * too "lossy")
70
73
  */
71
74
  gcCPU: Integer;
75
+ /**
76
+ * Actual wall-clock duration of the interval covered by these stats.
77
+ * Normally equals `measureInterval`, but is longer when the timer fired late,
78
+ * e.g. on a CloudRun instance that was CPU-throttled while idle (request-based billing).
79
+ * An interval much longer than `measureInterval` means the delay stats are dominated
80
+ * by that throttling rather than by the application.
81
+ */
82
+ intervalMs: NumberOfMilliseconds;
72
83
  }
@@ -26,7 +26,9 @@ import { monitorEventLoopDelay, performance, PerformanceObserver } from 'node:pe
26
26
  * expect p50/mean near 0 on a healthy service, instead of the ~`resolution` baseline
27
27
  * reported by older interval-based sampling.
28
28
  *
29
- * @experimental
29
+ * Beware of CPU-throttled processes (e.g. idle CloudRun instances with request-based billing):
30
+ * the interval timer fires late there, and the time the process spent runnable-but-unscheduled
31
+ * is recorded as event loop delay. Use `intervalMs` to detect such intervals.
30
32
  */
31
33
  export class EventLoopMonitor {
32
34
  constructor(cfg = {}) {
@@ -41,6 +43,7 @@ export class EventLoopMonitor {
41
43
  this.eld = monitorEventLoopDelay(eldOptions);
42
44
  this.eld.enable();
43
45
  this.lastElu = performance.eventLoopUtilization();
46
+ this.lastReportedAt = performance.now();
44
47
  this.po = new PerformanceObserver(list => {
45
48
  for (const entry of list.getEntries()) {
46
49
  this.gcCount++;
@@ -56,11 +59,17 @@ export class EventLoopMonitor {
56
59
  const p99 = Math.round(eld.percentile(99) / 1e6);
57
60
  const max = Math.round(eld.max / 1e6);
58
61
  const mean = Math.round(eld.mean / 1e6);
62
+ // Actual wall-clock length of this interval, as opposed to the nominal `measureInterval`.
63
+ // Timers fire late when the process is starved of CPU, so this can be much longer.
64
+ // Floored at 1 to never divide by zero (only reachable with fake timers).
65
+ const now = performance.now();
66
+ const intervalMs = Math.max(1, Math.round(now - this.lastReportedAt));
67
+ this.lastReportedAt = now;
59
68
  const currentElu = performance.eventLoopUtilization();
60
69
  const deltaElu = performance.eventLoopUtilization(this.lastElu, currentElu);
61
70
  this.lastElu = currentElu;
62
71
  const elu = Math.round(deltaElu.utilization * 100);
63
- const gcCPU = Math.round((gcTotalTime / measureInterval) * 10_000);
72
+ const gcCPU = Math.round((gcTotalTime / intervalMs) * 10_000);
64
73
  this.lastStats = {
65
74
  p50,
66
75
  p90,
@@ -71,6 +80,7 @@ export class EventLoopMonitor {
71
80
  gcCount,
72
81
  gcTotalTime,
73
82
  gcCPU,
83
+ intervalMs,
74
84
  };
75
85
  cfg.onStats?.(this.lastStats);
76
86
  eld.reset();
@@ -81,6 +91,7 @@ export class EventLoopMonitor {
81
91
  interval;
82
92
  eld;
83
93
  lastElu;
94
+ lastReportedAt;
84
95
  /**
85
96
  * Undefined until the first interval has completed.
86
97
  */
@@ -7,7 +7,7 @@ export interface ExpressApp extends Fetcher, AsyncDisposable {
7
7
  declare class ExpressTestService {
8
8
  createAppFromResource(resource: BackendRequestHandlerCfg, opt?: FetcherOptions, defaultAppCfg?: DefaultAppCfg): Promise<ExpressApp>;
9
9
  createAppFromResources(resources: BackendRequestHandlerCfg[], opt?: FetcherOptions): Promise<ExpressApp>;
10
- createApp(app: BackendApplication, opt?: FetcherOptions): ExpressApp;
10
+ createApp(app: BackendApplication, opt?: FetcherOptions): Promise<ExpressApp>;
11
11
  /**
12
12
  * Creates a "Default Express App" with provided resources.
13
13
  * Starts an http server on '127.0.0.1' and random available port.
@@ -1,9 +1,10 @@
1
1
  import { getFetcher } from '@naturalcycles/js-lib/http';
2
2
  import { pDelay } from '@naturalcycles/js-lib/promise/pDelay.js';
3
3
  import { createDefaultApp } from '../express/createDefaultApp.js';
4
+ const TEST_SERVER_HOST = '127.0.0.1';
4
5
  const nativeFetchFn = async (url, init) => await globalThis.fetch(url, init);
5
6
  // Example:
6
- // const app = expressTestService.createApp([ debugResource ])
7
+ // const app = await expressTestService.createAppFromResource(debugResource)
7
8
  // afterAll(async () => {
8
9
  // await app.close()
9
10
  // })
@@ -19,10 +20,10 @@ class ExpressTestService {
19
20
  resources,
20
21
  }), opt);
21
22
  }
22
- createApp(app, opt) {
23
- const server = this.createTestServer(app);
23
+ async createApp(app, opt) {
24
+ const server = await this.createTestServer(app);
24
25
  const { port } = server.address();
25
- const baseUrl = `http://127.0.0.1:${port}`;
26
+ const baseUrl = `http://${TEST_SERVER_HOST}:${port}`;
26
27
  const fetcher = getFetcher({
27
28
  baseUrl,
28
29
  responseType: 'json',
@@ -63,14 +64,23 @@ class ExpressTestService {
63
64
  * const { port } = server.address() as AddressInfo
64
65
  * const url = `http://127.0.0.1:${port}`
65
66
  */
66
- createTestServer(app) {
67
+ async createTestServer(app) {
67
68
  // Important!
68
- // Only with this syntax `app.listen(0)` it allocates a port synchronously
69
- // Trying to specify a hostname will make server.address() return null.
70
- // This is how `supertest` is doing it.
71
- const server = app.listen(0);
69
+ // Listening on the same host that the Fetcher connects to.
70
+ // `app.listen(0)` (without a hostname) listens on all interfaces (`::`), and the OS (e.g macOS)
71
+ // may give it a port that some other process (e.g IntelliJ on 63342) already listens on
72
+ // at 127.0.0.1. Requests to 127.0.0.1 would then reach that other process instead.
73
+ // Listening on 127.0.0.1 makes the OS pick a port that is free on 127.0.0.1.
74
+ // The downside is that the port is only known after the async `listening` callback
75
+ // (server.address() returns null until then).
72
76
  // enableDestroy(server)
73
- return server;
77
+ return await new Promise((resolve, reject) => {
78
+ const server = app.listen(0, TEST_SERVER_HOST, (err) => {
79
+ if (err)
80
+ return reject(err);
81
+ resolve(server);
82
+ });
83
+ });
74
84
  }
75
85
  }
76
86
  export const expressTestService = new ExpressTestService();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/backend-lib",
3
3
  "type": "module",
4
- "version": "9.83.0",
4
+ "version": "9.84.1",
5
5
  "dependencies": {
6
6
  "@naturalcycles/db-lib": "^10",
7
7
  "@naturalcycles/js-lib": "^15",
@@ -35,7 +35,9 @@ import type {
35
35
  * expect p50/mean near 0 on a healthy service, instead of the ~`resolution` baseline
36
36
  * reported by older interval-based sampling.
37
37
  *
38
- * @experimental
38
+ * Beware of CPU-throttled processes (e.g. idle CloudRun instances with request-based billing):
39
+ * the interval timer fires late there, and the time the process spent runnable-but-unscheduled
40
+ * is recorded as event loop delay. Use `intervalMs` to detect such intervals.
39
41
  */
40
42
  export class EventLoopMonitor {
41
43
  constructor(cfg: EventLoopMonitorCfg = {}) {
@@ -52,6 +54,7 @@ export class EventLoopMonitor {
52
54
  this.eld.enable()
53
55
 
54
56
  this.lastElu = performance.eventLoopUtilization()
57
+ this.lastReportedAt = performance.now()
55
58
 
56
59
  this.po = new PerformanceObserver(list => {
57
60
  for (const entry of list.getEntries()) {
@@ -71,12 +74,19 @@ export class EventLoopMonitor {
71
74
  const max = Math.round(eld.max / 1e6)
72
75
  const mean = Math.round(eld.mean / 1e6)
73
76
 
77
+ // Actual wall-clock length of this interval, as opposed to the nominal `measureInterval`.
78
+ // Timers fire late when the process is starved of CPU, so this can be much longer.
79
+ // Floored at 1 to never divide by zero (only reachable with fake timers).
80
+ const now = performance.now()
81
+ const intervalMs = Math.max(1, Math.round(now - this.lastReportedAt))
82
+ this.lastReportedAt = now
83
+
74
84
  const currentElu = performance.eventLoopUtilization()
75
85
  const deltaElu = performance.eventLoopUtilization(this.lastElu, currentElu)
76
86
  this.lastElu = currentElu
77
87
 
78
88
  const elu = Math.round(deltaElu.utilization * 100)
79
- const gcCPU = Math.round((gcTotalTime / measureInterval) * 10_000)
89
+ const gcCPU = Math.round((gcTotalTime / intervalMs) * 10_000)
80
90
 
81
91
  this.lastStats = {
82
92
  p50,
@@ -88,6 +98,7 @@ export class EventLoopMonitor {
88
98
  gcCount,
89
99
  gcTotalTime,
90
100
  gcCPU,
101
+ intervalMs,
91
102
  }
92
103
 
93
104
  cfg.onStats?.(this.lastStats)
@@ -101,6 +112,7 @@ export class EventLoopMonitor {
101
112
  private interval: NodeJS.Timeout
102
113
  private eld: ELDHistogram
103
114
  private lastElu: EventLoopUtilization
115
+ private lastReportedAt: NumberOfMilliseconds
104
116
  /**
105
117
  * Undefined until the first interval has completed.
106
118
  */
@@ -146,15 +158,15 @@ export interface EventLoopStats {
146
158
  */
147
159
  elu: NumberOfPercent
148
160
  /**
149
- * Number of times gc ran during the last `measureInterval`
161
+ * Number of times gc ran during the last interval
150
162
  */
151
163
  gcCount: NonNegativeInteger
152
164
  /**
153
- * Total time spent on gc during the last `measureInterval`
165
+ * Total time spent on gc during the last interval
154
166
  */
155
167
  gcTotalTime: NumberOfMilliseconds
156
168
  /**
157
- * CPU time spent on GC in the last `measureInterval`.
169
+ * CPU time spent on GC in the last interval, relative to `intervalMs`.
158
170
  * In "basis points", 100 basis points == 1%.
159
171
  * E.g 125 means 1.25% of CPU spent on GC.
160
172
  * It's reported in BPS to be able to do more accurate aggregate reporting
@@ -162,4 +174,12 @@ export interface EventLoopStats {
162
174
  * too "lossy")
163
175
  */
164
176
  gcCPU: Integer
177
+ /**
178
+ * Actual wall-clock duration of the interval covered by these stats.
179
+ * Normally equals `measureInterval`, but is longer when the timer fired late,
180
+ * e.g. on a CloudRun instance that was CPU-throttled while idle (request-based billing).
181
+ * An interval much longer than `measureInterval` means the delay stats are dominated
182
+ * by that throttling rather than by the application.
183
+ */
184
+ intervalMs: NumberOfMilliseconds
165
185
  }
@@ -7,6 +7,8 @@ import { createDefaultApp } from '../express/createDefaultApp.js'
7
7
  import type { BackendRequestHandlerCfg, DefaultAppCfg } from '../express/createDefaultApp.js'
8
8
  import type { BackendApplication } from '../server/server.model.js'
9
9
 
10
+ const TEST_SERVER_HOST = '127.0.0.1'
11
+
10
12
  const nativeFetchFn: FetchFunction = async (url, init) => await globalThis.fetch(url, init)
11
13
 
12
14
  export interface ExpressApp extends Fetcher, AsyncDisposable {
@@ -14,7 +16,7 @@ export interface ExpressApp extends Fetcher, AsyncDisposable {
14
16
  }
15
17
 
16
18
  // Example:
17
- // const app = expressTestService.createApp([ debugResource ])
19
+ // const app = await expressTestService.createAppFromResource(debugResource)
18
20
  // afterAll(async () => {
19
21
  // await app.close()
20
22
  // })
@@ -46,10 +48,10 @@ class ExpressTestService {
46
48
  )
47
49
  }
48
50
 
49
- createApp(app: BackendApplication, opt?: FetcherOptions): ExpressApp {
50
- const server = this.createTestServer(app)
51
+ async createApp(app: BackendApplication, opt?: FetcherOptions): Promise<ExpressApp> {
52
+ const server = await this.createTestServer(app)
51
53
  const { port } = server.address() as AddressInfo
52
- const baseUrl = `http://127.0.0.1:${port}`
54
+ const baseUrl = `http://${TEST_SERVER_HOST}:${port}`
53
55
 
54
56
  const fetcher = getFetcher({
55
57
  baseUrl,
@@ -95,14 +97,22 @@ class ExpressTestService {
95
97
  * const { port } = server.address() as AddressInfo
96
98
  * const url = `http://127.0.0.1:${port}`
97
99
  */
98
- private createTestServer(app: BackendApplication): Server {
100
+ private async createTestServer(app: BackendApplication): Promise<Server> {
99
101
  // Important!
100
- // Only with this syntax `app.listen(0)` it allocates a port synchronously
101
- // Trying to specify a hostname will make server.address() return null.
102
- // This is how `supertest` is doing it.
103
- const server = app.listen(0)
102
+ // Listening on the same host that the Fetcher connects to.
103
+ // `app.listen(0)` (without a hostname) listens on all interfaces (`::`), and the OS (e.g macOS)
104
+ // may give it a port that some other process (e.g IntelliJ on 63342) already listens on
105
+ // at 127.0.0.1. Requests to 127.0.0.1 would then reach that other process instead.
106
+ // Listening on 127.0.0.1 makes the OS pick a port that is free on 127.0.0.1.
107
+ // The downside is that the port is only known after the async `listening` callback
108
+ // (server.address() returns null until then).
104
109
  // enableDestroy(server)
105
- return server
110
+ return await new Promise<Server>((resolve, reject) => {
111
+ const server = app.listen(0, TEST_SERVER_HOST, (err?: Error) => {
112
+ if (err) return reject(err)
113
+ resolve(server)
114
+ })
115
+ })
106
116
  }
107
117
  }
108
118