@brust/cache-redis 0.0.0-stage → 2.0.0-alpha.3
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 +5 -2
- package/package.json +42 -4
- package/src/adapter.ts +114 -0
- package/src/conn.ts +128 -0
- package/src/dragonfly.ts +11 -0
- package/src/frame.ts +46 -0
- package/src/index.ts +11 -0
- package/src/keys.ts +47 -0
- package/src/lua.ts +59 -0
- package/src/options.ts +82 -0
- package/src/pairing.ts +75 -0
- package/src/redact.ts +10 -0
- package/src/subscriber.ts +65 -0
package/README.md
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @brust/cache-redis
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Redis and Dragonfly cache adapter for brust (`[cache] adapter = "redis"` or `"dragonfly"`).
|
|
4
|
+
Built on Bun's `RedisClient`: no npm dependency, Bun only (not Cloudflare Workers).
|
|
5
|
+
|
|
6
|
+
Configure it in `brust.toml` (`[cache]` + `[cache.redis]`, the URL always via `env:NAME`); see the brust docs, "Redis / Dragonfly".
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brust/cache-redis",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "2.0.0-alpha.3",
|
|
4
|
+
"description": "brust cache adapter for Redis and Dragonfly (Bun RedisClient, no npm dependency)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/AssetsArt/brust.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./src/index.ts",
|
|
14
|
+
"default": "./src/index.ts"
|
|
15
|
+
},
|
|
16
|
+
"./dragonfly": {
|
|
17
|
+
"types": "./src/dragonfly.ts",
|
|
18
|
+
"default": "./src/dragonfly.ts"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "bun test",
|
|
23
|
+
"typecheck": "bun check"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@brust/core": "2.0.0-alpha.3"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@brust/core": "2.0.0-alpha.3",
|
|
31
|
+
"@types/bun": "^1.4.0",
|
|
32
|
+
"typescript": "^7.0.2"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"bun": ">=1.2.23"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"src",
|
|
39
|
+
"README.md"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { CacheAdapter, CacheAdapterInit, GetResult, InvalidateArgs, InvalidationEvent, SetMeta, StoredPage } from '@brust/core/cache-adapter'
|
|
2
|
+
import { Link } from './conn'
|
|
3
|
+
import { decodeFrame, encodeFrame } from './frame'
|
|
4
|
+
import { type Keys, keys as makeKeys } from './keys'
|
|
5
|
+
import { evalsha, SET_SCRIPT } from './lua'
|
|
6
|
+
import { Pairing } from './pairing'
|
|
7
|
+
import { parseOptions, type ResolvedOptions } from './options'
|
|
8
|
+
import { type SubscriberState, startSubscriber } from './subscriber'
|
|
9
|
+
|
|
10
|
+
const STAGE_TTL_MS = 30_000
|
|
11
|
+
|
|
12
|
+
export interface RedisAdapterTest {
|
|
13
|
+
/** Close the subscriber socket as a network loss would. */
|
|
14
|
+
dropSubscriber(): void
|
|
15
|
+
state(): { connected: boolean; subscribed: boolean; last: number | null; reconnects: number }
|
|
16
|
+
/** The command client (raw access for tests). */
|
|
17
|
+
client(): import('bun').RedisClient
|
|
18
|
+
keys(): Keys
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type RedisAdapter = CacheAdapter & { __test: RedisAdapterTest }
|
|
22
|
+
|
|
23
|
+
export function createAdapter(name: 'redis' | 'dragonfly', raw: Record<string, unknown>): RedisAdapter {
|
|
24
|
+
const o: ResolvedOptions = parseOptions(raw)
|
|
25
|
+
let ks: Keys | null = null
|
|
26
|
+
let cmd: Link | null = null
|
|
27
|
+
const k = (): Keys => ks ?? fail()
|
|
28
|
+
const link = (): Link => cmd ?? fail()
|
|
29
|
+
const fail = (): never => {
|
|
30
|
+
throw new Error('cache-redis: used before init')
|
|
31
|
+
}
|
|
32
|
+
const sub: SubscriberState = { link: null, subscribed: false, last: null }
|
|
33
|
+
const pairing = new Pairing(() => link().c, k, o.markerTtlMs)
|
|
34
|
+
|
|
35
|
+
const store = {
|
|
36
|
+
maxBodyBytes: o.maxBodyBytes,
|
|
37
|
+
async get(key: string): Promise<GetResult> {
|
|
38
|
+
const c = link().c
|
|
39
|
+
const [buf, seq] = await Promise.all([c.getBuffer(k().page(key)), c.get(k().seq)])
|
|
40
|
+
const stamp = Number(seq ?? 0)
|
|
41
|
+
if (buf === null) return { page: null, stamp, entryStamp: null, storedAt: null }
|
|
42
|
+
const f = decodeFrame(buf)
|
|
43
|
+
if (f === null) throw new Error('cache-redis: stored page has an unknown frame version')
|
|
44
|
+
return { page: f.page, stamp, entryStamp: f.entryStamp, storedAt: f.storedAt }
|
|
45
|
+
},
|
|
46
|
+
async set(key: string, page: StoredPage, meta: SetMeta): Promise<boolean> {
|
|
47
|
+
if (page.body.length > o.maxBodyBytes) return false
|
|
48
|
+
const c = link().c
|
|
49
|
+
const K = k()
|
|
50
|
+
const method = meta.method.toUpperCase()
|
|
51
|
+
const tags = [...new Set(meta.tags)]
|
|
52
|
+
const stage = K.stage(crypto.randomUUID())
|
|
53
|
+
const idx = [...tags.map((t) => K.tag(t)), K.path(method, meta.path)]
|
|
54
|
+
const marks = [...tags.map((t) => K.markTag(t)), K.markPath(method, meta.path)]
|
|
55
|
+
const frame = encodeFrame(page, { entryStamp: meta.stamp, storedAt: Date.now(), tags, method, path: meta.path })
|
|
56
|
+
// Both commands go out on the one auto-pipelined connection: one round trip.
|
|
57
|
+
const staged = c.set(stage, frame, 'PX', STAGE_TTL_MS)
|
|
58
|
+
const ran = evalsha(c, SET_SCRIPT, [stage, K.page(key), ...idx, ...marks], [meta.stamp === null ? '' : meta.stamp, Math.max(1, Math.ceil(meta.ttlMs)), idx.length])
|
|
59
|
+
const [a, b] = await Promise.allSettled([staged, ran])
|
|
60
|
+
if (a.status === 'rejected') throw a.reason
|
|
61
|
+
if (b.status === 'rejected') throw b.reason
|
|
62
|
+
if (b.value === -1) {
|
|
63
|
+
console.warn('[brust] cache-redis: staging key vanished before the guarded write')
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
return b.value === 1
|
|
67
|
+
},
|
|
68
|
+
async invalidate(args: InvalidateArgs): Promise<void> {
|
|
69
|
+
const { members } = await pairing.run(args, 'invalidate')
|
|
70
|
+
if (!members.length) return
|
|
71
|
+
const c = link().c
|
|
72
|
+
// UNLINK frees off the main thread (Dragonfly too); batched so one call never carries every key.
|
|
73
|
+
const jobs: Promise<number>[] = []
|
|
74
|
+
for (let i = 0; i < members.length; i += o.unlinkBatch) jobs.push(c.unlink(...members.slice(i, i + o.unlinkBatch)))
|
|
75
|
+
await Promise.all(jobs)
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const broadcast = {
|
|
80
|
+
async publish(args: InvalidateArgs, origin: string): Promise<number> {
|
|
81
|
+
const { seq } = await pairing.run(args, 'publish')
|
|
82
|
+
await link().c.publish(k().channel, JSON.stringify({ seq, origin, args }))
|
|
83
|
+
return seq
|
|
84
|
+
},
|
|
85
|
+
async subscribe(on: (e: InvalidationEvent) => void, onGap: () => void): Promise<() => Promise<void>> {
|
|
86
|
+
return startSubscriber(o, k(), async () => Number((await link().c.get(k().seq)) ?? 0), sub, on, onGap)
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const adapter: RedisAdapter = {
|
|
91
|
+
name,
|
|
92
|
+
runtimes: ['bun'],
|
|
93
|
+
store,
|
|
94
|
+
broadcast,
|
|
95
|
+
async init(ctx: CacheAdapterInit) {
|
|
96
|
+
ks = makeKeys(ctx.namespace)
|
|
97
|
+
cmd = new Link(o, 'commands')
|
|
98
|
+
await cmd.start()
|
|
99
|
+
},
|
|
100
|
+
async close() {
|
|
101
|
+
sub.link?.close()
|
|
102
|
+
cmd?.close()
|
|
103
|
+
},
|
|
104
|
+
__test: {
|
|
105
|
+
dropSubscriber: () => sub.link?.drop(),
|
|
106
|
+
state: () => ({ connected: !!cmd?.connected, subscribed: sub.subscribed, last: sub.last, reconnects: (cmd?.reconnects ?? 0) + (sub.link?.reconnects ?? 0) }),
|
|
107
|
+
client: () => link().c,
|
|
108
|
+
keys: () => k(),
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
return adapter
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type { InvalidationEvent }
|
package/src/conn.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// A Bun RedisClient with an adapter-owned reconnect loop (cache-redis R8): the client itself never
|
|
2
|
+
// reconnects and never queues offline, so a dead server fails commands at once instead of pinning
|
|
3
|
+
// a worker slot until the get budget. A fresh client per attempt (a closed one is not reusable).
|
|
4
|
+
import { RedisClient } from 'bun'
|
|
5
|
+
import type { ResolvedOptions } from './options'
|
|
6
|
+
import { redact } from './redact'
|
|
7
|
+
|
|
8
|
+
export class Link {
|
|
9
|
+
private client: RedisClient | null = null
|
|
10
|
+
private closing = false
|
|
11
|
+
private timer: ReturnType<typeof setTimeout> | null = null
|
|
12
|
+
private fails = 0
|
|
13
|
+
private warned = 0
|
|
14
|
+
/** Reconnect attempts started (a test hook: stable once `close()` ran). */
|
|
15
|
+
reconnects = 0
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly o: ResolvedOptions,
|
|
19
|
+
private readonly what: string,
|
|
20
|
+
/** Runs on every successful connect (subscribe + gap check); a throw drops the connection and retries. */
|
|
21
|
+
private readonly onUp?: (c: RedisClient) => Promise<void>,
|
|
22
|
+
/** Runs when a live connection is lost. */
|
|
23
|
+
private readonly onDown?: () => void,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
get connected(): boolean {
|
|
27
|
+
return this.client !== null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The live client; throws at once when disconnected. */
|
|
31
|
+
get c(): RedisClient {
|
|
32
|
+
if (!this.client) throw new Error(`cache-redis: not connected to ${redact(this.o.url)}`)
|
|
33
|
+
return this.client
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One bounded connect; a failure starts the loop. Never throws for network reasons (R9). */
|
|
37
|
+
async start(): Promise<void> {
|
|
38
|
+
if (!(await this.attempt())) this.schedule()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Close the socket as a network loss would (the loop reconnects). */
|
|
42
|
+
drop(): void {
|
|
43
|
+
const cl = this.client
|
|
44
|
+
if (cl) {
|
|
45
|
+
cl.close()
|
|
46
|
+
this.lost(cl)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
close(): void {
|
|
51
|
+
this.closing = true
|
|
52
|
+
if (this.timer) clearTimeout(this.timer)
|
|
53
|
+
this.timer = null
|
|
54
|
+
const cl = this.client
|
|
55
|
+
this.client = null
|
|
56
|
+
cl?.close()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private async attempt(): Promise<boolean> {
|
|
60
|
+
const { url, tls, connectTimeoutMs } = this.o
|
|
61
|
+
const cl = new RedisClient(url, { autoReconnect: false, enableOfflineQueue: false, connectionTimeout: connectTimeoutMs, ...(tls ? { tls } : {}) })
|
|
62
|
+
cl.onclose = (e) => {
|
|
63
|
+
if (this.client === cl) this.lost(cl, e)
|
|
64
|
+
}
|
|
65
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
66
|
+
try {
|
|
67
|
+
await Promise.race([
|
|
68
|
+
cl.connect(),
|
|
69
|
+
new Promise<never>((_, rej) => {
|
|
70
|
+
timer = setTimeout(() => rej(new Error('connect timed out')), connectTimeoutMs)
|
|
71
|
+
}),
|
|
72
|
+
])
|
|
73
|
+
clearTimeout(timer)
|
|
74
|
+
if (this.closing) {
|
|
75
|
+
cl.close()
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
this.client = cl
|
|
79
|
+
await this.onUp?.(cl)
|
|
80
|
+
this.fails = 0
|
|
81
|
+
this.warned = 0
|
|
82
|
+
return true
|
|
83
|
+
} catch (e) {
|
|
84
|
+
clearTimeout(timer)
|
|
85
|
+
if (this.client === cl) this.client = null
|
|
86
|
+
cl.close()
|
|
87
|
+
this.warn(e)
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private lost(cl: RedisClient, e?: unknown): void {
|
|
93
|
+
if (this.client !== cl) return
|
|
94
|
+
this.client = null
|
|
95
|
+
this.onDown?.()
|
|
96
|
+
if (this.closing) return
|
|
97
|
+
this.warn(e ?? new Error('connection lost'))
|
|
98
|
+
this.schedule()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private schedule(): void {
|
|
102
|
+
if (this.closing || this.timer) return
|
|
103
|
+
const { reconnectMinMs, reconnectMaxMs } = this.o
|
|
104
|
+
const base = Math.min(reconnectMaxMs, reconnectMinMs * 2 ** Math.min(this.fails++, 20))
|
|
105
|
+
const delay = base * (0.8 + Math.random() * 0.4)
|
|
106
|
+
this.timer = setTimeout(async () => {
|
|
107
|
+
this.timer = null
|
|
108
|
+
if (this.closing) return
|
|
109
|
+
this.reconnects++
|
|
110
|
+
if (!(await this.attempt())) this.schedule()
|
|
111
|
+
}, delay)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The first failure, then every 10th; host:port only, the password scrubbed from the message. */
|
|
115
|
+
private warn(e: unknown): void {
|
|
116
|
+
if (this.warned++ % 10 !== 0) return
|
|
117
|
+
console.warn(`[brust] cache-redis ${this.what}: ${redact(this.o.url)} unavailable (${this.scrub(e)}); retrying`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private scrub(e: unknown): string {
|
|
121
|
+
let m = e instanceof Error ? e.message : String(e)
|
|
122
|
+
try {
|
|
123
|
+
const u = new URL(this.o.url)
|
|
124
|
+
for (const s of [this.o.url, u.password, u.username]) if (s) m = m.split(s).join('<redacted>')
|
|
125
|
+
} catch {}
|
|
126
|
+
return m.slice(0, 200)
|
|
127
|
+
}
|
|
128
|
+
}
|
package/src/dragonfly.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createAdapter, type RedisAdapter } from './adapter'
|
|
2
|
+
|
|
3
|
+
export type { RedisAdapter, RedisAdapterTest } from './adapter'
|
|
4
|
+
export type { RedisCacheOptions } from './options'
|
|
5
|
+
|
|
6
|
+
/** `[cache] adapter = "dragonfly"`: the same code as Redis (every script declares all its keys). */
|
|
7
|
+
export function dragonflyAdapter(options: Record<string, unknown>): RedisAdapter {
|
|
8
|
+
return createAdapter('dragonfly', options)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default dragonflyAdapter
|
package/src/frame.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// One page = one binary string (cache-redis R3): [u32 LE meta_len][meta JSON][body].
|
|
2
|
+
import type { StoredPage } from '@brust/core/cache-adapter'
|
|
3
|
+
|
|
4
|
+
export interface FrameMeta {
|
|
5
|
+
v: 1
|
|
6
|
+
status: number
|
|
7
|
+
headers: [string, string][]
|
|
8
|
+
entryStamp: number | null
|
|
9
|
+
storedAt: number
|
|
10
|
+
tags: string[]
|
|
11
|
+
method: string
|
|
12
|
+
path: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DecodedFrame {
|
|
16
|
+
page: StoredPage
|
|
17
|
+
entryStamp: number | null
|
|
18
|
+
storedAt: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const enc = new TextEncoder()
|
|
22
|
+
const dec = new TextDecoder()
|
|
23
|
+
|
|
24
|
+
export function encodeFrame(page: StoredPage, m: Omit<FrameMeta, 'v' | 'status' | 'headers'>): Uint8Array {
|
|
25
|
+
const meta = enc.encode(JSON.stringify({ v: 1, status: page.status, headers: page.headers, ...m } satisfies FrameMeta))
|
|
26
|
+
const out = new Uint8Array(4 + meta.length + page.body.length)
|
|
27
|
+
new DataView(out.buffer).setUint32(0, meta.length, true)
|
|
28
|
+
out.set(meta, 4)
|
|
29
|
+
out.set(page.body, 4 + meta.length)
|
|
30
|
+
return out
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `null` = a frame of an unknown version (a miss); throws on a truncated or corrupt frame. */
|
|
34
|
+
export function decodeFrame(buf: Uint8Array): DecodedFrame | null {
|
|
35
|
+
if (buf.length < 4) throw new Error('cache-redis: truncated page frame')
|
|
36
|
+
const len = new DataView(buf.buffer, buf.byteOffset, buf.byteLength).getUint32(0, true)
|
|
37
|
+
if (4 + len > buf.length) throw new Error('cache-redis: page frame meta length exceeds the frame')
|
|
38
|
+
const meta = JSON.parse(dec.decode(buf.subarray(4, 4 + len))) as Partial<FrameMeta> & { v?: unknown }
|
|
39
|
+
if (meta.v !== 1) return null
|
|
40
|
+
if (typeof meta.status !== 'number' || !Array.isArray(meta.headers)) throw new Error('cache-redis: corrupt page frame meta')
|
|
41
|
+
return {
|
|
42
|
+
page: { status: meta.status, headers: meta.headers, body: buf.slice(4 + len) },
|
|
43
|
+
entryStamp: typeof meta.entryStamp === 'number' ? meta.entryStamp : null,
|
|
44
|
+
storedAt: typeof meta.storedAt === 'number' ? meta.storedAt : 0,
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createAdapter, type RedisAdapter } from './adapter'
|
|
2
|
+
|
|
3
|
+
export type { RedisAdapter, RedisAdapterTest } from './adapter'
|
|
4
|
+
export type { RedisCacheOptions } from './options'
|
|
5
|
+
|
|
6
|
+
/** `[cache] adapter = "redis"`: the factory brust calls with `[cache.redis]` (after `env:` resolution). */
|
|
7
|
+
export function redisAdapter(options: Record<string, unknown>): RedisAdapter {
|
|
8
|
+
return createAdapter('redis', options)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default redisAdapter
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The key schema (cache-redis R1): every key of a namespace carries the cluster hash tag
|
|
2
|
+
// `{brust:<ns>}`, so the multi-key scripts stay on one slot. Only page keys are build-scoped
|
|
3
|
+
// (their L2 key carries the build); index sets, markers, seq and the channel are namespace-level.
|
|
4
|
+
import type { InvalidateArgs } from '@brust/core/cache-adapter'
|
|
5
|
+
|
|
6
|
+
export interface Keys {
|
|
7
|
+
page(l2Key: string): string
|
|
8
|
+
tag(tag: string): string
|
|
9
|
+
path(method: string | undefined, path: string): string
|
|
10
|
+
markTag(tag: string): string
|
|
11
|
+
markPath(method: string | undefined, path: string): string
|
|
12
|
+
seq: string
|
|
13
|
+
stage(id: string): string
|
|
14
|
+
channel: string
|
|
15
|
+
/** SCAN pattern of every key of the namespace. */
|
|
16
|
+
all: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const pathName = (method: string | undefined, path: string) => `${(method ?? 'GET').toUpperCase()} ${path}`
|
|
20
|
+
|
|
21
|
+
export function keys(ns: string): Keys {
|
|
22
|
+
const p = `{brust:${ns}}`
|
|
23
|
+
return {
|
|
24
|
+
page: (k) => `${p}:page:${k}`,
|
|
25
|
+
tag: (t) => `${p}:tag:${t}`,
|
|
26
|
+
path: (m, path) => `${p}:path:${pathName(m, path)}`,
|
|
27
|
+
markTag: (t) => `${p}:inval:tag:${t}`,
|
|
28
|
+
markPath: (m, path) => `${p}:inval:path:${pathName(m, path)}`,
|
|
29
|
+
seq: `${p}:seq`,
|
|
30
|
+
stage: (id) => `${p}:stage:${id}`,
|
|
31
|
+
channel: `brust:${ns}:inval`,
|
|
32
|
+
all: `${p}:*`,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Order- and case-insensitive identity of an invalidation (pairs its store and broadcast halves). */
|
|
37
|
+
export function canonical(a: InvalidateArgs): string {
|
|
38
|
+
return JSON.stringify({
|
|
39
|
+
tags: [...new Set(a.tags ?? [])].sort(),
|
|
40
|
+
...(a.path !== undefined ? { path: a.path, method: (a.method ?? 'GET').toUpperCase() } : {}),
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Does the invalidation touch pages? (A key-only one is tier 1's business: the store holds none by key.) */
|
|
45
|
+
export function hasStoreWork(a: InvalidateArgs): boolean {
|
|
46
|
+
return (a.tags?.length ?? 0) > 0 || a.path !== undefined
|
|
47
|
+
}
|
package/src/lua.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The two scripts (cache-redis R4, R6). The text is exact: the reviewer diffs it against the plan.
|
|
2
|
+
// Every key a script touches is declared in KEYS (Dragonfly needs it; cluster needs one slot).
|
|
3
|
+
import type { RedisClient } from 'bun'
|
|
4
|
+
|
|
5
|
+
// SET_LUA KEYS = [stage, page, idx_1..idx_n, mark_1..mark_n] ARGV = [stamp|"", ttl_ms, n]
|
|
6
|
+
export const SET_LUA = `local n = tonumber(ARGV[3]); local ttl = tonumber(ARGV[2])
|
|
7
|
+
if ARGV[1] ~= '' then
|
|
8
|
+
local stamp = tonumber(ARGV[1])
|
|
9
|
+
for i = 1, n do
|
|
10
|
+
local m = redis.call('GET', KEYS[2 + n + i])
|
|
11
|
+
if m and tonumber(m) > stamp then redis.call('DEL', KEYS[1]); return 0 end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
if redis.call('EXISTS', KEYS[1]) == 0 then return -1 end
|
|
15
|
+
redis.call('RENAME', KEYS[1], KEYS[2]); redis.call('PEXPIRE', KEYS[2], ttl)
|
|
16
|
+
for i = 1, n do
|
|
17
|
+
redis.call('SADD', KEYS[2 + i], KEYS[2])
|
|
18
|
+
local t = redis.call('PTTL', KEYS[2 + i])
|
|
19
|
+
if t == -1 or (t >= 0 and t < ttl) then redis.call('PEXPIRE', KEYS[2 + i], ttl) end
|
|
20
|
+
end
|
|
21
|
+
return 1`
|
|
22
|
+
|
|
23
|
+
// INVAL_LUA KEYS = [seq, idx_1..idx_n, mark_1..mark_n] ARGV = [mode ('both'|'delete'|'publish'), n, marker_ttl_ms]
|
|
24
|
+
export const INVAL_LUA = `local unpack = table.unpack or unpack -- Lua 5.1 (Redis) and 5.4 (Dragonfly)
|
|
25
|
+
local mode = ARGV[1]; local n = tonumber(ARGV[2]); local mttl = tonumber(ARGV[3])
|
|
26
|
+
local v
|
|
27
|
+
if mode == 'delete' then v = tonumber(redis.call('GET', KEYS[1]) or '0') + 1 else v = redis.call('INCR', KEYS[1]) end
|
|
28
|
+
for i = 1, n do
|
|
29
|
+
local k = KEYS[1 + n + i]; local c = tonumber(redis.call('GET', k) or '0')
|
|
30
|
+
if v > c then redis.call('SET', k, v, 'PX', mttl) else redis.call('PEXPIRE', k, mttl) end
|
|
31
|
+
end
|
|
32
|
+
local out = { mode == 'delete' and v - 1 or v }
|
|
33
|
+
if mode ~= 'publish' and n > 0 then
|
|
34
|
+
local idx = {}; for i = 1, n do idx[i] = KEYS[1 + i] end
|
|
35
|
+
for _, m in ipairs(redis.call('SUNION', unpack(idx))) do out[#out + 1] = m end
|
|
36
|
+
redis.call('DEL', unpack(idx))
|
|
37
|
+
end
|
|
38
|
+
return out`
|
|
39
|
+
|
|
40
|
+
const sha1 = (s: string) => new Bun.CryptoHasher('sha1').update(s).digest('hex')
|
|
41
|
+
|
|
42
|
+
export interface Script {
|
|
43
|
+
readonly source: string
|
|
44
|
+
readonly sha: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const SET_SCRIPT: Script = { source: SET_LUA, sha: sha1(SET_LUA) }
|
|
48
|
+
export const INVAL_SCRIPT: Script = { source: INVAL_LUA, sha: sha1(INVAL_LUA) }
|
|
49
|
+
|
|
50
|
+
/** EVALSHA; a NOSCRIPT reply (restart, failover, SCRIPT FLUSH) → EVAL, which also caches it. */
|
|
51
|
+
export async function evalsha(c: RedisClient, s: Script, keys: string[], argv: (string | number)[]): Promise<unknown> {
|
|
52
|
+
const tail = [String(keys.length), ...keys, ...argv.map(String)]
|
|
53
|
+
try {
|
|
54
|
+
return await c.send('EVALSHA', [s.sha, ...tail])
|
|
55
|
+
} catch (e) {
|
|
56
|
+
if (!/NOSCRIPT/.test(e instanceof Error ? e.message : String(e))) throw e
|
|
57
|
+
return await c.send('EVAL', [s.source, ...tail])
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// `[cache.redis]` (cache-redis R10, R11): validated at worker boot; the same key list is checked at
|
|
2
|
+
// `brust build` by `parseCache`. Errors name the key and never echo the url (it may hold a password).
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
export const OPTION_KEYS = ['url', 'tls', 'connect_timeout_ms', 'max_body_bytes', 'marker_ttl_ms', 'unlink_batch', 'reconnect_min_ms', 'reconnect_max_ms'] as const
|
|
6
|
+
|
|
7
|
+
export interface RedisCacheOptions {
|
|
8
|
+
url: string
|
|
9
|
+
tls?: boolean | { reject_unauthorized?: boolean; server_name?: string; ca_file?: string }
|
|
10
|
+
connect_timeout_ms?: number
|
|
11
|
+
max_body_bytes?: number
|
|
12
|
+
marker_ttl_ms?: number
|
|
13
|
+
unlink_batch?: number
|
|
14
|
+
reconnect_min_ms?: number
|
|
15
|
+
reconnect_max_ms?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ResolvedOptions {
|
|
19
|
+
url: string
|
|
20
|
+
tls: boolean | Bun.TLSOptions
|
|
21
|
+
connectTimeoutMs: number
|
|
22
|
+
maxBodyBytes: number
|
|
23
|
+
markerTtlMs: number
|
|
24
|
+
unlinkBatch: number
|
|
25
|
+
reconnectMinMs: number
|
|
26
|
+
reconnectMaxMs: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const bad = (m: string): never => {
|
|
30
|
+
throw new TypeError(`cache-redis: ${m}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function int(o: Record<string, unknown>, k: string, min: number, max: number, dflt: number): number {
|
|
34
|
+
const v = o[k]
|
|
35
|
+
if (v === undefined) return dflt
|
|
36
|
+
if (typeof v !== 'number' || !Number.isInteger(v) || v < min || v > max) return bad(`${k} must be an integer in ${min}..${max} (got ${JSON.stringify(v)})`)
|
|
37
|
+
return v
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function tlsOf(v: unknown): boolean | Bun.TLSOptions {
|
|
41
|
+
if (v === undefined || typeof v === 'boolean') return v ?? false
|
|
42
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v)) return bad('tls must be true, false or a table')
|
|
43
|
+
const t = v as Record<string, unknown>
|
|
44
|
+
for (const k of Object.keys(t)) if (k !== 'reject_unauthorized' && k !== 'server_name' && k !== 'ca_file') bad(`unknown option "tls.${k}"`)
|
|
45
|
+
const out: Bun.TLSOptions = {}
|
|
46
|
+
if (t.reject_unauthorized !== undefined) {
|
|
47
|
+
if (typeof t.reject_unauthorized !== 'boolean') bad('tls.reject_unauthorized must be a boolean')
|
|
48
|
+
out.rejectUnauthorized = t.reject_unauthorized as boolean
|
|
49
|
+
}
|
|
50
|
+
if (t.server_name !== undefined) {
|
|
51
|
+
if (typeof t.server_name !== 'string' || t.server_name === '') bad('tls.server_name must be a non-empty string')
|
|
52
|
+
out.serverName = t.server_name as string
|
|
53
|
+
}
|
|
54
|
+
if (t.ca_file !== undefined) {
|
|
55
|
+
if (typeof t.ca_file !== 'string' || t.ca_file === '') bad('tls.ca_file must be a non-empty path')
|
|
56
|
+
try {
|
|
57
|
+
out.ca = readFileSync(t.ca_file as string, 'utf8')
|
|
58
|
+
} catch {
|
|
59
|
+
bad('tls.ca_file cannot be read')
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function parseOptions(raw: Record<string, unknown>): ResolvedOptions {
|
|
66
|
+
for (const k of Object.keys(raw)) if (!(OPTION_KEYS as readonly string[]).includes(k)) bad(`unknown option "${k}"`)
|
|
67
|
+
const url = raw.url
|
|
68
|
+
if (typeof url !== 'string' || url.trim() === '') bad('url is required (set it as "env:NAME" in [cache.redis])')
|
|
69
|
+
const reconnectMinMs = int(raw, 'reconnect_min_ms', 10, 60_000, 100)
|
|
70
|
+
const reconnectMaxMs = int(raw, 'reconnect_max_ms', 10, 600_000, Math.max(5000, reconnectMinMs))
|
|
71
|
+
if (reconnectMaxMs < reconnectMinMs) bad('reconnect_max_ms must be >= reconnect_min_ms')
|
|
72
|
+
return {
|
|
73
|
+
url: (url as string).trim(),
|
|
74
|
+
tls: tlsOf(raw.tls),
|
|
75
|
+
connectTimeoutMs: int(raw, 'connect_timeout_ms', 100, 60_000, 2000),
|
|
76
|
+
maxBodyBytes: int(raw, 'max_body_bytes', 1, 67_108_864, 1_048_576),
|
|
77
|
+
markerTtlMs: int(raw, 'marker_ttl_ms', 60_000, 604_800_000, 86_400_000),
|
|
78
|
+
unlinkBatch: int(raw, 'unlink_batch', 1, 10_000, 500),
|
|
79
|
+
reconnectMinMs,
|
|
80
|
+
reconnectMaxMs,
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/pairing.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Pairs the two halves of one invalidation (cache-redis R4). lane A issues `store.invalidate` and
|
|
2
|
+
// `broadcast.publish` for the same args; when both arrive in the same tick ONE INVAL_LUA run does
|
|
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).
|
|
5
|
+
import type { RedisClient } from 'bun'
|
|
6
|
+
import type { InvalidateArgs } from '@brust/core/cache-adapter'
|
|
7
|
+
import { canonical, hasStoreWork, type Keys } from './keys'
|
|
8
|
+
import { evalsha, INVAL_SCRIPT } from './lua'
|
|
9
|
+
|
|
10
|
+
export type Side = 'invalidate' | 'publish'
|
|
11
|
+
export interface InvalResult {
|
|
12
|
+
seq: number
|
|
13
|
+
members: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Entry {
|
|
17
|
+
sides: Set<Side>
|
|
18
|
+
promise: Promise<InvalResult>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class Pairing {
|
|
22
|
+
private readonly pending = new Map<string, Entry[]>()
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly c: () => RedisClient,
|
|
26
|
+
private readonly keys: () => Keys,
|
|
27
|
+
private readonly markerTtlMs: number,
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
run(args: InvalidateArgs, side: Side): Promise<InvalResult> {
|
|
31
|
+
if (side === 'invalidate' && !hasStoreWork(args)) return Promise.resolve({ seq: 0, members: [] })
|
|
32
|
+
const id = canonical(args)
|
|
33
|
+
const list = this.pending.get(id) ?? []
|
|
34
|
+
// Created synchronously, before any await: the partner finds it in the same tick.
|
|
35
|
+
const open = list.find((e) => !e.sides.has(side))
|
|
36
|
+
if (open) {
|
|
37
|
+
open.sides.add(side)
|
|
38
|
+
return open.promise
|
|
39
|
+
}
|
|
40
|
+
const entry: Entry = { sides: new Set([side]), promise: undefined as never }
|
|
41
|
+
entry.promise = new Promise<InvalResult>((resolve, reject) => {
|
|
42
|
+
queueMicrotask(() => {
|
|
43
|
+
const l = this.pending.get(id)
|
|
44
|
+
if (l) {
|
|
45
|
+
l.splice(l.indexOf(entry), 1)
|
|
46
|
+
if (!l.length) this.pending.delete(id)
|
|
47
|
+
}
|
|
48
|
+
const mode = entry.sides.size === 2 ? 'both' : side === 'invalidate' ? 'delete' : 'publish'
|
|
49
|
+
this.exec(args, mode).then(resolve, reject)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
list.push(entry)
|
|
53
|
+
this.pending.set(id, list)
|
|
54
|
+
return entry.promise
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private async exec(args: InvalidateArgs, mode: 'both' | 'delete' | 'publish'): Promise<InvalResult> {
|
|
58
|
+
const K = this.keys()
|
|
59
|
+
const method = args.method
|
|
60
|
+
const tags = [...new Set(args.tags ?? [])]
|
|
61
|
+
const idx: string[] = []
|
|
62
|
+
const marks: string[] = []
|
|
63
|
+
// A publish-only half marks but deletes nothing; a key-only publish has n = 0.
|
|
64
|
+
for (const t of tags) {
|
|
65
|
+
idx.push(K.tag(t))
|
|
66
|
+
marks.push(K.markTag(t))
|
|
67
|
+
}
|
|
68
|
+
if (args.path !== undefined) {
|
|
69
|
+
idx.push(K.path(method, args.path))
|
|
70
|
+
marks.push(K.markPath(method, args.path))
|
|
71
|
+
}
|
|
72
|
+
const r = (await evalsha(this.c(), INVAL_SCRIPT, [K.seq, ...idx, ...marks], [mode, idx.length, this.markerTtlMs])) as (number | string)[]
|
|
73
|
+
return { seq: Number(r[0]), members: r.slice(1).map(String) }
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/redact.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** `host:port` of a Redis URL, never the userinfo, path or query (a URL may carry a password). */
|
|
2
|
+
export function redact(url: string): string {
|
|
3
|
+
try {
|
|
4
|
+
const u = new URL(url)
|
|
5
|
+
if (!u.hostname) return '<invalid url>'
|
|
6
|
+
return u.port ? `${u.hostname}:${u.port}` : u.hostname
|
|
7
|
+
} catch {
|
|
8
|
+
return '<invalid url>'
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The push half (cache-redis R7, R8): a dedicated subscriber connection with the adapter-owned
|
|
2
|
+
// reconnect loop, and a gap check on every (re)subscribe so an event lost during an outage clears
|
|
3
|
+
// tier 1 (spec §4.2.5) instead of leaving a stale page for up to its ttl.
|
|
4
|
+
import type { InvalidationEvent } from '@brust/core/cache-adapter'
|
|
5
|
+
import { Link } from './conn'
|
|
6
|
+
import type { Keys } from './keys'
|
|
7
|
+
import type { ResolvedOptions } from './options'
|
|
8
|
+
|
|
9
|
+
export interface SubscriberState {
|
|
10
|
+
link: Link | null
|
|
11
|
+
subscribed: boolean
|
|
12
|
+
last: number | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function startSubscriber(
|
|
16
|
+
o: ResolvedOptions,
|
|
17
|
+
keys: Keys,
|
|
18
|
+
readSeq: () => Promise<number>,
|
|
19
|
+
state: SubscriberState,
|
|
20
|
+
on: (e: InvalidationEvent) => void,
|
|
21
|
+
onGap: () => void,
|
|
22
|
+
): Promise<() => Promise<void>> {
|
|
23
|
+
const listener = (message: string) => {
|
|
24
|
+
let e: InvalidationEvent
|
|
25
|
+
try {
|
|
26
|
+
const j = JSON.parse(message) as Partial<InvalidationEvent>
|
|
27
|
+
if (!Number.isSafeInteger(j.seq) || typeof j.origin !== 'string' || typeof j.args !== 'object' || j.args === null) throw new Error('shape')
|
|
28
|
+
e = j as InvalidationEvent
|
|
29
|
+
} catch {
|
|
30
|
+
console.warn('[brust] cache-redis: dropped a malformed invalidation message')
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
state.last = Math.max(state.last ?? 0, e.seq)
|
|
34
|
+
try {
|
|
35
|
+
on(e)
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.error(`[brust] cache-redis: invalidation handler failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const link = new Link(o, 'subscriber', async (cl) => {
|
|
41
|
+
state.subscribed = false
|
|
42
|
+
await cl.subscribe(keys.channel, listener)
|
|
43
|
+
// The command link may be down too: a throw here drops this attempt and retries after the backoff.
|
|
44
|
+
const seq = await readSeq()
|
|
45
|
+
const first = state.last === null
|
|
46
|
+
if (first) state.last = seq
|
|
47
|
+
else if (seq > state.last!) {
|
|
48
|
+
state.last = seq
|
|
49
|
+
try {
|
|
50
|
+
onGap()
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error(`[brust] cache-redis: gap handler failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
state.subscribed = true
|
|
56
|
+
}, () => {
|
|
57
|
+
state.subscribed = false
|
|
58
|
+
})
|
|
59
|
+
state.link = link
|
|
60
|
+
await link.start()
|
|
61
|
+
return async () => {
|
|
62
|
+
link.close()
|
|
63
|
+
state.subscribed = false
|
|
64
|
+
}
|
|
65
|
+
}
|