Skip to content

Commit 4233475

Browse files
committed
feat!: use hashtags to ensure hyper cache store keys hash to the same slot #22 #17
BREAKING CHANGE: all cache store keys that currently exist in a hyper cache will MISS. The the values will need to be recomputed and then re-stored in hyper cache
1 parent 4c5338d commit 4233475

File tree

2 files changed

+41
-15
lines changed

2 files changed

+41
-15
lines changed

adapter.js

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,23 @@ import { handleHyperErr } from './utils.js'
44
const { Async } = crocks
55
const { always, append, identity, ifElse, isNil, map, not, compose } = R
66

7-
const createKey = (store, key) => `${store}_${key}`
7+
/**
8+
* Create a hyper store cache key, with the given prefix.
9+
* By default, the prefix is hashtagged to ensure all keys "contained" in the prefix
10+
* are stored within the same hash slot. This is useful when performing multi-key operations
11+
* on a Redis cluster. See https://github.com/hyper63/hyper-adapter-redis/issues/17
12+
*
13+
* See: https://redis.io/docs/reference/cluster-spec/#hash-tags
14+
*
15+
* @param {string} prefix - the prefix for the key, typically the hyper cache store name
16+
* @param {string} key - the key
17+
* @param {boolean} [noHashSlot] - whether to hashtag the prefix, which will cause all keys
18+
* "within" this prefix to be mapped to the same hash slot. This is useful for multi-key operations
19+
* performed on a Redis cluster. Defaults to false, which is to say "hashtag the prefix"
20+
* @returns
21+
*/
22+
const createKey = (prefix, key, noHashSlot = false) =>
23+
`${noHashSlot ? `${prefix}` : `{${prefix}}`}_${key}`
824

925
const mapTtl = (ttl) =>
1026
ifElse(
@@ -48,7 +64,7 @@ export default function (client, options) {
4864
}
4965

5066
const checkIfStoreExists = (store) => (key) =>
51-
get(createKey('store', store))
67+
get(createKey('store', store, true))
5268
.chain(
5369
(_) =>
5470
_ ? Async.Resolved(key) : Async.Rejected(HyperErr({
@@ -77,7 +93,7 @@ export default function (client, options) {
7793
*/
7894
const createStore = (name) =>
7995
Async.of([])
80-
.map(append(createKey('store', name)))
96+
.map(append(createKey('store', name, true)))
8197
.map(append('active'))
8298
.chain((args) => set(...args))
8399
.bichain(
@@ -113,7 +129,7 @@ export default function (client, options) {
113129
),
114130
)
115131
// Delete the key that tracks the store's existence
116-
.chain(() => del(createKey('store', name)))
132+
.chain(() => del(createKey('store', name, true)))
117133
.bichain(
118134
handleHyperErr,
119135
always(Async.Resolved({ ok: true })),
@@ -146,8 +162,9 @@ export default function (client, options) {
146162
* @returns {Promise<object>}
147163
*/
148164
const getDoc = ({ store, key }) =>
149-
checkIfStoreExists(store)()
150-
.chain(() => get(createKey(store, key)))
165+
Async.of(createKey(store, key))
166+
.chain(checkIfStoreExists(store))
167+
.chain(get)
151168
.chain((v) => {
152169
if (!v) {
153170
return Async.Rejected(HyperErr({

adapter.test.js

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,11 @@ Deno.test('adapter', async (t) => {
4141
})
4242

4343
assertObjectMatch(scan.calls[0].args, [0, {
44-
pattern: 'word_*',
44+
pattern: '{word}_*',
4545
count: 100,
4646
}])
4747
assertObjectMatch(scan.calls[1].args, ['50', {
48-
pattern: 'word_*',
48+
pattern: '{word}_*',
4949
count: 100,
5050
}])
5151
assert(results.docs.length === 100)
@@ -122,14 +122,14 @@ Deno.test('adapter', async (t) => {
122122
const adapter = createAdapter({
123123
...baseStubClient,
124124
del,
125-
scan: resolves(['0', ['baz', 'bar']]),
125+
scan: resolves(['0', ['{foo}_baz', '{foo}_bar']]),
126126
}, baseOptions)
127127

128128
const result = await adapter.destroyStore('foo')
129129

130130
assert(result.ok)
131-
assertObjectMatch(del.calls[0], { args: ['baz'] })
132-
assertObjectMatch(del.calls[1], { args: ['bar'] })
131+
assertObjectMatch(del.calls[0], { args: ['{foo}_baz'] })
132+
assertObjectMatch(del.calls[1], { args: ['{foo}_bar'] })
133133
assertObjectMatch(del.calls[2], { args: ['store_foo'] })
134134
},
135135
)
@@ -139,7 +139,8 @@ Deno.test('adapter', async (t) => {
139139
await t.step('should save the doc in redis as serialized JSON', async () => {
140140
const adapter = createAdapter({
141141
...baseStubClient,
142-
set: (_k, v, opts) => {
142+
set: (k, v, opts) => {
143+
assertEquals(k, '{foo}_bar')
143144
assertEquals(v, JSON.stringify({ bam: 'baz' }))
144145
assertObjectMatch(opts, { px: 5000 })
145146
return Promise.resolve('OK')
@@ -229,7 +230,11 @@ Deno.test('adapter', async (t) => {
229230
const adapter = createAdapter({
230231
...baseStubClient,
231232
// serialized JSON
232-
get: resolves(JSON.stringify(value)),
233+
get: (k) => {
234+
return k === 'store_foo'
235+
? Promise.resolve(JSON.stringify({ active: true })) // store
236+
: (assertEquals(k, '{foo}_bar'), Promise.resolve(JSON.stringify(value)))
237+
},
233238
}, baseOptions)
234239

235240
const result = await adapter.getDoc({
@@ -266,12 +271,16 @@ Deno.test('adapter', async (t) => {
266271
await t.step('should upsert the doc into Redis as serialized JSON', async () => {
267272
const adapter = createAdapter({
268273
...baseStubClient,
269-
set: (_k, v, opts) => {
274+
set: (k, v, opts) => {
275+
assertEquals(k, '{foo}_bar')
270276
assertEquals(v, JSON.stringify({ hello: 'world' }))
271277
assertObjectMatch(opts, { px: 123 })
272278
return Promise.resolve('OK')
273279
},
274-
get: (k) => k === 'store_foo' ? Promise.resolve('{"active": true}') : Promise.resolve(null),
280+
get: (k) =>
281+
k === 'store_foo'
282+
? Promise.resolve(JSON.stringify({ active: true }))
283+
: Promise.resolve(undefined),
275284
}, baseOptions)
276285

277286
const result = await adapter.updateDoc({

0 commit comments

Comments
 (0)