@brust/cache-redis 2.0.0-alpha.3 → 2.0.0-alpha.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/package.json +9 -4
- package/src/adapter.ts +20 -7
- package/src/lua.ts +7 -1
- package/src/pairing.ts +11 -6
package/package.json
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brust/cache-redis",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.5",
|
|
4
4
|
"description": "brust cache adapter for Redis and Dragonfly (Bun RedisClient, no npm dependency)",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"homepage": "https://brust.dev",
|
|
6
7
|
"repository": {
|
|
7
8
|
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/AssetsArt/brust.git"
|
|
9
|
+
"url": "git+https://github.com/AssetsArt/brust.git",
|
|
10
|
+
"directory": "packages/cache-redis"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/AssetsArt/brust/issues"
|
|
9
14
|
},
|
|
10
15
|
"type": "module",
|
|
11
16
|
"exports": {
|
|
@@ -24,10 +29,10 @@
|
|
|
24
29
|
},
|
|
25
30
|
"dependencies": {},
|
|
26
31
|
"peerDependencies": {
|
|
27
|
-
"@brust/core": "2.0.0-alpha.
|
|
32
|
+
"@brust/core": "2.0.0-alpha.5"
|
|
28
33
|
},
|
|
29
34
|
"devDependencies": {
|
|
30
|
-
"@brust/core": "2.0.0-alpha.
|
|
35
|
+
"@brust/core": "2.0.0-alpha.5",
|
|
31
36
|
"@types/bun": "^1.4.0",
|
|
32
37
|
"typescript": "^7.0.2"
|
|
33
38
|
},
|
package/src/adapter.ts
CHANGED
|
@@ -8,10 +8,13 @@ import { parseOptions, type ResolvedOptions } from './options'
|
|
|
8
8
|
import { type SubscriberState, startSubscriber } from './subscriber'
|
|
9
9
|
|
|
10
10
|
const STAGE_TTL_MS = 30_000
|
|
11
|
+
const MAX_ORPHAN_BATCHES = 64
|
|
11
12
|
|
|
12
13
|
export interface RedisAdapterTest {
|
|
13
14
|
/** Close the subscriber socket as a network loss would. */
|
|
14
15
|
dropSubscriber(): void
|
|
16
|
+
/** Close the command socket as a network loss would (the loop reconnects). */
|
|
17
|
+
dropCommands(): void
|
|
15
18
|
state(): { connected: boolean; subscribed: boolean; last: number | null; reconnects: number }
|
|
16
19
|
/** The command client (raw access for tests). */
|
|
17
20
|
client(): import('bun').RedisClient
|
|
@@ -30,7 +33,9 @@ export function createAdapter(name: 'redis' | 'dragonfly', raw: Record<string, u
|
|
|
30
33
|
throw new Error('cache-redis: used before init')
|
|
31
34
|
}
|
|
32
35
|
const sub: SubscriberState = { link: null, subscribed: false, last: null }
|
|
33
|
-
const pairing = new Pairing(() => link().c, k, o.markerTtlMs)
|
|
36
|
+
const pairing = new Pairing(() => link().c, k, o.markerTtlMs, () => k().channel)
|
|
37
|
+
// N2: page batches whose UNLINK failed (the link dropped between the script and the delete); retried once on reconnect.
|
|
38
|
+
const orphaned: string[][] = []
|
|
34
39
|
|
|
35
40
|
const store = {
|
|
36
41
|
maxBodyBytes: o.maxBodyBytes,
|
|
@@ -70,16 +75,21 @@ export function createAdapter(name: 'redis' | 'dragonfly', raw: Record<string, u
|
|
|
70
75
|
if (!members.length) return
|
|
71
76
|
const c = link().c
|
|
72
77
|
// UNLINK frees off the main thread (Dragonfly too); batched so one call never carries every key.
|
|
73
|
-
const
|
|
74
|
-
for (let i = 0; i < members.length; i += o.unlinkBatch)
|
|
75
|
-
await Promise.
|
|
78
|
+
const batches: string[][] = []
|
|
79
|
+
for (let i = 0; i < members.length; i += o.unlinkBatch) batches.push(members.slice(i, i + o.unlinkBatch))
|
|
80
|
+
const done = await Promise.allSettled(batches.map((b) => c.unlink(...b)))
|
|
81
|
+
// A failed batch is kept (at most MAX_ORPHAN_BATCHES, then the page TTL is the bound) and retried once on reconnect.
|
|
82
|
+
const failed = done.flatMap((d, i) => (d.status === 'rejected' ? [i] : []))
|
|
83
|
+
for (const i of failed) if (orphaned.length < MAX_ORPHAN_BATCHES) orphaned.push(batches[i]!)
|
|
84
|
+
const first = done.find((d): d is PromiseRejectedResult => d.status === 'rejected')
|
|
85
|
+
if (first) throw first.reason
|
|
76
86
|
},
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
const broadcast = {
|
|
80
90
|
async publish(args: InvalidateArgs, origin: string): Promise<number> {
|
|
81
|
-
|
|
82
|
-
await
|
|
91
|
+
// The event is published by the script itself (N1): a failure here means no seq was taken and no event went out.
|
|
92
|
+
const { seq } = await pairing.run(args, 'publish', origin)
|
|
83
93
|
return seq
|
|
84
94
|
},
|
|
85
95
|
async subscribe(on: (e: InvalidationEvent) => void, onGap: () => void): Promise<() => Promise<void>> {
|
|
@@ -94,7 +104,9 @@ export function createAdapter(name: 'redis' | 'dragonfly', raw: Record<string, u
|
|
|
94
104
|
broadcast,
|
|
95
105
|
async init(ctx: CacheAdapterInit) {
|
|
96
106
|
ks = makeKeys(ctx.namespace)
|
|
97
|
-
cmd = new Link(o, 'commands')
|
|
107
|
+
cmd = new Link(o, 'commands', async (cl) => {
|
|
108
|
+
for (const b of orphaned.splice(0)) await cl.unlink(...b).catch(() => 0) // once: a second failure leaves it to the TTL
|
|
109
|
+
})
|
|
98
110
|
await cmd.start()
|
|
99
111
|
},
|
|
100
112
|
async close() {
|
|
@@ -103,6 +115,7 @@ export function createAdapter(name: 'redis' | 'dragonfly', raw: Record<string, u
|
|
|
103
115
|
},
|
|
104
116
|
__test: {
|
|
105
117
|
dropSubscriber: () => sub.link?.drop(),
|
|
118
|
+
dropCommands: () => cmd?.drop(),
|
|
106
119
|
state: () => ({ connected: !!cmd?.connected, subscribed: sub.subscribed, last: sub.last, reconnects: (cmd?.reconnects ?? 0) + (sub.link?.reconnects ?? 0) }),
|
|
107
120
|
client: () => link().c,
|
|
108
121
|
keys: () => k(),
|
package/src/lua.ts
CHANGED
|
@@ -20,7 +20,10 @@ for i = 1, n do
|
|
|
20
20
|
end
|
|
21
21
|
return 1`
|
|
22
22
|
|
|
23
|
-
// INVAL_LUA KEYS = [seq, idx_1..idx_n, mark_1..mark_n]
|
|
23
|
+
// INVAL_LUA KEYS = [seq, idx_1..idx_n, mark_1..mark_n]
|
|
24
|
+
// ARGV = [mode ('both'|'delete'|'publish'), n, marker_ttl_ms, channel, origin_json, args_json]
|
|
25
|
+
// The `publish` and `both` modes PUBLISH {seq, origin, args} from INSIDE the script (N1): scripts run one at a time, so
|
|
26
|
+
// the order of events on the channel is the order of seq, however many processes invalidate at once.
|
|
24
27
|
export const INVAL_LUA = `local unpack = table.unpack or unpack -- Lua 5.1 (Redis) and 5.4 (Dragonfly)
|
|
25
28
|
local mode = ARGV[1]; local n = tonumber(ARGV[2]); local mttl = tonumber(ARGV[3])
|
|
26
29
|
local v
|
|
@@ -35,6 +38,9 @@ if mode ~= 'publish' and n > 0 then
|
|
|
35
38
|
for _, m in ipairs(redis.call('SUNION', unpack(idx))) do out[#out + 1] = m end
|
|
36
39
|
redis.call('DEL', unpack(idx))
|
|
37
40
|
end
|
|
41
|
+
if mode ~= 'delete' then
|
|
42
|
+
redis.call('PUBLISH', ARGV[4], '{"seq":' .. string.format('%d', v) .. ',"origin":' .. ARGV[5] .. ',"args":' .. ARGV[6] .. '}')
|
|
43
|
+
end
|
|
38
44
|
return out`
|
|
39
45
|
|
|
40
46
|
const sha1 = (s: string) => new Bun.CryptoHasher('sha1').update(s).digest('hex')
|
package/src/pairing.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Pairs the two halves of one invalidation (cache-redis R4). lane A issues `store.invalidate` and
|
|
2
2
|
// `broadcast.publish` for the same args; when both arrive in the same tick ONE INVAL_LUA run does
|
|
3
3
|
// INCR + markers + SUNION/DEL atomically (mode `both`). Otherwise each half runs alone: `delete`
|
|
4
|
-
// (markers at seq+1, seq untouched, so it never jumps without an event) or `publish` (INCR + markers).
|
|
4
|
+
// (markers at seq+1, seq untouched, so it never jumps without an event) or `publish` (INCR + markers). The two
|
|
5
|
+
// modes that publish do it inside the script, so the event order is the seq order (N1).
|
|
5
6
|
import type { RedisClient } from 'bun'
|
|
6
7
|
import type { InvalidateArgs } from '@brust/core/cache-adapter'
|
|
7
8
|
import { canonical, hasStoreWork, type Keys } from './keys'
|
|
@@ -15,6 +16,8 @@ export interface InvalResult {
|
|
|
15
16
|
|
|
16
17
|
interface Entry {
|
|
17
18
|
sides: Set<Side>
|
|
19
|
+
/** The publishing side's origin (set by `run(…, 'publish', origin)`; read when the entry executes). */
|
|
20
|
+
origin?: string
|
|
18
21
|
promise: Promise<InvalResult>
|
|
19
22
|
}
|
|
20
23
|
|
|
@@ -25,9 +28,10 @@ export class Pairing {
|
|
|
25
28
|
private readonly c: () => RedisClient,
|
|
26
29
|
private readonly keys: () => Keys,
|
|
27
30
|
private readonly markerTtlMs: number,
|
|
31
|
+
private readonly channel: () => string,
|
|
28
32
|
) {}
|
|
29
33
|
|
|
30
|
-
run(args: InvalidateArgs, side: Side): Promise<InvalResult> {
|
|
34
|
+
run(args: InvalidateArgs, side: Side, origin?: string): Promise<InvalResult> {
|
|
31
35
|
if (side === 'invalidate' && !hasStoreWork(args)) return Promise.resolve({ seq: 0, members: [] })
|
|
32
36
|
const id = canonical(args)
|
|
33
37
|
const list = this.pending.get(id) ?? []
|
|
@@ -35,9 +39,10 @@ export class Pairing {
|
|
|
35
39
|
const open = list.find((e) => !e.sides.has(side))
|
|
36
40
|
if (open) {
|
|
37
41
|
open.sides.add(side)
|
|
42
|
+
if (origin !== undefined) open.origin = origin
|
|
38
43
|
return open.promise
|
|
39
44
|
}
|
|
40
|
-
const entry: Entry = { sides: new Set([side]), promise: undefined as never }
|
|
45
|
+
const entry: Entry = { sides: new Set([side]), ...(origin !== undefined && { origin }), promise: undefined as never }
|
|
41
46
|
entry.promise = new Promise<InvalResult>((resolve, reject) => {
|
|
42
47
|
queueMicrotask(() => {
|
|
43
48
|
const l = this.pending.get(id)
|
|
@@ -46,7 +51,7 @@ export class Pairing {
|
|
|
46
51
|
if (!l.length) this.pending.delete(id)
|
|
47
52
|
}
|
|
48
53
|
const mode = entry.sides.size === 2 ? 'both' : side === 'invalidate' ? 'delete' : 'publish'
|
|
49
|
-
this.exec(args, mode).then(resolve, reject)
|
|
54
|
+
this.exec(args, mode, entry.origin ?? '').then(resolve, reject)
|
|
50
55
|
})
|
|
51
56
|
})
|
|
52
57
|
list.push(entry)
|
|
@@ -54,7 +59,7 @@ export class Pairing {
|
|
|
54
59
|
return entry.promise
|
|
55
60
|
}
|
|
56
61
|
|
|
57
|
-
private async exec(args: InvalidateArgs, mode: 'both' | 'delete' | 'publish'): Promise<InvalResult> {
|
|
62
|
+
private async exec(args: InvalidateArgs, mode: 'both' | 'delete' | 'publish', origin: string): Promise<InvalResult> {
|
|
58
63
|
const K = this.keys()
|
|
59
64
|
const method = args.method
|
|
60
65
|
const tags = [...new Set(args.tags ?? [])]
|
|
@@ -69,7 +74,7 @@ export class Pairing {
|
|
|
69
74
|
idx.push(K.path(method, args.path))
|
|
70
75
|
marks.push(K.markPath(method, args.path))
|
|
71
76
|
}
|
|
72
|
-
const r = (await evalsha(this.c(), INVAL_SCRIPT, [K.seq, ...idx, ...marks], [mode, idx.length, this.markerTtlMs])) as (number | string)[]
|
|
77
|
+
const r = (await evalsha(this.c(), INVAL_SCRIPT, [K.seq, ...idx, ...marks], [mode, idx.length, this.markerTtlMs, this.channel(), JSON.stringify(origin), JSON.stringify(args)])) as (number | string)[]
|
|
73
78
|
return { seq: Number(r[0]), members: r.slice(1).map(String) }
|
|
74
79
|
}
|
|
75
80
|
}
|