@open-mercato/search 0.4.11-develop.2543.c880fff2e7 → 0.4.11-develop.2575.e689a7bd59
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/dist/service.js +69 -10
- package/dist/service.js.map +2 -2
- package/package.json +4 -4
- package/src/__tests__/service.test.ts +163 -0
- package/src/service.ts +82 -13
package/dist/service.js
CHANGED
|
@@ -4,8 +4,11 @@ import { needsSearchResultEnrichment } from "./lib/search-result-enrichment.js";
|
|
|
4
4
|
const DEFAULT_MERGE_CONFIG = {
|
|
5
5
|
duplicateHandling: "highest_score"
|
|
6
6
|
};
|
|
7
|
+
const STRATEGY_AVAILABILITY_CACHE_TTL_MS = 2e3;
|
|
7
8
|
class SearchService {
|
|
8
9
|
constructor(options = {}) {
|
|
10
|
+
this.availabilityCache = /* @__PURE__ */ new Map();
|
|
11
|
+
this.availabilityInflight = /* @__PURE__ */ new Map();
|
|
9
12
|
this.strategies = /* @__PURE__ */ new Map();
|
|
10
13
|
for (const strategy of options.strategies ?? []) {
|
|
11
14
|
this.strategies.set(strategy.id, strategy);
|
|
@@ -14,6 +17,7 @@ class SearchService {
|
|
|
14
17
|
this.fallbackStrategy = options.fallbackStrategy;
|
|
15
18
|
this.mergeConfig = options.mergeConfig ?? DEFAULT_MERGE_CONFIG;
|
|
16
19
|
this.presenterEnricher = options.presenterEnricher;
|
|
20
|
+
this.availabilityCacheTtlMs = options.availabilityCacheTtlMs ?? STRATEGY_AVAILABILITY_CACHE_TTL_MS;
|
|
17
21
|
}
|
|
18
22
|
/**
|
|
19
23
|
* Get all registered strategies.
|
|
@@ -200,6 +204,8 @@ class SearchService {
|
|
|
200
204
|
*/
|
|
201
205
|
registerStrategy(strategy) {
|
|
202
206
|
this.strategies.set(strategy.id, strategy);
|
|
207
|
+
this.availabilityCache.delete(strategy.id);
|
|
208
|
+
this.availabilityInflight.delete(strategy.id);
|
|
203
209
|
}
|
|
204
210
|
/**
|
|
205
211
|
* Unregister a strategy.
|
|
@@ -208,6 +214,22 @@ class SearchService {
|
|
|
208
214
|
*/
|
|
209
215
|
unregisterStrategy(strategyId) {
|
|
210
216
|
this.strategies.delete(strategyId);
|
|
217
|
+
this.availabilityCache.delete(strategyId);
|
|
218
|
+
this.availabilityInflight.delete(strategyId);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Invalidate the strategy availability cache.
|
|
222
|
+
* Call after manual reconnects or env changes when callers must observe the
|
|
223
|
+
* current backend state immediately rather than waiting for TTL expiry.
|
|
224
|
+
*/
|
|
225
|
+
invalidateAvailabilityCache(strategyId) {
|
|
226
|
+
if (strategyId) {
|
|
227
|
+
this.availabilityCache.delete(strategyId);
|
|
228
|
+
this.availabilityInflight.delete(strategyId);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
this.availabilityCache.clear();
|
|
232
|
+
this.availabilityInflight.clear();
|
|
211
233
|
}
|
|
212
234
|
/**
|
|
213
235
|
* Get all registered strategy IDs.
|
|
@@ -238,25 +260,62 @@ class SearchService {
|
|
|
238
260
|
async isStrategyAvailable(strategyId) {
|
|
239
261
|
const strategy = this.strategies.get(strategyId);
|
|
240
262
|
if (!strategy) return false;
|
|
241
|
-
return
|
|
263
|
+
return this.checkStrategyAvailability(strategy);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Resolve a strategy's availability via the short-lived TTL cache.
|
|
267
|
+
* Coalesces concurrent callers onto a single in-flight probe to avoid
|
|
268
|
+
* thundering-herd on remote backends.
|
|
269
|
+
*/
|
|
270
|
+
async checkStrategyAvailability(strategy) {
|
|
271
|
+
const now = Date.now();
|
|
272
|
+
const cached = this.availabilityCache.get(strategy.id);
|
|
273
|
+
if (cached && cached.expiresAt > now) return cached.value;
|
|
274
|
+
const inflight = this.availabilityInflight.get(strategy.id);
|
|
275
|
+
if (inflight) return inflight;
|
|
276
|
+
const probe = (async () => {
|
|
277
|
+
try {
|
|
278
|
+
const value = await strategy.isAvailable();
|
|
279
|
+
this.availabilityCache.set(strategy.id, {
|
|
280
|
+
value,
|
|
281
|
+
expiresAt: Date.now() + this.availabilityCacheTtlMs
|
|
282
|
+
});
|
|
283
|
+
return value;
|
|
284
|
+
} catch {
|
|
285
|
+
this.availabilityCache.set(strategy.id, {
|
|
286
|
+
value: false,
|
|
287
|
+
expiresAt: Date.now() + this.availabilityCacheTtlMs
|
|
288
|
+
});
|
|
289
|
+
return false;
|
|
290
|
+
} finally {
|
|
291
|
+
this.availabilityInflight.delete(strategy.id);
|
|
292
|
+
}
|
|
293
|
+
})();
|
|
294
|
+
this.availabilityInflight.set(strategy.id, probe);
|
|
295
|
+
return probe;
|
|
242
296
|
}
|
|
243
297
|
/**
|
|
244
298
|
* Get available strategies from the requested list.
|
|
245
299
|
* Filters out strategies that are not registered or not available.
|
|
300
|
+
* Probes run in parallel and reuse a short-lived per-strategy availability
|
|
301
|
+
* cache, so hot paths pay the max latency of the slowest probe (or zero
|
|
302
|
+
* when cached) instead of the sum of all probes.
|
|
246
303
|
*/
|
|
247
304
|
async getAvailableStrategies(ids) {
|
|
248
305
|
const targetIds = ids ?? Array.from(this.strategies.keys());
|
|
249
|
-
const
|
|
306
|
+
const candidates = [];
|
|
250
307
|
for (const id of targetIds) {
|
|
251
308
|
const strategy = this.strategies.get(id);
|
|
252
|
-
if (strategy)
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
309
|
+
if (strategy) candidates.push(strategy);
|
|
310
|
+
}
|
|
311
|
+
const probes = await Promise.allSettled(
|
|
312
|
+
candidates.map((strategy) => this.checkStrategyAvailability(strategy))
|
|
313
|
+
);
|
|
314
|
+
const available = [];
|
|
315
|
+
for (let i = 0; i < probes.length; i++) {
|
|
316
|
+
const probe = probes[i];
|
|
317
|
+
if (probe.status === "fulfilled" && probe.value) {
|
|
318
|
+
available.push(candidates[i]);
|
|
260
319
|
}
|
|
261
320
|
}
|
|
262
321
|
return available.sort((a, b) => b.priority - a.priority);
|
package/dist/service.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/service.ts"],
|
|
4
|
-
"sourcesContent": ["import type {\n SearchStrategy,\n SearchStrategyId,\n SearchOptions,\n SearchResult,\n SearchServiceOptions,\n ResultMergeConfig,\n IndexableRecord,\n PresenterEnricherFn,\n} from './types'\nimport { mergeAndRankResults } from './lib/merger'\nimport { searchError } from './lib/debug'\nimport { needsSearchResultEnrichment } from './lib/search-result-enrichment'\n\n/**\n * Default merge configuration.\n */\nconst DEFAULT_MERGE_CONFIG: ResultMergeConfig = {\n duplicateHandling: 'highest_score',\n}\n\n/**\n * SearchService orchestrates multiple search strategies, executing searches in parallel\n * and merging results using the RRF algorithm.\n *\n * Features:\n * - Parallel strategy execution for optimal performance\n * - Graceful degradation when strategies fail\n * - Result merging with configurable weights\n * - Strategy availability checking\n *\n * @example\n * ```typescript\n * const service = new SearchService({\n * strategies: [tokenStrategy, vectorStrategy, fulltextStrategy],\n * defaultStrategies: ['fulltext', 'vector', 'tokens'],\n * mergeConfig: {\n * duplicateHandling: 'highest_score',\n * strategyWeights: { fulltext: 1.2, vector: 1.0, tokens: 0.8 },\n * },\n * })\n *\n * const results = await service.search('john doe', { tenantId: 'tenant-123' })\n * ```\n */\nexport class SearchService {\n private readonly strategies: Map<SearchStrategyId, SearchStrategy>\n private readonly defaultStrategies: SearchStrategyId[]\n private readonly fallbackStrategy: SearchStrategyId | undefined\n private readonly mergeConfig: ResultMergeConfig\n private readonly presenterEnricher?: PresenterEnricherFn\n\n constructor(options: SearchServiceOptions = {}) {\n this.strategies = new Map()\n for (const strategy of options.strategies ?? []) {\n this.strategies.set(strategy.id, strategy)\n }\n this.defaultStrategies = options.defaultStrategies ?? ['tokens']\n this.fallbackStrategy = options.fallbackStrategy\n this.mergeConfig = options.mergeConfig ?? DEFAULT_MERGE_CONFIG\n this.presenterEnricher = options.presenterEnricher\n }\n\n /**\n * Get all registered strategies.\n */\n getStrategies(): SearchStrategy[] {\n return Array.from(this.strategies.values())\n }\n\n /**\n * Execute a search query across configured strategies.\n *\n * @param query - Search query string\n * @param options - Search options with tenant, filters, etc.\n * @returns Merged and ranked search results\n */\n async search(query: string, options: SearchOptions): Promise<SearchResult[]> {\n const strategyIds = options.strategies ?? this.defaultStrategies\n const activeStrategies = await this.getAvailableStrategies(strategyIds)\n\n if (activeStrategies.length === 0) {\n // Try fallback strategy if defined\n if (this.fallbackStrategy) {\n const fallback = await this.getAvailableStrategies([this.fallbackStrategy])\n if (fallback.length > 0) {\n activeStrategies.push(...fallback)\n }\n }\n }\n\n if (activeStrategies.length === 0) {\n return []\n }\n\n // Execute searches in parallel with graceful degradation\n const results = await Promise.allSettled(\n activeStrategies.map((strategy) => this.executeStrategySearch(strategy, query, options)),\n )\n\n // Collect successful results, log failures\n const allResults: SearchResult[] = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'fulfilled') {\n allResults.push(...result.value)\n } else {\n const strategy = activeStrategies[i]\n searchError('SearchService', 'Strategy search failed', {\n strategyId: strategy?.id,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n\n // Merge and rank results\n const merged = mergeAndRankResults(allResults, this.mergeConfig)\n\n // Enrich results missing presenter or navigation metadata\n return this.enrichResultsWithPresenter(merged, options.tenantId, options.organizationId)\n }\n\n /**\n * Enrich results that are missing presenter data using the configured enricher.\n * This ensures token-only results get proper titles/subtitles for display.\n */\n private async enrichResultsWithPresenter(\n results: SearchResult[],\n tenantId: string,\n organizationId?: string | null,\n ): Promise<SearchResult[]> {\n // If no enricher configured, return as-is\n if (!this.presenterEnricher) return results\n\n const hasMissing = results.some(needsSearchResultEnrichment)\n if (!hasMissing) return results\n\n // Use the configured presenter enricher\n try {\n return await this.presenterEnricher(results, tenantId, organizationId)\n } catch {\n // Enrichment failed, return results as-is\n return results\n }\n }\n\n /**\n * Index a record across all available strategies.\n *\n * @param record - Record to index\n */\n async index(record: IndexableRecord): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n if (strategies.length === 0) {\n return\n }\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyIndex(strategy, record)),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy index failed', {\n strategyId: strategy?.id,\n entityId: record.entityId,\n recordId: record.recordId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Delete a record from all strategies.\n *\n * @param entityId - Entity type identifier\n * @param recordId - Record primary key\n * @param tenantId - Tenant for isolation\n */\n async delete(entityId: string, recordId: string, tenantId: string): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyDelete(strategy, entityId, recordId, tenantId)),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy delete failed', {\n strategyId: strategy?.id,\n entityId,\n recordId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Bulk index multiple records.\n *\n * @param records - Records to index\n */\n async bulkIndex(records: IndexableRecord[]): Promise<void> {\n if (records.length === 0) return\n\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.bulkIndex) {\n return strategy.bulkIndex(records)\n }\n // Fallback to individual indexing\n return Promise.all(records.map((record) => this.executeStrategyIndex(strategy, record)))\n }),\n )\n\n // Collect failures and throw if any occurred\n const failures: Array<{ strategyId: string; error: string }> = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n const errorMessage = result.reason instanceof Error ? result.reason.message : result.reason\n failures.push({\n strategyId: strategy?.id || 'unknown',\n error: errorMessage,\n })\n searchError('SearchService', 'Strategy bulkIndex failed', {\n strategyId: strategy?.id,\n recordCount: records.length,\n error: errorMessage,\n })\n }\n }\n\n if (failures.length > 0) {\n throw new Error(\n `Bulk indexing failed for ${failures.length} strategy(ies): ${failures\n .map((f) => `${f.strategyId} (${f.error})`)\n .join(', ')}`\n )\n }\n }\n\n /**\n * Purge all records for an entity type.\n *\n * @param entityId - Entity type to purge\n * @param tenantId - Tenant for isolation\n */\n async purge(entityId: string, tenantId: string): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.purge) {\n return strategy.purge(entityId, tenantId)\n }\n return Promise.resolve()\n }),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy purge failed', {\n strategyId: strategy?.id,\n entityId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Register a new strategy at runtime.\n *\n * @param strategy - Strategy to register\n */\n registerStrategy(strategy: SearchStrategy): void {\n this.strategies.set(strategy.id, strategy)\n }\n\n /**\n * Unregister a strategy.\n *\n * @param strategyId - Strategy ID to remove\n */\n unregisterStrategy(strategyId: SearchStrategyId): void {\n this.strategies.delete(strategyId)\n }\n\n /**\n * Get all registered strategy IDs.\n */\n getRegisteredStrategies(): SearchStrategyId[] {\n return Array.from(this.strategies.keys())\n }\n\n /**\n * Get a specific strategy by ID.\n *\n * @param strategyId - Strategy ID to retrieve\n * @returns The strategy if registered, undefined otherwise\n */\n getStrategy(strategyId: SearchStrategyId): SearchStrategy | undefined {\n return this.strategies.get(strategyId)\n }\n\n /**\n * Get the default strategies list.\n */\n getDefaultStrategies(): SearchStrategyId[] {\n return [...this.defaultStrategies]\n }\n\n /**\n * Check if a specific strategy is available.\n *\n * @param strategyId - Strategy ID to check\n */\n async isStrategyAvailable(strategyId: SearchStrategyId): Promise<boolean> {\n const strategy = this.strategies.get(strategyId)\n if (!strategy) return false\n return strategy.isAvailable()\n }\n\n /**\n * Get available strategies from the requested list.\n * Filters out strategies that are not registered or not available.\n */\n private async getAvailableStrategies(ids?: SearchStrategyId[]): Promise<SearchStrategy[]> {\n const targetIds = ids ?? Array.from(this.strategies.keys())\n const available: SearchStrategy[] = []\n\n for (const id of targetIds) {\n const strategy = this.strategies.get(id)\n if (strategy) {\n try {\n const isAvailable = await strategy.isAvailable()\n if (isAvailable) {\n available.push(strategy)\n }\n } catch {\n // Strategy availability check failed, skip it\n }\n }\n }\n\n // Sort by priority (higher priority first)\n return available.sort((a, b) => b.priority - a.priority)\n }\n\n /**\n * Execute search on a single strategy with error handling.\n */\n private async executeStrategySearch(\n strategy: SearchStrategy,\n query: string,\n options: SearchOptions,\n ): Promise<SearchResult[]> {\n await strategy.ensureReady()\n return strategy.search(query, options)\n }\n\n /**\n * Execute index on a single strategy with error handling.\n */\n private async executeStrategyIndex(\n strategy: SearchStrategy,\n record: IndexableRecord,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.index(record)\n }\n\n /**\n * Execute delete on a single strategy with error handling.\n */\n private async executeStrategyDelete(\n strategy: SearchStrategy,\n entityId: string,\n recordId: string,\n tenantId: string,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.delete(entityId, recordId, tenantId)\n }\n}\n"],
|
|
5
|
-
"mappings": "AAUA,SAAS,2BAA2B;AACpC,SAAS,mBAAmB;AAC5B,SAAS,mCAAmC;AAK5C,MAAM,uBAA0C;AAAA,EAC9C,mBAAmB;AACrB;
|
|
4
|
+
"sourcesContent": ["import type {\n SearchStrategy,\n SearchStrategyId,\n SearchOptions,\n SearchResult,\n SearchServiceOptions,\n ResultMergeConfig,\n IndexableRecord,\n PresenterEnricherFn,\n} from './types'\nimport { mergeAndRankResults } from './lib/merger'\nimport { searchError } from './lib/debug'\nimport { needsSearchResultEnrichment } from './lib/search-result-enrichment'\n\n/**\n * Default merge configuration.\n */\nconst DEFAULT_MERGE_CONFIG: ResultMergeConfig = {\n duplicateHandling: 'highest_score',\n}\n\n/**\n * Cache TTL for strategy availability checks.\n * Short window so connectivity changes (Meilisearch up/down) propagate quickly,\n * long enough to skip per-request RTT to remote backends on hot paths.\n */\nconst STRATEGY_AVAILABILITY_CACHE_TTL_MS = 2_000\n\n/**\n * SearchService orchestrates multiple search strategies, executing searches in parallel\n * and merging results using the RRF algorithm.\n *\n * Features:\n * - Parallel strategy execution for optimal performance\n * - Graceful degradation when strategies fail\n * - Result merging with configurable weights\n * - Strategy availability checking\n *\n * @example\n * ```typescript\n * const service = new SearchService({\n * strategies: [tokenStrategy, vectorStrategy, fulltextStrategy],\n * defaultStrategies: ['fulltext', 'vector', 'tokens'],\n * mergeConfig: {\n * duplicateHandling: 'highest_score',\n * strategyWeights: { fulltext: 1.2, vector: 1.0, tokens: 0.8 },\n * },\n * })\n *\n * const results = await service.search('john doe', { tenantId: 'tenant-123' })\n * ```\n */\nexport class SearchService {\n private readonly strategies: Map<SearchStrategyId, SearchStrategy>\n private readonly defaultStrategies: SearchStrategyId[]\n private readonly fallbackStrategy: SearchStrategyId | undefined\n private readonly mergeConfig: ResultMergeConfig\n private readonly presenterEnricher?: PresenterEnricherFn\n private readonly availabilityCache = new Map<SearchStrategyId, { value: boolean; expiresAt: number }>()\n private readonly availabilityInflight = new Map<SearchStrategyId, Promise<boolean>>()\n private readonly availabilityCacheTtlMs: number\n\n constructor(options: SearchServiceOptions = {}) {\n this.strategies = new Map()\n for (const strategy of options.strategies ?? []) {\n this.strategies.set(strategy.id, strategy)\n }\n this.defaultStrategies = options.defaultStrategies ?? ['tokens']\n this.fallbackStrategy = options.fallbackStrategy\n this.mergeConfig = options.mergeConfig ?? DEFAULT_MERGE_CONFIG\n this.presenterEnricher = options.presenterEnricher\n this.availabilityCacheTtlMs = options.availabilityCacheTtlMs ?? STRATEGY_AVAILABILITY_CACHE_TTL_MS\n }\n\n /**\n * Get all registered strategies.\n */\n getStrategies(): SearchStrategy[] {\n return Array.from(this.strategies.values())\n }\n\n /**\n * Execute a search query across configured strategies.\n *\n * @param query - Search query string\n * @param options - Search options with tenant, filters, etc.\n * @returns Merged and ranked search results\n */\n async search(query: string, options: SearchOptions): Promise<SearchResult[]> {\n const strategyIds = options.strategies ?? this.defaultStrategies\n const activeStrategies = await this.getAvailableStrategies(strategyIds)\n\n if (activeStrategies.length === 0) {\n // Try fallback strategy if defined\n if (this.fallbackStrategy) {\n const fallback = await this.getAvailableStrategies([this.fallbackStrategy])\n if (fallback.length > 0) {\n activeStrategies.push(...fallback)\n }\n }\n }\n\n if (activeStrategies.length === 0) {\n return []\n }\n\n // Execute searches in parallel with graceful degradation\n const results = await Promise.allSettled(\n activeStrategies.map((strategy) => this.executeStrategySearch(strategy, query, options)),\n )\n\n // Collect successful results, log failures\n const allResults: SearchResult[] = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'fulfilled') {\n allResults.push(...result.value)\n } else {\n const strategy = activeStrategies[i]\n searchError('SearchService', 'Strategy search failed', {\n strategyId: strategy?.id,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n\n // Merge and rank results\n const merged = mergeAndRankResults(allResults, this.mergeConfig)\n\n // Enrich results missing presenter or navigation metadata\n return this.enrichResultsWithPresenter(merged, options.tenantId, options.organizationId)\n }\n\n /**\n * Enrich results that are missing presenter data using the configured enricher.\n * This ensures token-only results get proper titles/subtitles for display.\n */\n private async enrichResultsWithPresenter(\n results: SearchResult[],\n tenantId: string,\n organizationId?: string | null,\n ): Promise<SearchResult[]> {\n // If no enricher configured, return as-is\n if (!this.presenterEnricher) return results\n\n const hasMissing = results.some(needsSearchResultEnrichment)\n if (!hasMissing) return results\n\n // Use the configured presenter enricher\n try {\n return await this.presenterEnricher(results, tenantId, organizationId)\n } catch {\n // Enrichment failed, return results as-is\n return results\n }\n }\n\n /**\n * Index a record across all available strategies.\n *\n * @param record - Record to index\n */\n async index(record: IndexableRecord): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n if (strategies.length === 0) {\n return\n }\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyIndex(strategy, record)),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy index failed', {\n strategyId: strategy?.id,\n entityId: record.entityId,\n recordId: record.recordId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Delete a record from all strategies.\n *\n * @param entityId - Entity type identifier\n * @param recordId - Record primary key\n * @param tenantId - Tenant for isolation\n */\n async delete(entityId: string, recordId: string, tenantId: string): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyDelete(strategy, entityId, recordId, tenantId)),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy delete failed', {\n strategyId: strategy?.id,\n entityId,\n recordId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Bulk index multiple records.\n *\n * @param records - Records to index\n */\n async bulkIndex(records: IndexableRecord[]): Promise<void> {\n if (records.length === 0) return\n\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.bulkIndex) {\n return strategy.bulkIndex(records)\n }\n // Fallback to individual indexing\n return Promise.all(records.map((record) => this.executeStrategyIndex(strategy, record)))\n }),\n )\n\n // Collect failures and throw if any occurred\n const failures: Array<{ strategyId: string; error: string }> = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n const errorMessage = result.reason instanceof Error ? result.reason.message : result.reason\n failures.push({\n strategyId: strategy?.id || 'unknown',\n error: errorMessage,\n })\n searchError('SearchService', 'Strategy bulkIndex failed', {\n strategyId: strategy?.id,\n recordCount: records.length,\n error: errorMessage,\n })\n }\n }\n\n if (failures.length > 0) {\n throw new Error(\n `Bulk indexing failed for ${failures.length} strategy(ies): ${failures\n .map((f) => `${f.strategyId} (${f.error})`)\n .join(', ')}`\n )\n }\n }\n\n /**\n * Purge all records for an entity type.\n *\n * @param entityId - Entity type to purge\n * @param tenantId - Tenant for isolation\n */\n async purge(entityId: string, tenantId: string): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.purge) {\n return strategy.purge(entityId, tenantId)\n }\n return Promise.resolve()\n }),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy purge failed', {\n strategyId: strategy?.id,\n entityId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Register a new strategy at runtime.\n *\n * @param strategy - Strategy to register\n */\n registerStrategy(strategy: SearchStrategy): void {\n this.strategies.set(strategy.id, strategy)\n this.availabilityCache.delete(strategy.id)\n this.availabilityInflight.delete(strategy.id)\n }\n\n /**\n * Unregister a strategy.\n *\n * @param strategyId - Strategy ID to remove\n */\n unregisterStrategy(strategyId: SearchStrategyId): void {\n this.strategies.delete(strategyId)\n this.availabilityCache.delete(strategyId)\n this.availabilityInflight.delete(strategyId)\n }\n\n /**\n * Invalidate the strategy availability cache.\n * Call after manual reconnects or env changes when callers must observe the\n * current backend state immediately rather than waiting for TTL expiry.\n */\n invalidateAvailabilityCache(strategyId?: SearchStrategyId): void {\n if (strategyId) {\n this.availabilityCache.delete(strategyId)\n this.availabilityInflight.delete(strategyId)\n return\n }\n this.availabilityCache.clear()\n this.availabilityInflight.clear()\n }\n\n /**\n * Get all registered strategy IDs.\n */\n getRegisteredStrategies(): SearchStrategyId[] {\n return Array.from(this.strategies.keys())\n }\n\n /**\n * Get a specific strategy by ID.\n *\n * @param strategyId - Strategy ID to retrieve\n * @returns The strategy if registered, undefined otherwise\n */\n getStrategy(strategyId: SearchStrategyId): SearchStrategy | undefined {\n return this.strategies.get(strategyId)\n }\n\n /**\n * Get the default strategies list.\n */\n getDefaultStrategies(): SearchStrategyId[] {\n return [...this.defaultStrategies]\n }\n\n /**\n * Check if a specific strategy is available.\n *\n * @param strategyId - Strategy ID to check\n */\n async isStrategyAvailable(strategyId: SearchStrategyId): Promise<boolean> {\n const strategy = this.strategies.get(strategyId)\n if (!strategy) return false\n return this.checkStrategyAvailability(strategy)\n }\n\n /**\n * Resolve a strategy's availability via the short-lived TTL cache.\n * Coalesces concurrent callers onto a single in-flight probe to avoid\n * thundering-herd on remote backends.\n */\n private async checkStrategyAvailability(strategy: SearchStrategy): Promise<boolean> {\n const now = Date.now()\n const cached = this.availabilityCache.get(strategy.id)\n if (cached && cached.expiresAt > now) return cached.value\n\n const inflight = this.availabilityInflight.get(strategy.id)\n if (inflight) return inflight\n\n const probe = (async () => {\n try {\n const value = await strategy.isAvailable()\n this.availabilityCache.set(strategy.id, {\n value,\n expiresAt: Date.now() + this.availabilityCacheTtlMs,\n })\n return value\n } catch {\n this.availabilityCache.set(strategy.id, {\n value: false,\n expiresAt: Date.now() + this.availabilityCacheTtlMs,\n })\n return false\n } finally {\n this.availabilityInflight.delete(strategy.id)\n }\n })()\n this.availabilityInflight.set(strategy.id, probe)\n return probe\n }\n\n /**\n * Get available strategies from the requested list.\n * Filters out strategies that are not registered or not available.\n * Probes run in parallel and reuse a short-lived per-strategy availability\n * cache, so hot paths pay the max latency of the slowest probe (or zero\n * when cached) instead of the sum of all probes.\n */\n private async getAvailableStrategies(ids?: SearchStrategyId[]): Promise<SearchStrategy[]> {\n const targetIds = ids ?? Array.from(this.strategies.keys())\n const candidates: SearchStrategy[] = []\n for (const id of targetIds) {\n const strategy = this.strategies.get(id)\n if (strategy) candidates.push(strategy)\n }\n\n const probes = await Promise.allSettled(\n candidates.map((strategy) => this.checkStrategyAvailability(strategy)),\n )\n\n const available: SearchStrategy[] = []\n for (let i = 0; i < probes.length; i++) {\n const probe = probes[i]\n if (probe.status === 'fulfilled' && probe.value) {\n available.push(candidates[i])\n }\n }\n\n return available.sort((a, b) => b.priority - a.priority)\n }\n\n /**\n * Execute search on a single strategy with error handling.\n */\n private async executeStrategySearch(\n strategy: SearchStrategy,\n query: string,\n options: SearchOptions,\n ): Promise<SearchResult[]> {\n await strategy.ensureReady()\n return strategy.search(query, options)\n }\n\n /**\n * Execute index on a single strategy with error handling.\n */\n private async executeStrategyIndex(\n strategy: SearchStrategy,\n record: IndexableRecord,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.index(record)\n }\n\n /**\n * Execute delete on a single strategy with error handling.\n */\n private async executeStrategyDelete(\n strategy: SearchStrategy,\n entityId: string,\n recordId: string,\n tenantId: string,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.delete(entityId, recordId, tenantId)\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/search",
|
|
3
|
-
"version": "0.4.11-develop.
|
|
3
|
+
"version": "0.4.11-develop.2575.e689a7bd59",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -126,9 +126,9 @@
|
|
|
126
126
|
"zod": "^4.0.0"
|
|
127
127
|
},
|
|
128
128
|
"peerDependencies": {
|
|
129
|
-
"@open-mercato/core": "0.4.11-develop.
|
|
130
|
-
"@open-mercato/queue": "0.4.11-develop.
|
|
131
|
-
"@open-mercato/shared": "0.4.11-develop.
|
|
129
|
+
"@open-mercato/core": "0.4.11-develop.2575.e689a7bd59",
|
|
130
|
+
"@open-mercato/queue": "0.4.11-develop.2575.e689a7bd59",
|
|
131
|
+
"@open-mercato/shared": "0.4.11-develop.2575.e689a7bd59"
|
|
132
132
|
},
|
|
133
133
|
"devDependencies": {
|
|
134
134
|
"@types/jest": "^30.0.0",
|
|
@@ -416,4 +416,167 @@ describe('SearchService', () => {
|
|
|
416
416
|
expect(strategyWithPurge.purge).toHaveBeenCalledWith('test:entity', 'tenant-123')
|
|
417
417
|
})
|
|
418
418
|
})
|
|
419
|
+
|
|
420
|
+
describe('availability checks (issue #1404)', () => {
|
|
421
|
+
it('runs strategy availability probes in parallel, not sequentially', async () => {
|
|
422
|
+
const probeTimings: Record<string, { start: number; end: number }> = {}
|
|
423
|
+
const makeSlowStrategy = (id: string, delayMs: number) =>
|
|
424
|
+
createMockStrategy({
|
|
425
|
+
id,
|
|
426
|
+
isAvailable: jest.fn().mockImplementation(async () => {
|
|
427
|
+
const start = Date.now()
|
|
428
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
|
429
|
+
probeTimings[id] = { start, end: Date.now() }
|
|
430
|
+
return true
|
|
431
|
+
}),
|
|
432
|
+
search: jest.fn().mockResolvedValue([]),
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
const slowA = makeSlowStrategy('slow-a', 60)
|
|
436
|
+
const slowB = makeSlowStrategy('slow-b', 60)
|
|
437
|
+
const slowC = makeSlowStrategy('slow-c', 60)
|
|
438
|
+
const service = new SearchService({
|
|
439
|
+
strategies: [slowA, slowB, slowC],
|
|
440
|
+
defaultStrategies: ['slow-a', 'slow-b', 'slow-c'],
|
|
441
|
+
availabilityCacheTtlMs: 0,
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
const start = Date.now()
|
|
445
|
+
await service.search('q', { tenantId: 't-1' })
|
|
446
|
+
const elapsed = Date.now() - start
|
|
447
|
+
|
|
448
|
+
// Sequential would be ~180ms; parallel should land near the slowest probe.
|
|
449
|
+
expect(elapsed).toBeLessThan(150)
|
|
450
|
+
expect(slowA.isAvailable).toHaveBeenCalledTimes(1)
|
|
451
|
+
expect(slowB.isAvailable).toHaveBeenCalledTimes(1)
|
|
452
|
+
expect(slowC.isAvailable).toHaveBeenCalledTimes(1)
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('caches positive availability checks within the TTL window', async () => {
|
|
456
|
+
const strategy = createMockStrategy({
|
|
457
|
+
id: 'cached',
|
|
458
|
+
isAvailable: jest.fn().mockResolvedValue(true),
|
|
459
|
+
search: jest.fn().mockResolvedValue([]),
|
|
460
|
+
})
|
|
461
|
+
const service = new SearchService({
|
|
462
|
+
strategies: [strategy],
|
|
463
|
+
defaultStrategies: ['cached'],
|
|
464
|
+
availabilityCacheTtlMs: 60_000,
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
await service.search('q', { tenantId: 't-1' })
|
|
468
|
+
await service.search('q', { tenantId: 't-1' })
|
|
469
|
+
await service.search('q', { tenantId: 't-1' })
|
|
470
|
+
|
|
471
|
+
expect(strategy.isAvailable).toHaveBeenCalledTimes(1)
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it('caches negative availability checks within the TTL window', async () => {
|
|
475
|
+
const strategy = createMockStrategy({
|
|
476
|
+
id: 'down',
|
|
477
|
+
isAvailable: jest.fn().mockResolvedValue(false),
|
|
478
|
+
search: jest.fn().mockResolvedValue([]),
|
|
479
|
+
})
|
|
480
|
+
const service = new SearchService({
|
|
481
|
+
strategies: [strategy],
|
|
482
|
+
defaultStrategies: ['down'],
|
|
483
|
+
availabilityCacheTtlMs: 60_000,
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
await service.search('q', { tenantId: 't-1' })
|
|
487
|
+
await service.search('q', { tenantId: 't-1' })
|
|
488
|
+
|
|
489
|
+
expect(strategy.isAvailable).toHaveBeenCalledTimes(1)
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
it('caches thrown availability errors as unavailable within the TTL window', async () => {
|
|
493
|
+
const strategy = createMockStrategy({
|
|
494
|
+
id: 'flaky',
|
|
495
|
+
isAvailable: jest.fn().mockRejectedValue(new Error('boom')),
|
|
496
|
+
search: jest.fn().mockResolvedValue([]),
|
|
497
|
+
})
|
|
498
|
+
const service = new SearchService({
|
|
499
|
+
strategies: [strategy],
|
|
500
|
+
defaultStrategies: ['flaky'],
|
|
501
|
+
availabilityCacheTtlMs: 60_000,
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
const r1 = await service.search('q', { tenantId: 't-1' })
|
|
505
|
+
const r2 = await service.search('q', { tenantId: 't-1' })
|
|
506
|
+
|
|
507
|
+
expect(r1).toEqual([])
|
|
508
|
+
expect(r2).toEqual([])
|
|
509
|
+
expect(strategy.isAvailable).toHaveBeenCalledTimes(1)
|
|
510
|
+
expect(strategy.search).not.toHaveBeenCalled()
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
it('coalesces concurrent probes of the same strategy onto a single in-flight call', async () => {
|
|
514
|
+
let resolveProbe: ((value: boolean) => void) | undefined
|
|
515
|
+
const strategy = createMockStrategy({
|
|
516
|
+
id: 'coalesced',
|
|
517
|
+
isAvailable: jest.fn().mockImplementation(
|
|
518
|
+
() =>
|
|
519
|
+
new Promise<boolean>((resolve) => {
|
|
520
|
+
resolveProbe = resolve
|
|
521
|
+
}),
|
|
522
|
+
),
|
|
523
|
+
search: jest.fn().mockResolvedValue([]),
|
|
524
|
+
})
|
|
525
|
+
const service = new SearchService({
|
|
526
|
+
strategies: [strategy],
|
|
527
|
+
defaultStrategies: ['coalesced'],
|
|
528
|
+
availabilityCacheTtlMs: 0,
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
const p1 = service.search('q', { tenantId: 't-1' })
|
|
532
|
+
const p2 = service.search('q', { tenantId: 't-1' })
|
|
533
|
+
const p3 = service.isStrategyAvailable('coalesced')
|
|
534
|
+
|
|
535
|
+
// Wait a tick so all three callers register their probes.
|
|
536
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
537
|
+
resolveProbe?.(true)
|
|
538
|
+
await Promise.all([p1, p2, p3])
|
|
539
|
+
|
|
540
|
+
expect(strategy.isAvailable).toHaveBeenCalledTimes(1)
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
it('invalidates the availability cache on demand', async () => {
|
|
544
|
+
const strategy = createMockStrategy({
|
|
545
|
+
id: 'invalidated',
|
|
546
|
+
isAvailable: jest.fn().mockResolvedValue(true),
|
|
547
|
+
search: jest.fn().mockResolvedValue([]),
|
|
548
|
+
})
|
|
549
|
+
const service = new SearchService({
|
|
550
|
+
strategies: [strategy],
|
|
551
|
+
defaultStrategies: ['invalidated'],
|
|
552
|
+
availabilityCacheTtlMs: 60_000,
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
await service.search('q', { tenantId: 't-1' })
|
|
556
|
+
service.invalidateAvailabilityCache('invalidated')
|
|
557
|
+
await service.search('q', { tenantId: 't-1' })
|
|
558
|
+
|
|
559
|
+
expect(strategy.isAvailable).toHaveBeenCalledTimes(2)
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
it('invalidates cached availability when a strategy is unregistered and re-registered', async () => {
|
|
563
|
+
const strategy = createMockStrategy({
|
|
564
|
+
id: 'reregistered',
|
|
565
|
+
isAvailable: jest.fn().mockResolvedValue(true),
|
|
566
|
+
search: jest.fn().mockResolvedValue([]),
|
|
567
|
+
})
|
|
568
|
+
const service = new SearchService({
|
|
569
|
+
strategies: [strategy],
|
|
570
|
+
defaultStrategies: ['reregistered'],
|
|
571
|
+
availabilityCacheTtlMs: 60_000,
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
await service.search('q', { tenantId: 't-1' })
|
|
575
|
+
service.unregisterStrategy('reregistered')
|
|
576
|
+
service.registerStrategy(strategy)
|
|
577
|
+
await service.search('q', { tenantId: 't-1' })
|
|
578
|
+
|
|
579
|
+
expect(strategy.isAvailable).toHaveBeenCalledTimes(2)
|
|
580
|
+
})
|
|
581
|
+
})
|
|
419
582
|
})
|
package/src/service.ts
CHANGED
|
@@ -19,6 +19,13 @@ const DEFAULT_MERGE_CONFIG: ResultMergeConfig = {
|
|
|
19
19
|
duplicateHandling: 'highest_score',
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Cache TTL for strategy availability checks.
|
|
24
|
+
* Short window so connectivity changes (Meilisearch up/down) propagate quickly,
|
|
25
|
+
* long enough to skip per-request RTT to remote backends on hot paths.
|
|
26
|
+
*/
|
|
27
|
+
const STRATEGY_AVAILABILITY_CACHE_TTL_MS = 2_000
|
|
28
|
+
|
|
22
29
|
/**
|
|
23
30
|
* SearchService orchestrates multiple search strategies, executing searches in parallel
|
|
24
31
|
* and merging results using the RRF algorithm.
|
|
@@ -49,6 +56,9 @@ export class SearchService {
|
|
|
49
56
|
private readonly fallbackStrategy: SearchStrategyId | undefined
|
|
50
57
|
private readonly mergeConfig: ResultMergeConfig
|
|
51
58
|
private readonly presenterEnricher?: PresenterEnricherFn
|
|
59
|
+
private readonly availabilityCache = new Map<SearchStrategyId, { value: boolean; expiresAt: number }>()
|
|
60
|
+
private readonly availabilityInflight = new Map<SearchStrategyId, Promise<boolean>>()
|
|
61
|
+
private readonly availabilityCacheTtlMs: number
|
|
52
62
|
|
|
53
63
|
constructor(options: SearchServiceOptions = {}) {
|
|
54
64
|
this.strategies = new Map()
|
|
@@ -59,6 +69,7 @@ export class SearchService {
|
|
|
59
69
|
this.fallbackStrategy = options.fallbackStrategy
|
|
60
70
|
this.mergeConfig = options.mergeConfig ?? DEFAULT_MERGE_CONFIG
|
|
61
71
|
this.presenterEnricher = options.presenterEnricher
|
|
72
|
+
this.availabilityCacheTtlMs = options.availabilityCacheTtlMs ?? STRATEGY_AVAILABILITY_CACHE_TTL_MS
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
/**
|
|
@@ -291,6 +302,8 @@ export class SearchService {
|
|
|
291
302
|
*/
|
|
292
303
|
registerStrategy(strategy: SearchStrategy): void {
|
|
293
304
|
this.strategies.set(strategy.id, strategy)
|
|
305
|
+
this.availabilityCache.delete(strategy.id)
|
|
306
|
+
this.availabilityInflight.delete(strategy.id)
|
|
294
307
|
}
|
|
295
308
|
|
|
296
309
|
/**
|
|
@@ -300,6 +313,23 @@ export class SearchService {
|
|
|
300
313
|
*/
|
|
301
314
|
unregisterStrategy(strategyId: SearchStrategyId): void {
|
|
302
315
|
this.strategies.delete(strategyId)
|
|
316
|
+
this.availabilityCache.delete(strategyId)
|
|
317
|
+
this.availabilityInflight.delete(strategyId)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Invalidate the strategy availability cache.
|
|
322
|
+
* Call after manual reconnects or env changes when callers must observe the
|
|
323
|
+
* current backend state immediately rather than waiting for TTL expiry.
|
|
324
|
+
*/
|
|
325
|
+
invalidateAvailabilityCache(strategyId?: SearchStrategyId): void {
|
|
326
|
+
if (strategyId) {
|
|
327
|
+
this.availabilityCache.delete(strategyId)
|
|
328
|
+
this.availabilityInflight.delete(strategyId)
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
this.availabilityCache.clear()
|
|
332
|
+
this.availabilityInflight.clear()
|
|
303
333
|
}
|
|
304
334
|
|
|
305
335
|
/**
|
|
@@ -334,32 +364,71 @@ export class SearchService {
|
|
|
334
364
|
async isStrategyAvailable(strategyId: SearchStrategyId): Promise<boolean> {
|
|
335
365
|
const strategy = this.strategies.get(strategyId)
|
|
336
366
|
if (!strategy) return false
|
|
337
|
-
return
|
|
367
|
+
return this.checkStrategyAvailability(strategy)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Resolve a strategy's availability via the short-lived TTL cache.
|
|
372
|
+
* Coalesces concurrent callers onto a single in-flight probe to avoid
|
|
373
|
+
* thundering-herd on remote backends.
|
|
374
|
+
*/
|
|
375
|
+
private async checkStrategyAvailability(strategy: SearchStrategy): Promise<boolean> {
|
|
376
|
+
const now = Date.now()
|
|
377
|
+
const cached = this.availabilityCache.get(strategy.id)
|
|
378
|
+
if (cached && cached.expiresAt > now) return cached.value
|
|
379
|
+
|
|
380
|
+
const inflight = this.availabilityInflight.get(strategy.id)
|
|
381
|
+
if (inflight) return inflight
|
|
382
|
+
|
|
383
|
+
const probe = (async () => {
|
|
384
|
+
try {
|
|
385
|
+
const value = await strategy.isAvailable()
|
|
386
|
+
this.availabilityCache.set(strategy.id, {
|
|
387
|
+
value,
|
|
388
|
+
expiresAt: Date.now() + this.availabilityCacheTtlMs,
|
|
389
|
+
})
|
|
390
|
+
return value
|
|
391
|
+
} catch {
|
|
392
|
+
this.availabilityCache.set(strategy.id, {
|
|
393
|
+
value: false,
|
|
394
|
+
expiresAt: Date.now() + this.availabilityCacheTtlMs,
|
|
395
|
+
})
|
|
396
|
+
return false
|
|
397
|
+
} finally {
|
|
398
|
+
this.availabilityInflight.delete(strategy.id)
|
|
399
|
+
}
|
|
400
|
+
})()
|
|
401
|
+
this.availabilityInflight.set(strategy.id, probe)
|
|
402
|
+
return probe
|
|
338
403
|
}
|
|
339
404
|
|
|
340
405
|
/**
|
|
341
406
|
* Get available strategies from the requested list.
|
|
342
407
|
* Filters out strategies that are not registered or not available.
|
|
408
|
+
* Probes run in parallel and reuse a short-lived per-strategy availability
|
|
409
|
+
* cache, so hot paths pay the max latency of the slowest probe (or zero
|
|
410
|
+
* when cached) instead of the sum of all probes.
|
|
343
411
|
*/
|
|
344
412
|
private async getAvailableStrategies(ids?: SearchStrategyId[]): Promise<SearchStrategy[]> {
|
|
345
413
|
const targetIds = ids ?? Array.from(this.strategies.keys())
|
|
346
|
-
const
|
|
347
|
-
|
|
414
|
+
const candidates: SearchStrategy[] = []
|
|
348
415
|
for (const id of targetIds) {
|
|
349
416
|
const strategy = this.strategies.get(id)
|
|
350
|
-
if (strategy)
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
417
|
+
if (strategy) candidates.push(strategy)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const probes = await Promise.allSettled(
|
|
421
|
+
candidates.map((strategy) => this.checkStrategyAvailability(strategy)),
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
const available: SearchStrategy[] = []
|
|
425
|
+
for (let i = 0; i < probes.length; i++) {
|
|
426
|
+
const probe = probes[i]
|
|
427
|
+
if (probe.status === 'fulfilled' && probe.value) {
|
|
428
|
+
available.push(candidates[i])
|
|
359
429
|
}
|
|
360
430
|
}
|
|
361
431
|
|
|
362
|
-
// Sort by priority (higher priority first)
|
|
363
432
|
return available.sort((a, b) => b.priority - a.priority)
|
|
364
433
|
}
|
|
365
434
|
|