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.
- package/README.md +28 -1
- package/__tests__/plugin-capabilities.js +42 -0
- package/api.yml +84 -12
- package/controllers/README.md +25 -35
- package/controllers/__tests__/bookings-searchProducts.js +481 -216
- package/controllers/__tests__/product-search-ttr.js +39 -0
- package/controllers/__tests__/user.js +5 -1
- package/controllers/app.js +20 -17
- package/controllers/bookings.js +337 -163
- package/controllers/user.js +1 -0
- package/index.js +11 -1
- package/package.json +1 -1
- package/worker/__tests__/job-api.js +55 -3
- package/worker/__tests__/job-plugin.js +3 -3
- package/worker/index.js +16 -1
package/controllers/bookings.js
CHANGED
|
@@ -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
|
|
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
|
|
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')
|
|
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 = ({
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
365
|
-
const timeoutAt = Date.now() +
|
|
491
|
+
const waitForProductSearchRefresh = async previousLockOwnerToken => {
|
|
492
|
+
const timeoutAt = Date.now() + getProductSearchLockWaitMs();
|
|
366
493
|
while (Date.now() < timeoutAt) {
|
|
367
|
-
const
|
|
368
|
-
if (
|
|
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)
|
|
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
|
-
|
|
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
|
|
552
|
+
const refreshOutcome = await waitForProductSearchRefresh(
|
|
553
|
+
previousRefreshOutcome && previousRefreshOutcome.lockOwnerToken,
|
|
554
|
+
);
|
|
555
|
+
const cacheContent = await getCachedProductSearchResults();
|
|
413
556
|
const lockWaitMs = Date.now() - lockStartedAt;
|
|
414
|
-
if (
|
|
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
|
-
//
|
|
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:
|
|
651
|
+
reason: cachePreserved
|
|
652
|
+
? 'partialResultPreservedExistingCache'
|
|
653
|
+
: 'partialResultNotCached',
|
|
464
654
|
pluginElapsedMs,
|
|
465
|
-
cachePreserved
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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;
|