bazis 0.98.33 → 0.99.0
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/cli/templates/module.ts +4 -64
- package/cli/templates/passport.ts +14 -18
- package/core/agent/AgentContextBuilder.ts +4 -98
- package/core/agent/AgentRegistry.ts +79 -473
- package/core/agent/AgentRuntime.ts +47 -19
- package/core/agent/AgentToolExecutor.ts +3 -3
- package/core/agent/README.md +89 -181
- package/core/agent/index.ts +3 -31
- package/core/agent/internal/AgentExecutionDriver.ts +37 -392
- package/core/agent/internal/AgentExecutionState.ts +1 -9
- package/core/agent/metadata.ts +8 -303
- package/core/agent/semantic.ts +0 -4
- package/core/agent/session/contracts.ts +5 -7
- package/core/agent/session/entities.ts +0 -1
- package/core/cache/COMPATIBILITY.md +4 -4
- package/core/cache/README.md +2 -2
- package/core/cache/SPEC.md +30 -48
- package/core/cache/decorators/Cacheable.ts +4 -3
- package/core/cache/decorators/CacheableRedis.ts +3 -4
- package/core/cache/di/classProviderHook.ts +31 -0
- package/core/cache/distributed/DistributedCache.ts +96 -25
- package/core/cache/distributed/IDistributedCache.ts +9 -0
- package/core/cache/distributed/codecs.ts +49 -2
- package/core/cache/distributed/index.ts +1 -1
- package/core/cache/errors/CacheError.ts +17 -2
- package/core/cache/http/outputRedisCacheMiddleware.ts +11 -2
- package/core/cache/index.ts +2 -6
- package/core/cache/services/cacheProxy.ts +6 -2
- package/core/generatedRuntime.ts +0 -31
- package/core/infra/cache/RedisDistributedCacheBackend.ts +3 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/infra/connectors/llm.ts +15 -1
- package/core/infra/connectors/llmRouter.ts +4 -0
- package/core/infra/connectors/redis.ts +9 -1
- package/core/scripts/di-generate.ts +28 -42
- package/core/scripts/tool-codegen.ts +122 -0
- package/index.ts +3 -24
- package/package.json +1 -1
- package/core/agent/decorators/Agent.ts +0 -11
- package/core/agent/decorators/Prompt.ts +0 -11
- package/core/agent/decorators/Task.ts +0 -29
- package/core/agent/generated/catalog.ts +0 -26
- package/core/scripts/agent-codegen.ts +0 -1166
package/core/cache/SPEC.md
CHANGED
|
@@ -19,7 +19,7 @@ Quick navigation:
|
|
|
19
19
|
- [7. `@OutputRedisCache` — HTTP output cache (distributed)](#7-outputrediscache--http-output-cache-distributed)
|
|
20
20
|
- [8. `@Cacheable`: service method cache (memory)](#8-cacheable-service-method-cache-memory)
|
|
21
21
|
- [9. `@CacheableRedis`: service method cache (distributed)](#9-cacheableredis-service-method-cache-distributed)
|
|
22
|
-
- [10. DI
|
|
22
|
+
- [10. DI registration: `singleton` / `scoped` with the cache hook](#10-di-registration-singleton--scoped-with-the-cache-hook)
|
|
23
23
|
- [11. `ICache`: programmatic API](#11-icache-programmatic-api)
|
|
24
24
|
- [12. Output cache key building](#12-output-cache-key-building)
|
|
25
25
|
- [13. Anti-stampede and concurrent miss](#13-anti-stampede-and-concurrent-miss)
|
|
@@ -68,7 +68,6 @@ import {
|
|
|
68
68
|
OutputRedisCache,
|
|
69
69
|
Cacheable,
|
|
70
70
|
CacheableRedis,
|
|
71
|
-
cachedScoped,
|
|
72
71
|
ICache,
|
|
73
72
|
} from "@/core/cache";
|
|
74
73
|
import { composeRouteMiddlewareComposers } from "@/core/http";
|
|
@@ -116,8 +115,8 @@ import { redisConnect } from "@/core/infra";
|
|
|
116
115
|
| --- | --- | --- | --- |
|
|
117
116
|
| `@OutputCache` | controller action | `ICache` in-memory | the cache module's `ROUTE_MIDDLEWARE_COMPOSER` registration (automatic) |
|
|
118
117
|
| `@OutputRedisCache` | controller action | `IDistributedCache` | the same + `redisConnect(config, { cache: "distributed" })` in Infra |
|
|
119
|
-
| `@Cacheable` | service method | `ICache` in-memory | `
|
|
120
|
-
| `@CacheableRedis` | service method | `IDistributedCache` | `
|
|
118
|
+
| `@Cacheable` | service method | `ICache` in-memory | `singleton` / `scoped` (the cache hook wraps it) |
|
|
119
|
+
| `@CacheableRedis` | service method | `IDistributedCache` | `singleton` / `scoped` + `redisConnect(config, { cache: "distributed" })` in Infra |
|
|
121
120
|
|
|
122
121
|
**Important:** put decorators on the **implementation class**, not on a TypeScript interface. DI registers the interface token → the proxy wraps the implementation.
|
|
123
122
|
|
|
@@ -161,7 +160,7 @@ flowchart TB
|
|
|
161
160
|
### 3.1. Minimum: in-memory output + method cache
|
|
162
161
|
|
|
163
162
|
```ts
|
|
164
|
-
import { Cacheable, OutputCache,
|
|
163
|
+
import { Cacheable, OutputCache, memory } from "@/core/cache";
|
|
165
164
|
import { runApp } from "@/core/app";
|
|
166
165
|
|
|
167
166
|
await runApp(AppModule, {
|
|
@@ -195,7 +194,7 @@ class UserService implements IUserStore {
|
|
|
195
194
|
|
|
196
195
|
// UsersModule:
|
|
197
196
|
providers: [
|
|
198
|
-
|
|
197
|
+
scoped(IUserStore, UserService, [repositoryFor(User)]),
|
|
199
198
|
],
|
|
200
199
|
```
|
|
201
200
|
|
|
@@ -232,7 +231,7 @@ class ProductService {
|
|
|
232
231
|
|
|
233
232
|
// The same provider as for memory: the distributed level turns on
|
|
234
233
|
// automatically when Infra publishes the registry of distributed stores in DI.
|
|
235
|
-
|
|
234
|
+
scoped(IProductService, ProductService, deps);
|
|
236
235
|
```
|
|
237
236
|
|
|
238
237
|
---
|
|
@@ -620,32 +619,27 @@ class UserService implements IUserStore {
|
|
|
620
619
|
|
|
621
620
|
---
|
|
622
621
|
|
|
623
|
-
## 10. DI
|
|
622
|
+
## 10. DI registration: `singleton` / `scoped` with the cache hook
|
|
624
623
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
624
|
+
`memory()` registers a hook on the core `singleton()` / `scoped()` providers: if a class
|
|
625
|
+
carries `@Cacheable` / `@CacheableRedis`, the regular registration is wrapped with a
|
|
626
|
+
caching proxy. One proxy routes `@Cacheable` to the in-memory `ICache` and
|
|
627
|
+
`@CacheableRedis` to the distributed `DISTRIBUTED_SERVICE_CACHE`. The former
|
|
628
|
+
`cachedSingleton` / `cachedScoped` helpers were removed in 0.98.34: they did the same.
|
|
628
629
|
|
|
629
|
-
|
|
630
|
-
| --- | --- | --- | --- |
|
|
631
|
-
| `cachedSingleton` | `@Cacheable` and/or `@CacheableRedis` | `ICache`, `CACHE_POLICIES`, (optional) `DISTRIBUTED_SERVICE_CACHE` | singleton |
|
|
632
|
-
| `cachedScoped` | `@Cacheable` and/or `@CacheableRedis` | the same | scoped |
|
|
630
|
+
The hook checks the lifetime at container build (0.98.34):
|
|
633
631
|
|
|
634
|
-
|
|
632
|
+
| Lifetime | Rule |
|
|
633
|
+
| --- | --- |
|
|
634
|
+
| `singleton` | Any declaration; the automatic key works across calls |
|
|
635
|
+
| `scoped` | Every cached method needs an explicit `key` (or a named `policy`): the automatic key belongs to the instance, so it would cache within one request only. Otherwise `CacheDeclarationError` at startup |
|
|
636
|
+
| `transient` | Refused with `CacheDeclarationError`: a new instance per injection never reuses its cache |
|
|
635
637
|
|
|
636
638
|
```ts
|
|
637
639
|
// Interface token ← proxy ← implementation class with decorators.
|
|
638
|
-
|
|
639
|
-
cachedScoped(IUserStore, UserService, [repositoryFor(User)]);
|
|
640
|
+
scoped(IUserStore, UserService);
|
|
640
641
|
```
|
|
641
642
|
|
|
642
|
-
### 10.0. Out of the box (auto-hook)
|
|
643
|
-
|
|
644
|
-
`memory()` registers a hook on the core `singleton()` / `scoped()` providers: if a class
|
|
645
|
-
carries `@Cacheable` / `@CacheableRedis`, the regular registration is **automatically**
|
|
646
|
-
wrapped with a caching proxy. An explicit `cachedScoped` is needed only when you want to
|
|
647
|
-
be completely explicit.
|
|
648
|
-
|
|
649
643
|
### 10.1. Interface vs implementation: a common question
|
|
650
644
|
|
|
651
645
|
```ts
|
|
@@ -659,7 +653,7 @@ class UserService implements IUserStore {
|
|
|
659
653
|
}
|
|
660
654
|
|
|
661
655
|
// ✅ correct
|
|
662
|
-
|
|
656
|
+
scoped(IUserStore, UserService, deps);
|
|
663
657
|
|
|
664
658
|
// ❌ a decorator on an interface does not work
|
|
665
659
|
// With memory() connected, a plain singleton(IUserStore, UserService)
|
|
@@ -674,7 +668,7 @@ or a root import of `memory()`. A feature module registers only its own service:
|
|
|
674
668
|
```ts
|
|
675
669
|
@Module({
|
|
676
670
|
imports: [UsersDataModule],
|
|
677
|
-
providers: [
|
|
671
|
+
providers: [scoped(IUserStore, UserService, deps)],
|
|
678
672
|
})
|
|
679
673
|
class UsersModule {}
|
|
680
674
|
```
|
|
@@ -1066,7 +1060,7 @@ class UserService implements IUserStore {
|
|
|
1066
1060
|
|
|
1067
1061
|
// UsersModule:
|
|
1068
1062
|
imports: [UsersDataModule],
|
|
1069
|
-
providers: [
|
|
1063
|
+
providers: [scoped(IUserStore, UserService, deps)],
|
|
1070
1064
|
```
|
|
1071
1065
|
|
|
1072
1066
|
`@CacheableRedis.findByName` uses the distributed level automatically when the backend is
|
|
@@ -1174,7 +1168,7 @@ Checklist:
|
|
|
1174
1168
|
|
|
1175
1169
|
1. The decorator is on the **class `UserService`**, not on `interface IUserStore`.
|
|
1176
1170
|
2. The root app installs `cache: memory()` or imports `memory()` (auto-hook + `ICache`, `CACHE_POLICIES`).
|
|
1177
|
-
3. The registration goes through `singleton`/`scoped` (the
|
|
1171
|
+
3. The registration goes through `singleton`/`scoped` (the cache hook wraps it); a scoped one needs an explicit `key`.
|
|
1178
1172
|
4. The options have **`seconds`** (inline or in the `policy`).
|
|
1179
1173
|
5. For `@CacheableRedis`, infra connects `redisConnect(redisConfig, { cache: "distributed" })`, otherwise the call throws.
|
|
1180
1174
|
|
|
@@ -1190,7 +1184,7 @@ Checklist:
|
|
|
1190
1184
|
async byId(id: number) { ... }
|
|
1191
1185
|
|
|
1192
1186
|
// UsersModule.ts: registration on the interface token
|
|
1193
|
-
|
|
1187
|
+
scoped(IUserStore, UserService, deps);
|
|
1194
1188
|
```
|
|
1195
1189
|
|
|
1196
1190
|
The controller gets the proxy registered as `IUserStore`. The metadata is read from the `UserService` class (`Symbol.metadata`), not from the interface.
|
|
@@ -1242,28 +1236,16 @@ Technically yes, but **do not**: a startup warning, and Redis wins. Pick one sto
|
|
|
1242
1236
|
|
|
1243
1237
|
### Can `@Cacheable` and `@CacheableRedis` be on one method?
|
|
1244
1238
|
|
|
1245
|
-
**No**: a runtime error. On **different** methods of one class use a regular `
|
|
1239
|
+
**No**: a runtime error. On **different** methods of one class use a regular `scoped` registration (one proxy routes memory and distributed).
|
|
1246
1240
|
|
|
1247
1241
|
---
|
|
1248
1242
|
|
|
1249
|
-
### `
|
|
1250
|
-
|
|
1251
|
-
| | singleton | scoped |
|
|
1252
|
-
| --- | --- | --- |
|
|
1253
|
-
| Proxy instance | one per DI registration in the container | one per DI registration in the scope |
|
|
1254
|
-
| `@Cacheable` cache | the shared ICache store, automatic keys are isolated per instance | the same ICache store, automatic keys are isolated per instance |
|
|
1255
|
-
| ORM `DbContext` | ⚠️ careful | ✅ the typical case |
|
|
1256
|
-
|
|
1257
|
-
A `UserService` with an ORM → **`cachedScoped`**. A stateless read-only service without scoped deps → `cachedSingleton` is fine.
|
|
1258
|
-
An explicit `key` in `cachedScoped` and `cachedSingleton` is a sharing contract: include the
|
|
1259
|
-
tenant/user in it if the result is personalized. Without an explicit `key` the framework
|
|
1260
|
-
isolates the automatic key per instance, so hidden constructor state does not leak into the
|
|
1261
|
-
response of another registration or request. This also applies to `@CacheableRedis`:
|
|
1262
|
-
sharing between processes and reuse after a restart need an explicit application `key`.
|
|
1263
|
-
DI creates the object with a regular class provider and then applies the proxy;
|
|
1264
|
-
constructor deps and lifecycle are not bypassed.
|
|
1243
|
+
### `singleton` or `scoped`?
|
|
1265
1244
|
|
|
1266
|
-
|
|
1245
|
+
A `UserService` with an ORM is **`scoped`**: give every cached method an explicit `key`
|
|
1246
|
+
that includes the user or tenant when the result depends on them. A stateless read-only
|
|
1247
|
+
service without scoped dependencies can be a `singleton`; its automatic keys work across
|
|
1248
|
+
calls.
|
|
1267
1249
|
|
|
1268
1250
|
### Is Redis needed for the application to work?
|
|
1269
1251
|
|
|
@@ -12,8 +12,9 @@ type ClassOrMethodDecorator = (
|
|
|
12
12
|
* Caches the return value of a service/provider method in {@link ICache}.
|
|
13
13
|
*
|
|
14
14
|
* Mirrors Spring / FusionCache `@Cacheable`. Put the decorator on the
|
|
15
|
-
* **implementation class
|
|
16
|
-
*
|
|
15
|
+
* **implementation class** and register it as usual (`scoped`, `singleton`):
|
|
16
|
+
* with the cache module installed, DI returns a caching proxy. A scoped
|
|
17
|
+
* service needs an explicit `key`; a transient one cannot cache.
|
|
17
18
|
*
|
|
18
19
|
* ```ts
|
|
19
20
|
* class UserService implements IUserStore {
|
|
@@ -21,7 +22,7 @@ type ClassOrMethodDecorator = (
|
|
|
21
22
|
* async byId(id: number) { ... }
|
|
22
23
|
* }
|
|
23
24
|
*
|
|
24
|
-
*
|
|
25
|
+
* scoped(IUserStore, UserService)
|
|
25
26
|
* ```
|
|
26
27
|
*/
|
|
27
28
|
export function Cacheable(options: CacheableOptions): ClassOrMethodDecorator {
|
|
@@ -12,9 +12,8 @@ type ClassOrMethodDecorator = (
|
|
|
12
12
|
* Shared service method cache backed by the distributed tier (multi-instance safe).
|
|
13
13
|
*
|
|
14
14
|
* Requires a distributed backend: add `redisConnect(redisConfig, { cache: "distributed" })`
|
|
15
|
-
* to your `@Infra` manifest. Register the class
|
|
16
|
-
*
|
|
17
|
-
* tier activates automatically when a backend is configured.
|
|
15
|
+
* to your `@Infra` manifest. Register the class as usual (`scoped` / `singleton`):
|
|
16
|
+
* the cache module wraps it. Values must be plain JSON.
|
|
18
17
|
*
|
|
19
18
|
* ```ts
|
|
20
19
|
* class UserService implements IUserStore {
|
|
@@ -22,7 +21,7 @@ type ClassOrMethodDecorator = (
|
|
|
22
21
|
* async byId(id: number) { ... }
|
|
23
22
|
* }
|
|
24
23
|
*
|
|
25
|
-
*
|
|
24
|
+
* scoped(IUserStore, UserService);
|
|
26
25
|
* ```
|
|
27
26
|
*/
|
|
28
27
|
export function CacheableRedis(options: CacheableRedisOptions): ClassOrMethodDecorator {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Class, ClassProviderHook, Token } from "../../di";
|
|
2
|
+
import { CacheDeclarationError } from "../errors/CacheError";
|
|
2
3
|
import { cacheableMetaOf, hasCacheableMethods } from "../decorators/cacheableMetadata";
|
|
3
4
|
import { cacheableRedisMetaOf, hasCacheableRedisMethods } from "../decorators/cacheableRedisMetadata";
|
|
4
5
|
import { cachedScoped, cachedSingleton } from "../providers/cachedProviders";
|
|
@@ -16,6 +17,7 @@ export const cacheableClassProviderHook: ClassProviderHook = ({ lifetime, provid
|
|
|
16
17
|
}
|
|
17
18
|
const token = provide as Token<object>;
|
|
18
19
|
const impl = useClass as Class<object>;
|
|
20
|
+
assertCacheableLifetime(impl, lifetime);
|
|
19
21
|
if (lifetime === "scoped") {
|
|
20
22
|
return cachedScoped(token, impl, deps);
|
|
21
23
|
}
|
|
@@ -24,3 +26,32 @@ export const cacheableClassProviderHook: ClassProviderHook = ({ lifetime, provid
|
|
|
24
26
|
}
|
|
25
27
|
return undefined;
|
|
26
28
|
};
|
|
29
|
+
|
|
30
|
+
interface Declared { readonly key?: unknown; readonly policy?: unknown }
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A cache that can never hit is refused at startup, as an invalid registration
|
|
34
|
+
* (a DI error: the start prints it as a configuration error). The automatic key belongs
|
|
35
|
+
* to the service instance (a scoped result may depend on the request's user),
|
|
36
|
+
* so without an explicit `key` a scoped service caches only within one request,
|
|
37
|
+
* and a transient one — never.
|
|
38
|
+
*/
|
|
39
|
+
function assertCacheableLifetime(impl: Class<object>, lifetime: string): void {
|
|
40
|
+
const declarations: { decorator: string; classLevel?: Declared; methods: ReadonlyMap<string | symbol, Declared> }[] = [];
|
|
41
|
+
const memory = cacheableMetaOf(impl);
|
|
42
|
+
if (hasCacheableMethods(memory)) declarations.push({ decorator: "@Cacheable", classLevel: memory!.cacheable, methods: memory!.methods });
|
|
43
|
+
const shared = cacheableRedisMetaOf(impl);
|
|
44
|
+
if (hasCacheableRedisMethods(shared)) declarations.push({ decorator: "@CacheableRedis", classLevel: shared!.cacheableRedis, methods: shared!.methods });
|
|
45
|
+
for (const { decorator, classLevel, methods } of declarations) {
|
|
46
|
+
if (lifetime === "transient") {
|
|
47
|
+
throw new CacheDeclarationError(`${impl.name} uses ${decorator} but is registered transient: a new instance for every injection never reuses its cache. Register it as singleton or scoped.`);
|
|
48
|
+
}
|
|
49
|
+
if (lifetime !== "scoped") continue;
|
|
50
|
+
const names = methods.size > 0 ? [...methods.keys()] : ["(every method)"];
|
|
51
|
+
for (const name of names) {
|
|
52
|
+
const declared = { ...classLevel, ...(typeof name === "string" && name === "(every method)" ? {} : methods.get(name)) };
|
|
53
|
+
if (declared.key !== undefined || declared.policy !== undefined) continue;
|
|
54
|
+
throw new CacheDeclarationError(`${impl.name}.${String(name)}: ${decorator} without key on a scoped service caches only within one request (the automatic key belongs to the instance). Set key — include the user or tenant when the result depends on them — or register ${impl.name} as a singleton.`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { CacheValueError } from "../errors/CacheError";
|
|
2
|
+
import { CacheUnavailableError, CacheValueError } from "../errors/CacheError";
|
|
3
3
|
import { assertCacheKey, measureStringBytes } from "../internal/CacheKeyGuard";
|
|
4
4
|
import type { CacheCodec } from "./CacheCodec";
|
|
5
5
|
import type {
|
|
@@ -36,8 +36,12 @@ export interface DistributedCacheConfig {
|
|
|
36
36
|
readonly maxValueBytes?: number;
|
|
37
37
|
/** Poll interval (ms) while waiting for a peer worker to fill the cache. */
|
|
38
38
|
readonly pollIntervalMs?: number;
|
|
39
|
+
/** Bound of one store operation (ms); a hung store must not hang the caller. Default 1000. */
|
|
40
|
+
readonly operationTimeoutMs?: number;
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
const DEFAULT_OPERATION_TIMEOUT_MS = 1000;
|
|
44
|
+
|
|
41
45
|
/**
|
|
42
46
|
* Backend-agnostic distributed cache. Owns all caching policy and delegates raw
|
|
43
47
|
* I/O to a {@link DistributedCacheDriver}. One instance per (connection × namespace).
|
|
@@ -52,6 +56,9 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
|
|
|
52
56
|
private readonly fullPrefix: string;
|
|
53
57
|
private readonly maxKeyLength: number;
|
|
54
58
|
private readonly pollIntervalMs: number;
|
|
59
|
+
private readonly operationTimeoutMs: number;
|
|
60
|
+
/** The driver with every operation bounded by {@link io}. */
|
|
61
|
+
private readonly ioCall: DistributedCacheDriver;
|
|
55
62
|
|
|
56
63
|
public readonly connectionName: string;
|
|
57
64
|
public readonly keyPrefix: string;
|
|
@@ -71,6 +78,9 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
|
|
|
71
78
|
if (snapshot.maxValueBytes !== undefined && (!Number.isInteger(snapshot.maxValueBytes) || snapshot.maxValueBytes <= 0)) {
|
|
72
79
|
throw new CacheValueError("maxValueBytes must be a positive integer");
|
|
73
80
|
}
|
|
81
|
+
if (snapshot.operationTimeoutMs !== undefined && (!Number.isSafeInteger(snapshot.operationTimeoutMs) || snapshot.operationTimeoutMs <= 0 || snapshot.operationTimeoutMs > 2_147_483_647)) {
|
|
82
|
+
throw new CacheValueError("operationTimeoutMs must be a positive integer of milliseconds");
|
|
83
|
+
}
|
|
74
84
|
if (snapshot.pollIntervalMs !== undefined && (!Number.isFinite(snapshot.pollIntervalMs) || snapshot.pollIntervalMs <= 0)) {
|
|
75
85
|
throw new CacheValueError("pollIntervalMs must be a positive number");
|
|
76
86
|
}
|
|
@@ -80,12 +90,40 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
|
|
|
80
90
|
this.fullPrefix = `${snapshot.keyPrefix}${snapshot.namespace ?? ""}`;
|
|
81
91
|
this.maxKeyLength = snapshot.maxKeyLength ?? DEFAULT_DISTRIBUTED_MAX_KEY_LENGTH;
|
|
82
92
|
this.pollIntervalMs = snapshot.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
93
|
+
this.operationTimeoutMs = snapshot.operationTimeoutMs ?? DEFAULT_OPERATION_TIMEOUT_MS;
|
|
94
|
+
this.ioCall = new Proxy(driver, {
|
|
95
|
+
get: (target, property) => {
|
|
96
|
+
const member = (target as unknown as Record<PropertyKey, unknown>)[property];
|
|
97
|
+
return typeof member === "function"
|
|
98
|
+
? (...args: unknown[]) => this.io(() => (member as (...values: unknown[]) => Promise<unknown>).apply(target, args))
|
|
99
|
+
: member;
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* One store operation, bounded by `operationTimeoutMs`. A timeout or a driver
|
|
106
|
+
* failure becomes {@link CacheUnavailableError}; the native operation is not
|
|
107
|
+
* cancelled (the driver has no cancellation), only no longer awaited.
|
|
108
|
+
*/
|
|
109
|
+
private io<T>(work: () => Promise<T>): Promise<T> {
|
|
110
|
+
return new Promise<T>((resolve, reject) => {
|
|
111
|
+
const timer = setTimeout(() => reject(new CacheUnavailableError(
|
|
112
|
+
`Distributed cache "${this.connectionName}" did not answer within ${this.operationTimeoutMs} ms.`,
|
|
113
|
+
)), this.operationTimeoutMs);
|
|
114
|
+
let started: Promise<T>;
|
|
115
|
+
try { started = work(); } catch (error) { clearTimeout(timer); reject(unavailable(this.connectionName, error)); return; }
|
|
116
|
+
started.then(
|
|
117
|
+
(value) => { clearTimeout(timer); resolve(value); },
|
|
118
|
+
(error: unknown) => { clearTimeout(timer); reject(unavailable(this.connectionName, error)); },
|
|
119
|
+
);
|
|
120
|
+
});
|
|
83
121
|
}
|
|
84
122
|
|
|
85
123
|
public async get<T = TValue>(key: string): Promise<T | undefined> {
|
|
86
124
|
assertCacheKey(key, this.maxKeyLength);
|
|
87
125
|
const storageKey = this.storageKey(key);
|
|
88
|
-
const raw = await this.
|
|
126
|
+
const raw = await this.ioCall.read(storageKey);
|
|
89
127
|
if (raw === null) {
|
|
90
128
|
return undefined;
|
|
91
129
|
}
|
|
@@ -93,7 +131,7 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
|
|
|
93
131
|
return this.codec.deserialize(decodeEnvelope(raw).payload) as unknown as T;
|
|
94
132
|
} catch {
|
|
95
133
|
try {
|
|
96
|
-
await this.
|
|
134
|
+
await this.ioCall.deleteIfValue(storageKey, raw);
|
|
97
135
|
} catch {
|
|
98
136
|
// A corrupt cache entry is still a miss when backend cleanup fails.
|
|
99
137
|
// Do not turn best-effort cache maintenance into a request failure.
|
|
@@ -108,7 +146,14 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
|
|
|
108
146
|
options: DistributedCacheSetOptions,
|
|
109
147
|
): Promise<T | undefined> {
|
|
110
148
|
const normalizedOptions = this.normalizeSetOptions(options);
|
|
111
|
-
|
|
149
|
+
let hit: T | undefined;
|
|
150
|
+
try {
|
|
151
|
+
hit = await this.get<T>(key);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
// The cache is optional: an unavailable store means "compute it now".
|
|
154
|
+
if (error instanceof CacheUnavailableError) return factory();
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
112
157
|
if (hit !== undefined) {
|
|
113
158
|
return hit;
|
|
114
159
|
}
|
|
@@ -132,7 +177,7 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
|
|
|
132
177
|
public async remove(key: string): Promise<boolean> {
|
|
133
178
|
assertCacheKey(key, this.maxKeyLength);
|
|
134
179
|
this.inFlight.delete(key);
|
|
135
|
-
return this.
|
|
180
|
+
return this.ioCall.invalidate(this.entryVersionKey(key), this.storageKey(key), this.lockKeyFor(key));
|
|
136
181
|
}
|
|
137
182
|
|
|
138
183
|
public async evictByTag(tag: string): Promise<number> {
|
|
@@ -142,8 +187,8 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
|
|
|
142
187
|
}
|
|
143
188
|
const versionKey = this.tagVersionKey(tag);
|
|
144
189
|
const pendingKey = this.tagPendingKey(tag);
|
|
145
|
-
await this.
|
|
146
|
-
const generations = [...await this.
|
|
190
|
+
await this.ioCall.advanceTagGeneration(versionKey, pendingKey);
|
|
191
|
+
const generations = [...await this.ioCall.tagMembers(pendingKey)]
|
|
147
192
|
.filter((value) => /^\d+$/.test(value))
|
|
148
193
|
.sort((left, right) => Number(left) - Number(right));
|
|
149
194
|
let removed = 0;
|
|
@@ -155,7 +200,7 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
|
|
|
155
200
|
|
|
156
201
|
private async evictTagGeneration(tag: string, generation: string, pendingKey: string): Promise<number> {
|
|
157
202
|
const tagKey = this.tagKey(tag, generation);
|
|
158
|
-
const members = await this.
|
|
203
|
+
const members = await this.ioCall.tagMembers(tagKey);
|
|
159
204
|
let removed = 0;
|
|
160
205
|
for (const member of members) {
|
|
161
206
|
const parsed = decodeTagMember(member);
|
|
@@ -165,19 +210,19 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
|
|
|
165
210
|
if (!parsed.storageKey.startsWith(`${this.fullPrefix}entry:`)) {
|
|
166
211
|
continue;
|
|
167
212
|
}
|
|
168
|
-
const raw = await this.
|
|
213
|
+
const raw = await this.ioCall.read(parsed.storageKey);
|
|
169
214
|
if (raw === null) {
|
|
170
215
|
continue;
|
|
171
216
|
}
|
|
172
217
|
if (decodeEnvelope(raw).token !== parsed.token) {
|
|
173
218
|
continue;
|
|
174
219
|
}
|
|
175
|
-
if (await this.
|
|
220
|
+
if (await this.ioCall.deleteIfValue(parsed.storageKey, raw)) {
|
|
176
221
|
removed += 1;
|
|
177
222
|
}
|
|
178
223
|
}
|
|
179
|
-
await this.
|
|
180
|
-
await this.
|
|
224
|
+
await this.ioCall.delete([tagKey]);
|
|
225
|
+
await this.ioCall.removeTagMembers(pendingKey, [generation]);
|
|
181
226
|
return removed;
|
|
182
227
|
}
|
|
183
228
|
|
|
@@ -215,9 +260,21 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
|
|
|
215
260
|
const lockTtl = Math.max(1, Math.floor(options.lockSeconds ?? this.config.defaultLockSeconds));
|
|
216
261
|
const token = randomUUID();
|
|
217
262
|
|
|
218
|
-
|
|
263
|
+
let acquired: boolean;
|
|
264
|
+
try {
|
|
265
|
+
acquired = await this.ioCall.acquireLock(lockKey, token, lockTtl);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (error instanceof CacheUnavailableError) return factory();
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
219
270
|
if (!acquired) {
|
|
220
|
-
|
|
271
|
+
let peerValue: T | undefined;
|
|
272
|
+
try {
|
|
273
|
+
peerValue = await this.waitForPeerResult<T>(key, lockKey, lockTtl);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (error instanceof CacheUnavailableError) return factory();
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
221
278
|
if (peerValue !== undefined) return peerValue;
|
|
222
279
|
if (contentionAttempt < 2) {
|
|
223
280
|
return this.runLockedFactory(key, factory, options, contentionAttempt + 1);
|
|
@@ -228,11 +285,18 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
|
|
|
228
285
|
}
|
|
229
286
|
|
|
230
287
|
try {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
288
|
+
let versionChecks: readonly DistributedCacheVersionCheck[];
|
|
289
|
+
try {
|
|
290
|
+
const again = await this.get<T>(key);
|
|
291
|
+
if (again !== undefined) {
|
|
292
|
+
return again;
|
|
293
|
+
}
|
|
294
|
+
versionChecks = await this.captureVersions(key, options.tags);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
// The store went away under the lock: compute, but do not write.
|
|
297
|
+
if (error instanceof CacheUnavailableError) return await factory();
|
|
298
|
+
throw error;
|
|
234
299
|
}
|
|
235
|
-
const versionChecks = await this.captureVersions(key, options.tags);
|
|
236
300
|
const value = await factory();
|
|
237
301
|
if (value !== undefined) {
|
|
238
302
|
await this.trySet(key, value, options, lockKey, token, versionChecks);
|
|
@@ -243,7 +307,7 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
|
|
|
243
307
|
// Unlock is best effort: a transport error must not replace a fresh
|
|
244
308
|
// service result or the factory's original exception. The lock still
|
|
245
309
|
// has a bounded TTL, so another worker eventually makes progress.
|
|
246
|
-
await this.
|
|
310
|
+
await this.ioCall.releaseLock(lockKey, token, this.entryVersionKey(key));
|
|
247
311
|
} catch {
|
|
248
312
|
// Intentionally preserve the primary outcome.
|
|
249
313
|
}
|
|
@@ -257,7 +321,7 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
|
|
|
257
321
|
if (hit !== undefined) {
|
|
258
322
|
return hit;
|
|
259
323
|
}
|
|
260
|
-
if (await this.
|
|
324
|
+
if (await this.ioCall.read(lockKey) === null) {
|
|
261
325
|
break;
|
|
262
326
|
}
|
|
263
327
|
await Bun.sleep(this.pollIntervalMs);
|
|
@@ -273,8 +337,11 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
|
|
|
273
337
|
lockToken: string,
|
|
274
338
|
versionChecks: readonly DistributedCacheVersionCheck[],
|
|
275
339
|
): Promise<void> {
|
|
340
|
+
// A value the codec refuses is a bug of the cached method, not a store
|
|
341
|
+
// outage: it surfaces instead of silently never being cached.
|
|
342
|
+
const serialized = this.codec.serialize(value as unknown as TValue);
|
|
276
343
|
try {
|
|
277
|
-
await this.write(key,
|
|
344
|
+
await this.write(key, serialized, options, lockKey, lockToken, versionChecks);
|
|
278
345
|
} catch {
|
|
279
346
|
// Graceful degradation — the caller still receives the fresh value.
|
|
280
347
|
}
|
|
@@ -282,14 +349,13 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
|
|
|
282
349
|
|
|
283
350
|
private async write(
|
|
284
351
|
key: string,
|
|
285
|
-
|
|
352
|
+
serialized: string,
|
|
286
353
|
options: DistributedCacheSetOptions,
|
|
287
354
|
lockKey: string,
|
|
288
355
|
lockToken: string,
|
|
289
356
|
versionChecks: readonly DistributedCacheVersionCheck[],
|
|
290
357
|
): Promise<void> {
|
|
291
358
|
assertCacheKey(key, this.maxKeyLength);
|
|
292
|
-
const serialized = this.codec.serialize(value);
|
|
293
359
|
|
|
294
360
|
if (this.config.maxValueBytes !== undefined) {
|
|
295
361
|
const bytes = measureStringBytes(serialized);
|
|
@@ -318,7 +384,7 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
|
|
|
318
384
|
tags[index] = { key: tag.key, member: encodeTagMember(storageKey, writeToken) };
|
|
319
385
|
}
|
|
320
386
|
const envelope = encodeEnvelope(writeToken, serialized);
|
|
321
|
-
await this.
|
|
387
|
+
await this.ioCall.writeIfLockOwner({
|
|
322
388
|
lockKey,
|
|
323
389
|
lockToken,
|
|
324
390
|
key: storageKey,
|
|
@@ -336,7 +402,7 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
|
|
|
336
402
|
const keys = [this.entryVersionKey(key), ...(tags ?? []).map((tag) => this.tagVersionKey(tag))];
|
|
337
403
|
return Promise.all(keys.map(async (versionKey) => ({
|
|
338
404
|
key: versionKey,
|
|
339
|
-
expected: await this.
|
|
405
|
+
expected: await this.ioCall.read(versionKey),
|
|
340
406
|
})));
|
|
341
407
|
}
|
|
342
408
|
|
|
@@ -401,3 +467,8 @@ function decodeTagMember(member: string): { readonly storageKey: string; readonl
|
|
|
401
467
|
}
|
|
402
468
|
return undefined;
|
|
403
469
|
}
|
|
470
|
+
|
|
471
|
+
function unavailable(connection: string, error: unknown): CacheUnavailableError {
|
|
472
|
+
if (error instanceof CacheUnavailableError) return error;
|
|
473
|
+
return new CacheUnavailableError(`Distributed cache "${connection}" failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
474
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createToken, type InjectionToken } from "../../di";
|
|
1
2
|
import type { CacheSetOptions } from "../types/CacheOptions";
|
|
2
3
|
|
|
3
4
|
/** Default named connection used when a decorator omits `connection`. */
|
|
@@ -38,3 +39,11 @@ export interface IDistributedCache<TValue = unknown> {
|
|
|
38
39
|
/** Removes all entries carrying `tag`; returns the number removed. */
|
|
39
40
|
evictByTag(tag: string): Promise<number>;
|
|
40
41
|
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The distributed cache by type: `constructor(private readonly shared: IDistributedCache)`.
|
|
45
|
+
* Registered by `redisConnect(config, { cache: "distributed" })` for its one
|
|
46
|
+
* connection; a custom backend with several connections keeps
|
|
47
|
+
* `DISTRIBUTED_SERVICE_CACHE.resolve(name)`.
|
|
48
|
+
*/
|
|
49
|
+
export const IDistributedCache = createToken<IDistributedCache>("IDistributedCache") as InjectionToken<IDistributedCache<unknown>>;
|
|
@@ -1,12 +1,59 @@
|
|
|
1
1
|
import type { CachedHttpPayload } from "../http/CachedHttpPayload";
|
|
2
2
|
import type { CacheCodec } from "./CacheCodec";
|
|
3
|
+
import { CacheValueError } from "../errors/CacheError";
|
|
3
4
|
|
|
4
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* JSON codec for service values (`@CacheableRedis`, `IDistributedCache`). It
|
|
7
|
+
* refuses a value that JSON would change on the way back — a Date comes back
|
|
8
|
+
* as a string, a Set or Map as `{}` — instead of returning altered data on a
|
|
9
|
+
* cache hit.
|
|
10
|
+
*/
|
|
5
11
|
export const jsonCacheCodec: CacheCodec<unknown> = {
|
|
6
|
-
serialize: (value) =>
|
|
12
|
+
serialize: (value) => {
|
|
13
|
+
const problem = notPlainJson(value, "value", new Set());
|
|
14
|
+
if (problem !== undefined) {
|
|
15
|
+
throw new CacheValueError(`Cache value is not plain JSON: ${problem}. Return plain data (an ISO string for a date, an array for a set), or cache it in memory with @Cacheable.`);
|
|
16
|
+
}
|
|
17
|
+
return JSON.stringify(value);
|
|
18
|
+
},
|
|
7
19
|
deserialize: (raw) => JSON.parse(raw) as unknown,
|
|
8
20
|
};
|
|
9
21
|
|
|
22
|
+
/** Where `value` stops surviving JSON unchanged, or undefined when it does. */
|
|
23
|
+
function notPlainJson(value: unknown, path: string, seen: Set<object>): string | undefined {
|
|
24
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return undefined;
|
|
25
|
+
if (typeof value === "number") return Number.isFinite(value) ? undefined : `${path} is ${String(value)} (JSON writes null)`;
|
|
26
|
+
if (typeof value === "bigint") return `${path} is a bigint`;
|
|
27
|
+
if (typeof value !== "object") return `${path} is a ${typeof value}`;
|
|
28
|
+
if (seen.has(value)) return `${path} is a circular reference`;
|
|
29
|
+
seen.add(value);
|
|
30
|
+
try {
|
|
31
|
+
if (Array.isArray(value)) {
|
|
32
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
33
|
+
const item: unknown = value[index];
|
|
34
|
+
if (item === undefined) return `${path}[${index}] is undefined (JSON writes null)`;
|
|
35
|
+
const problem = notPlainJson(item, `${path}[${index}]`, seen);
|
|
36
|
+
if (problem !== undefined) return problem;
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const prototype = Object.getPrototypeOf(value) as object | null;
|
|
41
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
42
|
+
const name = (value as { constructor?: { name?: string } }).constructor?.name || "object with a prototype";
|
|
43
|
+
return `${path} is a ${name}${value instanceof Date ? " (JSON returns a string)" : value instanceof Map || value instanceof Set ? " (JSON returns {})" : ""}`;
|
|
44
|
+
}
|
|
45
|
+
for (const key of Object.keys(value)) {
|
|
46
|
+
const item = (value as Record<string, unknown>)[key];
|
|
47
|
+
if (item === undefined) continue; // dropped and read back as undefined
|
|
48
|
+
const problem = notPlainJson(item, `${path}.${key}`, seen);
|
|
49
|
+
if (problem !== undefined) return problem;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
} finally {
|
|
53
|
+
seen.delete(value);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
10
57
|
interface SerializedHttpPayload {
|
|
11
58
|
readonly status: number;
|
|
12
59
|
readonly headers: Readonly<Record<string, string>>;
|