@ductape/sdk 0.1.129 → 0.1.130
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.
|
@@ -76,6 +76,33 @@ export declare class FeatureService {
|
|
|
76
76
|
* Get or create a ProductBuilder instance for the given product tag
|
|
77
77
|
*/
|
|
78
78
|
private getProductBuilder;
|
|
79
|
+
/** Per-process guard so the one-time "cold cache" bulk warm (fetchFeatures()) only ever runs
|
|
80
|
+
* once per product per process, even under concurrent define() calls for many features at
|
|
81
|
+
* boot -- without this, N concurrent cache-miss defines would each independently trigger
|
|
82
|
+
* their own redundant bulk fetch. */
|
|
83
|
+
private featureCacheWarmed;
|
|
84
|
+
private featureFingerprintCacheKey;
|
|
85
|
+
/**
|
|
86
|
+
* A stable, deterministic fingerprint of exactly the fields that define a feature's real
|
|
87
|
+
* content (the same field set IFeatureConfig already establishes as "what a feature
|
|
88
|
+
* definition consists of" -- see features.types.ts). Deliberately excludes `tag` (redundant
|
|
89
|
+
* with the cache key) and any server-added bookkeeping fields (`_id`, timestamps) a remote
|
|
90
|
+
* IProductFeature carries that a locally-compiled schema never has -- comparing those would
|
|
91
|
+
* make every fingerprint mismatch even when the real, meaningful content is unchanged.
|
|
92
|
+
*/
|
|
93
|
+
private computeFeatureFingerprint;
|
|
94
|
+
private getCachedFeatureFingerprint;
|
|
95
|
+
private setCachedFeatureFingerprint;
|
|
96
|
+
/**
|
|
97
|
+
* Fetches every real feature currently persisted for `productTag` in ONE bulk remote call
|
|
98
|
+
* (`builder.fetchFeatures()`, already existing -- the same call Ductape's own Workbench uses)
|
|
99
|
+
* and warms the Redis fingerprint cache for all of them at once. Real answer to "if Redis is
|
|
100
|
+
* empty, fetch all existing features, then check if the defined feature is already there
|
|
101
|
+
* before recreating it" -- this runs at most ONCE per product per process (concurrent
|
|
102
|
+
* define() calls for different features share the same in-flight promise, never triggering
|
|
103
|
+
* their own separate bulk fetch).
|
|
104
|
+
*/
|
|
105
|
+
private warmFeatureCache;
|
|
79
106
|
/**
|
|
80
107
|
* Initialize logging service
|
|
81
108
|
*/
|
|
@@ -66,6 +66,22 @@ const console = {
|
|
|
66
66
|
warn: globalThis.console.warn.bind(globalThis.console),
|
|
67
67
|
error: globalThis.console.error.bind(globalThis.console),
|
|
68
68
|
};
|
|
69
|
+
/**
|
|
70
|
+
* Deterministic JSON stringify: object keys are sorted recursively so two structurally-equal
|
|
71
|
+
* values always produce byte-identical output regardless of real-world key insertion order
|
|
72
|
+
* (a remote-fetched feature and a freshly-compiled local schema can legitimately differ only
|
|
73
|
+
* in property order, never in meaning). Array ORDER is preserved -- unlike object keys, array
|
|
74
|
+
* order is semantically meaningful here (`steps` is an ordered execution sequence).
|
|
75
|
+
*/
|
|
76
|
+
function stableStringify(value) {
|
|
77
|
+
if (value === null || typeof value !== 'object')
|
|
78
|
+
return JSON.stringify(value);
|
|
79
|
+
if (Array.isArray(value))
|
|
80
|
+
return `[${value.map((entry) => stableStringify(entry)).join(',')}]`;
|
|
81
|
+
const keys = Object.keys(value).sort();
|
|
82
|
+
const entries = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`);
|
|
83
|
+
return `{${entries.join(',')}}`;
|
|
84
|
+
}
|
|
69
85
|
/**
|
|
70
86
|
* Error class for feature-related errors
|
|
71
87
|
*/
|
|
@@ -119,6 +135,21 @@ class FeatureService {
|
|
|
119
135
|
this.cacheManager = null;
|
|
120
136
|
/** Local cache for cache configurations to avoid repeated API calls */
|
|
121
137
|
this.cacheConfigCache = new Map();
|
|
138
|
+
// ==================== FEATURE-DEFINITION REDIS CACHE ====================
|
|
139
|
+
// define() previously did fetchFeature -> [fetchFeature + fetchEnv*N + updateProduct] ->
|
|
140
|
+
// fetchFeature on EVERY call, unconditionally, even when the feature's compiled schema was
|
|
141
|
+
// byte-for-byte identical to what was already persisted -- 5-6 real remote round-trips per
|
|
142
|
+
// feature, on every process boot, for features that never actually changed. At real
|
|
143
|
+
// application scale (dozens of features registered at startup) this turned every boot into a
|
|
144
|
+
// multi-minute serial remote-call chain. This cache closes that gap using the SAME Redis
|
|
145
|
+
// client already threaded through this service's own config (`config.redis_client`, already
|
|
146
|
+
// used by CacheManager above) -- degrades safely to "cache disabled, always hit the remote
|
|
147
|
+
// path" when no redis_client is configured, matching this class's existing pattern.
|
|
148
|
+
/** Per-process guard so the one-time "cold cache" bulk warm (fetchFeatures()) only ever runs
|
|
149
|
+
* once per product per process, even under concurrent define() calls for many features at
|
|
150
|
+
* boot -- without this, N concurrent cache-miss defines would each independently trigger
|
|
151
|
+
* their own redundant bulk fetch. */
|
|
152
|
+
this.featureCacheWarmed = new Map();
|
|
122
153
|
//console.log('[FeatureService] constructor', { hasConfig: !!config, env_type: config?.env_type, hasRedis: !!config?.redis_client });
|
|
123
154
|
this.config = config || null;
|
|
124
155
|
this._privateKey = (config === null || config === void 0 ? void 0 : config.private_key) || '';
|
|
@@ -224,6 +255,89 @@ class FeatureService {
|
|
|
224
255
|
}
|
|
225
256
|
return builder;
|
|
226
257
|
}
|
|
258
|
+
featureFingerprintCacheKey(productTag, tag) {
|
|
259
|
+
return `ductape:feature-fingerprint:${productTag}:${tag}`;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* A stable, deterministic fingerprint of exactly the fields that define a feature's real
|
|
263
|
+
* content (the same field set IFeatureConfig already establishes as "what a feature
|
|
264
|
+
* definition consists of" -- see features.types.ts). Deliberately excludes `tag` (redundant
|
|
265
|
+
* with the cache key) and any server-added bookkeeping fields (`_id`, timestamps) a remote
|
|
266
|
+
* IProductFeature carries that a locally-compiled schema never has -- comparing those would
|
|
267
|
+
* make every fingerprint mismatch even when the real, meaningful content is unchanged.
|
|
268
|
+
*/
|
|
269
|
+
computeFeatureFingerprint(feature) {
|
|
270
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
271
|
+
const comparable = {
|
|
272
|
+
name: feature.name,
|
|
273
|
+
description: (_a = feature.description) !== null && _a !== void 0 ? _a : null,
|
|
274
|
+
input: (_b = feature.input) !== null && _b !== void 0 ? _b : null,
|
|
275
|
+
output: (_c = feature.output) !== null && _c !== void 0 ? _c : null,
|
|
276
|
+
steps: (_d = feature.steps) !== null && _d !== void 0 ? _d : [],
|
|
277
|
+
signals: (_e = feature.signals) !== null && _e !== void 0 ? _e : null,
|
|
278
|
+
queries: (_f = feature.queries) !== null && _f !== void 0 ? _f : null,
|
|
279
|
+
options: (_g = feature.options) !== null && _g !== void 0 ? _g : null,
|
|
280
|
+
envs: (_h = feature.envs) !== null && _h !== void 0 ? _h : null,
|
|
281
|
+
};
|
|
282
|
+
return (0, crypto_1.createHash)('sha256').update(stableStringify(comparable)).digest('hex');
|
|
283
|
+
}
|
|
284
|
+
async getCachedFeatureFingerprint(productTag, tag) {
|
|
285
|
+
var _a;
|
|
286
|
+
const redisClient = (_a = this.config) === null || _a === void 0 ? void 0 : _a.redis_client;
|
|
287
|
+
if (!redisClient)
|
|
288
|
+
return null;
|
|
289
|
+
try {
|
|
290
|
+
return await redisClient.get(this.featureFingerprintCacheKey(productTag, tag));
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
console.log('[FeatureService] getCachedFeatureFingerprint failed (treating as cache miss)', { productTag, tag, error: error instanceof Error ? error.message : String(error) });
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async setCachedFeatureFingerprint(productTag, tag, fingerprint) {
|
|
298
|
+
var _a;
|
|
299
|
+
const redisClient = (_a = this.config) === null || _a === void 0 ? void 0 : _a.redis_client;
|
|
300
|
+
if (!redisClient)
|
|
301
|
+
return;
|
|
302
|
+
try {
|
|
303
|
+
await redisClient.set(this.featureFingerprintCacheKey(productTag, tag), fingerprint);
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
// A failed cache WRITE must never fail define() itself -- the remote write already
|
|
307
|
+
// succeeded (or was confirmed unchanged) by the time this runs; losing the cache entry
|
|
308
|
+
// only costs a future redundant remote round-trip, not correctness.
|
|
309
|
+
console.log('[FeatureService] setCachedFeatureFingerprint failed (non-fatal)', { productTag, tag, error: error instanceof Error ? error.message : String(error) });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Fetches every real feature currently persisted for `productTag` in ONE bulk remote call
|
|
314
|
+
* (`builder.fetchFeatures()`, already existing -- the same call Ductape's own Workbench uses)
|
|
315
|
+
* and warms the Redis fingerprint cache for all of them at once. Real answer to "if Redis is
|
|
316
|
+
* empty, fetch all existing features, then check if the defined feature is already there
|
|
317
|
+
* before recreating it" -- this runs at most ONCE per product per process (concurrent
|
|
318
|
+
* define() calls for different features share the same in-flight promise, never triggering
|
|
319
|
+
* their own separate bulk fetch).
|
|
320
|
+
*/
|
|
321
|
+
async warmFeatureCache(productTag, builder) {
|
|
322
|
+
let warmPromise = this.featureCacheWarmed.get(productTag);
|
|
323
|
+
if (!warmPromise) {
|
|
324
|
+
warmPromise = (async () => {
|
|
325
|
+
console.log('[FeatureService] warmFeatureCache: cold cache, fetching all existing features', { productTag });
|
|
326
|
+
const all = await builder.fetchFeatures();
|
|
327
|
+
const byTag = new Map();
|
|
328
|
+
for (const feature of all) {
|
|
329
|
+
if (!feature.tag)
|
|
330
|
+
continue;
|
|
331
|
+
byTag.set(feature.tag, feature);
|
|
332
|
+
await this.setCachedFeatureFingerprint(productTag, feature.tag, this.computeFeatureFingerprint(feature));
|
|
333
|
+
}
|
|
334
|
+
console.log('[FeatureService] warmFeatureCache: cached fingerprints for existing features', { productTag, count: byTag.size });
|
|
335
|
+
return byTag;
|
|
336
|
+
})();
|
|
337
|
+
this.featureCacheWarmed.set(productTag, warmPromise);
|
|
338
|
+
}
|
|
339
|
+
return warmPromise;
|
|
340
|
+
}
|
|
227
341
|
/**
|
|
228
342
|
* Initialize logging service
|
|
229
343
|
*/
|
|
@@ -481,7 +595,7 @@ class FeatureService {
|
|
|
481
595
|
* ```
|
|
482
596
|
*/
|
|
483
597
|
async define(options) {
|
|
484
|
-
var _a, _b;
|
|
598
|
+
var _a, _b, _c;
|
|
485
599
|
console.log('[FeatureService] define ENTRY', { tag: options.tag, name: options.name, product: options.product });
|
|
486
600
|
// Validate required fields
|
|
487
601
|
if (!options.tag || !options.name || !options.handler) {
|
|
@@ -494,18 +608,61 @@ class FeatureService {
|
|
|
494
608
|
console.log('[FeatureService] define compiling handler (compileAsync)', { tag: options.tag });
|
|
495
609
|
const schema = await compiler.compileAsync();
|
|
496
610
|
console.log('[FeatureService] define compiled schema', { tag: schema.tag, stepsCount: (_b = (_a = schema.steps) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0 });
|
|
497
|
-
// If product is specified, create the feature
|
|
611
|
+
// If product is specified, create the feature -- but first check the Redis fingerprint
|
|
612
|
+
// cache (see the "FEATURE-DEFINITION REDIS CACHE" section above) so an unaltered feature
|
|
613
|
+
// that is ALREADY known to be persisted correctly never touches the remote API at all.
|
|
498
614
|
if (options.product) {
|
|
499
|
-
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
else
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
615
|
+
const fingerprint = this.computeFeatureFingerprint(schema);
|
|
616
|
+
const cachedFingerprint = await this.getCachedFeatureFingerprint(options.product, schema.tag);
|
|
617
|
+
if (cachedFingerprint === fingerprint) {
|
|
618
|
+
console.log('[FeatureService] define SKIPPED remote calls (Redis cache hit, unaltered)', { product: options.product, tag: schema.tag });
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
console.log('[FeatureService] define creating feature on backend', { product: options.product, tag: schema.tag, cacheState: cachedFingerprint === null ? 'miss' : 'stale' });
|
|
622
|
+
const builder = await this.getProductBuilder(options.product);
|
|
623
|
+
let existing = await builder.fetchFeature(schema.tag);
|
|
624
|
+
// Real answer to "if Redis is empty, fetch all existing features, then check if the
|
|
625
|
+
// defined feature is there before recreating it": a cache miss AND no remote record for
|
|
626
|
+
// THIS tag could simply mean the cache was never warmed (e.g. first boot after a Redis
|
|
627
|
+
// flush) while the feature genuinely already exists remotely under normal operation --
|
|
628
|
+
// fetchFeature() above already covers that single-tag case, but warm the full cache here
|
|
629
|
+
// too (once per product per process) so every OTHER feature defined later in this same
|
|
630
|
+
// boot benefits from cache hits instead of each paying its own fetchFeature() call.
|
|
631
|
+
if (!this.featureCacheWarmed.has(options.product)) {
|
|
632
|
+
const allExisting = await this.warmFeatureCache(options.product, builder);
|
|
633
|
+
if (!existing)
|
|
634
|
+
existing = (_c = allExisting.get(schema.tag)) !== null && _c !== void 0 ? _c : null;
|
|
635
|
+
}
|
|
636
|
+
// Only a genuine remote WRITE (create or update) needs the post-write re-fetch below to
|
|
637
|
+
// guard against "acknowledged but not actually persisted" backend inconsistencies -- when
|
|
638
|
+
// `existing` already proves the feature is there (whether from fetchFeature's own by-tag
|
|
639
|
+
// lookup or the bulk warm fallback) and its fingerprint already matches, re-fetching by
|
|
640
|
+
// tag is not just redundant, it can genuinely fail: `existing` reaching us via the bulk
|
|
641
|
+
// warm path exists PRECISELY BECAUSE the by-tag lookup lagged behind once already for
|
|
642
|
+
// this same feature -- re-running that same lookup immediately after is not a reliable
|
|
643
|
+
// verification, it is repeating the exact race the warm fallback exists to route around.
|
|
644
|
+
let wroteRemotely = false;
|
|
645
|
+
if (existing) {
|
|
646
|
+
const existingFingerprint = this.computeFeatureFingerprint(existing);
|
|
647
|
+
if (existingFingerprint === fingerprint) {
|
|
648
|
+
console.log('[FeatureService] define: remote already matches compiled schema, skipping updateFeature', { product: options.product, tag: schema.tag });
|
|
649
|
+
}
|
|
650
|
+
else {
|
|
651
|
+
await builder.updateFeature(schema.tag, schema);
|
|
652
|
+
wroteRemotely = true;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
else {
|
|
656
|
+
await builder.createFeature(schema, true);
|
|
657
|
+
wroteRemotely = true;
|
|
658
|
+
}
|
|
659
|
+
if (wroteRemotely) {
|
|
660
|
+
const persisted = await builder.fetchFeature(schema.tag);
|
|
661
|
+
if (!persisted) {
|
|
662
|
+
throw new FeatureError(`Feature ${schema.tag} was acknowledged but is absent from the catalogue read path`, 'FEATURE_CATALOGUE_INCONSISTENT');
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
await this.setCachedFeatureFingerprint(options.product, schema.tag, fingerprint);
|
|
509
666
|
}
|
|
510
667
|
}
|
|
511
668
|
// Store locally
|