@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
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# @strav/cache
|
|
2
|
+
|
|
3
|
+
Key/value cache with TTLs for Strav 1.0. Apps inject the abstract `Cache` token; the provider in the container picks the concrete driver — `MemoryCache` for single-node dev, `PostgresCache` for multi-node deployments. Same dependency shape as `@strav/broadcast` (kernel-free core in the root, optional Postgres driver under a subpath).
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { Cache } from '@strav/cache'
|
|
7
|
+
|
|
8
|
+
@inject()
|
|
9
|
+
class LeadsService {
|
|
10
|
+
constructor(private readonly cache: Cache) {}
|
|
11
|
+
|
|
12
|
+
async trending(): Promise<Lead[]> {
|
|
13
|
+
return this.cache.remember('leads.trending', '5m', async () => {
|
|
14
|
+
return this.leads.query().orderBy('score', 'desc').limit(10).get()
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Canonical docs live in [`docs/cache/README.md`](../../docs/cache/README.md).
|
|
21
|
+
|
|
22
|
+
## What ships
|
|
23
|
+
|
|
24
|
+
| Driver | Subpath | Notes |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| Memory | `@strav/cache` (root) + `@strav/cache/memory` | In-process. Bounded buffer; locks + tags first-class. Single-node only. |
|
|
27
|
+
| Postgres | `@strav/cache/postgres` | Cross-process backplane via three tables (`strav_cache`, `strav_cache_locks`, `strav_cache_tags`). Atomic increments via row locks, FK cascade keeps tag rows tight. |
|
|
28
|
+
|
|
29
|
+
The full Cache surface (`get`/`put`/`forget`/`has`/`flush`/`add`/`increment`/`decrement`/`remember`/`rememberForever`/`lock`/`tags`) ships on both drivers; the abstract base provides `remember` + `rememberForever` so every driver behaves identically there. No Redis driver yet — apps that need one write against the `Cache` contract (two abstract methods + the wrapper classes for locks/tags).
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@strav/cache",
|
|
3
|
+
"version": "1.0.0-alpha.25",
|
|
4
|
+
"description": "Strav cache layer — Cache abstraction + MemoryCache (in-process) + PostgresCache (cross-process ledger) + RedisCache (Bun.RedisClient, all data structures incl. tagged sets) + MemcachedCache (text-protocol client over Bun.connect). Atomic increments, distributed locks, tagged invalidation. Mirrors @strav/broadcast: kernel-free core in the root, every driver under its own subpath.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./memory": "./src/drivers/memory/index.ts",
|
|
11
|
+
"./postgres": "./src/drivers/postgres/index.ts",
|
|
12
|
+
"./redis": "./src/drivers/redis/index.ts",
|
|
13
|
+
"./memcached": "./src/drivers/memcached/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"bun": ">=1.3.14"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@strav/kernel": "1.0.0-alpha.25"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@strav/database": "1.0.0-alpha.25",
|
|
30
|
+
"@types/bun": ">=1.3.14"
|
|
31
|
+
},
|
|
32
|
+
"peerDependenciesMeta": {
|
|
33
|
+
"@strav/database": {
|
|
34
|
+
"optional": true
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": null
|
|
38
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Cache` — the abstract base every driver extends and apps inject
|
|
3
|
+
* via the container.
|
|
4
|
+
*
|
|
5
|
+
* Driver primitives (subclass overrides):
|
|
6
|
+
*
|
|
7
|
+
* - `get(key)` — returns the stored value or `null`. Expired entries
|
|
8
|
+
* return `null` AND are removed in passing.
|
|
9
|
+
* - `put(key, value, ttl?)` — overwrite. `ttl` parsed via `parseTtl`.
|
|
10
|
+
* - `has(key)` — boolean check.
|
|
11
|
+
* - `forget(key)` — delete. Returns true if a key was actually
|
|
12
|
+
* removed.
|
|
13
|
+
* - `flush()` — clear everything. DEV-mode tool; production code
|
|
14
|
+
* should prefer scoped invalidation.
|
|
15
|
+
* - `add(key, value, ttl)` — atomic put-if-absent. Returns true if
|
|
16
|
+
* stored, false if a value was already there (and unexpired).
|
|
17
|
+
* - `increment(key, by)` / `decrement(key, by)` — atomic numeric
|
|
18
|
+
* update. Drivers must guarantee atomicity across concurrent
|
|
19
|
+
* callers; the Memory driver is fine for single-process dev,
|
|
20
|
+
* Postgres uses an UPDATE … RETURNING.
|
|
21
|
+
* - `lock(name, ttl)` — return a `CacheLock` handle. The same `name`
|
|
22
|
+
* across processes / requests competes for one slot.
|
|
23
|
+
* - `tags(...tags)` — return a `TaggedCache` namespace.
|
|
24
|
+
*
|
|
25
|
+
* The base provides:
|
|
26
|
+
*
|
|
27
|
+
* - `remember(key, ttl, fn)` / `rememberForever(key, fn)` — the
|
|
28
|
+
* "get or compute and cache" pattern. Implemented once on top of
|
|
29
|
+
* `get` + `put` so every driver behaves identically.
|
|
30
|
+
* - `close()` — default no-op; drivers override to release pools.
|
|
31
|
+
*
|
|
32
|
+
* Non-abstract on purpose so the class can serve as the container
|
|
33
|
+
* token (`app.singleton(Cache, factory)`). Subclasses MUST override
|
|
34
|
+
* the primitives; the default implementations throw to surface
|
|
35
|
+
* forgotten overrides during development. Same trade-off as
|
|
36
|
+
* `kernel`'s `Logger` and `@strav/broadcast`'s `Broadcaster`.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import type { CacheLock, CacheTtl, TaggedCache } from './types.ts'
|
|
40
|
+
|
|
41
|
+
export class Cache {
|
|
42
|
+
// biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
|
|
43
|
+
get<T = unknown>(key: string, fallback?: T | null): Promise<T | null> {
|
|
44
|
+
throw new Error('Cache.get must be overridden by the driver subclass.')
|
|
45
|
+
}
|
|
46
|
+
// biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
|
|
47
|
+
put(key: string, value: unknown, ttl?: CacheTtl): Promise<void> {
|
|
48
|
+
throw new Error('Cache.put must be overridden by the driver subclass.')
|
|
49
|
+
}
|
|
50
|
+
// biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
|
|
51
|
+
has(key: string): Promise<boolean> {
|
|
52
|
+
throw new Error('Cache.has must be overridden by the driver subclass.')
|
|
53
|
+
}
|
|
54
|
+
// biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
|
|
55
|
+
forget(key: string): Promise<boolean> {
|
|
56
|
+
throw new Error('Cache.forget must be overridden by the driver subclass.')
|
|
57
|
+
}
|
|
58
|
+
flush(): Promise<void> {
|
|
59
|
+
throw new Error('Cache.flush must be overridden by the driver subclass.')
|
|
60
|
+
}
|
|
61
|
+
// biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
|
|
62
|
+
add(key: string, value: unknown, ttl: CacheTtl): Promise<boolean> {
|
|
63
|
+
throw new Error('Cache.add must be overridden by the driver subclass.')
|
|
64
|
+
}
|
|
65
|
+
// biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
|
|
66
|
+
increment(key: string, by = 1): Promise<number> {
|
|
67
|
+
throw new Error('Cache.increment must be overridden by the driver subclass.')
|
|
68
|
+
}
|
|
69
|
+
// biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
|
|
70
|
+
decrement(key: string, by = 1): Promise<number> {
|
|
71
|
+
throw new Error('Cache.decrement must be overridden by the driver subclass.')
|
|
72
|
+
}
|
|
73
|
+
// biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
|
|
74
|
+
lock(name: string, ttl: CacheTtl): CacheLock {
|
|
75
|
+
throw new Error('Cache.lock must be overridden by the driver subclass.')
|
|
76
|
+
}
|
|
77
|
+
// biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
|
|
78
|
+
tags(...tags: string[]): TaggedCache {
|
|
79
|
+
throw new Error('Cache.tags must be overridden by the driver subclass.')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Higher-level patterns (driver-agnostic) ───────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* "Get this value, or compute and cache it." The most common
|
|
86
|
+
* cache-aside shape — and the one driver-portable code reaches for
|
|
87
|
+
* by default.
|
|
88
|
+
*/
|
|
89
|
+
async remember<T>(key: string, ttl: CacheTtl, fn: () => Promise<T> | T): Promise<T> {
|
|
90
|
+
const cached = await this.get<T>(key)
|
|
91
|
+
if (cached !== null) return cached
|
|
92
|
+
const fresh = await fn()
|
|
93
|
+
await this.put(key, fresh, ttl)
|
|
94
|
+
return fresh
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `remember` with no TTL. The entry persists until `forget` / `flush`. */
|
|
98
|
+
rememberForever<T>(key: string, fn: () => Promise<T> | T): Promise<T> {
|
|
99
|
+
return this.remember(key, null, fn)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Optional driver-resource cleanup. Default no-op. */
|
|
103
|
+
async close(): Promise<void> {}
|
|
104
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed error hierarchy. Same shape as `@strav/broadcast` /
|
|
3
|
+
* `@strav/notification` — base `CacheError` extending `StravError`,
|
|
4
|
+
* narrower subclasses with stable `code`s apps can branch on.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { StravError } from '@strav/kernel'
|
|
8
|
+
|
|
9
|
+
interface CacheErrorOptions {
|
|
10
|
+
code?: string
|
|
11
|
+
status?: number
|
|
12
|
+
context?: Record<string, unknown>
|
|
13
|
+
cause?: unknown
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class CacheError extends StravError {
|
|
17
|
+
constructor(message: string, options: CacheErrorOptions = {}) {
|
|
18
|
+
super(
|
|
19
|
+
message,
|
|
20
|
+
{ code: options.code ?? 'cache.error', status: options.status ?? 500 },
|
|
21
|
+
{
|
|
22
|
+
...(options.context ? { context: options.context } : {}),
|
|
23
|
+
...(options.cause !== undefined ? { cause: options.cause } : {}),
|
|
24
|
+
},
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Driver constructed with bad config (empty connection url, etc.). */
|
|
30
|
+
export class CacheConfigError extends CacheError {
|
|
31
|
+
constructor(message: string, options: { context?: Record<string, unknown> } = {}) {
|
|
32
|
+
super(message, {
|
|
33
|
+
code: 'cache.config',
|
|
34
|
+
status: 500,
|
|
35
|
+
...(options.context ? { context: options.context } : {}),
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A driver primitive rejected at I/O time (network, query failed, etc.). */
|
|
41
|
+
export class CacheDriverError extends CacheError {
|
|
42
|
+
constructor(
|
|
43
|
+
message: string,
|
|
44
|
+
options: { context?: Record<string, unknown>; cause?: unknown } = {},
|
|
45
|
+
) {
|
|
46
|
+
super(message, {
|
|
47
|
+
code: 'cache.driver',
|
|
48
|
+
status: 502,
|
|
49
|
+
...(options.context ? { context: options.context } : {}),
|
|
50
|
+
...(options.cause !== undefined ? { cause: options.cause } : {}),
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `CacheLock.block(timeoutMs, fn)` exhausted its window. */
|
|
56
|
+
export class CacheLockTimeoutError extends CacheError {
|
|
57
|
+
constructor(message: string, options: { context?: Record<string, unknown> } = {}) {
|
|
58
|
+
super(message, {
|
|
59
|
+
code: 'cache.lock_timeout',
|
|
60
|
+
status: 503,
|
|
61
|
+
...(options.context ? { context: options.context } : {}),
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `cache.put(key, value, ttl)` got a TTL string it couldn't parse. */
|
|
67
|
+
export class CacheTtlParseError extends CacheError {
|
|
68
|
+
constructor(message: string, options: { context?: Record<string, unknown> } = {}) {
|
|
69
|
+
super(message, {
|
|
70
|
+
code: 'cache.ttl_parse',
|
|
71
|
+
status: 400,
|
|
72
|
+
...(options.context ? { context: options.context } : {}),
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `CacheProvider` — registers `MemoryCache` under the `Cache` token by
|
|
3
|
+
* default.
|
|
4
|
+
*
|
|
5
|
+
* Apps that want the Postgres backplane swap providers:
|
|
6
|
+
*
|
|
7
|
+
* import { CacheProvider } from '@strav/cache'
|
|
8
|
+
* import { PostgresCacheProvider } from '@strav/cache/postgres'
|
|
9
|
+
*
|
|
10
|
+
* providers: [
|
|
11
|
+
* ...,
|
|
12
|
+
* new PostgresCacheProvider(), // INSTEAD OF CacheProvider
|
|
13
|
+
* ]
|
|
14
|
+
*
|
|
15
|
+
* Both providers register under the same `Cache` token, so app code
|
|
16
|
+
* injecting `Cache` doesn't change between dev and prod.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { type Application, ConfigRepository, ServiceProvider } from '@strav/kernel'
|
|
20
|
+
import { Cache } from './cache.ts'
|
|
21
|
+
import { MemoryCache, type MemoryCacheOptions } from './drivers/memory/memory_cache.ts'
|
|
22
|
+
|
|
23
|
+
export interface MemoryCacheConfig extends MemoryCacheOptions {
|
|
24
|
+
driver: 'memory'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class CacheProvider extends ServiceProvider {
|
|
28
|
+
override readonly name = 'cache'
|
|
29
|
+
override readonly dependencies = ['config']
|
|
30
|
+
|
|
31
|
+
override register(app: Application): void {
|
|
32
|
+
app.singleton(Cache, (c) => {
|
|
33
|
+
const cfg = c.resolve(ConfigRepository).get('cache') as MemoryCacheConfig | undefined
|
|
34
|
+
return new MemoryCache(cfg !== undefined && cfg.now !== undefined ? { now: cfg.now } : {})
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
override async boot(app: Application): Promise<void> {
|
|
39
|
+
app.resolve(Cache)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
override async shutdown(app: Application): Promise<void> {
|
|
43
|
+
await app.resolve(Cache).close()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { MemcachedCache, type MemcachedCacheOptions } from './memcached_cache.ts'
|
|
2
|
+
export {
|
|
3
|
+
type MemcachedCacheConfig,
|
|
4
|
+
MemcachedCacheProvider,
|
|
5
|
+
} from './memcached_cache_provider.ts'
|
|
6
|
+
export { MemcachedClient, type MemcachedClientOptions } from './memcached_client.ts'
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MemcachedCache` — Memcached-backed cross-process cache.
|
|
3
|
+
*
|
|
4
|
+
* Operation mapping (text protocol):
|
|
5
|
+
*
|
|
6
|
+
* - `get` → `get key\r\n` → `VALUE key 0 N\r\n<value>\r\nEND\r\n`.
|
|
7
|
+
* JSON.parse, fall back to the raw string for non-JSON payloads
|
|
8
|
+
* (counters return bare decimal text).
|
|
9
|
+
* - `put` → `set key 0 N bytes\r\n<value>\r\n`.
|
|
10
|
+
* - `has` → `get` + non-null check.
|
|
11
|
+
* - `forget` → `delete key\r\n`.
|
|
12
|
+
* - `flush` → `flush_all\r\n` — wipes the WHOLE server. There's no
|
|
13
|
+
* prefix-scoped equivalent in Memcached. Apps that share their
|
|
14
|
+
* Memcached instance with other code shouldn't use `flush`.
|
|
15
|
+
* - `add` → `add key 0 N bytes\r\n<value>\r\n` (atomic put-if-absent).
|
|
16
|
+
* - `increment` / `decrement` → `incr key by\r\n` (or `decr`).
|
|
17
|
+
* Memcached returns `NOT_FOUND` for missing keys; the driver
|
|
18
|
+
* falls back to an `add` of the delta, then re-`incr`s on race.
|
|
19
|
+
* - `lock(name, ttl)` → `add` (atomic). **No CAS-scoped release** —
|
|
20
|
+
* a slow caller's expired lock can be released by a different
|
|
21
|
+
* holder. Apps that need strict ownership use Redis or Postgres.
|
|
22
|
+
* - `tags(...)` → throws `CacheDriverError`. Memcached has no
|
|
23
|
+
* native sets and no SCAN; emulating tags would require a
|
|
24
|
+
* full-server scan per flush. Out of scope.
|
|
25
|
+
*
|
|
26
|
+
* Keys are namespaced with `prefix` (default `'strav:'`). Memcached
|
|
27
|
+
* keys are ASCII-only, no whitespace, ≤250 bytes — the driver does
|
|
28
|
+
* NOT validate this; callers are expected to keep keys clean.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { Cache } from '../../cache.ts'
|
|
32
|
+
import { CacheConfigError, CacheDriverError, CacheLockTimeoutError } from '../../cache_error.ts'
|
|
33
|
+
import { parseTtl } from '../../ttl.ts'
|
|
34
|
+
import type { CacheLock, CacheTtl, TaggedCache } from '../../types.ts'
|
|
35
|
+
import { MemcachedClient, type MemcachedClientOptions } from './memcached_client.ts'
|
|
36
|
+
|
|
37
|
+
const ENCODER = new TextEncoder()
|
|
38
|
+
const DECODER = new TextDecoder()
|
|
39
|
+
|
|
40
|
+
export interface MemcachedCacheOptions extends Omit<MemcachedClientOptions, 'host' | 'port'> {
|
|
41
|
+
host: string
|
|
42
|
+
port: number
|
|
43
|
+
/** Key namespace prefix. Default `'strav:'`. */
|
|
44
|
+
prefix?: string
|
|
45
|
+
/** Pre-constructed client for tests. */
|
|
46
|
+
client?: MemcachedClient
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class MemcachedCache extends Cache {
|
|
50
|
+
private readonly client: MemcachedClient
|
|
51
|
+
private readonly ownsClient: boolean
|
|
52
|
+
private readonly prefix: string
|
|
53
|
+
|
|
54
|
+
constructor(options: MemcachedCacheOptions) {
|
|
55
|
+
super()
|
|
56
|
+
this.prefix = options.prefix ?? 'strav:'
|
|
57
|
+
if (options.client !== undefined) {
|
|
58
|
+
this.client = options.client
|
|
59
|
+
this.ownsClient = false
|
|
60
|
+
} else {
|
|
61
|
+
if (!options.host || !options.port) {
|
|
62
|
+
throw new CacheConfigError(
|
|
63
|
+
'MemcachedCache requires `host` + `port` (or an injected `client`).',
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
this.client = new MemcachedClient({
|
|
67
|
+
host: options.host,
|
|
68
|
+
port: options.port,
|
|
69
|
+
...(options.connectTimeoutMs !== undefined
|
|
70
|
+
? { connectTimeoutMs: options.connectTimeoutMs }
|
|
71
|
+
: {}),
|
|
72
|
+
...(options.requestTimeoutMs !== undefined
|
|
73
|
+
? { requestTimeoutMs: options.requestTimeoutMs }
|
|
74
|
+
: {}),
|
|
75
|
+
})
|
|
76
|
+
this.ownsClient = true
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Core primitives ───────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
override async get<T = unknown>(key: string, fallback: T | null = null): Promise<T | null> {
|
|
83
|
+
const reply = DECODER.decode(await this.client.send(`get ${this.k(key)}\r\n`))
|
|
84
|
+
if (reply === 'END\r\n') return fallback
|
|
85
|
+
if (!reply.startsWith('VALUE ')) {
|
|
86
|
+
throw new CacheDriverError(`MemcachedCache.get: unexpected reply: ${trimError(reply)}`, {
|
|
87
|
+
context: { key },
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
const headerEnd = reply.indexOf('\r\n')
|
|
91
|
+
const header = reply.slice(0, headerEnd) // `VALUE key 0 N`
|
|
92
|
+
const bytes = Number(header.split(' ')[3] ?? '0')
|
|
93
|
+
const valueStart = headerEnd + 2
|
|
94
|
+
const value = reply.slice(valueStart, valueStart + bytes)
|
|
95
|
+
return this.decode<T>(value, fallback)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
override async put(key: string, value: unknown, ttl?: CacheTtl): Promise<void> {
|
|
99
|
+
const seconds = parseTtl(ttl) ?? 0
|
|
100
|
+
await this.storeCommand('set', key, value, seconds)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
override async has(key: string): Promise<boolean> {
|
|
104
|
+
const reply = DECODER.decode(await this.client.send(`get ${this.k(key)}\r\n`))
|
|
105
|
+
return !reply.startsWith('END\r\n')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
override async forget(key: string): Promise<boolean> {
|
|
109
|
+
const reply = DECODER.decode(await this.client.send(`delete ${this.k(key)}\r\n`))
|
|
110
|
+
return reply.startsWith('DELETED')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
override async flush(): Promise<void> {
|
|
114
|
+
// flush_all is server-wide — apps sharing the instance shouldn't
|
|
115
|
+
// call this. There is no scoped equivalent in Memcached.
|
|
116
|
+
await this.client.send('flush_all\r\n')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
override async add(key: string, value: unknown, ttl: CacheTtl): Promise<boolean> {
|
|
120
|
+
const seconds = parseTtl(ttl) ?? 0
|
|
121
|
+
return this.storeCommand('add', key, value, seconds)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
override async increment(key: string, by = 1): Promise<number> {
|
|
125
|
+
return this.adjust(key, by, 'incr')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
override async decrement(key: string, by = 1): Promise<number> {
|
|
129
|
+
return this.adjust(key, by, 'decr')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
override lock(name: string, ttl: CacheTtl): CacheLock {
|
|
133
|
+
return new MemcachedCacheLock(this, name, ttl)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
override tags(..._tags: string[]): TaggedCache {
|
|
137
|
+
throw new CacheDriverError(
|
|
138
|
+
'MemcachedCache does not support tagged invalidation — Memcached has no native sets and no SCAN. Use RedisCache or PostgresCache for tag support.',
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
override async close(): Promise<void> {
|
|
143
|
+
if (!this.ownsClient) return
|
|
144
|
+
await this.client.close()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── Lock internals ────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
/** @internal */
|
|
150
|
+
async _tryAcquireLock(name: string, ttl: CacheTtl): Promise<string | undefined> {
|
|
151
|
+
const seconds = parseTtl(ttl)
|
|
152
|
+
if (seconds === null) {
|
|
153
|
+
throw new CacheConfigError('MemcachedCache.lock: TTL must be set — no "forever" locks.')
|
|
154
|
+
}
|
|
155
|
+
const owner = randomToken()
|
|
156
|
+
const stored = await this.storeCommand('add', `lock:${name}`, owner, seconds)
|
|
157
|
+
return stored ? owner : undefined
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** @internal */
|
|
161
|
+
async _releaseLock(name: string, owner: string): Promise<boolean> {
|
|
162
|
+
// No CAS-scoped delete in Memcached's text protocol — read the
|
|
163
|
+
// value, compare, then delete. A slim race window exists where
|
|
164
|
+
// another holder acquired between our `get` and `delete`; document
|
|
165
|
+
// the limitation and let apps that need strict ownership pick a
|
|
166
|
+
// different driver.
|
|
167
|
+
const current = await this.get<string>(`lock:${name}`)
|
|
168
|
+
if (current !== owner) return false
|
|
169
|
+
return this.forget(`lock:${name}`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── Internals ─────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
private async storeCommand(
|
|
175
|
+
cmd: 'set' | 'add',
|
|
176
|
+
key: string,
|
|
177
|
+
value: unknown,
|
|
178
|
+
seconds: number,
|
|
179
|
+
): Promise<boolean> {
|
|
180
|
+
const encoded = ENCODER.encode(JSON.stringify(value))
|
|
181
|
+
const header = `${cmd} ${this.k(key)} 0 ${seconds} ${encoded.length}\r\n`
|
|
182
|
+
const headerBytes = ENCODER.encode(header)
|
|
183
|
+
const trailer = ENCODER.encode('\r\n')
|
|
184
|
+
const payload = new Uint8Array(headerBytes.length + encoded.length + trailer.length)
|
|
185
|
+
payload.set(headerBytes, 0)
|
|
186
|
+
payload.set(encoded, headerBytes.length)
|
|
187
|
+
payload.set(trailer, headerBytes.length + encoded.length)
|
|
188
|
+
const reply = DECODER.decode(await this.client.send(payload))
|
|
189
|
+
if (reply.startsWith('STORED')) return true
|
|
190
|
+
if (reply.startsWith('NOT_STORED')) return false
|
|
191
|
+
throw new CacheDriverError(`MemcachedCache.${cmd}: unexpected reply: ${trimError(reply)}`, {
|
|
192
|
+
context: { key },
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private async adjust(key: string, by: number, op: 'incr' | 'decr'): Promise<number> {
|
|
197
|
+
const reply = DECODER.decode(await this.client.send(`${op} ${this.k(key)} ${by}\r\n`))
|
|
198
|
+
if (/^\d+/.test(reply)) return Number(reply.split('\r\n')[0])
|
|
199
|
+
if (!reply.startsWith('NOT_FOUND')) {
|
|
200
|
+
throw new CacheDriverError(`MemcachedCache.${op}: unexpected reply: ${trimError(reply)}`, {
|
|
201
|
+
context: { key },
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
// NOT_FOUND — try to seed via add(delta). Use no expiry (0) so the
|
|
205
|
+
// counter behaves like Redis (forever until forget).
|
|
206
|
+
const seedValue = op === 'incr' ? by : -by
|
|
207
|
+
const stored = await this.storeCommand('add', key, seedValue, 0)
|
|
208
|
+
if (stored) return seedValue
|
|
209
|
+
// Race — another caller created the key first. Re-run incr/decr.
|
|
210
|
+
const replyAfter = DECODER.decode(await this.client.send(`${op} ${this.k(key)} ${by}\r\n`))
|
|
211
|
+
if (/^\d+/.test(replyAfter)) return Number(replyAfter.split('\r\n')[0])
|
|
212
|
+
throw new CacheDriverError(
|
|
213
|
+
`MemcachedCache.${op}: still NOT_FOUND after seed retry — odd race or non-numeric value at "${key}".`,
|
|
214
|
+
{ context: { key } },
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private k(key: string): string {
|
|
219
|
+
return `${this.prefix}${key}`
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private decode<T>(raw: string, fallback: T | null): T | null {
|
|
223
|
+
try {
|
|
224
|
+
return JSON.parse(raw) as T
|
|
225
|
+
} catch {
|
|
226
|
+
return raw as unknown as T
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
class MemcachedCacheLock implements CacheLock {
|
|
232
|
+
private owner: string | undefined
|
|
233
|
+
|
|
234
|
+
constructor(
|
|
235
|
+
private readonly cache: MemcachedCache,
|
|
236
|
+
readonly name: string,
|
|
237
|
+
private readonly ttl: CacheTtl,
|
|
238
|
+
) {}
|
|
239
|
+
|
|
240
|
+
async acquire(): Promise<boolean> {
|
|
241
|
+
if (this.owner !== undefined) return false
|
|
242
|
+
const token = await this.cache._tryAcquireLock(this.name, this.ttl)
|
|
243
|
+
if (token === undefined) return false
|
|
244
|
+
this.owner = token
|
|
245
|
+
return true
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async release(): Promise<boolean> {
|
|
249
|
+
if (this.owner === undefined) return false
|
|
250
|
+
const released = await this.cache._releaseLock(this.name, this.owner)
|
|
251
|
+
this.owner = undefined
|
|
252
|
+
return released
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async block<T>(timeoutMs: number, fn: () => Promise<T> | T): Promise<T> {
|
|
256
|
+
if (timeoutMs < 0 || !Number.isFinite(timeoutMs)) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`CacheLock.block: timeoutMs must be a non-negative finite number; got: ${timeoutMs}`,
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
const deadline = Date.now() + timeoutMs
|
|
262
|
+
const pollIntervalMs = 75
|
|
263
|
+
while (true) {
|
|
264
|
+
if (await this.acquire()) {
|
|
265
|
+
try {
|
|
266
|
+
return await fn()
|
|
267
|
+
} finally {
|
|
268
|
+
await this.release()
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (Date.now() >= deadline) {
|
|
272
|
+
throw new CacheLockTimeoutError(
|
|
273
|
+
`CacheLock "${this.name}" not acquired within ${timeoutMs}ms.`,
|
|
274
|
+
{ context: { lock: this.name, timeoutMs } },
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
await new Promise<void>((r) => setTimeout(r, pollIntervalMs))
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function randomToken(): string {
|
|
283
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function trimError(reply: string): string {
|
|
287
|
+
return reply.replace(/\r\n$/, '').slice(0, 200)
|
|
288
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type Application, ConfigRepository, ServiceProvider } from '@strav/kernel'
|
|
2
|
+
import { Cache } from '../../cache.ts'
|
|
3
|
+
import { CacheConfigError } from '../../cache_error.ts'
|
|
4
|
+
import { MemcachedCache, type MemcachedCacheOptions } from './memcached_cache.ts'
|
|
5
|
+
|
|
6
|
+
export interface MemcachedCacheConfig extends Omit<MemcachedCacheOptions, 'client'> {
|
|
7
|
+
driver: 'memcached'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class MemcachedCacheProvider extends ServiceProvider {
|
|
11
|
+
override readonly name = 'cache'
|
|
12
|
+
override readonly dependencies = ['config']
|
|
13
|
+
|
|
14
|
+
override register(app: Application): void {
|
|
15
|
+
app.singleton(Cache, (c) => {
|
|
16
|
+
const cfg = c.resolve(ConfigRepository).get('cache') as MemcachedCacheConfig | undefined
|
|
17
|
+
if (cfg === undefined || !cfg.host || !cfg.port) {
|
|
18
|
+
throw new CacheConfigError(
|
|
19
|
+
'MemcachedCacheProvider: `config.cache.host` and `config.cache.port` are required.',
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
return new MemcachedCache({
|
|
23
|
+
host: cfg.host,
|
|
24
|
+
port: cfg.port,
|
|
25
|
+
...(cfg.prefix !== undefined ? { prefix: cfg.prefix } : {}),
|
|
26
|
+
...(cfg.connectTimeoutMs !== undefined ? { connectTimeoutMs: cfg.connectTimeoutMs } : {}),
|
|
27
|
+
...(cfg.requestTimeoutMs !== undefined ? { requestTimeoutMs: cfg.requestTimeoutMs } : {}),
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
override async boot(app: Application): Promise<void> {
|
|
33
|
+
app.resolve(Cache)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
override async shutdown(app: Application): Promise<void> {
|
|
37
|
+
await app.resolve(Cache).close()
|
|
38
|
+
}
|
|
39
|
+
}
|