@naturalcycles/backend-lib 9.84.0 → 9.84.2

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,19 @@ 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 time the process spent runnable-but-unscheduled is recorded as event loop delay,
15
+ * and the interval timer fires late (see `intervalMs`).
16
+ * Use `pause()` / `resume()` to keep such periods out of the delay samples,
17
+ * e.g. pause when the last in-flight request finishes and resume when the next one arrives.
14
18
  */
15
19
  export declare class EventLoopMonitor {
16
20
  constructor(cfg?: EventLoopMonitorCfg);
17
21
  private interval;
18
22
  private eld;
19
23
  private lastElu;
24
+ private lastReportedAt;
25
+ private paused;
20
26
  /**
21
27
  * Undefined until the first interval has completed.
22
28
  */
@@ -25,6 +31,22 @@ export declare class EventLoopMonitor {
25
31
  gcCount: number;
26
32
  gcTotalTime: number;
27
33
  stop(): void;
34
+ /**
35
+ * Stops sampling event loop delay until `resume()` is called.
36
+ * GC and ELU keep being tracked, and the samples recorded so far are kept.
37
+ *
38
+ * Meant for periods when the process is known to be CPU-throttled, e.g. a CloudRun instance
39
+ * (request-based billing) with no request in flight: a delay recorded there measures
40
+ * the throttling, not the application.
41
+ */
42
+ pause(): void;
43
+ /**
44
+ * Resumes sampling event loop delay after `pause()`.
45
+ * Safe to call from within the event that ended the throttled period (e.g. the middleware of
46
+ * the request that woke the instance): the histogram restarts its clock from this moment,
47
+ * so the stall that preceded it in the same loop iteration is not sampled.
48
+ */
49
+ resume(): void;
28
50
  }
29
51
  export interface EventLoopMonitorCfg {
30
52
  /**
@@ -53,15 +75,15 @@ export interface EventLoopStats {
53
75
  */
54
76
  elu: NumberOfPercent;
55
77
  /**
56
- * Number of times gc ran during the last `measureInterval`
78
+ * Number of times gc ran during the last interval
57
79
  */
58
80
  gcCount: NonNegativeInteger;
59
81
  /**
60
- * Total time spent on gc during the last `measureInterval`
82
+ * Total time spent on gc during the last interval
61
83
  */
62
84
  gcTotalTime: NumberOfMilliseconds;
63
85
  /**
64
- * CPU time spent on GC in the last `measureInterval`.
86
+ * CPU time spent on GC in the last interval, relative to `intervalMs`.
65
87
  * In "basis points", 100 basis points == 1%.
66
88
  * E.g 125 means 1.25% of CPU spent on GC.
67
89
  * It's reported in BPS to be able to do more accurate aggregate reporting
@@ -69,4 +91,12 @@ export interface EventLoopStats {
69
91
  * too "lossy")
70
92
  */
71
93
  gcCPU: Integer;
94
+ /**
95
+ * Actual wall-clock duration of the interval covered by these stats.
96
+ * Normally equals `measureInterval`, but is longer when the timer fired late,
97
+ * e.g. on a CloudRun instance that was CPU-throttled while idle (request-based billing).
98
+ * An interval much longer than `measureInterval` means the delay stats are dominated
99
+ * by that throttling rather than by the application.
100
+ */
101
+ intervalMs: NumberOfMilliseconds;
72
102
  }
@@ -26,7 +26,11 @@ 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 time the process spent runnable-but-unscheduled is recorded as event loop delay,
31
+ * and the interval timer fires late (see `intervalMs`).
32
+ * Use `pause()` / `resume()` to keep such periods out of the delay samples,
33
+ * e.g. pause when the last in-flight request finishes and resume when the next one arrives.
30
34
  */
31
35
  export class EventLoopMonitor {
32
36
  constructor(cfg = {}) {
@@ -41,6 +45,7 @@ export class EventLoopMonitor {
41
45
  this.eld = monitorEventLoopDelay(eldOptions);
42
46
  this.eld.enable();
43
47
  this.lastElu = performance.eventLoopUtilization();
48
+ this.lastReportedAt = performance.now();
44
49
  this.po = new PerformanceObserver(list => {
45
50
  for (const entry of list.getEntries()) {
46
51
  this.gcCount++;
@@ -56,11 +61,17 @@ export class EventLoopMonitor {
56
61
  const p99 = Math.round(eld.percentile(99) / 1e6);
57
62
  const max = Math.round(eld.max / 1e6);
58
63
  const mean = Math.round(eld.mean / 1e6);
64
+ // Actual wall-clock length of this interval, as opposed to the nominal `measureInterval`.
65
+ // Timers fire late when the process is starved of CPU, so this can be much longer.
66
+ // Floored at 1 to never divide by zero (only reachable with fake timers).
67
+ const now = performance.now();
68
+ const intervalMs = Math.max(1, Math.round(now - this.lastReportedAt));
69
+ this.lastReportedAt = now;
59
70
  const currentElu = performance.eventLoopUtilization();
60
71
  const deltaElu = performance.eventLoopUtilization(this.lastElu, currentElu);
61
72
  this.lastElu = currentElu;
62
73
  const elu = Math.round(deltaElu.utilization * 100);
63
- const gcCPU = Math.round((gcTotalTime / measureInterval) * 10_000);
74
+ const gcCPU = Math.round((gcTotalTime / intervalMs) * 10_000);
64
75
  this.lastStats = {
65
76
  p50,
66
77
  p90,
@@ -71,6 +82,7 @@ export class EventLoopMonitor {
71
82
  gcCount,
72
83
  gcTotalTime,
73
84
  gcCPU,
85
+ intervalMs,
74
86
  };
75
87
  cfg.onStats?.(this.lastStats);
76
88
  eld.reset();
@@ -81,6 +93,8 @@ export class EventLoopMonitor {
81
93
  interval;
82
94
  eld;
83
95
  lastElu;
96
+ lastReportedAt;
97
+ paused = false;
84
98
  /**
85
99
  * Undefined until the first interval has completed.
86
100
  */
@@ -93,4 +107,28 @@ export class EventLoopMonitor {
93
107
  this.eld.disable();
94
108
  this.po.disconnect();
95
109
  }
110
+ /**
111
+ * Stops sampling event loop delay until `resume()` is called.
112
+ * GC and ELU keep being tracked, and the samples recorded so far are kept.
113
+ *
114
+ * Meant for periods when the process is known to be CPU-throttled, e.g. a CloudRun instance
115
+ * (request-based billing) with no request in flight: a delay recorded there measures
116
+ * the throttling, not the application.
117
+ */
118
+ pause() {
119
+ this.paused = true;
120
+ this.eld.disable();
121
+ }
122
+ /**
123
+ * Resumes sampling event loop delay after `pause()`.
124
+ * Safe to call from within the event that ended the throttled period (e.g. the middleware of
125
+ * the request that woke the instance): the histogram restarts its clock from this moment,
126
+ * so the stall that preceded it in the same loop iteration is not sampled.
127
+ */
128
+ resume() {
129
+ if (!this.paused)
130
+ return;
131
+ this.paused = false;
132
+ this.eld.enable();
133
+ }
96
134
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/backend-lib",
3
3
  "type": "module",
4
- "version": "9.84.0",
4
+ "version": "9.84.2",
5
5
  "dependencies": {
6
6
  "@naturalcycles/db-lib": "^10",
7
7
  "@naturalcycles/js-lib": "^15",
@@ -35,7 +35,11 @@ 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 time the process spent runnable-but-unscheduled is recorded as event loop delay,
40
+ * and the interval timer fires late (see `intervalMs`).
41
+ * Use `pause()` / `resume()` to keep such periods out of the delay samples,
42
+ * e.g. pause when the last in-flight request finishes and resume when the next one arrives.
39
43
  */
40
44
  export class EventLoopMonitor {
41
45
  constructor(cfg: EventLoopMonitorCfg = {}) {
@@ -52,6 +56,7 @@ export class EventLoopMonitor {
52
56
  this.eld.enable()
53
57
 
54
58
  this.lastElu = performance.eventLoopUtilization()
59
+ this.lastReportedAt = performance.now()
55
60
 
56
61
  this.po = new PerformanceObserver(list => {
57
62
  for (const entry of list.getEntries()) {
@@ -71,12 +76,19 @@ export class EventLoopMonitor {
71
76
  const max = Math.round(eld.max / 1e6)
72
77
  const mean = Math.round(eld.mean / 1e6)
73
78
 
79
+ // Actual wall-clock length of this interval, as opposed to the nominal `measureInterval`.
80
+ // Timers fire late when the process is starved of CPU, so this can be much longer.
81
+ // Floored at 1 to never divide by zero (only reachable with fake timers).
82
+ const now = performance.now()
83
+ const intervalMs = Math.max(1, Math.round(now - this.lastReportedAt))
84
+ this.lastReportedAt = now
85
+
74
86
  const currentElu = performance.eventLoopUtilization()
75
87
  const deltaElu = performance.eventLoopUtilization(this.lastElu, currentElu)
76
88
  this.lastElu = currentElu
77
89
 
78
90
  const elu = Math.round(deltaElu.utilization * 100)
79
- const gcCPU = Math.round((gcTotalTime / measureInterval) * 10_000)
91
+ const gcCPU = Math.round((gcTotalTime / intervalMs) * 10_000)
80
92
 
81
93
  this.lastStats = {
82
94
  p50,
@@ -88,6 +100,7 @@ export class EventLoopMonitor {
88
100
  gcCount,
89
101
  gcTotalTime,
90
102
  gcCPU,
103
+ intervalMs,
91
104
  }
92
105
 
93
106
  cfg.onStats?.(this.lastStats)
@@ -101,6 +114,8 @@ export class EventLoopMonitor {
101
114
  private interval: NodeJS.Timeout
102
115
  private eld: ELDHistogram
103
116
  private lastElu: EventLoopUtilization
117
+ private lastReportedAt: NumberOfMilliseconds
118
+ private paused = false
104
119
  /**
105
120
  * Undefined until the first interval has completed.
106
121
  */
@@ -115,6 +130,31 @@ export class EventLoopMonitor {
115
130
  this.po.disconnect()
116
131
  }
117
132
 
133
+ /**
134
+ * Stops sampling event loop delay until `resume()` is called.
135
+ * GC and ELU keep being tracked, and the samples recorded so far are kept.
136
+ *
137
+ * Meant for periods when the process is known to be CPU-throttled, e.g. a CloudRun instance
138
+ * (request-based billing) with no request in flight: a delay recorded there measures
139
+ * the throttling, not the application.
140
+ */
141
+ pause(): void {
142
+ this.paused = true
143
+ this.eld.disable()
144
+ }
145
+
146
+ /**
147
+ * Resumes sampling event loop delay after `pause()`.
148
+ * Safe to call from within the event that ended the throttled period (e.g. the middleware of
149
+ * the request that woke the instance): the histogram restarts its clock from this moment,
150
+ * so the stall that preceded it in the same loop iteration is not sampled.
151
+ */
152
+ resume(): void {
153
+ if (!this.paused) return
154
+ this.paused = false
155
+ this.eld.enable()
156
+ }
157
+
118
158
  // cfg: Required<EventLoopMonitorCfg>
119
159
  }
120
160
 
@@ -146,15 +186,15 @@ export interface EventLoopStats {
146
186
  */
147
187
  elu: NumberOfPercent
148
188
  /**
149
- * Number of times gc ran during the last `measureInterval`
189
+ * Number of times gc ran during the last interval
150
190
  */
151
191
  gcCount: NonNegativeInteger
152
192
  /**
153
- * Total time spent on gc during the last `measureInterval`
193
+ * Total time spent on gc during the last interval
154
194
  */
155
195
  gcTotalTime: NumberOfMilliseconds
156
196
  /**
157
- * CPU time spent on GC in the last `measureInterval`.
197
+ * CPU time spent on GC in the last interval, relative to `intervalMs`.
158
198
  * In "basis points", 100 basis points == 1%.
159
199
  * E.g 125 means 1.25% of CPU spent on GC.
160
200
  * It's reported in BPS to be able to do more accurate aggregate reporting
@@ -162,4 +202,12 @@ export interface EventLoopStats {
162
202
  * too "lossy")
163
203
  */
164
204
  gcCPU: Integer
205
+ /**
206
+ * Actual wall-clock duration of the interval covered by these stats.
207
+ * Normally equals `measureInterval`, but is longer when the timer fired late,
208
+ * e.g. on a CloudRun instance that was CPU-throttled while idle (request-based billing).
209
+ * An interval much longer than `measureInterval` means the delay stats are dominated
210
+ * by that throttling rather than by the application.
211
+ */
212
+ intervalMs: NumberOfMilliseconds
165
213
  }