@brust/cache-redis 2.0.0-alpha.4 → 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 CHANGED
@@ -1,11 +1,16 @@
1
1
  {
2
2
  "name": "@brust/cache-redis",
3
- "version": "2.0.0-alpha.4",
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.4"
32
+ "@brust/core": "2.0.0-alpha.5"
28
33
  },
29
34
  "devDependencies": {
30
- "@brust/core": "2.0.0-alpha.4",
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 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)
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
- const { seq } = await pairing.run(args, 'publish')
82
- await link().c.publish(k().channel, JSON.stringify({ seq, origin, args }))
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] ARGV = [mode ('both'|'delete'|'publish'), n, marker_ttl_ms]
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
  }