ti2 1.0.90 → 1.0.91

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.
@@ -237,6 +237,126 @@ describe('user: bookings controller', () => {
237
237
  expect(Array.isArray(products)).toBeTruthy();
238
238
  expect(products.length).toBe(2);
239
239
  });
240
+
241
+ describe('cache TTR and lock mechanism', () => {
242
+ const shortTTRToken = {
243
+ endpoint: 'https://api.travelgatex.com',
244
+ apiKey: chance.guid(),
245
+ client: 'tourconnect',
246
+ ttlForProducts: 2, // 2 seconds TTR
247
+ };
248
+
249
+ beforeEach(async () => {
250
+ // Clear any existing integration
251
+ try {
252
+ await doApiDelete({
253
+ url: `/${appKey}/${userId}`,
254
+ token: adminKey,
255
+ payload: { tokenHint: 'ttr-test' },
256
+ });
257
+ } catch (err) {
258
+ console.debug(err);
259
+ }
260
+
261
+ // Create new integration with short TTR
262
+ await doApiPost({
263
+ url: `/${appKey}/${userId}`,
264
+ token: adminKey,
265
+ payload: {
266
+ token: shortTTRToken,
267
+ tokenHint: 'ttr-test',
268
+ },
269
+ });
270
+
271
+ // Clear mock calls
272
+ jest.clearAllMocks();
273
+ });
274
+
275
+ it('should use cache within TTR period', async () => {
276
+ // First call should hit the plugin
277
+ await doApiPost({
278
+ url: `/products/${appKey}/${userId}/ttr-test/search`,
279
+ token: userToken,
280
+ payload: {},
281
+ });
282
+ expect(plugins[0].searchProducts).toHaveBeenCalledTimes(1);
283
+
284
+ // Second immediate call should use cache
285
+ await doApiPost({
286
+ url: `/products/${appKey}/${userId}/ttr-test/search`,
287
+ token: userToken,
288
+ payload: {},
289
+ });
290
+ expect(plugins[0].searchProducts).toHaveBeenCalledTimes(1); // Still 1, used cache
291
+ });
292
+
293
+ it('should refresh cache after TTR expires', async () => {
294
+ // Wait for TTR to expire (2 seconds + buffer) so the cache from previous test is stale
295
+ await new Promise(resolve => {
296
+ setTimeout(resolve, 2100);
297
+ });
298
+
299
+ // First call to populate cache
300
+ await doApiPost({
301
+ url: `/products/${appKey}/${userId}/ttr-test/search`,
302
+ token: userToken,
303
+ payload: {},
304
+ });
305
+ expect(plugins[0].searchProducts).toHaveBeenCalledTimes(1);
306
+
307
+ // Wait for TTR to expire (2 seconds + buffer)
308
+ await new Promise(resolve => {
309
+ setTimeout(resolve, 2100);
310
+ });
311
+
312
+ // Call after TTR expired should hit the plugin again
313
+ await doApiPost({
314
+ url: `/products/${appKey}/${userId}/ttr-test/search`,
315
+ token: userToken,
316
+ payload: {},
317
+ });
318
+ expect(plugins[0].searchProducts).toHaveBeenCalledTimes(2);
319
+ });
320
+
321
+ it('should use lock mechanism to prevent concurrent calls', async () => {
322
+ // Wait for TTR to expire (2 seconds + buffer) so the cache from previous test is stale
323
+ await new Promise(resolve => {
324
+ setTimeout(resolve, 2100);
325
+ });
326
+
327
+ // Make multiple requests with slight delays to simulate real-world concurrent requests
328
+ const makeRequest = () => doApiPost({
329
+ url: `/products/${appKey}/${userId}/ttr-test/search`,
330
+ token: userToken,
331
+ payload: {},
332
+ });
333
+
334
+ // Start first request
335
+ const request1 = makeRequest();
336
+ // Start second request after 10ms
337
+ await new Promise(resolve => {
338
+ setTimeout(resolve, 10);
339
+ });
340
+ const request2 = makeRequest();
341
+ // Start third request after another 10ms
342
+ await new Promise(resolve => {
343
+ setTimeout(resolve, 10);
344
+ });
345
+ const request3 = makeRequest();
346
+
347
+ // Wait for all requests to complete
348
+ const results = await Promise.all([request1, request2, request3]);
349
+
350
+ // All requests should return valid products
351
+ results.forEach(({ products }) => {
352
+ expect(Array.isArray(products)).toBeTruthy();
353
+ expect(products.length).toBe(2); // Based on existing test expectations
354
+ });
355
+
356
+ // Plugin should only be called once due to lock mechanism
357
+ expect(plugins[0].searchProducts).toHaveBeenCalledTimes(1);
358
+ });
359
+ });
240
360
  });
241
361
 
242
362
  it('should be able to search for availabiility', async () => {
@@ -1,7 +1,7 @@
1
1
  const assert = require('assert');
2
2
  const hash = require('object-hash');
3
- const cache = require('../cache');
4
3
  const R = require('ramda');
4
+ const cache = require('../cache');
5
5
  const { UserAppKey } = require('../models/index');
6
6
  const { typeDefs: productTypeDefs, query: productQuery } = require('./graphql-schemas/product');
7
7
  const { typeDefs: availTypeDefs, query: availQuery } = require('./graphql-schemas/availability');
@@ -136,7 +136,6 @@ const $bookingsProductSearch = plugins => async ({
136
136
  requestId,
137
137
  }) => {
138
138
  const app = plugins.find(({ name }) => name === appKey);
139
- // const app = load(appKey);
140
139
  assert(userId, 'userId is required');
141
140
  assert(appKey, 'appKey is required');
142
141
  assert(app.searchProducts || app.searchProductsForItinerary, `searchProducts or searchProductsForItinerary is not available for ${appKey}`);
@@ -157,20 +156,36 @@ const $bookingsProductSearch = plugins => async ({
157
156
  hint,
158
157
  operationId: 'bookingsProductSearch',
159
158
  });
160
- if (forceRefresh) {
161
- // remove the cache
162
- await cache.drop({
163
- pluginName: appKey,
164
- key: cacheKey,
165
- });
166
- }
167
- const cacheValue = await cache.get({
159
+ // TODO: remove debugging console.logs after no related issues reported for a while
160
+ // Check cache first if not forcing refresh
161
+ const cacheValue = forceRefresh ? null : await cache.get({
168
162
  pluginName: appKey,
169
163
  key: cacheKey,
170
164
  });
171
- if (cacheValue && cacheValue.products) {
165
+ const lastUpdated = await cache.get({
166
+ pluginName: appKey,
167
+ key: `${cacheKey}:lastUpdated`,
168
+ });
169
+ // refresh every 24 hours
170
+ const ttr = token.ttlForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'ttr'], app) || 60 * 60 * 24;
171
+ let isStale = lastUpdated && (Date.now() - lastUpdated > ttr * 1000);
172
+ const doNotCallPluginForProducts = token.doNotCallPluginForProducts
173
+ || R.path(['cacheSettings', 'bookingsProductSearch', 'doNotCall'], app);
174
+ // if a company says never to call the plugin (their booking system)
175
+ // we will never treat their cache as stale
176
+ if (doNotCallPluginForProducts) {
177
+ isStale = false;
178
+ }
179
+ const hasLock = await cache.get({
180
+ pluginName: appKey,
181
+ key: `${cacheKey}:lock`,
182
+ });
183
+ console.log(`${appKey}/${userId}/${hint}: lastUpdated: ${lastUpdated}, ttr: ${ttr}, isStale: ${isStale}, hasLock: ${hasLock}, foundCache: ${!!cacheValue}`);
184
+ // when there is a lock (meaning another request is already fetching the products)
185
+ // return stale cache during this short window
186
+ if (cacheValue && cacheValue.products && (hasLock || !isStale)) {
172
187
  const searchResults = $searchProductList(cacheValue.products, searchInput, optionId);
173
- console.log(`${appKey}/${userId}/${hint}: found cache and returning cached products: ${searchResults.length}`);
188
+ console.log(`${appKey}/${userId}/${hint}: returning cached products: ${searchResults.length}`);
174
189
  return {
175
190
  ...cacheValue,
176
191
  products: searchResults,
@@ -178,13 +193,18 @@ const $bookingsProductSearch = plugins => async ({
178
193
  ...(token.configuration || {}),
179
194
  };
180
195
  }
181
- const doNotCallPluginForProducts = token.doNotCallPluginForProducts
182
- || R.path(['cacheSettings', 'bookingsProductSearch', 'doNotCall'], app);
183
196
  if (doNotCallPluginForProducts && !forceRefresh) {
184
- console.log(`${appKey}/${userId}/${hint}: no cache found but not calling the plugin because doNotCallPluginForProducts is true and forceRefresh is false`);
197
+ console.log(`${appKey}/${userId}/${hint}:not calling the plugin because doNotCallPluginForProducts is true and forceRefresh is false`);
185
198
  return { products: [] };
186
199
  }
187
- console.log(`${appKey}/${userId}/${hint}: no cache found(forceRefresh: ${forceRefresh}) and calling func`);
200
+ console.log(`${appKey}/${userId}/${hint}: (forceRefresh: ${forceRefresh}) so calling func to get fresh products`);
201
+ // create a lock with 2 minute TTL
202
+ await cache.save({
203
+ pluginName: appKey,
204
+ key: `${cacheKey}:lock`,
205
+ value: true,
206
+ ttl: 120,
207
+ });
188
208
  const funcResults = await func({
189
209
  axios,
190
210
  token,
@@ -195,17 +215,30 @@ const $bookingsProductSearch = plugins => async ({
195
215
  });
196
216
  // save cache if products are found
197
217
  if (funcResults && funcResults.products && funcResults.products.length > 0) {
198
- // 25 hours, just to have some buffer
199
- const ttl = token.ttlForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'ttl'], app) || 60 * 60 * 25;
200
- console.log(`${appKey}/${userId}/${hint}: saving cache of ${funcResults.products.length} products for (ttl:${ttl})`);
218
+ console.log(`${appKey}/${userId}/${hint}: saving cache of ${funcResults.products.length} products`);
219
+ // I initially wanted to let the cache live forever
220
+ // but just in case user left TC or something, we don't want to keep the cache forever
221
+ // so we set the TTL to 30 days, within 30 days,
222
+ // we will refresh the cache every 24 hours(or specified by the user) without deleting the stale cache
223
+ const monthInSeconds = 30 * 24 * 60 * 60;
224
+ await cache.save({
225
+ pluginName: appKey,
226
+ key: `${cacheKey}:lastUpdated`,
227
+ value: Date.now(),
228
+ ttl: monthInSeconds,
229
+ });
201
230
  await cache.save({
202
231
  pluginName: appKey,
203
232
  key: cacheKey,
204
233
  value: funcResults,
205
- ttl,
206
- skipTTL: Boolean(doNotCallPluginForProducts),
234
+ ttl: monthInSeconds,
207
235
  });
208
236
  }
237
+ // release the lock
238
+ await cache.drop({
239
+ pluginName: appKey,
240
+ key: `${cacheKey}:lock`,
241
+ });
209
242
  const searchResults = $searchProductList(R.pathOr([], ['products'], funcResults), searchInput, optionId);
210
243
  return {
211
244
  ...funcResults,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2",
3
- "version": "1.0.90",
3
+ "version": "1.0.91",
4
4
  "description": "Tourist Industry Exchange (TI2)",
5
5
  "main": "index.js",
6
6
  "scripts": {