ti2 1.0.122 → 1.0.124
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -1
- package/__tests__/plugin-capabilities.js +42 -0
- package/api.yml +76 -13
- package/controllers/README.md +24 -34
- package/controllers/__tests__/bookings-searchProducts.js +408 -232
- package/controllers/__tests__/product-search-ttr.js +39 -0
- package/controllers/__tests__/user.js +5 -1
- package/controllers/app.js +1 -1
- package/controllers/bookings.js +294 -152
- package/controllers/user.js +1 -0
- package/index.js +11 -1
- package/package.json +1 -1
- package/worker/__tests__/job-api.js +55 -3
- package/worker/__tests__/job-plugin.js +3 -3
- package/worker/index.js +16 -1
package/README.md
CHANGED
|
@@ -65,6 +65,35 @@ Plugins are the connectores to other systems and/or features you intent to use;
|
|
|
65
65
|
|searchItineraries|||||||||✓|
|
|
66
66
|
|queryAllotment|||||||||✓|
|
|
67
67
|
|
|
68
|
+
### Product catalog refresh contract
|
|
69
|
+
|
|
70
|
+
`POST /products/{appKey}/{userId}/{hint}/search` returns a canonical object with a
|
|
71
|
+
`products` array. Use `cacheOnly: true` to read Ti2's current catalog without
|
|
72
|
+
calling the integration; `cacheFound` distinguishes a cache miss from a cached
|
|
73
|
+
empty catalog.
|
|
74
|
+
|
|
75
|
+
A forced request is a full-catalog refresh by default. `fullSyncTrigger` may be
|
|
76
|
+
`scheduled`, `manual`, or `organic`; an omitted trigger on a forced request is
|
|
77
|
+
treated as manual. Manual refreshes require a non-blank `admissionOverrideReason`.
|
|
78
|
+
Scheduled and manual refreshes reject `searchInput`, `optionId`, `productId`,
|
|
79
|
+
`productName`, or `lastUpdatedFrom` selectors. An empty `searchInput` or
|
|
80
|
+
`searchInput: "*"` represents the full catalog. Scheduler-owned requests may
|
|
81
|
+
also carry `fullSyncStartedAt` and `fullSyncAdmissionToken`.
|
|
82
|
+
|
|
83
|
+
Unscoped forced responses include `catalogRefreshOutcome`, `cacheUpdated`,
|
|
84
|
+
`cachePreserved`, and `cachedProductCount`. Consumers that update a downstream
|
|
85
|
+
catalog should use these fields instead of assuming that HTTP 200 or the returned
|
|
86
|
+
`products.length` means Ti2 replaced its cache. Empty or partial plugin results
|
|
87
|
+
can preserve an existing catalog. If a concurrent refresh remains in progress
|
|
88
|
+
past the lock wait, Ti2 serves the retained catalog with
|
|
89
|
+
`catalogRefreshOutcome: "refresh_in_progress_cache_served"`. This outcome is
|
|
90
|
+
non-terminal: background-job `success` means the HTTP call completed, not that
|
|
91
|
+
the catalog was written.
|
|
92
|
+
|
|
93
|
+
The product cache refresh interval defaults to seven days when no TTR is set.
|
|
94
|
+
Any explicit `ttlForProducts` or plugin `cacheSettings.bookingsProductSearch.ttr`
|
|
95
|
+
value is honored, including `86400` for a one-day interval.
|
|
96
|
+
|
|
68
97
|
## Contributing
|
|
69
98
|
|
|
70
99
|
Contributions are welcome and ecouraged.
|
|
@@ -94,4 +123,3 @@ TL;DR Here's what the license entails:
|
|
|
94
123
|
7. Any modifications of this code base MUST be distributed with the same license, GPLv3.
|
|
95
124
|
8. This software is provided without warranty.
|
|
96
125
|
9. The software author or license can not be held liable for any damages inflicted by the software.
|
|
97
|
-
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/* globals describe expect it */
|
|
2
|
+
|
|
3
|
+
const createApp = require('../index');
|
|
4
|
+
|
|
5
|
+
class SelectivePlugin {
|
|
6
|
+
constructor({ cache, events, name }) {
|
|
7
|
+
this.cache = cache;
|
|
8
|
+
this.events = events;
|
|
9
|
+
this.name = name;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('host-owned plugin capabilities', () => {
|
|
14
|
+
it('assigns configured capabilities when a plugin ignores unknown constructor fields', async () => {
|
|
15
|
+
const app = await createApp({
|
|
16
|
+
pluginCapabilities: {
|
|
17
|
+
selective: { weeklyProductCatalogSync: true },
|
|
18
|
+
},
|
|
19
|
+
plugins: { selective: SelectivePlugin },
|
|
20
|
+
startServer: false,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(app.plugins[0].capabilities).toEqual({
|
|
24
|
+
weeklyProductCatalogSync: true,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('assigns configured capabilities to pre-instantiated plugins', async () => {
|
|
29
|
+
const plugin = { name: 'instantiated' };
|
|
30
|
+
const app = await createApp({
|
|
31
|
+
pluginCapabilities: {
|
|
32
|
+
instantiated: { weeklyProductCatalogSync: true },
|
|
33
|
+
},
|
|
34
|
+
pluginsInstantiated: [plugin],
|
|
35
|
+
startServer: false,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(app.plugins[0].capabilities).toEqual({
|
|
39
|
+
weeklyProductCatalogSync: true,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
});
|
package/api.yml
CHANGED
|
@@ -275,6 +275,11 @@ components:
|
|
|
275
275
|
appMethods:
|
|
276
276
|
type: object
|
|
277
277
|
properties:
|
|
278
|
+
capabilities:
|
|
279
|
+
type: object
|
|
280
|
+
properties:
|
|
281
|
+
weeklyProductCatalogSync:
|
|
282
|
+
type: boolean
|
|
278
283
|
methods:
|
|
279
284
|
type: array
|
|
280
285
|
items:
|
|
@@ -426,15 +431,56 @@ components:
|
|
|
426
431
|
bookingsProductQuery:
|
|
427
432
|
type: object
|
|
428
433
|
additionalProperties: true
|
|
429
|
-
description:
|
|
434
|
+
description: Product search or full-catalog refresh request. Scheduled and manual force refreshes must not include selectors.
|
|
430
435
|
properties:
|
|
436
|
+
searchInput:
|
|
437
|
+
type: string
|
|
438
|
+
description: Free-text product selector. An empty value or "*" means the full catalog.
|
|
439
|
+
optionId:
|
|
440
|
+
description: Option ID selector
|
|
441
|
+
oneOf:
|
|
442
|
+
- type: string
|
|
443
|
+
- type: array
|
|
444
|
+
items:
|
|
445
|
+
type: string
|
|
446
|
+
productId:
|
|
447
|
+
type: string
|
|
448
|
+
description: Product ID selector
|
|
431
449
|
productName:
|
|
432
450
|
type: string
|
|
433
|
-
required: false
|
|
434
451
|
description: Product Name wildcard match
|
|
435
452
|
example: '*bus*'
|
|
453
|
+
lastUpdatedFrom:
|
|
454
|
+
description: Return products updated since this value
|
|
455
|
+
forceRefresh:
|
|
456
|
+
type: boolean
|
|
457
|
+
default: false
|
|
458
|
+
description: Call the plugin directly. Full-catalog refreshes require the app's weeklyProductCatalogSync capability and default to a manual trigger when fullSyncTrigger is omitted. Manual refreshes require admissionOverrideReason.
|
|
459
|
+
cacheOnly:
|
|
460
|
+
type: boolean
|
|
461
|
+
default: false
|
|
462
|
+
description: Return the current Ti2 cache without calling the plugin. cacheFound distinguishes a miss from a cached empty catalog.
|
|
463
|
+
backgroundJob:
|
|
464
|
+
type: boolean
|
|
465
|
+
default: false
|
|
466
|
+
description: Execute the request through the Ti2 worker.
|
|
467
|
+
fullSyncTrigger:
|
|
468
|
+
type: string
|
|
469
|
+
description: Catalog refresh owner. Forced requests default to manual when omitted. Scheduled and manual triggers reject selector fields.
|
|
470
|
+
enum:
|
|
471
|
+
- organic
|
|
472
|
+
- scheduled
|
|
473
|
+
- manual
|
|
474
|
+
admissionOverrideReason:
|
|
475
|
+
type: string
|
|
476
|
+
description: Required non-blank audit reason for a manual refresh override.
|
|
477
|
+
fullSyncStartedAt:
|
|
478
|
+
type: number
|
|
479
|
+
description: Start time supplied by the full-sync admission owner.
|
|
480
|
+
fullSyncAdmissionToken:
|
|
481
|
+
type: string
|
|
482
|
+
description: Opaque token supplied by the full-sync admission owner.
|
|
436
483
|
omitServiceCodes:
|
|
437
|
-
required: false
|
|
438
484
|
description: Service codes to omit from the product catalog. When the integration has productSearchOmitServiceCodes, that list is used instead.
|
|
439
485
|
example: ['AC', 'SM']
|
|
440
486
|
oneOf:
|
|
@@ -443,25 +489,42 @@ components:
|
|
|
443
489
|
items:
|
|
444
490
|
type: string
|
|
445
491
|
credentials:
|
|
446
|
-
required: false
|
|
447
492
|
$ref: '#/components/schemas/integrationCredentials'
|
|
448
493
|
bookingsProductSearchReturn:
|
|
449
494
|
type: object
|
|
450
495
|
additionalProperties: true
|
|
451
|
-
|
|
496
|
+
required:
|
|
497
|
+
- products
|
|
498
|
+
description: Canonical product search response. Additional properties may be included.
|
|
452
499
|
properties:
|
|
453
500
|
products:
|
|
454
501
|
type: array
|
|
455
|
-
|
|
456
|
-
description: Product Search Results
|
|
502
|
+
description: Effective Ti2 catalog; preserved refreshes return the retained cached products.
|
|
457
503
|
items:
|
|
458
504
|
type: object
|
|
459
|
-
|
|
460
|
-
type:
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
505
|
+
cacheFound:
|
|
506
|
+
type: boolean
|
|
507
|
+
description: For cacheOnly requests, whether Ti2 had a cached catalog (including an intentionally cached empty catalog).
|
|
508
|
+
catalogRefreshOutcome:
|
|
509
|
+
type: string
|
|
510
|
+
description: Outcome of an unscoped forceRefresh catalog operation
|
|
511
|
+
enum:
|
|
512
|
+
- cache_updated
|
|
513
|
+
- empty_cache_saved
|
|
514
|
+
- empty_result_preserved_cache
|
|
515
|
+
- partial_result_preserved_cache
|
|
516
|
+
- partial_result_not_cached
|
|
517
|
+
- refresh_in_progress_cache_served
|
|
518
|
+
cacheUpdated:
|
|
519
|
+
type: boolean
|
|
520
|
+
description: Whether this forceRefresh wrote the returned catalog to Ti2 cache
|
|
521
|
+
cachePreserved:
|
|
522
|
+
type: boolean
|
|
523
|
+
description: Whether this forceRefresh retained a prior catalog instead of replacing it
|
|
524
|
+
cachedProductCount:
|
|
525
|
+
type: integer
|
|
526
|
+
nullable: true
|
|
527
|
+
description: Product count in the Ti2 cache after the forceRefresh decision
|
|
465
528
|
pricingItem:
|
|
466
529
|
type: object
|
|
467
530
|
additionalProperties: true
|
package/controllers/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Bookings Controller Caching and Locking Strategy
|
|
2
2
|
|
|
3
|
-
The `$bookingsProductSearch` function in `controllers/bookings.js` implements a
|
|
3
|
+
The `$bookingsProductSearch` function in `controllers/bookings.js` implements a caching and locking strategy to optimize product searches, manage stale data, and prevent redundant plugin calls. This document outlines its core flow.
|
|
4
4
|
|
|
5
5
|
## Core Flow of `$bookingsProductSearch`
|
|
6
6
|
|
|
@@ -9,63 +9,53 @@ The `$bookingsProductSearch` function in `controllers/bookings.js` implements a
|
|
|
9
9
|
* Determines the specific plugin function to call for product search (e.g., `searchProducts` or `searchProductsForItinerary`).
|
|
10
10
|
* Injects the integration's configured `productSearchOmitServiceCodes` into the plugin payload so every caller for that token omits the same service codes. When the setting is empty, the request body is unchanged.
|
|
11
11
|
* Calculates a `cacheKey` based on `userId`, `hint`, and a static `operationId` (`bookingsProductSearch`). Changing the omit setting does not change the key; clear that integration's product-search cache and resync.
|
|
12
|
-
* Defines
|
|
12
|
+
* Defines a lock key derived from this `cacheKey`:
|
|
13
13
|
* `pluginExecutionLockKey` (resolves to `${cacheKey}:lock`): Used to serialize direct calls to the plugin.
|
|
14
|
-
*
|
|
14
|
+
* `${pluginExecutionLockKey}:outcome`: Short-lived refresh metadata used by concurrent waiters.
|
|
15
15
|
* Fetches the current cache content (`initialActualCacheContent`) and its `lastUpdated` timestamp.
|
|
16
16
|
* Determines if the cache is stale (`isStaleByTTR`) based on the Time-To-Refresh (`ttr`) value from the token or plugin settings.
|
|
17
17
|
* Checks for a `doNotCallPluginForProducts` flag (from token or plugin settings) and whether a `pluginExecutionLockKey` is currently active.
|
|
18
18
|
|
|
19
19
|
2. **Request Handling Logic (Simplified Order):**
|
|
20
20
|
|
|
21
|
-
* **Condition 1: `
|
|
21
|
+
* **Condition 1: `cacheOnly` is true**:
|
|
22
|
+
* Returns the current cache without calling the plugin. `cacheFound` distinguishes a cache miss from an intentionally cached empty catalog.
|
|
23
|
+
|
|
24
|
+
* **Condition 2: `doNotCallPluginForProducts` is true AND NOT `forceRefresh`**:
|
|
22
25
|
* If this flag is set and the request is not a forced refresh, the system serves data directly from the cache if available.
|
|
23
26
|
* If the cache is empty, it returns an empty product list.
|
|
24
|
-
* No plugin calls are made
|
|
27
|
+
* No plugin calls are made in this path.
|
|
25
28
|
|
|
26
|
-
* **Condition
|
|
27
|
-
* The system
|
|
29
|
+
* **Condition 3: `forceRefresh` is true**:
|
|
30
|
+
* This is the catalog refresh owner. The system fetches fresh data from the plugin.
|
|
31
|
+
* Ti2 rejects the request unless the host declared `weeklyProductCatalogSync: true` for the plugin. Manual audit reasons may override timing admission, but not catalog-completeness eligibility.
|
|
32
|
+
* An omitted `fullSyncTrigger` is treated as `manual`, and manual refreshes require a non-blank `admissionOverrideReason`. Weekly scheduler requests must explicitly send `fullSyncTrigger: scheduled`.
|
|
33
|
+
* Unless `fullSyncTrigger: organic` is explicit, a force refresh is a scheduled or manual full-catalog request and rejects `searchInput`, `optionId`, `productId`, `productName`, and `lastUpdatedFrom` selectors. `searchInput: "*"` is unscoped.
|
|
28
34
|
* The `fetchFromPluginAndCache` helper function is invoked. This function:
|
|
29
35
|
* Sets the `pluginExecutionLockKey` before calling the plugin to prevent other concurrent direct calls.
|
|
30
36
|
* Calls the plugin's product search method.
|
|
31
37
|
* If the plugin returns valid products, these are saved to the cache (both `cacheKey` for data and `${cacheKey}:lastUpdated` for timestamp).
|
|
38
|
+
* If the plugin returns empty or partial products and a non-empty cache already exists, the existing cache is kept and returned.
|
|
39
|
+
* Concurrent waiters return the same effective catalog and refresh outcome. If the wait expires while the leader is still active, the waiter returns the retained cache with `refresh_in_progress_cache_served`.
|
|
32
40
|
* Drops the `pluginExecutionLockKey` after completion.
|
|
33
|
-
* The
|
|
41
|
+
* The response includes `catalogRefreshOutcome`, `cacheUpdated`, `cachePreserved`, and `cachedProductCount`. `catalogRefreshOutcome` is authoritative; background-job `success` only means the HTTP request completed and does not prove a terminal catalog write.
|
|
34
42
|
|
|
35
|
-
* **Condition
|
|
36
|
-
*
|
|
37
|
-
|
|
38
|
-
*
|
|
39
|
-
* The system checks for an active `jobQueueLockKey`.
|
|
40
|
-
* If `jobQueueLockKey` IS active: It implies that another request very recently detected the stale cache and has already queued a background refresh job. The current request serves the stale data from the cache without queueing another job.
|
|
41
|
-
* If `jobQueueLockKey` is NOT active: This request is the first (or among the first) to find the stale cache without an ongoing direct refresh or a recently queued job. It will:
|
|
42
|
-
1. Set the `jobQueueLockKey` with a short TTL (e.g., 60 seconds).
|
|
43
|
-
2. Queue a background job using `addJob`. This job will eventually call the plugin to refresh the data and then use `$updateProductSearchCache` to update the cache.
|
|
44
|
-
3. Serve the stale data from the cache to the current client.
|
|
43
|
+
* **Condition 4: Cache Exists AND NOT `forceRefresh`**:
|
|
44
|
+
* Organic search never starts a catalog refresh.
|
|
45
|
+
* If the cache is fresh, or a plugin fetch is already running, serve the cache.
|
|
46
|
+
* If the cache is stale, serve it while the scheduler separately owns the next `forceRefresh` catalog sync.
|
|
45
47
|
|
|
46
|
-
* **Condition
|
|
47
|
-
*
|
|
48
|
+
* **Condition 5: No Cache Content AND NOT `forceRefresh` AND NOT `doNotCallPluginForProducts`**:
|
|
49
|
+
* Cache miss still fetches from the plugin so search can return products.
|
|
48
50
|
* It calls `fetchFromPluginAndCache` (which sets `pluginExecutionLockKey`, calls the plugin, caches results, and drops the lock) to get initial data.
|
|
49
51
|
* The (potentially filtered) results are returned to the client.
|
|
50
52
|
|
|
51
53
|
## Locking Mechanisms Explained
|
|
52
54
|
|
|
53
|
-
The system uses two types of locks, both based on the primary `cacheKey`, to manage concurrency and prevent redundant operations:
|
|
54
|
-
|
|
55
55
|
1. **`pluginExecutionLockKey` (derived from `${cacheKey}:lock`)**:
|
|
56
|
-
* **Purpose**: To prevent multiple simultaneous *direct calls* to the external plugin for the same product search parameters. This is
|
|
56
|
+
* **Purpose**: To prevent multiple simultaneous *direct calls* to the external plugin for the same product search parameters. This is used during `forceRefresh` and first-time cache population.
|
|
57
57
|
* **Behavior**:
|
|
58
58
|
* This lock is set by the `fetchFromPluginAndCache` helper function immediately before it makes an actual call to the plugin's `searchProducts` (or equivalent) method.
|
|
59
59
|
* It is configured with a TTL (e.g., 120 seconds) to ensure it automatically expires if the process holding the lock crashes or fails to release it.
|
|
60
60
|
* The lock is explicitly dropped by `fetchFromPluginAndCache` after the plugin call completes (whether successfully or with an error).
|
|
61
|
-
* Other parts of the main logic (e.g., in Condition
|
|
62
|
-
|
|
63
|
-
2. **`jobQueueLockKey` (derived from `${cacheKey}:jobLock`)**:
|
|
64
|
-
* **Purpose**: To prevent the submission of multiple identical *background refresh jobs* by nearly simultaneous requests when the cache is found to be stale and no direct plugin execution (covered by `pluginExecutionLockKey`) is active.
|
|
65
|
-
* **Behavior**:
|
|
66
|
-
* This lock is checked specifically when the cache is determined to be effectively stale (`isEffectivelyStale`) and no `pluginExecutionLockKey` is currently active (Condition 3b).
|
|
67
|
-
* If the `jobQueueLockKey` is NOT found in the cache, the current request assumes responsibility for queuing the refresh job. It sets this lock with a short TTL (e.g., 60 seconds) *before* calling `addJob`.
|
|
68
|
-
* If the `jobQueueLockKey` IS found, it indicates that another request has very recently detected the stale state and has already taken action to queue the refresh job. The current request will then proceed to serve stale data without attempting to queue another job.
|
|
69
|
-
* This mechanism ensures that even if numerous requests detect stale data at virtually the same moment, only one of them will succeed in setting the `jobQueueLockKey` and thereby be responsible for queueing the single background refresh task.
|
|
70
|
-
|
|
71
|
-
These two locks work in tandem: `pluginExecutionLockKey` manages contention for direct, immediate plugin interactions, while `jobQueueLockKey` manages contention for initiating background refresh tasks when stale data is being served. This dual-lock strategy helps maintain system performance and avoids overwhelming external plugin services or the background job queue.
|
|
61
|
+
* Other parts of the main logic (e.g., in Condition 3) check for the presence of this lock (`hasPluginExecutionLock`). If active, it signals that a direct plugin data fetch is already in progress, prompting the current request to serve cache rather than initiating another direct plugin call.
|