akanjs 3.0.0-alpha.0 → 3.0.0-alpha.2
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/base/symbols.ts +1 -0
- package/constant/cascadePaths.ts +79 -8
- package/document/database.ts +2 -1
- package/document/databaseRegistry.ts +2 -2
- package/document/into.ts +10 -6
- package/package.json +1 -1
- package/server/SSR_MEMORY_DIAGNOSIS.md +19 -2
- package/server/cachePolicy.ts +114 -12
- package/server/di/diLifecycle.ts +8 -10
- package/server/processMetricsCollector.ts +4 -0
- package/server/resolver/CascadeRunner.ts +201 -0
- package/server/resolver/database.resolver.ts +23 -3
- package/server/resolver/index.ts +1 -0
- package/server/resolver/service.resolver.ts +9 -25
- package/server/rscWorker.tsx +32 -4
- package/server/rscWorkerHost.ts +61 -12
- package/server/ssrFromRscRenderer.tsx +2 -2
- package/server/webRouter.ts +9 -0
- package/service/ipcTypes.ts +25 -0
- package/service/predefinedAdaptor/database.adaptor.ts +3 -3
- package/service/serve.ts +5 -1
- package/service/types.ts +2 -0
- package/types/base/symbols.d.ts +1 -0
- package/types/constant/cascadePaths.d.ts +22 -4
- package/types/document/database.d.ts +2 -1
- package/types/document/databaseRegistry.d.ts +2 -2
- package/types/document/into.d.ts +6 -5
- package/types/server/cachePolicy.d.ts +33 -2
- package/types/server/resolver/CascadeRunner.d.ts +13 -0
- package/types/server/resolver/database.resolver.d.ts +6 -2
- package/types/server/resolver/index.d.ts +1 -0
- package/types/server/resolver/service.resolver.d.ts +3 -3
- package/types/server/rscWorkerHost.d.ts +13 -0
- package/types/service/ipcTypes.d.ts +24 -0
- package/types/service/predefinedAdaptor/database.adaptor.d.ts +2 -2
- package/types/service/types.d.ts +2 -1
package/base/symbols.ts
CHANGED
|
@@ -3,6 +3,7 @@ export const SLICE_META = Symbol.for("akan.slice");
|
|
|
3
3
|
export const FILTER_META = Symbol.for("akan.filter");
|
|
4
4
|
export const LOADER_META = Symbol.for("akan.loader");
|
|
5
5
|
export const INJECT_META = Symbol.for("akan.inject");
|
|
6
|
+
export const LIBS_REMOVE_HOOK = Symbol.for("akan.service.libsRemoveHook");
|
|
6
7
|
export const ENDPOINT_META = Symbol.for("akan.endpoint");
|
|
7
8
|
export const ENDPOINT_DICT_SHAPE: unique symbol = Symbol.for("akan.endpoint.dictShape") as never;
|
|
8
9
|
export const SLICE_DICT_SHAPE: unique symbol = Symbol.for("akan.slice.dictShape") as never;
|
package/constant/cascadePaths.ts
CHANGED
|
@@ -1,32 +1,103 @@
|
|
|
1
|
+
import { type Cls, PrimitiveRegistry, type PrimitiveScalar } from "akanjs/base";
|
|
1
2
|
import type { ConstantField, FieldObject } from "./fieldInfo";
|
|
2
3
|
import type { ConstantModelRef } from "./via";
|
|
3
4
|
|
|
4
|
-
/**
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Which end of a relation goes away with the other. `removeRef` removes what the field points at when this
|
|
7
|
+
* document is removed; `removeWith` removes this document when what the field points at is removed. The two
|
|
8
|
+
* read identically on a relation field, so the value has to name the direction — a mistake here is a data loss.
|
|
9
|
+
*/
|
|
10
|
+
export const cascadeActions = ["removeRef", "removeWith"] as const;
|
|
6
11
|
export type CascadeAction = (typeof cascadeActions)[number];
|
|
7
12
|
|
|
13
|
+
/** How a `removeWith` field names the owner whose removal takes this document with it. */
|
|
14
|
+
export interface CascadeWithPath {
|
|
15
|
+
readonly key: string;
|
|
16
|
+
/** Set when the field is a relation. Resolved to a refName later: the owner may not be registered yet. */
|
|
17
|
+
readonly modelRef: ConstantModelRef | null;
|
|
18
|
+
/** Set when the field declares `ref`, which names the owner at declaration. */
|
|
19
|
+
readonly refName: string | null;
|
|
20
|
+
/** Set when the field declares `refPath`: the sibling field holding the owner's refName. */
|
|
21
|
+
readonly typeKey: string | null;
|
|
22
|
+
/** The refNames `typeKey` may hold. Empty unless the field is polymorphic. */
|
|
23
|
+
readonly typeValues: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const idNames = new Set(["ID", "String"]);
|
|
27
|
+
|
|
8
28
|
export class CascadePaths {
|
|
9
|
-
/** Field key → the model its ids point at
|
|
10
|
-
readonly
|
|
29
|
+
/** Field key → the model its ids point at, removed when this document is. */
|
|
30
|
+
readonly removeRef = new Map<string, ConstantModelRef>();
|
|
31
|
+
/** Field key → the owner whose removal removes this document. */
|
|
32
|
+
readonly removeWith = new Map<string, CascadeWithPath>();
|
|
11
33
|
|
|
12
34
|
collect(fieldMap: FieldObject) {
|
|
13
35
|
for (const [key, field] of Object.entries(fieldMap)) {
|
|
14
36
|
if (!field.cascade) continue;
|
|
15
|
-
this.#
|
|
16
|
-
this.
|
|
37
|
+
this.#assertKnownAction(key, field.cascade);
|
|
38
|
+
if (field.cascade === "removeRef") this.removeRef.set(key, this.#readOwnedRelation(key, field));
|
|
39
|
+
else this.removeWith.set(key, this.#readOwnerPath(key, field, fieldMap));
|
|
17
40
|
}
|
|
18
41
|
return this;
|
|
19
42
|
}
|
|
20
43
|
|
|
21
|
-
#
|
|
22
|
-
|
|
44
|
+
#assertKnownAction(key: string, action: CascadeAction) {
|
|
45
|
+
|
|
23
46
|
if (!cascadeActions.includes(action)) {
|
|
24
47
|
throw new Error(`Cascade field "${key}" declares cascade: "${action}", which is not one of ${cascadeActions}`);
|
|
25
48
|
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#readOwnedRelation(key: string, field: ConstantField) {
|
|
26
52
|
|
|
27
53
|
if (!field.isClass || field.isScalar) {
|
|
28
54
|
throw new Error(`Cascade field "${key}" is not a model reference and has no document to remove`);
|
|
29
55
|
}
|
|
30
56
|
if (field.arrDepth > 1) throw new Error(`Cascade field "${key}" is a nested array and cannot cascade`);
|
|
57
|
+
return field.modelRef;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#readOwnerPath(key: string, field: ConstantField, fieldMap: FieldObject): CascadeWithPath {
|
|
61
|
+
|
|
62
|
+
if (field.arrDepth > 0) throw new Error(`Cascade field "${key}" is an array and names more than one owner`);
|
|
63
|
+
if (field.isMap) throw new Error(`Cascade field "${key}" is a Map and names no owner`);
|
|
64
|
+
if (field.refPath) return this.#readPolymorphicOwner(key, field, fieldMap);
|
|
65
|
+
if (field.ref) {
|
|
66
|
+
this.#assertHoldsId(key, field);
|
|
67
|
+
return { key, modelRef: null, refName: field.ref, typeKey: null, typeValues: [] };
|
|
68
|
+
}
|
|
69
|
+
if (field.isClass && !field.isScalar) {
|
|
70
|
+
return { key, modelRef: field.modelRef, refName: null, typeKey: null, typeValues: [] };
|
|
71
|
+
}
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Cascade field "${key}" declares cascade: "removeWith" but names no owner; make it a model reference, ` +
|
|
74
|
+
`or add ref: "<model>" / refPath: "<typeField>"`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#readPolymorphicOwner(key: string, field: ConstantField, fieldMap: FieldObject): CascadeWithPath {
|
|
79
|
+
if (field.ref) throw new Error(`Cascade field "${key}" declares both ref and refPath; keep one`);
|
|
80
|
+
this.#assertHoldsId(key, field);
|
|
81
|
+
const typeKey = field.refPath as string;
|
|
82
|
+
const typeField = fieldMap[typeKey];
|
|
83
|
+
if (!typeField) throw new Error(`Cascade field "${key}" declares refPath: "${typeKey}", which is not a field`);
|
|
84
|
+
|
|
85
|
+
if (!typeField.enum) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Cascade field "${key}" declares refPath: "${typeKey}", which must be an enumOf(...) naming the owner ` +
|
|
88
|
+
`refNames it may hold`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const typeValues = typeField.enum.values.map((value) => String(value));
|
|
92
|
+
return { key, modelRef: null, refName: null, typeKey, typeValues };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#assertHoldsId(key: string, field: ConstantField) {
|
|
96
|
+
const modelRef = field.modelRef as unknown as Cls;
|
|
97
|
+
const refName = PrimitiveRegistry.has(modelRef)
|
|
98
|
+
? PrimitiveRegistry.getName(modelRef as unknown as typeof PrimitiveScalar)
|
|
99
|
+
: null;
|
|
100
|
+
if (refName && idNames.has(refName)) return;
|
|
101
|
+
throw new Error(`Cascade field "${key}" declares ref or refPath and must hold an ID`);
|
|
31
102
|
}
|
|
32
103
|
}
|
package/document/database.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { DocumentModel, QueryOf } from "akanjs/constant";
|
|
|
4
4
|
import type { CacheAdaptor, CacheSetOptions } from "akanjs/service";
|
|
5
5
|
import type { DataLoader } from "./dataLoader";
|
|
6
6
|
import type { ExtractQuery, ExtractSort, FilterInstance } from "./filterMeta";
|
|
7
|
-
import type { CRUDEventType, Mdl, SaveEventType } from "./into";
|
|
7
|
+
import type { CRUDEventType, Mdl, SaveEventType, UpdateResult } from "./into";
|
|
8
8
|
import type { DataInputOf, FindQueryOption, ListQueryOption } from "./types";
|
|
9
9
|
|
|
10
10
|
export class CacheDatabase<T = unknown> {
|
|
@@ -105,6 +105,7 @@ type DatabaseModelWithQuerySort<
|
|
|
105
105
|
__create: (data: _DataInput) => Promise<Doc>;
|
|
106
106
|
__update: (id: string, data: Partial<Doc>) => Promise<Doc>;
|
|
107
107
|
__remove: (id: string) => Promise<Doc>;
|
|
108
|
+
__removeMany: (query: _QueryOfDoc) => Promise<UpdateResult>;
|
|
108
109
|
__list(query: _QueryOfDoc, queryOption?: _ListQueryOption): Promise<Doc[]>;
|
|
109
110
|
__listIds(query: _QueryOfDoc, queryOption?: _ListQueryOption): Promise<string[]>;
|
|
110
111
|
__find(query: _QueryOfDoc, queryOption?: _FindQueryOption): Promise<Doc | null>;
|
|
@@ -19,7 +19,7 @@ export interface DatabaseModel<
|
|
|
19
19
|
model: ModelCls<Model>;
|
|
20
20
|
filter: FilterCls<Filter>;
|
|
21
21
|
obj: ConstantCls<Obj>;
|
|
22
|
-
insight:
|
|
22
|
+
insight: DatabaseCls<Insight>;
|
|
23
23
|
_Input: Input;
|
|
24
24
|
_Doc: Doc;
|
|
25
25
|
_Model: Model;
|
|
@@ -92,7 +92,7 @@ export class DatabaseRegistry {
|
|
|
92
92
|
doc: DatabaseCls<Doc>,
|
|
93
93
|
model: ModelCls<Model>,
|
|
94
94
|
obj: ConstantCls<Obj>,
|
|
95
|
-
insight:
|
|
95
|
+
insight: DatabaseCls<Insight>,
|
|
96
96
|
filter: FilterCls<Filter>,
|
|
97
97
|
): DatabaseModel<T, Input, Doc, Model, Obj, Insight, Filter, _Query, _Sort> {
|
|
98
98
|
const dbInfo = {
|
package/document/into.ts
CHANGED
|
@@ -85,14 +85,15 @@ export type Mdl<
|
|
|
85
85
|
options?: DocumentUpdateOptions,
|
|
86
86
|
): Promise<UpdateResult>;
|
|
87
87
|
updateMany(query: _RawQuery, update: DocumentUpdateInput<_RawDoc>): Promise<UpdateResult>;
|
|
88
|
-
|
|
88
|
+
removeMany(query: _RawQuery): Promise<UpdateResult>;
|
|
89
89
|
bulkWrite(operations: BulkWriteOperation<Raw, _RawDoc, _RawQuery>[]): Promise<UpdateResult>;
|
|
90
90
|
};
|
|
91
91
|
|
|
92
|
-
interface IntoConstantModel<T extends string, _CapitalizedRefName extends string, Raw> {
|
|
92
|
+
interface IntoConstantModel<T extends string, _CapitalizedRefName extends string, Raw, Insight> {
|
|
93
93
|
refName: T;
|
|
94
94
|
_CapitalizedRefName: _CapitalizedRefName;
|
|
95
95
|
_Full: Raw;
|
|
96
|
+
_Insight: Insight;
|
|
96
97
|
}
|
|
97
98
|
type NoInferType<T> = [T][T extends unknown ? 0 : never];
|
|
98
99
|
type IntoModelActions<
|
|
@@ -100,6 +101,8 @@ type IntoModelActions<
|
|
|
100
101
|
_CapitalizedRefName extends string,
|
|
101
102
|
Doc,
|
|
102
103
|
Raw,
|
|
104
|
+
|
|
105
|
+
Insight,
|
|
103
106
|
_Query,
|
|
104
107
|
_Sort,
|
|
105
108
|
_QueryOfDoc = QueryOf<Doc>,
|
|
@@ -121,13 +124,14 @@ type IntoModelActions<
|
|
|
121
124
|
[K in `update${_CapitalizedRefName}`]: (id: string, data: Partial<Doc>) => Promise<Doc>;
|
|
122
125
|
} & {
|
|
123
126
|
[K in `remove${_CapitalizedRefName}`]: (id: string) => Promise<Doc>;
|
|
124
|
-
} & QueryMethodPart<_Query, _Sort, Raw, Doc,
|
|
127
|
+
} & QueryMethodPart<_Query, _Sort, Raw, Doc, DocumentModel<Insight>, unknown, unknown, _QueryOfDoc>;
|
|
125
128
|
|
|
126
129
|
export const into = <
|
|
127
130
|
Doc,
|
|
128
131
|
FilterRef extends FilterCls,
|
|
129
132
|
T extends string,
|
|
130
133
|
Raw,
|
|
134
|
+
Insight,
|
|
131
135
|
AddDbModels extends ModelCls[],
|
|
132
136
|
_CapitalizedRefName extends string,
|
|
133
137
|
_QueryOfDoc = QueryOf<Doc>,
|
|
@@ -137,11 +141,11 @@ export const into = <
|
|
|
137
141
|
>(
|
|
138
142
|
docRef: Cls<Doc>,
|
|
139
143
|
filterRef: FilterRef,
|
|
140
|
-
cnst: IntoConstantModel<T, _CapitalizedRefName, Raw>,
|
|
144
|
+
cnst: IntoConstantModel<T, _CapitalizedRefName, Raw, Insight>,
|
|
141
145
|
loaderBuilder: _LoaderBuilder,
|
|
142
146
|
...addMdls: [...AddDbModels]
|
|
143
147
|
): ModelCls<
|
|
144
|
-
IntoModelActions<T, _CapitalizedRefName, Doc, Raw, _Query, _Sort, _QueryOfDoc>,
|
|
148
|
+
IntoModelActions<T, _CapitalizedRefName, Doc, Raw, Insight, _Query, _Sort, _QueryOfDoc>,
|
|
145
149
|
ReturnType<_LoaderBuilder>
|
|
146
150
|
> => {
|
|
147
151
|
const loaderInfoMap = loaderBuilder(makeLoaderBuilder<Doc>());
|
|
@@ -162,7 +166,7 @@ export const into = <
|
|
|
162
166
|
});
|
|
163
167
|
});
|
|
164
168
|
return DefaultModel as unknown as ModelCls<
|
|
165
|
-
IntoModelActions<T, _CapitalizedRefName, Doc, Raw, _Query, _Sort, _QueryOfDoc>,
|
|
169
|
+
IntoModelActions<T, _CapitalizedRefName, Doc, Raw, Insight, _Query, _Sort, _QueryOfDoc>,
|
|
166
170
|
ReturnType<_LoaderBuilder>
|
|
167
171
|
>;
|
|
168
172
|
};
|
package/package.json
CHANGED
|
@@ -23,14 +23,31 @@ Read snapshots from:
|
|
|
23
23
|
curl http://localhost:8080/_akan/app/metrics
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
A replica and its RSC worker are separate processes and are reported separately. **`rssBytes` is the
|
|
27
|
+
replica's own; the worker's is `rscWorkerRssBytes`.** Sum them for what the pod pays.
|
|
28
|
+
|
|
26
29
|
Key fields:
|
|
27
30
|
|
|
28
|
-
- `rssBytes`, `heapUsedBytes`, `jscHeapSizeBytes`:
|
|
31
|
+
- `rssBytes`, `heapUsedBytes`, `jscHeapSizeBytes`: the replica. Distinguish RSS-only native retention from JS
|
|
32
|
+
heap retention.
|
|
33
|
+
- `rscWorkerRssBytes`, `rscWorkerHeapUsedBytes`, `rscWorkerJscHeapSizeBytes`, `rscWorkerJscExtraMemorySizeBytes`:
|
|
34
|
+
the same for the worker. `jscExtra` is off-heap, mostly typed-array backing stores.
|
|
29
35
|
- `rscRenderCount`, `rscInFlightRenderCount`: detect request lifecycle leaks.
|
|
30
36
|
- `rscLoadedRouteModuleCount`, `rscRouteModuleCacheHits`, `rscRouteModuleCacheMisses`: detect route module warm-up.
|
|
31
|
-
- `ssrChunkRegistrySize`, `ssrChunkLoadCount`:
|
|
37
|
+
- `ssrChunkRegistrySize`, `ssrChunkLoadCount`: full-document SSR client chunk loading. **`…RegistrySize` counts
|
|
38
|
+
keys, not bytes** — evicting does not unload the module, so it can never fall on its own.
|
|
39
|
+
- `httpHtmlCacheEntries` / `httpHtmlCacheBytes`, `rscResultCacheEntries` / `rscResultCacheBytes`,
|
|
40
|
+
`rscPatchResultCacheEntries` / `rscPatchResultCacheBytes`: what each cache actually holds. Entry count alone
|
|
41
|
+
says nothing when entries span three orders of magnitude.
|
|
32
42
|
- `httpFullSsrCount`, `httpRscNavigationCount`, `httpStaticAssetCount`, `httpImageCount`: separate request kinds.
|
|
33
43
|
|
|
44
|
+
Two things measured on `apps/akan` that shape how to read all of the above:
|
|
45
|
+
|
|
46
|
+
- **Growth converges; it is not a leak.** Ten passes over the same routes plateau by roughly the sixth, with a
|
|
47
|
+
flat JS heap throughout. Three passes is not enough to tell a plateau from a ratchet.
|
|
48
|
+
- **Freeing JS objects does not lower RSS.** Emptying both result caches returns their bytes to the heap and
|
|
49
|
+
leaves RSS unchanged. Only not allocating, or restarting the process, reduces what the pod pays.
|
|
50
|
+
|
|
34
51
|
## Scenarios
|
|
35
52
|
|
|
36
53
|
### Same Route Repeated
|
package/server/cachePolicy.ts
CHANGED
|
@@ -234,20 +234,62 @@ export function shouldInvalidateRouteCacheEntry(
|
|
|
234
234
|
return false;
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
export
|
|
238
|
-
|
|
237
|
+
export interface LruTtlCacheOptions<T> {
|
|
238
|
+
/**
|
|
239
|
+
* Measures one entry's payload. Entry count alone says nothing about a cache whose entries span
|
|
240
|
+
* three orders of magnitude, and a byte ceiling needs a running total to enforce. The default
|
|
241
|
+
* reports 0 rather than guessing, so `byteSize` stays honest about not knowing.
|
|
242
|
+
*/
|
|
243
|
+
sizeOf?: (value: T) => number;
|
|
244
|
+
/** Total payload ceiling. 0 leaves the cache bounded only by `maxEntries`. */
|
|
245
|
+
maxBytes?: number;
|
|
246
|
+
/** An entry over this is not stored at all, rather than evicting everything else to fit it. */
|
|
247
|
+
maxEntryBytes?: number;
|
|
248
|
+
/**
|
|
249
|
+
* Cadence of the idle sweep. Without one a filled cache never shrinks: an entry is dropped only
|
|
250
|
+
* when its own key is fetched after expiry or when a write evicts it, so a pod that stops
|
|
251
|
+
* serving holds its peak forever — measured at 100 entries / 21.4 MiB still resident 310s after
|
|
252
|
+
* the last request, with a 30s TTL. 0 disables it.
|
|
253
|
+
*/
|
|
254
|
+
sweepIntervalMs?: number;
|
|
255
|
+
}
|
|
239
256
|
|
|
240
|
-
|
|
257
|
+
export class LruTtlCache<T> {
|
|
258
|
+
readonly #entries = new Map<string, { value: T; expiresAt: number; byteLength: number }>();
|
|
259
|
+
#byteLength = 0;
|
|
260
|
+
#sweepTimer: ReturnType<typeof setInterval> | null = null;
|
|
261
|
+
readonly #sizeOf: (value: T) => number;
|
|
262
|
+
readonly #maxBytes: number;
|
|
263
|
+
readonly #maxEntryBytes: number;
|
|
264
|
+
|
|
265
|
+
constructor(
|
|
266
|
+
readonly maxEntries = 100,
|
|
267
|
+
options: LruTtlCacheOptions<T> = {},
|
|
268
|
+
) {
|
|
269
|
+
this.#sizeOf = options.sizeOf ?? (() => 0);
|
|
270
|
+
this.#maxBytes = options.maxBytes ?? 0;
|
|
271
|
+
this.#maxEntryBytes = options.maxEntryBytes ?? 0;
|
|
272
|
+
const sweepIntervalMs = options.sweepIntervalMs ?? 0;
|
|
273
|
+
if (sweepIntervalMs > 0) {
|
|
274
|
+
this.#sweepTimer = setInterval(() => this.sweepExpired(), sweepIntervalMs);
|
|
275
|
+
|
|
276
|
+
(this.#sweepTimer as { unref?: () => void }).unref?.();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
241
279
|
|
|
242
280
|
get size(): number {
|
|
243
281
|
return this.#entries.size;
|
|
244
282
|
}
|
|
245
283
|
|
|
284
|
+
get byteSize(): number {
|
|
285
|
+
return this.#byteLength;
|
|
286
|
+
}
|
|
287
|
+
|
|
246
288
|
get(key: string): T | null {
|
|
247
289
|
const entry = this.#entries.get(key);
|
|
248
290
|
if (!entry) return null;
|
|
249
291
|
if (entry.expiresAt <= Date.now()) {
|
|
250
|
-
this.#
|
|
292
|
+
this.#remove(key);
|
|
251
293
|
return null;
|
|
252
294
|
}
|
|
253
295
|
this.#entries.delete(key);
|
|
@@ -255,26 +297,56 @@ export class LruTtlCache<T> {
|
|
|
255
297
|
return entry.value;
|
|
256
298
|
}
|
|
257
299
|
|
|
258
|
-
|
|
259
|
-
|
|
300
|
+
/** Returns whether the entry was stored; a payload over `maxEntryBytes` is rejected. */
|
|
301
|
+
set(key: string, value: T, ttlSeconds: number): boolean {
|
|
302
|
+
this.#remove(key);
|
|
303
|
+
const byteLength = LruTtlCache.#measure(this.#sizeOf, value);
|
|
304
|
+
if (this.#maxEntryBytes > 0 && byteLength > this.#maxEntryBytes) return false;
|
|
305
|
+
this.sweepExpired();
|
|
260
306
|
const maxEntries = this.maxEntries > 0 ? this.maxEntries : 100;
|
|
261
307
|
while (this.#entries.size >= maxEntries) {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
308
|
+
if (!this.#removeOldest()) break;
|
|
309
|
+
}
|
|
310
|
+
while (this.#maxBytes > 0 && this.#entries.size > 0 && this.#byteLength + byteLength > this.#maxBytes) {
|
|
311
|
+
if (!this.#removeOldest()) break;
|
|
265
312
|
}
|
|
266
|
-
this.#entries.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
|
|
313
|
+
this.#entries.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000, byteLength });
|
|
314
|
+
this.#byteLength += byteLength;
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Drops every expired entry. Deliberately a full scan rather than a walk from the oldest that
|
|
320
|
+
* stops at the first live entry: insertion order is *LRU* order because `get` reinserts, and TTLs
|
|
321
|
+
* differ per entry, so expiry is not monotonic in map order and an early break would leave
|
|
322
|
+
* expired entries behind. The map is bounded by `maxEntries`, so the scan is cheap.
|
|
323
|
+
*/
|
|
324
|
+
sweepExpired(now = Date.now()): number {
|
|
325
|
+
let removed = 0;
|
|
326
|
+
for (const [key, entry] of this.#entries) {
|
|
327
|
+
if (entry.expiresAt > now) continue;
|
|
328
|
+
this.#remove(key);
|
|
329
|
+
removed += 1;
|
|
330
|
+
}
|
|
331
|
+
return removed;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Stops the idle sweep. The cache stays usable; only the timer goes away. */
|
|
335
|
+
dispose(): void {
|
|
336
|
+
if (!this.#sweepTimer) return;
|
|
337
|
+
clearInterval(this.#sweepTimer);
|
|
338
|
+
this.#sweepTimer = null;
|
|
267
339
|
}
|
|
268
340
|
|
|
269
341
|
delete(key: string): boolean {
|
|
270
|
-
return this.#
|
|
342
|
+
return this.#remove(key);
|
|
271
343
|
}
|
|
272
344
|
|
|
273
345
|
invalidate(predicate: (key: string, value: T) => boolean): number {
|
|
274
346
|
let count = 0;
|
|
275
347
|
for (const [key, entry] of this.#entries) {
|
|
276
348
|
if (!predicate(key, entry.value)) continue;
|
|
277
|
-
this.#
|
|
349
|
+
this.#remove(key);
|
|
278
350
|
count += 1;
|
|
279
351
|
}
|
|
280
352
|
return count;
|
|
@@ -282,5 +354,35 @@ export class LruTtlCache<T> {
|
|
|
282
354
|
|
|
283
355
|
clear(): void {
|
|
284
356
|
this.#entries.clear();
|
|
357
|
+
this.#byteLength = 0;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
static parseByteCeiling(value: string | undefined | null, fallback = 0): number {
|
|
361
|
+
const parsed = Number.parseInt(value ?? "", 10);
|
|
362
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
#remove(key: string): boolean {
|
|
366
|
+
const entry = this.#entries.get(key);
|
|
367
|
+
if (!entry) return false;
|
|
368
|
+
this.#entries.delete(key);
|
|
369
|
+
this.#byteLength -= entry.byteLength;
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
#removeOldest(): boolean {
|
|
374
|
+
const oldest = this.#entries.keys().next().value;
|
|
375
|
+
if (!oldest) return false;
|
|
376
|
+
return this.#remove(oldest);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** A measurement must never fail a cache write, and a bad measurement must never skew the total. */
|
|
380
|
+
static #measure<T>(sizeOf: (value: T) => number, value: T): number {
|
|
381
|
+
try {
|
|
382
|
+
const measured = sizeOf(value);
|
|
383
|
+
return Number.isFinite(measured) && measured > 0 ? measured : 0;
|
|
384
|
+
} catch {
|
|
385
|
+
return 0;
|
|
386
|
+
}
|
|
285
387
|
}
|
|
286
388
|
}
|
package/server/di/diLifecycle.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { SignalRegistry } from "../../signal/signalRegistry";
|
|
|
21
21
|
import type { AkanLib, DatabaseModule, ScalarModule, ServiceModule } from "../akanLib";
|
|
22
22
|
import { createDefaultAkanOption } from "../akanOption";
|
|
23
23
|
import type { WebProxyRegistration } from "../proxy";
|
|
24
|
-
import { DatabaseResolver, ServiceResolver, SignalResolver } from "../resolver";
|
|
24
|
+
import { CascadeRunner, DatabaseResolver, ServiceResolver, SignalResolver } from "../resolver";
|
|
25
25
|
import type { SignalRoutes, WebsocketRoutes } from "../types";
|
|
26
26
|
import { getPredefinedAdaptor, predefinedAdaptorRole } from "./predefinedAdaptor";
|
|
27
27
|
import { collectAdaptors, resolveAdaptorHierarchy } from "./resolveAdaptorHierarchy";
|
|
@@ -63,6 +63,7 @@ export class DiLifecycle {
|
|
|
63
63
|
readonly disabledModules = new Map<string, string>();
|
|
64
64
|
readonly #predefinedAdaptor;
|
|
65
65
|
readonly #predefinedAdaptorRole = predefinedAdaptorRole;
|
|
66
|
+
readonly #cascade = new CascadeRunner();
|
|
66
67
|
|
|
67
68
|
/** Read-only view of the resolved module maps, for tooling that needs to describe the container. */
|
|
68
69
|
get modules(): {
|
|
@@ -122,8 +123,9 @@ export class DiLifecycle {
|
|
|
122
123
|
this.#service.set(refName, module as ServiceModule);
|
|
123
124
|
});
|
|
124
125
|
this.#database.forEach((mod) => {
|
|
125
|
-
const
|
|
126
|
-
this.#adaptor.set(
|
|
126
|
+
const { adaptor, schema } = DatabaseResolver.resolveDatabase(mod.constant, mod.database);
|
|
127
|
+
this.#adaptor.set(adaptor.refName, adaptor);
|
|
128
|
+
this.#cascade.register(mod.constant, schema, mod.service.srv);
|
|
127
129
|
});
|
|
128
130
|
const services = [
|
|
129
131
|
...[...this.#service.values()].map((mod) => mod.service.srv),
|
|
@@ -457,13 +459,7 @@ export class DiLifecycle {
|
|
|
457
459
|
if (serviceCls.type === "database") {
|
|
458
460
|
const databaseModule = this.#database.get(serviceCls.refName);
|
|
459
461
|
if (!databaseModule) throw new Error(`Database "${serviceCls.refName}" is not registered`);
|
|
460
|
-
ServiceResolver.resolveDatabaseService(
|
|
461
|
-
databaseModule.constant,
|
|
462
|
-
databaseModule.database,
|
|
463
|
-
serviceCls,
|
|
464
|
-
|
|
465
|
-
(refName) => this.getService(refName),
|
|
466
|
-
);
|
|
462
|
+
ServiceResolver.resolveDatabaseService(databaseModule.database, serviceCls, this.#cascade);
|
|
467
463
|
}
|
|
468
464
|
const service = new serviceCls();
|
|
469
465
|
await InjectInfo.resolveInjection(service, serviceCls, this.registry, this.#env);
|
|
@@ -476,6 +472,8 @@ export class DiLifecycle {
|
|
|
476
472
|
})),
|
|
477
473
|
);
|
|
478
474
|
}
|
|
475
|
+
|
|
476
|
+
this.#cascade.seal((refName) => this.getService(refName));
|
|
479
477
|
}
|
|
480
478
|
|
|
481
479
|
async #initializeInternal() {
|
|
@@ -138,6 +138,10 @@ export class ProcessMetricsCollector {
|
|
|
138
138
|
...(metrics.jscHeapSizeBytes !== undefined
|
|
139
139
|
? [`jscHeap=${ProcessMetricsCollector.formatBytes(metrics.jscHeapSizeBytes)}`]
|
|
140
140
|
: []),
|
|
141
|
+
|
|
142
|
+
...(metrics.rscWorkerRssBytes !== undefined
|
|
143
|
+
? [`rscWorkerRss=${ProcessMetricsCollector.formatBytes(metrics.rscWorkerRssBytes)}`]
|
|
144
|
+
: []),
|
|
141
145
|
...(metrics.eventLoopLagMeanMs !== undefined
|
|
142
146
|
? [`elLag=${metrics.eventLoopLagMeanMs}/${metrics.eventLoopLagP99Ms ?? 0}/${metrics.eventLoopLagMaxMs ?? 0}ms`]
|
|
143
147
|
: []),
|