bunsane 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +445 -318
- package/config/cache.config.ts +35 -1
- package/core/App.ts +24 -1064
- package/core/ArcheType.ts +78 -2110
- package/core/BatchLoader.ts +56 -32
- package/core/Entity.ts +85 -1043
- package/core/EntityHookManager.ts +52 -754
- package/core/Logger.ts +10 -0
- package/core/RequestContext.ts +64 -6
- package/core/RequestLoaders.ts +187 -36
- package/core/SchedulerManager.ts +28 -600
- package/core/app/bootstrap.ts +133 -0
- package/core/app/cors.ts +85 -0
- package/core/app/graphqlSetup.ts +56 -0
- package/core/app/healthEndpoints.ts +31 -0
- package/core/app/metricsCollector.ts +27 -0
- package/core/app/preparedStatementWarmup.ts +15 -0
- package/core/app/processHandlers.ts +43 -0
- package/core/app/requestRouter.ts +310 -0
- package/core/app/restRegistry.ts +80 -0
- package/core/app/shutdown.ts +97 -0
- package/core/app/studioRouter.ts +83 -0
- package/core/archetype/customTypes.ts +100 -0
- package/core/archetype/decorators.ts +171 -0
- package/core/archetype/fieldResolvers.ts +666 -0
- package/core/archetype/helpers.ts +29 -0
- package/core/archetype/relationLoader.ts +161 -0
- package/core/archetype/schemaBuilder.ts +141 -0
- package/core/archetype/weaver.ts +218 -0
- package/core/archetype/zodSchemaBuilder.ts +527 -0
- package/core/cache/CacheManager.ts +173 -267
- package/core/cache/CompressionUtils.ts +34 -3
- package/core/cache/MemoryCache.ts +40 -37
- package/core/cache/RedisCache.ts +4 -4
- package/core/cache/health.ts +30 -0
- package/core/cache/invalidation.ts +96 -0
- package/core/cache/strategies/writeInvalidate.ts +111 -0
- package/core/cache/strategies/writeThrough.ts +233 -0
- package/core/components/BaseComponent.ts +16 -8
- package/core/components/ComponentRegistry.ts +28 -0
- package/core/decorators/IndexedField.ts +1 -1
- package/core/entity/cacheStrategies.ts +97 -0
- package/core/entity/componentAccess.ts +364 -0
- package/core/entity/finders.ts +202 -0
- package/core/entity/pendingOps.ts +72 -0
- package/core/entity/saveEntity.ts +377 -0
- package/core/hooks/dispatcher.ts +439 -0
- package/core/hooks/guards.ts +155 -0
- package/core/hooks/registry.ts +247 -0
- package/core/metadata/definitions/Component.ts +1 -1
- package/core/metadata/index.ts +15 -4
- package/core/middleware/AccessLog.ts +8 -1
- package/core/middleware/RateLimit.ts +102 -105
- package/core/middleware/RequestId.ts +2 -9
- package/core/middleware/SecurityHeaders.ts +2 -11
- package/core/middleware/headers.ts +28 -0
- package/core/remote/OutboxWorker.ts +213 -183
- package/core/remote/RemoteManager.ts +401 -400
- package/core/remote/types.ts +153 -151
- package/core/requestScope.ts +34 -0
- package/core/scheduler/cronEvaluator.ts +174 -0
- package/core/scheduler/lifecycleHooks.ts +21 -0
- package/core/scheduler/lockCoordinator.ts +27 -0
- package/core/scheduler/metrics.ts +14 -0
- package/core/scheduler/taskRunner.ts +420 -0
- package/database/DatabaseHelper.ts +128 -101
- package/database/IndexingStrategy.ts +72 -2
- package/database/PreparedStatementCache.ts +20 -5
- package/database/cancellable.ts +35 -0
- package/database/index.ts +15 -3
- package/database/instrumentedDb.ts +141 -0
- package/endpoints/archetypes.ts +2 -8
- package/endpoints/tables.ts +6 -1
- package/gql/index.ts +1 -1
- package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
- package/package.json +22 -1
- package/query/CTENode.ts +5 -3
- package/query/ComponentInclusionNode.ts +240 -13
- package/query/OrNode.ts +6 -5
- package/query/Query.ts +203 -59
- package/query/QueryContext.ts +6 -0
- package/query/QueryDAG.ts +7 -2
- package/query/membershipSource.ts +66 -0
- package/storage/LocalStorageProvider.ts +8 -3
- package/studio/dist/assets/index-BMZ67Npg.js +254 -0
- package/studio/dist/assets/index-BpbuYz9g.css +1 -0
- package/studio/{index.html → dist/index.html} +3 -2
- package/swagger/generator.ts +11 -1
- package/upload/UploadManager.ts +8 -6
- package/utils/uuid.ts +40 -10
- package/.claude/settings.local.json +0 -47
- package/.prettierrc +0 -4
- package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
- package/.serena/memories/architecture.md +0 -154
- package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
- package/.serena/memories/code_style_and_conventions.md +0 -76
- package/.serena/memories/project_overview.md +0 -43
- package/.serena/memories/schema-dsl-plan.md +0 -107
- package/.serena/memories/suggested_commands.md +0 -80
- package/.serena/memories/typescript-compilation-status.md +0 -54
- package/.serena/project.yml +0 -114
- package/BunSane.jpg +0 -0
- package/CLAUDE.md +0 -198
- package/TODO.md +0 -2
- package/bun.lock +0 -302
- package/bunfig.toml +0 -10
- package/docs/SCALABILITY_PLAN.md +0 -175
- package/studio/bun.lock +0 -482
- package/studio/package.json +0 -39
- package/studio/postcss.config.js +0 -6
- package/studio/src/components/DataTable.tsx +0 -211
- package/studio/src/components/Layout.tsx +0 -13
- package/studio/src/components/PageContainer.tsx +0 -9
- package/studio/src/components/PageHeader.tsx +0 -13
- package/studio/src/components/SearchBar.tsx +0 -57
- package/studio/src/components/Sidebar.tsx +0 -294
- package/studio/src/components/ui/button.tsx +0 -56
- package/studio/src/components/ui/checkbox.tsx +0 -26
- package/studio/src/components/ui/input.tsx +0 -25
- package/studio/src/hooks/useDataTable.ts +0 -131
- package/studio/src/index.css +0 -36
- package/studio/src/lib/api.ts +0 -186
- package/studio/src/lib/utils.ts +0 -13
- package/studio/src/main.tsx +0 -17
- package/studio/src/pages/ArcheType.tsx +0 -239
- package/studio/src/pages/Components.tsx +0 -124
- package/studio/src/pages/EntityInspector.tsx +0 -302
- package/studio/src/pages/QueryRunner.tsx +0 -246
- package/studio/src/pages/Table.tsx +0 -94
- package/studio/src/pages/Welcome.tsx +0 -241
- package/studio/src/routes.tsx +0 -45
- package/studio/src/store/archeTypeSettings.ts +0 -30
- package/studio/src/store/studio.ts +0 -65
- package/studio/src/utils/columnHelpers.tsx +0 -114
- package/studio/studio-instructions.md +0 -81
- package/studio/tailwind.config.js +0 -77
- package/studio/utils.ts +0 -54
- package/studio/vite.config.js +0 -19
- package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
- package/tests/benchmark/bunfig.toml +0 -9
- package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
- package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
- package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
- package/tests/benchmark/fixtures/index.ts +0 -6
- package/tests/benchmark/index.ts +0 -22
- package/tests/benchmark/noop-preload.ts +0 -3
- package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
- package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
- package/tests/benchmark/runners/index.ts +0 -4
- package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
- package/tests/benchmark/scripts/generate-db.ts +0 -344
- package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
- package/tests/e2e/http.test.ts +0 -130
- package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
- package/tests/fixtures/components/TestOrder.ts +0 -23
- package/tests/fixtures/components/TestProduct.ts +0 -23
- package/tests/fixtures/components/TestUser.ts +0 -20
- package/tests/fixtures/components/index.ts +0 -6
- package/tests/graphql/SchemaGeneration.test.ts +0 -90
- package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
- package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
- package/tests/helpers/MockRedisClient.ts +0 -113
- package/tests/helpers/MockRedisStreamServer.ts +0 -448
- package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
- package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
- package/tests/integration/entity/Entity.persistence.test.ts +0 -333
- package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
- package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
- package/tests/integration/query/Query.edgeCases.test.ts +0 -595
- package/tests/integration/query/Query.exec.test.ts +0 -576
- package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
- package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
- package/tests/integration/remote/dlq.test.ts +0 -175
- package/tests/integration/remote/event-dispatch.test.ts +0 -114
- package/tests/integration/remote/outbox.test.ts +0 -130
- package/tests/integration/remote/rpc.test.ts +0 -177
- package/tests/pglite-setup.ts +0 -62
- package/tests/setup.ts +0 -164
- package/tests/stress/BenchmarkRunner.ts +0 -203
- package/tests/stress/DataSeeder.ts +0 -190
- package/tests/stress/StressTestReporter.ts +0 -229
- package/tests/stress/cursor-perf-test.ts +0 -171
- package/tests/stress/fixtures/RealisticComponents.ts +0 -235
- package/tests/stress/fixtures/StressTestComponents.ts +0 -58
- package/tests/stress/index.ts +0 -7
- package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
- package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
- package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
- package/tests/unit/BatchLoader.test.ts +0 -196
- package/tests/unit/archetype/ArcheType.test.ts +0 -107
- package/tests/unit/cache/CacheManager.test.ts +0 -367
- package/tests/unit/cache/MemoryCache.test.ts +0 -260
- package/tests/unit/cache/RedisCache.test.ts +0 -411
- package/tests/unit/entity/Entity.components.test.ts +0 -317
- package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
- package/tests/unit/entity/Entity.reload.test.ts +0 -63
- package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
- package/tests/unit/entity/Entity.test.ts +0 -345
- package/tests/unit/gql/depthLimit.test.ts +0 -203
- package/tests/unit/gql/operationMiddleware.test.ts +0 -293
- package/tests/unit/health/Health.test.ts +0 -129
- package/tests/unit/middleware/AccessLog.test.ts +0 -37
- package/tests/unit/middleware/Middleware.test.ts +0 -98
- package/tests/unit/middleware/RequestId.test.ts +0 -54
- package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
- package/tests/unit/query/FilterBuilder.test.ts +0 -111
- package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
- package/tests/unit/query/Query.emptyString.test.ts +0 -69
- package/tests/unit/query/Query.test.ts +0 -310
- package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
- package/tests/unit/remote/RemoteError.test.ts +0 -55
- package/tests/unit/remote/decorators.test.ts +0 -195
- package/tests/unit/remote/metrics.test.ts +0 -115
- package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
- package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
- package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
- package/tests/unit/schema/schema-integration.test.ts +0 -426
- package/tests/unit/schema/schema.test.ts +0 -580
- package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
- package/tests/unit/upload/RestUpload.test.ts +0 -267
- package/tests/unit/validateEnv.test.ts +0 -82
- package/tests/utils/entity-tracker.ts +0 -57
- package/tests/utils/index.ts +0 -13
- package/tests/utils/test-context.ts +0 -149
|
@@ -19,7 +19,11 @@ export class CompressionUtils {
|
|
|
19
19
|
private static readonly COMPRESSION_PREFIX = '__COMPRESSED__';
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Compresses data if it exceeds the threshold size
|
|
22
|
+
* Compresses data if it exceeds the threshold size.
|
|
23
|
+
* Returns the original value (unmodified) when below threshold, or a
|
|
24
|
+
* compressed wrapper object when above. Callers that need the final
|
|
25
|
+
* storage string should use compressForStorage() to avoid a second
|
|
26
|
+
* JSON.stringify pass.
|
|
23
27
|
*/
|
|
24
28
|
static async compress(data: any): Promise<any> {
|
|
25
29
|
try {
|
|
@@ -33,7 +37,6 @@ export class CompressionUtils {
|
|
|
33
37
|
const compressed = await gzipAsync(serialized);
|
|
34
38
|
const compressedData = compressed.toString('base64');
|
|
35
39
|
|
|
36
|
-
// Return compressed data with metadata
|
|
37
40
|
return {
|
|
38
41
|
[this.COMPRESSION_PREFIX]: true,
|
|
39
42
|
data: compressedData,
|
|
@@ -41,12 +44,40 @@ export class CompressionUtils {
|
|
|
41
44
|
compressedSize: compressed.length
|
|
42
45
|
};
|
|
43
46
|
} catch (error) {
|
|
44
|
-
// Fallback to uncompressed data on compression error
|
|
45
47
|
console.warn('Compression failed, using uncompressed data:', error);
|
|
46
48
|
return data;
|
|
47
49
|
}
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Serializes data to the final string written to Redis.
|
|
54
|
+
* Reuses the JSON.stringify pass done for size measurement so values
|
|
55
|
+
* below the compression threshold are never stringified twice.
|
|
56
|
+
* Values above the threshold are gzip-compressed and then stringified once.
|
|
57
|
+
*/
|
|
58
|
+
static async compressForStorage(data: any): Promise<string> {
|
|
59
|
+
try {
|
|
60
|
+
const serialized = JSON.stringify(data);
|
|
61
|
+
const size = Buffer.byteLength(serialized, 'utf8');
|
|
62
|
+
|
|
63
|
+
if (size <= this.COMPRESSION_THRESHOLD) {
|
|
64
|
+
return serialized;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const compressed = await gzipAsync(serialized);
|
|
68
|
+
const wrapper = {
|
|
69
|
+
[this.COMPRESSION_PREFIX]: true,
|
|
70
|
+
data: compressed.toString('base64'),
|
|
71
|
+
originalSize: size,
|
|
72
|
+
compressedSize: compressed.length,
|
|
73
|
+
};
|
|
74
|
+
return JSON.stringify(wrapper);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.warn('Compression failed, using uncompressed data:', error);
|
|
77
|
+
return JSON.stringify(data);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
50
81
|
/**
|
|
51
82
|
* Decompresses data if it was previously compressed
|
|
52
83
|
*/
|
|
@@ -12,8 +12,8 @@ function formatBytes(bytes: number): string {
|
|
|
12
12
|
interface CacheEntry<T> {
|
|
13
13
|
value: T;
|
|
14
14
|
expiresAt?: number;
|
|
15
|
-
lastAccessed: number;
|
|
16
15
|
accessCount: number;
|
|
16
|
+
size: number; // Estimated bytes (key + value + metadata), cached for O(1) memory accounting
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export interface MemoryCacheConfig {
|
|
@@ -36,7 +36,6 @@ export class MemoryCache implements CacheProvider {
|
|
|
36
36
|
size: 0,
|
|
37
37
|
memoryUsage: 0
|
|
38
38
|
};
|
|
39
|
-
private accessCounter = 0; // For LRU ordering
|
|
40
39
|
|
|
41
40
|
constructor(config: MemoryCacheConfig = {}) {
|
|
42
41
|
this.config = {
|
|
@@ -60,13 +59,15 @@ export class MemoryCache implements CacheProvider {
|
|
|
60
59
|
// Check if expired
|
|
61
60
|
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
|
62
61
|
this.cache.delete(key);
|
|
62
|
+
this.stats.size--;
|
|
63
|
+
this.stats.memoryUsage = Math.max(0, this.stats.memoryUsage - entry.size);
|
|
63
64
|
this.stats.misses++;
|
|
64
|
-
this.updateMemoryUsage();
|
|
65
65
|
return null;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
//
|
|
69
|
-
|
|
68
|
+
// Move to end of Map iteration order (most recently used) for O(1) LRU.
|
|
69
|
+
this.cache.delete(key);
|
|
70
|
+
this.cache.set(key, entry);
|
|
70
71
|
entry.accessCount++;
|
|
71
72
|
|
|
72
73
|
this.stats.hits++;
|
|
@@ -76,21 +77,27 @@ export class MemoryCache implements CacheProvider {
|
|
|
76
77
|
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
|
77
78
|
const expiresAt = ttl ? Date.now() + ttl : (this.config.defaultTTL ? Date.now() + this.config.defaultTTL : undefined);
|
|
78
79
|
|
|
80
|
+
const size = this.entrySize(key, value);
|
|
79
81
|
const entry: CacheEntry<T> = {
|
|
80
82
|
value,
|
|
81
83
|
expiresAt,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
accessCount: 1,
|
|
85
|
+
size
|
|
84
86
|
};
|
|
85
87
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
// Incremental memory accounting: adjust by the delta instead of
|
|
89
|
+
// re-walking the entire cache on every write.
|
|
90
|
+
// Delete before set so the key moves to the end of Map iteration
|
|
91
|
+
// order (most recently used position for LRU eviction).
|
|
92
|
+
const existing = this.cache.get(key);
|
|
93
|
+
if (existing) {
|
|
94
|
+
this.stats.memoryUsage = Math.max(0, this.stats.memoryUsage - existing.size);
|
|
95
|
+
this.cache.delete(key);
|
|
96
|
+
} else {
|
|
90
97
|
this.stats.size++;
|
|
91
98
|
}
|
|
92
|
-
|
|
93
|
-
this.
|
|
99
|
+
this.cache.set(key, entry);
|
|
100
|
+
this.stats.memoryUsage += size;
|
|
94
101
|
|
|
95
102
|
// Evict if necessary
|
|
96
103
|
await this.evictIfNeeded();
|
|
@@ -98,16 +105,15 @@ export class MemoryCache implements CacheProvider {
|
|
|
98
105
|
|
|
99
106
|
async delete(key: string | string[]): Promise<void> {
|
|
100
107
|
const keys = Array.isArray(key) ? key : [key];
|
|
101
|
-
let deletedCount = 0;
|
|
102
108
|
|
|
103
109
|
for (const k of keys) {
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
const entry = this.cache.get(k);
|
|
111
|
+
if (entry) {
|
|
112
|
+
this.cache.delete(k);
|
|
113
|
+
this.stats.size--;
|
|
114
|
+
this.stats.memoryUsage = Math.max(0, this.stats.memoryUsage - entry.size);
|
|
106
115
|
}
|
|
107
116
|
}
|
|
108
|
-
|
|
109
|
-
this.stats.size -= deletedCount;
|
|
110
|
-
this.updateMemoryUsage();
|
|
111
117
|
}
|
|
112
118
|
|
|
113
119
|
async clear(): Promise<void> {
|
|
@@ -144,7 +150,7 @@ export class MemoryCache implements CacheProvider {
|
|
|
144
150
|
const regex = new RegExp(pattern.replace(/\*/g, '.*').replace(/\?/g, '.'));
|
|
145
151
|
|
|
146
152
|
const keysToDelete: string[] = [];
|
|
147
|
-
for (const key of
|
|
153
|
+
for (const key of this.cache.keys()) {
|
|
148
154
|
if (regex.test(key)) {
|
|
149
155
|
keysToDelete.push(key);
|
|
150
156
|
}
|
|
@@ -171,16 +177,9 @@ export class MemoryCache implements CacheProvider {
|
|
|
171
177
|
};
|
|
172
178
|
}
|
|
173
179
|
|
|
174
|
-
private
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
let memoryUsage = 0;
|
|
178
|
-
for (const [key, entry] of Array.from(this.cache.entries())) {
|
|
179
|
-
memoryUsage += key.length * 2; // Rough string overhead
|
|
180
|
-
memoryUsage += this.estimateValueSize(entry.value);
|
|
181
|
-
memoryUsage += 100; // Overhead for entry metadata
|
|
182
|
-
}
|
|
183
|
-
this.stats.memoryUsage = memoryUsage;
|
|
180
|
+
private entrySize(key: string, value: any): number {
|
|
181
|
+
// key string overhead + value estimate + fixed per-entry metadata
|
|
182
|
+
return key.length * 2 + this.estimateValueSize(value) + 100;
|
|
184
183
|
}
|
|
185
184
|
|
|
186
185
|
private estimateValueSize(value: any): number {
|
|
@@ -214,14 +213,16 @@ export class MemoryCache implements CacheProvider {
|
|
|
214
213
|
}
|
|
215
214
|
|
|
216
215
|
private async evictLRU(count: number): Promise<void> {
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
216
|
+
// Map iteration order is insertion order. get() and set() move accessed/
|
|
217
|
+
// updated keys to the end, so keys at the front are least recently used.
|
|
218
|
+
// Collect the first `count` keys without sorting or materialising a full array.
|
|
219
|
+
const keysToDelete: string[] = [];
|
|
220
|
+
for (const key of this.cache.keys()) {
|
|
221
|
+
if (keysToDelete.length >= count) break;
|
|
222
|
+
keysToDelete.push(key);
|
|
223
|
+
}
|
|
221
224
|
|
|
222
|
-
const keysToDelete = entries.slice(0, count).map(item => item.key);
|
|
223
225
|
await this.delete(keysToDelete);
|
|
224
|
-
|
|
225
226
|
logger.debug(`Evicted ${keysToDelete.length} entries from cache due to LRU policy`);
|
|
226
227
|
}
|
|
227
228
|
|
|
@@ -229,13 +230,15 @@ export class MemoryCache implements CacheProvider {
|
|
|
229
230
|
this.cleanupTimer = setInterval(() => {
|
|
230
231
|
this.cleanupExpired();
|
|
231
232
|
}, this.config.cleanupInterval);
|
|
233
|
+
// Allow the process to exit without waiting for this maintenance timer.
|
|
234
|
+
this.cleanupTimer?.unref?.();
|
|
232
235
|
}
|
|
233
236
|
|
|
234
237
|
private cleanupExpired(): void {
|
|
235
238
|
const now = Date.now();
|
|
236
239
|
const keysToDelete: string[] = [];
|
|
237
240
|
|
|
238
|
-
for (const [key, entry] of
|
|
241
|
+
for (const [key, entry] of this.cache) {
|
|
239
242
|
if (entry.expiresAt && now > entry.expiresAt) {
|
|
240
243
|
keysToDelete.push(key);
|
|
241
244
|
}
|
package/core/cache/RedisCache.ts
CHANGED
|
@@ -161,6 +161,8 @@ export class RedisCache implements CacheProvider {
|
|
|
161
161
|
logger.error({ error, msg: 'Failed to get Redis memory info' });
|
|
162
162
|
}
|
|
163
163
|
}, 300000); // 5 minutes
|
|
164
|
+
// Allow the process to exit without waiting for this monitoring timer.
|
|
165
|
+
this.monitoringInterval?.unref?.();
|
|
164
166
|
}
|
|
165
167
|
|
|
166
168
|
/**
|
|
@@ -192,8 +194,7 @@ export class RedisCache implements CacheProvider {
|
|
|
192
194
|
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
|
193
195
|
try {
|
|
194
196
|
const prefixedKey = this.prefixKey(key);
|
|
195
|
-
const
|
|
196
|
-
const serializedValue = JSON.stringify(compressedValue);
|
|
197
|
+
const serializedValue = await CompressionUtils.compressForStorage(value);
|
|
197
198
|
|
|
198
199
|
if (ttl) {
|
|
199
200
|
await this.client.setex(prefixedKey, Math.floor(ttl / 1000), serializedValue);
|
|
@@ -273,8 +274,7 @@ export class RedisCache implements CacheProvider {
|
|
|
273
274
|
|
|
274
275
|
for (const entry of entries) {
|
|
275
276
|
const prefixedKey = this.prefixKey(entry.key);
|
|
276
|
-
const
|
|
277
|
-
const serializedValue = JSON.stringify(compressedValue);
|
|
277
|
+
const serializedValue = await CompressionUtils.compressForStorage(entry.value);
|
|
278
278
|
|
|
279
279
|
if (entry.ttl) {
|
|
280
280
|
pipeline.setex(prefixedKey, Math.floor(entry.ttl / 1000), serializedValue);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type CacheProvider } from './CacheProvider';
|
|
2
|
+
import { logger } from '../Logger';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Health check operations: ping and getStats.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export async function getStats(provider: CacheProvider) {
|
|
9
|
+
try {
|
|
10
|
+
return await provider.getStats();
|
|
11
|
+
} catch (error) {
|
|
12
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error getting cache stats', error });
|
|
13
|
+
return {
|
|
14
|
+
hits: 0,
|
|
15
|
+
misses: 0,
|
|
16
|
+
hitRate: 0,
|
|
17
|
+
size: 0,
|
|
18
|
+
memoryUsage: 0
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function ping(provider: CacheProvider): Promise<boolean> {
|
|
24
|
+
try {
|
|
25
|
+
return await provider.ping();
|
|
26
|
+
} catch (error) {
|
|
27
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Cache ping failed', error });
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type CacheProvider } from './CacheProvider';
|
|
2
|
+
import { MultiLevelCache } from './MultiLevelCache';
|
|
3
|
+
import { RedisCache } from './RedisCache';
|
|
4
|
+
import { logger } from '../Logger';
|
|
5
|
+
|
|
6
|
+
export interface InvalidationMessage {
|
|
7
|
+
instanceId: string;
|
|
8
|
+
type: 'key' | 'pattern';
|
|
9
|
+
keys?: string[];
|
|
10
|
+
pattern?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const INVALIDATION_CHANNEL = 'bunsane:cache:invalidate';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Setup pub/sub for cross-instance cache invalidation.
|
|
17
|
+
* Only activates when using MultiLevel provider with a Redis L2.
|
|
18
|
+
* Returns true if pub/sub was successfully enabled.
|
|
19
|
+
*/
|
|
20
|
+
export async function setupPubSub(
|
|
21
|
+
provider: CacheProvider,
|
|
22
|
+
instanceId: string,
|
|
23
|
+
handleRemoteInvalidation: (raw: string) => Promise<void>
|
|
24
|
+
): Promise<boolean> {
|
|
25
|
+
if (!(provider instanceof MultiLevelCache)) return false;
|
|
26
|
+
|
|
27
|
+
const l2 = provider.getL2Cache();
|
|
28
|
+
if (!(l2 instanceof RedisCache)) return false;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
await l2.subscribeInvalidation(
|
|
32
|
+
INVALIDATION_CHANNEL,
|
|
33
|
+
(_channel, message) => handleRemoteInvalidation(message)
|
|
34
|
+
);
|
|
35
|
+
logger.info({ scope: 'cache', component: 'CacheManager', msg: 'Cross-instance cache invalidation enabled', instanceId });
|
|
36
|
+
return true;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
logger.warn({ scope: 'cache', component: 'CacheManager', msg: 'Failed to setup pub/sub', error });
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Handle an invalidation message from another instance.
|
|
45
|
+
* Ignores messages from self. Invalidates L1 only (L2 is shared Redis).
|
|
46
|
+
*/
|
|
47
|
+
export async function handleRemoteInvalidation(
|
|
48
|
+
provider: CacheProvider,
|
|
49
|
+
instanceId: string,
|
|
50
|
+
raw: string
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
try {
|
|
53
|
+
const msg: InvalidationMessage = JSON.parse(raw);
|
|
54
|
+
|
|
55
|
+
// Ignore our own messages
|
|
56
|
+
if (msg.instanceId === instanceId) return;
|
|
57
|
+
|
|
58
|
+
if (!(provider instanceof MultiLevelCache)) return;
|
|
59
|
+
const l1 = provider.getL1Cache();
|
|
60
|
+
|
|
61
|
+
if (msg.type === 'key' && msg.keys) {
|
|
62
|
+
await l1.deleteMany(msg.keys);
|
|
63
|
+
} else if (msg.type === 'pattern' && msg.pattern) {
|
|
64
|
+
await l1.invalidatePattern(msg.pattern);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
logger.debug({ scope: 'cache', component: 'CacheManager', msg: 'Applied remote invalidation', from: msg.instanceId, type: msg.type });
|
|
68
|
+
} catch (error) {
|
|
69
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error handling remote invalidation', error });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Publish an invalidation event to other instances via Redis pub/sub.
|
|
75
|
+
*/
|
|
76
|
+
export async function publishInvalidation(
|
|
77
|
+
provider: CacheProvider,
|
|
78
|
+
pubSubEnabled: boolean,
|
|
79
|
+
instanceId: string,
|
|
80
|
+
type: 'key' | 'pattern',
|
|
81
|
+
keys?: string[],
|
|
82
|
+
pattern?: string
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
if (!pubSubEnabled) return;
|
|
85
|
+
if (!(provider instanceof MultiLevelCache)) return;
|
|
86
|
+
|
|
87
|
+
const l2 = provider.getL2Cache();
|
|
88
|
+
if (!(l2 instanceof RedisCache)) return;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const msg: InvalidationMessage = { instanceId, type, keys, pattern };
|
|
92
|
+
await l2.publishInvalidation(INVALIDATION_CHANNEL, JSON.stringify(msg));
|
|
93
|
+
} catch (error) {
|
|
94
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error publishing invalidation', error });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { type CacheProvider } from '../CacheProvider';
|
|
2
|
+
import { type CacheConfig } from '../../../config/cache.config';
|
|
3
|
+
import { logger } from '../../Logger';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Write-invalidate strategy: entity and component invalidation operations.
|
|
7
|
+
* publishInvalidation is passed as a callback to avoid circular imports.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
type PublishFn = (type: 'key' | 'pattern', keys?: string[], pattern?: string) => Promise<void>;
|
|
11
|
+
|
|
12
|
+
export async function invalidateEntity(provider: CacheProvider, config: CacheConfig, publishInvalidation: PublishFn, id: string): Promise<void> {
|
|
13
|
+
if (!config.enabled || !config.entity?.enabled) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const key = `entity:${id}`;
|
|
19
|
+
await provider.delete(key);
|
|
20
|
+
await publishInvalidation('key', [key]);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error invalidating entity from cache', error });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function invalidateEntities(provider: CacheProvider, config: CacheConfig, publishInvalidation: PublishFn, entityIds: string[]): Promise<void> {
|
|
27
|
+
if (!config.enabled || entityIds.length === 0) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
await Promise.all(
|
|
31
|
+
entityIds.flatMap(id => [
|
|
32
|
+
invalidateEntity(provider, config, publishInvalidation, id),
|
|
33
|
+
invalidateAllEntityComponents(provider, config, publishInvalidation, id),
|
|
34
|
+
])
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function invalidateAllEntityComponents(provider: CacheProvider, config: CacheConfig, publishInvalidation: PublishFn, entityId: string): Promise<void> {
|
|
39
|
+
if (!config.enabled || !config.component?.enabled) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const pattern = `component:${entityId}:*`;
|
|
45
|
+
await provider.invalidatePattern(pattern);
|
|
46
|
+
await publishInvalidation('pattern', undefined, pattern);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error invalidating all entity components from cache', error });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function invalidateComponent(provider: CacheProvider, config: CacheConfig, publishInvalidation: PublishFn, entityId: string, typeId: string): Promise<void> {
|
|
53
|
+
if (!config.enabled || !config.component?.enabled) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
logger.trace({
|
|
59
|
+
msg: 'Invalidating component from cache',
|
|
60
|
+
entityId,
|
|
61
|
+
typeId
|
|
62
|
+
})
|
|
63
|
+
const key = `component:${entityId}:${typeId}`;
|
|
64
|
+
await provider.delete(key);
|
|
65
|
+
await publishInvalidation('key', [key]);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error invalidating component from cache', error });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function invalidateComponents(provider: CacheProvider, config: CacheConfig, publishInvalidation: PublishFn, components: Array<{ entityId: string; typeId: string }>): Promise<void> {
|
|
72
|
+
if (!config.enabled || !config.component?.enabled) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const keys = components.map(comp => `component:${comp.entityId}:${comp.typeId}`);
|
|
78
|
+
await provider.deleteMany(keys);
|
|
79
|
+
await publishInvalidation('key', keys);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error invalidating components from cache', error });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Invalidate all listed component types for one entity in a single round-trip.
|
|
87
|
+
* Optionally includes the entity existence key.
|
|
88
|
+
* Emits a single pub/sub message carrying all keys rather than one per component.
|
|
89
|
+
*/
|
|
90
|
+
export async function invalidateEntityComponents(
|
|
91
|
+
provider: CacheProvider,
|
|
92
|
+
config: CacheConfig,
|
|
93
|
+
publishInvalidation: PublishFn,
|
|
94
|
+
entityId: string,
|
|
95
|
+
componentTypeIds: string[],
|
|
96
|
+
opts?: { includeEntityKey?: boolean },
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
if (!config.enabled) return;
|
|
99
|
+
if (componentTypeIds.length === 0 && !opts?.includeEntityKey) return;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const keys: string[] = componentTypeIds.map(typeId => `component:${entityId}:${typeId}`);
|
|
103
|
+
if (opts?.includeEntityKey) {
|
|
104
|
+
keys.push(`entity:${entityId}`);
|
|
105
|
+
}
|
|
106
|
+
await provider.deleteMany(keys);
|
|
107
|
+
await publishInvalidation('key', keys);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
logger.error({ scope: 'cache', component: 'CacheManager', msg: 'Error invalidating entity components', entityId, error });
|
|
110
|
+
}
|
|
111
|
+
}
|