ti2 1.0.122 → 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 +76 -13
- package/controllers/README.md +23 -34
- package/controllers/__tests__/bookings-searchProducts.js +386 -227
- 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 +278 -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,22 @@ 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 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
|
+
|
|
166
204
|
const getAppAndToken = async ({ plugins, appKey, userId, hint }) => {
|
|
167
205
|
const app = plugins.find(({ name }) => name === appKey);
|
|
168
206
|
assert(app, 'could not find the app ' + appKey);
|
|
@@ -271,7 +309,8 @@ const confirmBooking = plugins => async (req, res, next) => {
|
|
|
271
309
|
const $searchProductList = (products, searchInput = '', optionId = '') => {
|
|
272
310
|
// NOTE: optionId could be a string or an array of strings
|
|
273
311
|
// NOTE: searchInput should not appear at the same time as optionId
|
|
274
|
-
|
|
312
|
+
const trimmedSearchInput = (searchInput || '').trim();
|
|
313
|
+
if ((!trimmedSearchInput || trimmedSearchInput === '*') && !(optionId && optionId.length)) {
|
|
275
314
|
return products;
|
|
276
315
|
}
|
|
277
316
|
const getFullSearchStr = (product, option) => `${
|
|
@@ -279,7 +318,7 @@ const $searchProductList = (products, searchInput = '', optionId = '') => {
|
|
|
279
318
|
} ${R.path(['optionName'], option) || ''
|
|
280
319
|
} ${R.path(['optionId'], option) || ''
|
|
281
320
|
} ${R.path(['supplierId'], product) || ''}`;
|
|
282
|
-
const inputValueLower =
|
|
321
|
+
const inputValueLower = trimmedSearchInput.toLowerCase();
|
|
283
322
|
const parts = inputValueLower.split(' ').filter(Boolean); // Filter out any empty strings just in case
|
|
284
323
|
const pwFilteredOptions = products.map(product => {
|
|
285
324
|
const filteredOptions = R.pathOr([], ['options'], product).filter(option => {
|
|
@@ -309,6 +348,13 @@ const hasCacheableProductResults = pluginResults => Boolean(
|
|
|
309
348
|
&& !pluginResults.partial
|
|
310
349
|
);
|
|
311
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
|
+
|
|
312
358
|
const hasNonEmptyProductCache = cacheContent => Boolean(
|
|
313
359
|
cacheContent
|
|
314
360
|
&& cacheContent.products
|
|
@@ -322,7 +368,7 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
322
368
|
hint,
|
|
323
369
|
payload: originalRequestBody, // Renamed for clarity, this is req.body
|
|
324
370
|
requestId,
|
|
325
|
-
headers,
|
|
371
|
+
headers,
|
|
326
372
|
}) => {
|
|
327
373
|
const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
|
|
328
374
|
assert(userId, 'userId is required');
|
|
@@ -345,18 +391,56 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
345
391
|
searchInput = '', // Provide defaults
|
|
346
392
|
optionId = '',
|
|
347
393
|
forceRefresh = false,
|
|
394
|
+
cacheOnly = false,
|
|
395
|
+
fullSyncTrigger: requestedFullSyncTrigger,
|
|
396
|
+
admissionOverrideReason: requestedOverrideReason,
|
|
397
|
+
fullSyncStartedAt: requestedFullSyncStartedAt,
|
|
398
|
+
fullSyncAdmissionToken: requestedAdmissionToken,
|
|
348
399
|
} = normalizedRequestBody;
|
|
349
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
|
+
|
|
350
419
|
// Keep forceRefresh in the plugin payload so plugins can trigger their own rebuilds.
|
|
351
420
|
const payloadForPlugin = {
|
|
352
|
-
...R.omit([
|
|
421
|
+
...R.omit([
|
|
422
|
+
'forceRefresh',
|
|
423
|
+
'cacheOnly',
|
|
424
|
+
'fullSyncTrigger',
|
|
425
|
+
'admissionOverrideReason',
|
|
426
|
+
'fullSyncStartedAt',
|
|
427
|
+
'fullSyncAdmissionToken',
|
|
428
|
+
], normalizedRequestBody),
|
|
353
429
|
forceRefresh,
|
|
354
430
|
};
|
|
355
431
|
const isScopedSearch = hasProductSearchSelector(normalizedRequestBody);
|
|
356
|
-
const
|
|
357
|
-
['
|
|
358
|
-
|
|
359
|
-
|
|
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
|
+
}
|
|
360
444
|
|
|
361
445
|
const cacheKey = productSearchCacheKey({ userId, hint });
|
|
362
446
|
const telemetryContext = {
|
|
@@ -368,6 +452,10 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
368
452
|
searchInput,
|
|
369
453
|
optionId,
|
|
370
454
|
forceRefresh,
|
|
455
|
+
fullSyncTrigger,
|
|
456
|
+
admissionOverrideReason,
|
|
457
|
+
fullSyncStartedAt,
|
|
458
|
+
fullSyncAdmissionToken,
|
|
371
459
|
startedAt: Date.now(),
|
|
372
460
|
};
|
|
373
461
|
const emitDecision = (action, extra = {}) => emitProductSearchCacheDecision(
|
|
@@ -376,12 +464,12 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
376
464
|
extra,
|
|
377
465
|
);
|
|
378
466
|
const pluginExecutionLockKey = `${cacheKey}:lock`; // Lock for direct plugin execution
|
|
379
|
-
const
|
|
467
|
+
const refreshOutcomeCacheKey = `${pluginExecutionLockKey}:outcome`;
|
|
380
468
|
|
|
381
469
|
// Fetch actualCacheContent once at the beginning.
|
|
382
470
|
const initialActualCacheContent = await app.cache.get({ key: cacheKey });
|
|
383
471
|
const lastUpdated = await app.cache.get({ key: `${cacheKey}:lastUpdated` });
|
|
384
|
-
const ttr = token
|
|
472
|
+
const ttr = resolveProductSearchTtrSeconds(token, app);
|
|
385
473
|
const isStaleByTTR = lastUpdated && (Date.now() - lastUpdated > ttr * 1000);
|
|
386
474
|
const doNotCallPluginForProducts = token.doNotCallPluginForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'doNotCall'], app);
|
|
387
475
|
const hasPluginExecutionLock = await app.cache.get({ key: pluginExecutionLockKey });
|
|
@@ -400,19 +488,30 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
400
488
|
return null;
|
|
401
489
|
};
|
|
402
490
|
|
|
403
|
-
const
|
|
404
|
-
const timeoutAt = Date.now() +
|
|
491
|
+
const waitForProductSearchRefresh = async previousLockOwnerToken => {
|
|
492
|
+
const timeoutAt = Date.now() + getProductSearchLockWaitMs();
|
|
405
493
|
while (Date.now() < timeoutAt) {
|
|
406
|
-
const
|
|
407
|
-
if (
|
|
494
|
+
const refreshOutcome = await app.cache.get({ key: refreshOutcomeCacheKey });
|
|
495
|
+
if (refreshOutcome && refreshOutcome.lockOwnerToken !== previousLockOwnerToken) {
|
|
496
|
+
return refreshOutcome;
|
|
497
|
+
}
|
|
408
498
|
|
|
409
499
|
const lockStillActive = await app.cache.get({ key: pluginExecutionLockKey });
|
|
410
|
-
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
|
+
}
|
|
411
507
|
|
|
412
508
|
await sleep(productSearchLockPollMs);
|
|
413
509
|
}
|
|
414
510
|
|
|
415
|
-
|
|
511
|
+
const refreshOutcome = await app.cache.get({ key: refreshOutcomeCacheKey });
|
|
512
|
+
return refreshOutcome && refreshOutcome.lockOwnerToken !== previousLockOwnerToken
|
|
513
|
+
? refreshOutcome
|
|
514
|
+
: null;
|
|
416
515
|
};
|
|
417
516
|
|
|
418
517
|
const acquirePluginExecutionLock = async lockOwnerToken => app.cache.saveIfNotExists({
|
|
@@ -434,6 +533,7 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
434
533
|
userId,
|
|
435
534
|
hint,
|
|
436
535
|
});
|
|
536
|
+
assertCanonicalProductSearchResult(pluginResults, appKey);
|
|
437
537
|
emitDecision('scoped_result_not_cached', {
|
|
438
538
|
reason,
|
|
439
539
|
pluginElapsedMs: Date.now() - pluginStartedAt,
|
|
@@ -444,13 +544,29 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
444
544
|
}
|
|
445
545
|
|
|
446
546
|
const lockOwnerToken = crypto.randomBytes(16).toString('hex');
|
|
547
|
+
const previousRefreshOutcome = await app.cache.get({ key: refreshOutcomeCacheKey });
|
|
447
548
|
const lockStartedAt = Date.now();
|
|
448
549
|
const lockAcquired = await acquirePluginExecutionLock(lockOwnerToken);
|
|
449
550
|
if (!lockAcquired) {
|
|
450
551
|
emitDecision('lock_wait', { reason, lockWaitMs: 0 });
|
|
451
|
-
const
|
|
552
|
+
const refreshOutcome = await waitForProductSearchRefresh(
|
|
553
|
+
previousRefreshOutcome && previousRefreshOutcome.lockOwnerToken,
|
|
554
|
+
);
|
|
555
|
+
const cacheContent = await getCachedProductSearchResults();
|
|
452
556
|
const lockWaitMs = Date.now() - lockStartedAt;
|
|
453
|
-
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) {
|
|
454
570
|
emitDecision('cache_hit', {
|
|
455
571
|
reason: 'waitedForLeader',
|
|
456
572
|
lockWaitMs,
|
|
@@ -459,6 +575,21 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
459
575
|
});
|
|
460
576
|
return cacheContent;
|
|
461
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
|
+
}
|
|
462
593
|
emitDecision('lock_timeout', { reason, lockWaitMs });
|
|
463
594
|
throw createProductSearchUnavailableError();
|
|
464
595
|
}
|
|
@@ -474,6 +605,11 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
474
605
|
});
|
|
475
606
|
|
|
476
607
|
let pluginResults;
|
|
608
|
+
let catalogRefreshOutcome;
|
|
609
|
+
let cacheUpdated = false;
|
|
610
|
+
let cachePreserved = false;
|
|
611
|
+
let cachedProductCount;
|
|
612
|
+
let effectiveCatalogResults;
|
|
477
613
|
try {
|
|
478
614
|
const pluginStartedAt = Date.now();
|
|
479
615
|
pluginResults = await func({
|
|
@@ -485,11 +621,10 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
485
621
|
userId,
|
|
486
622
|
hint,
|
|
487
623
|
});
|
|
624
|
+
assertCanonicalProductSearchResult(pluginResults, appKey);
|
|
488
625
|
const pluginElapsedMs = Date.now() - pluginStartedAt;
|
|
489
626
|
|
|
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.
|
|
627
|
+
// Cache usable results from a direct plugin fetch (forceRefresh or cache miss).
|
|
493
628
|
if (hasCacheableProductResults(pluginResults)) {
|
|
494
629
|
await app.cache.save({ key: `${cacheKey}:lastUpdated`, value: Date.now(), ttl: productSearchCacheTtlSeconds });
|
|
495
630
|
await app.cache.save({ key: cacheKey, value: pluginResults, ttl: productSearchCacheTtlSeconds });
|
|
@@ -499,22 +634,85 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
499
634
|
cacheProductCount: productCount(pluginResults),
|
|
500
635
|
cacheOptionCount: optionCount(pluginResults),
|
|
501
636
|
});
|
|
637
|
+
catalogRefreshOutcome = 'cache_updated';
|
|
638
|
+
cacheUpdated = true;
|
|
639
|
+
cachedProductCount = productCount(pluginResults);
|
|
502
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;
|
|
503
650
|
emitDecision('partial_refresh_skipped', {
|
|
504
|
-
reason:
|
|
651
|
+
reason: cachePreserved
|
|
652
|
+
? 'partialResultPreservedExistingCache'
|
|
653
|
+
: 'partialResultNotCached',
|
|
505
654
|
pluginElapsedMs,
|
|
506
|
-
cachePreserved
|
|
655
|
+
cachePreserved,
|
|
656
|
+
existingProductCount: cachePreserved
|
|
657
|
+
? existingCacheContent.products.length
|
|
658
|
+
: undefined,
|
|
507
659
|
returnedProductCount: productCount(pluginResults),
|
|
508
660
|
});
|
|
509
661
|
} else if (pluginResults && pluginResults.products && pluginResults.products.length === 0) {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
+
}
|
|
517
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
|
+
});
|
|
518
716
|
} finally {
|
|
519
717
|
await lockRenewal.stop();
|
|
520
718
|
if (app.cache.dropIfValue) {
|
|
@@ -523,9 +721,39 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
523
721
|
await app.cache.drop({ key: pluginExecutionLockKey });
|
|
524
722
|
}
|
|
525
723
|
}
|
|
526
|
-
|
|
724
|
+
const result = effectiveCatalogResults || pluginResults || { products: [] };
|
|
725
|
+
if (!forceRefresh) return result;
|
|
726
|
+
return {
|
|
727
|
+
...result,
|
|
728
|
+
catalogRefreshOutcome,
|
|
729
|
+
cacheUpdated,
|
|
730
|
+
cachePreserved,
|
|
731
|
+
cachedProductCount,
|
|
732
|
+
};
|
|
527
733
|
};
|
|
528
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
|
+
|
|
529
757
|
// 1. `doNotCallPluginForProducts` is true, and not `forceRefresh`: Serve from cache or empty.
|
|
530
758
|
if (doNotCallPluginForProducts && !forceRefresh) {
|
|
531
759
|
if (hasProductCache(initialActualCacheContent)) {
|
|
@@ -549,7 +777,15 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
549
777
|
emitDecision('force_refresh');
|
|
550
778
|
const funcResults = await fetchFromPluginAndCache('force_refresh');
|
|
551
779
|
const searchResults = $searchProductList(funcResults.products, searchInput, optionId);
|
|
552
|
-
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
|
+
};
|
|
553
789
|
}
|
|
554
790
|
|
|
555
791
|
// 3. Cache exists (initialActualCacheContent) and not forceRefresh:
|
|
@@ -577,38 +813,13 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
577
813
|
|
|
578
814
|
const isEffectivelyStale = isStaleByTTR && !doNotCallPluginForProducts;
|
|
579
815
|
|
|
580
|
-
// 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.
|
|
581
819
|
if (!isEffectivelyStale || hasPluginExecutionLock) {
|
|
582
820
|
return returnCachedResults('cache_hit', { reason: hasPluginExecutionLock ? 'lockActive' : 'freshCache' });
|
|
583
821
|
}
|
|
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
|
-
});
|
|
822
|
+
return returnCachedResults('stale_served', { reason: 'awaitingScheduledRefresh' });
|
|
612
823
|
}
|
|
613
824
|
}
|
|
614
825
|
|
|
@@ -889,93 +1100,6 @@ const getCreateBookingFields = plugins => async (req, res, next) => {
|
|
|
889
1100
|
}
|
|
890
1101
|
};
|
|
891
1102
|
|
|
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
1103
|
const controllerFactory = plugins => {
|
|
980
1104
|
const controllerFunctions = {
|
|
981
1105
|
bookingsSearch: bookingsSearch(plugins),
|
|
@@ -993,7 +1117,6 @@ const controllerFactory = plugins => {
|
|
|
993
1117
|
getAffiliateDesks: getAffiliateDesks(plugins),
|
|
994
1118
|
getPickupPoints: getPickupPoints(plugins),
|
|
995
1119
|
getCreateBookingFields: getCreateBookingFields(plugins),
|
|
996
|
-
$updateProductSearchCache: $updateProductSearchCache(plugins), // Add the new function here
|
|
997
1120
|
};
|
|
998
1121
|
// Ensure $bookingsProductSearch can be called internally by worker with plugins already bound
|
|
999
1122
|
// This is more of a conceptual note as the factory pattern already handles this.
|
|
@@ -1002,5 +1125,8 @@ const controllerFactory = plugins => {
|
|
|
1002
1125
|
|
|
1003
1126
|
// Add this line to attach typeDefsAndQueries to the factory:
|
|
1004
1127
|
controllerFactory.typeDefsAndQueries = typeDefsAndQueries;
|
|
1128
|
+
controllerFactory.resolveProductSearchTtrSeconds = resolveProductSearchTtrSeconds;
|
|
1129
|
+
controllerFactory.defaultProductSearchTtrSeconds = defaultProductSearchTtrSeconds;
|
|
1130
|
+
controllerFactory.oneDayProductSearchTtrSeconds = oneDayProductSearchTtrSeconds;
|
|
1005
1131
|
|
|
1006
1132
|
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) {
|