@constructive-io/express-context 0.26.1 → 0.27.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.
@@ -0,0 +1,195 @@
1
+ "use strict";
2
+ /**
3
+ * usage-counter — the shared in-memory count + batch-flush mechanism.
4
+ *
5
+ * Two classes, neither of which knows what is being counted:
6
+ *
7
+ * - `BoundedCounter<K>` is the map. Consumers pass the key type, how a key
8
+ * serialises (`keyOf`) and where a key folds to once the map is full
9
+ * (`overflowKey`). `increment` is O(1), synchronous and never throws, so a
10
+ * request path can call it while refusing traffic without ever waiting on,
11
+ * or being affected by, whatever eventually stores the counts.
12
+ * - `CounterFlusher<K>` is the loop. It drains the counter on a jittered
13
+ * interval, hands the batch to a `sink`, and owns the failure policy: a
14
+ * batch the sink cannot write is reported through `onError` *and dropped*.
15
+ * Re-queuing under a broken sink is how a bounded counter becomes an
16
+ * unbounded one, and the counter is already accumulating the next window.
17
+ *
18
+ * Refusal observability instantiates these with a refusal key and a sink that
19
+ * calls `constructive_usage_private.record_refusals` (see `refusals.ts`); the
20
+ * page/request metering lane instantiates the same two classes with its own
21
+ * `K`, `keyOf`, `overflowKey`, bound, interval and sink. The seam is the
22
+ * constructor arguments — nothing in this file is specific to either.
23
+ *
24
+ * Counters live in process memory only. A crash loses at most one interval's
25
+ * worth of counts; `stop()` flushes on graceful shutdown. That is deliberate:
26
+ * the alternative is a durable write per event, which is the thing counting in
27
+ * memory exists to avoid.
28
+ *
29
+ * @module usage-counter
30
+ */
31
+ Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.CounterFlusher = exports.BoundedCounter = void 0;
33
+ class BoundedCounter {
34
+ entries = new Map();
35
+ overflowedSinceDrain = 0;
36
+ maxKeys;
37
+ keyOf;
38
+ overflowKey;
39
+ constructor(opts) {
40
+ if (!Number.isInteger(opts.maxKeys) || opts.maxKeys < 1) {
41
+ throw new Error(`BoundedCounter: maxKeys must be a positive integer, got ${opts.maxKeys}`);
42
+ }
43
+ this.maxKeys = opts.maxKeys;
44
+ this.keyOf = opts.keyOf;
45
+ this.overflowKey = opts.overflowKey;
46
+ }
47
+ /** Distinct keys currently held. */
48
+ get size() {
49
+ return this.entries.size;
50
+ }
51
+ /** Increments folded into an overflow key since the last drain. */
52
+ get overflowed() {
53
+ return this.overflowedSinceDrain;
54
+ }
55
+ /**
56
+ * Add `by` to `key`. Synchronous, O(1), never throws for a well-formed
57
+ * `keyOf`/`overflowKey`; the caller's request path must not depend on it in
58
+ * any other way.
59
+ */
60
+ increment(key, by = 1, at = Date.now()) {
61
+ let id = this.keyOf(key);
62
+ let entry = this.entries.get(id);
63
+ if (!entry && this.entries.size >= this.maxKeys) {
64
+ key = this.overflowKey(key);
65
+ id = this.keyOf(key);
66
+ entry = this.entries.get(id);
67
+ this.overflowedSinceDrain += by;
68
+ }
69
+ if (!entry) {
70
+ this.entries.set(id, { key, count: by, firstAt: at, lastAt: at });
71
+ return;
72
+ }
73
+ entry.count += by;
74
+ if (at < entry.firstAt)
75
+ entry.firstAt = at;
76
+ if (at > entry.lastAt)
77
+ entry.lastAt = at;
78
+ }
79
+ /**
80
+ * Return every entry and start a fresh window. The returned array is the
81
+ * only reference to those entries, so a slow sink never contends with the
82
+ * hot path.
83
+ */
84
+ drain() {
85
+ const drained = Array.from(this.entries.values());
86
+ this.entries.clear();
87
+ this.overflowedSinceDrain = 0;
88
+ return drained;
89
+ }
90
+ }
91
+ exports.BoundedCounter = BoundedCounter;
92
+ class CounterFlusher {
93
+ counter;
94
+ sink;
95
+ opts;
96
+ timer = null;
97
+ inFlight = null;
98
+ running = false;
99
+ lastFlushAt = null;
100
+ lastErrorAt = null;
101
+ lastError = null;
102
+ consecutiveFailures = 0;
103
+ droppedEntries = 0;
104
+ constructor(counter, sink, opts) {
105
+ this.counter = counter;
106
+ this.sink = sink;
107
+ this.opts = opts;
108
+ if (!(opts.intervalMs > 0)) {
109
+ throw new Error(`CounterFlusher: intervalMs must be positive, got ${opts.intervalMs}`);
110
+ }
111
+ if (!(opts.jitterMs >= 0)) {
112
+ throw new Error(`CounterFlusher: jitterMs must be non-negative, got ${opts.jitterMs}`);
113
+ }
114
+ }
115
+ /** Arm the timer. Idempotent. The timer is unref'd so it never pins the process. */
116
+ start() {
117
+ if (this.running)
118
+ return;
119
+ this.running = true;
120
+ this.arm();
121
+ }
122
+ /** Disarm the timer and flush whatever is pending. Idempotent. */
123
+ async stop() {
124
+ this.running = false;
125
+ if (this.timer) {
126
+ clearTimeout(this.timer);
127
+ this.timer = null;
128
+ }
129
+ await this.flush();
130
+ }
131
+ /**
132
+ * Drain and write now. Never rejects: a sink failure goes to `onError`. If a
133
+ * flush is already in flight the call waits for it and then runs its own, so
134
+ * two overlapping timers cannot send batches out of order.
135
+ */
136
+ async flush() {
137
+ if (this.inFlight)
138
+ await this.inFlight;
139
+ const run = this.flushOnce();
140
+ this.inFlight = run;
141
+ try {
142
+ await run;
143
+ }
144
+ finally {
145
+ if (this.inFlight === run)
146
+ this.inFlight = null;
147
+ }
148
+ }
149
+ stats() {
150
+ return {
151
+ lastFlushAt: this.lastFlushAt,
152
+ lastErrorAt: this.lastErrorAt,
153
+ lastError: this.lastError,
154
+ consecutiveFailures: this.consecutiveFailures,
155
+ droppedEntries: this.droppedEntries,
156
+ running: this.running
157
+ };
158
+ }
159
+ arm() {
160
+ const delay = this.opts.intervalMs + Math.floor(Math.random() * (this.opts.jitterMs + 1));
161
+ this.timer = setTimeout(() => {
162
+ this.timer = null;
163
+ void this.flush().finally(() => {
164
+ if (this.running)
165
+ this.arm();
166
+ });
167
+ }, delay);
168
+ this.timer.unref?.();
169
+ }
170
+ async flushOnce() {
171
+ const entries = this.counter.drain();
172
+ if (entries.length === 0)
173
+ return;
174
+ try {
175
+ await this.sink(entries);
176
+ this.lastFlushAt = Date.now();
177
+ this.consecutiveFailures = 0;
178
+ }
179
+ catch (err) {
180
+ this.lastErrorAt = Date.now();
181
+ this.lastError = err instanceof Error ? err.message : String(err);
182
+ this.consecutiveFailures += 1;
183
+ this.droppedEntries += entries.length;
184
+ // The batch is gone whether or not onError itself misbehaves; a
185
+ // reporter that throws must not turn one lost window into a stuck loop.
186
+ try {
187
+ this.opts.onError(err, entries);
188
+ }
189
+ catch {
190
+ // Fire-and-forget by design: the failure is already recorded in stats().
191
+ }
192
+ }
193
+ }
194
+ }
195
+ exports.CounterFlusher = CounterFlusher;