@betterdb/semantic-cache 0.2.0 → 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/README.md +27 -1
- package/dist/SemanticCache.d.ts +43 -3
- package/dist/SemanticCache.js +179 -2
- package/dist/discovery.d.ts +67 -0
- package/dist/discovery.js +140 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/telemetry.d.ts +2 -0
- package/dist/telemetry.js +12 -0
- package/dist/types.d.ts +21 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -161,6 +161,24 @@ Cost savings scale with the model. Observed values from live examples:
|
|
|
161
161
|
| `@betterdb/semantic-cache/embed/cohere` | `embed-english-v3.0` | 1024 |
|
|
162
162
|
| `@betterdb/semantic-cache/embed/ollama` | `nomic-embed-text` | 768 |
|
|
163
163
|
|
|
164
|
+
### Discovery markers
|
|
165
|
+
|
|
166
|
+
Starting in `0.2.0`, `initialize()` writes a small advisory record to a shared `__betterdb:caches` hash on the Valkey instance so Monitor (and other tooling) can enumerate caches without configuration. A 60s-TTL heartbeat key is refreshed every 30s; `flush()` and `dispose()` remove the heartbeat immediately. No sensitive data is ever written — only cache metadata (type, prefix, version, capabilities, configured thresholds).
|
|
167
|
+
|
|
168
|
+
Opt out by passing `discovery: { enabled: false }`. See `SemanticCacheOptions.discovery` for the full set of knobs.
|
|
169
|
+
|
|
170
|
+
If your Valkey runs with ACLs, grant the library's user access to the `__betterdb:*` prefix:
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
ACL SETUSER <user> +@write +@read ~__betterdb:* ~<your-cache-prefix>:*
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Discovery writes are best-effort — if the ACL denies them, the cache still functions and the `semantic_cache_discovery_write_failed_total` counter increments so operators can alert.
|
|
177
|
+
|
|
178
|
+
### `cache.dispose()`
|
|
179
|
+
|
|
180
|
+
Graceful shutdown: stops the heartbeat and deletes this instance's heartbeat key so Monitor marks the cache offline immediately. Does not drop the index or delete entries. Call from your SIGTERM handler alongside `client.quit()`.
|
|
181
|
+
|
|
164
182
|
## API
|
|
165
183
|
|
|
166
184
|
### `cache.initialize()`
|
|
@@ -215,7 +233,15 @@ Returns `{ name, numDocs, dimension, indexingState }`.
|
|
|
215
233
|
|
|
216
234
|
### `cache.flush()`
|
|
217
235
|
|
|
218
|
-
Drops the index and all
|
|
236
|
+
Drops the index and all entries. Call `initialize()` again to rebuild. Also stops the discovery heartbeat and deletes its heartbeat key, but preserves the registry entry in `__betterdb:caches` so Monitor retains history.
|
|
237
|
+
|
|
238
|
+
### `cache.shutdown()`
|
|
239
|
+
|
|
240
|
+
Stops the analytics client, cancels the stats snapshot timer, and disposes the discovery heartbeat. Safe to call multiple times.
|
|
241
|
+
|
|
242
|
+
### `cache.dispose()`
|
|
243
|
+
|
|
244
|
+
Graceful shutdown of the discovery layer for in-process caches without destroying data. Stops the discovery heartbeat and deletes the heartbeat key; does not touch the index or entries.
|
|
219
245
|
|
|
220
246
|
### `cache.thresholdEffectiveness(options?)`
|
|
221
247
|
|
package/dist/SemanticCache.d.ts
CHANGED
|
@@ -8,15 +8,22 @@ export declare class SemanticCache {
|
|
|
8
8
|
private readonly entryPrefix;
|
|
9
9
|
private readonly statsKey;
|
|
10
10
|
private readonly similarityWindowKey;
|
|
11
|
-
private readonly
|
|
11
|
+
private readonly configKey;
|
|
12
|
+
private defaultThreshold;
|
|
12
13
|
private readonly defaultTtl;
|
|
13
|
-
private
|
|
14
|
+
private categoryThresholds;
|
|
14
15
|
private readonly uncertaintyBand;
|
|
15
16
|
private readonly telemetry;
|
|
16
17
|
private readonly costTable;
|
|
17
18
|
private readonly embeddingCacheEnabled;
|
|
18
19
|
private readonly embeddingCacheTtl;
|
|
19
20
|
private readonly embedKeyPrefix;
|
|
21
|
+
private readonly discoveryOptions;
|
|
22
|
+
private readonly _initialDefaultThreshold;
|
|
23
|
+
private readonly _initialCategoryThresholds;
|
|
24
|
+
private readonly configRefreshOptions;
|
|
25
|
+
private configRefreshTimer;
|
|
26
|
+
private discovery;
|
|
20
27
|
private _initialized;
|
|
21
28
|
private _dimension;
|
|
22
29
|
private _hasBinaryRefs;
|
|
@@ -40,8 +47,18 @@ export declare class SemanticCache {
|
|
|
40
47
|
constructor(options: SemanticCacheOptions);
|
|
41
48
|
initialize(): Promise<void>;
|
|
42
49
|
flush(): Promise<void>;
|
|
43
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Shut down the analytics client, cancel the stats timer, and stop the
|
|
52
|
+
* discovery heartbeat. Safe to call multiple times.
|
|
53
|
+
*/
|
|
44
54
|
shutdown(): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Graceful shutdown of the discovery layer — stops the heartbeat and
|
|
57
|
+
* deletes this instance's heartbeat key so Monitor marks the cache offline
|
|
58
|
+
* immediately. Does NOT touch the registry hash, the FT index, or any
|
|
59
|
+
* entries. Safe to call multiple times.
|
|
60
|
+
*/
|
|
61
|
+
dispose(): Promise<void>;
|
|
45
62
|
check(prompt: string | ContentBlock[], options?: CacheCheckOptions): Promise<CacheCheckResult>;
|
|
46
63
|
store(prompt: string | ContentBlock[], response: string, options?: CacheStoreOptions): Promise<string>;
|
|
47
64
|
/**
|
|
@@ -82,8 +99,29 @@ export declare class SemanticCache {
|
|
|
82
99
|
thresholdEffectivenessAll(options?: {
|
|
83
100
|
minSamples?: number;
|
|
84
101
|
}): Promise<ThresholdEffectivenessResult[]>;
|
|
102
|
+
/**
|
|
103
|
+
* Refresh threshold config from Valkey. Returns true on a successful HGETALL,
|
|
104
|
+
* false if the call threw.
|
|
105
|
+
*
|
|
106
|
+
* Field semantics:
|
|
107
|
+
* - "threshold" -> updates defaultThreshold
|
|
108
|
+
* - "threshold:{category}" -> updates categoryThresholds[category]
|
|
109
|
+
* - "threshold:" (empty) -> ignored
|
|
110
|
+
* - non-numeric values -> ignored
|
|
111
|
+
* - out-of-range values -> ignored (must be 0 <= x <= 2)
|
|
112
|
+
*
|
|
113
|
+
* Categories present in memory but absent from the hash fall back to their
|
|
114
|
+
* constructor values (or are removed if no constructor override existed).
|
|
115
|
+
* The default threshold likewise falls back to its constructor value if
|
|
116
|
+
* `threshold` is absent from the hash.
|
|
117
|
+
*/
|
|
118
|
+
refreshConfig(): Promise<boolean>;
|
|
85
119
|
/** @internal Default similarity threshold. */
|
|
86
120
|
get _defaultThreshold(): number;
|
|
121
|
+
/** @internal Test-only getter. */
|
|
122
|
+
get _categoryThresholds(): Readonly<Record<string, number>>;
|
|
123
|
+
/** @internal Test-only getter. */
|
|
124
|
+
get _configRefreshIntervalMs(): number;
|
|
87
125
|
/**
|
|
88
126
|
* Execute a stable FT.SEARCH for use by adapters (e.g. LangGraph).
|
|
89
127
|
* SORTBY inserted_at ASC gives stable ordering across paginated calls.
|
|
@@ -98,7 +136,9 @@ export declare class SemanticCache {
|
|
|
98
136
|
vector: number[];
|
|
99
137
|
durationSec: number;
|
|
100
138
|
}>;
|
|
139
|
+
private startConfigRefresh;
|
|
101
140
|
private _doInitialize;
|
|
141
|
+
private registerDiscovery;
|
|
102
142
|
private initAnalyticsSafe;
|
|
103
143
|
private captureStatsSnapshot;
|
|
104
144
|
private ensureIndexAndGetDimension;
|
package/dist/SemanticCache.js
CHANGED
|
@@ -10,7 +10,9 @@ const utils_1 = require("./utils");
|
|
|
10
10
|
const defaultCostTable_1 = require("./defaultCostTable");
|
|
11
11
|
const cluster_1 = require("./cluster");
|
|
12
12
|
const analytics_1 = require("./analytics");
|
|
13
|
+
const discovery_1 = require("./discovery");
|
|
13
14
|
const INVALIDATE_BATCH_SIZE = 1000;
|
|
15
|
+
const PACKAGE_VERSION = require('../package.json').version;
|
|
14
16
|
function errMsg(err) {
|
|
15
17
|
return err instanceof Error ? err.message : String(err);
|
|
16
18
|
}
|
|
@@ -22,6 +24,7 @@ class SemanticCache {
|
|
|
22
24
|
entryPrefix;
|
|
23
25
|
statsKey;
|
|
24
26
|
similarityWindowKey;
|
|
27
|
+
configKey;
|
|
25
28
|
defaultThreshold;
|
|
26
29
|
defaultTtl;
|
|
27
30
|
categoryThresholds;
|
|
@@ -31,6 +34,12 @@ class SemanticCache {
|
|
|
31
34
|
embeddingCacheEnabled;
|
|
32
35
|
embeddingCacheTtl;
|
|
33
36
|
embedKeyPrefix;
|
|
37
|
+
discoveryOptions;
|
|
38
|
+
_initialDefaultThreshold;
|
|
39
|
+
_initialCategoryThresholds;
|
|
40
|
+
configRefreshOptions;
|
|
41
|
+
configRefreshTimer;
|
|
42
|
+
discovery = null;
|
|
34
43
|
_initialized = false;
|
|
35
44
|
_dimension = 0;
|
|
36
45
|
_hasBinaryRefs = false;
|
|
@@ -59,6 +68,7 @@ class SemanticCache {
|
|
|
59
68
|
this.entryPrefix = `${this.name}:entry:`;
|
|
60
69
|
this.statsKey = `${this.name}:__stats`;
|
|
61
70
|
this.similarityWindowKey = `${this.name}:__similarity_window`;
|
|
71
|
+
this.configKey = `${this.name}:__config`;
|
|
62
72
|
this.embedKeyPrefix = `${this.name}:embed:`;
|
|
63
73
|
this.defaultThreshold = options.defaultThreshold ?? 0.1;
|
|
64
74
|
this.defaultTtl = options.defaultTtl;
|
|
@@ -85,6 +95,16 @@ class SemanticCache {
|
|
|
85
95
|
});
|
|
86
96
|
this.analyticsOpts = options.analytics;
|
|
87
97
|
this.usesDefaultCostTable = useDefault;
|
|
98
|
+
this.discoveryOptions = options.discovery ?? {};
|
|
99
|
+
// Capture constructor values as fallback when __config fields are absent
|
|
100
|
+
this._initialDefaultThreshold = this.defaultThreshold;
|
|
101
|
+
this._initialCategoryThresholds = { ...this.categoryThresholds };
|
|
102
|
+
// Refresh options
|
|
103
|
+
const refresh = options.configRefresh ?? {};
|
|
104
|
+
this.configRefreshOptions = {
|
|
105
|
+
enabled: refresh.enabled ?? true,
|
|
106
|
+
intervalMs: Math.max(1000, refresh.intervalMs ?? 30_000),
|
|
107
|
+
};
|
|
88
108
|
}
|
|
89
109
|
// -- Lifecycle --
|
|
90
110
|
async initialize() {
|
|
@@ -102,6 +122,14 @@ class SemanticCache {
|
|
|
102
122
|
this._initialized = false;
|
|
103
123
|
this._initPromise = null;
|
|
104
124
|
this._initGeneration++;
|
|
125
|
+
// Capture and null the discovery ref synchronously, before any await,
|
|
126
|
+
// so a concurrent _doInitialize() (started after _initGeneration++) can't
|
|
127
|
+
// race in and have its new manager overwritten by this flush.
|
|
128
|
+
const discoveryToStop = this.discovery;
|
|
129
|
+
this.discovery = null;
|
|
130
|
+
if (discoveryToStop) {
|
|
131
|
+
await discoveryToStop.stop({ deleteHeartbeat: true });
|
|
132
|
+
}
|
|
105
133
|
// Valkey Search 1.2 does not support the DD (Delete Documents) flag on
|
|
106
134
|
// FT.DROPINDEX. Drop the index first, then clean up keys separately.
|
|
107
135
|
try {
|
|
@@ -126,14 +154,41 @@ class SemanticCache {
|
|
|
126
154
|
await this.client.del(this.similarityWindowKey);
|
|
127
155
|
this.analytics.capture('cache_flush');
|
|
128
156
|
}
|
|
129
|
-
/**
|
|
157
|
+
/**
|
|
158
|
+
* Shut down the analytics client, cancel the stats timer, and stop the
|
|
159
|
+
* discovery heartbeat. Safe to call multiple times.
|
|
160
|
+
*/
|
|
130
161
|
async shutdown() {
|
|
131
162
|
this.shutdownCalled = true;
|
|
163
|
+
if (this.configRefreshTimer) {
|
|
164
|
+
clearInterval(this.configRefreshTimer);
|
|
165
|
+
this.configRefreshTimer = undefined;
|
|
166
|
+
}
|
|
132
167
|
if (this.statsTimer) {
|
|
133
168
|
clearInterval(this.statsTimer);
|
|
134
169
|
this.statsTimer = undefined;
|
|
135
170
|
}
|
|
136
171
|
await this.analytics.shutdown();
|
|
172
|
+
await this.dispose();
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Graceful shutdown of the discovery layer — stops the heartbeat and
|
|
176
|
+
* deletes this instance's heartbeat key so Monitor marks the cache offline
|
|
177
|
+
* immediately. Does NOT touch the registry hash, the FT index, or any
|
|
178
|
+
* entries. Safe to call multiple times.
|
|
179
|
+
*/
|
|
180
|
+
async dispose() {
|
|
181
|
+
if (this.configRefreshTimer) {
|
|
182
|
+
clearInterval(this.configRefreshTimer);
|
|
183
|
+
this.configRefreshTimer = undefined;
|
|
184
|
+
}
|
|
185
|
+
if (this._initPromise) {
|
|
186
|
+
await this._initPromise.catch(() => { });
|
|
187
|
+
}
|
|
188
|
+
if (this.discovery) {
|
|
189
|
+
await this.discovery.stop({ deleteHeartbeat: true });
|
|
190
|
+
this.discovery = null;
|
|
191
|
+
}
|
|
137
192
|
}
|
|
138
193
|
// -- Public operations --
|
|
139
194
|
async check(prompt, options) {
|
|
@@ -769,9 +824,64 @@ class SemanticCache {
|
|
|
769
824
|
]);
|
|
770
825
|
return results;
|
|
771
826
|
}
|
|
827
|
+
/**
|
|
828
|
+
* Refresh threshold config from Valkey. Returns true on a successful HGETALL,
|
|
829
|
+
* false if the call threw.
|
|
830
|
+
*
|
|
831
|
+
* Field semantics:
|
|
832
|
+
* - "threshold" -> updates defaultThreshold
|
|
833
|
+
* - "threshold:{category}" -> updates categoryThresholds[category]
|
|
834
|
+
* - "threshold:" (empty) -> ignored
|
|
835
|
+
* - non-numeric values -> ignored
|
|
836
|
+
* - out-of-range values -> ignored (must be 0 <= x <= 2)
|
|
837
|
+
*
|
|
838
|
+
* Categories present in memory but absent from the hash fall back to their
|
|
839
|
+
* constructor values (or are removed if no constructor override existed).
|
|
840
|
+
* The default threshold likewise falls back to its constructor value if
|
|
841
|
+
* `threshold` is absent from the hash.
|
|
842
|
+
*/
|
|
843
|
+
async refreshConfig() {
|
|
844
|
+
let raw = null;
|
|
845
|
+
try {
|
|
846
|
+
raw = await this.client.hgetall(this.configKey);
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
let nextDefault = this._initialDefaultThreshold;
|
|
852
|
+
const nextCategory = { ...this._initialCategoryThresholds };
|
|
853
|
+
if (raw) {
|
|
854
|
+
for (const [field, value] of Object.entries(raw)) {
|
|
855
|
+
const parsed = Number(value);
|
|
856
|
+
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 2) {
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
if (field === 'threshold') {
|
|
860
|
+
nextDefault = parsed;
|
|
861
|
+
}
|
|
862
|
+
else if (field.startsWith('threshold:')) {
|
|
863
|
+
const category = field.slice('threshold:'.length);
|
|
864
|
+
if (category.length > 0) {
|
|
865
|
+
nextCategory[category] = parsed;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
this.defaultThreshold = nextDefault;
|
|
871
|
+
this.categoryThresholds = nextCategory;
|
|
872
|
+
return true;
|
|
873
|
+
}
|
|
772
874
|
// -- Internal helpers exposed to package adapters --
|
|
773
875
|
/** @internal Default similarity threshold. */
|
|
774
876
|
get _defaultThreshold() { return this.defaultThreshold; }
|
|
877
|
+
/** @internal Test-only getter. */
|
|
878
|
+
get _categoryThresholds() {
|
|
879
|
+
return this.categoryThresholds;
|
|
880
|
+
}
|
|
881
|
+
/** @internal Test-only getter. */
|
|
882
|
+
get _configRefreshIntervalMs() {
|
|
883
|
+
return this.configRefreshOptions.intervalMs;
|
|
884
|
+
}
|
|
775
885
|
/**
|
|
776
886
|
* Execute a stable FT.SEARCH for use by adapters (e.g. LangGraph).
|
|
777
887
|
* SORTBY inserted_at ASC gives stable ordering across paginated calls.
|
|
@@ -788,19 +898,86 @@ class SemanticCache {
|
|
|
788
898
|
return this.embed(text);
|
|
789
899
|
}
|
|
790
900
|
// -- Private helpers --
|
|
901
|
+
startConfigRefresh() {
|
|
902
|
+
if (!this.configRefreshOptions.enabled) {
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
const tick = () => {
|
|
906
|
+
this.refreshConfig()
|
|
907
|
+
.then((ok) => {
|
|
908
|
+
if (!ok) {
|
|
909
|
+
this.telemetry.metrics.configRefreshFailed
|
|
910
|
+
.labels({ cache_name: this.name })
|
|
911
|
+
.inc();
|
|
912
|
+
}
|
|
913
|
+
})
|
|
914
|
+
.catch(() => {
|
|
915
|
+
this.telemetry.metrics.configRefreshFailed
|
|
916
|
+
.labels({ cache_name: this.name })
|
|
917
|
+
.inc();
|
|
918
|
+
});
|
|
919
|
+
};
|
|
920
|
+
// Synchronous first refresh: process started immediately after a proposal
|
|
921
|
+
// was applied picks up the change without waiting for the first tick.
|
|
922
|
+
tick();
|
|
923
|
+
this.configRefreshTimer = setInterval(tick, this.configRefreshOptions.intervalMs);
|
|
924
|
+
if (typeof this.configRefreshTimer.unref === 'function') {
|
|
925
|
+
this.configRefreshTimer.unref();
|
|
926
|
+
}
|
|
927
|
+
}
|
|
791
928
|
async _doInitialize() {
|
|
792
929
|
const gen = this._initGeneration;
|
|
793
930
|
return this.traced('initialize', async () => {
|
|
794
931
|
const { dim, hasBinaryRefs } = await this.ensureIndexAndGetDimension();
|
|
795
|
-
if (this._initGeneration !== gen)
|
|
932
|
+
if (this._initGeneration !== gen) {
|
|
796
933
|
return;
|
|
934
|
+
}
|
|
797
935
|
this._dimension = dim;
|
|
798
936
|
this._hasBinaryRefs = hasBinaryRefs;
|
|
937
|
+
// registerDiscovery() may throw SemanticCacheUsageError on a name
|
|
938
|
+
// collision. Mark the cache initialized only after discovery succeeds
|
|
939
|
+
// so a colliding caller cannot subsequently call check()/store()
|
|
940
|
+
// against another owner's keys.
|
|
941
|
+
const manager = await this.registerDiscovery();
|
|
942
|
+
if (this._initGeneration !== gen) {
|
|
943
|
+
if (manager) {
|
|
944
|
+
await manager.stop({ deleteHeartbeat: true });
|
|
945
|
+
}
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
this.discovery = manager;
|
|
799
949
|
this._initialized = true;
|
|
950
|
+
this.startConfigRefresh();
|
|
800
951
|
// Fire analytics init once (not on every flush+initialize cycle)
|
|
801
952
|
this.initAnalyticsSafe().catch(() => { });
|
|
802
953
|
});
|
|
803
954
|
}
|
|
955
|
+
async registerDiscovery() {
|
|
956
|
+
if (this.discoveryOptions.enabled === false) {
|
|
957
|
+
return null;
|
|
958
|
+
}
|
|
959
|
+
const metadata = (0, discovery_1.buildSemanticMetadata)({
|
|
960
|
+
name: this.name,
|
|
961
|
+
version: PACKAGE_VERSION,
|
|
962
|
+
defaultThreshold: this.defaultThreshold,
|
|
963
|
+
categoryThresholds: this.categoryThresholds,
|
|
964
|
+
uncertaintyBand: this.uncertaintyBand,
|
|
965
|
+
includeCategories: this.discoveryOptions.includeCategories ?? true,
|
|
966
|
+
});
|
|
967
|
+
const manager = new discovery_1.DiscoveryManager({
|
|
968
|
+
client: this.client,
|
|
969
|
+
name: this.name,
|
|
970
|
+
metadata,
|
|
971
|
+
heartbeatIntervalMs: this.discoveryOptions.heartbeatIntervalMs,
|
|
972
|
+
onWriteFailed: () => {
|
|
973
|
+
this.telemetry.metrics.discoveryWriteFailed
|
|
974
|
+
.labels({ cache_name: this.name })
|
|
975
|
+
.inc();
|
|
976
|
+
},
|
|
977
|
+
});
|
|
978
|
+
await manager.register();
|
|
979
|
+
return manager;
|
|
980
|
+
}
|
|
804
981
|
async initAnalyticsSafe() {
|
|
805
982
|
if (this.analyticsInitiated)
|
|
806
983
|
return;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Valkey } from './types';
|
|
2
|
+
export declare const PROTOCOL_VERSION = 1;
|
|
3
|
+
export declare const REGISTRY_KEY = "__betterdb:caches";
|
|
4
|
+
export declare const PROTOCOL_KEY = "__betterdb:protocol";
|
|
5
|
+
export declare const HEARTBEAT_KEY_PREFIX = "__betterdb:heartbeat:";
|
|
6
|
+
export declare const DEFAULT_HEARTBEAT_INTERVAL_MS = 30000;
|
|
7
|
+
export declare const HEARTBEAT_TTL_SECONDS = 60;
|
|
8
|
+
export declare const CACHE_TYPE: "semantic_cache";
|
|
9
|
+
export type CacheType = typeof CACHE_TYPE;
|
|
10
|
+
export interface DiscoveryOptions {
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
heartbeatIntervalMs?: number;
|
|
13
|
+
includeCategories?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface MarkerMetadata {
|
|
16
|
+
type: CacheType;
|
|
17
|
+
prefix: string;
|
|
18
|
+
version: string;
|
|
19
|
+
protocol_version: number;
|
|
20
|
+
capabilities: string[];
|
|
21
|
+
stats_key: string;
|
|
22
|
+
started_at: string;
|
|
23
|
+
pid?: number;
|
|
24
|
+
hostname?: string;
|
|
25
|
+
[extra: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
export interface BuildSemanticMetadataInput {
|
|
28
|
+
name: string;
|
|
29
|
+
version: string;
|
|
30
|
+
defaultThreshold: number;
|
|
31
|
+
categoryThresholds: Record<string, number>;
|
|
32
|
+
uncertaintyBand: number;
|
|
33
|
+
includeCategories: boolean;
|
|
34
|
+
}
|
|
35
|
+
export declare function buildSemanticMetadata(input: BuildSemanticMetadataInput): MarkerMetadata;
|
|
36
|
+
export interface DiscoveryLogger {
|
|
37
|
+
warn: (msg: string) => void;
|
|
38
|
+
debug: (msg: string) => void;
|
|
39
|
+
}
|
|
40
|
+
export interface DiscoveryManagerDeps {
|
|
41
|
+
client: Valkey;
|
|
42
|
+
name: string;
|
|
43
|
+
metadata: MarkerMetadata;
|
|
44
|
+
heartbeatIntervalMs?: number;
|
|
45
|
+
logger?: DiscoveryLogger;
|
|
46
|
+
onWriteFailed?: () => void;
|
|
47
|
+
}
|
|
48
|
+
export declare class DiscoveryManager {
|
|
49
|
+
private readonly client;
|
|
50
|
+
private readonly name;
|
|
51
|
+
private readonly metadata;
|
|
52
|
+
private readonly heartbeatIntervalMs;
|
|
53
|
+
private readonly heartbeatKey;
|
|
54
|
+
private readonly logger;
|
|
55
|
+
private readonly onWriteFailed;
|
|
56
|
+
private heartbeatHandle;
|
|
57
|
+
constructor(deps: DiscoveryManagerDeps);
|
|
58
|
+
register(): Promise<void>;
|
|
59
|
+
stop(opts: {
|
|
60
|
+
deleteHeartbeat: boolean;
|
|
61
|
+
}): Promise<void>;
|
|
62
|
+
tickHeartbeat(): Promise<void>;
|
|
63
|
+
private startHeartbeat;
|
|
64
|
+
private safeHget;
|
|
65
|
+
private safeCall;
|
|
66
|
+
private checkCollision;
|
|
67
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DiscoveryManager = exports.CACHE_TYPE = exports.HEARTBEAT_TTL_SECONDS = exports.DEFAULT_HEARTBEAT_INTERVAL_MS = exports.HEARTBEAT_KEY_PREFIX = exports.PROTOCOL_KEY = exports.REGISTRY_KEY = exports.PROTOCOL_VERSION = void 0;
|
|
4
|
+
exports.buildSemanticMetadata = buildSemanticMetadata;
|
|
5
|
+
const node_os_1 = require("node:os");
|
|
6
|
+
const errors_1 = require("./errors");
|
|
7
|
+
exports.PROTOCOL_VERSION = 1;
|
|
8
|
+
exports.REGISTRY_KEY = '__betterdb:caches';
|
|
9
|
+
exports.PROTOCOL_KEY = '__betterdb:protocol';
|
|
10
|
+
exports.HEARTBEAT_KEY_PREFIX = '__betterdb:heartbeat:';
|
|
11
|
+
exports.DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
12
|
+
exports.HEARTBEAT_TTL_SECONDS = 60;
|
|
13
|
+
exports.CACHE_TYPE = 'semantic_cache';
|
|
14
|
+
function buildSemanticMetadata(input) {
|
|
15
|
+
const metadata = {
|
|
16
|
+
type: exports.CACHE_TYPE,
|
|
17
|
+
prefix: input.name,
|
|
18
|
+
version: input.version,
|
|
19
|
+
protocol_version: exports.PROTOCOL_VERSION,
|
|
20
|
+
capabilities: ['invalidate', 'similarity_distribution', 'threshold_adjust'],
|
|
21
|
+
index_name: `${input.name}:idx`,
|
|
22
|
+
stats_key: `${input.name}:__stats`,
|
|
23
|
+
config_key: `${input.name}:__config`,
|
|
24
|
+
default_threshold: input.defaultThreshold,
|
|
25
|
+
uncertainty_band: input.uncertaintyBand,
|
|
26
|
+
started_at: new Date().toISOString(),
|
|
27
|
+
pid: process.pid,
|
|
28
|
+
hostname: (0, node_os_1.hostname)(),
|
|
29
|
+
};
|
|
30
|
+
if (input.includeCategories && Object.keys(input.categoryThresholds).length > 0) {
|
|
31
|
+
metadata.category_thresholds = { ...input.categoryThresholds };
|
|
32
|
+
}
|
|
33
|
+
return metadata;
|
|
34
|
+
}
|
|
35
|
+
const noopLogger = {
|
|
36
|
+
warn: () => { },
|
|
37
|
+
debug: () => { },
|
|
38
|
+
};
|
|
39
|
+
function errMsg(err) {
|
|
40
|
+
return err instanceof Error ? err.message : String(err);
|
|
41
|
+
}
|
|
42
|
+
class DiscoveryManager {
|
|
43
|
+
client;
|
|
44
|
+
name;
|
|
45
|
+
metadata;
|
|
46
|
+
heartbeatIntervalMs;
|
|
47
|
+
heartbeatKey;
|
|
48
|
+
logger;
|
|
49
|
+
onWriteFailed;
|
|
50
|
+
heartbeatHandle = null;
|
|
51
|
+
constructor(deps) {
|
|
52
|
+
this.client = deps.client;
|
|
53
|
+
this.name = deps.name;
|
|
54
|
+
this.metadata = deps.metadata;
|
|
55
|
+
this.heartbeatIntervalMs = deps.heartbeatIntervalMs ?? exports.DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
56
|
+
this.heartbeatKey = `${exports.HEARTBEAT_KEY_PREFIX}${deps.name}`;
|
|
57
|
+
this.logger = deps.logger ?? noopLogger;
|
|
58
|
+
this.onWriteFailed = deps.onWriteFailed ?? (() => { });
|
|
59
|
+
}
|
|
60
|
+
async register() {
|
|
61
|
+
const existingJson = await this.safeHget();
|
|
62
|
+
if (existingJson !== null) {
|
|
63
|
+
this.checkCollision(existingJson);
|
|
64
|
+
}
|
|
65
|
+
await this.safeCall(() => this.client.hset(exports.REGISTRY_KEY, this.name, JSON.stringify(this.metadata)), 'HSET registry');
|
|
66
|
+
await this.safeCall(() => this.client.set(exports.PROTOCOL_KEY, String(exports.PROTOCOL_VERSION), 'NX'), 'SET protocol');
|
|
67
|
+
await this.tickHeartbeat();
|
|
68
|
+
this.startHeartbeat();
|
|
69
|
+
}
|
|
70
|
+
async stop(opts) {
|
|
71
|
+
if (this.heartbeatHandle) {
|
|
72
|
+
clearInterval(this.heartbeatHandle);
|
|
73
|
+
this.heartbeatHandle = null;
|
|
74
|
+
}
|
|
75
|
+
if (!opts.deleteHeartbeat) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
await this.client.del(this.heartbeatKey);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
this.logger.debug(`discovery: DEL heartbeat failed: ${errMsg(err)}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async tickHeartbeat() {
|
|
86
|
+
const now = new Date().toISOString();
|
|
87
|
+
try {
|
|
88
|
+
await this.client.set(this.heartbeatKey, now, 'EX', exports.HEARTBEAT_TTL_SECONDS);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
this.logger.debug(`discovery: heartbeat SET failed: ${errMsg(err)}`);
|
|
92
|
+
this.onWriteFailed();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
startHeartbeat() {
|
|
96
|
+
if (this.heartbeatHandle) {
|
|
97
|
+
clearInterval(this.heartbeatHandle);
|
|
98
|
+
}
|
|
99
|
+
const handle = setInterval(() => {
|
|
100
|
+
void this.tickHeartbeat();
|
|
101
|
+
}, this.heartbeatIntervalMs);
|
|
102
|
+
handle.unref?.();
|
|
103
|
+
this.heartbeatHandle = handle;
|
|
104
|
+
}
|
|
105
|
+
async safeHget() {
|
|
106
|
+
try {
|
|
107
|
+
return await this.client.hget(exports.REGISTRY_KEY, this.name);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
this.logger.warn(`discovery: HGET registry failed: ${errMsg(err)}`);
|
|
111
|
+
this.onWriteFailed();
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async safeCall(fn, label) {
|
|
116
|
+
try {
|
|
117
|
+
await fn();
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
this.logger.warn(`discovery: ${label} failed: ${errMsg(err)}`);
|
|
121
|
+
this.onWriteFailed();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
checkCollision(existingJson) {
|
|
125
|
+
let parsed;
|
|
126
|
+
try {
|
|
127
|
+
parsed = JSON.parse(existingJson);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (parsed.type && parsed.type !== exports.CACHE_TYPE) {
|
|
133
|
+
throw new errors_1.SemanticCacheUsageError(`cache name collision: '${this.name}' is already registered as type '${String(parsed.type)}' on this Valkey instance`);
|
|
134
|
+
}
|
|
135
|
+
if (parsed.version && parsed.version !== this.metadata.version) {
|
|
136
|
+
this.logger.warn(`discovery: overwriting marker for '${this.name}' (existing version ${String(parsed.version)}, this version ${this.metadata.version})`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
exports.DiscoveryManager = DiscoveryManager;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export { SemanticCache } from './SemanticCache';
|
|
2
2
|
export type { ThresholdEffectivenessResult } from './SemanticCache';
|
|
3
3
|
export { DEFAULT_COST_TABLE } from './defaultCostTable';
|
|
4
|
-
export type { SemanticCacheOptions, CacheCheckOptions, CacheStoreOptions, CacheCheckResult, CacheStats, IndexInfo, InvalidateResult, CacheConfidence, EmbedFn, ModelCost, RerankOptions, } from './types';
|
|
4
|
+
export type { SemanticCacheOptions, CacheCheckOptions, CacheStoreOptions, CacheCheckResult, CacheStats, IndexInfo, InvalidateResult, CacheConfidence, EmbedFn, ModelCost, RerankOptions, ConfigRefreshOptions, } from './types';
|
|
5
5
|
export { SemanticCacheUsageError, EmbeddingError, ValkeyCommandError, } from './errors';
|
|
6
6
|
export type { ContentBlock, TextBlock, BinaryBlock, ToolCallBlock, ToolResultBlock, ReasoningBlock, BlockHints, } from './utils';
|
|
7
|
+
export { escapeTag } from './utils';
|
|
7
8
|
export type { BinaryRef, BinaryNormalizer, NormalizerConfig } from './normalizer';
|
|
8
9
|
export { hashBase64, hashBytes, hashUrl, fetchAndHash, passthrough, composeNormalizer, defaultNormalizer, } from './normalizer';
|
|
10
|
+
export type { DiscoveryOptions } from './discovery';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.defaultNormalizer = exports.composeNormalizer = exports.passthrough = exports.fetchAndHash = exports.hashUrl = exports.hashBytes = exports.hashBase64 = exports.ValkeyCommandError = exports.EmbeddingError = exports.SemanticCacheUsageError = exports.DEFAULT_COST_TABLE = exports.SemanticCache = void 0;
|
|
3
|
+
exports.defaultNormalizer = exports.composeNormalizer = exports.passthrough = exports.fetchAndHash = exports.hashUrl = exports.hashBytes = exports.hashBase64 = exports.escapeTag = exports.ValkeyCommandError = exports.EmbeddingError = exports.SemanticCacheUsageError = exports.DEFAULT_COST_TABLE = exports.SemanticCache = void 0;
|
|
4
4
|
var SemanticCache_1 = require("./SemanticCache");
|
|
5
5
|
Object.defineProperty(exports, "SemanticCache", { enumerable: true, get: function () { return SemanticCache_1.SemanticCache; } });
|
|
6
6
|
var defaultCostTable_1 = require("./defaultCostTable");
|
|
@@ -9,6 +9,8 @@ var errors_1 = require("./errors");
|
|
|
9
9
|
Object.defineProperty(exports, "SemanticCacheUsageError", { enumerable: true, get: function () { return errors_1.SemanticCacheUsageError; } });
|
|
10
10
|
Object.defineProperty(exports, "EmbeddingError", { enumerable: true, get: function () { return errors_1.EmbeddingError; } });
|
|
11
11
|
Object.defineProperty(exports, "ValkeyCommandError", { enumerable: true, get: function () { return errors_1.ValkeyCommandError; } });
|
|
12
|
+
var utils_1 = require("./utils");
|
|
13
|
+
Object.defineProperty(exports, "escapeTag", { enumerable: true, get: function () { return utils_1.escapeTag; } });
|
|
12
14
|
var normalizer_1 = require("./normalizer");
|
|
13
15
|
Object.defineProperty(exports, "hashBase64", { enumerable: true, get: function () { return normalizer_1.hashBase64; } });
|
|
14
16
|
Object.defineProperty(exports, "hashBytes", { enumerable: true, get: function () { return normalizer_1.hashBytes; } });
|
package/dist/telemetry.d.ts
CHANGED
package/dist/telemetry.js
CHANGED
|
@@ -57,6 +57,16 @@ function createTelemetry(opts) {
|
|
|
57
57
|
help: 'Entries evicted due to staleAfterModelChange detection',
|
|
58
58
|
labelNames: ['cache_name'],
|
|
59
59
|
});
|
|
60
|
+
const discoveryWriteFailed = getOrCreateCounter(registry, {
|
|
61
|
+
name: `${opts.prefix}_discovery_write_failed_total`,
|
|
62
|
+
help: 'Count of failed discovery-marker writes (best-effort HGET/HSET/SET operations against __betterdb:* keys)',
|
|
63
|
+
labelNames: ['cache_name'],
|
|
64
|
+
});
|
|
65
|
+
const configRefreshFailed = getOrCreateCounter(registry, {
|
|
66
|
+
name: `${opts.prefix}_config_refresh_failed_total`,
|
|
67
|
+
help: 'Count of failed periodic config refreshes (HGETALL on __config).',
|
|
68
|
+
labelNames: ['cache_name'],
|
|
69
|
+
});
|
|
60
70
|
return {
|
|
61
71
|
tracer,
|
|
62
72
|
metrics: {
|
|
@@ -67,6 +77,8 @@ function createTelemetry(opts) {
|
|
|
67
77
|
costSavedTotal,
|
|
68
78
|
embeddingCacheTotal,
|
|
69
79
|
staleModelEvictions,
|
|
80
|
+
discoveryWriteFailed,
|
|
81
|
+
configRefreshFailed,
|
|
70
82
|
},
|
|
71
83
|
};
|
|
72
84
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type Valkey from 'iovalkey';
|
|
2
2
|
import type { Registry } from 'prom-client';
|
|
3
|
+
import type { DiscoveryOptions } from './discovery';
|
|
3
4
|
export type { Valkey };
|
|
5
|
+
export interface ConfigRefreshOptions {
|
|
6
|
+
/** Enable periodic config refresh from Valkey. Default: true. */
|
|
7
|
+
enabled?: boolean;
|
|
8
|
+
/** Refresh interval in milliseconds. Default: 30000. Minimum: 1000. */
|
|
9
|
+
intervalMs?: number;
|
|
10
|
+
}
|
|
4
11
|
export type EmbedFn = (text: string) => Promise<number[]>;
|
|
5
12
|
export interface ModelCost {
|
|
6
13
|
inputPer1k: number;
|
|
@@ -92,6 +99,20 @@ export interface SemanticCacheOptions {
|
|
|
92
99
|
/** Interval in ms for periodic stats snapshots. Default: 300_000 (5 min). 0 to disable. */
|
|
93
100
|
statsIntervalMs?: number;
|
|
94
101
|
};
|
|
102
|
+
/**
|
|
103
|
+
* Discovery-marker protocol controls. See
|
|
104
|
+
* docs/plans/specs/spec-semantic-cache-discovery-markers.md.
|
|
105
|
+
* Defaults: enabled=true, heartbeatIntervalMs=30000, includeCategories=true.
|
|
106
|
+
*/
|
|
107
|
+
discovery?: DiscoveryOptions;
|
|
108
|
+
/**
|
|
109
|
+
* Periodic refresh of in-memory threshold config from Valkey.
|
|
110
|
+
* When enabled, the cache re-reads `{name}:__config` on the configured
|
|
111
|
+
* interval. Field `threshold` updates `defaultThreshold`; fields named
|
|
112
|
+
* `threshold:{category}` update `categoryThresholds[category]`.
|
|
113
|
+
* Defaults: enabled=true, intervalMs=30000.
|
|
114
|
+
*/
|
|
115
|
+
configRefresh?: ConfigRefreshOptions;
|
|
95
116
|
}
|
|
96
117
|
export interface RerankOptions {
|
|
97
118
|
/**
|
package/package.json
CHANGED