@strav/cache 1.0.0-alpha.25
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 +29 -0
- package/package.json +38 -0
- package/src/cache.ts +104 -0
- package/src/cache_error.ts +75 -0
- package/src/cache_provider.ts +45 -0
- package/src/drivers/memcached/index.ts +6 -0
- package/src/drivers/memcached/memcached_cache.ts +288 -0
- package/src/drivers/memcached/memcached_cache_provider.ts +39 -0
- package/src/drivers/memcached/memcached_client.ts +251 -0
- package/src/drivers/memory/index.ts +1 -0
- package/src/drivers/memory/memory_cache.ts +279 -0
- package/src/drivers/postgres/apply_cache_migration.ts +54 -0
- package/src/drivers/postgres/index.ts +10 -0
- package/src/drivers/postgres/postgres_cache.ts +410 -0
- package/src/drivers/postgres/postgres_cache_provider.ts +46 -0
- package/src/drivers/redis/index.ts +5 -0
- package/src/drivers/redis/redis_cache.ts +362 -0
- package/src/drivers/redis/redis_cache_provider.ts +42 -0
- package/src/index.ts +19 -0
- package/src/ttl.ts +68 -0
- package/src/types.ts +88 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Memcached text-protocol client over `Bun.connect`.
|
|
3
|
+
*
|
|
4
|
+
* Just enough surface to back `MemcachedCache`: `set` / `add` / `get`
|
|
5
|
+
* / `delete` / `incr` / `decr` / `flush_all`. No SASL auth, no
|
|
6
|
+
* pipelining, no consistent-hash routing — one TCP connection to one
|
|
7
|
+
* server, serialized request stream.
|
|
8
|
+
*
|
|
9
|
+
* Requests are queued; the next request waits for the previous
|
|
10
|
+
* response to land so the parser stays simple (response bytes can
|
|
11
|
+
* only be matched against the head of the queue). Throughput at one
|
|
12
|
+
* server is fine for cache workloads; if you outgrow it, an app
|
|
13
|
+
* builds its own multi-conn wrapper or uses Redis.
|
|
14
|
+
*
|
|
15
|
+
* Bytes go through `TextEncoder` / `TextDecoder` — Memcached's text
|
|
16
|
+
* protocol is single-byte ASCII for control lines but values are
|
|
17
|
+
* binary-safe. The driver `JSON.stringify`s on the way in and is
|
|
18
|
+
* tolerant of non-JSON strings on the way out (counters return
|
|
19
|
+
* decimal strings).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const ENCODER = new TextEncoder()
|
|
23
|
+
const DECODER = new TextDecoder()
|
|
24
|
+
|
|
25
|
+
export interface MemcachedClientOptions {
|
|
26
|
+
host: string
|
|
27
|
+
port: number
|
|
28
|
+
/** Connection timeout in ms. Default `5000`. */
|
|
29
|
+
connectTimeoutMs?: number
|
|
30
|
+
/** Per-request response timeout in ms. Default `5000`. */
|
|
31
|
+
requestTimeoutMs?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface PendingRequest {
|
|
35
|
+
// Lines we've collected so far (excluding the terminator).
|
|
36
|
+
buffer: Uint8Array
|
|
37
|
+
resolve: (response: Uint8Array) => void
|
|
38
|
+
reject: (err: Error) => void
|
|
39
|
+
timer: ReturnType<typeof setTimeout> | undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type Socket = Awaited<ReturnType<typeof Bun.connect>>
|
|
43
|
+
|
|
44
|
+
export class MemcachedClient {
|
|
45
|
+
private readonly host: string
|
|
46
|
+
private readonly port: number
|
|
47
|
+
private readonly connectTimeoutMs: number
|
|
48
|
+
private readonly requestTimeoutMs: number
|
|
49
|
+
|
|
50
|
+
private socket: Socket | undefined
|
|
51
|
+
private connecting: Promise<Socket> | undefined
|
|
52
|
+
private closed = false
|
|
53
|
+
|
|
54
|
+
/** FIFO queue of (request → pending response). */
|
|
55
|
+
private readonly queue: PendingRequest[] = []
|
|
56
|
+
|
|
57
|
+
constructor(options: MemcachedClientOptions) {
|
|
58
|
+
this.host = options.host
|
|
59
|
+
this.port = options.port
|
|
60
|
+
this.connectTimeoutMs = options.connectTimeoutMs ?? 5_000
|
|
61
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? 5_000
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async send(command: string | Uint8Array): Promise<Uint8Array> {
|
|
65
|
+
if (this.closed) throw new Error('MemcachedClient: closed.')
|
|
66
|
+
const socket = await this.connect()
|
|
67
|
+
return new Promise<Uint8Array>((resolve, reject) => {
|
|
68
|
+
const timer = setTimeout(() => {
|
|
69
|
+
// Pull this request from the queue when it times out so the
|
|
70
|
+
// parser doesn't fire callbacks on a dead promise. Subsequent
|
|
71
|
+
// responses still land on the next pending request.
|
|
72
|
+
const idx = this.queue.findIndex((r) => r === pending)
|
|
73
|
+
if (idx >= 0) this.queue.splice(idx, 1)
|
|
74
|
+
reject(new Error(`MemcachedClient: request timed out after ${this.requestTimeoutMs}ms.`))
|
|
75
|
+
}, this.requestTimeoutMs)
|
|
76
|
+
const pending: PendingRequest = {
|
|
77
|
+
buffer: new Uint8Array(0),
|
|
78
|
+
resolve,
|
|
79
|
+
reject,
|
|
80
|
+
timer,
|
|
81
|
+
}
|
|
82
|
+
this.queue.push(pending)
|
|
83
|
+
const payload = typeof command === 'string' ? ENCODER.encode(command) : command
|
|
84
|
+
socket.write(payload)
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async close(): Promise<void> {
|
|
89
|
+
this.closed = true
|
|
90
|
+
for (const p of this.queue) {
|
|
91
|
+
if (p.timer !== undefined) clearTimeout(p.timer)
|
|
92
|
+
p.reject(new Error('MemcachedClient: closed.'))
|
|
93
|
+
}
|
|
94
|
+
this.queue.length = 0
|
|
95
|
+
if (this.socket !== undefined) {
|
|
96
|
+
try {
|
|
97
|
+
this.socket.end()
|
|
98
|
+
} catch {
|
|
99
|
+
// Already torn down.
|
|
100
|
+
}
|
|
101
|
+
this.socket = undefined
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── Internals ─────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
private async connect(): Promise<Socket> {
|
|
108
|
+
if (this.socket !== undefined) return this.socket
|
|
109
|
+
if (this.connecting !== undefined) return this.connecting
|
|
110
|
+
this.connecting = this.openSocket()
|
|
111
|
+
try {
|
|
112
|
+
this.socket = await this.connecting
|
|
113
|
+
return this.socket
|
|
114
|
+
} finally {
|
|
115
|
+
this.connecting = undefined
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private async openSocket(): Promise<Socket> {
|
|
120
|
+
let resolveOpen: ((socket: Socket) => void) | undefined
|
|
121
|
+
let rejectOpen: ((err: Error) => void) | undefined
|
|
122
|
+
const opened = new Promise<Socket>((res, rej) => {
|
|
123
|
+
resolveOpen = res
|
|
124
|
+
rejectOpen = rej
|
|
125
|
+
})
|
|
126
|
+
const timeout = setTimeout(() => {
|
|
127
|
+
rejectOpen?.(
|
|
128
|
+
new Error(`MemcachedClient: connect timed out after ${this.connectTimeoutMs}ms.`),
|
|
129
|
+
)
|
|
130
|
+
}, this.connectTimeoutMs)
|
|
131
|
+
|
|
132
|
+
const sock = await Bun.connect({
|
|
133
|
+
hostname: this.host,
|
|
134
|
+
port: this.port,
|
|
135
|
+
socket: {
|
|
136
|
+
open: (socket) => {
|
|
137
|
+
clearTimeout(timeout)
|
|
138
|
+
resolveOpen?.(socket as Socket)
|
|
139
|
+
},
|
|
140
|
+
data: (_socket, chunk) => {
|
|
141
|
+
// Bun typings model the chunk as Buffer; downstream code
|
|
142
|
+
// uses Uint8Array operations only, so the cast is safe.
|
|
143
|
+
this.onData(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength))
|
|
144
|
+
},
|
|
145
|
+
error: (_socket, err) => {
|
|
146
|
+
this.failQueue(err)
|
|
147
|
+
},
|
|
148
|
+
close: () => {
|
|
149
|
+
this.failQueue(new Error('MemcachedClient: socket closed.'))
|
|
150
|
+
this.socket = undefined
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
return opened.catch((err) => {
|
|
155
|
+
try {
|
|
156
|
+
sock.end()
|
|
157
|
+
} catch {
|
|
158
|
+
// ignore
|
|
159
|
+
}
|
|
160
|
+
throw err
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Append chunk to the head-of-queue request and check whether the
|
|
166
|
+
* accumulated buffer now represents a complete reply. Replies end on
|
|
167
|
+
* one of the protocol's terminator lines:
|
|
168
|
+
*
|
|
169
|
+
* - `END\r\n` (after a `get` / `gets` block)
|
|
170
|
+
* - `STORED\r\n` / `NOT_STORED\r\n` / `EXISTS\r\n` (storage commands)
|
|
171
|
+
* - `DELETED\r\n` / `NOT_FOUND\r\n` / `TOUCHED\r\n`
|
|
172
|
+
* - `OK\r\n` (flush_all, version)
|
|
173
|
+
* - a single decimal line (incr/decr — `12345\r\n`)
|
|
174
|
+
* - `CLIENT_ERROR <msg>\r\n` / `SERVER_ERROR <msg>\r\n` / `ERROR\r\n`
|
|
175
|
+
*/
|
|
176
|
+
private onData(chunk: Uint8Array): void {
|
|
177
|
+
while (chunk.length > 0) {
|
|
178
|
+
const pending = this.queue[0]
|
|
179
|
+
if (pending === undefined) {
|
|
180
|
+
// Stray bytes after a queue purge — drop them.
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
const merged = new Uint8Array(pending.buffer.length + chunk.length)
|
|
184
|
+
merged.set(pending.buffer)
|
|
185
|
+
merged.set(chunk, pending.buffer.length)
|
|
186
|
+
pending.buffer = merged
|
|
187
|
+
const text = DECODER.decode(pending.buffer)
|
|
188
|
+
const end = findReplyEnd(text)
|
|
189
|
+
if (end === -1) {
|
|
190
|
+
// Need more bytes — exit and wait.
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
const replyText = text.slice(0, end)
|
|
194
|
+
const replyBytes = ENCODER.encode(replyText)
|
|
195
|
+
const leftoverText = text.slice(end)
|
|
196
|
+
this.queue.shift()
|
|
197
|
+
if (pending.timer !== undefined) clearTimeout(pending.timer)
|
|
198
|
+
pending.resolve(replyBytes)
|
|
199
|
+
chunk = ENCODER.encode(leftoverText)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private failQueue(err: Error): void {
|
|
204
|
+
for (const p of this.queue) {
|
|
205
|
+
if (p.timer !== undefined) clearTimeout(p.timer)
|
|
206
|
+
p.reject(err)
|
|
207
|
+
}
|
|
208
|
+
this.queue.length = 0
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Locate the end-of-reply boundary in the text-form accumulated buffer.
|
|
214
|
+
* Returns the index *after* the terminator. `-1` if the reply is
|
|
215
|
+
* incomplete.
|
|
216
|
+
*/
|
|
217
|
+
function findReplyEnd(text: string): number {
|
|
218
|
+
// Multi-line `get` reply ends with `END\r\n` on its own line. Scan
|
|
219
|
+
// for that first — the response may have any number of `VALUE` /
|
|
220
|
+
// data lines before it.
|
|
221
|
+
const endMarker = '\r\nEND\r\n'
|
|
222
|
+
const endIdx = text.indexOf(endMarker)
|
|
223
|
+
if (endIdx >= 0) return endIdx + endMarker.length
|
|
224
|
+
|
|
225
|
+
// Replies that ARE just `END\r\n` (empty get) — the buffer starts
|
|
226
|
+
// with it.
|
|
227
|
+
if (text.startsWith('END\r\n')) return 'END\r\n'.length
|
|
228
|
+
|
|
229
|
+
// Single-line replies — find the first CRLF and check the line.
|
|
230
|
+
const firstCrlf = text.indexOf('\r\n')
|
|
231
|
+
if (firstCrlf === -1) return -1
|
|
232
|
+
const firstLine = text.slice(0, firstCrlf)
|
|
233
|
+
if (SINGLE_LINE_REPLIES.has(firstLine)) return firstCrlf + 2
|
|
234
|
+
if (firstLine.startsWith('CLIENT_ERROR ')) return firstCrlf + 2
|
|
235
|
+
if (firstLine.startsWith('SERVER_ERROR ')) return firstCrlf + 2
|
|
236
|
+
if (firstLine.startsWith('VERSION ')) return firstCrlf + 2
|
|
237
|
+
// incr/decr return a bare decimal value.
|
|
238
|
+
if (/^\d+$/.test(firstLine)) return firstCrlf + 2
|
|
239
|
+
return -1
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const SINGLE_LINE_REPLIES = new Set([
|
|
243
|
+
'STORED',
|
|
244
|
+
'NOT_STORED',
|
|
245
|
+
'EXISTS',
|
|
246
|
+
'DELETED',
|
|
247
|
+
'NOT_FOUND',
|
|
248
|
+
'TOUCHED',
|
|
249
|
+
'OK',
|
|
250
|
+
'ERROR',
|
|
251
|
+
])
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { MemoryCache, type MemoryCacheOptions } from './memory_cache.ts'
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MemoryCache` — in-process cache backed by a single `Map`.
|
|
3
|
+
*
|
|
4
|
+
* Right driver for: dev, tests, per-process caches that don't need
|
|
5
|
+
* cross-process visibility. Wrong driver for: production deployments
|
|
6
|
+
* with more than one node (each node sees its own copy).
|
|
7
|
+
*
|
|
8
|
+
* Atomic ops (`add`, `increment`, `decrement`) are atomic *within
|
|
9
|
+
* this process* — Bun's event loop is single-threaded so no JS-level
|
|
10
|
+
* race exists. Cross-process, you want `PostgresCache`.
|
|
11
|
+
*
|
|
12
|
+
* Locks + tags are first-class here for parity with the Postgres
|
|
13
|
+
* driver, but the lock guarantee is only meaningful in a single
|
|
14
|
+
* process (use Postgres for cross-process mutual exclusion). The tag
|
|
15
|
+
* implementation walks two parallel maps (`key → tag-set`,
|
|
16
|
+
* `tag → key-set`) — cheap because everything is in memory.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { ulid } from '@strav/kernel'
|
|
20
|
+
import { Cache } from '../../cache.ts'
|
|
21
|
+
import { CacheLockTimeoutError } from '../../cache_error.ts'
|
|
22
|
+
import { ttlToExpiresAt } from '../../ttl.ts'
|
|
23
|
+
import type { CacheEntry, CacheLock, CacheTtl, TaggedCache } from '../../types.ts'
|
|
24
|
+
|
|
25
|
+
export interface MemoryCacheOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Override the clock for deterministic TTL tests. Default `Date.now`.
|
|
28
|
+
*/
|
|
29
|
+
now?: () => number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class MemoryCache extends Cache {
|
|
33
|
+
private readonly entries = new Map<string, CacheEntry<unknown>>()
|
|
34
|
+
private readonly keyTags = new Map<string, Set<string>>()
|
|
35
|
+
private readonly tagKeys = new Map<string, Set<string>>()
|
|
36
|
+
private readonly locks = new Map<string, { owner: string; expiresAt: number }>()
|
|
37
|
+
private readonly nowFn: () => number
|
|
38
|
+
|
|
39
|
+
constructor(options: MemoryCacheOptions = {}) {
|
|
40
|
+
super()
|
|
41
|
+
this.nowFn = options.now ?? Date.now
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
override async get<T = unknown>(key: string, fallback: T | null = null): Promise<T | null> {
|
|
45
|
+
const entry = this.entries.get(key)
|
|
46
|
+
if (entry === undefined) return fallback
|
|
47
|
+
if (this.expired(entry)) {
|
|
48
|
+
this.entries.delete(key)
|
|
49
|
+
this.removeKeyFromAllTags(key)
|
|
50
|
+
return fallback
|
|
51
|
+
}
|
|
52
|
+
return entry.value as T
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
override async put(key: string, value: unknown, ttl?: CacheTtl): Promise<void> {
|
|
56
|
+
this.entries.set(key, { value, expiresAt: ttlToExpiresAt(ttl, this.nowFn()) })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
override async has(key: string): Promise<boolean> {
|
|
60
|
+
const entry = this.entries.get(key)
|
|
61
|
+
if (entry === undefined) return false
|
|
62
|
+
if (this.expired(entry)) {
|
|
63
|
+
this.entries.delete(key)
|
|
64
|
+
this.removeKeyFromAllTags(key)
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
override async forget(key: string): Promise<boolean> {
|
|
71
|
+
this.removeKeyFromAllTags(key)
|
|
72
|
+
return this.entries.delete(key)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
override async flush(): Promise<void> {
|
|
76
|
+
this.entries.clear()
|
|
77
|
+
this.keyTags.clear()
|
|
78
|
+
this.tagKeys.clear()
|
|
79
|
+
// Active locks aren't cleared — they're a separate concept from
|
|
80
|
+
// cached values, and `flush()` shouldn't surprise a holder by
|
|
81
|
+
// dropping their lock.
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
override async add(key: string, value: unknown, ttl: CacheTtl): Promise<boolean> {
|
|
85
|
+
const existing = this.entries.get(key)
|
|
86
|
+
if (existing !== undefined && !this.expired(existing)) return false
|
|
87
|
+
this.entries.set(key, { value, expiresAt: ttlToExpiresAt(ttl, this.nowFn()) })
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
override async increment(key: string, by = 1): Promise<number> {
|
|
92
|
+
return this.adjust(key, by)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
override async decrement(key: string, by = 1): Promise<number> {
|
|
96
|
+
return this.adjust(key, -by)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
override lock(name: string, ttl: CacheTtl): CacheLock {
|
|
100
|
+
return new MemoryCacheLock(this, name, ttl)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
override tags(...tags: string[]): TaggedCache {
|
|
104
|
+
return new MemoryTaggedCache(this, tags)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Diagnostics / helpers ─────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/** Total entry count, including expired-but-not-yet-evicted rows. */
|
|
110
|
+
size(): number {
|
|
111
|
+
return this.entries.size
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Driver-internals exposed to the lock + tagged wrappers ────────────────
|
|
115
|
+
|
|
116
|
+
/** @internal */
|
|
117
|
+
_now(): number {
|
|
118
|
+
return this.nowFn()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** @internal */
|
|
122
|
+
_tryAcquireLock(name: string, ttl: CacheTtl): string | undefined {
|
|
123
|
+
const current = this.locks.get(name)
|
|
124
|
+
if (current !== undefined && current.expiresAt > this.nowFn()) return undefined
|
|
125
|
+
const owner = ulid()
|
|
126
|
+
this.locks.set(name, { owner, expiresAt: ttlToExpiresAt(ttl, this.nowFn()) ?? Infinity })
|
|
127
|
+
return owner
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @internal */
|
|
131
|
+
_releaseLock(name: string, owner: string): boolean {
|
|
132
|
+
const current = this.locks.get(name)
|
|
133
|
+
if (current === undefined) return false
|
|
134
|
+
if (current.owner !== owner) return false
|
|
135
|
+
this.locks.delete(name)
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @internal */
|
|
140
|
+
_putWithTags(key: string, value: unknown, ttl: CacheTtl, tags: readonly string[]): void {
|
|
141
|
+
this.entries.set(key, { value, expiresAt: ttlToExpiresAt(ttl, this.nowFn()) })
|
|
142
|
+
this.setKeyTags(key, tags)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** @internal */
|
|
146
|
+
_flushTags(tags: readonly string[]): number {
|
|
147
|
+
const toDrop = new Set<string>()
|
|
148
|
+
for (const tag of tags) {
|
|
149
|
+
const keys = this.tagKeys.get(tag)
|
|
150
|
+
if (keys === undefined) continue
|
|
151
|
+
for (const k of keys) toDrop.add(k)
|
|
152
|
+
}
|
|
153
|
+
for (const k of toDrop) {
|
|
154
|
+
this.entries.delete(k)
|
|
155
|
+
this.removeKeyFromAllTags(k)
|
|
156
|
+
}
|
|
157
|
+
return toDrop.size
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private adjust(key: string, delta: number): number {
|
|
161
|
+
const entry = this.entries.get(key)
|
|
162
|
+
if (entry === undefined || this.expired(entry)) {
|
|
163
|
+
this.entries.set(key, { value: delta, expiresAt: null })
|
|
164
|
+
return delta
|
|
165
|
+
}
|
|
166
|
+
const current = typeof entry.value === 'number' ? entry.value : Number(entry.value)
|
|
167
|
+
const next = current + delta
|
|
168
|
+
entry.value = next
|
|
169
|
+
return next
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private setKeyTags(key: string, tags: readonly string[]): void {
|
|
173
|
+
// Reset existing tag wiring for this key first — same key getting
|
|
174
|
+
// re-tagged is a legit operation, and stale tag links would
|
|
175
|
+
// make `flushTags` over-delete.
|
|
176
|
+
this.removeKeyFromAllTags(key)
|
|
177
|
+
const tagSet = new Set(tags)
|
|
178
|
+
this.keyTags.set(key, tagSet)
|
|
179
|
+
for (const tag of tagSet) {
|
|
180
|
+
let bucket = this.tagKeys.get(tag)
|
|
181
|
+
if (bucket === undefined) {
|
|
182
|
+
bucket = new Set()
|
|
183
|
+
this.tagKeys.set(tag, bucket)
|
|
184
|
+
}
|
|
185
|
+
bucket.add(key)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private removeKeyFromAllTags(key: string): void {
|
|
190
|
+
const tagSet = this.keyTags.get(key)
|
|
191
|
+
if (tagSet === undefined) return
|
|
192
|
+
for (const tag of tagSet) {
|
|
193
|
+
const bucket = this.tagKeys.get(tag)
|
|
194
|
+
if (bucket === undefined) continue
|
|
195
|
+
bucket.delete(key)
|
|
196
|
+
if (bucket.size === 0) this.tagKeys.delete(tag)
|
|
197
|
+
}
|
|
198
|
+
this.keyTags.delete(key)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private expired(entry: CacheEntry<unknown>): boolean {
|
|
202
|
+
return entry.expiresAt !== null && entry.expiresAt <= this.nowFn()
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
class MemoryCacheLock implements CacheLock {
|
|
207
|
+
private owner: string | undefined
|
|
208
|
+
|
|
209
|
+
constructor(
|
|
210
|
+
private readonly cache: MemoryCache,
|
|
211
|
+
readonly name: string,
|
|
212
|
+
private readonly ttl: CacheTtl,
|
|
213
|
+
) {}
|
|
214
|
+
|
|
215
|
+
async acquire(): Promise<boolean> {
|
|
216
|
+
if (this.owner !== undefined) return false
|
|
217
|
+
const token = this.cache._tryAcquireLock(this.name, this.ttl)
|
|
218
|
+
if (token === undefined) return false
|
|
219
|
+
this.owner = token
|
|
220
|
+
return true
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async release(): Promise<boolean> {
|
|
224
|
+
if (this.owner === undefined) return false
|
|
225
|
+
const released = this.cache._releaseLock(this.name, this.owner)
|
|
226
|
+
this.owner = undefined
|
|
227
|
+
return released
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async block<T>(timeoutMs: number, fn: () => Promise<T> | T): Promise<T> {
|
|
231
|
+
// parseTtl validates the input even though we don't use seconds here.
|
|
232
|
+
if (timeoutMs < 0 || !Number.isFinite(timeoutMs)) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`CacheLock.block: timeoutMs must be a non-negative finite number; got: ${timeoutMs}`,
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
const deadline = this.cache._now() + timeoutMs
|
|
238
|
+
const pollIntervalMs = 50
|
|
239
|
+
while (true) {
|
|
240
|
+
if (await this.acquire()) {
|
|
241
|
+
try {
|
|
242
|
+
return await fn()
|
|
243
|
+
} finally {
|
|
244
|
+
await this.release()
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (this.cache._now() >= deadline) {
|
|
248
|
+
throw new CacheLockTimeoutError(
|
|
249
|
+
`CacheLock "${this.name}" not acquired within ${timeoutMs}ms.`,
|
|
250
|
+
{ context: { lock: this.name, timeoutMs } },
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
await new Promise<void>((r) => setTimeout(r, pollIntervalMs))
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
class MemoryTaggedCache implements TaggedCache {
|
|
259
|
+
constructor(
|
|
260
|
+
private readonly cache: MemoryCache,
|
|
261
|
+
readonly tags: readonly string[],
|
|
262
|
+
) {}
|
|
263
|
+
|
|
264
|
+
async put(key: string, value: unknown, ttl?: CacheTtl): Promise<void> {
|
|
265
|
+
this.cache._putWithTags(key, value, ttl, this.tags)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
get<T = unknown>(key: string, fallback: T | null = null): Promise<T | null> {
|
|
269
|
+
return this.cache.get<T>(key, fallback)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
forget(key: string): Promise<boolean> {
|
|
273
|
+
return this.cache.forget(key)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async flush(): Promise<number> {
|
|
277
|
+
return this.cache._flushTags(this.tags)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `applyCacheMigration` — emit DDL for the three tables `PostgresCache`
|
|
3
|
+
* uses.
|
|
4
|
+
*
|
|
5
|
+
* - `strav_cache (key text PK, data jsonb, expires_at timestamptz NULL)`
|
|
6
|
+
* — the main key/value store. `expires_at` indexed for the
|
|
7
|
+
* periodic sweep.
|
|
8
|
+
* - `strav_cache_locks (name text PK, owner text NOT NULL,
|
|
9
|
+
* expires_at timestamptz NOT NULL)` — distributed-lock slots.
|
|
10
|
+
* - `strav_cache_tags (key text NOT NULL, tag text NOT NULL,
|
|
11
|
+
* PRIMARY KEY (key, tag))` — tag-to-key index. `tag` indexed
|
|
12
|
+
* for the flush path. FK to `strav_cache.key` ON DELETE CASCADE
|
|
13
|
+
* so cache-entry deletion takes its tag wiring with it.
|
|
14
|
+
*
|
|
15
|
+
* Schemas are NOT registered with the SchemaRegistry because the cache
|
|
16
|
+
* table layout (text PK, composite PK on tags) doesn't fit the schema
|
|
17
|
+
* DSL — apps just invoke this helper from a migration `up()` and DROP
|
|
18
|
+
* the tables in `down()`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { DatabaseExecutor } from '@strav/database'
|
|
22
|
+
|
|
23
|
+
export async function applyCacheMigration(db: DatabaseExecutor): Promise<void> {
|
|
24
|
+
await db.execute(
|
|
25
|
+
`CREATE TABLE IF NOT EXISTS "strav_cache" (
|
|
26
|
+
"key" text PRIMARY KEY,
|
|
27
|
+
"data" jsonb NOT NULL,
|
|
28
|
+
"expires_at" timestamptz NULL
|
|
29
|
+
)`,
|
|
30
|
+
)
|
|
31
|
+
await db.execute(
|
|
32
|
+
`CREATE INDEX IF NOT EXISTS "idx_strav_cache_expires_at"
|
|
33
|
+
ON "strav_cache" ("expires_at")
|
|
34
|
+
WHERE "expires_at" IS NOT NULL`,
|
|
35
|
+
)
|
|
36
|
+
await db.execute(
|
|
37
|
+
`CREATE TABLE IF NOT EXISTS "strav_cache_locks" (
|
|
38
|
+
"name" text PRIMARY KEY,
|
|
39
|
+
"owner" text NOT NULL,
|
|
40
|
+
"expires_at" timestamptz NOT NULL
|
|
41
|
+
)`,
|
|
42
|
+
)
|
|
43
|
+
await db.execute(
|
|
44
|
+
`CREATE TABLE IF NOT EXISTS "strav_cache_tags" (
|
|
45
|
+
"key" text NOT NULL,
|
|
46
|
+
"tag" text NOT NULL,
|
|
47
|
+
PRIMARY KEY ("key", "tag"),
|
|
48
|
+
FOREIGN KEY ("key") REFERENCES "strav_cache" ("key") ON DELETE CASCADE
|
|
49
|
+
)`,
|
|
50
|
+
)
|
|
51
|
+
await db.execute(
|
|
52
|
+
`CREATE INDEX IF NOT EXISTS "idx_strav_cache_tags_tag" ON "strav_cache_tags" ("tag")`,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { applyCacheMigration } from './apply_cache_migration.ts'
|
|
2
|
+
export {
|
|
3
|
+
PostgresCache,
|
|
4
|
+
type PostgresCacheDatabase,
|
|
5
|
+
type PostgresCacheOptions,
|
|
6
|
+
} from './postgres_cache.ts'
|
|
7
|
+
export {
|
|
8
|
+
type PostgresCacheConfig,
|
|
9
|
+
PostgresCacheProvider,
|
|
10
|
+
} from './postgres_cache_provider.ts'
|