hanzio 2.2.0 → 3.1.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/README.md +133 -7
- package/dist/api-wrapper/connection-auth.d.ts +61 -0
- package/dist/cli/fields.d.ts +30 -0
- package/dist/cli/index.d.ts +122 -0
- package/dist/format/index.d.ts +91 -0
- package/dist/format/phone.d.ts +19 -0
- package/dist/http/index.d.ts +14 -0
- package/dist/i18n/catalog.d.ts +47 -0
- package/dist/i18n/check.d.ts +45 -0
- package/dist/i18n/cli.d.ts +2 -0
- package/dist/i18n/content-rules.d.ts +55 -0
- package/dist/i18n/i18next.d.ts +39 -0
- package/dist/i18n/index.d.ts +16 -1
- package/dist/i18n/locales.d.ts +62 -0
- package/dist/i18n/vite.d.ts +16 -0
- package/dist/i18n/vue.d.ts +93 -0
- package/dist/{index-kj1phgrx.js → index-29mmh343.js} +24 -1
- package/dist/{index-zbj9s3s3.js → index-2kz392vw.js} +1 -1
- package/dist/index-5n2e326q.js +409 -0
- package/dist/{index-hy7kbb99.js → index-5tzqfmnj.js} +1 -1
- package/dist/{index-0zvtcbzy.js → index-6f2w90pp.js} +69 -1
- package/dist/index-6ragggbs.js +473 -0
- package/dist/index-mq2qgc20.js +116 -0
- package/dist/index-najbyzpr.js +99 -0
- package/dist/index-ww09bm49.js +250 -0
- package/dist/iso/countries.d.ts +262 -0
- package/dist/iso/currencies.d.ts +165 -0
- package/dist/iso/index.d.ts +4 -0
- package/dist/iso/languages.d.ts +195 -0
- package/dist/iso/phone-language.d.ts +5 -0
- package/dist/promise/index.d.ts +31 -0
- package/dist/redis/client.d.ts +49 -0
- package/dist/redis/concurrency-limit.d.ts +63 -0
- package/dist/redis/index.d.ts +3 -0
- package/dist/redis/lua-script.d.ts +13 -0
- package/dist/secrets/infisical-identity-file.d.ts +12 -0
- package/dist/src/api-wrapper/connection-auth.js +82 -0
- package/dist/src/api-wrapper/index.js +1 -1
- package/dist/src/cli/index.js +10 -0
- package/dist/src/format/index.js +79 -2
- package/dist/src/format/phone.js +21 -0
- package/dist/src/http/index.js +69 -0
- package/dist/src/i18n/check.js +15 -0
- package/dist/src/i18n/cli.js +71 -0
- package/dist/src/i18n/i18next.js +48 -0
- package/dist/src/i18n/index.js +16 -34
- package/dist/src/i18n/vite.js +25 -0
- package/dist/src/i18n/vue.js +51 -0
- package/dist/src/index.js +22 -6
- package/dist/src/iso/index.js +655 -0
- package/dist/src/redis/index.js +577 -0
- package/dist/src/secrets/index.js +78 -35
- package/dist/src/sitemap/index.js +1 -1
- package/dist/src/storage/index.js +8 -117
- package/dist/src/testing/index.js +348 -0
- package/dist/src/zod/index.js +20 -253
- package/dist/string/index.d.ts +21 -0
- package/dist/testing/connection-auth-contract.d.ts +43 -0
- package/dist/testing/helpers.d.ts +26 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/module-mocker.d.ts +30 -0
- package/dist/testing/prisma.d.ts +30 -0
- package/dist/testing/window.d.ts +18 -0
- package/dist/typedswitch/index.d.ts +55 -34
- package/dist/zod/index.d.ts +10 -0
- package/dist/zod/introspect.d.ts +2 -1
- package/package.json +97 -2
- package/src/api-wrapper/README.md +57 -2
- package/src/api-wrapper/SKILL.md +256 -0
- package/src/api-wrapper/connection-auth.ts +142 -0
- package/src/cli/README.md +220 -0
- package/src/cli/fields.ts +213 -0
- package/src/cli/index.ts +532 -0
- package/src/format/README.md +82 -2
- package/src/format/index.ts +148 -0
- package/src/format/phone.ts +43 -0
- package/src/http/README.md +25 -0
- package/src/http/index.ts +94 -0
- package/src/i18n/README.md +206 -3
- package/src/i18n/catalog.ts +139 -0
- package/src/i18n/check.ts +376 -0
- package/src/i18n/cli.ts +90 -0
- package/src/i18n/content-rules.ts +179 -0
- package/src/i18n/i18next.ts +89 -0
- package/src/i18n/index.ts +34 -0
- package/src/i18n/locales.ts +135 -0
- package/src/i18n/vite.ts +40 -0
- package/src/i18n/vue.ts +82 -0
- package/src/index.ts +1 -1
- package/src/iso/README.md +44 -0
- package/src/iso/countries.ts +272 -0
- package/src/iso/currencies.ts +172 -0
- package/src/iso/index.ts +8 -0
- package/src/iso/languages.ts +208 -0
- package/src/iso/phone-language.ts +46 -0
- package/src/promise/index.ts +100 -0
- package/src/redis/README.md +112 -0
- package/src/redis/client.ts +239 -0
- package/src/redis/concurrency-limit.ts +591 -0
- package/src/redis/index.ts +15 -0
- package/src/redis/lua-script.ts +43 -0
- package/src/secrets/README.md +5 -1
- package/src/secrets/infisical-identity-file.ts +69 -0
- package/src/secrets/infisical.ts +18 -3
- package/src/string/index.ts +59 -0
- package/src/testing/README.md +119 -0
- package/src/testing/connection-auth-contract.ts +303 -0
- package/src/testing/helpers.ts +41 -0
- package/src/testing/index.ts +12 -0
- package/src/testing/module-mocker.ts +50 -0
- package/src/testing/prisma.ts +78 -0
- package/src/testing/window.ts +119 -0
- package/src/typedswitch/README.md +55 -39
- package/src/typedswitch/index.ts +97 -83
- package/src/zod/index.ts +20 -0
- package/src/zod/introspect.ts +1 -1
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
// src/redis/client.ts
|
|
2
|
+
var CONNECTION_OPTIONS = {
|
|
3
|
+
autoReconnect: true,
|
|
4
|
+
maxRetries: 1e9,
|
|
5
|
+
enableOfflineQueue: true
|
|
6
|
+
};
|
|
7
|
+
function defaultCreateClient(url, options) {
|
|
8
|
+
return new Bun.RedisClient(url, options);
|
|
9
|
+
}
|
|
10
|
+
function defaultOnError(error, context) {
|
|
11
|
+
const channel = context.channel ? ` (${context.channel})` : "";
|
|
12
|
+
console.error(`Redis ${context.role} ${context.stage} failed${channel}`, error);
|
|
13
|
+
}
|
|
14
|
+
function createManagedRedis(options) {
|
|
15
|
+
const createClient = options.createClient ?? defaultCreateClient;
|
|
16
|
+
const report = options.onError ?? defaultOnError;
|
|
17
|
+
const url = () => typeof options.url === "function" ? options.url() : options.url;
|
|
18
|
+
let client = null;
|
|
19
|
+
let subscriber = null;
|
|
20
|
+
const handlers = new Map;
|
|
21
|
+
const listeners = new Map;
|
|
22
|
+
const subscribing = new Map;
|
|
23
|
+
function listenerFor(channel) {
|
|
24
|
+
let listener = listeners.get(channel);
|
|
25
|
+
if (!listener) {
|
|
26
|
+
listener = (message, messageChannel) => {
|
|
27
|
+
if (messageChannel !== channel)
|
|
28
|
+
return;
|
|
29
|
+
for (const entry of [...handlers.get(channel) ?? []]) {
|
|
30
|
+
try {
|
|
31
|
+
entry.handler(message, channel);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
report(error, { role: "subscriber", stage: "handler", channel });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
listeners.set(channel, listener);
|
|
38
|
+
}
|
|
39
|
+
return listener;
|
|
40
|
+
}
|
|
41
|
+
async function resubscribe(connection) {
|
|
42
|
+
for (const channel of handlers.keys()) {
|
|
43
|
+
try {
|
|
44
|
+
await connection.subscribe(channel, listenerFor(channel));
|
|
45
|
+
} catch (error) {
|
|
46
|
+
report(error, { role: "subscriber", stage: "resubscribe", channel });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function getSubscriber() {
|
|
51
|
+
if (subscriber)
|
|
52
|
+
return subscriber;
|
|
53
|
+
const connection = createClient(url(), CONNECTION_OPTIONS);
|
|
54
|
+
connection.onconnect = () => {
|
|
55
|
+
resubscribe(connection);
|
|
56
|
+
};
|
|
57
|
+
connection.onclose = (error) => {
|
|
58
|
+
if (subscriber !== connection)
|
|
59
|
+
return;
|
|
60
|
+
subscriber = null;
|
|
61
|
+
listeners.clear();
|
|
62
|
+
report(error, { role: "subscriber", stage: "close" });
|
|
63
|
+
if (handlers.size > 0)
|
|
64
|
+
getSubscriber();
|
|
65
|
+
};
|
|
66
|
+
subscriber = connection;
|
|
67
|
+
return connection;
|
|
68
|
+
}
|
|
69
|
+
function getClient() {
|
|
70
|
+
if (client)
|
|
71
|
+
return client;
|
|
72
|
+
const connection = createClient(url(), CONNECTION_OPTIONS);
|
|
73
|
+
connection.onclose = (error) => {
|
|
74
|
+
if (client !== connection)
|
|
75
|
+
return;
|
|
76
|
+
client = null;
|
|
77
|
+
if (error)
|
|
78
|
+
report(error, { role: "client", stage: "close" });
|
|
79
|
+
};
|
|
80
|
+
client = connection;
|
|
81
|
+
return connection;
|
|
82
|
+
}
|
|
83
|
+
async function subscribe(channel, handler) {
|
|
84
|
+
const entry = { handler };
|
|
85
|
+
let entries = handlers.get(channel);
|
|
86
|
+
if (!entries) {
|
|
87
|
+
entries = new Set;
|
|
88
|
+
handlers.set(channel, entries);
|
|
89
|
+
}
|
|
90
|
+
entries.add(entry);
|
|
91
|
+
let pending = subscribing.get(channel);
|
|
92
|
+
const first = !pending && entries.size === 1;
|
|
93
|
+
if (first) {
|
|
94
|
+
pending = getSubscriber().subscribe(channel, listenerFor(channel));
|
|
95
|
+
subscribing.set(channel, pending);
|
|
96
|
+
pending.catch(() => {}).finally(() => {
|
|
97
|
+
if (subscribing.get(channel) === pending)
|
|
98
|
+
subscribing.delete(channel);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
await pending;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
entries.delete(entry);
|
|
105
|
+
if (entries.size === 0 && handlers.get(channel) === entries) {
|
|
106
|
+
handlers.delete(channel);
|
|
107
|
+
listeners.delete(channel);
|
|
108
|
+
}
|
|
109
|
+
if (first)
|
|
110
|
+
report(error, { role: "subscriber", stage: "subscribe", channel });
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
let left = false;
|
|
114
|
+
return async () => {
|
|
115
|
+
if (left)
|
|
116
|
+
return;
|
|
117
|
+
left = true;
|
|
118
|
+
const current = handlers.get(channel);
|
|
119
|
+
if (!current?.delete(entry) || current.size > 0)
|
|
120
|
+
return;
|
|
121
|
+
handlers.delete(channel);
|
|
122
|
+
const listener = listeners.get(channel);
|
|
123
|
+
listeners.delete(channel);
|
|
124
|
+
const connection = subscriber;
|
|
125
|
+
try {
|
|
126
|
+
if (listener)
|
|
127
|
+
await connection?.unsubscribe(channel, listener);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (subscriber !== connection)
|
|
130
|
+
return;
|
|
131
|
+
report(error, { role: "subscriber", stage: "unsubscribe", channel });
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function close() {
|
|
136
|
+
const connections = [client, subscriber];
|
|
137
|
+
client = null;
|
|
138
|
+
subscriber = null;
|
|
139
|
+
handlers.clear();
|
|
140
|
+
listeners.clear();
|
|
141
|
+
subscribing.clear();
|
|
142
|
+
for (const connection of connections) {
|
|
143
|
+
try {
|
|
144
|
+
connection?.close();
|
|
145
|
+
} catch {}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
getClient,
|
|
150
|
+
subscribe,
|
|
151
|
+
isConnected: () => client?.connected === true,
|
|
152
|
+
getStats: () => ({
|
|
153
|
+
channelCount: handlers.size,
|
|
154
|
+
channels: [...handlers.keys()],
|
|
155
|
+
isConnected: client?.connected === true,
|
|
156
|
+
isSubscriberConnected: subscriber?.connected === true
|
|
157
|
+
}),
|
|
158
|
+
close
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
// src/redis/lua-script.ts
|
|
162
|
+
function isNoScriptError(error) {
|
|
163
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
164
|
+
return message.includes("NOSCRIPT");
|
|
165
|
+
}
|
|
166
|
+
function createLuaScript(send, source) {
|
|
167
|
+
let sha = null;
|
|
168
|
+
let loading = null;
|
|
169
|
+
function load() {
|
|
170
|
+
loading ??= send("SCRIPT", ["LOAD", source]).then(String).finally(() => {
|
|
171
|
+
loading = null;
|
|
172
|
+
});
|
|
173
|
+
return loading;
|
|
174
|
+
}
|
|
175
|
+
return async function evalScript(keys, args) {
|
|
176
|
+
const tail = [String(keys.length), ...keys, ...args];
|
|
177
|
+
sha ??= await load();
|
|
178
|
+
try {
|
|
179
|
+
return await send("EVALSHA", [sha, ...tail]);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (!isNoScriptError(error))
|
|
182
|
+
throw error;
|
|
183
|
+
sha = null;
|
|
184
|
+
return await send("EVAL", [source, ...tail]);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/redis/concurrency-limit.ts
|
|
190
|
+
var POLL_MS = 100;
|
|
191
|
+
var FAILURE_LOG_THROTTLE_MS = 1e4;
|
|
192
|
+
var DEFAULT_FAIL_OPEN_AFTER_MS = 15000;
|
|
193
|
+
var DEFAULT_COMMAND_TIMEOUT_MS = 3000;
|
|
194
|
+
var START_WINDOW_MS = 1000;
|
|
195
|
+
var MAX_COOLDOWN_MS = 5 * 60 * 1000;
|
|
196
|
+
var BREAKER_PROBE_INTERVAL_MS = 5000;
|
|
197
|
+
var MAX_RENEW_BATCH = 200;
|
|
198
|
+
var NOW_MS_LUA = `
|
|
199
|
+
local time = redis.call('TIME')
|
|
200
|
+
local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
|
|
201
|
+
`;
|
|
202
|
+
var ACQUIRE_SCRIPT = `
|
|
203
|
+
local inflightKey = KEYS[1]
|
|
204
|
+
local cooldownKey = KEYS[2]
|
|
205
|
+
local limit = tonumber(ARGV[1])
|
|
206
|
+
local ttlMs = tonumber(ARGV[2])
|
|
207
|
+
local cooldownMs = redis.call('PTTL', cooldownKey)
|
|
208
|
+
if cooldownMs > 0 then
|
|
209
|
+
return {0, cooldownMs}
|
|
210
|
+
end
|
|
211
|
+
${NOW_MS_LUA}
|
|
212
|
+
redis.call('ZREMRANGEBYSCORE', inflightKey, '-inf', now)
|
|
213
|
+
local free = limit - redis.call('ZCARD', inflightKey)
|
|
214
|
+
local granted = 0
|
|
215
|
+
while granted < free and (granted + 3) <= #ARGV do
|
|
216
|
+
redis.call('ZADD', inflightKey, now + ttlMs, ARGV[granted + 3])
|
|
217
|
+
granted = granted + 1
|
|
218
|
+
end
|
|
219
|
+
if granted > 0 then
|
|
220
|
+
redis.call('PEXPIRE', inflightKey, ttlMs)
|
|
221
|
+
end
|
|
222
|
+
return {granted, 0}
|
|
223
|
+
`.trim();
|
|
224
|
+
var START_SCRIPT = `
|
|
225
|
+
local startsKey = KEYS[1]
|
|
226
|
+
local cooldownKey = KEYS[2]
|
|
227
|
+
local limit = tonumber(ARGV[1])
|
|
228
|
+
local windowMs = tonumber(ARGV[2])
|
|
229
|
+
local cooldownMs = redis.call('PTTL', cooldownKey)
|
|
230
|
+
if cooldownMs > 0 then
|
|
231
|
+
return {0, cooldownMs}
|
|
232
|
+
end
|
|
233
|
+
${NOW_MS_LUA}
|
|
234
|
+
redis.call('ZREMRANGEBYSCORE', startsKey, '-inf', now - windowMs)
|
|
235
|
+
local free = limit - redis.call('ZCARD', startsKey)
|
|
236
|
+
local granted = 0
|
|
237
|
+
while granted < free and (granted + 3) <= #ARGV do
|
|
238
|
+
redis.call('ZADD', startsKey, now, ARGV[granted + 3])
|
|
239
|
+
granted = granted + 1
|
|
240
|
+
end
|
|
241
|
+
if granted > 0 then
|
|
242
|
+
redis.call('PEXPIRE', startsKey, windowMs)
|
|
243
|
+
end
|
|
244
|
+
local waitMs = 0
|
|
245
|
+
if granted < (#ARGV - 2) then
|
|
246
|
+
local oldest = redis.call('ZRANGE', startsKey, 0, 0, 'WITHSCORES')
|
|
247
|
+
if oldest[2] then
|
|
248
|
+
waitMs = math.ceil(tonumber(oldest[2]) + windowMs - now)
|
|
249
|
+
if waitMs < 0 then
|
|
250
|
+
waitMs = 0
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
return {granted, waitMs}
|
|
255
|
+
`.trim();
|
|
256
|
+
var RENEW_SCRIPT = `
|
|
257
|
+
local key = KEYS[1]
|
|
258
|
+
local ttlMs = tonumber(ARGV[1])
|
|
259
|
+
${NOW_MS_LUA}
|
|
260
|
+
local renewed = 0
|
|
261
|
+
for i = 2, #ARGV do
|
|
262
|
+
if redis.call('ZSCORE', key, ARGV[i]) then
|
|
263
|
+
redis.call('ZADD', key, now + ttlMs, ARGV[i])
|
|
264
|
+
renewed = renewed + 1
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
if renewed > 0 then
|
|
268
|
+
redis.call('PEXPIRE', key, ttlMs)
|
|
269
|
+
end
|
|
270
|
+
return renewed
|
|
271
|
+
`.trim();
|
|
272
|
+
var TRIP_COOLDOWN_SCRIPT = `
|
|
273
|
+
local key = KEYS[1]
|
|
274
|
+
local waitMs = tonumber(ARGV[1])
|
|
275
|
+
local ttl = redis.call('PTTL', key)
|
|
276
|
+
if ttl < waitMs then
|
|
277
|
+
redis.call('SET', key, '1', 'PX', ARGV[1])
|
|
278
|
+
end
|
|
279
|
+
return 1
|
|
280
|
+
`.trim();
|
|
281
|
+
function withCommandTimeout(send, timeoutMs) {
|
|
282
|
+
return async (command, args) => {
|
|
283
|
+
let timer;
|
|
284
|
+
try {
|
|
285
|
+
return await Promise.race([
|
|
286
|
+
send(command, args),
|
|
287
|
+
new Promise((_, reject) => {
|
|
288
|
+
timer = setTimeout(() => reject(new Error(`Redis command timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
289
|
+
})
|
|
290
|
+
]);
|
|
291
|
+
} finally {
|
|
292
|
+
if (timer)
|
|
293
|
+
clearTimeout(timer);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function createToken() {
|
|
298
|
+
const pid = globalThis.process?.pid;
|
|
299
|
+
return pid === undefined ? crypto.randomUUID() : `${pid}:${crypto.randomUUID()}`;
|
|
300
|
+
}
|
|
301
|
+
function jitterMs() {
|
|
302
|
+
return Math.floor(Math.random() * POLL_MS);
|
|
303
|
+
}
|
|
304
|
+
function unref(timer) {
|
|
305
|
+
timer.unref?.();
|
|
306
|
+
}
|
|
307
|
+
function parseGrantReply(reply) {
|
|
308
|
+
const [granted, waitMs] = Array.isArray(reply) ? reply : [];
|
|
309
|
+
return {
|
|
310
|
+
granted: Math.max(0, Number(granted) || 0),
|
|
311
|
+
waitMs: Math.min(MAX_COOLDOWN_MS, Math.max(0, Number(waitMs) || 0))
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
function createRedisConcurrencyLimit(input) {
|
|
315
|
+
if (!Number.isInteger(input.max) || input.max < 1)
|
|
316
|
+
throw new Error(`max must be a positive integer, got ${input.max}`);
|
|
317
|
+
const logger = input.logger ?? console;
|
|
318
|
+
const commandTimeoutMs = input.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
319
|
+
const send = withCommandTimeout(input.send, commandTimeoutMs);
|
|
320
|
+
const ttlMs = String(input.slotTtlMs);
|
|
321
|
+
const renewEveryMs = Math.max(100, Math.floor(input.slotTtlMs / 3));
|
|
322
|
+
const failOpenAfterMs = input.failOpenAfterMs ?? DEFAULT_FAIL_OPEN_AFTER_MS;
|
|
323
|
+
const maxStartsPerSecond = input.maxStartsPerSecond;
|
|
324
|
+
const inflightKey = `{${input.key}}:inflight`;
|
|
325
|
+
const startsKey = `{${input.key}}:starts`;
|
|
326
|
+
const cooldownKey = `{${input.key}}:cooldown`;
|
|
327
|
+
const acquireScript = createLuaScript(send, ACQUIRE_SCRIPT);
|
|
328
|
+
const startScript = createLuaScript(send, START_SCRIPT);
|
|
329
|
+
const renewScript = createLuaScript(send, RENEW_SCRIPT);
|
|
330
|
+
const tripScript = createLuaScript(send, TRIP_COOLDOWN_SCRIPT);
|
|
331
|
+
const lastFailureLogAtMs = new Map;
|
|
332
|
+
function logFailure(message, error) {
|
|
333
|
+
const nowMs = Date.now();
|
|
334
|
+
if (nowMs - (lastFailureLogAtMs.get(message) ?? 0) < FAILURE_LOG_THROTTLE_MS)
|
|
335
|
+
return;
|
|
336
|
+
lastFailureLogAtMs.set(message, nowMs);
|
|
337
|
+
logger.warn(message, { key: input.key, error });
|
|
338
|
+
}
|
|
339
|
+
let firstFailureAtMs = null;
|
|
340
|
+
let degraded = false;
|
|
341
|
+
let probeTimer = null;
|
|
342
|
+
function scheduleProbe() {
|
|
343
|
+
if (probeTimer)
|
|
344
|
+
return;
|
|
345
|
+
probeTimer = setTimeout(() => {
|
|
346
|
+
probeTimer = null;
|
|
347
|
+
probeRedis();
|
|
348
|
+
}, BREAKER_PROBE_INTERVAL_MS);
|
|
349
|
+
unref(probeTimer);
|
|
350
|
+
}
|
|
351
|
+
async function probeRedis() {
|
|
352
|
+
try {
|
|
353
|
+
await acquireScript([inflightKey, cooldownKey], [String(input.max), ttlMs]);
|
|
354
|
+
noteRedisSuccess();
|
|
355
|
+
} catch (error) {
|
|
356
|
+
logFailure("Redis concurrency probe failed", error);
|
|
357
|
+
scheduleProbe();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function noteRedisSuccess() {
|
|
361
|
+
firstFailureAtMs = null;
|
|
362
|
+
if (!degraded)
|
|
363
|
+
return;
|
|
364
|
+
degraded = false;
|
|
365
|
+
if (probeTimer) {
|
|
366
|
+
clearTimeout(probeTimer);
|
|
367
|
+
probeTimer = null;
|
|
368
|
+
}
|
|
369
|
+
logger.warn("Redis reachable again, concurrency limiting re-enabled", {
|
|
370
|
+
key: input.key
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
function noteRedisFailure(error) {
|
|
374
|
+
logFailure("Redis concurrency command failed, retrying", error);
|
|
375
|
+
if (degraded)
|
|
376
|
+
return true;
|
|
377
|
+
firstFailureAtMs ??= Date.now();
|
|
378
|
+
if (Date.now() - firstFailureAtMs < failOpenAfterMs)
|
|
379
|
+
return false;
|
|
380
|
+
degraded = true;
|
|
381
|
+
scheduleProbe();
|
|
382
|
+
logger.warn("Redis unreachable, running without a concurrency slot", {
|
|
383
|
+
key: input.key
|
|
384
|
+
});
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
function createPump(stage, maxBatch, grant) {
|
|
388
|
+
const queue = [];
|
|
389
|
+
let running = false;
|
|
390
|
+
let wakeSleeper = null;
|
|
391
|
+
function sleep(ms) {
|
|
392
|
+
return new Promise((resolve) => {
|
|
393
|
+
const timer = setTimeout(() => {
|
|
394
|
+
wakeSleeper = null;
|
|
395
|
+
resolve();
|
|
396
|
+
}, ms);
|
|
397
|
+
wakeSleeper = () => {
|
|
398
|
+
wakeSleeper = null;
|
|
399
|
+
clearTimeout(timer);
|
|
400
|
+
resolve();
|
|
401
|
+
};
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
function settleAll(grantedSlot) {
|
|
405
|
+
for (const waiter of queue.splice(0))
|
|
406
|
+
waiter.settle(grantedSlot);
|
|
407
|
+
}
|
|
408
|
+
async function loop() {
|
|
409
|
+
running = true;
|
|
410
|
+
try {
|
|
411
|
+
await Promise.resolve();
|
|
412
|
+
while (queue.length > 0) {
|
|
413
|
+
let reply;
|
|
414
|
+
try {
|
|
415
|
+
reply = await grant(queue.slice(0, maxBatch).map((waiter) => waiter.token));
|
|
416
|
+
noteRedisSuccess();
|
|
417
|
+
} catch (error) {
|
|
418
|
+
if (noteRedisFailure(error)) {
|
|
419
|
+
settleAll(false);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
await sleep(POLL_MS + jitterMs());
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
for (const waiter of queue.splice(0, reply.granted))
|
|
426
|
+
waiter.settle(true);
|
|
427
|
+
if (queue.length === 0)
|
|
428
|
+
break;
|
|
429
|
+
await sleep((reply.waitMs || POLL_MS) + jitterMs());
|
|
430
|
+
}
|
|
431
|
+
} finally {
|
|
432
|
+
running = false;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
depth: () => queue.length,
|
|
437
|
+
acquire(token) {
|
|
438
|
+
const stopWait = input.onWait?.(stage);
|
|
439
|
+
const granted = new Promise((resolve) => {
|
|
440
|
+
queue.push({ token, settle: resolve });
|
|
441
|
+
});
|
|
442
|
+
if (!running)
|
|
443
|
+
loop();
|
|
444
|
+
return stopWait ? granted.finally(stopWait) : granted;
|
|
445
|
+
},
|
|
446
|
+
wake() {
|
|
447
|
+
wakeSleeper?.();
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
const slotPump = createPump("slot", input.max, async (tokens) => parseGrantReply(await acquireScript([inflightKey, cooldownKey], [String(input.max), ttlMs, ...tokens])));
|
|
452
|
+
const startPump = maxStartsPerSecond === undefined ? null : createPump("start", maxStartsPerSecond, async (tokens) => parseGrantReply(await startScript([startsKey, cooldownKey], [String(maxStartsPerSecond), String(START_WINDOW_MS), ...tokens])));
|
|
453
|
+
const queueDepth = () => slotPump.depth() + (startPump?.depth() ?? 0);
|
|
454
|
+
const heldTokens = new Set;
|
|
455
|
+
let heartbeatTimer = null;
|
|
456
|
+
function scheduleHeartbeat() {
|
|
457
|
+
if (heartbeatTimer || heldTokens.size === 0)
|
|
458
|
+
return;
|
|
459
|
+
heartbeatTimer = setTimeout(() => {
|
|
460
|
+
heartbeatTimer = null;
|
|
461
|
+
renewHeldSlots();
|
|
462
|
+
}, renewEveryMs);
|
|
463
|
+
unref(heartbeatTimer);
|
|
464
|
+
}
|
|
465
|
+
async function renewHeldSlots() {
|
|
466
|
+
const tokens = [...heldTokens];
|
|
467
|
+
for (let index = 0;index < tokens.length; index += MAX_RENEW_BATCH) {
|
|
468
|
+
try {
|
|
469
|
+
await renewScript([inflightKey], [ttlMs, ...tokens.slice(index, index + MAX_RENEW_BATCH)]);
|
|
470
|
+
noteRedisSuccess();
|
|
471
|
+
} catch (error) {
|
|
472
|
+
logFailure("Failed to renew Redis concurrency slots", error);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
scheduleHeartbeat();
|
|
476
|
+
}
|
|
477
|
+
let pendingReleases = [];
|
|
478
|
+
let releaseFlush = null;
|
|
479
|
+
function release(token) {
|
|
480
|
+
pendingReleases.push(token);
|
|
481
|
+
releaseFlush ??= (async () => {
|
|
482
|
+
await Promise.resolve();
|
|
483
|
+
const tokens = pendingReleases;
|
|
484
|
+
pendingReleases = [];
|
|
485
|
+
releaseFlush = null;
|
|
486
|
+
try {
|
|
487
|
+
await send("ZREM", [inflightKey, ...tokens]);
|
|
488
|
+
noteRedisSuccess();
|
|
489
|
+
} catch (error) {
|
|
490
|
+
logFailure("Failed to release Redis concurrency slot", error);
|
|
491
|
+
}
|
|
492
|
+
slotPump.wake();
|
|
493
|
+
})();
|
|
494
|
+
return releaseFlush;
|
|
495
|
+
}
|
|
496
|
+
async function tripCooldown(retryAfterMs) {
|
|
497
|
+
const waitMs = Math.min(MAX_COOLDOWN_MS, Math.max(0, Math.ceil(retryAfterMs)));
|
|
498
|
+
if (waitMs === 0)
|
|
499
|
+
return;
|
|
500
|
+
try {
|
|
501
|
+
await tripScript([cooldownKey], [String(waitMs)]);
|
|
502
|
+
} catch (error) {
|
|
503
|
+
logFailure("Failed to trip Redis rate-limit cooldown", error);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
async function waitForGrant(pending, signal, lateGrant) {
|
|
507
|
+
if (!signal)
|
|
508
|
+
return pending;
|
|
509
|
+
let aborted = false;
|
|
510
|
+
let abort;
|
|
511
|
+
try {
|
|
512
|
+
return await Promise.race([
|
|
513
|
+
pending.then((granted) => {
|
|
514
|
+
if (aborted && granted)
|
|
515
|
+
lateGrant();
|
|
516
|
+
return granted;
|
|
517
|
+
}),
|
|
518
|
+
new Promise((_, reject) => {
|
|
519
|
+
abort = () => {
|
|
520
|
+
aborted = true;
|
|
521
|
+
reject(signal.reason);
|
|
522
|
+
};
|
|
523
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
524
|
+
if (signal.aborted)
|
|
525
|
+
abort();
|
|
526
|
+
})
|
|
527
|
+
]);
|
|
528
|
+
} finally {
|
|
529
|
+
if (abort)
|
|
530
|
+
signal.removeEventListener("abort", abort);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
async function withRedisConcurrencyLimit(run, signal) {
|
|
534
|
+
signal?.throwIfAborted();
|
|
535
|
+
if (degraded)
|
|
536
|
+
return run();
|
|
537
|
+
const token = createToken();
|
|
538
|
+
const granted = await waitForGrant(slotPump.acquire(token), signal, () => {
|
|
539
|
+
release(token);
|
|
540
|
+
});
|
|
541
|
+
if (!granted) {
|
|
542
|
+
signal?.throwIfAborted();
|
|
543
|
+
return run();
|
|
544
|
+
}
|
|
545
|
+
heldTokens.add(token);
|
|
546
|
+
scheduleHeartbeat();
|
|
547
|
+
try {
|
|
548
|
+
if (startPump)
|
|
549
|
+
await waitForGrant(startPump.acquire(createToken()), signal, () => {});
|
|
550
|
+
signal?.throwIfAborted();
|
|
551
|
+
return await run();
|
|
552
|
+
} finally {
|
|
553
|
+
heldTokens.delete(token);
|
|
554
|
+
await release(token);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
function dispose() {
|
|
558
|
+
if (probeTimer) {
|
|
559
|
+
clearTimeout(probeTimer);
|
|
560
|
+
probeTimer = null;
|
|
561
|
+
}
|
|
562
|
+
if (heartbeatTimer) {
|
|
563
|
+
clearTimeout(heartbeatTimer);
|
|
564
|
+
heartbeatTimer = null;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return Object.assign(withRedisConcurrencyLimit, {
|
|
568
|
+
tripCooldown,
|
|
569
|
+
queueDepth,
|
|
570
|
+
dispose
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
export {
|
|
574
|
+
createLuaScript,
|
|
575
|
+
createManagedRedis,
|
|
576
|
+
createRedisConcurrencyLimit
|
|
577
|
+
};
|