@constructive-io/express-context 0.26.2 → 0.28.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/admission.d.ts +146 -0
- package/admission.js +249 -0
- package/esm/admission.js +242 -0
- package/esm/index.js +3 -0
- package/esm/pg-settings.js +11 -0
- package/esm/refusals.js +214 -0
- package/esm/usage-counter.js +190 -0
- package/index.d.ts +6 -0
- package/index.js +23 -1
- package/package.json +6 -6
- package/pg-settings.js +11 -0
- package/refusals.d.ts +154 -0
- package/refusals.js +223 -0
- package/request-protection.d.ts +4 -4
- package/types.d.ts +6 -0
- package/usage-counter.d.ts +137 -0
- package/usage-counter.js +195 -0
package/refusals.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* refusals — the refusal-observability instantiation of `usage-counter`.
|
|
4
|
+
*
|
|
5
|
+
* Every refusal the platform issues (a 429 from admission control, a 413 for an
|
|
6
|
+
* oversized body, a document-gate rejection, a gateway's route or IP window)
|
|
7
|
+
* is one `RefusalRecorder.record(...)` call at the point the response is
|
|
8
|
+
* written. That call is an in-memory increment and nothing else — no promise,
|
|
9
|
+
* no pool, no allocation beyond the key — so a flood costs a counter bump and
|
|
10
|
+
* refusing traffic keeps working when the recorder's store is down.
|
|
11
|
+
*
|
|
12
|
+
* Counts are keyed by `(minute, database, lane, reason, route, source /24)`,
|
|
13
|
+
* which is exactly the set of questions the platform table answers: which
|
|
14
|
+
* tenant, why, on which route, from which network, since when. Raw addresses
|
|
15
|
+
* are never part of the key; `sourceBucket` truncates to a /24 (v4) or /48
|
|
16
|
+
* (v6) before anything is counted.
|
|
17
|
+
*
|
|
18
|
+
* The flush writes the drained batch as one `record_refusals(jsonb)` call —
|
|
19
|
+
* one statement per window regardless of how many keys it holds.
|
|
20
|
+
*
|
|
21
|
+
* @module refusals
|
|
22
|
+
*/
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.createRecordRefusalsSink = exports.RefusalRecorder = exports.DEFAULT_REFUSAL_MAX_KEYS = exports.DEFAULT_REFUSAL_FLUSH_JITTER_MS = exports.DEFAULT_REFUSAL_FLUSH_INTERVAL_MS = exports.refusalRows = exports.refusalOverflowKey = exports.refusalKeyOf = exports.sourceBucket = exports.OVERFLOW_ROUTE = exports.OVERFLOW_SOURCE = exports.UNKNOWN_SOURCE = exports.REFUSAL_REASONS = void 0;
|
|
25
|
+
const logger_1 = require("@pgpmjs/logger");
|
|
26
|
+
const pg_query_context_1 = require("pg-query-context");
|
|
27
|
+
const usage_counter_1 = require("./usage-counter");
|
|
28
|
+
const log = new logger_1.Logger('refusals');
|
|
29
|
+
exports.REFUSAL_REASONS = [
|
|
30
|
+
'rate_limited',
|
|
31
|
+
'concurrency_saturated',
|
|
32
|
+
'queue_timeout',
|
|
33
|
+
'request_too_large',
|
|
34
|
+
'query_too_deep',
|
|
35
|
+
'query_too_costly',
|
|
36
|
+
'page_size_too_large',
|
|
37
|
+
'anonymous_not_callable',
|
|
38
|
+
'route_rate_limited'
|
|
39
|
+
];
|
|
40
|
+
/** `source_bucket` when the lane could not resolve an address. */
|
|
41
|
+
exports.UNKNOWN_SOURCE = 'unknown';
|
|
42
|
+
/** `source_bucket` / `route_key` of the fold-down entry once the counter is full. */
|
|
43
|
+
exports.OVERFLOW_SOURCE = 'overflow';
|
|
44
|
+
exports.OVERFLOW_ROUTE = '*';
|
|
45
|
+
const MINUTE_MS = 60_000;
|
|
46
|
+
const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
|
47
|
+
/**
|
|
48
|
+
* Reduce a client address to a network prefix: `/24` for IPv4, `/48` for IPv6.
|
|
49
|
+
* Enough to tell "one network hammering an anonymous route" from "the tenant's
|
|
50
|
+
* own users"; not enough to be a person. Anything unparseable is `unknown`.
|
|
51
|
+
*/
|
|
52
|
+
const sourceBucket = (ip) => {
|
|
53
|
+
if (!ip)
|
|
54
|
+
return exports.UNKNOWN_SOURCE;
|
|
55
|
+
let addr = ip.trim();
|
|
56
|
+
if (addr.startsWith('[')) {
|
|
57
|
+
const end = addr.indexOf(']');
|
|
58
|
+
if (end === -1)
|
|
59
|
+
return exports.UNKNOWN_SOURCE;
|
|
60
|
+
addr = addr.slice(1, end);
|
|
61
|
+
}
|
|
62
|
+
const zone = addr.indexOf('%');
|
|
63
|
+
if (zone !== -1)
|
|
64
|
+
addr = addr.slice(0, zone);
|
|
65
|
+
if (addr.toLowerCase().startsWith('::ffff:')) {
|
|
66
|
+
const mapped = addr.slice('::ffff:'.length);
|
|
67
|
+
if (IPV4.test(mapped))
|
|
68
|
+
addr = mapped;
|
|
69
|
+
}
|
|
70
|
+
const v4 = IPV4.exec(addr);
|
|
71
|
+
if (v4) {
|
|
72
|
+
const octets = v4.slice(1, 5).map(Number);
|
|
73
|
+
if (octets.some((o) => o > 255))
|
|
74
|
+
return exports.UNKNOWN_SOURCE;
|
|
75
|
+
return `${octets[0]}.${octets[1]}.${octets[2]}.0/24`;
|
|
76
|
+
}
|
|
77
|
+
const groups = expandIpv6(addr);
|
|
78
|
+
if (!groups)
|
|
79
|
+
return exports.UNKNOWN_SOURCE;
|
|
80
|
+
return `${groups[0]}:${groups[1]}:${groups[2]}::/48`;
|
|
81
|
+
};
|
|
82
|
+
exports.sourceBucket = sourceBucket;
|
|
83
|
+
/** The eight hextets of an IPv6 address, or null when it does not parse. */
|
|
84
|
+
const expandIpv6 = (addr) => {
|
|
85
|
+
if (!/^[0-9a-fA-F:]+$/.test(addr) || addr.indexOf(':::') !== -1)
|
|
86
|
+
return null;
|
|
87
|
+
const halves = addr.split('::');
|
|
88
|
+
if (halves.length > 2)
|
|
89
|
+
return null;
|
|
90
|
+
const head = halves[0] ? halves[0].split(':') : [];
|
|
91
|
+
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
92
|
+
const missing = 8 - head.length - tail.length;
|
|
93
|
+
if (halves.length === 2 ? missing < 1 : missing !== 0)
|
|
94
|
+
return null;
|
|
95
|
+
const groups = [...head, ...Array(missing).fill('0'), ...tail];
|
|
96
|
+
if (groups.some((g) => g.length === 0 || g.length > 4))
|
|
97
|
+
return null;
|
|
98
|
+
return groups.map((g) => parseInt(g, 16).toString(16));
|
|
99
|
+
};
|
|
100
|
+
const minuteOf = (at) => Math.floor(at / MINUTE_MS) * MINUTE_MS;
|
|
101
|
+
const refusalKeyOf = (key) => `${key.minuteBucket}\u0000${key.databaseId ?? ''}\u0000${key.lane}\u0000${key.reason}\u0000${key.routeKey}\u0000${key.sourceBucket}`;
|
|
102
|
+
exports.refusalKeyOf = refusalKeyOf;
|
|
103
|
+
/**
|
|
104
|
+
* Where a key folds to once the counter is full: same minute, tenant, lane and
|
|
105
|
+
* reason; route and source collapsed. A source-spraying flood therefore costs
|
|
106
|
+
* `maxKeys` entries plus one per `(database, lane, reason)`, the tenant/reason
|
|
107
|
+
* attribution is never lost, and the presence of an `overflow` row is itself
|
|
108
|
+
* the signal that a spray happened.
|
|
109
|
+
*/
|
|
110
|
+
const refusalOverflowKey = (key) => ({
|
|
111
|
+
...key,
|
|
112
|
+
routeKey: exports.OVERFLOW_ROUTE,
|
|
113
|
+
sourceBucket: exports.OVERFLOW_SOURCE
|
|
114
|
+
});
|
|
115
|
+
exports.refusalOverflowKey = refusalOverflowKey;
|
|
116
|
+
/** Shape a drained batch for `record_refusals(jsonb)`. */
|
|
117
|
+
const refusalRows = (entries) => entries.map(({ key, count, firstAt, lastAt }) => ({
|
|
118
|
+
minute_bucket: new Date(key.minuteBucket).toISOString(),
|
|
119
|
+
database_id: key.databaseId,
|
|
120
|
+
lane: key.lane,
|
|
121
|
+
reason: key.reason,
|
|
122
|
+
route_key: key.routeKey,
|
|
123
|
+
source_bucket: key.sourceBucket,
|
|
124
|
+
count,
|
|
125
|
+
first_seen_at: new Date(firstAt).toISOString(),
|
|
126
|
+
last_seen_at: new Date(lastAt).toISOString()
|
|
127
|
+
}));
|
|
128
|
+
exports.refusalRows = refusalRows;
|
|
129
|
+
exports.DEFAULT_REFUSAL_FLUSH_INTERVAL_MS = 10_000;
|
|
130
|
+
exports.DEFAULT_REFUSAL_FLUSH_JITTER_MS = 2_000;
|
|
131
|
+
exports.DEFAULT_REFUSAL_MAX_KEYS = 2_000;
|
|
132
|
+
const defaultOnError = (err, dropped) => {
|
|
133
|
+
const refusals = dropped.reduce((sum, e) => sum + e.count, 0);
|
|
134
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
135
|
+
log.error(`[refusals] flush failed; dropped ${refusals} refusals across ${dropped.length} keys: ${message}`);
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* `BoundedCounter<RefusalKey>` + `CounterFlusher` + a `record_refusals` sink.
|
|
139
|
+
* Construct one per process, `start()` it, `record()` from every refusal site,
|
|
140
|
+
* `stop()` on shutdown.
|
|
141
|
+
*/
|
|
142
|
+
class RefusalRecorder {
|
|
143
|
+
counter;
|
|
144
|
+
flusher;
|
|
145
|
+
recordFailures = 0;
|
|
146
|
+
constructor(opts) {
|
|
147
|
+
this.counter = new usage_counter_1.BoundedCounter({
|
|
148
|
+
maxKeys: opts.maxKeys ?? exports.DEFAULT_REFUSAL_MAX_KEYS,
|
|
149
|
+
keyOf: exports.refusalKeyOf,
|
|
150
|
+
overflowKey: exports.refusalOverflowKey
|
|
151
|
+
});
|
|
152
|
+
this.flusher = new usage_counter_1.CounterFlusher(this.counter, (entries) => opts.sink((0, exports.refusalRows)(entries)), {
|
|
153
|
+
intervalMs: opts.intervalMs ?? exports.DEFAULT_REFUSAL_FLUSH_INTERVAL_MS,
|
|
154
|
+
jitterMs: opts.jitterMs ?? exports.DEFAULT_REFUSAL_FLUSH_JITTER_MS,
|
|
155
|
+
onError: opts.onError ?? defaultOnError
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Count one refusal. Synchronous and never throws: the request that is
|
|
160
|
+
* being refused must finish being refused whatever state the recorder is in.
|
|
161
|
+
*/
|
|
162
|
+
record(refusal) {
|
|
163
|
+
try {
|
|
164
|
+
const at = refusal.at ?? Date.now();
|
|
165
|
+
this.counter.increment({
|
|
166
|
+
minuteBucket: minuteOf(at),
|
|
167
|
+
databaseId: refusal.databaseId,
|
|
168
|
+
lane: refusal.lane,
|
|
169
|
+
reason: refusal.reason,
|
|
170
|
+
routeKey: refusal.routeKey,
|
|
171
|
+
sourceBucket: (0, exports.sourceBucket)(refusal.sourceIp)
|
|
172
|
+
}, 1, at);
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
this.recordFailures += 1;
|
|
176
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
177
|
+
log.error(`[refusals] record failed (refusal still served): ${message}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
start() {
|
|
181
|
+
this.flusher.start();
|
|
182
|
+
}
|
|
183
|
+
/** Final flush, then stop. */
|
|
184
|
+
stop() {
|
|
185
|
+
return this.flusher.stop();
|
|
186
|
+
}
|
|
187
|
+
/** Drain and write now; a sink failure is reported, never thrown. */
|
|
188
|
+
flush() {
|
|
189
|
+
return this.flusher.flush();
|
|
190
|
+
}
|
|
191
|
+
stats() {
|
|
192
|
+
return {
|
|
193
|
+
keys: this.counter.size,
|
|
194
|
+
overflowed: this.counter.overflowed,
|
|
195
|
+
recordFailures: this.recordFailures,
|
|
196
|
+
flusher: this.flusher.stats()
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
exports.RefusalRecorder = RefusalRecorder;
|
|
201
|
+
const FUNCTION_NAME = /^[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*$/;
|
|
202
|
+
/**
|
|
203
|
+
* A sink that writes a batch as one `record_refusals(jsonb)` call inside a
|
|
204
|
+
* transaction carrying the caller's claims. Claims are resolved per flush so a
|
|
205
|
+
* pool that was unreachable at startup does not leave the recorder wedged.
|
|
206
|
+
*/
|
|
207
|
+
const createRecordRefusalsSink = (opts) => {
|
|
208
|
+
const fn = opts.functionName ?? 'constructive_usage_private.record_refusals';
|
|
209
|
+
if (!FUNCTION_NAME.test(fn)) {
|
|
210
|
+
throw new Error(`createRecordRefusalsSink: invalid function name ${JSON.stringify(fn)}`);
|
|
211
|
+
}
|
|
212
|
+
const [schema, name] = fn.split('.');
|
|
213
|
+
const sql = `SELECT "${schema}"."${name}"($1::jsonb) AS recorded`;
|
|
214
|
+
return async (rows) => {
|
|
215
|
+
if (rows.length === 0)
|
|
216
|
+
return;
|
|
217
|
+
const claims = await opts.claims();
|
|
218
|
+
await (0, pg_query_context_1.withPgClient)(opts.pool, claims, async (client) => {
|
|
219
|
+
await client.query(sql, [JSON.stringify(rows)]);
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
};
|
|
223
|
+
exports.createRecordRefusalsSink = createRecordRefusalsSink;
|
package/request-protection.d.ts
CHANGED
|
@@ -23,13 +23,13 @@ export interface RequestProtection {
|
|
|
23
23
|
idleInTransactionTimeoutMs: number;
|
|
24
24
|
/** `lock_timeout` for the request's transaction. */
|
|
25
25
|
lockTimeoutMs: number;
|
|
26
|
-
/** In-flight requests
|
|
26
|
+
/** In-flight requests admitted per database, per replica. */
|
|
27
27
|
maxConcurrentRequests: number;
|
|
28
|
-
/** How long a request may wait for a concurrency slot
|
|
28
|
+
/** How long a request may wait for a concurrency slot. `0` never queues. */
|
|
29
29
|
maxQueueWaitMs: number;
|
|
30
|
-
/** Sustained request rate
|
|
30
|
+
/** Sustained request rate allowed one caller on one route. */
|
|
31
31
|
rateLimitRpm: number;
|
|
32
|
-
/** Burst allowance above `rateLimitRpm`
|
|
32
|
+
/** Burst allowance above `rateLimitRpm` within the same window. */
|
|
33
33
|
rateLimitBurst: number;
|
|
34
34
|
/** Deepest selection set accepted in an operation. */
|
|
35
35
|
maxQueryDepth: number;
|
package/types.d.ts
CHANGED
|
@@ -152,6 +152,12 @@ export type ConstructiveAPIToken = {
|
|
|
152
152
|
session_id?: string;
|
|
153
153
|
access_level?: string;
|
|
154
154
|
kind?: string;
|
|
155
|
+
/** Session lineage: the top of this session's exchange chain. */
|
|
156
|
+
root_session_id?: string;
|
|
157
|
+
/** Session lineage: the session this one was exchanged from, if any. */
|
|
158
|
+
parent_session_id?: string;
|
|
159
|
+
/** Caller-declared purpose recorded on the credential at exchange time. */
|
|
160
|
+
intent?: string;
|
|
155
161
|
[key: string]: unknown;
|
|
156
162
|
};
|
|
157
163
|
export interface BillingConfig {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usage-counter — the shared in-memory count + batch-flush mechanism.
|
|
3
|
+
*
|
|
4
|
+
* Two classes, neither of which knows what is being counted:
|
|
5
|
+
*
|
|
6
|
+
* - `BoundedCounter<K>` is the map. Consumers pass the key type, how a key
|
|
7
|
+
* serialises (`keyOf`) and where a key folds to once the map is full
|
|
8
|
+
* (`overflowKey`). `increment` is O(1), synchronous and never throws, so a
|
|
9
|
+
* request path can call it while refusing traffic without ever waiting on,
|
|
10
|
+
* or being affected by, whatever eventually stores the counts.
|
|
11
|
+
* - `CounterFlusher<K>` is the loop. It drains the counter on a jittered
|
|
12
|
+
* interval, hands the batch to a `sink`, and owns the failure policy: a
|
|
13
|
+
* batch the sink cannot write is reported through `onError` *and dropped*.
|
|
14
|
+
* Re-queuing under a broken sink is how a bounded counter becomes an
|
|
15
|
+
* unbounded one, and the counter is already accumulating the next window.
|
|
16
|
+
*
|
|
17
|
+
* Refusal observability instantiates these with a refusal key and a sink that
|
|
18
|
+
* calls `constructive_usage_private.record_refusals` (see `refusals.ts`); the
|
|
19
|
+
* page/request metering lane instantiates the same two classes with its own
|
|
20
|
+
* `K`, `keyOf`, `overflowKey`, bound, interval and sink. The seam is the
|
|
21
|
+
* constructor arguments — nothing in this file is specific to either.
|
|
22
|
+
*
|
|
23
|
+
* Counters live in process memory only. A crash loses at most one interval's
|
|
24
|
+
* worth of counts; `stop()` flushes on graceful shutdown. That is deliberate:
|
|
25
|
+
* the alternative is a durable write per event, which is the thing counting in
|
|
26
|
+
* memory exists to avoid.
|
|
27
|
+
*
|
|
28
|
+
* @module usage-counter
|
|
29
|
+
*/
|
|
30
|
+
export interface CounterEntry<K> {
|
|
31
|
+
key: K;
|
|
32
|
+
/** Sum of every `by` since this entry was created (or last drained). */
|
|
33
|
+
count: number;
|
|
34
|
+
/** Earliest `at` folded into this entry. */
|
|
35
|
+
firstAt: number;
|
|
36
|
+
/** Latest `at` folded into this entry. */
|
|
37
|
+
lastAt: number;
|
|
38
|
+
}
|
|
39
|
+
export interface BoundedCounterOptions<K> {
|
|
40
|
+
/**
|
|
41
|
+
* Distinct keys the counter will hold between drains. Past this, a *new* key
|
|
42
|
+
* is replaced by `overflowKey(key)` before it is counted; keys already
|
|
43
|
+
* present keep incrementing. Overflow keys are always admitted, so the true
|
|
44
|
+
* ceiling is `maxKeys` plus the number of distinct overflow keys the
|
|
45
|
+
* consumer's `overflowKey` can produce — keep that small.
|
|
46
|
+
*/
|
|
47
|
+
maxKeys: number;
|
|
48
|
+
/** Serialise a key. Two keys with the same string share one entry. */
|
|
49
|
+
keyOf: (key: K) => string;
|
|
50
|
+
/**
|
|
51
|
+
* Where a key folds to once the map is full. Should collapse the
|
|
52
|
+
* high-cardinality parts of the key (a source address, a route) while
|
|
53
|
+
* keeping the attribution the consumer cannot afford to lose.
|
|
54
|
+
*/
|
|
55
|
+
overflowKey: (key: K) => K;
|
|
56
|
+
}
|
|
57
|
+
export declare class BoundedCounter<K> {
|
|
58
|
+
private readonly entries;
|
|
59
|
+
private overflowedSinceDrain;
|
|
60
|
+
private readonly maxKeys;
|
|
61
|
+
private readonly keyOf;
|
|
62
|
+
private readonly overflowKey;
|
|
63
|
+
constructor(opts: BoundedCounterOptions<K>);
|
|
64
|
+
/** Distinct keys currently held. */
|
|
65
|
+
get size(): number;
|
|
66
|
+
/** Increments folded into an overflow key since the last drain. */
|
|
67
|
+
get overflowed(): number;
|
|
68
|
+
/**
|
|
69
|
+
* Add `by` to `key`. Synchronous, O(1), never throws for a well-formed
|
|
70
|
+
* `keyOf`/`overflowKey`; the caller's request path must not depend on it in
|
|
71
|
+
* any other way.
|
|
72
|
+
*/
|
|
73
|
+
increment(key: K, by?: number, at?: number): void;
|
|
74
|
+
/**
|
|
75
|
+
* Return every entry and start a fresh window. The returned array is the
|
|
76
|
+
* only reference to those entries, so a slow sink never contends with the
|
|
77
|
+
* hot path.
|
|
78
|
+
*/
|
|
79
|
+
drain(): CounterEntry<K>[];
|
|
80
|
+
}
|
|
81
|
+
export type CounterSink<K> = (entries: CounterEntry<K>[]) => Promise<void>;
|
|
82
|
+
export interface CounterFlusherOptions<K> {
|
|
83
|
+
/** Base time between flushes. */
|
|
84
|
+
intervalMs: number;
|
|
85
|
+
/**
|
|
86
|
+
* Uniform random offset added to each interval so replicas started together
|
|
87
|
+
* do not hit the store on the same tick.
|
|
88
|
+
*/
|
|
89
|
+
jitterMs: number;
|
|
90
|
+
/**
|
|
91
|
+
* Called for every failed flush with the batch that was dropped. Every
|
|
92
|
+
* failure is reported — there is no "log once then go quiet" — and the
|
|
93
|
+
* request path is not involved, so making this loud is free.
|
|
94
|
+
*/
|
|
95
|
+
onError: (err: unknown, dropped: CounterEntry<K>[]) => void;
|
|
96
|
+
}
|
|
97
|
+
export interface CounterFlusherStats {
|
|
98
|
+
/** Epoch ms of the last flush attempt that returned without error, or null. */
|
|
99
|
+
lastFlushAt: number | null;
|
|
100
|
+
/** Epoch ms of the last failed flush, or null. */
|
|
101
|
+
lastErrorAt: number | null;
|
|
102
|
+
/** Message of the last failed flush, or null. */
|
|
103
|
+
lastError: string | null;
|
|
104
|
+
/** Failed flushes since the last success. */
|
|
105
|
+
consecutiveFailures: number;
|
|
106
|
+
/** Entries handed to `onError` over the flusher's lifetime. */
|
|
107
|
+
droppedEntries: number;
|
|
108
|
+
/** Whether the timer is armed. */
|
|
109
|
+
running: boolean;
|
|
110
|
+
}
|
|
111
|
+
export declare class CounterFlusher<K> {
|
|
112
|
+
private readonly counter;
|
|
113
|
+
private readonly sink;
|
|
114
|
+
private readonly opts;
|
|
115
|
+
private timer;
|
|
116
|
+
private inFlight;
|
|
117
|
+
private running;
|
|
118
|
+
private lastFlushAt;
|
|
119
|
+
private lastErrorAt;
|
|
120
|
+
private lastError;
|
|
121
|
+
private consecutiveFailures;
|
|
122
|
+
private droppedEntries;
|
|
123
|
+
constructor(counter: BoundedCounter<K>, sink: CounterSink<K>, opts: CounterFlusherOptions<K>);
|
|
124
|
+
/** Arm the timer. Idempotent. The timer is unref'd so it never pins the process. */
|
|
125
|
+
start(): void;
|
|
126
|
+
/** Disarm the timer and flush whatever is pending. Idempotent. */
|
|
127
|
+
stop(): Promise<void>;
|
|
128
|
+
/**
|
|
129
|
+
* Drain and write now. Never rejects: a sink failure goes to `onError`. If a
|
|
130
|
+
* flush is already in flight the call waits for it and then runs its own, so
|
|
131
|
+
* two overlapping timers cannot send batches out of order.
|
|
132
|
+
*/
|
|
133
|
+
flush(): Promise<void>;
|
|
134
|
+
stats(): CounterFlusherStats;
|
|
135
|
+
private arm;
|
|
136
|
+
private flushOnce;
|
|
137
|
+
}
|
package/usage-counter.js
ADDED
|
@@ -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;
|