@mark1russell7/service 0.5.3 → 0.5.5
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 +15 -2
- package/build/http.d.ts +5 -1
- package/build/http.js +79 -9
- package/build/http.js.map +1 -1
- package/build/index.d.ts +2 -1
- package/build/index.js +32 -9
- package/build/index.js.map +1 -1
- package/build/{knobs-BxG-c7Zb.js → knobs-BZAsgb5H.js} +16 -2
- package/build/knobs-BZAsgb5H.js.map +1 -0
- package/build/limiter.d.ts +1 -1
- package/build/redis.d.ts +41 -1
- package/build/redis.js +154 -1
- package/build/redis.js.map +1 -1
- package/build/{limiter-DzTyosou.d.ts → store-DzTyosou.d.ts} +1 -1
- package/package.json +2 -2
- package/build/knobs-BxG-c7Zb.js.map +0 -1
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import { convertible, valueIn } from "@mark1russell7/quantity";
|
|
2
|
+
//#region src/drains.ts
|
|
3
|
+
const holds = /* @__PURE__ */ new WeakMap();
|
|
4
|
+
/** This function adds the hold of a drain under its lifecycle. It gives the function that removes the hold. */
|
|
5
|
+
const holdLate = (life, hold) => {
|
|
6
|
+
const set = holds.get(life) ?? /* @__PURE__ */ new Set();
|
|
7
|
+
holds.set(life, set);
|
|
8
|
+
set.add(hold);
|
|
9
|
+
return () => {
|
|
10
|
+
set.delete(hold);
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
/** This function gives the hold of each drain under a lifecycle, as it is at this time. */
|
|
14
|
+
const lateHolds = (life) => [...holds.get(life) ?? []].map((hold) => hold());
|
|
15
|
+
//#endregion
|
|
2
16
|
//#region src/knobs.ts
|
|
3
17
|
/**
|
|
4
18
|
* This function gives a time knob in milliseconds, or infinity for a time without a bound. The
|
|
@@ -34,6 +48,6 @@ const countOf = (knobs, name, owner) => {
|
|
|
34
48
|
return value;
|
|
35
49
|
};
|
|
36
50
|
//#endregion
|
|
37
|
-
export { countOf as n, millisecondsOf as r, boundedMillisecondsOf as t };
|
|
51
|
+
export { lateHolds as a, holdLate as i, countOf as n, millisecondsOf as r, boundedMillisecondsOf as t };
|
|
38
52
|
|
|
39
|
-
//# sourceMappingURL=knobs-
|
|
53
|
+
//# sourceMappingURL=knobs-BZAsgb5H.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"knobs-BZAsgb5H.js","names":[],"sources":["../src/drains.ts","../src/knobs.ts"],"sourcesContent":["// The holds of the drains after their deadline (SYS-29). A transport that waits after the deadline of\n// its drain gives its hold under its lifecycle. At the cap of the drain, the lifecycle reads each hold.\n// A drain that waits only for the bytes of its answers did its work, thus the phase does not fail.\n\n/** A late hold tells what holds the drain of a transport after its deadline. */\nexport interface LateHold {\n /** This count is the number of calls whose end did not come: their handler still runs. */\n readonly running: number;\n /** This count is the number of answers whose bytes did not go to the client, for example to a client that does not read. */\n readonly unflushed: number;\n}\n\nconst holds = new WeakMap<object, Set<() => LateHold>>();\n\n/** This function adds the hold of a drain under its lifecycle. It gives the function that removes the hold. */\nexport const holdLate = (life: object, hold: () => LateHold): (() => void) => {\n const set = holds.get(life) ?? new Set<() => LateHold>();\n holds.set(life, set);\n set.add(hold);\n return () => {\n set.delete(hold);\n };\n};\n\n/** This function gives the hold of each drain under a lifecycle, as it is at this time. */\nexport const lateHolds = (life: object): LateHold[] => [...(holds.get(life) ?? [])].map((hold) => hold());\n","import type { Extent } from \"@mark1russell7/quantity\";\nimport { convertible, valueIn } from \"@mark1russell7/quantity\";\n\n/** A knob record is the knobs of a part, by name. A knob that is absent is undefined. */\nexport type KnobRecord = Readonly<Record<string, Extent | undefined>>;\n\n/**\n * This function gives a time knob in milliseconds, or infinity for a time without a bound. The\n * runtime has no defaults, thus a knob that is absent throws. A knob that is not a time, or that is\n * less than zero, also throws.\n */\nexport const millisecondsOf = (knobs: KnobRecord, name: string, owner: string): number => {\n const amount = knobs[name];\n if (amount === undefined) throw new Error(`${owner} needs the knob ${name}: it has no defaults`);\n if (!convertible(amount.unit, \"s\")) throw new Error(`the knob ${name} is not a time`);\n if (amount.value === \"infinite\") return Number.POSITIVE_INFINITY;\n const value = valueIn(amount, \"ms\");\n if (!(value >= 0)) throw new Error(`the knob ${name} is less than zero`);\n return value;\n};\n\n/** This function gives a time knob in milliseconds, and it throws for a time without a bound. */\nexport const boundedMillisecondsOf = (knobs: KnobRecord, name: string, owner: string): number => {\n const value = millisecondsOf(knobs, name, owner);\n if (!Number.isFinite(value)) throw new Error(`the knob ${name} of ${owner} has no bound: it needs a time`);\n return value;\n};\n\n/**\n * This function gives a count knob (the unit `count`) as a whole number above zero, or infinity\n * for a count without a bound. A knob that is absent, not a count, or not a whole number above zero throws.\n */\nexport const countOf = (knobs: KnobRecord, name: string, owner: string): number => {\n const amount = knobs[name];\n if (amount === undefined) throw new Error(`${owner} needs the knob ${name}: it has no defaults`);\n if (!convertible(amount.unit, \"count\")) throw new Error(`the knob ${name} is not a count`);\n if (amount.value === \"infinite\") return Number.POSITIVE_INFINITY;\n const value = valueIn(amount, \"count\");\n if (!(Number.isInteger(value) && value >= 1)) throw new Error(`the knob ${name} is ${value}, not a whole number above zero`);\n return value;\n};\n"],"mappings":";;AAYA,MAAM,wBAAQ,IAAI,QAAqC;;AAGvD,MAAa,YAAY,MAAc,SAAuC;CAC5E,MAAM,MAAM,MAAM,IAAI,IAAI,qBAAK,IAAI,IAAoB;CACvD,MAAM,IAAI,MAAM,GAAG;CACnB,IAAI,IAAI,IAAI;CACZ,aAAa;EACX,IAAI,OAAO,IAAI;CACjB;AACF;;AAGA,MAAa,aAAa,SAA6B,CAAC,GAAI,MAAM,IAAI,IAAI,KAAK,CAAC,CAAE,CAAC,CAAC,KAAK,SAAS,KAAK,CAAC;;;;;;;;ACdxG,MAAa,kBAAkB,OAAmB,MAAc,UAA0B;CACxF,MAAM,SAAS,MAAM;CACrB,IAAI,WAAW,KAAA,GAAW,MAAM,IAAI,MAAM,GAAG,MAAM,kBAAkB,KAAK,qBAAqB;CAC/F,IAAI,CAAC,YAAY,OAAO,MAAM,GAAG,GAAG,MAAM,IAAI,MAAM,YAAY,KAAK,eAAe;CACpF,IAAI,OAAO,UAAU,YAAY,OAAO,OAAO;CAC/C,MAAM,QAAQ,QAAQ,QAAQ,IAAI;CAClC,IAAI,EAAE,SAAS,IAAI,MAAM,IAAI,MAAM,YAAY,KAAK,mBAAmB;CACvE,OAAO;AACT;;AAGA,MAAa,yBAAyB,OAAmB,MAAc,UAA0B;CAC/F,MAAM,QAAQ,eAAe,OAAO,MAAM,KAAK;CAC/C,IAAI,CAAC,OAAO,SAAS,KAAK,GAAG,MAAM,IAAI,MAAM,YAAY,KAAK,MAAM,MAAM,+BAA+B;CACzG,OAAO;AACT;;;;;AAMA,MAAa,WAAW,OAAmB,MAAc,UAA0B;CACjF,MAAM,SAAS,MAAM;CACrB,IAAI,WAAW,KAAA,GAAW,MAAM,IAAI,MAAM,GAAG,MAAM,kBAAkB,KAAK,qBAAqB;CAC/F,IAAI,CAAC,YAAY,OAAO,MAAM,OAAO,GAAG,MAAM,IAAI,MAAM,YAAY,KAAK,gBAAgB;CACzF,IAAI,OAAO,UAAU,YAAY,OAAO,OAAO;CAC/C,MAAM,QAAQ,QAAQ,QAAQ,OAAO;CACrC,IAAI,EAAE,OAAO,UAAU,KAAK,KAAK,SAAS,IAAI,MAAM,IAAI,MAAM,YAAY,KAAK,MAAM,MAAM,gCAAgC;CAC3H,OAAO;AACT"}
|
package/build/limiter.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { S as retryAfterSeconds, _ as freeAt, a as LimitRule, b as limiterProblems, c as LimiterSpec, d as Refused, f as SlidingWindow, g as decide, h as TokenBucket, i as LimitPolicy, l as LimiterStoreSpec, m as TakeStep, n as Decision, o as Limiter, p as StoreFailure, r as KeyState, s as LimiterEviction, t as Allowed, u as RefusalCause, v as idleSpan, x as longestRetryAfter, y as limitRule } from "./policy-G4QJ1ZZq.js";
|
|
2
|
-
import { a as keyedLimiter, i as TakeRequest, n as LimiterStore, o as memoryStore, r as MemoryStore, t as KeyedLimiter } from "./
|
|
2
|
+
import { a as keyedLimiter, i as TakeRequest, n as LimiterStore, o as memoryStore, r as MemoryStore, t as KeyedLimiter } from "./store-DzTyosou.js";
|
|
3
3
|
export { type Allowed, type Decision, type KeyState, type KeyedLimiter, type LimitPolicy, type LimitRule, type Limiter, type LimiterEviction, type LimiterSpec, type LimiterStore, type LimiterStoreSpec, type MemoryStore, type RefusalCause, type Refused, type SlidingWindow, type StoreFailure, type TakeRequest, type TakeStep, type TokenBucket, decide, freeAt, idleSpan, keyedLimiter, limitRule, limiterProblems, longestRetryAfter, memoryStore, retryAfterSeconds };
|
package/build/redis.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { t as Backoff } from "./backoff-B-jGWVDm.js";
|
|
|
2
2
|
import { n as Clock } from "./clock-D-38uH_z.js";
|
|
3
3
|
import { B as Gate, Ct as Operation, E as CheckFn, G as Step, J as Poll, L as Dependency, M as Recorder, N as Check, W as ServiceDescription, et as heartbeatDetection, t as DependencyHandle } from "./lifecycle-Chu9Pri2.js";
|
|
4
4
|
import { n as ServiceBuilder } from "./builder-DyDB7zM9.js";
|
|
5
|
+
import { n as LimiterStore } from "./store-DzTyosou.js";
|
|
5
6
|
import { Extent, Quantity } from "@mark1russell7/quantity";
|
|
6
7
|
import { Machine } from "@mark1russell7/algebra";
|
|
7
8
|
//#region src/redis/connection.d.ts
|
|
@@ -273,6 +274,45 @@ export declare const tcpDial: DialFn;
|
|
|
273
274
|
*/
|
|
274
275
|
export declare const redisClient: (name: string, spec: RedisClientSpec, options?: RedisClientOptions) => RedisClient;
|
|
275
276
|
//#endregion
|
|
277
|
+
//#region src/redis/limiter.d.ts
|
|
278
|
+
/**
|
|
279
|
+
* This script decides one take in one atomic step of Redis. It does the arithmetic of `decide`, and
|
|
280
|
+
* it reads `TIME` of the server, the one clock of all replicas. A test can give the time in its place.
|
|
281
|
+
* - KEYS[1] is the state of the key. KEYS[2] is the index of the limiter, a sorted set of its keys
|
|
282
|
+
* by the time at which each key is free.
|
|
283
|
+
* - ARGV is the type of the rule, its two numbers, the cap of keys, the key, and the time or "".
|
|
284
|
+
* - The state of a bucket is its full time. The state of a window is the time of each take in it.
|
|
285
|
+
* - The state expires at its free time (PX), and the index lets the cap count only busy keys.
|
|
286
|
+
* - The reply is `{1, remaining}`, or `{0, retryAfter, cause}` with the time as text. Redis gives
|
|
287
|
+
* each Lua number as a whole number, thus the fraction of a millisecond goes as text.
|
|
288
|
+
*/
|
|
289
|
+
export declare const LIMITER_SCRIPT = "\nlocal kind = ARGV[1]\nlocal a = tonumber(ARGV[2])\nlocal b = tonumber(ARGV[3])\nlocal cap = tonumber(ARGV[4])\nlocal member = ARGV[5]\nlocal now\nif ARGV[6] == '' then\n local t = redis.call('TIME')\n now = tonumber(t[1]) * 1000 + tonumber(t[2]) / 1000\nelse\n now = tonumber(ARGV[6])\nend\nlocal TOL = 1e-6\nlocal function num(x) return string.format('%.6f', x) end\nlocal raw = redis.call('GET', KEYS[1])\nlocal full = nil\nlocal stamps = nil\nif raw then\n if kind == 'token-bucket' then\n full = tonumber(raw)\n else\n stamps = {}\n for x in string.gmatch(raw, '[^,]+') do stamps[#stamps + 1] = tonumber(x) end\n end\nend\nlocal function freeAt()\n if kind == 'token-bucket' then return full end\n if #stamps == 0 then return -math.huge end\n return stamps[#stamps] + b\nend\nif raw and freeAt() <= now then\n redis.call('DEL', KEYS[1])\n redis.call('ZREM', KEYS[2], member)\n raw = nil\n full = nil\n stamps = nil\nend\nif not raw then\n if redis.call('ZCARD', KEYS[2]) >= cap then\n redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', num(now))\n if redis.call('ZCARD', KEYS[2]) >= cap then\n local first = redis.call('ZRANGE', KEYS[2], 0, 0, 'WITHSCORES')\n return {0, num(tonumber(first[2]) - now), 'cap'}\n end\n end\nend\nlocal remaining\nlocal state\nif kind == 'token-bucket' then\n local span = a * b\n local base = now\n if full and full > now then base = full end\n local nxt = base + a\n if nxt - now > span + TOL then\n return {0, num(nxt - span - now), 'limit'}\n end\n remaining = math.floor((span - (nxt - now)) / a + TOL)\n if remaining < 0 then remaining = 0 end\n full = nxt\n state = num(full)\nelse\n local live = {}\n for _, stamp in ipairs(stamps or {}) do\n if stamp + b > now + TOL then live[#live + 1] = stamp end\n end\n if #live >= a then\n return {0, num(live[1] + b - now), 'limit'}\n end\n live[#live + 1] = now\n remaining = a - #live\n stamps = live\n local parts = {}\n for i, stamp in ipairs(live) do parts[i] = num(stamp) end\n state = table.concat(parts, ',')\nend\nlocal free = freeAt()\nlocal ttl = math.ceil(free - now)\nif ttl < 1 then ttl = 1 end\nredis.call('SET', KEYS[1], state, 'PX', ttl)\nredis.call('ZADD', KEYS[2], num(free), member)\nif redis.call('PTTL', KEYS[2]) < ttl then redis.call('PEXPIRE', KEYS[2], ttl) end\nreturn {1, remaining}\n";
|
|
290
|
+
/** These options make a store of a limiter in Redis. */
|
|
291
|
+
interface RedisLimiterOptions {
|
|
292
|
+
/** This text starts the name of each key of the store in Redis, for example `limit:`. It has no braces. */
|
|
293
|
+
readonly prefix: string;
|
|
294
|
+
/**
|
|
295
|
+
* This clock replaces `TIME` of the server, for a test that compares the store with `decide`. Without
|
|
296
|
+
* it, the script reads `TIME`, thus each replica gets the one clock of the store.
|
|
297
|
+
*/
|
|
298
|
+
readonly clock?: Clock | undefined;
|
|
299
|
+
}
|
|
300
|
+
/** A Redis client part is the part of a Redis client that the store uses: `send`. */
|
|
301
|
+
type RedisSender = Pick<RedisClient, "send">;
|
|
302
|
+
/**
|
|
303
|
+
* This function makes a store that the replicas share, in Redis 7. Each take is one `EVAL` of
|
|
304
|
+
* `LIMITER_SCRIPT`. The client does not send it again, because a second take counts two times.
|
|
305
|
+
* - The names of the keys have the limiter in braces, a hash tag. Thus the state and the index of a
|
|
306
|
+
* limiter are in one slot of a Redis Cluster.
|
|
307
|
+
* - Each state expires at the time that the key is free, thus the eviction is exact, as in the memory
|
|
308
|
+
* store. The index of the limiter bounds the count of busy keys to `maxKeys`.
|
|
309
|
+
* - A reply of an error, an outcome that is not certain, or a reply of another shape rejects the
|
|
310
|
+
* take of the store. `keyedLimiter` then applies the `onStoreFailure` of the spec.
|
|
311
|
+
* - `TIME` is the wall clock of the Redis host, not a monotonic clock. Thus a step of that clock
|
|
312
|
+
* moves the counts of each key. The replicas still share one clock.
|
|
313
|
+
*/
|
|
314
|
+
export declare const redisLimiterStore: (client: RedisSender, options: RedisLimiterOptions) => LimiterStore;
|
|
315
|
+
//#endregion
|
|
276
316
|
//#region src/redis/machine.d.ts
|
|
277
317
|
/**
|
|
278
318
|
* The Redis client as a `mark1russell7.machine/1` document. Its states are the states of ioredis.
|
|
@@ -292,5 +332,5 @@ export declare const REDIS_OPERATIONS: Readonly<Record<string, Operation>>;
|
|
|
292
332
|
/** This function gives the name of a command as the table writes it: upper case, and CLIENT ID with its subcommand. */
|
|
293
333
|
export declare const commandName: (command: string, args: readonly unknown[]) => string;
|
|
294
334
|
//#endregion
|
|
295
|
-
export { type CheckOptions, type ClientStep, type ConnectionFactory, type Detector, type DialFn, type Join, type Meeting, type RedisClient, type RedisClientOptions, type RedisClientSpec, type RedisConnection, type RedisConnectionOptions, type RedisKnobs, type RedisSocket, type Sent, type Situation, heartbeatDetection };
|
|
335
|
+
export { type CheckOptions, type ClientStep, type ConnectionFactory, type Detector, type DialFn, type Join, type Meeting, type RedisClient, type RedisClientOptions, type RedisClientSpec, type RedisConnection, type RedisConnectionOptions, type RedisKnobs, type RedisLimiterOptions, type RedisSender, type RedisSocket, type Sent, type Situation, heartbeatDetection };
|
|
296
336
|
//# sourceMappingURL=redis.d.ts.map
|
package/build/redis.js
CHANGED
|
@@ -688,6 +688,159 @@ const redisClient = (name, spec, options = {}) => {
|
|
|
688
688
|
};
|
|
689
689
|
};
|
|
690
690
|
//#endregion
|
|
691
|
+
//#region src/redis/limiter.ts
|
|
692
|
+
/**
|
|
693
|
+
* This script decides one take in one atomic step of Redis. It does the arithmetic of `decide`, and
|
|
694
|
+
* it reads `TIME` of the server, the one clock of all replicas. A test can give the time in its place.
|
|
695
|
+
* - KEYS[1] is the state of the key. KEYS[2] is the index of the limiter, a sorted set of its keys
|
|
696
|
+
* by the time at which each key is free.
|
|
697
|
+
* - ARGV is the type of the rule, its two numbers, the cap of keys, the key, and the time or "".
|
|
698
|
+
* - The state of a bucket is its full time. The state of a window is the time of each take in it.
|
|
699
|
+
* - The state expires at its free time (PX), and the index lets the cap count only busy keys.
|
|
700
|
+
* - The reply is `{1, remaining}`, or `{0, retryAfter, cause}` with the time as text. Redis gives
|
|
701
|
+
* each Lua number as a whole number, thus the fraction of a millisecond goes as text.
|
|
702
|
+
*/
|
|
703
|
+
const LIMITER_SCRIPT = `
|
|
704
|
+
local kind = ARGV[1]
|
|
705
|
+
local a = tonumber(ARGV[2])
|
|
706
|
+
local b = tonumber(ARGV[3])
|
|
707
|
+
local cap = tonumber(ARGV[4])
|
|
708
|
+
local member = ARGV[5]
|
|
709
|
+
local now
|
|
710
|
+
if ARGV[6] == '' then
|
|
711
|
+
local t = redis.call('TIME')
|
|
712
|
+
now = tonumber(t[1]) * 1000 + tonumber(t[2]) / 1000
|
|
713
|
+
else
|
|
714
|
+
now = tonumber(ARGV[6])
|
|
715
|
+
end
|
|
716
|
+
local TOL = 1e-6
|
|
717
|
+
local function num(x) return string.format('%.6f', x) end
|
|
718
|
+
local raw = redis.call('GET', KEYS[1])
|
|
719
|
+
local full = nil
|
|
720
|
+
local stamps = nil
|
|
721
|
+
if raw then
|
|
722
|
+
if kind == 'token-bucket' then
|
|
723
|
+
full = tonumber(raw)
|
|
724
|
+
else
|
|
725
|
+
stamps = {}
|
|
726
|
+
for x in string.gmatch(raw, '[^,]+') do stamps[#stamps + 1] = tonumber(x) end
|
|
727
|
+
end
|
|
728
|
+
end
|
|
729
|
+
local function freeAt()
|
|
730
|
+
if kind == 'token-bucket' then return full end
|
|
731
|
+
if #stamps == 0 then return -math.huge end
|
|
732
|
+
return stamps[#stamps] + b
|
|
733
|
+
end
|
|
734
|
+
if raw and freeAt() <= now then
|
|
735
|
+
redis.call('DEL', KEYS[1])
|
|
736
|
+
redis.call('ZREM', KEYS[2], member)
|
|
737
|
+
raw = nil
|
|
738
|
+
full = nil
|
|
739
|
+
stamps = nil
|
|
740
|
+
end
|
|
741
|
+
if not raw then
|
|
742
|
+
if redis.call('ZCARD', KEYS[2]) >= cap then
|
|
743
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', num(now))
|
|
744
|
+
if redis.call('ZCARD', KEYS[2]) >= cap then
|
|
745
|
+
local first = redis.call('ZRANGE', KEYS[2], 0, 0, 'WITHSCORES')
|
|
746
|
+
return {0, num(tonumber(first[2]) - now), 'cap'}
|
|
747
|
+
end
|
|
748
|
+
end
|
|
749
|
+
end
|
|
750
|
+
local remaining
|
|
751
|
+
local state
|
|
752
|
+
if kind == 'token-bucket' then
|
|
753
|
+
local span = a * b
|
|
754
|
+
local base = now
|
|
755
|
+
if full and full > now then base = full end
|
|
756
|
+
local nxt = base + a
|
|
757
|
+
if nxt - now > span + TOL then
|
|
758
|
+
return {0, num(nxt - span - now), 'limit'}
|
|
759
|
+
end
|
|
760
|
+
remaining = math.floor((span - (nxt - now)) / a + TOL)
|
|
761
|
+
if remaining < 0 then remaining = 0 end
|
|
762
|
+
full = nxt
|
|
763
|
+
state = num(full)
|
|
764
|
+
else
|
|
765
|
+
local live = {}
|
|
766
|
+
for _, stamp in ipairs(stamps or {}) do
|
|
767
|
+
if stamp + b > now + TOL then live[#live + 1] = stamp end
|
|
768
|
+
end
|
|
769
|
+
if #live >= a then
|
|
770
|
+
return {0, num(live[1] + b - now), 'limit'}
|
|
771
|
+
end
|
|
772
|
+
live[#live + 1] = now
|
|
773
|
+
remaining = a - #live
|
|
774
|
+
stamps = live
|
|
775
|
+
local parts = {}
|
|
776
|
+
for i, stamp in ipairs(live) do parts[i] = num(stamp) end
|
|
777
|
+
state = table.concat(parts, ',')
|
|
778
|
+
end
|
|
779
|
+
local free = freeAt()
|
|
780
|
+
local ttl = math.ceil(free - now)
|
|
781
|
+
if ttl < 1 then ttl = 1 end
|
|
782
|
+
redis.call('SET', KEYS[1], state, 'PX', ttl)
|
|
783
|
+
redis.call('ZADD', KEYS[2], num(free), member)
|
|
784
|
+
if redis.call('PTTL', KEYS[2]) < ttl then redis.call('PEXPIRE', KEYS[2], ttl) end
|
|
785
|
+
return {1, remaining}
|
|
786
|
+
`;
|
|
787
|
+
/**
|
|
788
|
+
* This function makes a store that the replicas share, in Redis 7. Each take is one `EVAL` of
|
|
789
|
+
* `LIMITER_SCRIPT`. The client does not send it again, because a second take counts two times.
|
|
790
|
+
* - The names of the keys have the limiter in braces, a hash tag. Thus the state and the index of a
|
|
791
|
+
* limiter are in one slot of a Redis Cluster.
|
|
792
|
+
* - Each state expires at the time that the key is free, thus the eviction is exact, as in the memory
|
|
793
|
+
* store. The index of the limiter bounds the count of busy keys to `maxKeys`.
|
|
794
|
+
* - A reply of an error, an outcome that is not certain, or a reply of another shape rejects the
|
|
795
|
+
* take of the store. `keyedLimiter` then applies the `onStoreFailure` of the spec.
|
|
796
|
+
* - `TIME` is the wall clock of the Redis host, not a monotonic clock. Thus a step of that clock
|
|
797
|
+
* moves the counts of each key. The replicas still share one clock.
|
|
798
|
+
*/
|
|
799
|
+
const redisLimiterStore = (client, options) => {
|
|
800
|
+
if (/[{}]/.test(options.prefix)) throw new Error(`the prefix ${options.prefix} of a limiter store has a brace: the store puts the hash tag in it`);
|
|
801
|
+
return {
|
|
802
|
+
kind: "shared",
|
|
803
|
+
async take({ limiter, key, rule, maxKeys }) {
|
|
804
|
+
const base = `${options.prefix}{${limiter}}`;
|
|
805
|
+
const numbers = rule.type === "token-bucket" ? [rule.interval, rule.burst] : [rule.limit, rule.window];
|
|
806
|
+
const now = options.clock === void 0 ? "" : String(options.clock.now());
|
|
807
|
+
const sent = await client.send("EVAL", [
|
|
808
|
+
LIMITER_SCRIPT,
|
|
809
|
+
2,
|
|
810
|
+
`${base}:key:${key}`,
|
|
811
|
+
`${base}:keys`,
|
|
812
|
+
rule.type,
|
|
813
|
+
String(numbers[0]),
|
|
814
|
+
String(numbers[1]),
|
|
815
|
+
String(maxKeys),
|
|
816
|
+
key,
|
|
817
|
+
now
|
|
818
|
+
]);
|
|
819
|
+
if (sent.outcome !== "ok") throw new Error(`the limiter store ${limiter} failed (${sent.outcome}): ${sent.error}`);
|
|
820
|
+
return replyOf(limiter, sent.value);
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
};
|
|
824
|
+
/** This function gives the decision of a reply of `LIMITER_SCRIPT`. A reply of another shape throws. */
|
|
825
|
+
const replyOf = (limiter, reply) => {
|
|
826
|
+
if (Array.isArray(reply) && Number(reply[0]) === 1 && reply.length === 2) return {
|
|
827
|
+
allowed: true,
|
|
828
|
+
remaining: Number(reply[1])
|
|
829
|
+
};
|
|
830
|
+
if (Array.isArray(reply) && Number(reply[0]) === 0 && reply.length === 3 && (reply[2] === "limit" || reply[2] === "cap")) {
|
|
831
|
+
const retryAfter = Number(reply[1]);
|
|
832
|
+
if (Number.isFinite(retryAfter) && retryAfter > 0) return {
|
|
833
|
+
allowed: false,
|
|
834
|
+
retryAfter: {
|
|
835
|
+
value: retryAfter,
|
|
836
|
+
unit: "ms"
|
|
837
|
+
},
|
|
838
|
+
cause: reply[2]
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
throw new Error(`the limiter store ${limiter} gave a reply that is not a decision: ${JSON.stringify(reply)}`);
|
|
842
|
+
};
|
|
843
|
+
//#endregion
|
|
691
844
|
//#region src/redis/machine.ts
|
|
692
845
|
const t = (id, from, on, to, extra = {}) => ({
|
|
693
846
|
id,
|
|
@@ -850,6 +1003,6 @@ const REDIS_CLIENT_MACHINE = {
|
|
|
850
1003
|
]
|
|
851
1004
|
};
|
|
852
1005
|
//#endregion
|
|
853
|
-
export { REDIS_CLIENT_MACHINE, REDIS_OPERATIONS, commandName, firstDetector, heartbeatDetection, ioredis, meetings, redisClient, retryBackoff, specProblems, tcpDial, unwatched };
|
|
1006
|
+
export { LIMITER_SCRIPT, REDIS_CLIENT_MACHINE, REDIS_OPERATIONS, commandName, firstDetector, heartbeatDetection, ioredis, meetings, redisClient, redisLimiterStore, retryBackoff, specProblems, tcpDial, unwatched };
|
|
854
1007
|
|
|
855
1008
|
//# sourceMappingURL=redis.js.map
|