ti2 1.0.115 → 1.0.116
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/CLAUDE.md +8 -1
- package/__tests__/cache.js +24 -0
- package/cache.js +39 -30
- package/controllers/__tests__/bookings-searchProducts.js +315 -7
- package/controllers/bookings.js +140 -27
- package/index.js +1 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -28,6 +28,13 @@ Ti2 (Tourism Information Interchange) is an open-source integration framework fo
|
|
|
28
28
|
|
|
29
29
|
4. **Code Confidence**: Do not make changes until 95% confident. Ask follow-up questions if uncertain.
|
|
30
30
|
|
|
31
|
+
## Public Repo Privacy
|
|
32
|
+
|
|
33
|
+
- `ti2` is open source. Before opening or updating public PRs, review summaries, inline comments, release notes, or commit messages, scrub internal TourConnect service names and private implementation details.
|
|
34
|
+
- Do not mention internal services such as `pyfilematch`, `filematch`, `tcoutlook`, private frontend names, private trace URLs, private customer identifiers, or internal deployment architecture in public GitHub text.
|
|
35
|
+
- Use generic wording such as `upstream caller`, `internal catalog caller`, `private frontend`, `caller-side timeout`, or `internal workflow` when context is needed.
|
|
36
|
+
- After editing public GitHub text, search the PR body, issue comments, review comments, and review summaries for internal service names before handoff.
|
|
37
|
+
|
|
31
38
|
## Common Development Commands
|
|
32
39
|
|
|
33
40
|
```bash
|
|
@@ -156,4 +163,4 @@ export SSL_INSECURE_ALLOWED_DOMAINS="dev.example.com|test.example.com"
|
|
|
156
163
|
- SSL configuration uses a per-request interceptor approach for better isolation
|
|
157
164
|
- The configuration checks domains dynamically on each request
|
|
158
165
|
|
|
159
|
-
**Warning**: Only use this in development/testing environments. Never skip SSL verification in production for security reasons.
|
|
166
|
+
**Warning**: Only use this in development/testing environments. Never skip SSL verification in production for security reasons.
|
package/__tests__/cache.js
CHANGED
|
@@ -15,6 +15,30 @@ describe('cache', () => {
|
|
|
15
15
|
});
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
describe('saveIfNotExists', () => {
|
|
19
|
+
it('should only save the first value for a key', async () => {
|
|
20
|
+
const key = 'lock:test';
|
|
21
|
+
testKeys.push(key);
|
|
22
|
+
|
|
23
|
+
const firstSaved = await cache.saveIfNotExists({
|
|
24
|
+
pluginName: testPluginName,
|
|
25
|
+
key,
|
|
26
|
+
value: { owner: 'first' },
|
|
27
|
+
ttl: 60,
|
|
28
|
+
});
|
|
29
|
+
const secondSaved = await cache.saveIfNotExists({
|
|
30
|
+
pluginName: testPluginName,
|
|
31
|
+
key,
|
|
32
|
+
value: { owner: 'second' },
|
|
33
|
+
ttl: 60,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(firstSaved).toBe(true);
|
|
37
|
+
expect(secondSaved).toBe(false);
|
|
38
|
+
expect(await cache.get({ pluginName: testPluginName, key })).toEqual({ owner: 'first' });
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
18
42
|
describe('scan', () => {
|
|
19
43
|
it('should return keys matching the pattern without plugin prefix', async () => {
|
|
20
44
|
// Save some test keys
|
package/cache.js
CHANGED
|
@@ -5,6 +5,23 @@ const REDIS_URL = process.env.REDIS_URL || 'redis://redis:6379';
|
|
|
5
5
|
const cache = new Redis(`${REDIS_URL}/2`);
|
|
6
6
|
const defaultTTL = 60 * 60; // 1 hour
|
|
7
7
|
|
|
8
|
+
const normalizeKey = ({
|
|
9
|
+
key: keyParam,
|
|
10
|
+
fn,
|
|
11
|
+
fnParams,
|
|
12
|
+
useFunctionFallback = false,
|
|
13
|
+
}) => {
|
|
14
|
+
if (useFunctionFallback && !keyParam) {
|
|
15
|
+
return hash({ fn, fnParams });
|
|
16
|
+
}
|
|
17
|
+
if (typeof keyParam !== 'string') {
|
|
18
|
+
return hash(keyParam);
|
|
19
|
+
}
|
|
20
|
+
return keyParam;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const buildKey = ({ pluginName, ...args }) => `${pluginName}:${normalizeKey(args)}`;
|
|
24
|
+
|
|
8
25
|
const save = async ({
|
|
9
26
|
pluginName,
|
|
10
27
|
key: keyParam,
|
|
@@ -12,12 +29,7 @@ const save = async ({
|
|
|
12
29
|
skipTTL,
|
|
13
30
|
ttl = defaultTTL,
|
|
14
31
|
}) => {
|
|
15
|
-
const key =
|
|
16
|
-
if (typeof keyParam !== 'string') {
|
|
17
|
-
return hash(keyParam);
|
|
18
|
-
}
|
|
19
|
-
return keyParam;
|
|
20
|
-
})()}`;
|
|
32
|
+
const key = buildKey({ pluginName, key: keyParam });
|
|
21
33
|
// console.log('saving key', key);
|
|
22
34
|
await cache.set(key, JSON.stringify(value));
|
|
23
35
|
if (!skipTTL) {
|
|
@@ -25,16 +37,22 @@ const save = async ({
|
|
|
25
37
|
}
|
|
26
38
|
};
|
|
27
39
|
|
|
40
|
+
const saveIfNotExists = async ({
|
|
41
|
+
pluginName,
|
|
42
|
+
key: keyParam,
|
|
43
|
+
value,
|
|
44
|
+
ttl = defaultTTL,
|
|
45
|
+
}) => {
|
|
46
|
+
const key = buildKey({ pluginName, key: keyParam });
|
|
47
|
+
const result = await cache.set(key, JSON.stringify(value), 'EX', ttl, 'NX');
|
|
48
|
+
return result === 'OK';
|
|
49
|
+
};
|
|
50
|
+
|
|
28
51
|
const get = async ({
|
|
29
52
|
pluginName,
|
|
30
53
|
key: keyParam,
|
|
31
54
|
}) => {
|
|
32
|
-
const key =
|
|
33
|
-
if (typeof keyParam !== 'string') {
|
|
34
|
-
return hash(keyParam);
|
|
35
|
-
}
|
|
36
|
-
return keyParam;
|
|
37
|
-
})()}`;
|
|
55
|
+
const key = buildKey({ pluginName, key: keyParam });
|
|
38
56
|
const storeVal = await cache.get(key);
|
|
39
57
|
if (!storeVal) return storeVal;
|
|
40
58
|
return JSON.parse(storeVal);
|
|
@@ -48,15 +66,7 @@ const getOrExec = async ({
|
|
|
48
66
|
fnParams,
|
|
49
67
|
forceRefresh,
|
|
50
68
|
}) => {
|
|
51
|
-
const key = (
|
|
52
|
-
if (!keyParam) {
|
|
53
|
-
return hash({ fn, fnParams });
|
|
54
|
-
}
|
|
55
|
-
if (typeof keyParam !== 'string') {
|
|
56
|
-
return hash(keyParam);
|
|
57
|
-
}
|
|
58
|
-
return keyParam;
|
|
59
|
-
})();
|
|
69
|
+
const key = normalizeKey({ key: keyParam, fn, fnParams, useFunctionFallback: true });
|
|
60
70
|
let value;
|
|
61
71
|
if (!forceRefresh) {
|
|
62
72
|
value = await get({ pluginName, key });
|
|
@@ -78,15 +88,13 @@ const drop = async ({
|
|
|
78
88
|
fn,
|
|
79
89
|
fnParams,
|
|
80
90
|
}) => {
|
|
81
|
-
const key =
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return keyParam;
|
|
89
|
-
})()}`;
|
|
91
|
+
const key = buildKey({
|
|
92
|
+
pluginName,
|
|
93
|
+
key: keyParam,
|
|
94
|
+
fn,
|
|
95
|
+
fnParams,
|
|
96
|
+
useFunctionFallback: true,
|
|
97
|
+
});
|
|
90
98
|
// console.log('dropping key', key);
|
|
91
99
|
await cache.del(key);
|
|
92
100
|
};
|
|
@@ -116,6 +124,7 @@ const scan = ({
|
|
|
116
124
|
module.exports = {
|
|
117
125
|
cache,
|
|
118
126
|
save,
|
|
127
|
+
saveIfNotExists,
|
|
119
128
|
getOrExec,
|
|
120
129
|
drop,
|
|
121
130
|
get,
|
|
@@ -16,6 +16,7 @@ jest.mock('../../worker/queue', () => ({
|
|
|
16
16
|
const { addJob, jobStatus: mockJobStatus } = require('../../worker/queue'); // Now addJob is the mock
|
|
17
17
|
|
|
18
18
|
const testUtils = require('../../test/utils'); // Require the module itself
|
|
19
|
+
const bookingsControllerFactory = require('../bookings');
|
|
19
20
|
|
|
20
21
|
// Global setup for the entire test file
|
|
21
22
|
let globalDoApiPost, globalPlugins, globalUtils, globalSqldb;
|
|
@@ -314,8 +315,8 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
314
315
|
|
|
315
316
|
beforeEach(async () => {
|
|
316
317
|
// Clear mocks
|
|
317
|
-
if (travelgatePlugin && travelgatePlugin.searchProducts && travelgatePlugin.searchProducts.
|
|
318
|
-
travelgatePlugin.searchProducts.
|
|
318
|
+
if (travelgatePlugin && travelgatePlugin.searchProducts && travelgatePlugin.searchProducts.mockReset) {
|
|
319
|
+
travelgatePlugin.searchProducts.mockReset();
|
|
319
320
|
}
|
|
320
321
|
if (addJob && addJob.mockClear) {
|
|
321
322
|
addJob.mockClear();
|
|
@@ -341,13 +342,18 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
341
342
|
token: staleCacheTokenConfig,
|
|
342
343
|
});
|
|
343
344
|
// Clear mocks that might have been called during appSetup
|
|
344
|
-
if (travelgatePlugin.searchProducts.
|
|
345
|
+
if (travelgatePlugin.searchProducts.mockReset) travelgatePlugin.searchProducts.mockReset();
|
|
345
346
|
if (addJob.mockClear) addJob.mockClear();
|
|
346
347
|
});
|
|
347
348
|
|
|
348
|
-
it('should return stale
|
|
349
|
+
it('should return stale products and preserve the cache when refresh yields empty products', async () => {
|
|
349
350
|
const initialProductsInCache = [{ productId: 'staleProd1', name: 'Stale Product One', optionId: 'optStale1' }];
|
|
350
351
|
const productsFromPluginRefresh = []; // Simulate plugin returning empty on refresh
|
|
352
|
+
const cacheKeyForTest = hash({
|
|
353
|
+
userId: testUserId,
|
|
354
|
+
hint: staleCacheTestHint,
|
|
355
|
+
operationId: 'bookingsProductSearch',
|
|
356
|
+
});
|
|
351
357
|
|
|
352
358
|
// 1. First call: Populate the cache with initialProductsInCache
|
|
353
359
|
travelgatePlugin.searchProducts.mockResolvedValueOnce({ products: initialProductsInCache });
|
|
@@ -387,8 +393,195 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
387
393
|
expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
|
|
388
394
|
|
|
389
395
|
// Note: This test verifies the immediate response serves stale data and a job is queued.
|
|
390
|
-
//
|
|
391
|
-
|
|
396
|
+
// Cache preservation for empty background refreshes is covered below.
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('should keep existing non-empty cache when background refresh returns empty products', async () => {
|
|
400
|
+
const initialProductsInCache = [{ productId: 'staleProd1', name: 'Stale Product One', optionId: 'optStale1' }];
|
|
401
|
+
const cacheKeyForTest = hash({
|
|
402
|
+
userId: testUserId,
|
|
403
|
+
hint: staleCacheTestHint,
|
|
404
|
+
operationId: 'bookingsProductSearch',
|
|
405
|
+
});
|
|
406
|
+
await cache.save({
|
|
407
|
+
pluginName: testAppName,
|
|
408
|
+
key: cacheKeyForTest,
|
|
409
|
+
value: { products: initialProductsInCache },
|
|
410
|
+
ttl: 60,
|
|
411
|
+
});
|
|
412
|
+
await cache.save({
|
|
413
|
+
pluginName: testAppName,
|
|
414
|
+
key: `${cacheKeyForTest}:lastUpdated`,
|
|
415
|
+
value: 1,
|
|
416
|
+
ttl: 60,
|
|
417
|
+
});
|
|
418
|
+
const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
|
|
419
|
+
|
|
420
|
+
await bookingsControllerFactory(globalPlugins).$updateProductSearchCache({
|
|
421
|
+
appKey: testAppName,
|
|
422
|
+
userId: testUserId,
|
|
423
|
+
hint: staleCacheTestHint,
|
|
424
|
+
pluginResult: { products: [] },
|
|
425
|
+
requestId: 'empty-refresh-test',
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
|
|
429
|
+
const lastUpdated = await cache.get({ pluginName: testAppName, key: `${cacheKeyForTest}:lastUpdated` });
|
|
430
|
+
expect(cached.products).toEqual(initialProductsInCache);
|
|
431
|
+
expect(lastUpdated).toBeGreaterThan(1);
|
|
432
|
+
expect(emitSpy).toHaveBeenCalledWith(
|
|
433
|
+
'bookingsProductSearch:cache:emptyRefreshSkipped',
|
|
434
|
+
expect.objectContaining({
|
|
435
|
+
reason: 'emptyResultPreservedExistingCache',
|
|
436
|
+
cachePreserved: true,
|
|
437
|
+
existingProductCount: initialProductsInCache.length,
|
|
438
|
+
}),
|
|
439
|
+
);
|
|
440
|
+
emitSpy.mockRestore();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('should not cache partial product refreshes as complete results', async () => {
|
|
444
|
+
const cacheKeyForTest = hash({
|
|
445
|
+
userId: testUserId,
|
|
446
|
+
hint: staleCacheTestHint,
|
|
447
|
+
operationId: 'bookingsProductSearch',
|
|
448
|
+
});
|
|
449
|
+
await cache.drop({ pluginName: testAppName, key: cacheKeyForTest });
|
|
450
|
+
|
|
451
|
+
await bookingsControllerFactory(globalPlugins).$updateProductSearchCache({
|
|
452
|
+
appKey: testAppName,
|
|
453
|
+
userId: testUserId,
|
|
454
|
+
hint: staleCacheTestHint,
|
|
455
|
+
pluginResult: {
|
|
456
|
+
catalogPartial: true,
|
|
457
|
+
products: [{ productId: 'partialProd1' }],
|
|
458
|
+
},
|
|
459
|
+
requestId: 'partial-refresh-test',
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
|
|
463
|
+
const lastUpdated = await cache.get({ pluginName: testAppName, key: `${cacheKeyForTest}:lastUpdated` });
|
|
464
|
+
expect(cached).toBeFalsy();
|
|
465
|
+
expect(lastUpdated).toBeTruthy();
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('should not update cache metadata when direct force refresh returns partial products', async () => {
|
|
469
|
+
const pluginResult = {
|
|
470
|
+
catalogPartial: true,
|
|
471
|
+
products: [{ productId: 'partialProd1' }],
|
|
472
|
+
};
|
|
473
|
+
const cacheKeyForTest = hash({
|
|
474
|
+
userId: testUserId,
|
|
475
|
+
hint: staleCacheTestHint,
|
|
476
|
+
operationId: 'bookingsProductSearch',
|
|
477
|
+
});
|
|
478
|
+
travelgatePlugin.searchProducts.mockResolvedValueOnce(pluginResult);
|
|
479
|
+
|
|
480
|
+
await doApiPost({
|
|
481
|
+
url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
|
|
482
|
+
token: userToken,
|
|
483
|
+
payload: { forceRefresh: true },
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
|
|
487
|
+
const lastUpdated = await cache.get({ pluginName: testAppName, key: `${cacheKeyForTest}:lastUpdated` });
|
|
488
|
+
expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
489
|
+
expect(cached).toBeFalsy();
|
|
490
|
+
expect(lastUpdated).toBeFalsy();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('should keep existing non-empty cache when background refresh returns partial products', async () => {
|
|
494
|
+
const initialProductsInCache = [{ productId: 'staleProd1', name: 'Stale Product One', optionId: 'optStale1' }];
|
|
495
|
+
const pluginResult = {
|
|
496
|
+
catalogPartial: true,
|
|
497
|
+
products: [{ productId: 'partialProd1' }],
|
|
498
|
+
};
|
|
499
|
+
const cacheKeyForTest = hash({
|
|
500
|
+
userId: testUserId,
|
|
501
|
+
hint: staleCacheTestHint,
|
|
502
|
+
operationId: 'bookingsProductSearch',
|
|
503
|
+
});
|
|
504
|
+
await cache.save({
|
|
505
|
+
pluginName: testAppName,
|
|
506
|
+
key: cacheKeyForTest,
|
|
507
|
+
value: { products: initialProductsInCache },
|
|
508
|
+
ttl: 60,
|
|
509
|
+
});
|
|
510
|
+
await cache.save({
|
|
511
|
+
pluginName: testAppName,
|
|
512
|
+
key: `${cacheKeyForTest}:lastUpdated`,
|
|
513
|
+
value: 1,
|
|
514
|
+
ttl: 60,
|
|
515
|
+
});
|
|
516
|
+
const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
|
|
517
|
+
|
|
518
|
+
await bookingsControllerFactory(globalPlugins).$updateProductSearchCache({
|
|
519
|
+
appKey: testAppName,
|
|
520
|
+
userId: testUserId,
|
|
521
|
+
hint: staleCacheTestHint,
|
|
522
|
+
pluginResult,
|
|
523
|
+
requestId: 'partial-refresh-test',
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
|
|
527
|
+
const lastUpdated = await cache.get({ pluginName: testAppName, key: `${cacheKeyForTest}:lastUpdated` });
|
|
528
|
+
expect(cached.products).toEqual(initialProductsInCache);
|
|
529
|
+
expect(lastUpdated).toBeGreaterThan(1);
|
|
530
|
+
expect(emitSpy).toHaveBeenCalledWith(
|
|
531
|
+
'bookingsProductSearch:cache:partialRefreshSkipped',
|
|
532
|
+
expect.objectContaining({
|
|
533
|
+
reason: 'partialResultPreservedExistingCache',
|
|
534
|
+
cachePreserved: true,
|
|
535
|
+
existingProductCount: initialProductsInCache.length,
|
|
536
|
+
pluginResult,
|
|
537
|
+
}),
|
|
538
|
+
);
|
|
539
|
+
emitSpy.mockRestore();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('should not cache empty products if non-empty cache appears before the empty save', async () => {
|
|
543
|
+
const cacheKeyForTest = hash({
|
|
544
|
+
userId: 'race-user',
|
|
545
|
+
hint: 'race-hint',
|
|
546
|
+
operationId: 'bookingsProductSearch',
|
|
547
|
+
});
|
|
548
|
+
const pluginResult = { products: [] };
|
|
549
|
+
const fakePlugin = {
|
|
550
|
+
name: 'racePlugin',
|
|
551
|
+
cache: {
|
|
552
|
+
get: jest.fn()
|
|
553
|
+
.mockResolvedValueOnce(null)
|
|
554
|
+
.mockResolvedValueOnce({ products: [{ productId: 'raceProd1' }] }),
|
|
555
|
+
save: jest.fn(async () => {}),
|
|
556
|
+
},
|
|
557
|
+
events: {
|
|
558
|
+
emit: jest.fn(),
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
await bookingsControllerFactory([fakePlugin]).$updateProductSearchCache({
|
|
563
|
+
appKey: fakePlugin.name,
|
|
564
|
+
userId: 'race-user',
|
|
565
|
+
hint: 'race-hint',
|
|
566
|
+
pluginResult,
|
|
567
|
+
requestId: 'race-refresh-test',
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
expect(fakePlugin.cache.save).toHaveBeenCalledWith(expect.objectContaining({
|
|
571
|
+
key: `${cacheKeyForTest}:lastUpdated`,
|
|
572
|
+
}));
|
|
573
|
+
expect(fakePlugin.cache.save).not.toHaveBeenCalledWith(expect.objectContaining({
|
|
574
|
+
key: cacheKeyForTest,
|
|
575
|
+
value: { products: [] },
|
|
576
|
+
}));
|
|
577
|
+
expect(fakePlugin.events.emit).toHaveBeenCalledWith(
|
|
578
|
+
'bookingsProductSearch:cache:emptyRefreshSkipped',
|
|
579
|
+
expect.objectContaining({
|
|
580
|
+
reason: 'emptyResultPreservedConcurrentCache',
|
|
581
|
+
cachePreserved: true,
|
|
582
|
+
existingProductCount: 1,
|
|
583
|
+
}),
|
|
584
|
+
);
|
|
392
585
|
});
|
|
393
586
|
});
|
|
394
587
|
});
|
|
@@ -445,6 +638,24 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
|
|
|
445
638
|
await cache.drop({ pluginName: testAppName, key: `${cacheKey}:jobLock` }); // jobQueueLock
|
|
446
639
|
});
|
|
447
640
|
|
|
641
|
+
const clearProductSearchCache = async () => {
|
|
642
|
+
const cacheKey = hash({
|
|
643
|
+
userId: testUserId,
|
|
644
|
+
hint: ttrTestHint,
|
|
645
|
+
operationId: 'bookingsProductSearch',
|
|
646
|
+
});
|
|
647
|
+
await cache.drop({ pluginName: testAppName, key: cacheKey });
|
|
648
|
+
await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lastUpdated` });
|
|
649
|
+
await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lock` });
|
|
650
|
+
await cache.drop({ pluginName: testAppName, key: `${cacheKey}:jobLock` });
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
const makeProductSearchRequest = () => doApiPost({
|
|
654
|
+
url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
|
|
655
|
+
token: userToken,
|
|
656
|
+
payload: {},
|
|
657
|
+
});
|
|
658
|
+
|
|
448
659
|
beforeEach(async () => {
|
|
449
660
|
// Clear mocks before each test in this suite
|
|
450
661
|
addJob.mockClear(); // Reset addJob mock calls
|
|
@@ -529,5 +740,102 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
|
|
|
529
740
|
);
|
|
530
741
|
}
|
|
531
742
|
});
|
|
532
|
-
});
|
|
533
743
|
|
|
744
|
+
it('multiple concurrent requests with no cache should wait for one plugin fetch', async () => {
|
|
745
|
+
await clearProductSearchCache();
|
|
746
|
+
|
|
747
|
+
const coldProducts = [{
|
|
748
|
+
productId: 'cold-product',
|
|
749
|
+
productName: 'Cold Product',
|
|
750
|
+
options: [{
|
|
751
|
+
optionId: 'cold-option',
|
|
752
|
+
optionName: 'Cold Option',
|
|
753
|
+
}],
|
|
754
|
+
}];
|
|
755
|
+
lockTestPlugin.searchProducts.mockReset();
|
|
756
|
+
const releasePluginFetches = [];
|
|
757
|
+
const pluginFetchStarted = new Promise(resolve => {
|
|
758
|
+
lockTestPlugin.searchProducts.mockImplementation(() => {
|
|
759
|
+
resolve();
|
|
760
|
+
return new Promise(pluginResolve => {
|
|
761
|
+
releasePluginFetches.push(() => pluginResolve({ products: coldProducts }));
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
const requestPromises = [makeProductSearchRequest(), makeProductSearchRequest(), makeProductSearchRequest()];
|
|
767
|
+
await pluginFetchStarted;
|
|
768
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
769
|
+
|
|
770
|
+
releasePluginFetches.forEach(releasePluginFetch => releasePluginFetch());
|
|
771
|
+
const results = await Promise.all(requestPromises);
|
|
772
|
+
|
|
773
|
+
results.forEach(result => {
|
|
774
|
+
expect(result.products).toEqual(coldProducts);
|
|
775
|
+
});
|
|
776
|
+
expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
777
|
+
expect(addJob).not.toHaveBeenCalled();
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it('multiple concurrent requests with no cache should share an empty plugin result', async () => {
|
|
781
|
+
await clearProductSearchCache();
|
|
782
|
+
|
|
783
|
+
lockTestPlugin.searchProducts.mockReset();
|
|
784
|
+
const releasePluginFetches = [];
|
|
785
|
+
const pluginFetchStarted = new Promise(resolve => {
|
|
786
|
+
lockTestPlugin.searchProducts.mockImplementation(() => {
|
|
787
|
+
resolve();
|
|
788
|
+
return new Promise(pluginResolve => {
|
|
789
|
+
releasePluginFetches.push(() => pluginResolve({ products: [] }));
|
|
790
|
+
});
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
const requestPromises = [makeProductSearchRequest(), makeProductSearchRequest(), makeProductSearchRequest()];
|
|
795
|
+
await pluginFetchStarted;
|
|
796
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
797
|
+
|
|
798
|
+
releasePluginFetches.forEach(releasePluginFetch => releasePluginFetch());
|
|
799
|
+
const results = await Promise.all(requestPromises);
|
|
800
|
+
|
|
801
|
+
results.forEach(result => {
|
|
802
|
+
expect(result.products).toEqual([]);
|
|
803
|
+
});
|
|
804
|
+
expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
805
|
+
expect(addJob).not.toHaveBeenCalled();
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it('multiple concurrent requests with no cache should fail when the leader throws', async () => {
|
|
809
|
+
await clearProductSearchCache();
|
|
810
|
+
|
|
811
|
+
lockTestPlugin.searchProducts.mockReset();
|
|
812
|
+
let rejectPluginFetch;
|
|
813
|
+
const pluginFetchStarted = new Promise(resolve => {
|
|
814
|
+
lockTestPlugin.searchProducts.mockImplementation(() => {
|
|
815
|
+
resolve();
|
|
816
|
+
return new Promise((_, reject) => {
|
|
817
|
+
rejectPluginFetch = () => reject(new Error('boom'));
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
const requestPromises = [makeProductSearchRequest(), makeProductSearchRequest(), makeProductSearchRequest()]
|
|
823
|
+
.map(requestPromise => requestPromise
|
|
824
|
+
.then(result => ({ status: 'fulfilled', result }))
|
|
825
|
+
.catch(error => ({ status: 'rejected', error })));
|
|
826
|
+
await pluginFetchStarted;
|
|
827
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
828
|
+
|
|
829
|
+
rejectPluginFetch();
|
|
830
|
+
const results = await Promise.all(requestPromises);
|
|
831
|
+
|
|
832
|
+
results.forEach(result => {
|
|
833
|
+
expect(result.status).toBe('rejected');
|
|
834
|
+
});
|
|
835
|
+
expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
836
|
+
expect(addJob).not.toHaveBeenCalled();
|
|
837
|
+
|
|
838
|
+
const statuses = results.map(result => result.error.response.status).sort();
|
|
839
|
+
expect(statuses).toEqual([500, 503, 503]);
|
|
840
|
+
});
|
|
841
|
+
});
|
package/controllers/bookings.js
CHANGED
|
@@ -28,6 +28,24 @@ const typeDefsAndQueries = {
|
|
|
28
28
|
itineraryBookingQuery,
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
+
const getPositiveIntegerEnv = (name, defaultValue) => {
|
|
32
|
+
const value = Number(process.env[name]);
|
|
33
|
+
const integerValue = Math.floor(value);
|
|
34
|
+
return Number.isFinite(value) && integerValue > 0 ? integerValue : defaultValue;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const productSearchLockTtlSeconds = getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_TTL_SECONDS', 120);
|
|
38
|
+
const productSearchLockWaitMs = getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_WAIT_MS', 25 * 1000);
|
|
39
|
+
const productSearchLockPollMs = getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_POLL_MS', 250);
|
|
40
|
+
const emptyProductSearchCacheTtlSeconds = 60;
|
|
41
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
42
|
+
|
|
43
|
+
const createProductSearchUnavailableError = () => {
|
|
44
|
+
const err = new Error('Product search cache refresh did not produce cached results');
|
|
45
|
+
err.status = 503;
|
|
46
|
+
return err;
|
|
47
|
+
};
|
|
48
|
+
|
|
31
49
|
const getAppAndToken = async ({ plugins, appKey, userId, hint }) => {
|
|
32
50
|
const app = plugins.find(({ name }) => name === appKey);
|
|
33
51
|
assert(app, 'could not find the app ' + appKey);
|
|
@@ -123,6 +141,22 @@ const $searchProductList = (products, searchInput = '', optionId = '') => {
|
|
|
123
141
|
return filteredProducts;
|
|
124
142
|
};
|
|
125
143
|
|
|
144
|
+
// Plugins may flag incomplete catalog responses with `catalogPartial` or `partial`;
|
|
145
|
+
// both must suppress cache writes so a partial result does not replace a complete cache.
|
|
146
|
+
const hasCacheableProductResults = pluginResults => Boolean(
|
|
147
|
+
pluginResults
|
|
148
|
+
&& pluginResults.products
|
|
149
|
+
&& pluginResults.products.length > 0
|
|
150
|
+
&& !pluginResults.catalogPartial
|
|
151
|
+
&& !pluginResults.partial
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const hasNonEmptyProductCache = cacheContent => Boolean(
|
|
155
|
+
cacheContent
|
|
156
|
+
&& cacheContent.products
|
|
157
|
+
&& cacheContent.products.length > 0
|
|
158
|
+
);
|
|
159
|
+
|
|
126
160
|
const $bookingsProductSearch = plugins => async ({
|
|
127
161
|
axios,
|
|
128
162
|
appKey,
|
|
@@ -160,10 +194,44 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
160
194
|
const isStaleByTTR = lastUpdated && (Date.now() - lastUpdated > ttr * 1000);
|
|
161
195
|
const doNotCallPluginForProducts = token.doNotCallPluginForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'doNotCall'], app);
|
|
162
196
|
const hasPluginExecutionLock = await app.cache.get({ key: pluginExecutionLockKey });
|
|
197
|
+
assert(app.cache.saveIfNotExists, 'cache adapter must expose saveIfNotExists');
|
|
198
|
+
|
|
199
|
+
const getCachedProductSearchResults = async () => {
|
|
200
|
+
const cacheContent = await app.cache.get({ key: cacheKey });
|
|
201
|
+
if (cacheContent && cacheContent.products) return cacheContent;
|
|
202
|
+
return null;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const waitForProductSearchCache = async () => {
|
|
206
|
+
const timeoutAt = Date.now() + productSearchLockWaitMs;
|
|
207
|
+
while (Date.now() < timeoutAt) {
|
|
208
|
+
const cacheContent = await getCachedProductSearchResults();
|
|
209
|
+
if (cacheContent) return cacheContent;
|
|
210
|
+
|
|
211
|
+
const lockStillActive = await app.cache.get({ key: pluginExecutionLockKey });
|
|
212
|
+
if (!lockStillActive) return null;
|
|
213
|
+
|
|
214
|
+
await sleep(productSearchLockPollMs);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return getCachedProductSearchResults();
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const acquirePluginExecutionLock = async () => app.cache.saveIfNotExists({
|
|
221
|
+
key: pluginExecutionLockKey,
|
|
222
|
+
value: true,
|
|
223
|
+
ttl: productSearchLockTtlSeconds,
|
|
224
|
+
});
|
|
163
225
|
|
|
164
226
|
// Helper function to call the plugin, save cache, and return results
|
|
165
227
|
const fetchFromPluginAndCache = async () => {
|
|
166
|
-
|
|
228
|
+
const lockAcquired = await acquirePluginExecutionLock();
|
|
229
|
+
if (!lockAcquired) {
|
|
230
|
+
const cacheContent = await waitForProductSearchCache();
|
|
231
|
+
if (cacheContent) return cacheContent;
|
|
232
|
+
throw createProductSearchUnavailableError();
|
|
233
|
+
}
|
|
234
|
+
|
|
167
235
|
let pluginResults;
|
|
168
236
|
try {
|
|
169
237
|
pluginResults = await func({
|
|
@@ -176,20 +244,25 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
176
244
|
hint,
|
|
177
245
|
});
|
|
178
246
|
|
|
179
|
-
// This function is responsible for caching if it fetched
|
|
247
|
+
// This function is responsible for caching if it fetched usable results.
|
|
180
248
|
// This applies to forceRefresh, initial load, or direct calls that result in a fetch.
|
|
181
249
|
// The $updateProductSearchCache function handles caching for background jobs queued due to stale data.
|
|
182
|
-
if (pluginResults
|
|
250
|
+
if (hasCacheableProductResults(pluginResults)) {
|
|
183
251
|
const monthInSeconds = 30 * 24 * 60 * 60;
|
|
184
252
|
await app.cache.save({ key: `${cacheKey}:lastUpdated`, value: Date.now(), ttl: monthInSeconds });
|
|
185
253
|
await app.cache.save({ key: cacheKey, value: pluginResults, ttl: monthInSeconds });
|
|
186
254
|
app.events.emit('bookingsProductSearch:cache:save', {
|
|
187
255
|
cacheKey, userId, hint, operationId: 'bookingsProductSearch', requestId, pluginName: app.name,
|
|
188
256
|
});
|
|
257
|
+
} else if (pluginResults && (pluginResults.catalogPartial || pluginResults.partial)) {
|
|
258
|
+
app.events.emit('bookingsProductSearch:cache:partialRefreshSkipped', {
|
|
259
|
+
cacheKey, userId, hint, operationId: 'bookingsProductSearch', requestId, pluginName: app.name,
|
|
260
|
+
reason: 'directPartialResultNotCached', pluginResult: pluginResults,
|
|
261
|
+
});
|
|
262
|
+
} else if (pluginResults && pluginResults.products && pluginResults.products.length === 0) {
|
|
263
|
+
// Short-lived empty cache gives concurrent waiters the same answer as the lock holder.
|
|
264
|
+
await app.cache.save({ key: cacheKey, value: pluginResults, ttl: emptyProductSearchCacheTtlSeconds });
|
|
189
265
|
}
|
|
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
266
|
} finally {
|
|
194
267
|
await app.cache.drop({ key: pluginExecutionLockKey });
|
|
195
268
|
}
|
|
@@ -234,14 +307,16 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
234
307
|
return returnCachedResults();
|
|
235
308
|
}
|
|
236
309
|
|
|
237
|
-
// Cache is stale -
|
|
238
|
-
const
|
|
239
|
-
|
|
310
|
+
// Cache is stale - queue at most one background refresh job and serve stale data.
|
|
311
|
+
const jobQueueLockAcquired = await app.cache.saveIfNotExists({
|
|
312
|
+
key: jobQueueLockKey,
|
|
313
|
+
value: true,
|
|
314
|
+
ttl: 60,
|
|
315
|
+
});
|
|
316
|
+
if (!jobQueueLockAcquired) {
|
|
240
317
|
return returnCachedResults();
|
|
241
318
|
}
|
|
242
319
|
|
|
243
|
-
// Queue background refresh job and serve stale data
|
|
244
|
-
await app.cache.save({ key: jobQueueLockKey, value: true, ttl: 60 });
|
|
245
320
|
await addJob({
|
|
246
321
|
type: 'plugin',
|
|
247
322
|
pluginName: appKey,
|
|
@@ -552,26 +627,64 @@ const $updateProductSearchCache = plugins => async ({
|
|
|
552
627
|
|
|
553
628
|
const cacheKey = hash({ userId, hint, operationId: 'bookingsProductSearch' });
|
|
554
629
|
const monthInSeconds = 30 * 24 * 60 * 60;
|
|
630
|
+
const markRefreshAttempted = () => app.cache.save({
|
|
631
|
+
key: `${cacheKey}:lastUpdated`,
|
|
632
|
+
value: Date.now(),
|
|
633
|
+
ttl: monthInSeconds,
|
|
634
|
+
});
|
|
635
|
+
const emitCacheEvent = (eventName, extra = {}) => app.events.emit(eventName, {
|
|
636
|
+
cacheKey, userId, hint, operationId: 'bookingsProductSearch', requestId, pluginName: app.name,
|
|
637
|
+
...extra,
|
|
638
|
+
});
|
|
555
639
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
await app.cache.save({ key: `${cacheKey}:lastUpdated`, value: Date.now(), ttl: monthInSeconds });
|
|
559
|
-
|
|
560
|
-
if (pluginResult && pluginResult.products && pluginResult.products.length > 0) {
|
|
640
|
+
if (hasCacheableProductResults(pluginResult)) {
|
|
641
|
+
await markRefreshAttempted();
|
|
561
642
|
await app.cache.save({ key: cacheKey, value: pluginResult, ttl: monthInSeconds });
|
|
562
|
-
|
|
563
|
-
cacheKey, userId, hint, operationId: 'bookingsProductSearch', requestId, pluginName: app.name,
|
|
564
|
-
});
|
|
565
|
-
console.log(`[$updateProductSearchCache][requestId: ${requestId}] Saved products to cache for ${appKey}, user ${userId}, hint ${hint}.`);
|
|
643
|
+
emitCacheEvent('bookingsProductSearch:cache:save');
|
|
566
644
|
} else {
|
|
567
|
-
|
|
568
|
-
|
|
645
|
+
const isPartialRefresh = pluginResult && (pluginResult.catalogPartial || pluginResult.partial);
|
|
646
|
+
const existingCacheContent = await app.cache.get({ key: cacheKey });
|
|
647
|
+
if (hasNonEmptyProductCache(existingCacheContent)) {
|
|
648
|
+
await markRefreshAttempted();
|
|
649
|
+
const eventExtra = {
|
|
650
|
+
reason: isPartialRefresh ? 'partialResultPreservedExistingCache' : 'emptyResultPreservedExistingCache',
|
|
651
|
+
cachePreserved: true,
|
|
652
|
+
existingProductCount: existingCacheContent.products.length,
|
|
653
|
+
};
|
|
654
|
+
if (isPartialRefresh) eventExtra.pluginResult = pluginResult;
|
|
655
|
+
emitCacheEvent(
|
|
656
|
+
isPartialRefresh ? 'bookingsProductSearch:cache:partialRefreshSkipped' : 'bookingsProductSearch:cache:emptyRefreshSkipped',
|
|
657
|
+
eventExtra,
|
|
658
|
+
);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (isPartialRefresh) {
|
|
663
|
+
await markRefreshAttempted();
|
|
664
|
+
emitCacheEvent('bookingsProductSearch:cache:partialRefreshSkipped', {
|
|
665
|
+
reason: 'partialResultNotCached',
|
|
666
|
+
cachePreserved: false,
|
|
667
|
+
pluginResult,
|
|
668
|
+
});
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Best-effort race guard: avoid overwriting a non-empty cache written after the first read.
|
|
673
|
+
const latestCacheContent = await app.cache.get({ key: cacheKey });
|
|
674
|
+
if (hasNonEmptyProductCache(latestCacheContent)) {
|
|
675
|
+
await markRefreshAttempted();
|
|
676
|
+
emitCacheEvent('bookingsProductSearch:cache:emptyRefreshSkipped', {
|
|
677
|
+
reason: 'emptyResultPreservedConcurrentCache',
|
|
678
|
+
cachePreserved: true,
|
|
679
|
+
existingProductCount: latestCacheContent.products.length,
|
|
680
|
+
});
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Empty complete refreshes are authoritative only when there is no existing non-empty cache.
|
|
685
|
+
await markRefreshAttempted();
|
|
569
686
|
await app.cache.save({ key: cacheKey, value: { products: [] }, ttl: monthInSeconds });
|
|
570
|
-
|
|
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}.`);
|
|
687
|
+
emitCacheEvent('bookingsProductSearch:cache:emptyRefresh');
|
|
575
688
|
}
|
|
576
689
|
};
|
|
577
690
|
|
package/index.js
CHANGED
|
@@ -90,6 +90,7 @@ 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 }),
|
|
93
94
|
scan: args => cache.scan({ ...args, pluginName }),
|
|
94
95
|
},
|
|
95
96
|
axios: pluginAxios,
|