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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { LIBS_REMOVE_HOOK } from "akanjs/base";
|
|
3
|
+
import { Logger } from "akanjs/common";
|
|
4
|
+
import { type CascadeWithPath, type ConstantModel, ConstantRegistry } from "akanjs/constant";
|
|
5
|
+
import { type DocumentSchema, documentQueryHelper } from "akanjs/document";
|
|
6
|
+
import type { DatabaseService, ServiceCls } from "akanjs/service";
|
|
7
|
+
|
|
8
|
+
/** A relation the removed document owns: the ids it holds name documents to remove. */
|
|
9
|
+
interface RefEdge {
|
|
10
|
+
readonly key: string;
|
|
11
|
+
readonly refName: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A model that declared itself removable with this one: its rows name the removed document as their owner. */
|
|
15
|
+
interface WithEdge {
|
|
16
|
+
readonly refName: string;
|
|
17
|
+
readonly key: string;
|
|
18
|
+
readonly typeKey: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface CascadeModule {
|
|
22
|
+
readonly constant: ConstantModel;
|
|
23
|
+
readonly schema: DocumentSchema;
|
|
24
|
+
readonly srvRef: ServiceCls;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CascadePlan {
|
|
28
|
+
readonly refEdges: RefEdge[];
|
|
29
|
+
readonly withEdges: WithEdge[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One cascade in flight. Shared down the chain so a cycle is caught wherever it closes. */
|
|
33
|
+
interface CascadeContext {
|
|
34
|
+
readonly seen: Set<string>;
|
|
35
|
+
readonly depth: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** How deep one removal may cascade before the chain is treated as runaway and abandoned. */
|
|
39
|
+
const maxDepth = 16;
|
|
40
|
+
/** Ids taken per query while draining children one document at a time. */
|
|
41
|
+
const drainSize = 200;
|
|
42
|
+
|
|
43
|
+
export class CascadeRunner {
|
|
44
|
+
readonly #modules = new Map<string, CascadeModule>();
|
|
45
|
+
readonly #plans = new Map<string, CascadePlan>();
|
|
46
|
+
readonly #bulk = new Set<string>();
|
|
47
|
+
readonly #context = new AsyncLocalStorage<CascadeContext>();
|
|
48
|
+
readonly #logger = new Logger("Cascade");
|
|
49
|
+
#getService: ((refName: string) => DatabaseService) | null = null;
|
|
50
|
+
|
|
51
|
+
register(constant: ConstantModel, schema: DocumentSchema, srvRef: ServiceCls) {
|
|
52
|
+
this.#modules.set(constant.refName, { constant, schema, srvRef });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Called once every service is live. A `_postRemove` and a `listenPost("remove")` registered during boot both
|
|
57
|
+
* count against bulk removal, so the strategy cannot be decided before the last service has initialized.
|
|
58
|
+
*/
|
|
59
|
+
seal(getService: (refName: string) => DatabaseService) {
|
|
60
|
+
this.#getService = getService;
|
|
61
|
+
for (const [refName, mod] of this.#modules) {
|
|
62
|
+
this.#plans.set(refName, { refEdges: this.#collectRefEdges(refName, mod), withEdges: [] });
|
|
63
|
+
}
|
|
64
|
+
for (const [refName, mod] of this.#modules) this.#collectWithEdges(refName, mod);
|
|
65
|
+
for (const refName of this.#modules.keys()) {
|
|
66
|
+
if (this.#hasRemoveSideEffect(refName)) continue;
|
|
67
|
+
this.#bulk.add(refName);
|
|
68
|
+
}
|
|
69
|
+
this.#report();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async run(refName: string, doc: Record<string, unknown>) {
|
|
73
|
+
const plan = this.#plans.get(refName);
|
|
74
|
+
if (!plan?.refEdges.length && !plan?.withEdges.length) return;
|
|
75
|
+
const parent = this.#context.getStore();
|
|
76
|
+
const seen = parent?.seen ?? new Set<string>();
|
|
77
|
+
const depth = (parent?.depth ?? 0) + 1;
|
|
78
|
+
const id = typeof doc.id === "string" ? doc.id : null;
|
|
79
|
+
if (id) seen.add(`${refName}:${id}`);
|
|
80
|
+
if (depth > maxDepth) {
|
|
81
|
+
this.#logger.error(`Cascade from ${refName} exceeded depth ${maxDepth} and was abandoned`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
await this.#context.run({ seen, depth }, async () => {
|
|
85
|
+
for (const edge of plan.refEdges) await this.#removeRef(edge, doc, seen);
|
|
86
|
+
if (id) for (const edge of plan.withEdges) await this.#removeWith(edge, refName, id, seen);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async #removeRef(edge: RefEdge, doc: Record<string, unknown>, seen: Set<string>) {
|
|
91
|
+
const value = doc[edge.key];
|
|
92
|
+
const ids = (Array.isArray(value) ? value : [value]).filter(
|
|
93
|
+
(item): item is string => typeof item === "string" && !seen.has(`${edge.refName}:${item}`),
|
|
94
|
+
);
|
|
95
|
+
if (!ids.length) return;
|
|
96
|
+
const service = this.#service(edge.refName);
|
|
97
|
+
if (this.#bulk.has(edge.refName)) {
|
|
98
|
+
await service.__removeMany({ id: documentQueryHelper.oneOf(ids) });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const id of ids) await service.__remove(id);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async #removeWith(edge: WithEdge, ownerRef: string, ownerId: string, seen: Set<string>) {
|
|
106
|
+
const service = this.#service(edge.refName);
|
|
107
|
+
const query = edge.typeKey ? { [edge.key]: ownerId, [edge.typeKey]: ownerRef } : { [edge.key]: ownerId };
|
|
108
|
+
if (this.#bulk.has(edge.refName)) {
|
|
109
|
+
await service.__removeMany(query);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (;;) {
|
|
114
|
+
const ids = await service.__listIds(query, { limit: drainSize });
|
|
115
|
+
if (!ids.length) return;
|
|
116
|
+
let removed = 0;
|
|
117
|
+
for (const id of ids) {
|
|
118
|
+
if (seen.has(`${edge.refName}:${id}`)) continue;
|
|
119
|
+
await service.__remove(id);
|
|
120
|
+
removed += 1;
|
|
121
|
+
}
|
|
122
|
+
if (!removed) return;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#collectRefEdges(refName: string, mod: CascadeModule) {
|
|
127
|
+
return [...mod.constant.full.cascade.removeRef].map(([key, modelRef]) => {
|
|
128
|
+
const target = ConstantRegistry.getRefName(modelRef);
|
|
129
|
+
|
|
130
|
+
if (!this.#modules.has(target)) {
|
|
131
|
+
throw new Error(`Cascade field "${refName}.${key}" removes "${target}", which this app does not mount`);
|
|
132
|
+
}
|
|
133
|
+
return { key, refName: target };
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#collectWithEdges(childRef: string, mod: CascadeModule) {
|
|
138
|
+
for (const [key, path] of mod.constant.full.cascade.removeWith) {
|
|
139
|
+
for (const owner of this.#resolveOwners(childRef, key, path)) {
|
|
140
|
+
this.#plans.get(owner)?.withEdges.push({ refName: childRef, key, typeKey: path.typeKey });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#resolveOwners(childRef: string, key: string, path: CascadeWithPath) {
|
|
146
|
+
if (path.typeValues.length) {
|
|
147
|
+
|
|
148
|
+
const mounted = path.typeValues.filter((owner) => this.#modules.has(owner));
|
|
149
|
+
for (const owner of path.typeValues) {
|
|
150
|
+
if (mounted.includes(owner)) continue;
|
|
151
|
+
this.#logger.warn(`Cascade field "${childRef}.${key}" names owner "${owner}", which this app does not mount`);
|
|
152
|
+
}
|
|
153
|
+
return mounted;
|
|
154
|
+
}
|
|
155
|
+
const owner = path.refName ?? ConstantRegistry.getRefName(path.modelRef as never);
|
|
156
|
+
if (!this.#modules.has(owner)) {
|
|
157
|
+
throw new Error(`Cascade field "${childRef}.${key}" is owned by "${owner}", which this app does not mount`);
|
|
158
|
+
}
|
|
159
|
+
return [owner];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Everything a bulk `removeMany` would skip. All of it absent means the two paths leave the same rows behind. */
|
|
163
|
+
#hasRemoveSideEffect(refName: string) {
|
|
164
|
+
const mod = this.#modules.get(refName);
|
|
165
|
+
if (!mod) return true;
|
|
166
|
+
if (mod.schema.preHooks.get("remove")?.length || mod.schema.postHooks.get("remove")?.length) return true;
|
|
167
|
+
if ((mod.srvRef as unknown as { [LIBS_REMOVE_HOOK]?: boolean })[LIBS_REMOVE_HOOK]) return true;
|
|
168
|
+
const proto = mod.srvRef.prototype as { _preRemove?: unknown; _postRemove?: unknown };
|
|
169
|
+
if (typeof proto._preRemove === "function" || typeof proto._postRemove === "function") return true;
|
|
170
|
+
const plan = this.#plans.get(refName);
|
|
171
|
+
return !!plan?.refEdges.length || !!plan?.withEdges.length;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Neither the strategy nor the edge list is visible from the source, and adding a `_postRemove` to a target
|
|
175
|
+
* silently flips it from one query back to one per document. A quiet cascade is the one nobody can explain. */
|
|
176
|
+
#report() {
|
|
177
|
+
const lines: string[] = [];
|
|
178
|
+
for (const [refName, plan] of this.#plans) {
|
|
179
|
+
for (const edge of plan.refEdges) {
|
|
180
|
+
lines.push(`${refName}.${edge.key} removeRef ${edge.refName} (${this.#strategy(edge.refName)})`);
|
|
181
|
+
}
|
|
182
|
+
for (const edge of plan.withEdges) {
|
|
183
|
+
const path = edge.typeKey ? `${edge.key}+${edge.typeKey}` : edge.key;
|
|
184
|
+
lines.push(`${refName} removeWith ${edge.refName}.${path} (${this.#strategy(edge.refName)})`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (!lines.length) return;
|
|
188
|
+
const bulk = lines.filter((line) => line.endsWith("(bulk)")).length;
|
|
189
|
+
this.#logger.info(`${lines.length} cascade edge(s), ${bulk} in one query`);
|
|
190
|
+
for (const line of lines) this.#logger.verbose(line);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#strategy(refName: string) {
|
|
194
|
+
return this.#bulk.has(refName) ? "bulk" : "per document";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#service(refName: string) {
|
|
198
|
+
if (!this.#getService) throw new Error(`Cascade ran before the plan was sealed: ${refName}`);
|
|
199
|
+
return this.#getService(refName);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -47,7 +47,11 @@ const timedQuery = async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
|
47
47
|
};
|
|
48
48
|
|
|
49
49
|
export class DatabaseResolver {
|
|
50
|
-
|
|
50
|
+
/** Returns the schema alongside the adaptor: the cascade planner reads its `remove` hooks to pick a strategy. */
|
|
51
|
+
static resolveDatabase(
|
|
52
|
+
constant: ConstantModel,
|
|
53
|
+
database: DatabaseModel,
|
|
54
|
+
): { adaptor: AdaptorCls<DatabaseInstance>; schema: DocumentSchema } {
|
|
51
55
|
const [modelName, className]: [string, string] = [database.refName, capitalize(database.refName)];
|
|
52
56
|
|
|
53
57
|
const resolveSort = (sortKey?: string | null) =>
|
|
@@ -85,6 +89,16 @@ export class DatabaseResolver {
|
|
|
85
89
|
schema.index(fields);
|
|
86
90
|
}
|
|
87
91
|
|
|
92
|
+
for (const path of constant.full.cascade.removeWith.values()) {
|
|
93
|
+
const fields = path.typeKey
|
|
94
|
+
? { removedAt: 1, [path.typeKey]: 1, [path.key]: 1 }
|
|
95
|
+
: { removedAt: 1, [path.key]: 1 };
|
|
96
|
+
const key = Object.keys(fields).join(",");
|
|
97
|
+
if (indexedSortFieldKeys.has(key)) continue;
|
|
98
|
+
indexedSortFieldKeys.add(key);
|
|
99
|
+
schema.index(fields as { [key: string]: 1 | -1 });
|
|
100
|
+
}
|
|
101
|
+
|
|
88
102
|
class DatabaseModelInstance extends adapt(`${modelName}Model`, ({ plug }) => ({
|
|
89
103
|
__database: plug(DatabaseAdaptorRole, (database) => database),
|
|
90
104
|
__cache: plug(CacheAdaptorRole, (cache) => new CacheDatabase(modelName, cache)),
|
|
@@ -222,7 +236,7 @@ export class DatabaseResolver {
|
|
|
222
236
|
updateOne: (query: QueryOf<any>, update: DocumentUpdateInput, options?: { upsert?: boolean }) =>
|
|
223
237
|
store.updateOneByQuery(query, update, options),
|
|
224
238
|
updateMany: (query: QueryOf<any>, update: DocumentUpdateInput) => store.updateManyByQuery(query, update),
|
|
225
|
-
|
|
239
|
+
removeMany: (query: QueryOf<any>) => store.removeManyByQuery(query),
|
|
226
240
|
bulkWrite: (
|
|
227
241
|
operations: { updateOne: { filter: QueryOf<any>; update: DocumentUpdateInput; upsert?: boolean } }[],
|
|
228
242
|
) => store.bulkWrite(operations),
|
|
@@ -322,6 +336,9 @@ export class DatabaseResolver {
|
|
|
322
336
|
async __remove(id: string) {
|
|
323
337
|
return await this.__store.remove(id);
|
|
324
338
|
}
|
|
339
|
+
async __removeMany(query: QueryOf<any>) {
|
|
340
|
+
return await timedQuery(() => this.__store.removeManyByQuery(query));
|
|
341
|
+
}
|
|
325
342
|
async [`remove${className}`](id: string) {
|
|
326
343
|
return this.__remove(id);
|
|
327
344
|
}
|
|
@@ -389,6 +406,9 @@ export class DatabaseResolver {
|
|
|
389
406
|
});
|
|
390
407
|
});
|
|
391
408
|
applyMixins(DatabaseModelInstance, [database.model]);
|
|
392
|
-
return
|
|
409
|
+
return {
|
|
410
|
+
adaptor: DatabaseModelInstance as unknown as AdaptorCls<DatabaseInstance<any, any, any, any, any, any>>,
|
|
411
|
+
schema,
|
|
412
|
+
};
|
|
393
413
|
}
|
|
394
414
|
}
|
package/server/resolver/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PromiseOrObject } from "akanjs/base";
|
|
2
2
|
import { capitalize } from "akanjs/common";
|
|
3
|
-
import
|
|
3
|
+
import type { QueryOf } from "akanjs/constant";
|
|
4
4
|
import {
|
|
5
5
|
type CRUDEventType,
|
|
6
6
|
type DatabaseModel,
|
|
@@ -15,13 +15,10 @@ import {
|
|
|
15
15
|
type SaveEventType,
|
|
16
16
|
} from "akanjs/document";
|
|
17
17
|
import type { DatabaseService, ServiceCls } from "akanjs/service";
|
|
18
|
+
import type { CascadeRunner } from "./CascadeRunner";
|
|
18
19
|
|
|
19
20
|
export class ServiceResolver {
|
|
20
|
-
static #getDefaultDbServiceMethods(
|
|
21
|
-
className: string,
|
|
22
|
-
cascades: [string, string][],
|
|
23
|
-
getService: (refName: string) => DatabaseService,
|
|
24
|
-
) {
|
|
21
|
+
static #getDefaultDbServiceMethods(refName: string, className: string, cascade: CascadeRunner) {
|
|
25
22
|
const dbServiceMethods = {
|
|
26
23
|
async __get(this: DatabaseService, id: string) {
|
|
27
24
|
return await this.__databaseModel.__get(id);
|
|
@@ -99,37 +96,24 @@ export class ServiceResolver {
|
|
|
99
96
|
return this.__update(id, data);
|
|
100
97
|
},
|
|
101
98
|
async __remove(this: DatabaseService, id: string): Promise<Doc> {
|
|
102
|
-
|
|
103
|
-
const targets = cascades.map(([key, refName]) => [key, getService(refName)] as const);
|
|
104
99
|
await this.__libsPreRemove(id);
|
|
105
100
|
const doc = await this.__databaseModel.__remove(id);
|
|
106
101
|
const removed = await this.__libsPostRemove(doc);
|
|
107
|
-
|
|
108
|
-
const value = (removed as Record<string, unknown>)[key];
|
|
109
|
-
const ids = (Array.isArray(value) ? value : [value]).filter((v): v is string => typeof v === "string");
|
|
110
|
-
|
|
111
|
-
for (const targetId of ids) await target.__remove(targetId);
|
|
112
|
-
}
|
|
102
|
+
await cascade.run(refName, removed as Record<string, unknown>);
|
|
113
103
|
return removed;
|
|
114
104
|
},
|
|
115
105
|
async [`remove${className}`](this: DatabaseService, id: string): Promise<Doc> {
|
|
116
106
|
return this.__remove(id);
|
|
117
107
|
},
|
|
108
|
+
async __removeMany(this: DatabaseService, query: QueryOf<any>) {
|
|
109
|
+
return await this.__databaseModel.__removeMany(query);
|
|
110
|
+
},
|
|
118
111
|
};
|
|
119
112
|
return dbServiceMethods;
|
|
120
113
|
}
|
|
121
|
-
static resolveDatabaseService(
|
|
122
|
-
constant: ConstantModel,
|
|
123
|
-
database: DatabaseModel,
|
|
124
|
-
srvRef: ServiceCls,
|
|
125
|
-
getService: (refName: string) => DatabaseService,
|
|
126
|
-
): ServiceCls {
|
|
114
|
+
static resolveDatabaseService(database: DatabaseModel, srvRef: ServiceCls, cascade: CascadeRunner): ServiceCls {
|
|
127
115
|
const className = capitalize(database.refName);
|
|
128
|
-
|
|
129
|
-
const cascades = [...constant.full.cascade.remove].map(
|
|
130
|
-
([key, modelRef]) => [key, ConstantRegistry.getRefName(modelRef)] as [string, string],
|
|
131
|
-
);
|
|
132
|
-
Object.assign(srvRef.prototype, ServiceResolver.#getDefaultDbServiceMethods(className, cascades, getService));
|
|
116
|
+
Object.assign(srvRef.prototype, ServiceResolver.#getDefaultDbServiceMethods(database.refName, className, cascade));
|
|
133
117
|
const getQueryDataFromKey = (queryKey: string, args: any): { query: any; queryOption: any } => {
|
|
134
118
|
const lastArg = args.at(-1);
|
|
135
119
|
const hasQueryOption =
|
package/server/rscWorker.tsx
CHANGED
|
@@ -24,6 +24,7 @@ import { renderToReadableStream } from "react-server-dom-webpack/server.node";
|
|
|
24
24
|
import type { ClientManifest } from "./artifact";
|
|
25
25
|
import {
|
|
26
26
|
LruTtlCache,
|
|
27
|
+
type LruTtlCacheOptions,
|
|
27
28
|
parsePositiveInt,
|
|
28
29
|
type RouteCacheEntry,
|
|
29
30
|
type RouteCacheInvalidation,
|
|
@@ -122,6 +123,9 @@ interface FlightRenderResult {
|
|
|
122
123
|
cancelled: boolean;
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
/** Well above any sensible TTL: this reclaims a cache nobody is reading, not tracks expiry closely. */
|
|
127
|
+
const RESULT_CACHE_SWEEP_INTERVAL_MS = 60_000;
|
|
128
|
+
|
|
125
129
|
function hashRscTraceCacheKey(cacheKey: string): string {
|
|
126
130
|
let hash = 5381;
|
|
127
131
|
for (let index = 0; index < cacheKey.length; index += 1) hash = (hash * 33) ^ cacheKey.charCodeAt(index);
|
|
@@ -197,11 +201,14 @@ export class RscRenderer {
|
|
|
197
201
|
pagesBundleBuildId: 0,
|
|
198
202
|
};
|
|
199
203
|
readonly #routeStats = new Map<string, RouteRenderStats>();
|
|
204
|
+
|
|
200
205
|
#resultCache = new LruTtlCache<CachedRscResult>(
|
|
201
206
|
parsePositiveInt(process.env.AKAN_RSC_RESULT_CACHE_MAX_ENTRIES) ?? 100,
|
|
207
|
+
RscRenderer.#resultCacheOptions(),
|
|
202
208
|
);
|
|
203
209
|
#patchResultCache = new LruTtlCache<CachedRscResult>(
|
|
204
210
|
parsePositiveInt(process.env.AKAN_RSC_RESULT_CACHE_MAX_ENTRIES) ?? 100,
|
|
211
|
+
RscRenderer.#resultCacheOptions(),
|
|
205
212
|
);
|
|
206
213
|
readonly #activeRenderReaders = new Map<string, ReadableStreamDefaultReader<Uint8Array>>();
|
|
207
214
|
readonly #cancelledRenderRequests = new Set<string>();
|
|
@@ -818,7 +825,11 @@ export class RscRenderer {
|
|
|
818
825
|
rscLoadedRouteModuleKeys: routeStats.loadedModuleKeys,
|
|
819
826
|
rscTopRoutesByRenderCount: this.#topRoutes((route) => route.count),
|
|
820
827
|
rscTopRoutesByFlightBytes: this.#topRoutes((route) => route.flightBytes),
|
|
821
|
-
|
|
828
|
+
|
|
829
|
+
rscResultCacheEntries: this.#resultCache.size,
|
|
830
|
+
rscResultCacheBytes: this.#resultCache.byteSize,
|
|
831
|
+
rscPatchResultCacheEntries: this.#patchResultCache.size,
|
|
832
|
+
rscPatchResultCacheBytes: this.#patchResultCache.byteSize,
|
|
822
833
|
rscResultCacheHits: this.#resultCacheHits,
|
|
823
834
|
rscResultCacheMisses: this.#resultCacheMisses,
|
|
824
835
|
rscResultCacheBypass: this.#resultCacheBypass,
|
|
@@ -899,7 +910,7 @@ export class RscRenderer {
|
|
|
899
910
|
});
|
|
900
911
|
};
|
|
901
912
|
try {
|
|
902
|
-
|
|
913
|
+
for (;;) {
|
|
903
914
|
if (options.requestId && this.#cancelledRenderRequests.has(options.requestId)) {
|
|
904
915
|
await reader.cancel();
|
|
905
916
|
return { chunks, bytes, chunksCount, control: null, lateControlSent, cancelled: true };
|
|
@@ -1125,6 +1136,15 @@ export class RscRenderer {
|
|
|
1125
1136
|
return decision;
|
|
1126
1137
|
}
|
|
1127
1138
|
|
|
1139
|
+
static #resultCacheOptions(): LruTtlCacheOptions<CachedRscResult> {
|
|
1140
|
+
return {
|
|
1141
|
+
sizeOf: (result) => result.bytes,
|
|
1142
|
+
maxBytes: LruTtlCache.parseByteCeiling(process.env.AKAN_RSC_RESULT_CACHE_MAX_BYTES),
|
|
1143
|
+
maxEntryBytes: LruTtlCache.parseByteCeiling(process.env.AKAN_RSC_RESULT_CACHE_MAX_BODY_BYTES),
|
|
1144
|
+
sweepIntervalMs: RESULT_CACHE_SWEEP_INTERVAL_MS,
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1128
1148
|
#getCachedResult(cacheKey: string): CachedRscResult | null {
|
|
1129
1149
|
const cached = this.#resultCache.get(cacheKey);
|
|
1130
1150
|
if (!cached) {
|
|
@@ -1146,11 +1166,19 @@ export class RscRenderer {
|
|
|
1146
1166
|
}
|
|
1147
1167
|
|
|
1148
1168
|
#setCachedResult(cacheKey: string, result: CachedRscResult, ttl: number): void {
|
|
1149
|
-
this.#resultCache.set(cacheKey, result, ttl);
|
|
1169
|
+
this.#logRejectedStore("full", result, this.#resultCache.set(cacheKey, result, ttl));
|
|
1150
1170
|
}
|
|
1151
1171
|
|
|
1152
1172
|
#setCachedPatchResult(cacheKey: string, result: CachedRscResult, ttl: number): void {
|
|
1153
|
-
this.#patchResultCache.set(cacheKey, result, ttl);
|
|
1173
|
+
this.#logRejectedStore("patch", result, this.#patchResultCache.set(cacheKey, result, ttl));
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/** A silently dropped store looks identical to a cache miss; say which route is too big to cache. */
|
|
1177
|
+
#logRejectedStore(kind: string, result: CachedRscResult, stored: boolean): void {
|
|
1178
|
+
if (stored) return;
|
|
1179
|
+
this.#logger.verbose(
|
|
1180
|
+
`${kind} result cache store skipped pathname=${result.pathname} bytes=${result.bytes} reason=body-too-large`,
|
|
1181
|
+
);
|
|
1154
1182
|
}
|
|
1155
1183
|
|
|
1156
1184
|
#runWithRequest<T>(request: Request, fn: () => Promise<T>): Promise<T> {
|
package/server/rscWorkerHost.ts
CHANGED
|
@@ -89,6 +89,66 @@ export function createRscWorkerInvalidateCacheMessage(
|
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The RSC worker samples its own process with the same `ProcessMetricsCollector.collect` its host
|
|
94
|
+
* replica uses, so its report carries `pid` / `rssBytes` / `role` / … under the identical names.
|
|
95
|
+
* `AkanServer` spreads the host's report into its own `collect({ role, ...webRouter.getMetrics() })`
|
|
96
|
+
* and `collect` spreads `extra` last (`processMetricsCollector.ts:122`) — so passing these through
|
|
97
|
+
* unprefixed silently overwrote the replica's own process fields, and the replica's RSS was reported
|
|
98
|
+
* nowhere. Rename them onto `rscWorker*`; the `rsc*` render counters, which only the worker
|
|
99
|
+
* produces, pass through untouched.
|
|
100
|
+
*
|
|
101
|
+
* XXX A new process-level field in `ProcessMetricsCollector.collect` must be added here too, or it
|
|
102
|
+
* starts shadowing the replica again.
|
|
103
|
+
*/
|
|
104
|
+
export function projectRscWorkerProcessMetrics(metrics: AkanMetricsReport): AkanMetricsReport {
|
|
105
|
+
const {
|
|
106
|
+
role: _role,
|
|
107
|
+
pid: _pid,
|
|
108
|
+
trace: _trace,
|
|
109
|
+
reportedAt,
|
|
110
|
+
rssBytes,
|
|
111
|
+
heapTotalBytes,
|
|
112
|
+
heapUsedBytes,
|
|
113
|
+
externalBytes,
|
|
114
|
+
arrayBuffersBytes,
|
|
115
|
+
cpuUserMicros,
|
|
116
|
+
cpuSystemMicros,
|
|
117
|
+
maxRssKb,
|
|
118
|
+
jscHeapSizeBytes,
|
|
119
|
+
jscHeapCapacityBytes,
|
|
120
|
+
jscExtraMemorySizeBytes,
|
|
121
|
+
jscObjectCount,
|
|
122
|
+
jscProtectedObjectCount,
|
|
123
|
+
eventLoopLagMeanMs,
|
|
124
|
+
eventLoopLagP99Ms,
|
|
125
|
+
eventLoopLagMaxMs,
|
|
126
|
+
gcDurationMs,
|
|
127
|
+
...renderMetrics
|
|
128
|
+
} = metrics;
|
|
129
|
+
return {
|
|
130
|
+
...renderMetrics,
|
|
131
|
+
rscWorkerReportedAt: reportedAt,
|
|
132
|
+
rscWorkerRssBytes: rssBytes,
|
|
133
|
+
rscWorkerHeapTotalBytes: heapTotalBytes,
|
|
134
|
+
rscWorkerHeapUsedBytes: heapUsedBytes,
|
|
135
|
+
rscWorkerExternalBytes: externalBytes,
|
|
136
|
+
rscWorkerArrayBuffersBytes: arrayBuffersBytes,
|
|
137
|
+
rscWorkerCpuUserMicros: cpuUserMicros,
|
|
138
|
+
rscWorkerCpuSystemMicros: cpuSystemMicros,
|
|
139
|
+
rscWorkerMaxRssKb: maxRssKb,
|
|
140
|
+
rscWorkerJscHeapSizeBytes: jscHeapSizeBytes,
|
|
141
|
+
rscWorkerJscHeapCapacityBytes: jscHeapCapacityBytes,
|
|
142
|
+
rscWorkerJscExtraMemorySizeBytes: jscExtraMemorySizeBytes,
|
|
143
|
+
rscWorkerJscObjectCount: jscObjectCount,
|
|
144
|
+
rscWorkerJscProtectedObjectCount: jscProtectedObjectCount,
|
|
145
|
+
rscWorkerEventLoopLagMeanMs: eventLoopLagMeanMs,
|
|
146
|
+
rscWorkerEventLoopLagP99Ms: eventLoopLagP99Ms,
|
|
147
|
+
rscWorkerEventLoopLagMaxMs: eventLoopLagMaxMs,
|
|
148
|
+
rscWorkerGcDurationMs: gcDurationMs,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
92
152
|
export function createRscHostRenderStream(input: {
|
|
93
153
|
setPending: (pending: RscPending) => void;
|
|
94
154
|
deletePending: () => void;
|
|
@@ -464,18 +524,7 @@ export class RscWorker {
|
|
|
464
524
|
}
|
|
465
525
|
|
|
466
526
|
getMetrics(): AkanMetricsReport {
|
|
467
|
-
|
|
468
|
-
rscWorkerPid: _rscWorkerPid,
|
|
469
|
-
rscWorkerStatus: _rscWorkerStatus,
|
|
470
|
-
rscWorkerRestartCount: _rscWorkerRestartCount,
|
|
471
|
-
rscWorkerRecycleCount: _rscWorkerRecycleCount,
|
|
472
|
-
rscWorkerLastRecycleReason: _rscWorkerLastRecycleReason,
|
|
473
|
-
rscPendingRenderCount: _rscPendingRenderCount,
|
|
474
|
-
rscQueuedSendCount: _rscQueuedSendCount,
|
|
475
|
-
rscHostPendingChunkOverflowCount: _rscHostPendingChunkOverflowCount,
|
|
476
|
-
...workerMetrics
|
|
477
|
-
} = this.#lastWorkerMetrics;
|
|
478
|
-
return Object.assign(workerMetrics, {
|
|
527
|
+
return Object.assign(projectRscWorkerProcessMetrics(this.#lastWorkerMetrics), {
|
|
479
528
|
rscWorkerPid: this.#proc.pid,
|
|
480
529
|
rscWorkerStatus: this.#status,
|
|
481
530
|
rscWorkerRestartCount: this.#restartCount,
|
|
@@ -411,7 +411,7 @@ export function interleaveRscScriptsWithHtml(
|
|
|
411
411
|
const pumpRscScripts = async () => {
|
|
412
412
|
rscReader = rscClientStream.getReader();
|
|
413
413
|
try {
|
|
414
|
-
|
|
414
|
+
for (;;) {
|
|
415
415
|
const { value, done } = await rscReader.read();
|
|
416
416
|
if (done || errored) break;
|
|
417
417
|
await waitForRscQueueDrain();
|
|
@@ -446,7 +446,7 @@ export function interleaveRscScriptsWithHtml(
|
|
|
446
446
|
|
|
447
447
|
htmlReader = htmlStream.getReader();
|
|
448
448
|
try {
|
|
449
|
-
|
|
449
|
+
for (;;) {
|
|
450
450
|
const { value, done } = await htmlReader.read();
|
|
451
451
|
if (done || errored) break;
|
|
452
452
|
controller.enqueue(value);
|
package/server/webRouter.ts
CHANGED
|
@@ -56,6 +56,8 @@ import type { BaseBuildArtifact, HttpRoutes, RenderState } from "./types";
|
|
|
56
56
|
|
|
57
57
|
const CLIENT_CLOSED_REQUEST_STATUS = 499;
|
|
58
58
|
export const DEFAULT_HTML_RESULT_CACHE_MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
59
|
+
/** Well above any sensible TTL: this only has to reclaim a cache nobody is reading, not track expiry closely. */
|
|
60
|
+
const ROUTE_CACHE_SWEEP_INTERVAL_MS = 60_000;
|
|
59
61
|
const APPLE_APP_SITE_ASSOCIATION_PATH = "/.well-known/apple-app-site-association";
|
|
60
62
|
const ANDROID_ASSET_LINKS_PATH = "/.well-known/assetlinks.json";
|
|
61
63
|
|
|
@@ -286,6 +288,11 @@ export class WebRouter {
|
|
|
286
288
|
};
|
|
287
289
|
readonly #htmlCache = new LruTtlCache<CachedHtmlResult>(
|
|
288
290
|
parsePositiveInt(process.env.AKAN_HTML_RESULT_CACHE_MAX_ENTRIES) ?? 100,
|
|
291
|
+
{
|
|
292
|
+
sizeOf: (result) => result.html.length,
|
|
293
|
+
maxBytes: LruTtlCache.parseByteCeiling(process.env.AKAN_HTML_RESULT_CACHE_MAX_BYTES),
|
|
294
|
+
sweepIntervalMs: ROUTE_CACHE_SWEEP_INTERVAL_MS,
|
|
295
|
+
},
|
|
289
296
|
);
|
|
290
297
|
#htmlCacheHits = 0;
|
|
291
298
|
#htmlCacheMisses = 0;
|
|
@@ -669,6 +676,7 @@ export class WebRouter {
|
|
|
669
676
|
this.#devHmr?.dispose();
|
|
670
677
|
this.#devHmr = null;
|
|
671
678
|
this.#builderRpc = null;
|
|
679
|
+
this.#htmlCache.dispose();
|
|
672
680
|
this.#rsc.kill();
|
|
673
681
|
this.#hub = null;
|
|
674
682
|
}
|
|
@@ -686,6 +694,7 @@ export class WebRouter {
|
|
|
686
694
|
httpCsrCount: this.#requestStats.csr,
|
|
687
695
|
httpImageCount: this.#requestStats.image,
|
|
688
696
|
httpHtmlCacheEntries: this.#htmlCache.size,
|
|
697
|
+
httpHtmlCacheBytes: this.#htmlCache.byteSize,
|
|
689
698
|
httpHtmlCacheHits: this.#htmlCacheHits,
|
|
690
699
|
httpHtmlCacheMisses: this.#htmlCacheMisses,
|
|
691
700
|
httpHtmlCacheBypass: this.#htmlCacheBypass,
|
package/service/ipcTypes.ts
CHANGED
|
@@ -55,6 +55,26 @@ export interface AkanMetricsReport {
|
|
|
55
55
|
rscWorkerRestartCount?: number;
|
|
56
56
|
rscWorkerRecycleCount?: number;
|
|
57
57
|
rscWorkerLastRecycleReason?: string;
|
|
58
|
+
|
|
59
|
+
rscWorkerReportedAt?: number;
|
|
60
|
+
rscWorkerRssBytes?: number;
|
|
61
|
+
rscWorkerHeapTotalBytes?: number;
|
|
62
|
+
rscWorkerHeapUsedBytes?: number;
|
|
63
|
+
rscWorkerExternalBytes?: number;
|
|
64
|
+
rscWorkerArrayBuffersBytes?: number;
|
|
65
|
+
rscWorkerCpuUserMicros?: number;
|
|
66
|
+
rscWorkerCpuSystemMicros?: number;
|
|
67
|
+
rscWorkerMaxRssKb?: number;
|
|
68
|
+
rscWorkerJscHeapSizeBytes?: number;
|
|
69
|
+
rscWorkerJscHeapCapacityBytes?: number;
|
|
70
|
+
/** Off-heap bytes JSC attributes to JS objects — typed-array backing stores, i.e. cached Flight chunks. */
|
|
71
|
+
rscWorkerJscExtraMemorySizeBytes?: number;
|
|
72
|
+
rscWorkerJscObjectCount?: number;
|
|
73
|
+
rscWorkerJscProtectedObjectCount?: number;
|
|
74
|
+
rscWorkerEventLoopLagMeanMs?: number;
|
|
75
|
+
rscWorkerEventLoopLagP99Ms?: number;
|
|
76
|
+
rscWorkerEventLoopLagMaxMs?: number;
|
|
77
|
+
rscWorkerGcDurationMs?: number;
|
|
58
78
|
rscPendingRenderCount?: number;
|
|
59
79
|
rscQueuedSendCount?: number;
|
|
60
80
|
rscHostPendingChunkOverflowCount?: number;
|
|
@@ -80,9 +100,13 @@ export interface AkanMetricsReport {
|
|
|
80
100
|
rscLastRenderLoadedModuleDelta?: number;
|
|
81
101
|
rscLastRenderLoadedModules?: string[];
|
|
82
102
|
rscResultCacheEntries?: number;
|
|
103
|
+
rscResultCacheBytes?: number;
|
|
104
|
+
rscPatchResultCacheEntries?: number;
|
|
105
|
+
rscPatchResultCacheBytes?: number;
|
|
83
106
|
rscResultCacheHits?: number;
|
|
84
107
|
rscResultCacheMisses?: number;
|
|
85
108
|
rscResultCacheBypass?: number;
|
|
109
|
+
/** Keys the SSR chunk registry tracks — NOT a memory bound; the modules stay in Bun's ESM registry. */
|
|
86
110
|
ssrChunkRegistrySize?: number;
|
|
87
111
|
ssrChunkLoadCount?: number;
|
|
88
112
|
ssrChunkCacheHitCount?: number;
|
|
@@ -93,6 +117,7 @@ export interface AkanMetricsReport {
|
|
|
93
117
|
httpCsrCount?: number;
|
|
94
118
|
httpImageCount?: number;
|
|
95
119
|
httpHtmlCacheEntries?: number;
|
|
120
|
+
httpHtmlCacheBytes?: number;
|
|
96
121
|
httpHtmlCacheHits?: number;
|
|
97
122
|
httpHtmlCacheMisses?: number;
|
|
98
123
|
httpHtmlCacheBypass?: number;
|
|
@@ -95,7 +95,7 @@ export interface DocumentStore {
|
|
|
95
95
|
query: DocumentQuery,
|
|
96
96
|
update: DocumentUpdateInput,
|
|
97
97
|
): Promise<{ acknowledged: boolean; matchedCount: number; modifiedCount: number }>;
|
|
98
|
-
|
|
98
|
+
removeManyByQuery(
|
|
99
99
|
query: DocumentQuery,
|
|
100
100
|
): Promise<{ acknowledged: boolean; matchedCount: number; modifiedCount: number }>;
|
|
101
101
|
bulkWrite(
|
|
@@ -1081,8 +1081,8 @@ export class SqlDocumentStore {
|
|
|
1081
1081
|
return { acknowledged: true, matchedCount: changes, modifiedCount: changes };
|
|
1082
1082
|
}
|
|
1083
1083
|
|
|
1084
|
-
async
|
|
1085
|
-
|
|
1084
|
+
async removeManyByQuery(query: DocumentQuery) {
|
|
1085
|
+
|
|
1086
1086
|
return this.updateManyByQuery(query, { removedAt: dayjs() });
|
|
1087
1087
|
}
|
|
1088
1088
|
|
package/service/serve.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Cls, INJECT_META } from "akanjs/base";
|
|
1
|
+
import { type Cls, INJECT_META, LIBS_REMOVE_HOOK } from "akanjs/base";
|
|
2
2
|
import { applyMixins, capitalize, Logger, lowerlize } from "akanjs/common";
|
|
3
3
|
import type { DatabaseModel } from "akanjs/document";
|
|
4
4
|
|
|
@@ -146,6 +146,10 @@ export function serve(
|
|
|
146
146
|
const postUpdateFns = extSrvs.map((srv) => srv.prototype._postUpdate);
|
|
147
147
|
const preRemoveFns = extSrvs.map((srv) => srv.prototype._preRemove);
|
|
148
148
|
const postRemoveFns = extSrvs.map((srv) => srv.prototype._postRemove);
|
|
149
|
+
|
|
150
|
+
Object.assign(srvRef, {
|
|
151
|
+
[LIBS_REMOVE_HOOK]: preRemoveFns.some(Boolean) || postRemoveFns.some(Boolean),
|
|
152
|
+
});
|
|
149
153
|
Object.assign(srvRef.prototype, {
|
|
150
154
|
async __libsPreCreate(this: DatabaseService, data: DatabaseServiceData) {
|
|
151
155
|
let result = data;
|
package/service/types.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
ListQueryOption,
|
|
12
12
|
QueryMethodPart,
|
|
13
13
|
SaveEventType,
|
|
14
|
+
UpdateResult,
|
|
14
15
|
} from "akanjs/document";
|
|
15
16
|
|
|
16
17
|
type ServiceMixinOmitKey =
|
|
@@ -101,6 +102,7 @@ export type DatabaseService<
|
|
|
101
102
|
__create: (data: _DataInputOfDoc) => Promise<Doc>;
|
|
102
103
|
__update: (id: string, data: Partial<Doc>) => Promise<Doc>;
|
|
103
104
|
__remove: (id: string) => Promise<Doc>;
|
|
105
|
+
__removeMany: (query: _QueryOfDoc) => Promise<UpdateResult>;
|
|
104
106
|
__list(query?: _QueryOfDoc, queryOption?: _ListQueryOption): Promise<Doc[]>;
|
|
105
107
|
__listIds(query?: _QueryOfDoc, queryOption?: _ListQueryOption): Promise<string[]>;
|
|
106
108
|
__find(query?: _QueryOfDoc, queryOption?: _FindQueryOption): Promise<Doc | null>;
|
package/types/base/symbols.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export declare const SLICE_META: unique symbol;
|
|
|
3
3
|
export declare const FILTER_META: unique symbol;
|
|
4
4
|
export declare const LOADER_META: unique symbol;
|
|
5
5
|
export declare const INJECT_META: unique symbol;
|
|
6
|
+
export declare const LIBS_REMOVE_HOOK: unique symbol;
|
|
6
7
|
export declare const ENDPOINT_META: unique symbol;
|
|
7
8
|
export declare const ENDPOINT_DICT_SHAPE: unique symbol;
|
|
8
9
|
export declare const SLICE_DICT_SHAPE: unique symbol;
|