ti2 1.0.115 → 1.0.118
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 +1 -0
- package/__tests__/cache.js +64 -0
- package/api.yml +112 -0
- package/cache.js +74 -30
- package/controllers/__tests__/bookings-searchProducts.js +483 -8
- package/controllers/__tests__/bookings.js +53 -0
- package/controllers/bookings.js +352 -48
- package/index.js +3 -0
- package/package.json +1 -1
- package/test/plugin.js +41 -0
- package/tutorials/plugin-development.md +2 -0
- package/.windsurfrules +0 -9
- package/CLAUDE.md +0 -159
package/controllers/bookings.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const assert = require('assert');
|
|
2
|
+
const crypto = require('crypto');
|
|
2
3
|
const hash = require('object-hash');
|
|
3
4
|
const R = require('ramda');
|
|
4
5
|
const { UserAppKey } = require('../models/index');
|
|
@@ -28,6 +29,94 @@ const typeDefsAndQueries = {
|
|
|
28
29
|
itineraryBookingQuery,
|
|
29
30
|
};
|
|
30
31
|
|
|
32
|
+
const getPositiveIntegerEnv = (name, defaultValue) => {
|
|
33
|
+
const value = Number(process.env[name]);
|
|
34
|
+
const integerValue = Math.floor(value);
|
|
35
|
+
return Number.isFinite(value) && integerValue > 0 ? integerValue : defaultValue;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const getProductSearchLockTtlSeconds = () => getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_TTL_SECONDS', 120);
|
|
39
|
+
const productSearchLockWaitMs = getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_WAIT_MS', 25 * 1000);
|
|
40
|
+
const productSearchLockPollMs = getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_POLL_MS', 250);
|
|
41
|
+
const emptyProductSearchCacheTtlSeconds = 60;
|
|
42
|
+
const productSearchCacheTtlSeconds = 30 * 24 * 60 * 60;
|
|
43
|
+
const productSearchOperationId = 'bookingsProductSearch';
|
|
44
|
+
const productSearchCacheDecisionEvent = 'bookingsProductSearch:cache:decision';
|
|
45
|
+
const legacyProductSearchCacheEvents = {
|
|
46
|
+
cache_saved: 'bookingsProductSearch:cache:save',
|
|
47
|
+
partial_refresh_skipped: 'bookingsProductSearch:cache:partialRefreshSkipped',
|
|
48
|
+
empty_refresh_skipped: 'bookingsProductSearch:cache:emptyRefreshSkipped',
|
|
49
|
+
empty_cache_saved: 'bookingsProductSearch:cache:emptyRefresh',
|
|
50
|
+
};
|
|
51
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
52
|
+
|
|
53
|
+
const productSearchCacheKey = ({ userId, hint }) => hash({ userId, hint, operationId: productSearchOperationId });
|
|
54
|
+
|
|
55
|
+
const productCount = value => R.pathOr([], ['products'], value).length;
|
|
56
|
+
|
|
57
|
+
const hasProductCache = cacheContent => Boolean(cacheContent && cacheContent.products);
|
|
58
|
+
|
|
59
|
+
const safeHash = value => (value === undefined || value === null ? undefined : hash(String(value)));
|
|
60
|
+
|
|
61
|
+
const emitProductSearchCacheDecision = ({ app, cacheKey, userId, hint, requestId, searchInput, optionId, forceRefresh, startedAt }, action, extra = {}) => {
|
|
62
|
+
if (!app.events || !app.events.emit) return;
|
|
63
|
+
const payload = {
|
|
64
|
+
action,
|
|
65
|
+
operationId: productSearchOperationId,
|
|
66
|
+
requestId,
|
|
67
|
+
pluginName: app.name,
|
|
68
|
+
userIdHash: safeHash(userId),
|
|
69
|
+
hintHash: safeHash(hint),
|
|
70
|
+
cacheKeyHash: safeHash(cacheKey),
|
|
71
|
+
forceRefresh: Boolean(forceRefresh),
|
|
72
|
+
hasSearchInput: Boolean((searchInput || '').trim()),
|
|
73
|
+
hasOptionId: Boolean(optionId && optionId.length),
|
|
74
|
+
elapsedMs: Date.now() - startedAt,
|
|
75
|
+
...extra,
|
|
76
|
+
};
|
|
77
|
+
app.events.emit(productSearchCacheDecisionEvent, payload);
|
|
78
|
+
if (legacyProductSearchCacheEvents[action]) {
|
|
79
|
+
app.events.emit(legacyProductSearchCacheEvents[action], payload);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const createLockRenewal = ({ app, key, value, ttl, onDecision }) => {
|
|
84
|
+
if (!app.cache.expireIfValue || ttl <= 0) return { stop: async () => {} };
|
|
85
|
+
|
|
86
|
+
const renewEveryMs = Math.max(250, Math.floor(ttl * 500));
|
|
87
|
+
let stopped = false;
|
|
88
|
+
let inFlightRenewal = Promise.resolve();
|
|
89
|
+
const renew = async () => {
|
|
90
|
+
if (stopped) return;
|
|
91
|
+
const renewed = await app.cache.expireIfValue({ key, value, ttl });
|
|
92
|
+
if (!renewed) {
|
|
93
|
+
stopped = true;
|
|
94
|
+
onDecision('lock_renew_lost', { reason: 'ownerTokenMismatch' });
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const interval = setInterval(() => {
|
|
98
|
+
inFlightRenewal = renew().catch(err => {
|
|
99
|
+
stopped = true;
|
|
100
|
+
onDecision('lock_renew_failed', { reason: err.message });
|
|
101
|
+
});
|
|
102
|
+
}, renewEveryMs);
|
|
103
|
+
if (interval.unref) interval.unref();
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
stop: async () => {
|
|
107
|
+
stopped = true;
|
|
108
|
+
clearInterval(interval);
|
|
109
|
+
await inFlightRenewal;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const createProductSearchUnavailableError = () => {
|
|
115
|
+
const err = new Error('Product search cache refresh did not produce cached results');
|
|
116
|
+
err.status = 503;
|
|
117
|
+
return err;
|
|
118
|
+
};
|
|
119
|
+
|
|
31
120
|
const getAppAndToken = async ({ plugins, appKey, userId, hint }) => {
|
|
32
121
|
const app = plugins.find(({ name }) => name === appKey);
|
|
33
122
|
assert(app, 'could not find the app ' + appKey);
|
|
@@ -77,6 +166,7 @@ const bookingsCancel = plugins => async (req, res, next) => {
|
|
|
77
166
|
} = req;
|
|
78
167
|
try {
|
|
79
168
|
const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
|
|
169
|
+
assert(app.cancelBooking, `cancelBooking is not available for ${appKey}`);
|
|
80
170
|
const results = await app.cancelBooking({
|
|
81
171
|
axios,
|
|
82
172
|
token,
|
|
@@ -86,6 +176,46 @@ const bookingsCancel = plugins => async (req, res, next) => {
|
|
|
86
176
|
hint,
|
|
87
177
|
requestId: req.requestId,
|
|
88
178
|
});
|
|
179
|
+
app.events.emit('bookingsCancelBooking', {
|
|
180
|
+
userId,
|
|
181
|
+
hint,
|
|
182
|
+
operationId: 'cancelBooking',
|
|
183
|
+
requestId: req.requestId,
|
|
184
|
+
pluginName: app.name,
|
|
185
|
+
payload: results,
|
|
186
|
+
});
|
|
187
|
+
return res.json(results);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
return next(err);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const confirmBooking = plugins => async (req, res, next) => {
|
|
194
|
+
const {
|
|
195
|
+
axios,
|
|
196
|
+
params: { appKey, userId, hint },
|
|
197
|
+
body,
|
|
198
|
+
} = req;
|
|
199
|
+
try {
|
|
200
|
+
const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
|
|
201
|
+
assert(app.confirmBooking, `confirmBooking is not available for ${appKey}`);
|
|
202
|
+
const results = await app.confirmBooking({
|
|
203
|
+
axios,
|
|
204
|
+
token,
|
|
205
|
+
payload: body,
|
|
206
|
+
typeDefsAndQueries,
|
|
207
|
+
userId,
|
|
208
|
+
hint,
|
|
209
|
+
requestId: req.requestId,
|
|
210
|
+
});
|
|
211
|
+
app.events.emit('bookingsConfirmBooking', {
|
|
212
|
+
userId,
|
|
213
|
+
hint,
|
|
214
|
+
operationId: 'confirmBooking',
|
|
215
|
+
requestId: req.requestId,
|
|
216
|
+
pluginName: app.name,
|
|
217
|
+
payload: results,
|
|
218
|
+
});
|
|
89
219
|
return res.json(results);
|
|
90
220
|
} catch (err) {
|
|
91
221
|
return next(err);
|
|
@@ -123,6 +253,22 @@ const $searchProductList = (products, searchInput = '', optionId = '') => {
|
|
|
123
253
|
return filteredProducts;
|
|
124
254
|
};
|
|
125
255
|
|
|
256
|
+
// Plugins may flag incomplete catalog responses with `catalogPartial` or `partial`;
|
|
257
|
+
// both must suppress cache writes so a partial result does not replace a complete cache.
|
|
258
|
+
const hasCacheableProductResults = pluginResults => Boolean(
|
|
259
|
+
pluginResults
|
|
260
|
+
&& pluginResults.products
|
|
261
|
+
&& pluginResults.products.length > 0
|
|
262
|
+
&& !pluginResults.catalogPartial
|
|
263
|
+
&& !pluginResults.partial
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const hasNonEmptyProductCache = cacheContent => Boolean(
|
|
267
|
+
cacheContent
|
|
268
|
+
&& cacheContent.products
|
|
269
|
+
&& cacheContent.products.length > 0
|
|
270
|
+
);
|
|
271
|
+
|
|
126
272
|
const $bookingsProductSearch = plugins => async ({
|
|
127
273
|
axios,
|
|
128
274
|
appKey,
|
|
@@ -149,7 +295,19 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
149
295
|
assert(app.searchProducts || app.searchProductsForItinerary, `searchProducts or searchProductsForItinerary is not available for ${appKey}`);
|
|
150
296
|
const func = (app.searchProducts || app.searchProductsForItinerary).bind(app);
|
|
151
297
|
|
|
152
|
-
const cacheKey =
|
|
298
|
+
const cacheKey = productSearchCacheKey({ userId, hint });
|
|
299
|
+
const telemetryContext = {
|
|
300
|
+
app,
|
|
301
|
+
cacheKey,
|
|
302
|
+
userId,
|
|
303
|
+
hint,
|
|
304
|
+
requestId,
|
|
305
|
+
searchInput,
|
|
306
|
+
optionId,
|
|
307
|
+
forceRefresh,
|
|
308
|
+
startedAt: Date.now(),
|
|
309
|
+
};
|
|
310
|
+
const emitDecision = (action, extra = {}) => emitProductSearchCacheDecision(telemetryContext, action, extra);
|
|
153
311
|
const pluginExecutionLockKey = `${cacheKey}:lock`; // Lock for direct plugin execution
|
|
154
312
|
const jobQueueLockKey = `${cacheKey}:jobLock`; // Lock for preventing multiple job queues
|
|
155
313
|
|
|
@@ -160,12 +318,75 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
160
318
|
const isStaleByTTR = lastUpdated && (Date.now() - lastUpdated > ttr * 1000);
|
|
161
319
|
const doNotCallPluginForProducts = token.doNotCallPluginForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'doNotCall'], app);
|
|
162
320
|
const hasPluginExecutionLock = await app.cache.get({ key: pluginExecutionLockKey });
|
|
321
|
+
emitDecision(hasProductCache(initialActualCacheContent) ? 'cache_hit' : 'cache_miss', {
|
|
322
|
+
cacheProductCount: productCount(initialActualCacheContent),
|
|
323
|
+
cacheAgeMs: lastUpdated ? Date.now() - lastUpdated : undefined,
|
|
324
|
+
ttrMs: ttr * 1000,
|
|
325
|
+
reason: hasPluginExecutionLock ? 'lockActive' : undefined,
|
|
326
|
+
});
|
|
327
|
+
assert(app.cache.saveIfNotExists, 'cache adapter must expose saveIfNotExists');
|
|
328
|
+
|
|
329
|
+
const getCachedProductSearchResults = async () => {
|
|
330
|
+
const cacheContent = await app.cache.get({ key: cacheKey });
|
|
331
|
+
if (hasProductCache(cacheContent)) return cacheContent;
|
|
332
|
+
return null;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const waitForProductSearchCache = async () => {
|
|
336
|
+
const timeoutAt = Date.now() + productSearchLockWaitMs;
|
|
337
|
+
while (Date.now() < timeoutAt) {
|
|
338
|
+
const cacheContent = await getCachedProductSearchResults();
|
|
339
|
+
if (cacheContent) return cacheContent;
|
|
340
|
+
|
|
341
|
+
const lockStillActive = await app.cache.get({ key: pluginExecutionLockKey });
|
|
342
|
+
if (!lockStillActive) return null;
|
|
343
|
+
|
|
344
|
+
await sleep(productSearchLockPollMs);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return getCachedProductSearchResults();
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const acquirePluginExecutionLock = async lockOwnerToken => app.cache.saveIfNotExists({
|
|
351
|
+
key: pluginExecutionLockKey,
|
|
352
|
+
value: lockOwnerToken,
|
|
353
|
+
ttl: getProductSearchLockTtlSeconds(),
|
|
354
|
+
});
|
|
163
355
|
|
|
164
356
|
// Helper function to call the plugin, save cache, and return results
|
|
165
|
-
const fetchFromPluginAndCache = async () => {
|
|
166
|
-
|
|
357
|
+
const fetchFromPluginAndCache = async (reason = 'cache_miss') => {
|
|
358
|
+
const lockOwnerToken = crypto.randomBytes(16).toString('hex');
|
|
359
|
+
const lockStartedAt = Date.now();
|
|
360
|
+
const lockAcquired = await acquirePluginExecutionLock(lockOwnerToken);
|
|
361
|
+
if (!lockAcquired) {
|
|
362
|
+
emitDecision('lock_wait', { reason, lockWaitMs: 0 });
|
|
363
|
+
const cacheContent = await waitForProductSearchCache();
|
|
364
|
+
const lockWaitMs = Date.now() - lockStartedAt;
|
|
365
|
+
if (cacheContent) {
|
|
366
|
+
emitDecision('cache_hit', {
|
|
367
|
+
reason: 'waitedForLeader',
|
|
368
|
+
lockWaitMs,
|
|
369
|
+
cacheProductCount: productCount(cacheContent),
|
|
370
|
+
});
|
|
371
|
+
return cacheContent;
|
|
372
|
+
}
|
|
373
|
+
emitDecision('lock_timeout', { reason, lockWaitMs });
|
|
374
|
+
throw createProductSearchUnavailableError();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const lockTtlSeconds = getProductSearchLockTtlSeconds();
|
|
378
|
+
emitDecision('lock_acquired', { reason, lockTtlSeconds });
|
|
379
|
+
const lockRenewal = createLockRenewal({
|
|
380
|
+
app,
|
|
381
|
+
key: pluginExecutionLockKey,
|
|
382
|
+
value: lockOwnerToken,
|
|
383
|
+
ttl: lockTtlSeconds,
|
|
384
|
+
onDecision: emitDecision,
|
|
385
|
+
});
|
|
386
|
+
|
|
167
387
|
let pluginResults;
|
|
168
388
|
try {
|
|
389
|
+
const pluginStartedAt = Date.now();
|
|
169
390
|
pluginResults = await func({
|
|
170
391
|
axios,
|
|
171
392
|
token,
|
|
@@ -175,46 +396,72 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
175
396
|
userId,
|
|
176
397
|
hint,
|
|
177
398
|
});
|
|
399
|
+
const pluginElapsedMs = Date.now() - pluginStartedAt;
|
|
178
400
|
|
|
179
|
-
// This function is responsible for caching if it fetched
|
|
401
|
+
// This function is responsible for caching if it fetched usable results.
|
|
180
402
|
// This applies to forceRefresh, initial load, or direct calls that result in a fetch.
|
|
181
403
|
// The $updateProductSearchCache function handles caching for background jobs queued due to stale data.
|
|
182
|
-
if (pluginResults
|
|
183
|
-
|
|
184
|
-
await app.cache.save({ key:
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
404
|
+
if (hasCacheableProductResults(pluginResults)) {
|
|
405
|
+
await app.cache.save({ key: `${cacheKey}:lastUpdated`, value: Date.now(), ttl: productSearchCacheTtlSeconds });
|
|
406
|
+
await app.cache.save({ key: cacheKey, value: pluginResults, ttl: productSearchCacheTtlSeconds });
|
|
407
|
+
emitDecision('cache_saved', {
|
|
408
|
+
reason,
|
|
409
|
+
pluginElapsedMs,
|
|
410
|
+
cacheProductCount: productCount(pluginResults),
|
|
411
|
+
});
|
|
412
|
+
} else if (pluginResults && (pluginResults.catalogPartial || pluginResults.partial)) {
|
|
413
|
+
emitDecision('partial_refresh_skipped', {
|
|
414
|
+
reason: 'directPartialResultNotCached',
|
|
415
|
+
pluginElapsedMs,
|
|
416
|
+
cachePreserved: false,
|
|
417
|
+
returnedProductCount: productCount(pluginResults),
|
|
418
|
+
});
|
|
419
|
+
} else if (pluginResults && pluginResults.products && pluginResults.products.length === 0) {
|
|
420
|
+
// Short-lived empty cache gives concurrent waiters the same answer as the lock holder.
|
|
421
|
+
await app.cache.save({ key: cacheKey, value: pluginResults, ttl: emptyProductSearchCacheTtlSeconds });
|
|
422
|
+
emitDecision('empty_cache_saved', {
|
|
423
|
+
reason,
|
|
424
|
+
pluginElapsedMs,
|
|
425
|
+
cacheProductCount: 0,
|
|
188
426
|
});
|
|
189
427
|
}
|
|
190
|
-
// If pluginResults are empty, cache is NOT updated here by fetchFromPluginAndCache.
|
|
191
|
-
// This maintains existing behavior for forceRefresh/initial load paths.
|
|
192
|
-
// $updateProductSearchCache has its own logic for handling empty results from background refresh.
|
|
193
428
|
} finally {
|
|
194
|
-
await
|
|
429
|
+
await lockRenewal.stop();
|
|
430
|
+
if (app.cache.dropIfValue) {
|
|
431
|
+
await app.cache.dropIfValue({ key: pluginExecutionLockKey, value: lockOwnerToken });
|
|
432
|
+
} else {
|
|
433
|
+
await app.cache.drop({ key: pluginExecutionLockKey });
|
|
434
|
+
}
|
|
195
435
|
}
|
|
196
436
|
return pluginResults || { products: [] }; // Ensure products array exists
|
|
197
437
|
};
|
|
198
438
|
|
|
199
439
|
// 1. `doNotCallPluginForProducts` is true, and not `forceRefresh`: Serve from cache or empty.
|
|
200
440
|
if (doNotCallPluginForProducts && !forceRefresh) {
|
|
201
|
-
if (initialActualCacheContent
|
|
441
|
+
if (hasProductCache(initialActualCacheContent)) {
|
|
202
442
|
const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
|
|
443
|
+
emitDecision('cache_hit', {
|
|
444
|
+
reason: 'doNotCallPluginForProducts',
|
|
445
|
+
cacheProductCount: initialActualCacheContent.products.length,
|
|
446
|
+
returnedProductCount: searchResults.length,
|
|
447
|
+
});
|
|
203
448
|
return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
|
|
204
449
|
}
|
|
450
|
+
emitDecision('cache_miss', { reason: 'doNotCallPluginForProducts' });
|
|
205
451
|
return { products: [], ...(token.configuration || {}) };
|
|
206
452
|
}
|
|
207
453
|
|
|
208
454
|
// 2. `forceRefresh` is true (and not case 1): Fetch from plugin.
|
|
209
455
|
// fetchFromPluginAndCache will handle caching the new results.
|
|
210
456
|
if (forceRefresh) {
|
|
211
|
-
|
|
457
|
+
emitDecision('force_refresh');
|
|
458
|
+
const funcResults = await fetchFromPluginAndCache('force_refresh');
|
|
212
459
|
const searchResults = $searchProductList(funcResults.products, searchInput, optionId);
|
|
213
460
|
return { ...funcResults, products: searchResults, ...(token.configuration || {}) };
|
|
214
461
|
}
|
|
215
462
|
|
|
216
463
|
// 3. Cache exists (initialActualCacheContent) and not forceRefresh:
|
|
217
|
-
if (initialActualCacheContent
|
|
464
|
+
if (hasProductCache(initialActualCacheContent)) {
|
|
218
465
|
const cacheIsEmpty = !initialActualCacheContent.products.length;
|
|
219
466
|
const trimmedSearch = (searchInput || '').trim();
|
|
220
467
|
const searchFilterIsEmpty = (!trimmedSearch || trimmedSearch === '*') && !(optionId && optionId.length);
|
|
@@ -222,8 +469,15 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
222
469
|
// If cache is empty and no search filter, skip to case 4 to fetch fresh data
|
|
223
470
|
const shouldSkipEmptyCache = cacheIsEmpty && searchFilterIsEmpty;
|
|
224
471
|
if (!shouldSkipEmptyCache) {
|
|
225
|
-
const returnCachedResults = () => {
|
|
472
|
+
const returnCachedResults = (action = 'cache_hit', extra = {}) => {
|
|
226
473
|
const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
|
|
474
|
+
emitDecision(action, {
|
|
475
|
+
cacheProductCount: initialActualCacheContent.products.length,
|
|
476
|
+
returnedProductCount: searchResults.length,
|
|
477
|
+
cacheAgeMs: lastUpdated ? Date.now() - lastUpdated : undefined,
|
|
478
|
+
ttrMs: ttr * 1000,
|
|
479
|
+
...extra,
|
|
480
|
+
});
|
|
227
481
|
return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
|
|
228
482
|
};
|
|
229
483
|
|
|
@@ -231,18 +485,20 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
231
485
|
|
|
232
486
|
// Cache is fresh or plugin execution in progress: serve from cache
|
|
233
487
|
if (!isEffectivelyStale || hasPluginExecutionLock) {
|
|
234
|
-
return returnCachedResults();
|
|
488
|
+
return returnCachedResults('cache_hit', { reason: hasPluginExecutionLock ? 'lockActive' : 'freshCache' });
|
|
235
489
|
}
|
|
236
490
|
|
|
237
|
-
// Cache is stale -
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
491
|
+
// Cache is stale - queue at most one background refresh job and serve stale data.
|
|
492
|
+
const jobQueueLockAcquired = await app.cache.saveIfNotExists({
|
|
493
|
+
key: jobQueueLockKey,
|
|
494
|
+
value: true,
|
|
495
|
+
ttl: 60,
|
|
496
|
+
});
|
|
497
|
+
if (!jobQueueLockAcquired) {
|
|
498
|
+
return returnCachedResults('stale_served', { reason: 'backgroundRefreshDeduped' });
|
|
241
499
|
}
|
|
242
500
|
|
|
243
|
-
|
|
244
|
-
await app.cache.save({ key: jobQueueLockKey, value: true, ttl: 60 });
|
|
245
|
-
await addJob({
|
|
501
|
+
const jobId = await addJob({
|
|
246
502
|
type: 'plugin',
|
|
247
503
|
pluginName: appKey,
|
|
248
504
|
method: app.searchProducts ? 'searchProducts' : 'searchProductsForItinerary',
|
|
@@ -255,13 +511,16 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
255
511
|
},
|
|
256
512
|
}, { removeOnComplete: true });
|
|
257
513
|
|
|
258
|
-
return returnCachedResults(
|
|
514
|
+
return returnCachedResults('stale_served', {
|
|
515
|
+
reason: 'backgroundRefreshQueued',
|
|
516
|
+
jobId,
|
|
517
|
+
});
|
|
259
518
|
}
|
|
260
519
|
}
|
|
261
520
|
|
|
262
521
|
// 4. No cache content (and not caught by previous conditions like forceRefresh or doNotCallPluginForProducts):
|
|
263
522
|
// Fetch from plugin. fetchFromPluginAndCache will handle caching.
|
|
264
|
-
const funcResults = await fetchFromPluginAndCache();
|
|
523
|
+
const funcResults = await fetchFromPluginAndCache('cache_miss');
|
|
265
524
|
const searchResults = $searchProductList(funcResults.products, searchInput, optionId);
|
|
266
525
|
return {
|
|
267
526
|
...funcResults,
|
|
@@ -550,28 +809,72 @@ const $updateProductSearchCache = plugins => async ({
|
|
|
550
809
|
return; // Or throw error
|
|
551
810
|
}
|
|
552
811
|
|
|
553
|
-
const cacheKey =
|
|
554
|
-
const
|
|
812
|
+
const cacheKey = productSearchCacheKey({ userId, hint });
|
|
813
|
+
const markRefreshAttempted = () => app.cache.save({
|
|
814
|
+
key: `${cacheKey}:lastUpdated`,
|
|
815
|
+
value: Date.now(),
|
|
816
|
+
ttl: productSearchCacheTtlSeconds,
|
|
817
|
+
});
|
|
818
|
+
const emitCacheEvent = (action, extra = {}) => emitProductSearchCacheDecision({
|
|
819
|
+
app,
|
|
820
|
+
cacheKey,
|
|
821
|
+
userId,
|
|
822
|
+
hint,
|
|
823
|
+
requestId,
|
|
824
|
+
searchInput: '',
|
|
825
|
+
optionId: '',
|
|
826
|
+
forceRefresh: false,
|
|
827
|
+
startedAt: Date.now(),
|
|
828
|
+
}, action, extra);
|
|
829
|
+
|
|
830
|
+
if (hasCacheableProductResults(pluginResult)) {
|
|
831
|
+
await markRefreshAttempted();
|
|
832
|
+
await app.cache.save({ key: cacheKey, value: pluginResult, ttl: productSearchCacheTtlSeconds });
|
|
833
|
+
emitCacheEvent('cache_saved', { cacheProductCount: productCount(pluginResult) });
|
|
834
|
+
} else {
|
|
835
|
+
const isPartialRefresh = pluginResult && (pluginResult.catalogPartial || pluginResult.partial);
|
|
836
|
+
const existingCacheContent = await app.cache.get({ key: cacheKey });
|
|
837
|
+
if (hasNonEmptyProductCache(existingCacheContent)) {
|
|
838
|
+
await markRefreshAttempted();
|
|
839
|
+
const eventExtra = {
|
|
840
|
+
reason: isPartialRefresh ? 'partialResultPreservedExistingCache' : 'emptyResultPreservedExistingCache',
|
|
841
|
+
cachePreserved: true,
|
|
842
|
+
existingProductCount: existingCacheContent.products.length,
|
|
843
|
+
};
|
|
844
|
+
if (isPartialRefresh) eventExtra.returnedProductCount = productCount(pluginResult);
|
|
845
|
+
emitCacheEvent(
|
|
846
|
+
isPartialRefresh ? 'partial_refresh_skipped' : 'empty_refresh_skipped',
|
|
847
|
+
eventExtra,
|
|
848
|
+
);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (isPartialRefresh) {
|
|
853
|
+
await markRefreshAttempted();
|
|
854
|
+
emitCacheEvent('partial_refresh_skipped', {
|
|
855
|
+
reason: 'partialResultNotCached',
|
|
856
|
+
cachePreserved: false,
|
|
857
|
+
returnedProductCount: productCount(pluginResult),
|
|
858
|
+
});
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
555
861
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
862
|
+
// Best-effort race guard: avoid overwriting a non-empty cache written after the first read.
|
|
863
|
+
const latestCacheContent = await app.cache.get({ key: cacheKey });
|
|
864
|
+
if (hasNonEmptyProductCache(latestCacheContent)) {
|
|
865
|
+
await markRefreshAttempted();
|
|
866
|
+
emitCacheEvent('empty_refresh_skipped', {
|
|
867
|
+
reason: 'emptyResultPreservedConcurrentCache',
|
|
868
|
+
cachePreserved: true,
|
|
869
|
+
existingProductCount: latestCacheContent.products.length,
|
|
870
|
+
});
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
559
873
|
|
|
560
|
-
|
|
561
|
-
await
|
|
562
|
-
app.
|
|
563
|
-
|
|
564
|
-
});
|
|
565
|
-
console.log(`[$updateProductSearchCache][requestId: ${requestId}] Saved products to cache for ${appKey}, user ${userId}, hint ${hint}.`);
|
|
566
|
-
} else {
|
|
567
|
-
// If plugin returned no products, update the cache to reflect this.
|
|
568
|
-
// This prevents serving stale data indefinitely if the source truly has no products anymore.
|
|
569
|
-
await app.cache.save({ key: cacheKey, value: { products: [] }, ttl: monthInSeconds });
|
|
570
|
-
app.events.emit('bookingsProductSearch:cache:emptyRefresh', {
|
|
571
|
-
cacheKey, userId, hint, operationId: 'bookingsProductSearch', requestId, pluginName: app.name,
|
|
572
|
-
pluginResult, // Log what the plugin returned
|
|
573
|
-
});
|
|
574
|
-
console.log(`[$updateProductSearchCache][requestId: ${requestId}] Plugin returned empty/no products. Cached empty for ${appKey}, user ${userId}, hint ${hint}.`);
|
|
874
|
+
// Empty complete refreshes are authoritative only when there is no existing non-empty cache.
|
|
875
|
+
await markRefreshAttempted();
|
|
876
|
+
await app.cache.save({ key: cacheKey, value: { products: [] }, ttl: productSearchCacheTtlSeconds });
|
|
877
|
+
emitCacheEvent('empty_cache_saved', { cacheProductCount: 0 });
|
|
575
878
|
}
|
|
576
879
|
};
|
|
577
880
|
|
|
@@ -580,6 +883,7 @@ const controllerFactory = plugins => {
|
|
|
580
883
|
const controllerFunctions = {
|
|
581
884
|
bookingsSearch: bookingsSearch(plugins),
|
|
582
885
|
bookingsCancel: bookingsCancel(plugins),
|
|
886
|
+
confirmBooking: confirmBooking(plugins),
|
|
583
887
|
$bookingsProductSearch: $bookingsProductSearch(plugins),
|
|
584
888
|
bookingsProductSearch: bookingsProductSearch(plugins),
|
|
585
889
|
getProductPackages: getProductPackages(plugins),
|
package/index.js
CHANGED
|
@@ -90,6 +90,9 @@ module.exports = async ({
|
|
|
90
90
|
get: args => cache.get({ ...args, pluginName }),
|
|
91
91
|
getOrExec: args => cache.getOrExec({ ...args, pluginName }),
|
|
92
92
|
save: args => cache.save({ ...args, pluginName }),
|
|
93
|
+
saveIfNotExists: args => cache.saveIfNotExists({ ...args, pluginName }),
|
|
94
|
+
expireIfValue: args => cache.expireIfValue({ ...args, pluginName }),
|
|
95
|
+
dropIfValue: args => cache.dropIfValue({ ...args, pluginName }),
|
|
93
96
|
scan: args => cache.scan({ ...args, pluginName }),
|
|
94
97
|
},
|
|
95
98
|
axios: pluginAxios,
|
package/package.json
CHANGED
package/test/plugin.js
CHANGED
|
@@ -75,6 +75,21 @@ class Plugin {
|
|
|
75
75
|
});
|
|
76
76
|
this.searchQuote = jestPlugin.fn(() => ({ quote: [{ id: chance.guid() }] }));
|
|
77
77
|
this.createBooking = jestPlugin.fn(() => {});
|
|
78
|
+
this.cancelBooking = jestPlugin.fn(() => ({
|
|
79
|
+
cancelServicesReply: {},
|
|
80
|
+
cancellation: {
|
|
81
|
+
id: chance.guid(),
|
|
82
|
+
status: 'Cancelled',
|
|
83
|
+
},
|
|
84
|
+
}));
|
|
85
|
+
this.confirmBooking = jestPlugin.fn(() => ({
|
|
86
|
+
confirmBookingReply: {},
|
|
87
|
+
booking: {
|
|
88
|
+
id: chance.guid(),
|
|
89
|
+
status: 'OK',
|
|
90
|
+
serviceLines: [],
|
|
91
|
+
},
|
|
92
|
+
}));
|
|
78
93
|
this.getAffiliateAgents = jestPlugin.fn(() => ({ agents: [] }));
|
|
79
94
|
this.searchItineraries = jestPlugin.fn(() => ({ bookings: [] }));
|
|
80
95
|
this.searchProductsForItinerary = jestPlugin.fn(() => ({ products: [] }));
|
|
@@ -535,6 +550,32 @@ class Plugin {
|
|
|
535
550
|
*/
|
|
536
551
|
createBooking() {}
|
|
537
552
|
|
|
553
|
+
/**
|
|
554
|
+
* Cancel a booking
|
|
555
|
+
* @async
|
|
556
|
+
* @param {Object} args - Cancel booking arguments.
|
|
557
|
+
* @param {Object} args.token - A token definition, it's content varies between integrations.
|
|
558
|
+
* @param {Object} args.payload - Booking identifier payload.
|
|
559
|
+
* @returns {object} retVal - the return object.
|
|
560
|
+
*/
|
|
561
|
+
cancelBooking() {}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Confirm a quote as a booking
|
|
565
|
+
* @async
|
|
566
|
+
* @param {Object} args - Confirm booking arguments.
|
|
567
|
+
* @param {Object} args.token - A token definition, it's content varies between integrations.
|
|
568
|
+
* @param {Object} args.payload - Booking identifier payload.
|
|
569
|
+
* @param {string} [args.payload.bookingId] - Booking id to confirm.
|
|
570
|
+
* @param {string} [args.payload.id] - Booking id to confirm.
|
|
571
|
+
* @param {string} [args.payload.ref] - Booking reference to confirm.
|
|
572
|
+
* @param {string} [args.payload.reference] - Booking reference to confirm.
|
|
573
|
+
* @returns {object} retVal - the return object.
|
|
574
|
+
* @returns {Object} retVal.confirmBookingReply - Raw integration reply.
|
|
575
|
+
* @returns {Booking} retVal.booking - A normalized Booking object.
|
|
576
|
+
*/
|
|
577
|
+
confirmBooking() {}
|
|
578
|
+
|
|
538
579
|
/**
|
|
539
580
|
* @typedef {Object} Passenger
|
|
540
581
|
* @property {string} firstName - Passenger first name
|
|
@@ -39,6 +39,8 @@ We are using a set of standardized methods to maintain compatibility; the follow
|
|
|
39
39
|
* [searchAvailability]{@link Plugin#searchAvailability}
|
|
40
40
|
* [searchQuote]{@link Plugin#searchQuote}
|
|
41
41
|
* [createBooking]{@link Plugin#createBooking}
|
|
42
|
+
* [cancelBooking]{@link Plugin#cancelBooking}
|
|
43
|
+
* [confirmBooking]{@link Plugin#confirmBooking}
|
|
42
44
|
* [searchProductsForItinerary]{@link Plugin#searchProductsForItinerary}
|
|
43
45
|
* [searchAvailabilityForItinerary]{@link Plugin#searchAvailabilityForItinerary}
|
|
44
46
|
* [addServiceToItinerary]{@link Plugin#addServiceToItinerary}
|
package/.windsurfrules
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
This is a Node.js project using Sequelize for database operations and Express.js for the web server.
|
|
2
|
-
The API is defined in api.yml using OpenAPI 3.0.2
|
|
3
|
-
The database is defined in models/index.js
|
|
4
|
-
The models are defined in models/
|
|
5
|
-
to run tests you have to run them on the ti2 named docker container, and consider this codebase is part of node_modules/ti2 path for example :
|
|
6
|
-
`` $ docker exec ti2 bash -c "cd /ti2 && npx jest lib/__tests__/callback.js --forceExit"``
|
|
7
|
-
When writing / editing tests, make use of test/utils.js and review their implementation to understand how to mock the environment.
|
|
8
|
-
Do not make any changes until you 95% confident of the changes you want to make, ask follow up questions until you have that confidence.
|
|
9
|
-
when sending jobs to the background worker using the queue, asssume a worker is always runing to execute it but is on a different instance thread, so to review / wait for jobs we have to use the wokrer/queue.js listJobs fn to check for it
|