@usehenri/redis 0.0.0 → 1.2.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/CHANGELOG.md +88 -0
- package/LICENSE +21 -0
- package/README.md +8 -1
- package/index.js +12 -0
- package/package.json +45 -10
- package/src/backend.js +335 -0
- package/src/kv.js +200 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# @usehenri/redis
|
|
2
|
+
|
|
3
|
+
## 1.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#419](https://github.com/usehenri/henri/pull/419) [`43a0e1a`](https://github.com/usehenri/henri/commit/43a0e1a6a320baa43298391e1c1e0334d6cd28d5) Thanks [@reel](https://github.com/reel)! - `henri.flags`: feature flags declared in one file, on for everyone, for a named set, for a stable share of them, or for a group the application writes in code.
|
|
8
|
+
|
|
9
|
+
`config/flags.js` is the declaration — `checkout: false` is a whole flag, and the long form adds `description`, `expose` and `group`. `await req.flag('checkout')` and `await henri.flags.enabled('checkout', user)` are the read; `henri flags`, `flags:on`, `flags:off`, `flags:percentage` and `flags:reset` are what an operator has. `flipper` is what this learns from: the gates and the `enable`/`disable` vocabulary are kept, `percentage_of_time` is not (a feature that flickers within one page load is a bug henri would have caused), and neither are the expression gates, the web UI or the metrics.
|
|
10
|
+
|
|
11
|
+
**A name nothing declares is a failure, not a `false`** — the position `req.permit()`, `params`, `answers` and `filters` already take. A typo that answers `false` forever is a feature that silently never ships; a typo that throws is a stack trace in development, in the suite, and on the first request that reaches it. Both messages name the closest declared flag, and the cost is stated out loud in the guide: removing a flag is two deploys.
|
|
12
|
+
|
|
13
|
+
**The percentage is stable and it is hashed carefully.** The bucket is `sha256(flag + actor)`, computed and never stored, so the same person keeps the same answer across processes and restarts and a rollout only ever adds people. The flag name is in the hash so two features at ten percent are not on for the same ten percent, and the identifier — the `externalId`, never the primary key — is hashed **whole**: a uuid v7 is time-ordered, so bucketing on any prefix of one would roll a feature out by signup date rather than at random.
|
|
14
|
+
|
|
15
|
+
**The state is where every process can read it.** `config.shared` when there is a backend, `.henri/flags.json` otherwise, this process's memory under `NODE_ENV=test`, and the boot line says which and says its limit. Reads come from an in-memory snapshot re-read on a timer (`flags.refresh`, ten seconds, which is the whole staleness window), not through `henri.cache` — a cache's answer to a backend that is down is a miss, and a miss here would revert every flag to its default mid-incident. **A store that cannot be read flips nothing**: the snapshot stands and it is reported at most once a minute.
|
|
16
|
+
|
|
17
|
+
`henri flags` boots to runlevel 2 and no further, so a kill switch can be flipped while the database is unreachable. There is **no HTTP surface** in any environment: an application that wants a page writes the controller and puts a policy on it.
|
|
18
|
+
|
|
19
|
+
New configuration key `flags` (`store`, `refresh`, `enabled`), four `HENRI_FLAGS_*` codes, and `@usehenri/redis` gains a key-value write with no expiry, for the state that must outlive a restart. See the new [Feature flags](https://usehenri.io/guides/feature-flags/) guide.
|
|
20
|
+
|
|
21
|
+
- [#373](https://github.com/usehenri/henri/pull/373) [`2c8a826`](https://github.com/usehenri/henri/commit/2c8a8265262dbf6ea5c3e73e8e7892a230d4d0f0) Thanks [@reel](https://github.com/reel)! - `config.shared`: one backend for the counters that only worked with one process
|
|
22
|
+
|
|
23
|
+
The rate limit, the sign-in lockout and the idempotency keys each keep a
|
|
24
|
+
number per key, and all three were kept in the process's memory unless the
|
|
25
|
+
application named a store in three separate configuration keys. Two processes
|
|
26
|
+
therefore meant two sets of counters: a rate limit that is twice what it says,
|
|
27
|
+
a lockout an attacker escapes by being routed elsewhere, and an idempotency
|
|
28
|
+
key that stops being idempotent.
|
|
29
|
+
|
|
30
|
+
`config.shared` is the one place to say where they live instead:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"shared": {
|
|
35
|
+
"adapter": "redis",
|
|
36
|
+
"url": "redis://127.0.0.1:6379",
|
|
37
|
+
"prefix": "lineup:",
|
|
38
|
+
"onError": "closed"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`@usehenri/redis` is the backend, a package an application installs
|
|
44
|
+
(`pnpm add @usehenri/redis`), resolved from the application the way a store
|
|
45
|
+
adapter is; nothing is added to an application that does not name it. It talks
|
|
46
|
+
to Redis through node-redis and counts the rate limits with `rate-limit-redis`.
|
|
47
|
+
`rateLimit.store`, `user.lockout.store` and `api.idempotency.store` keep
|
|
48
|
+
working and still win, key by key.
|
|
49
|
+
|
|
50
|
+
When the backend does not answer, `shared.onError` decides: `closed` (the
|
|
51
|
+
default) refuses the request with a `503` and a `Retry-After`, `open` serves it
|
|
52
|
+
uncounted; either is logged, at most once every ten seconds per counter. The
|
|
53
|
+
idempotency keys are always closed, whatever `onError` says. A backend that is
|
|
54
|
+
unreachable at boot does not fail the boot: the client keeps reconnecting and
|
|
55
|
+
`GET /readyz` reports it (`"shared": { "ok": false }`), so the process leaves
|
|
56
|
+
the load balancer instead of the fleet.
|
|
57
|
+
|
|
58
|
+
The boot says which it is on every application -- `counted in redis (fail
|
|
59
|
+
closed)` or `counted in this process` -- and warns outright when the
|
|
60
|
+
environment says this process is one of several (a cluster worker, a numbered
|
|
61
|
+
pm2 instance, `WEB_CONCURRENCY`, a Heroku dyno past the first) and no shared
|
|
62
|
+
backend is configured. `henri doctor` reports a shared store that does not
|
|
63
|
+
answer (`shared.unreachable`) and asks for the adapter package when the
|
|
64
|
+
configuration names one; `--no-reach` skips the connection.
|
|
65
|
+
|
|
66
|
+
Sessions are not part of this: they already go through the database adapter,
|
|
67
|
+
which every process shares.
|
|
68
|
+
|
|
69
|
+
- [#374](https://github.com/usehenri/henri/pull/374) [`0b32fbd`](https://github.com/usehenri/henri/commit/0b32fbde19c95da8fe07fab76933840a4242c71c) Thanks [@reel](https://github.com/reel)! - `henri.cache`: a cache store with `fetch`, on this process's memory or on the backend `config.shared` already names.
|
|
70
|
+
|
|
71
|
+
`henri.cache.fetch(key, [options], fn)` answers from the cache or runs the function and keeps what it returned; `get`, `set`, `delete`, `clear` and `scope(name)` are the store underneath, and `stats()` says what it has been doing. Every entry has a TTL (`config.cache.ttl`, five minutes by default) — there is no way to keep a value forever, by accident or on purpose.
|
|
72
|
+
|
|
73
|
+
**The stampede.** A key that expires under load is missed by every request at once. `fetch` keeps one promise per key while the function runs and hands it to everyone who missed it, so a hundred concurrent misses of one key in one process run the function once. Across processes the bound is the number of processes, deliberately: a cross-process lock needs a lease, and a lease means guessing how long the function may take — guess short and it runs twice anyway, guess long and one crashed process blocks every reader of that key.
|
|
74
|
+
|
|
75
|
+
**Two backends, named once.** Without `config.shared` the cache is this process's memory, bounded twice (`maxEntries`, 1000, and `maxSize`, 32mb) and evicting the least recently used, so it cannot become a leak. With `config.shared` — the block that already says where the rate limit, the sign-in lockout and the idempotency keys are counted — the cache is on that backend, in a key space of its own, with nothing else to configure. `config.cache.store` still names a module of its own for whoever wants the cache somewhere the counters are not.
|
|
76
|
+
|
|
77
|
+
**What a value may be.** JSON, plus `Date` (which comes back a `Date`). A model instance, any other class instance, `undefined`, `NaN`, `Infinity`, a `Map`, a `Set`, a `Buffer`, a `RegExp`, a function, a symbol, a bigint or anything circular is refused with `HENRI_CACHE_VALUE_UNSUPPORTED`, naming where it sat and what it was but never what it held — rather than stored to come back wrong. A value bigger than `maxEntrySize` (256kb) is not stored at all: `set` answers `false` and says so once, and nothing is ever truncated.
|
|
78
|
+
|
|
79
|
+
**A backend that is down is a miss**, whatever `config.shared.onError` says. The counters block because a guard that cannot count is not a guard; a cache holds no truth, so refusing a request over a copy would turn an optimization into an outage. Every fallthrough is logged at most once every ten seconds, like the counters'. Keys reaching a log line are masked by `config.filterParameters`, and values never reach one.
|
|
80
|
+
|
|
81
|
+
**henri invalidates nothing for you**: no model callback, no query cache, no route. `delete` is yours to call, and with the memory backend it reaches one process — which is the reason a deployment running several of them wants `config.shared`.
|
|
82
|
+
|
|
83
|
+
New configuration key `cache` (`ttl`, `maxEntries`, `maxSize`, `maxEntrySize`, `store`, `enabled`; `false` turns the cache off). `@usehenri/redis` gains a raw mode on its key-value store, so the cache's already-encoded entry is not wrapped in JSON a second time, and a `clear(prefix)` that walks its own key space with `SCAN` and `UNLINK` — never `FLUSHDB`. See the new [Caching](https://usehenri.io/guides/caching/) guide.
|
|
84
|
+
|
|
85
|
+
### Patch Changes
|
|
86
|
+
|
|
87
|
+
- Updated dependencies [[`792a15a`](https://github.com/usehenri/henri/commit/792a15ade614cf8b920d9197586f9866700d458e), [`1e23664`](https://github.com/usehenri/henri/commit/1e23664829bd1a356de28f404cfb21c9ae211388), [`5b627ad`](https://github.com/usehenri/henri/commit/5b627adfa37e9f16bc75af96cc8ff5308a91f688), [`4dff51e`](https://github.com/usehenri/henri/commit/4dff51edc050e29398c793a5aedb48776b7b7119), [`60dbf33`](https://github.com/usehenri/henri/commit/60dbf33c2a4f14e328a0df1cb44be061e15431e5), [`1b316c6`](https://github.com/usehenri/henri/commit/1b316c6f3c5d5eb5752c70b534092d1052956cc6), [`d074e8b`](https://github.com/usehenri/henri/commit/d074e8b482582e25f80d8a14b4735e69a2b7821e), [`7fd13f6`](https://github.com/usehenri/henri/commit/7fd13f631b75f7aa152b73046b50c6902ae3ca93), [`b559fb7`](https://github.com/usehenri/henri/commit/b559fb72b391eeb21a3f6a0cda1515e01ecbfafc), [`1c0dfe8`](https://github.com/usehenri/henri/commit/1c0dfe84a98eff2122512256c4f42ec7ccde4212), [`93060a8`](https://github.com/usehenri/henri/commit/93060a86df795dbbd99bf1895beb0cf14c4b86de), [`e031900`](https://github.com/usehenri/henri/commit/e031900082f28aec72af4cda9cd959f932e2ebc7), [`9173000`](https://github.com/usehenri/henri/commit/91730005efa88f073bfaaf67078c3ec0e137b459), [`62fac46`](https://github.com/usehenri/henri/commit/62fac46fd6cae5581979b73daf99700fd246e0ea), [`b7f33e2`](https://github.com/usehenri/henri/commit/b7f33e28a5e4844391befd75d08e42c4cf6212ed), [`3d6f3fc`](https://github.com/usehenri/henri/commit/3d6f3fc048d05db41be86069608d342e437408cb), [`b278119`](https://github.com/usehenri/henri/commit/b2781190de436eb5838e866446c9a0c8210bb6ca), [`b161e1b`](https://github.com/usehenri/henri/commit/b161e1b8fad94af2d2afc351dc1bc07dabbb1379), [`7cb0b04`](https://github.com/usehenri/henri/commit/7cb0b04b29b61dedaa82fcd1972646fb3765acfc), [`1616e34`](https://github.com/usehenri/henri/commit/1616e343a612be2bffffcfa5b23bfa8ad191bbe3), [`c8f5367`](https://github.com/usehenri/henri/commit/c8f53678b33341d086b467f801e959314afc7860), [`bcf4ce2`](https://github.com/usehenri/henri/commit/bcf4ce22bcd294844504164fac1fa4aef1ffec41), [`ab5a8e4`](https://github.com/usehenri/henri/commit/ab5a8e4a88c80cca070a2b8ad398c80babdaff11), [`43d267f`](https://github.com/usehenri/henri/commit/43d267f0f9d192b2c01e89c3925b7daf5000041b), [`9f868f3`](https://github.com/usehenri/henri/commit/9f868f3d9162fa218e34304110210e6949f97d5c), [`d88bf7f`](https://github.com/usehenri/henri/commit/d88bf7fe038a6b58e7bed02ff4c90755f6c0e65e), [`49398a6`](https://github.com/usehenri/henri/commit/49398a6308f0760f01c6ff2ec98aaa35f484474d), [`89dda62`](https://github.com/usehenri/henri/commit/89dda62da456a0a55600e79cfb65ce89f11258e2), [`62fac46`](https://github.com/usehenri/henri/commit/62fac46fd6cae5581979b73daf99700fd246e0ea), [`a93d6cc`](https://github.com/usehenri/henri/commit/a93d6cc39b33b261089e91f3e757b54fefc9fe15), [`d9f3be4`](https://github.com/usehenri/henri/commit/d9f3be49c5929d929a220220bf6e72fdcb135595), [`c44f025`](https://github.com/usehenri/henri/commit/c44f025acec3d5bbbb57e2310d02184a1053a10d), [`67cfb20`](https://github.com/usehenri/henri/commit/67cfb200ea0e0b31bacf2af183db6467b0fa011d), [`e661f98`](https://github.com/usehenri/henri/commit/e661f98fe8f8acce15aa10ce2dc320c5a2cb006f), [`43a0e1a`](https://github.com/usehenri/henri/commit/43a0e1a6a320baa43298391e1c1e0334d6cd28d5), [`cee57b9`](https://github.com/usehenri/henri/commit/cee57b9d3521a4a70c715222eae1f18ff4a6c128), [`61bf75c`](https://github.com/usehenri/henri/commit/61bf75cbaccda1aecff34408a175b2d85447d7a8), [`2c8a826`](https://github.com/usehenri/henri/commit/2c8a8265262dbf6ea5c3e73e8e7892a230d4d0f0), [`46d5dbc`](https://github.com/usehenri/henri/commit/46d5dbcc983c03e96ae5a87d7288c5d8a5adbc24), [`ba97ea9`](https://github.com/usehenri/henri/commit/ba97ea968f0b34cd67b7a3e803ecd34543b8aaaf), [`5ccd537`](https://github.com/usehenri/henri/commit/5ccd537b3621b54d11e7f24ccca39643ae7d5cf7), [`d88bf7f`](https://github.com/usehenri/henri/commit/d88bf7fe038a6b58e7bed02ff4c90755f6c0e65e), [`9895cbf`](https://github.com/usehenri/henri/commit/9895cbf4be85b476e341a5be915e3049e5a027de), [`61bf75c`](https://github.com/usehenri/henri/commit/61bf75cbaccda1aecff34408a175b2d85447d7a8), [`5a150d5`](https://github.com/usehenri/henri/commit/5a150d576208571c32b9cd12827d035e31ed4313), [`3c1c5b8`](https://github.com/usehenri/henri/commit/3c1c5b83ea135b000ddd6ffbfd05457b000f2f7c), [`2625067`](https://github.com/usehenri/henri/commit/26250673d91ab70ad024739d02b647754f75267d), [`aa3f90b`](https://github.com/usehenri/henri/commit/aa3f90bd6f42bb05431c33f3e4bf2202cb6bb7c6), [`a1c6769`](https://github.com/usehenri/henri/commit/a1c6769099e3dc28b22b5338a2a57b13bdf69f7a), [`0a8bb41`](https://github.com/usehenri/henri/commit/0a8bb415d352cd75b12d07e591c8ec7c16774a99), [`ec1c8c4`](https://github.com/usehenri/henri/commit/ec1c8c419f4d9063a7617472b2970fdb8a929fa1), [`dd2731d`](https://github.com/usehenri/henri/commit/dd2731d6a20fd96aa1be1aeb5e6ec0155001326b), [`ab52e18`](https://github.com/usehenri/henri/commit/ab52e187c420dfe381f03ed51c5c141fda525acb), [`b7b56e1`](https://github.com/usehenri/henri/commit/b7b56e190ae774abc0096fe2aebaf91f823115af), [`b7038ce`](https://github.com/usehenri/henri/commit/b7038ceaa430f4a0b9eaf7e983fc2844421bf636), [`1ea0f85`](https://github.com/usehenri/henri/commit/1ea0f85066b86fba31f58937cc10abb6359e6a26), [`762062a`](https://github.com/usehenri/henri/commit/762062aadc450d49b1a2d15524f9d579ab4f60e7), [`01a561a`](https://github.com/usehenri/henri/commit/01a561aa58650ec15df1c2659795a5e4c5bbfd53), [`bd1b630`](https://github.com/usehenri/henri/commit/bd1b63083b3817b8c47b8a187de76027458a1b32), [`27b5513`](https://github.com/usehenri/henri/commit/27b5513d1cae8aba734fe27da0ace2a82423e2f4), [`e31a3f7`](https://github.com/usehenri/henri/commit/e31a3f73e7e8facf3cedf7460f115e57995f32c3), [`2689779`](https://github.com/usehenri/henri/commit/26897798b840fd28a4bc091c050a83457b36905d), [`72cd1d3`](https://github.com/usehenri/henri/commit/72cd1d35ffb99311bbca815c1f6ab41ee3682f64), [`a2e1ec2`](https://github.com/usehenri/henri/commit/a2e1ec29df52462f12ebaae9bfbc1ad4f427b27f), [`afead74`](https://github.com/usehenri/henri/commit/afead7489498ed42e1893a25123ea772cac2ca09), [`a4ecba5`](https://github.com/usehenri/henri/commit/a4ecba50c663f4d5c741adbb6cd9bc0eefe0e5cc), [`baec3fd`](https://github.com/usehenri/henri/commit/baec3fd22be92bf8ffbaeb251b0b6c2771f8347a), [`1103628`](https://github.com/usehenri/henri/commit/110362808f8ec6d73a75ff7fc89a77f3e943d773), [`e865d94`](https://github.com/usehenri/henri/commit/e865d945d65419ac676f5a2fe3ba3b6114a1e53d), [`4274567`](https://github.com/usehenri/henri/commit/4274567e20a980657f07df9ec7db25296c7d55f5), [`aea429c`](https://github.com/usehenri/henri/commit/aea429ca99338a62370ab3e3d94bdc6b8c227601), [`8a8e3b3`](https://github.com/usehenri/henri/commit/8a8e3b33d7967b81f66633aa25c3075318f01d60), [`18715f9`](https://github.com/usehenri/henri/commit/18715f90ea8958dc57da1bb029b8209c36b84cc6), [`0d2ebc3`](https://github.com/usehenri/henri/commit/0d2ebc344bfcd80533ef900638083e4c105407bb), [`49398a6`](https://github.com/usehenri/henri/commit/49398a6308f0760f01c6ff2ec98aaa35f484474d), [`ec64e44`](https://github.com/usehenri/henri/commit/ec64e44e7b79a02da5fc587a72a9a6c900836982), [`831aa5c`](https://github.com/usehenri/henri/commit/831aa5c011f3432630c68b8d26755d2582f82f74), [`16824e8`](https://github.com/usehenri/henri/commit/16824e8fe9ccc6a04dab5d9b2481c29ff4f6b64b), [`fda9366`](https://github.com/usehenri/henri/commit/fda9366e9ed2b072764a995c5aa60205ca7a4725), [`1a86acb`](https://github.com/usehenri/henri/commit/1a86acbf15e4a43e5fb81277bb22e101c06e77a4), [`808d824`](https://github.com/usehenri/henri/commit/808d82471d59e64ccc735f617bab293eb572c46b), [`0b32fbd`](https://github.com/usehenri/henri/commit/0b32fbde19c95da8fe07fab76933840a4242c71c), [`c0c16e8`](https://github.com/usehenri/henri/commit/c0c16e873ba440aee9832160553cbb12ab81bd2c), [`41470bf`](https://github.com/usehenri/henri/commit/41470bf378d83ca3d35d00e8c31796fea5eb15e0), [`8e44e7e`](https://github.com/usehenri/henri/commit/8e44e7e882dd8741b3ac632651b453389d76bf2c), [`de1c1e0`](https://github.com/usehenri/henri/commit/de1c1e02ed83d13dcfeb8e44012f309eb663f03e), [`4b4677d`](https://github.com/usehenri/henri/commit/4b4677d4a09d39fe50b1fa4af577600342578daf)]:
|
|
88
|
+
- @usehenri/core@1.2.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2016-present, Félix-Antoine Paradis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
<!-- generated by scripts/prepublish.js -->
|
|
2
|
+
|
|
1
3
|
# @usehenri/redis
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
Henri shared store: the rate limit, the sign-in lockout and the idempotency keys counted in Redis instead of one process.
|
|
6
|
+
|
|
7
|
+
Part of [henri](https://usehenri.io), the Rails-like React framework for
|
|
8
|
+
Node.js: [documentation](https://usehenri.io),
|
|
9
|
+
[source and issues](https://github.com/usehenri/henri),
|
|
10
|
+
[changelog](https://github.com/usehenri/henri/blob/master/packages/redis/CHANGELOG.md).
|
package/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@usehenri/redis`: the shared store `config.shared` names.
|
|
3
|
+
*
|
|
4
|
+
* ```json
|
|
5
|
+
* { "shared": { "adapter": "redis", "url": "redis://127.0.0.1:6379" } }
|
|
6
|
+
* ```
|
|
7
|
+
*
|
|
8
|
+
* Core resolves this package from the application (`utils.resolveFrom`) and
|
|
9
|
+
* constructs the default export with the normalized block, the way it loads
|
|
10
|
+
* a database adapter. See `packages/core/src/base/shared.js`.
|
|
11
|
+
*/
|
|
12
|
+
module.exports = require('./src/backend');
|
package/package.json
CHANGED
|
@@ -1,15 +1,50 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "Placeholder creating @usehenri/redis on npm; the first real version is published by the henri release workflow",
|
|
3
|
-
"homepage": "https://usehenri.io",
|
|
4
|
-
"license": "MIT",
|
|
5
2
|
"name": "@usehenri/redis",
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "henri shared store: the rate limit, the sign-in lockout and the idempotency keys counted in Redis instead of one process",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Felix-Antoine Paradis",
|
|
7
|
+
"homepage": "https://usehenri.io",
|
|
9
8
|
"repository": {
|
|
10
|
-
"directory": "packages/redis",
|
|
11
9
|
"type": "git",
|
|
12
|
-
"url": "git+https://github.com/usehenri/henri.git"
|
|
10
|
+
"url": "git+https://github.com/usehenri/henri.git",
|
|
11
|
+
"directory": "packages/redis"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/usehenri/henri/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"henri",
|
|
18
|
+
"redis",
|
|
19
|
+
"rate-limit",
|
|
20
|
+
"idempotency",
|
|
21
|
+
"lockout",
|
|
22
|
+
"shared"
|
|
23
|
+
],
|
|
24
|
+
"main": "index.js",
|
|
25
|
+
"files": [
|
|
26
|
+
"index.js",
|
|
27
|
+
"src",
|
|
28
|
+
"CHANGELOG.md"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public",
|
|
32
|
+
"provenance": true
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=22"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"debug": "^4.4.3",
|
|
39
|
+
"rate-limit-redis": "^6.0.1",
|
|
40
|
+
"redis": "^6.2.1"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@usehenri/core": "^1.2.0"
|
|
13
44
|
},
|
|
14
|
-
"
|
|
15
|
-
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@usehenri/core": "^1.2.0",
|
|
47
|
+
"express": "^5.2.1",
|
|
48
|
+
"supertest": "^7.2.2"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/backend.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
const { RedisStore } = require('rate-limit-redis');
|
|
2
|
+
const debug = require('debug')('henri:redis');
|
|
3
|
+
const { createClient } = require('redis');
|
|
4
|
+
|
|
5
|
+
const KeyValueStore = require('./kv');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The Redis backend of `config.shared`.
|
|
9
|
+
*
|
|
10
|
+
* henri's rate limit, sign-in lockout and idempotency keys are numbers per
|
|
11
|
+
* key, and all three were per process. `config.shared` names where they are
|
|
12
|
+
* counted instead; this is what that name resolves to:
|
|
13
|
+
*
|
|
14
|
+
* ```json
|
|
15
|
+
* {
|
|
16
|
+
* "shared": {
|
|
17
|
+
* "adapter": "redis",
|
|
18
|
+
* "url": "redis://127.0.0.1:6379",
|
|
19
|
+
* "prefix": "lineup:"
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* Core loads it from the application with `utils.resolveFrom`, the way it
|
|
25
|
+
* loads a store adapter, so an application that does not name it never
|
|
26
|
+
* installs it. Nothing here is a henri module: there is no runlevel to sit
|
|
27
|
+
* at, because the server needs the counters at runlevel 2 and the sign-in
|
|
28
|
+
* lockout at 4.
|
|
29
|
+
*
|
|
30
|
+
* ## Why node-redis
|
|
31
|
+
*
|
|
32
|
+
* `redis` (node-redis), not `ioredis`. ioredis' own README now sends new
|
|
33
|
+
* projects to node-redis and calls its own maintenance best-effort, and
|
|
34
|
+
* node-redis is the client Redis develops. Two of its properties are the
|
|
35
|
+
* reason it fits here rather than merely being acceptable:
|
|
36
|
+
*
|
|
37
|
+
* - `connect()` is explicit, so a deployment learns at boot that the server
|
|
38
|
+
* is unreachable instead of at the first request that needed a counter;
|
|
39
|
+
* - `disableOfflineQueue` turns a command sent while disconnected into an
|
|
40
|
+
* immediate rejection rather than a queued promise. A fail-closed guard
|
|
41
|
+
* has to answer 503 in milliseconds; a queued command would hang the
|
|
42
|
+
* request until the reconnect, which is the worst of both answers.
|
|
43
|
+
*
|
|
44
|
+
* `rate-limit-redis` (the store express-rate-limit's own author maintains)
|
|
45
|
+
* takes `sendCommand`, so the limiter behaves exactly as it does in every
|
|
46
|
+
* other express-rate-limit deployment.
|
|
47
|
+
*
|
|
48
|
+
* ## Connecting
|
|
49
|
+
*
|
|
50
|
+
* The reconnect never gives up and never storms: the delay doubles from
|
|
51
|
+
* 100ms to ten seconds. `start()` waits `connectTimeout` for the first
|
|
52
|
+
* connection and then stops waiting, without failing -- the client keeps
|
|
53
|
+
* reconnecting, `GET /readyz` reports it, and requests meanwhile follow
|
|
54
|
+
* `config.shared.onError`.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** How long `start()` waits for the first connection (ms) */
|
|
58
|
+
const CONNECT_TIMEOUT = 5000;
|
|
59
|
+
|
|
60
|
+
/** The longest the reconnect ever waits between attempts (ms) */
|
|
61
|
+
const MAX_BACKOFF = 10000;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* How long to wait before the next connection attempt: 100ms doubling to
|
|
65
|
+
* ten seconds, so a server that is down for an hour costs a handful of
|
|
66
|
+
* connections a minute instead of a storm
|
|
67
|
+
*
|
|
68
|
+
* @param {number} retries how many attempts have failed
|
|
69
|
+
* @returns {number} the delay (ms)
|
|
70
|
+
*/
|
|
71
|
+
const backoff = (retries) =>
|
|
72
|
+
Math.min(100 * 2 ** Math.min(Number(retries) || 0, 7), MAX_BACKOFF);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The url with its password replaced, so it can be printed
|
|
76
|
+
*
|
|
77
|
+
* @param {string} url a connection string
|
|
78
|
+
* @returns {string} the url, without the password
|
|
79
|
+
*/
|
|
80
|
+
function redact(url) {
|
|
81
|
+
return String(url).replace(/^(\w+:\/\/[^:/@]*:)[^@/]*@/u, '$1[FILTERED]@');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A promise that rejects after a delay
|
|
86
|
+
*
|
|
87
|
+
* @param {Promise} promise the promise to bound
|
|
88
|
+
* @param {number} ms how long it may take
|
|
89
|
+
* @param {string} message what to say when it does not
|
|
90
|
+
* @returns {Promise} the promise, bounded
|
|
91
|
+
*/
|
|
92
|
+
function withTimeout(promise, ms, message) {
|
|
93
|
+
let timer = null;
|
|
94
|
+
|
|
95
|
+
return Promise.race([
|
|
96
|
+
promise,
|
|
97
|
+
new Promise((resolve, reject) => {
|
|
98
|
+
timer = setTimeout(() => reject(new Error(message)), ms);
|
|
99
|
+
timer.unref();
|
|
100
|
+
}),
|
|
101
|
+
]).finally(() => clearTimeout(timer));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The keys of `config.shared` henri owns; everything else is a node-redis
|
|
106
|
+
* option and reaches `createClient()`
|
|
107
|
+
*/
|
|
108
|
+
const OWN = ['adapter', 'connectTimeout', 'enabled', 'onError', 'prefix'];
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The Redis shared store
|
|
112
|
+
*
|
|
113
|
+
* @class RedisBackend
|
|
114
|
+
*/
|
|
115
|
+
class RedisBackend {
|
|
116
|
+
/**
|
|
117
|
+
* Creates an instance of RedisBackend.
|
|
118
|
+
*
|
|
119
|
+
* @param {object} [settings={}] the normalized `config.shared`
|
|
120
|
+
* @param {?object} [henri=null] the henri instance, when there is one
|
|
121
|
+
* (`henri doctor` builds one without booting)
|
|
122
|
+
* @memberof RedisBackend
|
|
123
|
+
*/
|
|
124
|
+
constructor(settings = {}, henri = null) {
|
|
125
|
+
const options = {};
|
|
126
|
+
|
|
127
|
+
for (const key of Object.keys(settings)) {
|
|
128
|
+
if (!OWN.includes(key)) {
|
|
129
|
+
options[key] = settings[key];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this.name = 'redis';
|
|
134
|
+
this.henri = henri;
|
|
135
|
+
this.prefix = settings.prefix || 'henri:';
|
|
136
|
+
this.connectTimeout =
|
|
137
|
+
Number(settings.connectTimeout) > 0
|
|
138
|
+
? Number(settings.connectTimeout)
|
|
139
|
+
: CONNECT_TIMEOUT;
|
|
140
|
+
this.url = options.url || 'redis://127.0.0.1:6379';
|
|
141
|
+
this.options = Object.assign({ url: this.url }, options, {
|
|
142
|
+
// A command sent while the connection is down fails now instead of
|
|
143
|
+
// waiting for a reconnect that may be minutes away
|
|
144
|
+
disableOfflineQueue: true,
|
|
145
|
+
socket: Object.assign(
|
|
146
|
+
{ connectTimeout: this.connectTimeout, reconnectStrategy: backoff },
|
|
147
|
+
options.socket
|
|
148
|
+
),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
this.client = null;
|
|
152
|
+
this.connecting = null;
|
|
153
|
+
this.lastError = null;
|
|
154
|
+
this.sendCommand = this.sendCommand.bind(this);
|
|
155
|
+
this.connected = this.connected.bind(this);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* What this is talking to, with the password taken out
|
|
160
|
+
*
|
|
161
|
+
* @returns {string} the redacted url
|
|
162
|
+
* @memberof RedisBackend
|
|
163
|
+
*/
|
|
164
|
+
describe() {
|
|
165
|
+
return redact(this.url);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Opens the connection, at most once at a time.
|
|
170
|
+
*
|
|
171
|
+
* It resolves when the client is ready and rejects when the first attempt
|
|
172
|
+
* has not landed within `connectTimeout` -- which does not stop the
|
|
173
|
+
* client: it keeps reconnecting on its own, and the next call finds it
|
|
174
|
+
* ready.
|
|
175
|
+
*
|
|
176
|
+
* @returns {Promise<object>} the client
|
|
177
|
+
* @throws when the first connection does not land in time
|
|
178
|
+
* @memberof RedisBackend
|
|
179
|
+
*/
|
|
180
|
+
async start() {
|
|
181
|
+
if (this.client && this.client.isReady) {
|
|
182
|
+
return this.client;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!this.client) {
|
|
186
|
+
this.client = createClient(this.options);
|
|
187
|
+
// An unhandled `error` event throws, and there is one on every failed
|
|
188
|
+
// reconnect. The log is not the place for them: what a request does
|
|
189
|
+
// about it is core's business (`config.shared.onError`), and the boot
|
|
190
|
+
// line and `GET /readyz` already say whether the server is up.
|
|
191
|
+
this.client.on('error', (error) => {
|
|
192
|
+
this.lastError = error;
|
|
193
|
+
debug('client error: %s', error.message);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!this.connecting) {
|
|
198
|
+
this.connecting = Promise.resolve()
|
|
199
|
+
.then(() => (this.client.isOpen ? null : this.client.connect()))
|
|
200
|
+
.finally(() => {
|
|
201
|
+
this.connecting = null;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
await withTimeout(
|
|
206
|
+
this.connecting,
|
|
207
|
+
this.connectTimeout,
|
|
208
|
+
`redis did not answer within ${this.connectTimeout}ms${
|
|
209
|
+
this.lastError ? ` (${this.lastError.message})` : ''
|
|
210
|
+
}`
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
return this.client;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The connected client, connecting when it is not.
|
|
218
|
+
*
|
|
219
|
+
* Every store call goes through this: a client that is up answers with no
|
|
220
|
+
* added work, and one that is not fails now rather than queueing.
|
|
221
|
+
*
|
|
222
|
+
* @returns {Promise<object>} the client
|
|
223
|
+
* @throws when it is not connected
|
|
224
|
+
* @memberof RedisBackend
|
|
225
|
+
*/
|
|
226
|
+
async connected() {
|
|
227
|
+
if (this.client && this.client.isReady) {
|
|
228
|
+
return this.client;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (this.client && this.client.isOpen) {
|
|
232
|
+
// Open but not ready: reconnecting. Fail now; the guard decides.
|
|
233
|
+
throw new Error(
|
|
234
|
+
`redis is not connected${this.lastError ? ` (${this.lastError.message})` : ''}`
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return this.start();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Sends a raw command, which is all `rate-limit-redis` needs
|
|
243
|
+
*
|
|
244
|
+
* @param {...string} args the command and its arguments
|
|
245
|
+
* @returns {Promise<*>} what Redis answered
|
|
246
|
+
* @memberof RedisBackend
|
|
247
|
+
*/
|
|
248
|
+
async sendCommand(...args) {
|
|
249
|
+
const client = await this.connected();
|
|
250
|
+
|
|
251
|
+
return client.sendCommand(args.map(String));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Whether the server answers
|
|
256
|
+
*
|
|
257
|
+
* @returns {Promise<boolean>} true when it did
|
|
258
|
+
* @throws when it did not
|
|
259
|
+
* @memberof RedisBackend
|
|
260
|
+
*/
|
|
261
|
+
async ping() {
|
|
262
|
+
const client = await this.connected();
|
|
263
|
+
|
|
264
|
+
await withTimeout(
|
|
265
|
+
client.ping(),
|
|
266
|
+
this.connectTimeout,
|
|
267
|
+
`redis did not answer PING within ${this.connectTimeout}ms`
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* An express-rate-limit store, one key space per limiter
|
|
275
|
+
*
|
|
276
|
+
* @param {string} feature the limiter name (`global`, `auth`, `lockout`)
|
|
277
|
+
* @returns {object} the store
|
|
278
|
+
* @memberof RedisBackend
|
|
279
|
+
*/
|
|
280
|
+
rateLimitStore(feature) {
|
|
281
|
+
return new RedisStore({
|
|
282
|
+
prefix: `${this.prefix}rl:${feature}:`,
|
|
283
|
+
sendCommand: this.sendCommand,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A `{ get, set, add, delete, clear }` store, one key space per feature
|
|
289
|
+
*
|
|
290
|
+
* @param {string} feature what the keys are for (`idempotency`, `cache`)
|
|
291
|
+
* @param {object} [options={}] `raw` stores the string it is handed as it
|
|
292
|
+
* is, for a caller that has serialized already (the cache)
|
|
293
|
+
* @returns {KeyValueStore} the store
|
|
294
|
+
* @memberof RedisBackend
|
|
295
|
+
*/
|
|
296
|
+
keyValueStore(feature, options = {}) {
|
|
297
|
+
return new KeyValueStore({
|
|
298
|
+
client: this.connected,
|
|
299
|
+
prefix: `${this.prefix}kv:${feature}:`,
|
|
300
|
+
raw: options.raw === true,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Closes the connection
|
|
306
|
+
*
|
|
307
|
+
* @returns {Promise<boolean>} done
|
|
308
|
+
* @memberof RedisBackend
|
|
309
|
+
*/
|
|
310
|
+
async stop() {
|
|
311
|
+
const { client } = this;
|
|
312
|
+
|
|
313
|
+
this.client = null;
|
|
314
|
+
this.connecting = null;
|
|
315
|
+
|
|
316
|
+
if (!client) {
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
await withTimeout(client.close(), 1000, 'redis did not close in time');
|
|
322
|
+
} catch (error) {
|
|
323
|
+
debug('closing: %s', error.message);
|
|
324
|
+
client.destroy();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
module.exports = RedisBackend;
|
|
332
|
+
module.exports.CONNECT_TIMEOUT = CONNECT_TIMEOUT;
|
|
333
|
+
module.exports.MAX_BACKOFF = MAX_BACKOFF;
|
|
334
|
+
module.exports.backoff = backoff;
|
|
335
|
+
module.exports.redact = redact;
|
package/src/kv.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
const debug = require('debug')('henri:redis');
|
|
2
|
+
|
|
3
|
+
/** How many keys a `clear()` asks Redis for per round trip */
|
|
4
|
+
const SCAN_COUNT = 250;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `{ get, set, add, delete }` store the idempotency keys use, on Redis.
|
|
8
|
+
*
|
|
9
|
+
* Values are JSON, the expiry is Redis' own (`PX`), and `add()` is one
|
|
10
|
+
* `SET ... NX PX` -- an atomic claim, which is what makes two processes
|
|
11
|
+
* racing on the same `Idempotency-Key` end with one execution and one 409
|
|
12
|
+
* rather than two executions.
|
|
13
|
+
*
|
|
14
|
+
* `raw` turns the JSON off, for a caller that has already serialized: the
|
|
15
|
+
* cache hands over the string it encoded and measured, and encoding it a
|
|
16
|
+
* second time would only escape every quote in it.
|
|
17
|
+
*
|
|
18
|
+
* Nothing here catches: a command that fails rejects, and `base/shared.js`
|
|
19
|
+
* in core decides what that means (for these keys, always a 503).
|
|
20
|
+
*
|
|
21
|
+
* @class KeyValueStore
|
|
22
|
+
*/
|
|
23
|
+
class KeyValueStore {
|
|
24
|
+
/**
|
|
25
|
+
* Creates an instance of KeyValueStore.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} options options
|
|
28
|
+
* @param {function(): Promise<object>} options.client resolves the connected client
|
|
29
|
+
* @param {string} options.prefix what every key is prefixed with
|
|
30
|
+
* @param {boolean} [options.raw=false] store the string as it arrives
|
|
31
|
+
* @memberof KeyValueStore
|
|
32
|
+
*/
|
|
33
|
+
constructor({ client, prefix, raw = false }) {
|
|
34
|
+
this.client = client;
|
|
35
|
+
this.prefix = prefix;
|
|
36
|
+
this.raw = Boolean(raw);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The Redis key of an entry
|
|
41
|
+
*
|
|
42
|
+
* @param {string} key the key the caller used
|
|
43
|
+
* @returns {string} the prefixed key
|
|
44
|
+
* @memberof KeyValueStore
|
|
45
|
+
*/
|
|
46
|
+
key(key) {
|
|
47
|
+
return `${this.prefix}${key}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Reads an entry
|
|
52
|
+
*
|
|
53
|
+
* @param {string} key the key
|
|
54
|
+
* @returns {Promise<*>} the value, or undefined when there is none
|
|
55
|
+
* @memberof KeyValueStore
|
|
56
|
+
*/
|
|
57
|
+
async get(key) {
|
|
58
|
+
const client = await this.client();
|
|
59
|
+
const raw = await client.get(this.key(key));
|
|
60
|
+
|
|
61
|
+
if (raw === null || typeof raw === 'undefined') {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (this.raw) {
|
|
66
|
+
return raw;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(raw);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// Something else wrote this key: forget it rather than fail every
|
|
73
|
+
// retry of a request that can never replay
|
|
74
|
+
debug('unreadable entry at %s: %s', key, error.message);
|
|
75
|
+
await client.del(this.key(key));
|
|
76
|
+
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* What is written down for a value: JSON, or the string itself in `raw`
|
|
83
|
+
*
|
|
84
|
+
* @param {*} value what the caller is storing
|
|
85
|
+
* @returns {string} what Redis is handed
|
|
86
|
+
* @memberof KeyValueStore
|
|
87
|
+
*/
|
|
88
|
+
payload(value) {
|
|
89
|
+
return this.raw ? String(value) : JSON.stringify(value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* How long an entry lives, as Redis takes it, or nothing at all.
|
|
94
|
+
*
|
|
95
|
+
* A ttl is what every counter here has, but not everything on this
|
|
96
|
+
* backend is a counter: `henri.flags` keeps the state of a feature
|
|
97
|
+
* switch, and a switch that turned itself back on after a fortnight
|
|
98
|
+
* would be the worst failure that module could have. So a call that
|
|
99
|
+
* names no expiry gets none, rather than a very long one that is a bug
|
|
100
|
+
* with a date on it.
|
|
101
|
+
*
|
|
102
|
+
* @param {*} ttl how long it lives (ms), or nothing
|
|
103
|
+
* @returns {?object} the options for `SET`, or null for no expiry
|
|
104
|
+
* @memberof KeyValueStore
|
|
105
|
+
*/
|
|
106
|
+
expiry(ttl) {
|
|
107
|
+
const found = Number(ttl);
|
|
108
|
+
|
|
109
|
+
return Number.isFinite(found) && found > 0
|
|
110
|
+
? { PX: Math.max(1, Math.round(found)) }
|
|
111
|
+
: null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Writes an entry, replacing whatever was there
|
|
116
|
+
*
|
|
117
|
+
* @param {string} key the key
|
|
118
|
+
* @param {*} value anything JSON-serializable (a string in `raw`)
|
|
119
|
+
* @param {number} [ttl] how long it lives (ms); forever without one
|
|
120
|
+
* @returns {Promise<void>} done
|
|
121
|
+
* @memberof KeyValueStore
|
|
122
|
+
*/
|
|
123
|
+
async set(key, value, ttl) {
|
|
124
|
+
const client = await this.client();
|
|
125
|
+
const expiry = this.expiry(ttl);
|
|
126
|
+
|
|
127
|
+
await client.set(this.key(key), this.payload(value), expiry || undefined);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Writes an entry unless the key is taken. One round trip, atomic across
|
|
132
|
+
* every process talking to this server.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} key the key
|
|
135
|
+
* @param {*} value anything JSON-serializable
|
|
136
|
+
* @param {number} [ttl] how long it lives (ms); forever without one
|
|
137
|
+
* @returns {Promise<boolean>} true when this call wrote it
|
|
138
|
+
* @memberof KeyValueStore
|
|
139
|
+
*/
|
|
140
|
+
async add(key, value, ttl) {
|
|
141
|
+
const client = await this.client();
|
|
142
|
+
const answer = await client.set(this.key(key), this.payload(value), {
|
|
143
|
+
NX: true,
|
|
144
|
+
...this.expiry(ttl),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return answer !== null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Removes an entry
|
|
152
|
+
*
|
|
153
|
+
* @param {string} key the key
|
|
154
|
+
* @returns {Promise<void>} done
|
|
155
|
+
* @memberof KeyValueStore
|
|
156
|
+
*/
|
|
157
|
+
async delete(key) {
|
|
158
|
+
const client = await this.client();
|
|
159
|
+
|
|
160
|
+
await client.del(this.key(key));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Removes every key of this store, or of a prefix inside it.
|
|
165
|
+
*
|
|
166
|
+
* `SCAN` and `UNLINK`, never `KEYS` and never `FLUSHDB`: the server may
|
|
167
|
+
* be holding somebody else's data and this only ever touches the store's
|
|
168
|
+
* own key space. It walks the whole keyspace to do it, so it belongs in a
|
|
169
|
+
* teardown or a console rather than in a request.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} [prefix=''] a prefix inside the store (all of it when empty)
|
|
172
|
+
* @returns {Promise<number>} how many keys were removed
|
|
173
|
+
* @memberof KeyValueStore
|
|
174
|
+
*/
|
|
175
|
+
async clear(prefix = '') {
|
|
176
|
+
const client = await this.client();
|
|
177
|
+
const match = `${this.key(prefix)}*`;
|
|
178
|
+
let cursor = '0';
|
|
179
|
+
let removed = 0;
|
|
180
|
+
|
|
181
|
+
do {
|
|
182
|
+
const page = await client.scan(cursor, {
|
|
183
|
+
COUNT: SCAN_COUNT,
|
|
184
|
+
MATCH: match,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
cursor = String(page.cursor);
|
|
188
|
+
|
|
189
|
+
if (page.keys.length > 0) {
|
|
190
|
+
removed += await client.unlink(page.keys);
|
|
191
|
+
}
|
|
192
|
+
} while (cursor !== '0');
|
|
193
|
+
|
|
194
|
+
debug('cleared %d keys matching %s', removed, match);
|
|
195
|
+
|
|
196
|
+
return removed;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = KeyValueStore;
|