ti2 1.0.121 → 1.0.123

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.
@@ -3,7 +3,6 @@ const crypto = require('crypto');
3
3
  const hash = require('object-hash');
4
4
  const R = require('ramda');
5
5
  const { UserAppKey } = require('../models/index');
6
- const { addJob } = require('../worker/queue');
7
6
  const { typeDefs: productTypeDefs, query: productQuery } = require('./graphql-schemas/product');
8
7
  const { typeDefs: availTypeDefs, query: availQuery } = require('./graphql-schemas/availability');
9
8
  const { typeDefs: bookingTypeDefs, query: bookingQuery } = require('./graphql-schemas/booking');
@@ -36,10 +35,19 @@ const getPositiveIntegerEnv = (name, defaultValue) => {
36
35
  };
37
36
 
38
37
  const getProductSearchLockTtlSeconds = () => getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_TTL_SECONDS', 120);
39
- const productSearchLockWaitMs = getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_WAIT_MS', 25 * 1000);
38
+ const getProductSearchLockWaitMs = () => getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_WAIT_MS', 25 * 1000);
40
39
  const productSearchLockPollMs = getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_POLL_MS', 250);
40
+ const productSearchRefreshOutcomeTtlSeconds = 60;
41
41
  const emptyProductSearchCacheTtlSeconds = 60;
42
42
  const productSearchCacheTtlSeconds = 30 * 24 * 60 * 60;
43
+ const oneDayProductSearchTtrSeconds = 60 * 60 * 24;
44
+ const defaultProductSearchTtrSeconds = oneDayProductSearchTtrSeconds * 7;
45
+
46
+ const resolveProductSearchTtrSeconds = (token = {}, app = {}) => {
47
+ const configured = token.ttlForProducts
48
+ || R.path(['cacheSettings', 'bookingsProductSearch', 'ttr'], app);
49
+ return configured == null ? defaultProductSearchTtrSeconds : configured;
50
+ };
43
51
  const productSearchOperationId = 'bookingsProductSearch';
44
52
  const productSearchCacheDecisionEvent = 'bookingsProductSearch:cache:decision';
45
53
  const legacyProductSearchCacheEvents = {
@@ -50,7 +58,19 @@ const legacyProductSearchCacheEvents = {
50
58
  };
51
59
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
52
60
 
53
- const productSearchCacheKey = ({ userId, hint }) => hash({ userId, hint, operationId: productSearchOperationId });
61
+ const normalizeOmitServiceCodes = (...values) => [...new Set(
62
+ values
63
+ .reduce((items, value) => items.concat(Array.isArray(value) ? value : [value]), [])
64
+ .reduce((items, value) => items.concat(String(value == null ? '' : value).split(',')), [])
65
+ .map(value => value.trim().toUpperCase())
66
+ .filter(Boolean),
67
+ )].sort();
68
+
69
+ const productSearchCacheKey = ({ userId, hint }) => hash({
70
+ userId,
71
+ hint,
72
+ operationId: productSearchOperationId,
73
+ });
54
74
 
55
75
  const productSearchSelectorFields = [
56
76
  'searchInput',
@@ -58,13 +78,15 @@ const productSearchSelectorFields = [
58
78
  'productId',
59
79
  'productName',
60
80
  'lastUpdatedFrom',
61
- 'skipAccommodation',
62
81
  ];
63
82
 
64
83
  const hasProductSearchSelector = payload => productSearchSelectorFields.some(field => {
65
84
  const value = R.path([field], payload);
66
85
  if (Array.isArray(value)) return value.length > 0;
67
- if (typeof value === 'string') return Boolean(value.trim());
86
+ if (typeof value === 'string') {
87
+ const trimmedValue = value.trim();
88
+ return Boolean(trimmedValue) && !(field === 'searchInput' && trimmedValue === '*');
89
+ }
68
90
  return value !== undefined && value !== null && value !== false;
69
91
  });
70
92
 
@@ -79,7 +101,21 @@ const hasProductCache = cacheContent => Boolean(cacheContent && cacheContent.pro
79
101
 
80
102
  const safeHash = value => (value === undefined || value === null ? undefined : hash(String(value)));
81
103
 
82
- const emitProductSearchCacheDecision = ({ app, cacheKey, userId, hint, requestId, searchInput, optionId, forceRefresh, startedAt }, action, extra = {}) => {
104
+ const emitProductSearchCacheDecision = ({
105
+ app,
106
+ cacheKey,
107
+ userId,
108
+ hint,
109
+ requestId,
110
+ searchInput,
111
+ optionId,
112
+ forceRefresh,
113
+ fullSyncTrigger,
114
+ admissionOverrideReason,
115
+ fullSyncStartedAt,
116
+ fullSyncAdmissionToken,
117
+ startedAt,
118
+ }, action, extra = {}) => {
83
119
  if (!app.events || !app.events.emit) return;
84
120
  const payload = {
85
121
  action,
@@ -90,6 +126,10 @@ const emitProductSearchCacheDecision = ({ app, cacheKey, userId, hint, requestId
90
126
  hintHash: safeHash(hint),
91
127
  cacheKeyHash: safeHash(cacheKey),
92
128
  forceRefresh: Boolean(forceRefresh),
129
+ fullSyncTrigger,
130
+ admissionOverrideReason,
131
+ fullSyncStartedAt,
132
+ fullSyncAdmissionTokenHash: safeHash(fullSyncAdmissionToken),
93
133
  hasSearchInput: Boolean((searchInput || '').trim()),
94
134
  hasOptionId: Boolean(optionId && optionId.length),
95
135
  elapsedMs: Date.now() - startedAt,
@@ -101,6 +141,9 @@ const emitProductSearchCacheDecision = ({ app, cacheKey, userId, hint, requestId
101
141
  ...payload,
102
142
  userId,
103
143
  hint,
144
+ ...(action === 'cache_saved' && fullSyncAdmissionToken
145
+ ? { fullSyncAdmissionToken }
146
+ : {}),
104
147
  });
105
148
  }
106
149
  };
@@ -142,6 +185,22 @@ const createProductSearchUnavailableError = () => {
142
185
  return err;
143
186
  };
144
187
 
188
+ const createScopedCatalogRefreshError = () => {
189
+ const err = new Error(
190
+ 'Scheduled and manual catalog refreshes must not include product selectors',
191
+ );
192
+ err.status = 400;
193
+ return err;
194
+ };
195
+
196
+ const createUnsupportedCatalogRefreshError = appKey => {
197
+ const err = new Error(
198
+ `${appKey} does not support complete product catalog refreshes`,
199
+ );
200
+ err.status = 400;
201
+ return err;
202
+ };
203
+
145
204
  const getAppAndToken = async ({ plugins, appKey, userId, hint }) => {
146
205
  const app = plugins.find(({ name }) => name === appKey);
147
206
  assert(app, 'could not find the app ' + appKey);
@@ -250,7 +309,8 @@ const confirmBooking = plugins => async (req, res, next) => {
250
309
  const $searchProductList = (products, searchInput = '', optionId = '') => {
251
310
  // NOTE: optionId could be a string or an array of strings
252
311
  // NOTE: searchInput should not appear at the same time as optionId
253
- if (!(searchInput && searchInput.trim()) && !(optionId && optionId.length)) {
312
+ const trimmedSearchInput = (searchInput || '').trim();
313
+ if ((!trimmedSearchInput || trimmedSearchInput === '*') && !(optionId && optionId.length)) {
254
314
  return products;
255
315
  }
256
316
  const getFullSearchStr = (product, option) => `${
@@ -258,7 +318,7 @@ const $searchProductList = (products, searchInput = '', optionId = '') => {
258
318
  } ${R.path(['optionName'], option) || ''
259
319
  } ${R.path(['optionId'], option) || ''
260
320
  } ${R.path(['supplierId'], product) || ''}`;
261
- const inputValueLower = (searchInput || '').trim().toLowerCase();
321
+ const inputValueLower = trimmedSearchInput.toLowerCase();
262
322
  const parts = inputValueLower.split(' ').filter(Boolean); // Filter out any empty strings just in case
263
323
  const pwFilteredOptions = products.map(product => {
264
324
  const filteredOptions = R.pathOr([], ['options'], product).filter(option => {
@@ -288,6 +348,13 @@ const hasCacheableProductResults = pluginResults => Boolean(
288
348
  && !pluginResults.partial
289
349
  );
290
350
 
351
+ const assertCanonicalProductSearchResult = (pluginResults, appKey) => {
352
+ assert(
353
+ pluginResults && Array.isArray(pluginResults.products),
354
+ `${appKey} product search must return an object with a products array`,
355
+ );
356
+ };
357
+
291
358
  const hasNonEmptyProductCache = cacheContent => Boolean(
292
359
  cacheContent
293
360
  && cacheContent.products
@@ -301,29 +368,80 @@ const $bookingsProductSearch = plugins => async ({
301
368
  hint,
302
369
  payload: originalRequestBody, // Renamed for clarity, this is req.body
303
370
  requestId,
304
- headers, // To pass original request headers for background job
371
+ headers,
305
372
  }) => {
306
- // Extract controller-specific flags from originalRequestBody
307
- const {
308
- searchInput = '', // Provide defaults
309
- optionId = '',
310
- forceRefresh = false,
311
- } = originalRequestBody;
312
-
313
- // Payload for the plugin function (func) - pass forceRefresh so plugins can trigger background cache rebuilds
314
- const payloadForPlugin = { ...R.omit(['forceRefresh'], originalRequestBody), forceRefresh };
315
- const isScopedSearch = hasProductSearchSelector(originalRequestBody);
316
- const payloadForBackgroundJob = R.omit(
317
- ['credentials', ...productSearchSelectorFields],
318
- payloadForPlugin,
319
- );
320
-
321
373
  const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
322
374
  assert(userId, 'userId is required');
323
375
  assert(appKey, 'appKey is required');
324
376
  assert(app.searchProducts || app.searchProductsForItinerary, `searchProducts or searchProductsForItinerary is not available for ${appKey}`);
325
377
  const func = (app.searchProducts || app.searchProductsForItinerary).bind(app);
326
378
 
379
+ const configuredOmitServiceCodes = normalizeOmitServiceCodes(
380
+ R.path(['productSearchOmitServiceCodes'], token),
381
+ );
382
+ const normalizedRequestBody = {
383
+ ...originalRequestBody,
384
+ ...(configuredOmitServiceCodes.length ? {
385
+ omitServiceCodes: configuredOmitServiceCodes,
386
+ } : {}),
387
+ };
388
+
389
+ // Extract controller-specific flags from originalRequestBody
390
+ const {
391
+ searchInput = '', // Provide defaults
392
+ optionId = '',
393
+ forceRefresh = false,
394
+ cacheOnly = false,
395
+ fullSyncTrigger: requestedFullSyncTrigger,
396
+ admissionOverrideReason: requestedOverrideReason,
397
+ fullSyncStartedAt: requestedFullSyncStartedAt,
398
+ fullSyncAdmissionToken: requestedAdmissionToken,
399
+ } = normalizedRequestBody;
400
+
401
+ const fullSyncTrigger = ['organic', 'scheduled', 'manual'].indexOf(
402
+ requestedFullSyncTrigger,
403
+ ) >= 0
404
+ ? requestedFullSyncTrigger
405
+ : (forceRefresh ? 'scheduled' : 'organic');
406
+ const admissionOverrideReason = typeof requestedOverrideReason === 'string'
407
+ ? requestedOverrideReason.trim() || undefined
408
+ : undefined;
409
+ const fullSyncStartedAt = requestedFullSyncStartedAt !== undefined
410
+ && requestedFullSyncStartedAt !== null
411
+ && requestedFullSyncStartedAt !== ''
412
+ && Number.isFinite(Number(requestedFullSyncStartedAt))
413
+ ? Number(requestedFullSyncStartedAt)
414
+ : undefined;
415
+ const fullSyncAdmissionToken = typeof requestedAdmissionToken === 'string'
416
+ ? requestedAdmissionToken.trim() || undefined
417
+ : undefined;
418
+
419
+ // Keep forceRefresh in the plugin payload so plugins can trigger their own rebuilds.
420
+ const payloadForPlugin = {
421
+ ...R.omit([
422
+ 'forceRefresh',
423
+ 'cacheOnly',
424
+ 'fullSyncTrigger',
425
+ 'admissionOverrideReason',
426
+ 'fullSyncStartedAt',
427
+ 'fullSyncAdmissionToken',
428
+ ], normalizedRequestBody),
429
+ forceRefresh,
430
+ };
431
+ const isScopedSearch = hasProductSearchSelector(normalizedRequestBody);
432
+ const isDeclaredFullCatalogRefresh = forceRefresh
433
+ && ['scheduled', 'manual'].indexOf(fullSyncTrigger) >= 0;
434
+ if (isDeclaredFullCatalogRefresh && isScopedSearch) {
435
+ throw createScopedCatalogRefreshError();
436
+ }
437
+ if (
438
+ forceRefresh
439
+ && !cacheOnly
440
+ && R.path(['capabilities', 'weeklyProductCatalogSync'], app) !== true
441
+ ) {
442
+ throw createUnsupportedCatalogRefreshError(appKey);
443
+ }
444
+
327
445
  const cacheKey = productSearchCacheKey({ userId, hint });
328
446
  const telemetryContext = {
329
447
  app,
@@ -334,21 +452,30 @@ const $bookingsProductSearch = plugins => async ({
334
452
  searchInput,
335
453
  optionId,
336
454
  forceRefresh,
455
+ fullSyncTrigger,
456
+ admissionOverrideReason,
457
+ fullSyncStartedAt,
458
+ fullSyncAdmissionToken,
337
459
  startedAt: Date.now(),
338
460
  };
339
- const emitDecision = (action, extra = {}) => emitProductSearchCacheDecision(telemetryContext, action, extra);
461
+ const emitDecision = (action, extra = {}) => emitProductSearchCacheDecision(
462
+ telemetryContext,
463
+ action,
464
+ extra,
465
+ );
340
466
  const pluginExecutionLockKey = `${cacheKey}:lock`; // Lock for direct plugin execution
341
- const jobQueueLockKey = `${cacheKey}:jobLock`; // Lock for preventing multiple job queues
467
+ const refreshOutcomeCacheKey = `${pluginExecutionLockKey}:outcome`;
342
468
 
343
469
  // Fetch actualCacheContent once at the beginning.
344
470
  const initialActualCacheContent = await app.cache.get({ key: cacheKey });
345
471
  const lastUpdated = await app.cache.get({ key: `${cacheKey}:lastUpdated` });
346
- const ttr = token.ttlForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'ttr'], app) || 60 * 60 * 24;
472
+ const ttr = resolveProductSearchTtrSeconds(token, app);
347
473
  const isStaleByTTR = lastUpdated && (Date.now() - lastUpdated > ttr * 1000);
348
474
  const doNotCallPluginForProducts = token.doNotCallPluginForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'doNotCall'], app);
349
475
  const hasPluginExecutionLock = await app.cache.get({ key: pluginExecutionLockKey });
350
476
  emitDecision(hasProductCache(initialActualCacheContent) ? 'cache_hit' : 'cache_miss', {
351
477
  cacheProductCount: productCount(initialActualCacheContent),
478
+ cacheOptionCount: optionCount(initialActualCacheContent),
352
479
  cacheAgeMs: lastUpdated ? Date.now() - lastUpdated : undefined,
353
480
  ttrMs: ttr * 1000,
354
481
  reason: hasPluginExecutionLock ? 'lockActive' : undefined,
@@ -361,19 +488,30 @@ const $bookingsProductSearch = plugins => async ({
361
488
  return null;
362
489
  };
363
490
 
364
- const waitForProductSearchCache = async () => {
365
- const timeoutAt = Date.now() + productSearchLockWaitMs;
491
+ const waitForProductSearchRefresh = async previousLockOwnerToken => {
492
+ const timeoutAt = Date.now() + getProductSearchLockWaitMs();
366
493
  while (Date.now() < timeoutAt) {
367
- const cacheContent = await getCachedProductSearchResults();
368
- if (cacheContent) return cacheContent;
494
+ const refreshOutcome = await app.cache.get({ key: refreshOutcomeCacheKey });
495
+ if (refreshOutcome && refreshOutcome.lockOwnerToken !== previousLockOwnerToken) {
496
+ return refreshOutcome;
497
+ }
369
498
 
370
499
  const lockStillActive = await app.cache.get({ key: pluginExecutionLockKey });
371
- if (!lockStillActive) return null;
500
+ if (!lockStillActive) {
501
+ const completedOutcome = await app.cache.get({ key: refreshOutcomeCacheKey });
502
+ return completedOutcome
503
+ && completedOutcome.lockOwnerToken !== previousLockOwnerToken
504
+ ? completedOutcome
505
+ : null;
506
+ }
372
507
 
373
508
  await sleep(productSearchLockPollMs);
374
509
  }
375
510
 
376
- return getCachedProductSearchResults();
511
+ const refreshOutcome = await app.cache.get({ key: refreshOutcomeCacheKey });
512
+ return refreshOutcome && refreshOutcome.lockOwnerToken !== previousLockOwnerToken
513
+ ? refreshOutcome
514
+ : null;
377
515
  };
378
516
 
379
517
  const acquirePluginExecutionLock = async lockOwnerToken => app.cache.saveIfNotExists({
@@ -395,6 +533,7 @@ const $bookingsProductSearch = plugins => async ({
395
533
  userId,
396
534
  hint,
397
535
  });
536
+ assertCanonicalProductSearchResult(pluginResults, appKey);
398
537
  emitDecision('scoped_result_not_cached', {
399
538
  reason,
400
539
  pluginElapsedMs: Date.now() - pluginStartedAt,
@@ -405,20 +544,52 @@ const $bookingsProductSearch = plugins => async ({
405
544
  }
406
545
 
407
546
  const lockOwnerToken = crypto.randomBytes(16).toString('hex');
547
+ const previousRefreshOutcome = await app.cache.get({ key: refreshOutcomeCacheKey });
408
548
  const lockStartedAt = Date.now();
409
549
  const lockAcquired = await acquirePluginExecutionLock(lockOwnerToken);
410
550
  if (!lockAcquired) {
411
551
  emitDecision('lock_wait', { reason, lockWaitMs: 0 });
412
- const cacheContent = await waitForProductSearchCache();
552
+ const refreshOutcome = await waitForProductSearchRefresh(
553
+ previousRefreshOutcome && previousRefreshOutcome.lockOwnerToken,
554
+ );
555
+ const cacheContent = await getCachedProductSearchResults();
413
556
  const lockWaitMs = Date.now() - lockStartedAt;
414
- if (cacheContent) {
557
+ if (forceRefresh && refreshOutcome) {
558
+ emitDecision('cache_hit', {
559
+ reason: 'waitedForLeader',
560
+ lockWaitMs,
561
+ cacheProductCount: productCount(cacheContent),
562
+ cacheOptionCount: optionCount(cacheContent),
563
+ });
564
+ return {
565
+ ...(cacheContent || { products: [] }),
566
+ ...R.omit(['lockOwnerToken'], refreshOutcome),
567
+ };
568
+ }
569
+ if (!forceRefresh && cacheContent) {
415
570
  emitDecision('cache_hit', {
416
571
  reason: 'waitedForLeader',
417
572
  lockWaitMs,
418
573
  cacheProductCount: productCount(cacheContent),
574
+ cacheOptionCount: optionCount(cacheContent),
419
575
  });
420
576
  return cacheContent;
421
577
  }
578
+ if (forceRefresh && cacheContent) {
579
+ emitDecision('stale_served', {
580
+ reason: 'refreshInProgress',
581
+ lockWaitMs,
582
+ cacheProductCount: productCount(cacheContent),
583
+ cacheOptionCount: optionCount(cacheContent),
584
+ });
585
+ return {
586
+ ...cacheContent,
587
+ catalogRefreshOutcome: 'refresh_in_progress_cache_served',
588
+ cacheUpdated: false,
589
+ cachePreserved: true,
590
+ cachedProductCount: productCount(cacheContent),
591
+ };
592
+ }
422
593
  emitDecision('lock_timeout', { reason, lockWaitMs });
423
594
  throw createProductSearchUnavailableError();
424
595
  }
@@ -434,6 +605,11 @@ const $bookingsProductSearch = plugins => async ({
434
605
  });
435
606
 
436
607
  let pluginResults;
608
+ let catalogRefreshOutcome;
609
+ let cacheUpdated = false;
610
+ let cachePreserved = false;
611
+ let cachedProductCount;
612
+ let effectiveCatalogResults;
437
613
  try {
438
614
  const pluginStartedAt = Date.now();
439
615
  pluginResults = await func({
@@ -445,11 +621,10 @@ const $bookingsProductSearch = plugins => async ({
445
621
  userId,
446
622
  hint,
447
623
  });
624
+ assertCanonicalProductSearchResult(pluginResults, appKey);
448
625
  const pluginElapsedMs = Date.now() - pluginStartedAt;
449
626
 
450
- // This function is responsible for caching if it fetched usable results.
451
- // This applies to forceRefresh, initial load, or direct calls that result in a fetch.
452
- // The $updateProductSearchCache function handles caching for background jobs queued due to stale data.
627
+ // Cache usable results from a direct plugin fetch (forceRefresh or cache miss).
453
628
  if (hasCacheableProductResults(pluginResults)) {
454
629
  await app.cache.save({ key: `${cacheKey}:lastUpdated`, value: Date.now(), ttl: productSearchCacheTtlSeconds });
455
630
  await app.cache.save({ key: cacheKey, value: pluginResults, ttl: productSearchCacheTtlSeconds });
@@ -457,23 +632,87 @@ const $bookingsProductSearch = plugins => async ({
457
632
  reason,
458
633
  pluginElapsedMs,
459
634
  cacheProductCount: productCount(pluginResults),
635
+ cacheOptionCount: optionCount(pluginResults),
460
636
  });
637
+ catalogRefreshOutcome = 'cache_updated';
638
+ cacheUpdated = true;
639
+ cachedProductCount = productCount(pluginResults);
461
640
  } else if (pluginResults && (pluginResults.catalogPartial || pluginResults.partial)) {
641
+ const existingCacheContent = await app.cache.get({ key: cacheKey });
642
+ cachePreserved = hasNonEmptyProductCache(existingCacheContent);
643
+ effectiveCatalogResults = cachePreserved ? existingCacheContent : undefined;
644
+ catalogRefreshOutcome = cachePreserved
645
+ ? 'partial_result_preserved_cache'
646
+ : 'partial_result_not_cached';
647
+ cachedProductCount = cachePreserved
648
+ ? existingCacheContent.products.length
649
+ : null;
462
650
  emitDecision('partial_refresh_skipped', {
463
- reason: 'directPartialResultNotCached',
651
+ reason: cachePreserved
652
+ ? 'partialResultPreservedExistingCache'
653
+ : 'partialResultNotCached',
464
654
  pluginElapsedMs,
465
- cachePreserved: false,
655
+ cachePreserved,
656
+ existingProductCount: cachePreserved
657
+ ? existingCacheContent.products.length
658
+ : undefined,
466
659
  returnedProductCount: productCount(pluginResults),
467
660
  });
468
661
  } else if (pluginResults && pluginResults.products && pluginResults.products.length === 0) {
469
- // Short-lived empty cache gives concurrent waiters the same answer as the lock holder.
470
- await app.cache.save({ key: cacheKey, value: pluginResults, ttl: emptyProductSearchCacheTtlSeconds });
471
- emitDecision('empty_cache_saved', {
472
- reason,
473
- pluginElapsedMs,
474
- cacheProductCount: 0,
475
- });
662
+ const existingCacheContent = await app.cache.get({ key: cacheKey });
663
+ if (hasNonEmptyProductCache(existingCacheContent)) {
664
+ catalogRefreshOutcome = 'empty_result_preserved_cache';
665
+ cachePreserved = true;
666
+ cachedProductCount = existingCacheContent.products.length;
667
+ effectiveCatalogResults = existingCacheContent;
668
+ emitDecision('empty_refresh_skipped', {
669
+ reason: 'emptyResultPreservedExistingCache',
670
+ pluginElapsedMs,
671
+ cachePreserved: true,
672
+ existingProductCount: existingCacheContent.products.length,
673
+ });
674
+ } else {
675
+ const latestCacheContent = await app.cache.get({ key: cacheKey });
676
+ if (hasNonEmptyProductCache(latestCacheContent)) {
677
+ catalogRefreshOutcome = 'empty_result_preserved_cache';
678
+ cachePreserved = true;
679
+ cachedProductCount = latestCacheContent.products.length;
680
+ effectiveCatalogResults = latestCacheContent;
681
+ emitDecision('empty_refresh_skipped', {
682
+ reason: 'emptyResultPreservedConcurrentCache',
683
+ pluginElapsedMs,
684
+ cachePreserved: true,
685
+ existingProductCount: latestCacheContent.products.length,
686
+ });
687
+ } else {
688
+ // Short-lived empty cache gives concurrent waiters the same answer as the lock holder.
689
+ await app.cache.save({
690
+ key: cacheKey,
691
+ value: pluginResults,
692
+ ttl: emptyProductSearchCacheTtlSeconds,
693
+ });
694
+ catalogRefreshOutcome = 'empty_cache_saved';
695
+ cacheUpdated = true;
696
+ cachedProductCount = 0;
697
+ emitDecision('empty_cache_saved', {
698
+ reason,
699
+ pluginElapsedMs,
700
+ cacheProductCount: 0,
701
+ });
702
+ }
703
+ }
476
704
  }
705
+ await app.cache.save({
706
+ key: refreshOutcomeCacheKey,
707
+ value: {
708
+ lockOwnerToken,
709
+ catalogRefreshOutcome,
710
+ cacheUpdated,
711
+ cachePreserved,
712
+ cachedProductCount,
713
+ },
714
+ ttl: productSearchRefreshOutcomeTtlSeconds,
715
+ });
477
716
  } finally {
478
717
  await lockRenewal.stop();
479
718
  if (app.cache.dropIfValue) {
@@ -482,9 +721,39 @@ const $bookingsProductSearch = plugins => async ({
482
721
  await app.cache.drop({ key: pluginExecutionLockKey });
483
722
  }
484
723
  }
485
- return pluginResults || { products: [] }; // Ensure products array exists
724
+ const result = effectiveCatalogResults || pluginResults || { products: [] };
725
+ if (!forceRefresh) return result;
726
+ return {
727
+ ...result,
728
+ catalogRefreshOutcome,
729
+ cacheUpdated,
730
+ cachePreserved,
731
+ cachedProductCount,
732
+ };
486
733
  };
487
734
 
735
+ // 0. `cacheOnly`: return current Ti2 cache or empty. Never call the plugin.
736
+ if (cacheOnly) {
737
+ if (hasProductCache(initialActualCacheContent)) {
738
+ const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
739
+ emitDecision('cache_hit', {
740
+ reason: 'cacheOnly',
741
+ cacheProductCount: initialActualCacheContent.products.length,
742
+ cacheOptionCount: optionCount(initialActualCacheContent),
743
+ returnedProductCount: searchResults.length,
744
+ returnedOptionCount: optionCount({ products: searchResults }),
745
+ });
746
+ return {
747
+ ...initialActualCacheContent,
748
+ products: searchResults,
749
+ ...(token.configuration || {}),
750
+ cacheFound: true,
751
+ };
752
+ }
753
+ emitDecision('cache_miss', { reason: 'cacheOnly' });
754
+ return { products: [], ...(token.configuration || {}), cacheFound: false };
755
+ }
756
+
488
757
  // 1. `doNotCallPluginForProducts` is true, and not `forceRefresh`: Serve from cache or empty.
489
758
  if (doNotCallPluginForProducts && !forceRefresh) {
490
759
  if (hasProductCache(initialActualCacheContent)) {
@@ -492,7 +761,9 @@ const $bookingsProductSearch = plugins => async ({
492
761
  emitDecision('cache_hit', {
493
762
  reason: 'doNotCallPluginForProducts',
494
763
  cacheProductCount: initialActualCacheContent.products.length,
764
+ cacheOptionCount: optionCount(initialActualCacheContent),
495
765
  returnedProductCount: searchResults.length,
766
+ returnedOptionCount: optionCount({ products: searchResults }),
496
767
  });
497
768
  return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
498
769
  }
@@ -506,7 +777,15 @@ const $bookingsProductSearch = plugins => async ({
506
777
  emitDecision('force_refresh');
507
778
  const funcResults = await fetchFromPluginAndCache('force_refresh');
508
779
  const searchResults = $searchProductList(funcResults.products, searchInput, optionId);
509
- return { ...funcResults, products: searchResults, ...(token.configuration || {}) };
780
+ return {
781
+ ...funcResults,
782
+ products: searchResults,
783
+ ...(token.configuration || {}),
784
+ catalogRefreshOutcome: funcResults.catalogRefreshOutcome,
785
+ cacheUpdated: funcResults.cacheUpdated,
786
+ cachePreserved: funcResults.cachePreserved,
787
+ cachedProductCount: funcResults.cachedProductCount,
788
+ };
510
789
  }
511
790
 
512
791
  // 3. Cache exists (initialActualCacheContent) and not forceRefresh:
@@ -522,7 +801,9 @@ const $bookingsProductSearch = plugins => async ({
522
801
  const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
523
802
  emitDecision(action, {
524
803
  cacheProductCount: initialActualCacheContent.products.length,
804
+ cacheOptionCount: optionCount(initialActualCacheContent),
525
805
  returnedProductCount: searchResults.length,
806
+ returnedOptionCount: optionCount({ products: searchResults }),
526
807
  cacheAgeMs: lastUpdated ? Date.now() - lastUpdated : undefined,
527
808
  ttrMs: ttr * 1000,
528
809
  ...extra,
@@ -532,38 +813,13 @@ const $bookingsProductSearch = plugins => async ({
532
813
 
533
814
  const isEffectivelyStale = isStaleByTTR && !doNotCallPluginForProducts;
534
815
 
535
- // Cache is fresh or plugin execution in progress: serve from cache
816
+ // Cache is fresh, a plugin fetch is already running, or the cache is
817
+ // stale: organic search never starts a catalog refresh. The scheduled
818
+ // forceRefresh search is the only catalog owner.
536
819
  if (!isEffectivelyStale || hasPluginExecutionLock) {
537
820
  return returnCachedResults('cache_hit', { reason: hasPluginExecutionLock ? 'lockActive' : 'freshCache' });
538
821
  }
539
-
540
- // Cache is stale - queue at most one background refresh job and serve stale data.
541
- const jobQueueLockAcquired = await app.cache.saveIfNotExists({
542
- key: jobQueueLockKey,
543
- value: true,
544
- ttl: 60,
545
- });
546
- if (!jobQueueLockAcquired) {
547
- return returnCachedResults('stale_served', { reason: 'backgroundRefreshDeduped' });
548
- }
549
-
550
- const jobId = await addJob({
551
- type: 'plugin',
552
- pluginName: appKey,
553
- method: app.searchProducts ? 'searchProducts' : 'searchProductsForItinerary',
554
- token,
555
- payload: { payload: payloadForBackgroundJob, userId, hint },
556
- postProcess: {
557
- controller: 'bookings',
558
- action: '$updateProductSearchCache',
559
- args: { appKey, userId, hint },
560
- },
561
- }, { removeOnComplete: true });
562
-
563
- return returnCachedResults('stale_served', {
564
- reason: 'backgroundRefreshQueued',
565
- jobId,
566
- });
822
+ return returnCachedResults('stale_served', { reason: 'awaitingScheduledRefresh' });
567
823
  }
568
824
  }
569
825
 
@@ -844,90 +1100,6 @@ const getCreateBookingFields = plugins => async (req, res, next) => {
844
1100
  }
845
1101
  };
846
1102
 
847
- const $updateProductSearchCache = plugins => async ({
848
- appKey,
849
- userId,
850
- hint,
851
- pluginResult, // Result from the plugin method call
852
- requestId,
853
- // 'plugins' is available via the factory closure
854
- }) => {
855
- const app = plugins.find(({ name }) => name === appKey);
856
- if (!app) {
857
- console.error(`[$updateProductSearchCache][requestId: ${requestId}] Plugin ${appKey} not found.`);
858
- return; // Or throw error
859
- }
860
-
861
- const cacheKey = productSearchCacheKey({ userId, hint });
862
- const markRefreshAttempted = () => app.cache.save({
863
- key: `${cacheKey}:lastUpdated`,
864
- value: Date.now(),
865
- ttl: productSearchCacheTtlSeconds,
866
- });
867
- const emitCacheEvent = (action, extra = {}) => emitProductSearchCacheDecision({
868
- app,
869
- cacheKey,
870
- userId,
871
- hint,
872
- requestId,
873
- searchInput: '',
874
- optionId: '',
875
- forceRefresh: false,
876
- startedAt: Date.now(),
877
- }, action, extra);
878
-
879
- if (hasCacheableProductResults(pluginResult)) {
880
- await markRefreshAttempted();
881
- await app.cache.save({ key: cacheKey, value: pluginResult, ttl: productSearchCacheTtlSeconds });
882
- emitCacheEvent('cache_saved', { cacheProductCount: productCount(pluginResult) });
883
- } else {
884
- const isPartialRefresh = pluginResult && (pluginResult.catalogPartial || pluginResult.partial);
885
- const existingCacheContent = await app.cache.get({ key: cacheKey });
886
- if (hasNonEmptyProductCache(existingCacheContent)) {
887
- await markRefreshAttempted();
888
- const eventExtra = {
889
- reason: isPartialRefresh ? 'partialResultPreservedExistingCache' : 'emptyResultPreservedExistingCache',
890
- cachePreserved: true,
891
- existingProductCount: existingCacheContent.products.length,
892
- };
893
- if (isPartialRefresh) eventExtra.returnedProductCount = productCount(pluginResult);
894
- emitCacheEvent(
895
- isPartialRefresh ? 'partial_refresh_skipped' : 'empty_refresh_skipped',
896
- eventExtra,
897
- );
898
- return;
899
- }
900
-
901
- if (isPartialRefresh) {
902
- await markRefreshAttempted();
903
- emitCacheEvent('partial_refresh_skipped', {
904
- reason: 'partialResultNotCached',
905
- cachePreserved: false,
906
- returnedProductCount: productCount(pluginResult),
907
- });
908
- return;
909
- }
910
-
911
- // Best-effort race guard: avoid overwriting a non-empty cache written after the first read.
912
- const latestCacheContent = await app.cache.get({ key: cacheKey });
913
- if (hasNonEmptyProductCache(latestCacheContent)) {
914
- await markRefreshAttempted();
915
- emitCacheEvent('empty_refresh_skipped', {
916
- reason: 'emptyResultPreservedConcurrentCache',
917
- cachePreserved: true,
918
- existingProductCount: latestCacheContent.products.length,
919
- });
920
- return;
921
- }
922
-
923
- // Empty complete refreshes are authoritative only when there is no existing non-empty cache.
924
- await markRefreshAttempted();
925
- await app.cache.save({ key: cacheKey, value: { products: [] }, ttl: productSearchCacheTtlSeconds });
926
- emitCacheEvent('empty_cache_saved', { cacheProductCount: 0 });
927
- }
928
- };
929
-
930
- // Update the module.exports to correctly assign the plugin-wrapped function
931
1103
  const controllerFactory = plugins => {
932
1104
  const controllerFunctions = {
933
1105
  bookingsSearch: bookingsSearch(plugins),
@@ -945,7 +1117,6 @@ const controllerFactory = plugins => {
945
1117
  getAffiliateDesks: getAffiliateDesks(plugins),
946
1118
  getPickupPoints: getPickupPoints(plugins),
947
1119
  getCreateBookingFields: getCreateBookingFields(plugins),
948
- $updateProductSearchCache: $updateProductSearchCache(plugins), // Add the new function here
949
1120
  };
950
1121
  // Ensure $bookingsProductSearch can be called internally by worker with plugins already bound
951
1122
  // This is more of a conceptual note as the factory pattern already handles this.
@@ -954,5 +1125,8 @@ const controllerFactory = plugins => {
954
1125
 
955
1126
  // Add this line to attach typeDefsAndQueries to the factory:
956
1127
  controllerFactory.typeDefsAndQueries = typeDefsAndQueries;
1128
+ controllerFactory.resolveProductSearchTtrSeconds = resolveProductSearchTtrSeconds;
1129
+ controllerFactory.defaultProductSearchTtrSeconds = defaultProductSearchTtrSeconds;
1130
+ controllerFactory.oneDayProductSearchTtrSeconds = oneDayProductSearchTtrSeconds;
957
1131
 
958
1132
  module.exports = controllerFactory;