ti2 1.0.122 → 1.0.124
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 +29 -1
- package/__tests__/plugin-capabilities.js +42 -0
- package/api.yml +76 -13
- package/controllers/README.md +24 -34
- package/controllers/__tests__/bookings-searchProducts.js +408 -232
- package/controllers/__tests__/product-search-ttr.js +39 -0
- package/controllers/__tests__/user.js +5 -1
- package/controllers/app.js +1 -1
- package/controllers/bookings.js +294 -152
- 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 = {
|
|
@@ -75,7 +83,10 @@ const productSearchSelectorFields = [
|
|
|
75
83
|
const hasProductSearchSelector = payload => productSearchSelectorFields.some(field => {
|
|
76
84
|
const value = R.path([field], payload);
|
|
77
85
|
if (Array.isArray(value)) return value.length > 0;
|
|
78
|
-
if (typeof value === 'string')
|
|
86
|
+
if (typeof value === 'string') {
|
|
87
|
+
const trimmedValue = value.trim();
|
|
88
|
+
return Boolean(trimmedValue) && !(field === 'searchInput' && trimmedValue === '*');
|
|
89
|
+
}
|
|
79
90
|
return value !== undefined && value !== null && value !== false;
|
|
80
91
|
});
|
|
81
92
|
|
|
@@ -99,6 +110,10 @@ const emitProductSearchCacheDecision = ({
|
|
|
99
110
|
searchInput,
|
|
100
111
|
optionId,
|
|
101
112
|
forceRefresh,
|
|
113
|
+
fullSyncTrigger,
|
|
114
|
+
admissionOverrideReason,
|
|
115
|
+
fullSyncStartedAt,
|
|
116
|
+
fullSyncAdmissionToken,
|
|
102
117
|
startedAt,
|
|
103
118
|
}, action, extra = {}) => {
|
|
104
119
|
if (!app.events || !app.events.emit) return;
|
|
@@ -111,6 +126,10 @@ const emitProductSearchCacheDecision = ({
|
|
|
111
126
|
hintHash: safeHash(hint),
|
|
112
127
|
cacheKeyHash: safeHash(cacheKey),
|
|
113
128
|
forceRefresh: Boolean(forceRefresh),
|
|
129
|
+
fullSyncTrigger,
|
|
130
|
+
admissionOverrideReason,
|
|
131
|
+
fullSyncStartedAt,
|
|
132
|
+
fullSyncAdmissionTokenHash: safeHash(fullSyncAdmissionToken),
|
|
114
133
|
hasSearchInput: Boolean((searchInput || '').trim()),
|
|
115
134
|
hasOptionId: Boolean(optionId && optionId.length),
|
|
116
135
|
elapsedMs: Date.now() - startedAt,
|
|
@@ -122,6 +141,9 @@ const emitProductSearchCacheDecision = ({
|
|
|
122
141
|
...payload,
|
|
123
142
|
userId,
|
|
124
143
|
hint,
|
|
144
|
+
...(action === 'cache_saved' && fullSyncAdmissionToken
|
|
145
|
+
? { fullSyncAdmissionToken }
|
|
146
|
+
: {}),
|
|
125
147
|
});
|
|
126
148
|
}
|
|
127
149
|
};
|
|
@@ -163,6 +185,30 @@ const createProductSearchUnavailableError = () => {
|
|
|
163
185
|
return err;
|
|
164
186
|
};
|
|
165
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 createManualCatalogRefreshReasonError = () => {
|
|
197
|
+
const err = new Error(
|
|
198
|
+
'A manual catalog refresh requires a non-blank admissionOverrideReason',
|
|
199
|
+
);
|
|
200
|
+
err.status = 400;
|
|
201
|
+
return err;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const createUnsupportedCatalogRefreshError = appKey => {
|
|
205
|
+
const err = new Error(
|
|
206
|
+
`${appKey} does not support complete product catalog refreshes`,
|
|
207
|
+
);
|
|
208
|
+
err.status = 400;
|
|
209
|
+
return err;
|
|
210
|
+
};
|
|
211
|
+
|
|
166
212
|
const getAppAndToken = async ({ plugins, appKey, userId, hint }) => {
|
|
167
213
|
const app = plugins.find(({ name }) => name === appKey);
|
|
168
214
|
assert(app, 'could not find the app ' + appKey);
|
|
@@ -271,7 +317,8 @@ const confirmBooking = plugins => async (req, res, next) => {
|
|
|
271
317
|
const $searchProductList = (products, searchInput = '', optionId = '') => {
|
|
272
318
|
// NOTE: optionId could be a string or an array of strings
|
|
273
319
|
// NOTE: searchInput should not appear at the same time as optionId
|
|
274
|
-
|
|
320
|
+
const trimmedSearchInput = (searchInput || '').trim();
|
|
321
|
+
if ((!trimmedSearchInput || trimmedSearchInput === '*') && !(optionId && optionId.length)) {
|
|
275
322
|
return products;
|
|
276
323
|
}
|
|
277
324
|
const getFullSearchStr = (product, option) => `${
|
|
@@ -279,7 +326,7 @@ const $searchProductList = (products, searchInput = '', optionId = '') => {
|
|
|
279
326
|
} ${R.path(['optionName'], option) || ''
|
|
280
327
|
} ${R.path(['optionId'], option) || ''
|
|
281
328
|
} ${R.path(['supplierId'], product) || ''}`;
|
|
282
|
-
const inputValueLower =
|
|
329
|
+
const inputValueLower = trimmedSearchInput.toLowerCase();
|
|
283
330
|
const parts = inputValueLower.split(' ').filter(Boolean); // Filter out any empty strings just in case
|
|
284
331
|
const pwFilteredOptions = products.map(product => {
|
|
285
332
|
const filteredOptions = R.pathOr([], ['options'], product).filter(option => {
|
|
@@ -309,6 +356,13 @@ const hasCacheableProductResults = pluginResults => Boolean(
|
|
|
309
356
|
&& !pluginResults.partial
|
|
310
357
|
);
|
|
311
358
|
|
|
359
|
+
const assertCanonicalProductSearchResult = (pluginResults, appKey) => {
|
|
360
|
+
assert(
|
|
361
|
+
pluginResults && Array.isArray(pluginResults.products),
|
|
362
|
+
`${appKey} product search must return an object with a products array`,
|
|
363
|
+
);
|
|
364
|
+
};
|
|
365
|
+
|
|
312
366
|
const hasNonEmptyProductCache = cacheContent => Boolean(
|
|
313
367
|
cacheContent
|
|
314
368
|
&& cacheContent.products
|
|
@@ -322,7 +376,7 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
322
376
|
hint,
|
|
323
377
|
payload: originalRequestBody, // Renamed for clarity, this is req.body
|
|
324
378
|
requestId,
|
|
325
|
-
headers,
|
|
379
|
+
headers,
|
|
326
380
|
}) => {
|
|
327
381
|
const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
|
|
328
382
|
assert(userId, 'userId is required');
|
|
@@ -345,18 +399,64 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
345
399
|
searchInput = '', // Provide defaults
|
|
346
400
|
optionId = '',
|
|
347
401
|
forceRefresh = false,
|
|
402
|
+
cacheOnly = false,
|
|
403
|
+
fullSyncTrigger: requestedFullSyncTrigger,
|
|
404
|
+
admissionOverrideReason: requestedOverrideReason,
|
|
405
|
+
fullSyncStartedAt: requestedFullSyncStartedAt,
|
|
406
|
+
fullSyncAdmissionToken: requestedAdmissionToken,
|
|
348
407
|
} = normalizedRequestBody;
|
|
349
408
|
|
|
409
|
+
const fullSyncTrigger = ['organic', 'scheduled', 'manual'].indexOf(
|
|
410
|
+
requestedFullSyncTrigger,
|
|
411
|
+
) >= 0
|
|
412
|
+
? requestedFullSyncTrigger
|
|
413
|
+
: (forceRefresh ? 'manual' : 'organic');
|
|
414
|
+
const admissionOverrideReason = typeof requestedOverrideReason === 'string'
|
|
415
|
+
? requestedOverrideReason.trim() || undefined
|
|
416
|
+
: undefined;
|
|
417
|
+
const fullSyncStartedAt = requestedFullSyncStartedAt !== undefined
|
|
418
|
+
&& requestedFullSyncStartedAt !== null
|
|
419
|
+
&& requestedFullSyncStartedAt !== ''
|
|
420
|
+
&& Number.isFinite(Number(requestedFullSyncStartedAt))
|
|
421
|
+
? Number(requestedFullSyncStartedAt)
|
|
422
|
+
: undefined;
|
|
423
|
+
const fullSyncAdmissionToken = typeof requestedAdmissionToken === 'string'
|
|
424
|
+
? requestedAdmissionToken.trim() || undefined
|
|
425
|
+
: undefined;
|
|
426
|
+
|
|
350
427
|
// Keep forceRefresh in the plugin payload so plugins can trigger their own rebuilds.
|
|
351
428
|
const payloadForPlugin = {
|
|
352
|
-
...R.omit([
|
|
429
|
+
...R.omit([
|
|
430
|
+
'forceRefresh',
|
|
431
|
+
'cacheOnly',
|
|
432
|
+
'fullSyncTrigger',
|
|
433
|
+
'admissionOverrideReason',
|
|
434
|
+
'fullSyncStartedAt',
|
|
435
|
+
'fullSyncAdmissionToken',
|
|
436
|
+
], normalizedRequestBody),
|
|
353
437
|
forceRefresh,
|
|
354
438
|
};
|
|
355
439
|
const isScopedSearch = hasProductSearchSelector(normalizedRequestBody);
|
|
356
|
-
const
|
|
357
|
-
['
|
|
358
|
-
|
|
359
|
-
|
|
440
|
+
const isDeclaredFullCatalogRefresh = forceRefresh
|
|
441
|
+
&& ['scheduled', 'manual'].indexOf(fullSyncTrigger) >= 0;
|
|
442
|
+
if (
|
|
443
|
+
forceRefresh
|
|
444
|
+
&& !cacheOnly
|
|
445
|
+
&& fullSyncTrigger === 'manual'
|
|
446
|
+
&& !admissionOverrideReason
|
|
447
|
+
) {
|
|
448
|
+
throw createManualCatalogRefreshReasonError();
|
|
449
|
+
}
|
|
450
|
+
if (isDeclaredFullCatalogRefresh && isScopedSearch) {
|
|
451
|
+
throw createScopedCatalogRefreshError();
|
|
452
|
+
}
|
|
453
|
+
if (
|
|
454
|
+
forceRefresh
|
|
455
|
+
&& !cacheOnly
|
|
456
|
+
&& R.path(['capabilities', 'weeklyProductCatalogSync'], app) !== true
|
|
457
|
+
) {
|
|
458
|
+
throw createUnsupportedCatalogRefreshError(appKey);
|
|
459
|
+
}
|
|
360
460
|
|
|
361
461
|
const cacheKey = productSearchCacheKey({ userId, hint });
|
|
362
462
|
const telemetryContext = {
|
|
@@ -368,6 +468,10 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
368
468
|
searchInput,
|
|
369
469
|
optionId,
|
|
370
470
|
forceRefresh,
|
|
471
|
+
fullSyncTrigger,
|
|
472
|
+
admissionOverrideReason,
|
|
473
|
+
fullSyncStartedAt,
|
|
474
|
+
fullSyncAdmissionToken,
|
|
371
475
|
startedAt: Date.now(),
|
|
372
476
|
};
|
|
373
477
|
const emitDecision = (action, extra = {}) => emitProductSearchCacheDecision(
|
|
@@ -376,12 +480,12 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
376
480
|
extra,
|
|
377
481
|
);
|
|
378
482
|
const pluginExecutionLockKey = `${cacheKey}:lock`; // Lock for direct plugin execution
|
|
379
|
-
const
|
|
483
|
+
const refreshOutcomeCacheKey = `${pluginExecutionLockKey}:outcome`;
|
|
380
484
|
|
|
381
485
|
// Fetch actualCacheContent once at the beginning.
|
|
382
486
|
const initialActualCacheContent = await app.cache.get({ key: cacheKey });
|
|
383
487
|
const lastUpdated = await app.cache.get({ key: `${cacheKey}:lastUpdated` });
|
|
384
|
-
const ttr = token
|
|
488
|
+
const ttr = resolveProductSearchTtrSeconds(token, app);
|
|
385
489
|
const isStaleByTTR = lastUpdated && (Date.now() - lastUpdated > ttr * 1000);
|
|
386
490
|
const doNotCallPluginForProducts = token.doNotCallPluginForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'doNotCall'], app);
|
|
387
491
|
const hasPluginExecutionLock = await app.cache.get({ key: pluginExecutionLockKey });
|
|
@@ -400,19 +504,30 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
400
504
|
return null;
|
|
401
505
|
};
|
|
402
506
|
|
|
403
|
-
const
|
|
404
|
-
const timeoutAt = Date.now() +
|
|
507
|
+
const waitForProductSearchRefresh = async previousLockOwnerToken => {
|
|
508
|
+
const timeoutAt = Date.now() + getProductSearchLockWaitMs();
|
|
405
509
|
while (Date.now() < timeoutAt) {
|
|
406
|
-
const
|
|
407
|
-
if (
|
|
510
|
+
const refreshOutcome = await app.cache.get({ key: refreshOutcomeCacheKey });
|
|
511
|
+
if (refreshOutcome && refreshOutcome.lockOwnerToken !== previousLockOwnerToken) {
|
|
512
|
+
return refreshOutcome;
|
|
513
|
+
}
|
|
408
514
|
|
|
409
515
|
const lockStillActive = await app.cache.get({ key: pluginExecutionLockKey });
|
|
410
|
-
if (!lockStillActive)
|
|
516
|
+
if (!lockStillActive) {
|
|
517
|
+
const completedOutcome = await app.cache.get({ key: refreshOutcomeCacheKey });
|
|
518
|
+
return completedOutcome
|
|
519
|
+
&& completedOutcome.lockOwnerToken !== previousLockOwnerToken
|
|
520
|
+
? completedOutcome
|
|
521
|
+
: null;
|
|
522
|
+
}
|
|
411
523
|
|
|
412
524
|
await sleep(productSearchLockPollMs);
|
|
413
525
|
}
|
|
414
526
|
|
|
415
|
-
|
|
527
|
+
const refreshOutcome = await app.cache.get({ key: refreshOutcomeCacheKey });
|
|
528
|
+
return refreshOutcome && refreshOutcome.lockOwnerToken !== previousLockOwnerToken
|
|
529
|
+
? refreshOutcome
|
|
530
|
+
: null;
|
|
416
531
|
};
|
|
417
532
|
|
|
418
533
|
const acquirePluginExecutionLock = async lockOwnerToken => app.cache.saveIfNotExists({
|
|
@@ -434,6 +549,7 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
434
549
|
userId,
|
|
435
550
|
hint,
|
|
436
551
|
});
|
|
552
|
+
assertCanonicalProductSearchResult(pluginResults, appKey);
|
|
437
553
|
emitDecision('scoped_result_not_cached', {
|
|
438
554
|
reason,
|
|
439
555
|
pluginElapsedMs: Date.now() - pluginStartedAt,
|
|
@@ -444,13 +560,29 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
444
560
|
}
|
|
445
561
|
|
|
446
562
|
const lockOwnerToken = crypto.randomBytes(16).toString('hex');
|
|
563
|
+
const previousRefreshOutcome = await app.cache.get({ key: refreshOutcomeCacheKey });
|
|
447
564
|
const lockStartedAt = Date.now();
|
|
448
565
|
const lockAcquired = await acquirePluginExecutionLock(lockOwnerToken);
|
|
449
566
|
if (!lockAcquired) {
|
|
450
567
|
emitDecision('lock_wait', { reason, lockWaitMs: 0 });
|
|
451
|
-
const
|
|
568
|
+
const refreshOutcome = await waitForProductSearchRefresh(
|
|
569
|
+
previousRefreshOutcome && previousRefreshOutcome.lockOwnerToken,
|
|
570
|
+
);
|
|
571
|
+
const cacheContent = await getCachedProductSearchResults();
|
|
452
572
|
const lockWaitMs = Date.now() - lockStartedAt;
|
|
453
|
-
if (
|
|
573
|
+
if (forceRefresh && refreshOutcome) {
|
|
574
|
+
emitDecision('cache_hit', {
|
|
575
|
+
reason: 'waitedForLeader',
|
|
576
|
+
lockWaitMs,
|
|
577
|
+
cacheProductCount: productCount(cacheContent),
|
|
578
|
+
cacheOptionCount: optionCount(cacheContent),
|
|
579
|
+
});
|
|
580
|
+
return {
|
|
581
|
+
...(cacheContent || { products: [] }),
|
|
582
|
+
...R.omit(['lockOwnerToken'], refreshOutcome),
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
if (!forceRefresh && cacheContent) {
|
|
454
586
|
emitDecision('cache_hit', {
|
|
455
587
|
reason: 'waitedForLeader',
|
|
456
588
|
lockWaitMs,
|
|
@@ -459,6 +591,21 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
459
591
|
});
|
|
460
592
|
return cacheContent;
|
|
461
593
|
}
|
|
594
|
+
if (forceRefresh && cacheContent) {
|
|
595
|
+
emitDecision('stale_served', {
|
|
596
|
+
reason: 'refreshInProgress',
|
|
597
|
+
lockWaitMs,
|
|
598
|
+
cacheProductCount: productCount(cacheContent),
|
|
599
|
+
cacheOptionCount: optionCount(cacheContent),
|
|
600
|
+
});
|
|
601
|
+
return {
|
|
602
|
+
...cacheContent,
|
|
603
|
+
catalogRefreshOutcome: 'refresh_in_progress_cache_served',
|
|
604
|
+
cacheUpdated: false,
|
|
605
|
+
cachePreserved: true,
|
|
606
|
+
cachedProductCount: productCount(cacheContent),
|
|
607
|
+
};
|
|
608
|
+
}
|
|
462
609
|
emitDecision('lock_timeout', { reason, lockWaitMs });
|
|
463
610
|
throw createProductSearchUnavailableError();
|
|
464
611
|
}
|
|
@@ -474,6 +621,11 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
474
621
|
});
|
|
475
622
|
|
|
476
623
|
let pluginResults;
|
|
624
|
+
let catalogRefreshOutcome;
|
|
625
|
+
let cacheUpdated = false;
|
|
626
|
+
let cachePreserved = false;
|
|
627
|
+
let cachedProductCount;
|
|
628
|
+
let effectiveCatalogResults;
|
|
477
629
|
try {
|
|
478
630
|
const pluginStartedAt = Date.now();
|
|
479
631
|
pluginResults = await func({
|
|
@@ -485,11 +637,10 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
485
637
|
userId,
|
|
486
638
|
hint,
|
|
487
639
|
});
|
|
640
|
+
assertCanonicalProductSearchResult(pluginResults, appKey);
|
|
488
641
|
const pluginElapsedMs = Date.now() - pluginStartedAt;
|
|
489
642
|
|
|
490
|
-
//
|
|
491
|
-
// This applies to forceRefresh, initial load, or direct calls that result in a fetch.
|
|
492
|
-
// The $updateProductSearchCache function handles caching for background jobs queued due to stale data.
|
|
643
|
+
// Cache usable results from a direct plugin fetch (forceRefresh or cache miss).
|
|
493
644
|
if (hasCacheableProductResults(pluginResults)) {
|
|
494
645
|
await app.cache.save({ key: `${cacheKey}:lastUpdated`, value: Date.now(), ttl: productSearchCacheTtlSeconds });
|
|
495
646
|
await app.cache.save({ key: cacheKey, value: pluginResults, ttl: productSearchCacheTtlSeconds });
|
|
@@ -499,22 +650,85 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
499
650
|
cacheProductCount: productCount(pluginResults),
|
|
500
651
|
cacheOptionCount: optionCount(pluginResults),
|
|
501
652
|
});
|
|
653
|
+
catalogRefreshOutcome = 'cache_updated';
|
|
654
|
+
cacheUpdated = true;
|
|
655
|
+
cachedProductCount = productCount(pluginResults);
|
|
502
656
|
} else if (pluginResults && (pluginResults.catalogPartial || pluginResults.partial)) {
|
|
657
|
+
const existingCacheContent = await app.cache.get({ key: cacheKey });
|
|
658
|
+
cachePreserved = hasNonEmptyProductCache(existingCacheContent);
|
|
659
|
+
effectiveCatalogResults = cachePreserved ? existingCacheContent : undefined;
|
|
660
|
+
catalogRefreshOutcome = cachePreserved
|
|
661
|
+
? 'partial_result_preserved_cache'
|
|
662
|
+
: 'partial_result_not_cached';
|
|
663
|
+
cachedProductCount = cachePreserved
|
|
664
|
+
? existingCacheContent.products.length
|
|
665
|
+
: null;
|
|
503
666
|
emitDecision('partial_refresh_skipped', {
|
|
504
|
-
reason:
|
|
667
|
+
reason: cachePreserved
|
|
668
|
+
? 'partialResultPreservedExistingCache'
|
|
669
|
+
: 'partialResultNotCached',
|
|
505
670
|
pluginElapsedMs,
|
|
506
|
-
cachePreserved
|
|
671
|
+
cachePreserved,
|
|
672
|
+
existingProductCount: cachePreserved
|
|
673
|
+
? existingCacheContent.products.length
|
|
674
|
+
: undefined,
|
|
507
675
|
returnedProductCount: productCount(pluginResults),
|
|
508
676
|
});
|
|
509
677
|
} else if (pluginResults && pluginResults.products && pluginResults.products.length === 0) {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
678
|
+
const existingCacheContent = await app.cache.get({ key: cacheKey });
|
|
679
|
+
if (hasNonEmptyProductCache(existingCacheContent)) {
|
|
680
|
+
catalogRefreshOutcome = 'empty_result_preserved_cache';
|
|
681
|
+
cachePreserved = true;
|
|
682
|
+
cachedProductCount = existingCacheContent.products.length;
|
|
683
|
+
effectiveCatalogResults = existingCacheContent;
|
|
684
|
+
emitDecision('empty_refresh_skipped', {
|
|
685
|
+
reason: 'emptyResultPreservedExistingCache',
|
|
686
|
+
pluginElapsedMs,
|
|
687
|
+
cachePreserved: true,
|
|
688
|
+
existingProductCount: existingCacheContent.products.length,
|
|
689
|
+
});
|
|
690
|
+
} else {
|
|
691
|
+
const latestCacheContent = await app.cache.get({ key: cacheKey });
|
|
692
|
+
if (hasNonEmptyProductCache(latestCacheContent)) {
|
|
693
|
+
catalogRefreshOutcome = 'empty_result_preserved_cache';
|
|
694
|
+
cachePreserved = true;
|
|
695
|
+
cachedProductCount = latestCacheContent.products.length;
|
|
696
|
+
effectiveCatalogResults = latestCacheContent;
|
|
697
|
+
emitDecision('empty_refresh_skipped', {
|
|
698
|
+
reason: 'emptyResultPreservedConcurrentCache',
|
|
699
|
+
pluginElapsedMs,
|
|
700
|
+
cachePreserved: true,
|
|
701
|
+
existingProductCount: latestCacheContent.products.length,
|
|
702
|
+
});
|
|
703
|
+
} else {
|
|
704
|
+
// Short-lived empty cache gives concurrent waiters the same answer as the lock holder.
|
|
705
|
+
await app.cache.save({
|
|
706
|
+
key: cacheKey,
|
|
707
|
+
value: pluginResults,
|
|
708
|
+
ttl: emptyProductSearchCacheTtlSeconds,
|
|
709
|
+
});
|
|
710
|
+
catalogRefreshOutcome = 'empty_cache_saved';
|
|
711
|
+
cacheUpdated = true;
|
|
712
|
+
cachedProductCount = 0;
|
|
713
|
+
emitDecision('empty_cache_saved', {
|
|
714
|
+
reason,
|
|
715
|
+
pluginElapsedMs,
|
|
716
|
+
cacheProductCount: 0,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
}
|
|
517
720
|
}
|
|
721
|
+
await app.cache.save({
|
|
722
|
+
key: refreshOutcomeCacheKey,
|
|
723
|
+
value: {
|
|
724
|
+
lockOwnerToken,
|
|
725
|
+
catalogRefreshOutcome,
|
|
726
|
+
cacheUpdated,
|
|
727
|
+
cachePreserved,
|
|
728
|
+
cachedProductCount,
|
|
729
|
+
},
|
|
730
|
+
ttl: productSearchRefreshOutcomeTtlSeconds,
|
|
731
|
+
});
|
|
518
732
|
} finally {
|
|
519
733
|
await lockRenewal.stop();
|
|
520
734
|
if (app.cache.dropIfValue) {
|
|
@@ -523,9 +737,39 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
523
737
|
await app.cache.drop({ key: pluginExecutionLockKey });
|
|
524
738
|
}
|
|
525
739
|
}
|
|
526
|
-
|
|
740
|
+
const result = effectiveCatalogResults || pluginResults || { products: [] };
|
|
741
|
+
if (!forceRefresh) return result;
|
|
742
|
+
return {
|
|
743
|
+
...result,
|
|
744
|
+
catalogRefreshOutcome,
|
|
745
|
+
cacheUpdated,
|
|
746
|
+
cachePreserved,
|
|
747
|
+
cachedProductCount,
|
|
748
|
+
};
|
|
527
749
|
};
|
|
528
750
|
|
|
751
|
+
// 0. `cacheOnly`: return current Ti2 cache or empty. Never call the plugin.
|
|
752
|
+
if (cacheOnly) {
|
|
753
|
+
if (hasProductCache(initialActualCacheContent)) {
|
|
754
|
+
const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
|
|
755
|
+
emitDecision('cache_hit', {
|
|
756
|
+
reason: 'cacheOnly',
|
|
757
|
+
cacheProductCount: initialActualCacheContent.products.length,
|
|
758
|
+
cacheOptionCount: optionCount(initialActualCacheContent),
|
|
759
|
+
returnedProductCount: searchResults.length,
|
|
760
|
+
returnedOptionCount: optionCount({ products: searchResults }),
|
|
761
|
+
});
|
|
762
|
+
return {
|
|
763
|
+
...initialActualCacheContent,
|
|
764
|
+
products: searchResults,
|
|
765
|
+
...(token.configuration || {}),
|
|
766
|
+
cacheFound: true,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
emitDecision('cache_miss', { reason: 'cacheOnly' });
|
|
770
|
+
return { products: [], ...(token.configuration || {}), cacheFound: false };
|
|
771
|
+
}
|
|
772
|
+
|
|
529
773
|
// 1. `doNotCallPluginForProducts` is true, and not `forceRefresh`: Serve from cache or empty.
|
|
530
774
|
if (doNotCallPluginForProducts && !forceRefresh) {
|
|
531
775
|
if (hasProductCache(initialActualCacheContent)) {
|
|
@@ -549,7 +793,15 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
549
793
|
emitDecision('force_refresh');
|
|
550
794
|
const funcResults = await fetchFromPluginAndCache('force_refresh');
|
|
551
795
|
const searchResults = $searchProductList(funcResults.products, searchInput, optionId);
|
|
552
|
-
return {
|
|
796
|
+
return {
|
|
797
|
+
...funcResults,
|
|
798
|
+
products: searchResults,
|
|
799
|
+
...(token.configuration || {}),
|
|
800
|
+
catalogRefreshOutcome: funcResults.catalogRefreshOutcome,
|
|
801
|
+
cacheUpdated: funcResults.cacheUpdated,
|
|
802
|
+
cachePreserved: funcResults.cachePreserved,
|
|
803
|
+
cachedProductCount: funcResults.cachedProductCount,
|
|
804
|
+
};
|
|
553
805
|
}
|
|
554
806
|
|
|
555
807
|
// 3. Cache exists (initialActualCacheContent) and not forceRefresh:
|
|
@@ -577,38 +829,13 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
577
829
|
|
|
578
830
|
const isEffectivelyStale = isStaleByTTR && !doNotCallPluginForProducts;
|
|
579
831
|
|
|
580
|
-
// Cache is fresh
|
|
832
|
+
// Cache is fresh, a plugin fetch is already running, or the cache is
|
|
833
|
+
// stale: organic search never starts a catalog refresh. The scheduled
|
|
834
|
+
// forceRefresh search is the only catalog owner.
|
|
581
835
|
if (!isEffectivelyStale || hasPluginExecutionLock) {
|
|
582
836
|
return returnCachedResults('cache_hit', { reason: hasPluginExecutionLock ? 'lockActive' : 'freshCache' });
|
|
583
837
|
}
|
|
584
|
-
|
|
585
|
-
// Cache is stale - queue at most one background refresh job and serve stale data.
|
|
586
|
-
const jobQueueLockAcquired = await app.cache.saveIfNotExists({
|
|
587
|
-
key: jobQueueLockKey,
|
|
588
|
-
value: true,
|
|
589
|
-
ttl: 60,
|
|
590
|
-
});
|
|
591
|
-
if (!jobQueueLockAcquired) {
|
|
592
|
-
return returnCachedResults('stale_served', { reason: 'backgroundRefreshDeduped' });
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
const jobId = await addJob({
|
|
596
|
-
type: 'plugin',
|
|
597
|
-
pluginName: appKey,
|
|
598
|
-
method: app.searchProducts ? 'searchProducts' : 'searchProductsForItinerary',
|
|
599
|
-
token,
|
|
600
|
-
payload: { payload: payloadForBackgroundJob, userId, hint },
|
|
601
|
-
postProcess: {
|
|
602
|
-
controller: 'bookings',
|
|
603
|
-
action: '$updateProductSearchCache',
|
|
604
|
-
args: { appKey, userId, hint },
|
|
605
|
-
},
|
|
606
|
-
}, { removeOnComplete: true });
|
|
607
|
-
|
|
608
|
-
return returnCachedResults('stale_served', {
|
|
609
|
-
reason: 'backgroundRefreshQueued',
|
|
610
|
-
jobId,
|
|
611
|
-
});
|
|
838
|
+
return returnCachedResults('stale_served', { reason: 'awaitingScheduledRefresh' });
|
|
612
839
|
}
|
|
613
840
|
}
|
|
614
841
|
|
|
@@ -889,93 +1116,6 @@ const getCreateBookingFields = plugins => async (req, res, next) => {
|
|
|
889
1116
|
}
|
|
890
1117
|
};
|
|
891
1118
|
|
|
892
|
-
const $updateProductSearchCache = plugins => async ({
|
|
893
|
-
appKey,
|
|
894
|
-
userId,
|
|
895
|
-
hint,
|
|
896
|
-
pluginResult, // Result from the plugin method call
|
|
897
|
-
requestId,
|
|
898
|
-
// 'plugins' is available via the factory closure
|
|
899
|
-
}) => {
|
|
900
|
-
const app = plugins.find(({ name }) => name === appKey);
|
|
901
|
-
if (!app) {
|
|
902
|
-
console.error(`[$updateProductSearchCache][requestId: ${requestId}] Plugin ${appKey} not found.`);
|
|
903
|
-
return; // Or throw error
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
const cacheKey = productSearchCacheKey({ userId, hint });
|
|
907
|
-
const markRefreshAttempted = () => app.cache.save({
|
|
908
|
-
key: `${cacheKey}:lastUpdated`,
|
|
909
|
-
value: Date.now(),
|
|
910
|
-
ttl: productSearchCacheTtlSeconds,
|
|
911
|
-
});
|
|
912
|
-
const emitCacheEvent = (action, extra = {}) => emitProductSearchCacheDecision({
|
|
913
|
-
app,
|
|
914
|
-
cacheKey,
|
|
915
|
-
userId,
|
|
916
|
-
hint,
|
|
917
|
-
requestId,
|
|
918
|
-
searchInput: '',
|
|
919
|
-
optionId: '',
|
|
920
|
-
forceRefresh: false,
|
|
921
|
-
startedAt: Date.now(),
|
|
922
|
-
}, action, extra);
|
|
923
|
-
|
|
924
|
-
if (hasCacheableProductResults(pluginResult)) {
|
|
925
|
-
await markRefreshAttempted();
|
|
926
|
-
await app.cache.save({ key: cacheKey, value: pluginResult, ttl: productSearchCacheTtlSeconds });
|
|
927
|
-
emitCacheEvent('cache_saved', {
|
|
928
|
-
cacheProductCount: productCount(pluginResult),
|
|
929
|
-
cacheOptionCount: optionCount(pluginResult),
|
|
930
|
-
});
|
|
931
|
-
} else {
|
|
932
|
-
const isPartialRefresh = pluginResult && (pluginResult.catalogPartial || pluginResult.partial);
|
|
933
|
-
const existingCacheContent = await app.cache.get({ key: cacheKey });
|
|
934
|
-
if (hasNonEmptyProductCache(existingCacheContent)) {
|
|
935
|
-
await markRefreshAttempted();
|
|
936
|
-
const eventExtra = {
|
|
937
|
-
reason: isPartialRefresh ? 'partialResultPreservedExistingCache' : 'emptyResultPreservedExistingCache',
|
|
938
|
-
cachePreserved: true,
|
|
939
|
-
existingProductCount: existingCacheContent.products.length,
|
|
940
|
-
};
|
|
941
|
-
if (isPartialRefresh) eventExtra.returnedProductCount = productCount(pluginResult);
|
|
942
|
-
emitCacheEvent(
|
|
943
|
-
isPartialRefresh ? 'partial_refresh_skipped' : 'empty_refresh_skipped',
|
|
944
|
-
eventExtra,
|
|
945
|
-
);
|
|
946
|
-
return;
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
if (isPartialRefresh) {
|
|
950
|
-
await markRefreshAttempted();
|
|
951
|
-
emitCacheEvent('partial_refresh_skipped', {
|
|
952
|
-
reason: 'partialResultNotCached',
|
|
953
|
-
cachePreserved: false,
|
|
954
|
-
returnedProductCount: productCount(pluginResult),
|
|
955
|
-
});
|
|
956
|
-
return;
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
// Best-effort race guard: avoid overwriting a non-empty cache written after the first read.
|
|
960
|
-
const latestCacheContent = await app.cache.get({ key: cacheKey });
|
|
961
|
-
if (hasNonEmptyProductCache(latestCacheContent)) {
|
|
962
|
-
await markRefreshAttempted();
|
|
963
|
-
emitCacheEvent('empty_refresh_skipped', {
|
|
964
|
-
reason: 'emptyResultPreservedConcurrentCache',
|
|
965
|
-
cachePreserved: true,
|
|
966
|
-
existingProductCount: latestCacheContent.products.length,
|
|
967
|
-
});
|
|
968
|
-
return;
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
// Empty complete refreshes are authoritative only when there is no existing non-empty cache.
|
|
972
|
-
await markRefreshAttempted();
|
|
973
|
-
await app.cache.save({ key: cacheKey, value: { products: [] }, ttl: productSearchCacheTtlSeconds });
|
|
974
|
-
emitCacheEvent('empty_cache_saved', { cacheProductCount: 0 });
|
|
975
|
-
}
|
|
976
|
-
};
|
|
977
|
-
|
|
978
|
-
// Update the module.exports to correctly assign the plugin-wrapped function
|
|
979
1119
|
const controllerFactory = plugins => {
|
|
980
1120
|
const controllerFunctions = {
|
|
981
1121
|
bookingsSearch: bookingsSearch(plugins),
|
|
@@ -993,7 +1133,6 @@ const controllerFactory = plugins => {
|
|
|
993
1133
|
getAffiliateDesks: getAffiliateDesks(plugins),
|
|
994
1134
|
getPickupPoints: getPickupPoints(plugins),
|
|
995
1135
|
getCreateBookingFields: getCreateBookingFields(plugins),
|
|
996
|
-
$updateProductSearchCache: $updateProductSearchCache(plugins), // Add the new function here
|
|
997
1136
|
};
|
|
998
1137
|
// Ensure $bookingsProductSearch can be called internally by worker with plugins already bound
|
|
999
1138
|
// This is more of a conceptual note as the factory pattern already handles this.
|
|
@@ -1002,5 +1141,8 @@ const controllerFactory = plugins => {
|
|
|
1002
1141
|
|
|
1003
1142
|
// Add this line to attach typeDefsAndQueries to the factory:
|
|
1004
1143
|
controllerFactory.typeDefsAndQueries = typeDefsAndQueries;
|
|
1144
|
+
controllerFactory.resolveProductSearchTtrSeconds = resolveProductSearchTtrSeconds;
|
|
1145
|
+
controllerFactory.defaultProductSearchTtrSeconds = defaultProductSearchTtrSeconds;
|
|
1146
|
+
controllerFactory.oneDayProductSearchTtrSeconds = oneDayProductSearchTtrSeconds;
|
|
1005
1147
|
|
|
1006
1148
|
module.exports = controllerFactory;
|
package/controllers/user.js
CHANGED
|
@@ -42,6 +42,7 @@ const getAppMethods = plugins => async (req, res, next) => {
|
|
|
42
42
|
const app = plugins.filter(({ name }) => name === appKey)[0];
|
|
43
43
|
const methods = getAllFuncs(app);
|
|
44
44
|
return res.json({
|
|
45
|
+
capabilities: app.capabilities || {},
|
|
45
46
|
methods,
|
|
46
47
|
});
|
|
47
48
|
} catch (err) {
|