bazis 0.98.32 → 0.98.34
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 +2 -4
- package/cli/templates/passport.ts +1 -1
- package/core/background/BackgroundService.ts +16 -3
- package/core/background/decorator.ts +2 -1
- package/core/background/index.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/di/extensions/hosted-service.ts +20 -0
- package/core/di/extensions/index.ts +1 -1
- package/core/di/module/createContainer.ts +8 -1
- package/core/infra/cache/RedisDistributedCacheBackend.ts +3 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/infra/connectors/redis.ts +9 -1
- package/core/kernel/health/HealthService.ts +3 -2
- package/index.ts +0 -2
- package/package.json +1 -1
- package/core/background/backgroundModule.ts +0 -42
package/cli/templates/module.ts
CHANGED
|
@@ -56,9 +56,7 @@ export function buildModuleTemplates(
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
function moduleFile(n: ModuleNaming, profile: ModuleTemplateProfile): string {
|
|
59
|
-
const cacheImport = profile === "full" ? "import { cachedScoped } from \"bazis/core/cache\";\n" : "";
|
|
60
59
|
const moduleImport = "import { Module, scoped } from \"bazis/core/di\";";
|
|
61
|
-
const providerFactory = profile === "full" ? "cachedScoped" : "scoped";
|
|
62
60
|
const backgroundImport = profile === "full" ? `import { ${n.module}Reporter } from "./background/${n.module}.reporter";\n` : "";
|
|
63
61
|
const aiImports = profile === "full"
|
|
64
62
|
? `import { ${n.module}AnalystAgent } from "./ai/agents/${n.module}Analyst.agent";
|
|
@@ -72,7 +70,7 @@ import { ${n.module}SummaryTool } from "./ai/tools/${n.module}Summary.tool";
|
|
|
72
70
|
`
|
|
73
71
|
: "";
|
|
74
72
|
|
|
75
|
-
return `${
|
|
73
|
+
return `${moduleImport}
|
|
76
74
|
${aiImports}${backgroundImport}import { ${n.module}Controller } from "./http/${n.module}.controller";
|
|
77
75
|
import { ${n.entity} } from "./model/${n.module}.model";
|
|
78
76
|
import { ${n.module}DbContext } from "./model/${n.module}.dbContext";
|
|
@@ -86,7 +84,7 @@ import { ${n.module}Service } from "./services/${n.module}.service";
|
|
|
86
84
|
},
|
|
87
85
|
controllers: [${n.module}Controller],
|
|
88
86
|
providers: [
|
|
89
|
-
|
|
87
|
+
scoped(I${n.module}Service, ${n.module}Service),
|
|
90
88
|
],
|
|
91
89
|
${backgroundLine}${aiLines} exports: [I${n.module}Service],
|
|
92
90
|
})
|
|
@@ -78,7 +78,7 @@ exports: [I${m}Service]. TypeScript contract: services/I${e}.service.ts;
|
|
|
78
78
|
HTTP access is defined by the controller and the host policy, separately from DI exports.
|
|
79
79
|
I${m}Service → ${m}Service, scoped lifetime. The context and DbSet belong to the ORM.
|
|
80
80
|
${profile === "full" ? `${m}SummaryTool is listed in tools: [${m}SummaryTool]; the framework registers it as scoped, the tool is not exported.
|
|
81
|
-
The service
|
|
81
|
+
The service is scoped; its keyed @Cacheable works when the host installs the cache (runApp cache: memory()).
|
|
82
82
|
The background service gets its own scope per tick and disposes it in finally.
|
|
83
83
|
The host also provides auth from src/app/modules/auth (TokenKind.Admin/Client),
|
|
84
84
|
AgentRuntime and the reasoning modelProfile. The CLI does not create them.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { HostedService, HostedServiceDiagnostics } from "../di";
|
|
1
|
+
import type { HostedService, HostedServiceDiagnostics, HostedServiceHealth } from "../di";
|
|
2
2
|
import { backgroundOptionsOf } from "./decorator";
|
|
3
3
|
import { delay } from "./delay";
|
|
4
4
|
import { redactSensitive } from "../../library/redaction";
|
|
@@ -56,6 +56,8 @@ export abstract class BackgroundService implements HostedService {
|
|
|
56
56
|
|
|
57
57
|
/** The application logger, passed by the kernel; console until then. */
|
|
58
58
|
private diagnostics?: HostedServiceDiagnostics;
|
|
59
|
+
/** Set when the service stopped for good after a crash: `/health` reports it. */
|
|
60
|
+
private terminated?: string;
|
|
59
61
|
|
|
60
62
|
public constructor(options?: BackgroundServiceOptions) {
|
|
61
63
|
// Explicit `super(...)` options win over `@Background({...})` metadata.
|
|
@@ -68,6 +70,15 @@ export abstract class BackgroundService implements HostedService {
|
|
|
68
70
|
/** The background workload. Must observe `signal` for cooperative shutdown. */
|
|
69
71
|
protected abstract execute(signal: AbortSignal): void | Promise<void>;
|
|
70
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Unhealthy once the service stopped for good: it crashed without a restart
|
|
75
|
+
* policy, or used up its restarts. A pause before a restart, a tick failure
|
|
76
|
+
* of a periodic service and a normal end of `execute` stay healthy.
|
|
77
|
+
*/
|
|
78
|
+
public checkHealth(): HostedServiceHealth {
|
|
79
|
+
return this.terminated === undefined ? { healthy: true } : { healthy: false, details: this.terminated };
|
|
80
|
+
}
|
|
81
|
+
|
|
71
82
|
public useDiagnostics(diagnostics: HostedServiceDiagnostics): void {
|
|
72
83
|
this.diagnostics = diagnostics;
|
|
73
84
|
}
|
|
@@ -119,9 +130,11 @@ export abstract class BackgroundService implements HostedService {
|
|
|
119
130
|
this.reportError(error, restarts);
|
|
120
131
|
if (restarts >= maxRestarts) {
|
|
121
132
|
// Without this line the service would just go quiet.
|
|
122
|
-
|
|
133
|
+
const stopped = maxRestarts === 0
|
|
123
134
|
? "stopped after a crash and will not run again (no restart policy)"
|
|
124
|
-
: `stopped after ${maxRestarts} restart${maxRestarts === 1 ? "" : "s"} and will not run again
|
|
135
|
+
: `stopped after ${maxRestarts} restart${maxRestarts === 1 ? "" : "s"} and will not run again`;
|
|
136
|
+
report(this.diagnostics, "error", this.constructor.name, stopped, { restarts });
|
|
137
|
+
this.terminated = `${stopped}: ${error instanceof Error ? error.message : String(error)}`;
|
|
125
138
|
return;
|
|
126
139
|
}
|
|
127
140
|
restarts += 1;
|
|
@@ -26,7 +26,8 @@ interface BackgroundMetadataCarrier {
|
|
|
26
26
|
* ```
|
|
27
27
|
*
|
|
28
28
|
* Options passed explicitly to `super(...)` take precedence over the decorator.
|
|
29
|
-
* Registration stays explicit — list the class in `
|
|
29
|
+
* Registration stays explicit — list the class in `background` of its module:
|
|
30
|
+
* `@Module({ background: [Heartbeat] })`.
|
|
30
31
|
*/
|
|
31
32
|
export function Background(options: BackgroundDecoratorOptions = {}) {
|
|
32
33
|
return (_value: abstract new (...args: never[]) => unknown, context: ClassDecoratorContext): void => {
|
package/core/background/index.ts
CHANGED
|
@@ -10,6 +10,5 @@ export {
|
|
|
10
10
|
type BackgroundServiceOptions,
|
|
11
11
|
type PeriodicBackgroundServiceOptions,
|
|
12
12
|
} from "./BackgroundService";
|
|
13
|
-
export { backgroundModule, type BackgroundModuleConfig } from "./backgroundModule";
|
|
14
13
|
export { Background, backgroundOptionsOf, type BackgroundDecoratorOptions } from "./decorator";
|
|
15
14
|
export { delay } from "./delay";
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Cache Compatibility
|
|
2
2
|
|
|
3
|
-
The
|
|
4
|
-
|
|
3
|
+
The names below were removed; use the replacement. The `cached*` helpers were
|
|
4
|
+
public until 0.98.34, the others were removed in 0.96.1 before the first npm release.
|
|
5
5
|
|
|
6
6
|
| Removed name | Kind | Replacement |
|
|
7
7
|
| --- | --- | --- |
|
|
8
|
-
| `autoCachedScoped` | public
|
|
9
|
-
| `autoCachedSingleton` | public
|
|
8
|
+
| `autoCachedScoped`, `cachedScoped` | public helpers, removed in 0.98.34 | `scoped(...)`: the cache hook wraps it |
|
|
9
|
+
| `autoCachedSingleton`, `cachedSingleton` | public helpers, removed in 0.98.34 | `singleton(...)` |
|
|
10
10
|
| `warnInsecureOutputCacheRoute` | public alias | `guardInsecureOutputCacheRoute` |
|
|
11
11
|
| `warnOnAuthorizedRoutesWithoutVaryByUser` | config switch | `insecureAuthorizedRouteBehavior` (`false` was `"ignore"`) |
|
|
12
12
|
| `DEFAULT_CACHEABLE_METHODS` | internal alias | `DEFAULT_OUTPUT_CACHE_METHODS` |
|
package/core/cache/README.md
CHANGED
|
@@ -79,5 +79,5 @@ The `@/core/cache` core does not import Redis at all, only the
|
|
|
79
79
|
|
|
80
80
|
## Compatibility
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
Cached services are registered with the regular `scoped` / `singleton`; the cache
|
|
83
|
+
hook wraps them. Removed names are listed in [COMPATIBILITY.md](./COMPATIBILITY.md).
|
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>>;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { DiError } from "../../di";
|
|
1
2
|
/** Base error of the cache module. */
|
|
2
3
|
export class CacheError extends Error {
|
|
3
|
-
public constructor(message: string) {
|
|
4
|
-
super(message);
|
|
4
|
+
public constructor(message: string, options?: ErrorOptions) {
|
|
5
|
+
super(message, options);
|
|
5
6
|
this.name = new.target.name;
|
|
6
7
|
}
|
|
7
8
|
}
|
|
@@ -12,5 +13,19 @@ export class CacheKeyError extends CacheError {}
|
|
|
12
13
|
/** Invalid value (size limit exceeded and the like). */
|
|
13
14
|
export class CacheValueError extends CacheError {}
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The distributed store did not answer in time or failed. On the cache-aside
|
|
18
|
+
* path (`getOrCreateAsync`, `@CacheableRedis`) the value is computed without
|
|
19
|
+
* the cache; explicit operations (`get`, `remove`, `evictByTag`) throw it.
|
|
20
|
+
*/
|
|
21
|
+
export class CacheUnavailableError extends CacheError {}
|
|
22
|
+
|
|
15
23
|
/** The limit of unfinished factories is exhausted; the new factory was not started. */
|
|
16
24
|
export class CacheCapacityError extends CacheError {}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A cache declaration that can never work, found while the container is built
|
|
28
|
+
* (a cached scoped method without a key, a cached transient service). A DI
|
|
29
|
+
* error, so the start prints it as a configuration error.
|
|
30
|
+
*/
|
|
31
|
+
export class CacheDeclarationError extends DiError {}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { HttpMiddleware } from "../../http";
|
|
2
|
-
import { CacheError } from "../errors/CacheError";
|
|
2
|
+
import { CacheError, CacheUnavailableError } from "../errors/CacheError";
|
|
3
3
|
import { DISTRIBUTED_OUTPUT_CACHE } from "../tokens/DISTRIBUTED_CACHE";
|
|
4
4
|
import {
|
|
5
5
|
DEFAULT_OUTPUT_CACHE_METHODS,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
resolveCachedHttpPayloadReadOptions,
|
|
15
15
|
responseAllowsServerCache,
|
|
16
16
|
responseToCachedPayload,
|
|
17
|
+
type CachedHttpPayload,
|
|
17
18
|
} from "./CachedHttpPayload";
|
|
18
19
|
|
|
19
20
|
export interface OutputRedisCacheMiddlewareOptions {
|
|
@@ -81,7 +82,15 @@ export function outputRedisCacheMiddleware(options: OutputRedisCacheMiddlewareOp
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
const key = buildRedisOutputCacheKey(ctx, routeName, config);
|
|
84
|
-
|
|
85
|
+
let cached: CachedHttpPayload | undefined;
|
|
86
|
+
try {
|
|
87
|
+
cached = await cache.get(key);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
// The cache is optional: an unavailable store serves the route uncached.
|
|
90
|
+
if (!(error instanceof CacheUnavailableError)) throw error;
|
|
91
|
+
await next();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
85
94
|
if (cached !== undefined) {
|
|
86
95
|
ctx.response = applyClientCacheHeaders(cachedPayloadToResponse(cached, { hit: true }), clientCache);
|
|
87
96
|
return;
|
package/core/cache/index.ts
CHANGED
|
@@ -51,7 +51,7 @@ export {
|
|
|
51
51
|
type DistributedCacheVersionCheck,
|
|
52
52
|
type DistributedCachePing,
|
|
53
53
|
type DistributedCacheSetOptions,
|
|
54
|
-
|
|
54
|
+
IDistributedCache,
|
|
55
55
|
} from "./distributed";
|
|
56
56
|
export {
|
|
57
57
|
DISTRIBUTED_CACHE_BACKEND,
|
|
@@ -66,7 +66,7 @@ export {
|
|
|
66
66
|
type CacheModuleConfig,
|
|
67
67
|
type CacheOutputCacheOptions,
|
|
68
68
|
} from "./internal/buildCacheProviders";
|
|
69
|
-
export { CacheError, CacheKeyError, CacheValueError, CacheCapacityError } from "./errors/CacheError";
|
|
69
|
+
export { CacheError, CacheKeyError, CacheValueError, CacheCapacityError, CacheDeclarationError, CacheUnavailableError } from "./errors/CacheError";
|
|
70
70
|
|
|
71
71
|
export { OutputCache, type OutputCacheOptions as OutputCacheDecoratorOptions } from "./decorators/OutputCache";
|
|
72
72
|
export {
|
|
@@ -91,7 +91,3 @@ export {
|
|
|
91
91
|
} from "./internal/resolveCachePolicy";
|
|
92
92
|
export { buildOutputCacheKey, OUTPUT_CACHE_PRINCIPAL_STATE_KEY } from "./http/buildOutputCacheKey";
|
|
93
93
|
export { wrapCachedService, type CacheProxyOptions } from "./services/cacheProxy";
|
|
94
|
-
export {
|
|
95
|
-
cachedSingleton,
|
|
96
|
-
cachedScoped,
|
|
97
|
-
} from "./providers/cachedProviders";
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "../decorators/cacheableRedisMetadata";
|
|
12
12
|
import type { IDistributedCache } from "../distributed/IDistributedCache";
|
|
13
13
|
import type { NamedCacheRegistry } from "../distributed/NamedCacheRegistry";
|
|
14
|
-
import { CacheError } from "../errors/CacheError";
|
|
14
|
+
import { CacheError, CacheValueError } from "../errors/CacheError";
|
|
15
15
|
import type { ICache } from "../ICache";
|
|
16
16
|
import { normalizeCacheKey } from "../internal/normalizeCacheKey";
|
|
17
17
|
import { AutomaticCacheKey } from "../internal/AutomaticCacheKey";
|
|
@@ -141,7 +141,11 @@ function wrapDistributedMethod(
|
|
|
141
141
|
return result instanceof Promise ? await result : result;
|
|
142
142
|
},
|
|
143
143
|
{ ttlSeconds: options.seconds, tags: options.tags, lockSeconds: options.lockSeconds },
|
|
144
|
-
)
|
|
144
|
+
).catch((error: unknown) => {
|
|
145
|
+
// Name the method: "value.at is a Date" alone does not say where.
|
|
146
|
+
if (error instanceof CacheValueError) throw new CacheValueError(`${className}.${String(methodName)}: ${error.message}`, { cause: error });
|
|
147
|
+
throw error;
|
|
148
|
+
});
|
|
145
149
|
};
|
|
146
150
|
}
|
|
147
151
|
|
|
@@ -22,7 +22,18 @@ export interface HostedServiceDiagnostics {
|
|
|
22
22
|
warn(message: string, fields?: Readonly<Record<string, unknown>>): void;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** What a hosted service reports to `/health`. */
|
|
26
|
+
export interface HostedServiceHealth {
|
|
27
|
+
readonly healthy: boolean;
|
|
28
|
+
readonly details?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
export interface HostedService {
|
|
32
|
+
/**
|
|
33
|
+
* The service's own state for `/health`. A `background` service of a module
|
|
34
|
+
* is reported as `background:<Class>`; without this method it is healthy.
|
|
35
|
+
*/
|
|
36
|
+
checkHealth?(): HostedServiceHealth;
|
|
26
37
|
/** Shared validators see real service identities, including inside supervised wrappers. */
|
|
27
38
|
readonly planValidator?: HostedServicePlanValidator;
|
|
28
39
|
/** Receives the application logger before start, when one is registered. */
|
|
@@ -39,6 +50,15 @@ export interface HostedService {
|
|
|
39
50
|
|
|
40
51
|
export const HOSTED_SERVICE = createToken<HostedService>("IHostedService");
|
|
41
52
|
|
|
53
|
+
/** A health check contributed by a hosted service; the kernel reports it next to its own checks. */
|
|
54
|
+
export interface HostedServiceHealthCheck {
|
|
55
|
+
readonly name: string;
|
|
56
|
+
check(): HostedServiceHealth;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Enumerable: one entry per `background` service of the module graph. */
|
|
60
|
+
export const HOSTED_SERVICE_HEALTH = createToken<HostedServiceHealthCheck>("BazisHostedServiceHealth");
|
|
61
|
+
|
|
42
62
|
interface HostedServiceResolver {
|
|
43
63
|
resolveAll(token: typeof HOSTED_SERVICE): readonly HostedService[];
|
|
44
64
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { createInstance, type ActivatorDependency } from "./activator";
|
|
2
2
|
export { Application, runApplication, type RunApplicationOptions } from "./application";
|
|
3
|
-
export { addHostedService, HOSTED_SERVICE, startHostedServices, stopHostedServices, type HostedService, type HostedServiceDiagnostics, type HostedServicePlanValidator } from "./hosted-service";
|
|
3
|
+
export { addHostedService, HOSTED_SERVICE, HOSTED_SERVICE_HEALTH, startHostedServices, stopHostedServices, type HostedService, type HostedServiceDiagnostics, type HostedServiceHealth, type HostedServiceHealthCheck, type HostedServicePlanValidator } from "./hosted-service";
|
|
4
4
|
export { SERVICE_PROVIDER } from "./service-provider-token";
|
|
5
5
|
export {
|
|
6
6
|
addOptions,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DiContainer } from "../container";
|
|
2
2
|
import { ServiceCollection } from "../ServiceCollection";
|
|
3
|
-
import { HOSTED_SERVICE, type HostedService } from "../extensions/hosted-service";
|
|
3
|
+
import { HOSTED_SERVICE, HOSTED_SERVICE_HEALTH, type HostedService } from "../extensions/hosted-service";
|
|
4
4
|
import { SERVICE_PROVIDER, SERVICE_PROVIDER_BY_TYPE } from "../extensions/service-provider-token";
|
|
5
5
|
import { isClassProvider, ProviderDefinition, type Provider } from "../provider";
|
|
6
6
|
import { getClassDeps } from "../internal/classDeps";
|
|
@@ -162,6 +162,13 @@ export function createContainer(rootModule: BazisModuleRef, options?: CreateCont
|
|
|
162
162
|
collection.add(
|
|
163
163
|
DI.singleton(DI.factoryProviderWithResolver(HOSTED_SERVICE, [], (resolver) => resolver.resolve(ServiceClass))),
|
|
164
164
|
);
|
|
165
|
+
// `/health` sees a service that stopped for good, not only the process.
|
|
166
|
+
collection.add(
|
|
167
|
+
DI.singleton(DI.factoryProviderWithResolver(HOSTED_SERVICE_HEALTH, [], (resolver) => {
|
|
168
|
+
const service = resolver.resolve(ServiceClass);
|
|
169
|
+
return { name: `background:${ServiceClass.name}`, check: () => service.checkHealth?.() ?? { healthy: true } };
|
|
170
|
+
})),
|
|
171
|
+
);
|
|
165
172
|
}
|
|
166
173
|
}
|
|
167
174
|
|
|
@@ -25,6 +25,8 @@ export interface RedisDistributedCacheTuning {
|
|
|
25
25
|
readonly maxValueBytes?: number;
|
|
26
26
|
/** Polling interval (ms) while waiting for another worker's result. */
|
|
27
27
|
readonly pollIntervalMs?: number;
|
|
28
|
+
/** Bound of one Redis operation (ms); on expiry the method runs without the cache. Default 1000. */
|
|
29
|
+
readonly operationTimeoutMs?: number;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
const DEFAULT_KEY_PREFIX = "bazis:cache:";
|
|
@@ -59,6 +61,7 @@ export class RedisDistributedCacheBackend implements DistributedCacheStores {
|
|
|
59
61
|
maxKeyLength: tuning.maxKeyLength,
|
|
60
62
|
maxValueBytes: tuning.maxValueBytes,
|
|
61
63
|
pollIntervalMs: tuning.pollIntervalMs,
|
|
64
|
+
operationTimeoutMs: tuning.operationTimeoutMs,
|
|
62
65
|
};
|
|
63
66
|
|
|
64
67
|
const output = new DistributedCache<CachedHttpPayload>(driver, httpPayloadCacheCodec, {
|
|
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
|
|
|
232
232
|
if (method === "account/updated") this.modelCache = undefined;
|
|
233
233
|
}, () => { this.clearLogin(); this.modelCache = undefined; });
|
|
234
234
|
const initialized = await client.request("initialize", {
|
|
235
|
-
clientInfo: { name: "bazis", title: "Bazis", version: "0.98.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.98.34" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
DISTRIBUTED_CACHE_BACKEND,
|
|
4
4
|
DISTRIBUTED_OUTPUT_CACHE,
|
|
5
5
|
DISTRIBUTED_SERVICE_CACHE,
|
|
6
|
+
IDistributedCache,
|
|
6
7
|
type DistributedCacheStores,
|
|
7
8
|
} from "../../cache";
|
|
8
9
|
import { createToken, DI, type InjectionToken, type ProviderDefinition } from "../../di";
|
|
@@ -60,6 +61,13 @@ function distributedCacheProviders(tuning: RedisDistributedCacheTuning, token: I
|
|
|
60
61
|
backend.serviceCache,
|
|
61
62
|
),
|
|
62
63
|
),
|
|
64
|
+
// This connector's one connection by type, for services that evict tags or
|
|
65
|
+
// read values; its name may be "default" or the configured `connection`.
|
|
66
|
+
DI.singleton(
|
|
67
|
+
DI.factoryProvider(IDistributedCache, [DISTRIBUTED_CACHE_BACKEND], (backend: DistributedCacheStores) =>
|
|
68
|
+
backend.serviceCache.resolve(backend.serviceCache.connectionNames[0]),
|
|
69
|
+
),
|
|
70
|
+
),
|
|
63
71
|
];
|
|
64
72
|
}
|
|
65
73
|
|
|
@@ -91,7 +99,7 @@ export function redisConnect<T extends RedisConfigShape>(
|
|
|
91
99
|
token,
|
|
92
100
|
config,
|
|
93
101
|
providers: cacheProviders,
|
|
94
|
-
exports: cacheProviders ? [DISTRIBUTED_CACHE_BACKEND, DISTRIBUTED_OUTPUT_CACHE, DISTRIBUTED_SERVICE_CACHE] : undefined,
|
|
102
|
+
exports: cacheProviders ? [DISTRIBUTED_CACHE_BACKEND, DISTRIBUTED_OUTPUT_CACHE, DISTRIBUTED_SERVICE_CACHE, IDistributedCache] : undefined,
|
|
95
103
|
create(configs) {
|
|
96
104
|
return new RedisClient(redisUrl(reader(config, configs).get("url")));
|
|
97
105
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { HOSTED_SERVICE_HEALTH, type ServiceResolver } from "../../di";
|
|
2
2
|
import { redactSensitiveText } from "../../../library/redaction";
|
|
3
3
|
import { awaitAbortable } from "../internal/awaitAbortable";
|
|
4
4
|
import { KernelError } from "../errors";
|
|
@@ -21,7 +21,8 @@ export class HealthService {
|
|
|
21
21
|
const timeoutMs = bounded(options.timeoutMs ?? 5000, "timeoutMs", 2147483647);
|
|
22
22
|
const checkTimeoutMs = bounded(options.checkTimeoutMs ?? 1000, "checkTimeoutMs", 2147483647);
|
|
23
23
|
const concurrency = bounded(options.concurrency ?? 4, "concurrency", 1024);
|
|
24
|
-
|
|
24
|
+
// Hosted services (background) report whether they still run.
|
|
25
|
+
const checks: readonly HealthCheck[] = [...this.resolver.resolveAll(HEALTH_CHECK), ...this.resolver.resolveAll(HOSTED_SERVICE_HEALTH)];
|
|
25
26
|
const entries: HealthReportEntry[] = new Array(checks.length);
|
|
26
27
|
const report = new AbortController();
|
|
27
28
|
const timer = setTimeout(() => report.abort(new Error("Health report timed out.")), timeoutMs);
|
package/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.98.
|
|
3
|
+
"version": "0.98.34",
|
|
4
4
|
"description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexander Alenin",
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
DI,
|
|
3
|
-
HOSTED_SERVICE,
|
|
4
|
-
Module,
|
|
5
|
-
singleton,
|
|
6
|
-
type Class,
|
|
7
|
-
type HostedService,
|
|
8
|
-
type BazisModule,
|
|
9
|
-
type ProviderDefinition,
|
|
10
|
-
} from "../di";
|
|
11
|
-
|
|
12
|
-
export interface BackgroundModuleConfig {
|
|
13
|
-
/**
|
|
14
|
-
* Background service classes (subclasses of `BackgroundService` /
|
|
15
|
-
* `PeriodicBackgroundService`). Each is registered as a singleton (with
|
|
16
|
-
* auto-resolved constructor dependencies) and exposed as a `HOSTED_SERVICE`,
|
|
17
|
-
* so the kernel starts and gracefully stops it with the rest of the app.
|
|
18
|
-
*/
|
|
19
|
-
readonly services: readonly Class<HostedService>[];
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Registers background services in DI and wires them into the kernel lifecycle.
|
|
24
|
-
*
|
|
25
|
-
* ```ts
|
|
26
|
-
* @Module({ imports: [backgroundModule({ services: [SessionCleanup, Heartbeat] })] })
|
|
27
|
-
* class AppModule {}
|
|
28
|
-
* ```
|
|
29
|
-
*/
|
|
30
|
-
export function backgroundModule(config: BackgroundModuleConfig): BazisModule {
|
|
31
|
-
const providers: ProviderDefinition[] = config.services.flatMap((ServiceClass) => [
|
|
32
|
-
singleton(ServiceClass),
|
|
33
|
-
DI.singleton(
|
|
34
|
-
DI.factoryProviderWithResolver(HOSTED_SERVICE, [], (resolver) => resolver.resolve(ServiceClass)),
|
|
35
|
-
),
|
|
36
|
-
]);
|
|
37
|
-
|
|
38
|
-
@Module({ providers })
|
|
39
|
-
class BackgroundModule {}
|
|
40
|
-
|
|
41
|
-
return BackgroundModule;
|
|
42
|
-
}
|