bunsane 0.2.3 → 0.2.7
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/config/cache.config.ts +2 -0
- package/core/ArcheType.ts +67 -34
- package/core/BatchLoader.ts +215 -30
- package/core/Entity.ts +2 -2
- package/core/RequestContext.ts +15 -10
- package/core/RequestLoaders.ts +4 -2
- package/core/cache/CacheFactory.ts +3 -1
- package/core/cache/CacheProvider.ts +1 -0
- package/core/cache/CacheWarmer.ts +45 -23
- package/core/cache/MemoryCache.ts +10 -1
- package/core/cache/RedisCache.ts +26 -7
- package/core/validateEnv.ts +8 -0
- package/database/DatabaseHelper.ts +113 -1
- package/database/index.ts +78 -45
- package/docs/SCALABILITY_PLAN.md +175 -0
- package/package.json +13 -2
- package/query/CTENode.ts +44 -24
- package/query/ComponentInclusionNode.ts +181 -91
- package/query/Query.ts +9 -9
- package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +338 -0
- package/tests/benchmark/bunfig.toml +9 -0
- package/tests/benchmark/fixtures/EcommerceComponents.ts +283 -0
- package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +301 -0
- package/tests/benchmark/fixtures/RelationTracker.ts +159 -0
- package/tests/benchmark/fixtures/index.ts +6 -0
- package/tests/benchmark/index.ts +22 -0
- package/tests/benchmark/noop-preload.ts +3 -0
- package/tests/benchmark/runners/BenchmarkLoader.ts +132 -0
- package/tests/benchmark/runners/index.ts +4 -0
- package/tests/benchmark/scenarios/query-benchmarks.test.ts +465 -0
- package/tests/benchmark/scripts/generate-db.ts +344 -0
- package/tests/benchmark/scripts/run-benchmarks.ts +97 -0
- package/tests/integration/query/Query.complexAnalysis.test.ts +557 -0
- package/tests/integration/query/Query.explainAnalyze.test.ts +233 -0
- package/tests/stress/fixtures/RealisticComponents.ts +235 -0
- package/tests/stress/scenarios/realistic-scenarios.test.ts +1081 -0
- package/tests/stress/scenarios/timeout-investigation.test.ts +522 -0
- package/tests/unit/BatchLoader.test.ts +139 -25
package/config/cache.config.ts
CHANGED
package/core/ArcheType.ts
CHANGED
|
@@ -1323,16 +1323,29 @@ export class BaseArcheType {
|
|
|
1323
1323
|
}
|
|
1324
1324
|
}
|
|
1325
1325
|
} else {
|
|
1326
|
+
// OPTIMIZED: Find candidate components first, then load in parallel
|
|
1327
|
+
const candidateComponents: Array<{ compCtor: any }> = [];
|
|
1326
1328
|
for (const compCtor of Object.values(this.componentMap)) {
|
|
1327
1329
|
const typeId = storage.getComponentId(compCtor.name);
|
|
1328
1330
|
const componentProps = storage.getComponentProperties(typeId);
|
|
1329
1331
|
const hasForeignKey = componentProps.some(prop => prop.propertyKey === foreignKey);
|
|
1330
|
-
if (
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1332
|
+
if (hasForeignKey) {
|
|
1333
|
+
candidateComponents.push({ compCtor });
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
if (candidateComponents.length > 0) {
|
|
1338
|
+
// Load all candidate components in parallel
|
|
1339
|
+
const componentInstances = await Promise.all(
|
|
1340
|
+
candidateComponents.map(({ compCtor }) => entity.get(compCtor as any))
|
|
1341
|
+
);
|
|
1342
|
+
|
|
1343
|
+
// Find the first one with the foreign key value
|
|
1344
|
+
for (const componentInstance of componentInstances) {
|
|
1345
|
+
if (componentInstance && (componentInstance as any)[foreignKey] !== undefined) {
|
|
1346
|
+
foreignId = (componentInstance as any)[foreignKey];
|
|
1347
|
+
break;
|
|
1348
|
+
}
|
|
1336
1349
|
}
|
|
1337
1350
|
}
|
|
1338
1351
|
}
|
|
@@ -1392,20 +1405,14 @@ export class BaseArcheType {
|
|
|
1392
1405
|
}
|
|
1393
1406
|
|
|
1394
1407
|
if (foreignKeyComponent) {
|
|
1395
|
-
// Query
|
|
1396
|
-
|
|
1397
|
-
|
|
1408
|
+
// OPTIMIZED: Use Query with filter instead of fetching all + filtering in JS
|
|
1409
|
+
// This pushes the filtering to the database, avoiding N+1 queries
|
|
1410
|
+
const matchingEntities = await new Query()
|
|
1411
|
+
.with(foreignKeyComponent, {
|
|
1412
|
+
filters: [{ field: foreignKey, operator: '=', value: entity.id }]
|
|
1413
|
+
})
|
|
1398
1414
|
.exec();
|
|
1399
1415
|
|
|
1400
|
-
// Filter entities that reference this entity
|
|
1401
|
-
const matchingEntities: Entity[] = [];
|
|
1402
|
-
for (const relatedEntity of relatedEntities) {
|
|
1403
|
-
const componentInstance = await relatedEntity.get(foreignKeyComponent);
|
|
1404
|
-
if (componentInstance && (componentInstance as any)[foreignKey] === entity.id) {
|
|
1405
|
-
matchingEntities.push(relatedEntity);
|
|
1406
|
-
}
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
1416
|
// Attach as computed property
|
|
1410
1417
|
(entity as any)[fieldName] = matchingEntities;
|
|
1411
1418
|
}
|
|
@@ -2037,21 +2044,34 @@ export class BaseArcheType {
|
|
|
2037
2044
|
}
|
|
2038
2045
|
}
|
|
2039
2046
|
} else {
|
|
2040
|
-
//
|
|
2047
|
+
// OPTIMIZED: Load all candidate components in parallel via DataLoader
|
|
2048
|
+
const candidateLoads: Array<{ compCtor: any; typeId: string }> = [];
|
|
2041
2049
|
for (const [componentField, compCtor] of Object.entries(this.componentMap)) {
|
|
2042
2050
|
const typeIdForComponent = storage.getComponentId(compCtor.name);
|
|
2043
2051
|
const componentProps = storage.getComponentProperties(typeIdForComponent);
|
|
2044
2052
|
const hasForeignKey = componentProps.some(prop => prop.propertyKey === foreignKey);
|
|
2045
|
-
if (
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
typeId: typeIdForComponent,
|
|
2050
|
-
});
|
|
2053
|
+
if (hasForeignKey && foreignKey) {
|
|
2054
|
+
candidateLoads.push({ compCtor, typeId: typeIdForComponent });
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2051
2057
|
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2058
|
+
if (candidateLoads.length > 0) {
|
|
2059
|
+
// Load all candidate components in parallel
|
|
2060
|
+
const componentDataResults = await Promise.all(
|
|
2061
|
+
candidateLoads.map(({ typeId }) =>
|
|
2062
|
+
context.loaders.componentsByEntityType.load({
|
|
2063
|
+
entityId: entityId,
|
|
2064
|
+
typeId: typeId,
|
|
2065
|
+
})
|
|
2066
|
+
)
|
|
2067
|
+
);
|
|
2068
|
+
|
|
2069
|
+
// Find the first one with the foreign key value
|
|
2070
|
+
for (const componentData of componentDataResults) {
|
|
2071
|
+
if (componentData?.data && componentData.data[foreignKey] !== undefined) {
|
|
2072
|
+
foreignId = componentData.data[foreignKey];
|
|
2073
|
+
break;
|
|
2074
|
+
}
|
|
2055
2075
|
}
|
|
2056
2076
|
}
|
|
2057
2077
|
}
|
|
@@ -2072,16 +2092,29 @@ export class BaseArcheType {
|
|
|
2072
2092
|
}
|
|
2073
2093
|
}
|
|
2074
2094
|
} else {
|
|
2075
|
-
//
|
|
2095
|
+
// OPTIMIZED: Find candidates first, then load in parallel
|
|
2096
|
+
const candidateComponents: Array<{ compCtor: any }> = [];
|
|
2076
2097
|
for (const compCtor of Object.values(this.componentMap)) {
|
|
2077
2098
|
const typeIdForComponent = storage.getComponentId(compCtor.name);
|
|
2078
2099
|
const componentProps = storage.getComponentProperties(typeIdForComponent);
|
|
2079
2100
|
const hasForeignKey = componentProps.some(prop => prop.propertyKey === foreignKey);
|
|
2080
|
-
if (
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2101
|
+
if (hasForeignKey && foreignKey) {
|
|
2102
|
+
candidateComponents.push({ compCtor });
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
if (candidateComponents.length > 0) {
|
|
2107
|
+
// Load all candidate components in parallel
|
|
2108
|
+
const componentInstances = await Promise.all(
|
|
2109
|
+
candidateComponents.map(({ compCtor }) => entity.get(compCtor as any))
|
|
2110
|
+
);
|
|
2111
|
+
|
|
2112
|
+
// Find the first one with the foreign key value
|
|
2113
|
+
for (const componentInstance of componentInstances) {
|
|
2114
|
+
if (componentInstance && (componentInstance as any)[foreignKey] !== undefined) {
|
|
2115
|
+
foreignId = (componentInstance as any)[foreignKey];
|
|
2116
|
+
break;
|
|
2117
|
+
}
|
|
2085
2118
|
}
|
|
2086
2119
|
}
|
|
2087
2120
|
}
|
package/core/BatchLoader.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { sql } from "bun";
|
|
|
7
7
|
interface CachedRelation {
|
|
8
8
|
ids: string[];
|
|
9
9
|
expiresAt: number;
|
|
10
|
+
lastAccessed: number;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
interface BatchLoaderOptions {
|
|
@@ -16,11 +17,192 @@ interface BatchLoaderOptions {
|
|
|
16
17
|
cacheTTL?: number;
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
interface BatchLoaderConfig {
|
|
21
|
+
maxCacheEntries: number;
|
|
22
|
+
maxCacheTypes: number;
|
|
23
|
+
evictionBatchSize: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* LRU-bounded cache for relation lookups.
|
|
28
|
+
* Prevents unbounded memory growth under high cardinality.
|
|
29
|
+
*/
|
|
30
|
+
class BoundedRelationCache {
|
|
31
|
+
private cache = new Map<string, Map<string, CachedRelation>>();
|
|
32
|
+
private accessOrder: string[] = []; // Tracks global access order for LRU eviction
|
|
33
|
+
private totalEntries = 0;
|
|
34
|
+
private config: BatchLoaderConfig;
|
|
35
|
+
|
|
36
|
+
constructor(config: BatchLoaderConfig) {
|
|
37
|
+
this.config = config;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getTypeCache(cacheKey: string): Map<string, CachedRelation> {
|
|
41
|
+
let typeCache = this.cache.get(cacheKey);
|
|
42
|
+
if (!typeCache) {
|
|
43
|
+
// Evict oldest type caches if at limit
|
|
44
|
+
if (this.cache.size >= this.config.maxCacheTypes) {
|
|
45
|
+
this.evictOldestType();
|
|
46
|
+
}
|
|
47
|
+
typeCache = new Map();
|
|
48
|
+
this.cache.set(cacheKey, typeCache);
|
|
49
|
+
}
|
|
50
|
+
return typeCache;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get(cacheKey: string, parentId: string): CachedRelation | undefined {
|
|
54
|
+
const typeCache = this.cache.get(cacheKey);
|
|
55
|
+
if (!typeCache) return undefined;
|
|
56
|
+
|
|
57
|
+
const entry = typeCache.get(parentId);
|
|
58
|
+
if (entry) {
|
|
59
|
+
// Update access time for LRU
|
|
60
|
+
entry.lastAccessed = Date.now();
|
|
61
|
+
}
|
|
62
|
+
return entry;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
set(cacheKey: string, parentId: string, entry: CachedRelation): void {
|
|
66
|
+
const typeCache = this.getTypeCache(cacheKey);
|
|
67
|
+
|
|
68
|
+
// Evict if at capacity before adding new entries
|
|
69
|
+
if (this.totalEntries >= this.config.maxCacheEntries && !typeCache.has(parentId)) {
|
|
70
|
+
this.evictLRUEntries(this.config.evictionBatchSize);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!typeCache.has(parentId)) {
|
|
74
|
+
this.totalEntries++;
|
|
75
|
+
}
|
|
76
|
+
typeCache.set(parentId, entry);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
delete(cacheKey: string, parentId: string): boolean {
|
|
80
|
+
const typeCache = this.cache.get(cacheKey);
|
|
81
|
+
if (!typeCache) return false;
|
|
82
|
+
|
|
83
|
+
const existed = typeCache.delete(parentId);
|
|
84
|
+
if (existed) {
|
|
85
|
+
this.totalEntries--;
|
|
86
|
+
}
|
|
87
|
+
return existed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
clear(): void {
|
|
91
|
+
this.cache.clear();
|
|
92
|
+
this.accessOrder = [];
|
|
93
|
+
this.totalEntries = 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getStats(): { types: number; entries: number; expired: number; memoryEstimate: string } {
|
|
97
|
+
let expiredEntries = 0;
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
|
|
100
|
+
for (const [, typeCache] of this.cache) {
|
|
101
|
+
for (const [, entry] of typeCache) {
|
|
102
|
+
if (now > entry.expiresAt) {
|
|
103
|
+
expiredEntries++;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Rough memory estimate: ~100 bytes per entry (UUID strings + overhead)
|
|
109
|
+
const memoryBytes = this.totalEntries * 100;
|
|
110
|
+
const memoryEstimate = memoryBytes > 1024 * 1024
|
|
111
|
+
? `${(memoryBytes / (1024 * 1024)).toFixed(2)} MB`
|
|
112
|
+
: `${(memoryBytes / 1024).toFixed(2)} KB`;
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
types: this.cache.size,
|
|
116
|
+
entries: this.totalEntries,
|
|
117
|
+
expired: expiredEntries,
|
|
118
|
+
memoryEstimate
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private evictOldestType(): void {
|
|
123
|
+
// Find the type cache with oldest average access time
|
|
124
|
+
let oldestKey: string | null = null;
|
|
125
|
+
let oldestAvgAccess = Infinity;
|
|
126
|
+
|
|
127
|
+
for (const [key, typeCache] of this.cache) {
|
|
128
|
+
let totalAccess = 0;
|
|
129
|
+
let count = 0;
|
|
130
|
+
for (const [, entry] of typeCache) {
|
|
131
|
+
totalAccess += entry.lastAccessed;
|
|
132
|
+
count++;
|
|
133
|
+
}
|
|
134
|
+
const avgAccess = count > 0 ? totalAccess / count : 0;
|
|
135
|
+
if (avgAccess < oldestAvgAccess) {
|
|
136
|
+
oldestAvgAccess = avgAccess;
|
|
137
|
+
oldestKey = key;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (oldestKey) {
|
|
142
|
+
const evictedCache = this.cache.get(oldestKey);
|
|
143
|
+
if (evictedCache) {
|
|
144
|
+
this.totalEntries -= evictedCache.size;
|
|
145
|
+
}
|
|
146
|
+
this.cache.delete(oldestKey);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private evictLRUEntries(count: number): void {
|
|
151
|
+
// Collect all entries with their access times
|
|
152
|
+
const entries: Array<{ cacheKey: string; parentId: string; lastAccessed: number }> = [];
|
|
153
|
+
|
|
154
|
+
for (const [cacheKey, typeCache] of this.cache) {
|
|
155
|
+
for (const [parentId, entry] of typeCache) {
|
|
156
|
+
entries.push({ cacheKey, parentId, lastAccessed: entry.lastAccessed });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Sort by lastAccessed ascending (oldest first)
|
|
161
|
+
entries.sort((a, b) => a.lastAccessed - b.lastAccessed);
|
|
162
|
+
|
|
163
|
+
// Evict the oldest entries
|
|
164
|
+
const toEvict = entries.slice(0, count);
|
|
165
|
+
for (const { cacheKey, parentId } of toEvict) {
|
|
166
|
+
this.delete(cacheKey, parentId);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Prune expired entries. Call periodically to reclaim memory.
|
|
172
|
+
*/
|
|
173
|
+
pruneExpired(): number {
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
let prunedCount = 0;
|
|
176
|
+
|
|
177
|
+
for (const [cacheKey, typeCache] of this.cache) {
|
|
178
|
+
for (const [parentId, entry] of typeCache) {
|
|
179
|
+
if (now > entry.expiresAt) {
|
|
180
|
+
typeCache.delete(parentId);
|
|
181
|
+
this.totalEntries--;
|
|
182
|
+
prunedCount++;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Remove empty type caches
|
|
186
|
+
if (typeCache.size === 0) {
|
|
187
|
+
this.cache.delete(cacheKey);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return prunedCount;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
19
195
|
export class BatchLoader {
|
|
20
|
-
private static cache = new
|
|
196
|
+
private static cache = new BoundedRelationCache({
|
|
197
|
+
maxCacheEntries: 100_000, // Max 100k relation entries (~10MB)
|
|
198
|
+
maxCacheTypes: 500, // Max 500 different component types
|
|
199
|
+
evictionBatchSize: 1000 // Evict 1000 entries at a time
|
|
200
|
+
});
|
|
21
201
|
private static readonly DEFAULT_BATCH_SIZE = 1000;
|
|
22
202
|
private static readonly DEFAULT_MAX_CONCURRENCY = 5;
|
|
23
203
|
private static readonly DEFAULT_CACHE_TTL = 300_000; // 5 minutes
|
|
204
|
+
private static lastPruneTime = 0;
|
|
205
|
+
private static readonly PRUNE_INTERVAL = 60_000; // Prune every minute
|
|
24
206
|
|
|
25
207
|
/**
|
|
26
208
|
* Load related entities efficiently with caching and batching
|
|
@@ -34,6 +216,13 @@ export class BatchLoader {
|
|
|
34
216
|
): Promise<Map<string, Entity>> {
|
|
35
217
|
if (entities.length === 0) return new Map();
|
|
36
218
|
|
|
219
|
+
// Periodic pruning of expired entries
|
|
220
|
+
const now = Date.now();
|
|
221
|
+
if (now - this.lastPruneTime > this.PRUNE_INTERVAL) {
|
|
222
|
+
this.cache.pruneExpired();
|
|
223
|
+
this.lastPruneTime = now;
|
|
224
|
+
}
|
|
225
|
+
|
|
37
226
|
const {
|
|
38
227
|
fieldName = 'value',
|
|
39
228
|
batchSize = this.DEFAULT_BATCH_SIZE,
|
|
@@ -45,22 +234,15 @@ export class BatchLoader {
|
|
|
45
234
|
const typeId = comp.getTypeID();
|
|
46
235
|
const parentIds = entities.map(e => e.id);
|
|
47
236
|
|
|
48
|
-
//
|
|
49
|
-
const cacheKey = `${typeId}
|
|
50
|
-
let cachedResults = this.cache.get(cacheKey);
|
|
51
|
-
if (!cachedResults) {
|
|
52
|
-
cachedResults = new Map();
|
|
53
|
-
this.cache.set(cacheKey, cachedResults);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const now = Date.now();
|
|
237
|
+
// Cache key uses null byte separator to prevent collision
|
|
238
|
+
const cacheKey = `${typeId}\x00${fieldName}`;
|
|
57
239
|
|
|
58
240
|
// Get uncached or expired parent IDs
|
|
59
241
|
const uncachedParentIds = parentIds.filter(id => {
|
|
60
|
-
const entry =
|
|
242
|
+
const entry = this.cache.get(cacheKey, id);
|
|
61
243
|
if (!entry) return true;
|
|
62
244
|
if (now > entry.expiresAt) {
|
|
63
|
-
|
|
245
|
+
this.cache.delete(cacheKey, id);
|
|
64
246
|
return true;
|
|
65
247
|
}
|
|
66
248
|
return false;
|
|
@@ -92,16 +274,17 @@ export class BatchLoader {
|
|
|
92
274
|
}
|
|
93
275
|
|
|
94
276
|
const expiresAt = Date.now() + cacheTTL;
|
|
277
|
+
const lastAccessed = Date.now();
|
|
95
278
|
|
|
96
279
|
// Cache the related IDs for each parent
|
|
97
280
|
for (const [parentId, relatedIds] of parentGroups) {
|
|
98
|
-
|
|
281
|
+
this.cache.set(cacheKey, parentId, { ids: relatedIds, expiresAt, lastAccessed });
|
|
99
282
|
}
|
|
100
283
|
|
|
101
284
|
// Cache empty arrays for parents with no relations
|
|
102
285
|
for (const parentId of batch) {
|
|
103
286
|
if (!parentGroups.has(parentId)) {
|
|
104
|
-
|
|
287
|
+
this.cache.set(cacheKey, parentId, { ids: [], expiresAt, lastAccessed });
|
|
105
288
|
}
|
|
106
289
|
}
|
|
107
290
|
}
|
|
@@ -110,7 +293,7 @@ export class BatchLoader {
|
|
|
110
293
|
// Collect all unique related IDs from cache
|
|
111
294
|
const allRelatedIds = new Set<string>();
|
|
112
295
|
for (const parentId of parentIds) {
|
|
113
|
-
const entry =
|
|
296
|
+
const entry = this.cache.get(cacheKey, parentId);
|
|
114
297
|
const relatedIds = entry?.ids || [];
|
|
115
298
|
relatedIds.forEach((id: string) => allRelatedIds.add(id));
|
|
116
299
|
}
|
|
@@ -151,21 +334,23 @@ export class BatchLoader {
|
|
|
151
334
|
}
|
|
152
335
|
|
|
153
336
|
/**
|
|
154
|
-
* Get cache statistics including
|
|
337
|
+
* Get cache statistics including memory estimate
|
|
155
338
|
*/
|
|
156
|
-
static getCacheStats(): { size: number; entries: number; expired: number } {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
339
|
+
static getCacheStats(): { size: number; entries: number; expired: number; memoryEstimate?: string } {
|
|
340
|
+
const stats = this.cache.getStats();
|
|
341
|
+
return {
|
|
342
|
+
size: stats.types,
|
|
343
|
+
entries: stats.entries,
|
|
344
|
+
expired: stats.expired,
|
|
345
|
+
memoryEstimate: stats.memoryEstimate
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Manually prune expired entries. Returns count of pruned entries.
|
|
351
|
+
*/
|
|
352
|
+
static pruneExpiredEntries(): number {
|
|
353
|
+
return this.cache.pruneExpired();
|
|
169
354
|
}
|
|
170
355
|
|
|
171
356
|
private static chunkArray<T>(array: T[], size: number): T[][] {
|
|
@@ -207,4 +392,4 @@ class Semaphore {
|
|
|
207
392
|
resolve();
|
|
208
393
|
}
|
|
209
394
|
}
|
|
210
|
-
}
|
|
395
|
+
}
|
package/core/Entity.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ComponentDataType, ComponentGetter, BaseComponent } from "./components";
|
|
2
2
|
import { logger } from "./Logger";
|
|
3
|
-
import db from "../database";
|
|
3
|
+
import db, { QUERY_TIMEOUT_MS } from "../database";
|
|
4
4
|
import EntityManager from "./EntityManager";
|
|
5
5
|
import ComponentRegistry from "./components/ComponentRegistry";
|
|
6
6
|
import { uuidv7 } from "../utils/uuid";
|
|
@@ -389,7 +389,7 @@ export class Entity implements IEntity {
|
|
|
389
389
|
const timeout = setTimeout(() => {
|
|
390
390
|
logger.error(`Entity save timeout for entity ${this.id}`);
|
|
391
391
|
reject(new Error(`Entity save timeout for entity ${this.id}`));
|
|
392
|
-
},
|
|
392
|
+
}, QUERY_TIMEOUT_MS); // Configurable timeout via DB_QUERY_TIMEOUT env var
|
|
393
393
|
|
|
394
394
|
// Capture dirty components BEFORE doSave clears the dirty flags
|
|
395
395
|
const changedComponentTypeIds = this.getDirtyComponents();
|
package/core/RequestContext.ts
CHANGED
|
@@ -6,23 +6,28 @@ import { CacheManager } from './cache/CacheManager';
|
|
|
6
6
|
|
|
7
7
|
declare module 'graphql-yoga' {
|
|
8
8
|
interface Context {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
};
|
|
9
|
+
// Loaders mounted at top-level context for ArcheType resolver access
|
|
10
|
+
loaders: RequestLoaders;
|
|
11
|
+
requestId: string;
|
|
12
|
+
cacheManager: CacheManager;
|
|
14
13
|
}
|
|
15
14
|
}
|
|
16
15
|
|
|
16
|
+
/**
|
|
17
|
+
* GraphQL Yoga plugin that creates per-request DataLoaders for batching.
|
|
18
|
+
*
|
|
19
|
+
* IMPORTANT: Loaders are mounted at context.loaders (NOT context.locals.loaders)
|
|
20
|
+
* to match what ArcheType.ts resolvers expect. This enables DataLoader batching
|
|
21
|
+
* for BelongsTo/HasMany relations, preventing N+1 queries.
|
|
22
|
+
*/
|
|
17
23
|
export function createRequestContextPlugin(): Plugin {
|
|
18
24
|
return {
|
|
19
25
|
onExecute: ({ args }) => {
|
|
20
26
|
const cacheManager = CacheManager.getInstance();
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
};
|
|
27
|
+
// Mount loaders at context.loaders to match ArcheType.ts resolver access pattern
|
|
28
|
+
(args as any).contextValue.loaders = createRequestLoaders(db, cacheManager);
|
|
29
|
+
(args as any).contextValue.requestId = crypto.randomUUID();
|
|
30
|
+
(args as any).contextValue.cacheManager = cacheManager;
|
|
26
31
|
},
|
|
27
32
|
};
|
|
28
33
|
}
|
package/core/RequestLoaders.ts
CHANGED
|
@@ -273,7 +273,8 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
273
273
|
return entity;
|
|
274
274
|
});
|
|
275
275
|
|
|
276
|
-
|
|
276
|
+
// Use null byte separator to prevent key collision when fields contain hyphens
|
|
277
|
+
const mapKey = `${key.entityId}\x00${key.relationField}\x00${key.relatedType}`;
|
|
277
278
|
resultMap.set(mapKey, entities);
|
|
278
279
|
|
|
279
280
|
logger.trace(`[RelationLoader] Mapped ${entities.length} entities for ${key.relationField} on ${key.entityId}`);
|
|
@@ -291,7 +292,8 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
291
292
|
if (!k.entityId || typeof k.entityId !== 'string' || k.entityId.trim() === '') {
|
|
292
293
|
return [];
|
|
293
294
|
}
|
|
294
|
-
|
|
295
|
+
// Use null byte separator to prevent key collision when fields contain hyphens
|
|
296
|
+
const mapKey = `${k.entityId}\x00${k.relationField}\x00${k.relatedType}`;
|
|
295
297
|
const result = resultMap.get(mapKey) || [];
|
|
296
298
|
return result;
|
|
297
299
|
});
|
|
@@ -66,7 +66,9 @@ export class CacheFactory {
|
|
|
66
66
|
retryStrategy: config.redis.retryStrategy,
|
|
67
67
|
maxRetriesPerRequest: 3,
|
|
68
68
|
lazyConnect: false,
|
|
69
|
-
enableReadyCheck: true
|
|
69
|
+
enableReadyCheck: true,
|
|
70
|
+
connectTimeout: config.redis.connectTimeout,
|
|
71
|
+
commandTimeout: config.redis.commandTimeout
|
|
70
72
|
};
|
|
71
73
|
|
|
72
74
|
const { password: _pw, ...safeConfig } = redisConfig;
|