soajs.controller 4.3.18 → 4.3.20

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.
Files changed (33) hide show
  1. package/.github/workflows/ci.yml +4 -1
  2. package/.github/workflows/release.yml +4 -1
  3. package/docs/maintenance-routes.md +31 -0
  4. package/docs/middleware/cache.md +74 -1
  5. package/docs/middleware/idempotency.md +31 -2
  6. package/mw/cache/index.js +24 -5
  7. package/mw/cache/invalidate.js +71 -0
  8. package/mw/cache/model/mongo.js +95 -1
  9. package/mw/gotoService/redirectToService.js +21 -9
  10. package/mw/idempotency/index.js +42 -4
  11. package/mw/idempotency/model/memory.js +12 -2
  12. package/mw/idempotency/model/mongo.js +10 -3
  13. package/package.json +1 -1
  14. package/server/maintenance.js +41 -0
  15. package/test/data/provision/customRegistry/gateway.js +47 -0
  16. package/test/data/provision/environment/dev.js +9 -0
  17. package/test/data/provision/hosts/idempotent.js +12 -0
  18. package/test/data/provision/hosts/spaces.js +12 -0
  19. package/test/data/provision/marketplace/idempotent.js +37 -0
  20. package/test/data/provision/marketplace/spaces.js +36 -0
  21. package/test/data/provision/oauth/token/second_owner_token.js +72 -0
  22. package/test/data/provision/products/dsbrd.js +49 -0
  23. package/test/data/provision/urac/users/second_owner.js +61 -0
  24. package/test/integration/UC6-idempotency/index.js +197 -0
  25. package/test/integration/UC7-cache-invalidate/index.js +209 -0
  26. package/test/integration/index.js +8 -0
  27. package/test/unit/index.js +3 -0
  28. package/test/unit/mw/cache/index.js +122 -0
  29. package/test/unit/mw/cache/invalidate.js +99 -0
  30. package/test/unit/mw/cache/mongo.js +104 -0
  31. package/test/unit/mw/idempotency/index.js +122 -0
  32. package/test/unit/mw/idempotency/memory.js +36 -4
  33. package/test/unit/mw/idempotency/mongo.js +62 -0
@@ -17,7 +17,10 @@ jobs:
17
17
 
18
18
  services:
19
19
  mongodb:
20
- image: mongo:8
20
+ # TODO: back to mongo:8 once it starts on the GitHub runners again. Since 2026-10-19 mongod 8
21
+ # exits on start with "Linux kernel versions 6.19 and newer has a known incompatibility
22
+ # with this version of MongoDB" (https://jira.mongodb.org/browse/SERVER-121912).
23
+ image: mongo:7.0
21
24
  ports:
22
25
  - 27017:27017
23
26
 
@@ -17,7 +17,10 @@ jobs:
17
17
 
18
18
  services:
19
19
  mongodb:
20
- image: mongo:8
20
+ # TODO: back to mongo:8 once it starts on the GitHub runners again. Since 2026-10-19 mongod 8
21
+ # exits on start with "Linux kernel versions 6.19 and newer has a known incompatibility
22
+ # with this version of MongoDB" (https://jira.mongodb.org/browse/SERVER-121912).
23
+ image: mongo:7.0
21
24
  ports:
22
25
  - 27017:27017
23
26
 
@@ -18,6 +18,7 @@ The maintenance server runs on a separate port from the main gateway to allow in
18
18
  | `/loadProvision` | GET | Reload provision/tenant data |
19
19
  | `/reloadRegistry` | GET | Reload environment registry |
20
20
  | `/awarenessStat` | GET | Get service awareness statistics |
21
+ | `/cache/invalidate` | POST | Remove cached GET responses a service knows are out of date |
21
22
 
22
23
  ---
23
24
 
@@ -226,6 +227,36 @@ curl http://localhost:5000/awarenessStat
226
227
 
227
228
  ---
228
229
 
230
+ ## 5. Cache Invalidation (`/cache/invalidate`)
231
+
232
+ **Endpoint:** `POST /cache/invalidate`
233
+
234
+ **Purpose:** Lets a service remove cached GET responses as soon as it knows they changed, instead of
235
+ waiting for their TTL. Needs the mongo cache model.
236
+
237
+ ### Request
238
+
239
+ ```bash
240
+ curl -X POST http://localhost:5000/cache/invalidate \
241
+ -H "Content-Type: application/json" \
242
+ -d '{"tenant": "5c0e74ba9acc3c5a84a51259", "service": "spaces", "api": "GET /spaces", "user": "5c8d0c505653de3985aa2001"}'
243
+ ```
244
+
245
+ ### Response
246
+
247
+ ```json
248
+ {
249
+ "result": true,
250
+ "ts": 1792430000000,
251
+ "service": { "service": "soajs.controller", "version": "4.3.20", "type": "rest", "route": "/cache/invalidate" },
252
+ "data": { "removed": 3 }
253
+ }
254
+ ```
255
+
256
+ What each field matches, and the error cases, are in [Cache, Invalidation](middleware/cache.md#invalidation).
257
+
258
+ ---
259
+
229
260
  ## Security Considerations
230
261
 
231
262
  ### Network Access
@@ -157,7 +157,7 @@ APIs are matched using `GET /path` format with support for path parameters:
157
157
  - **tenant_id**: Isolates cache per tenant
158
158
  - **serviceName**: The target service name
159
159
  - **path**: The API path
160
- - **queryHash**: MD5 hash of sorted query parameters
160
+ - **queryHash**: MD5 hash of sorted query parameters, without `access_token`
161
161
  - **user_id**: The logged-in user id (only for `tenant_user` scope), isolating cache per user within the tenant
162
162
 
163
163
  ### Query Parameter Handling
@@ -168,8 +168,14 @@ Query parameters are included in the cache key via MD5 hash:
168
168
  GET /users?page=1&limit=10 → key includes hash of {"limit":"10","page":"1"}
169
169
  GET /users?limit=10&page=1 → same hash (sorted)
170
170
  GET /users → different key (no query hash)
171
+ GET /users?access_token=abc → same key as GET /users
171
172
  ```
172
173
 
174
+ `access_token` is left out: it identifies the caller, not what is asked for. With it in the key, every
175
+ token got its own entry, so a `tenant` scoped API was never shared between users, and a user's entries
176
+ stopped matching after each login or token refresh. The user of a `tenant_user` entry is already in the
177
+ key.
178
+
173
179
  ## Storage Backends
174
180
 
175
181
  ### Memory (Default)
@@ -229,6 +235,73 @@ Content-Type: application/json
229
235
  - Error responses (4xx, 5xx)
230
236
  - APIs not configured for caching
231
237
 
238
+ ## Invalidation
239
+
240
+ A cached response normally lives until its TTL. A service that knows something changed can remove the
241
+ affected entries right away by calling the gateway's maintenance port:
242
+
243
+ ```
244
+ POST /cache/invalidate
245
+ Content-Type: application/json
246
+
247
+ {
248
+ "tenant": "5c0e74ba9acc3c5a84a51259", // required, the tenant the GET was cached under
249
+ "service": "spaces", // required
250
+ "api": "GET /spaces", // optional, as written in the cache configuration
251
+ "user": "5c8d0c505653de3985aa2001" // optional, the user id of a tenant_user entry
252
+ }
253
+ ```
254
+
255
+ ```json
256
+ { "result": true, "data": { "removed": 3 } }
257
+ ```
258
+
259
+ | Given | Removed |
260
+ |-------|---------|
261
+ | `tenant`, `service`, `api`, `user` | That user's entries of that api |
262
+ | `tenant`, `service`, `api` | That api's entries for every user, and its tenant scoped entry |
263
+ | `tenant`, `service`, `user` | That user's entries of every api of the service |
264
+ | `tenant`, `service` | Every entry of the service in the tenant |
265
+
266
+ Every cached variant of a matching entry is removed: every query string, and every value of a path
267
+ parameter. `"api": "GET /space/:id"` removes `/space/1`, `/space/2` and so on. A `user` only matches
268
+ `tenant_user` entries, a `tenant` scoped entry has no user.
269
+
270
+ **Only the mongo model is supported.** It is the one shared by every gateway, so one call clears the
271
+ entry everywhere. With the memory model the route answers `400`.
272
+
273
+ **Example.** A service caches `GET /spaces`, the spaces a user is invited to, per user. When user A
274
+ invites user B, the invite API removes B's list before it answers, so B's next call loads the new one:
275
+
276
+ ```javascript
277
+ // at the end of the invite API, before replying
278
+ await post("http://" + gatewayHost + ":" + maintenancePort + "/cache/invalidate", {
279
+ "tenant": req.soajs.tenant.id,
280
+ "service": "spaces",
281
+ "api": "GET /spaces",
282
+ "user": invitedUserId
283
+ });
284
+ ```
285
+
286
+ Wait for it with a short timeout. If the gateway cannot be reached, the change still stands and the
287
+ entry expires on its TTL.
288
+
289
+ **Requests already in flight.** A GET that started before the invalidation may have read the data
290
+ before the change. Its response is still sent to the client but not stored, so it cannot put the old
291
+ list back after the removal. Each invalidation is remembered for 15 minutes in the `cache_invalidation`
292
+ collection for this check. It compares the time the gateway handling the GET received it with the time
293
+ the invalidation ran, keep the gateways' clocks in sync.
294
+
295
+ Entries cached before this version carry no service, api or user, an invalidation does not match them,
296
+ they expire on their TTL.
297
+
298
+ | Status | Reason |
299
+ |--------|--------|
300
+ | `200` | Removed, `data.removed` is the number of entries |
301
+ | `400` | Missing `tenant` or `service`, an `api` not written as `GET /path`, a `user` that is not a string, caching not configured, or not the mongo model |
302
+ | `405` | Not a `POST` |
303
+ | `500` | The cache store failed or is not ready |
304
+
232
305
  ## Usage Notes
233
306
 
234
307
  - Only GET requests are cached
@@ -48,7 +48,8 @@ The Idempotency middleware prevents duplicate write operations by tracking reque
48
48
  │
49
49
  ├─── Found (in_flight) ──▶ 409 Conflict (code: 181)
50
50
  │
51
- └─── Not found ──▶ Lock key → Execute → Store response
51
+ └─── Not found ──▶ Lock key → Execute → Store response (status < 500)
52
+ or release the key
52
53
  ```
53
54
 
54
55
  ## Configuration
@@ -140,12 +141,39 @@ Content-Type: application/json
140
141
  {"caller": "user1", "callee": "user2"}
141
142
  ```
142
143
 
144
+ ## How a Locked Key Ends
145
+
146
+ The key is locked before the request goes on to throttling, caching and the proxy. How the
147
+ request ends decides what happens to it:
148
+
149
+ | The request ends with | The key | A retry with the same key |
150
+ |---|---|---|
151
+ | An answer of the service below 500 (2xx, 3xx, 4xx) | Completed, the answer is stored for the TTL | Gets the stored answer, the service is not called |
152
+ | An answer of the service of 500 or more | Released | Reaches the service again |
153
+ | A proxy error (service down, connection closed) | Released | Reaches the service again |
154
+ | An answer of the gateway before the service: throttling (429), an error, a refusal | Released | Runs again |
155
+ | The client disconnecting before the request reaches the service | Released | Runs again |
156
+ | The client disconnecting while the service is answering | Follows the service answer above | |
157
+
158
+ Only an answer the service actually produced is replayed. A 4xx is the service's answer to that
159
+ request and replaying it is correct. A 5xx, a throttling refusal or any other answer of the gateway
160
+ says nothing about the operation, so the key is released and the client's retry runs instead of
161
+ getting the same refusal for the whole TTL.
162
+
163
+ While the request is running, a duplicate with the same key gets 409, code 181.
164
+
165
+ A service that can fail with a 5xx after part of the work is done must protect that work itself,
166
+ the retry reaches it again.
167
+
168
+ The release only removes a key still in flight and locked by that request: never a completed
169
+ one, and never a lock taken by a retry after the TTL of the first ran out.
170
+
143
171
  ## Response Scenarios
144
172
 
145
173
  ### New Request (First Time)
146
174
 
147
175
  ```
148
- Request processed normally, response cached
176
+ Request processed normally, a service answer below 500 is stored
149
177
  ```
150
178
 
151
179
  ### Replay (Completed)
@@ -235,6 +263,7 @@ Keys are isolated per tenant to prevent cross-tenant collisions.
235
263
  - Only applies to non-GET requests (POST, PUT, DELETE, PATCH)
236
264
  - GET requests always pass through (idempotent by nature)
237
265
  - Keys expire after TTL (default 60 seconds)
266
+ - A key is never left in flight by a request that does not reach the service, see [How a Locked Key Ends](#how-a-locked-key-ends)
238
267
  - Clients should generate a new UUID for each unique operation
239
268
  - Same UUID = same operation (for retries)
240
269
  - MongoDB backend required for horizontal scaling
package/mw/cache/index.js CHANGED
@@ -16,17 +16,28 @@ const { logOnce } = require('../../lib/logOnce.js');
16
16
 
17
17
  let model = { memory, mongo };
18
18
 
19
+ //NOTE: the access token is how the caller is identified, not what is asked for. Kept in, every token
20
+ // gets its own entry and a tenant scoped api is never shared, the user is already in the key of a
21
+ // tenant_user entry.
22
+ const QUERY_PARAMS_NOT_IN_KEY = ['access_token'];
23
+
19
24
  function hashQuery(query) {
20
- if (!query || Object.keys(query).length === 0) {
25
+ if (!query) {
26
+ return '';
27
+ }
28
+ const params = Object.keys(query).filter((key) => !QUERY_PARAMS_NOT_IN_KEY.includes(key));
29
+ if (params.length === 0) {
21
30
  return '';
22
31
  }
23
- const sortedQuery = Object.keys(query).sort().reduce((obj, key) => {
32
+ const sortedQuery = params.sort().reduce((obj, key) => {
24
33
  obj[key] = query[key];
25
34
  return obj;
26
35
  }, {});
27
36
  return crypto.createHash('md5').update(JSON.stringify(sortedQuery)).digest('hex');
28
37
  }
29
38
 
39
+ //NOTE: returns the entry name as written in the configuration with its config, an invalidation names
40
+ // the api the same way
30
41
  function matchesApi(method, path, apis) {
31
42
  if (!apis || typeof apis !== 'object') {
32
43
  return null;
@@ -34,7 +45,7 @@ function matchesApi(method, path, apis) {
34
45
  const requestSignature = `${method} ${path}`;
35
46
 
36
47
  if (apis[requestSignature]) {
37
- return apis[requestSignature];
48
+ return { 'name': requestSignature, 'config': apis[requestSignature] };
38
49
  }
39
50
 
40
51
  for (const api in apis) {
@@ -59,7 +70,7 @@ function matchesApi(method, path, apis) {
59
70
  }
60
71
  }
61
72
  if (matches) {
62
- return apis[api];
73
+ return { 'name': api, 'config': apis[api] };
63
74
  }
64
75
  }
65
76
  }
@@ -96,7 +107,8 @@ module.exports = function (configuration) {
96
107
  return next();
97
108
  }
98
109
 
99
- const apiConfig = matchesApi(method, apiPath, serviceConfig.apis);
110
+ const matchedApi = matchesApi(method, apiPath, serviceConfig.apis);
111
+ const apiConfig = matchedApi ? matchedApi.config : null;
100
112
  if (!apiConfig || !apiConfig.enabled) {
101
113
  return next();
102
114
  }
@@ -141,6 +153,7 @@ module.exports = function (configuration) {
141
153
  'l2': `${serviceName}:GET:${apiPath}:${queryHash}${userPart}`
142
154
  };
143
155
 
156
+ const startedAt = Date.now();
144
157
  const processCache = async () => {
145
158
  try {
146
159
  const cached = await model[modelType].get(key);
@@ -157,6 +170,12 @@ module.exports = function (configuration) {
157
170
  req.soajs.cacheContext = {
158
171
  key: key,
159
172
  ttl: ttl,
173
+ meta: {
174
+ service: serviceName,
175
+ api: matchedApi.name,
176
+ user: (scope === 'tenant_user') ? String(userId) : null,
177
+ startedAt: startedAt
178
+ },
160
179
  modelType: modelType,
161
180
  model: model[modelType],
162
181
  log: configuration.log
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @license
5
+ * Copyright SOAJS All Rights Reserved.
6
+ *
7
+ * Use of this source code is governed by an Apache license that can be
8
+ * found in the LICENSE file at the root of this repository
9
+ */
10
+
11
+ const get = (p, o) => p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o);
12
+ const mongo = require('./model/mongo.js');
13
+
14
+ const API_REGEX = /^GET \/\S*$/;
15
+
16
+ function isNonEmptyString(value) {
17
+ return typeof value === 'string' && value.length > 0;
18
+ }
19
+
20
+ /**
21
+ * Remove cached GET responses a service knows are out of date, called on the maintenance port.
22
+ *
23
+ * NOTE: only the mongo model is supported, it is the one shared by every gateway, the memory model
24
+ * is per gateway and meant for testing
25
+ *
26
+ * @param registry the gateway registry
27
+ * @param input {tenant, service, api, user} tenant and service are required
28
+ * @returns {Promise<{status: number, removed: number}|{status: number, error: string}>}
29
+ */
30
+ async function invalidate(registry, input) {
31
+ if (!input || typeof input !== 'object') {
32
+ return { 'status': 400, 'error': 'A JSON body with tenant and service is required.' };
33
+ }
34
+ if (!isNonEmptyString(input.tenant) || !isNonEmptyString(input.service)) {
35
+ return { 'status': 400, 'error': 'tenant and service are required.' };
36
+ }
37
+ if (input.api !== undefined && (!isNonEmptyString(input.api) || !API_REGEX.test(input.api))) {
38
+ return { 'status': 400, 'error': 'api must be written as in the cache configuration, for example "GET /spaces".' };
39
+ }
40
+ if (input.user !== undefined && !isNonEmptyString(input.user)) {
41
+ return { 'status': 400, 'error': 'user must be a user id.' };
42
+ }
43
+
44
+ const cacheConfig = get(["custom", "gateway", "value", "cache"], registry);
45
+ if (!cacheConfig) {
46
+ return { 'status': 400, 'error': 'Caching is not configured.' };
47
+ }
48
+ if (cacheConfig.model !== 'mongo') {
49
+ return { 'status': 400, 'error': 'Cache invalidation needs the mongo model, the cache model is [' + (cacheConfig.model || 'memory') + '].' };
50
+ }
51
+ if (!mongo.mongo) {
52
+ return { 'status': 500, 'error': 'The cache store is not ready.' };
53
+ }
54
+
55
+ try {
56
+ const removed = await mongo.invalidateMatching({
57
+ 'tenant': input.tenant,
58
+ 'service': input.service,
59
+ 'api': input.api,
60
+ 'user': input.user
61
+ });
62
+ return { 'status': 200, 'removed': removed };
63
+ } catch (e) {
64
+ if (mongo.log) {
65
+ mongo.log.error('Cache invalidate:', e.message);
66
+ }
67
+ return { 'status': 500, 'error': 'Cache invalidation failed.' };
68
+ }
69
+ }
70
+
71
+ module.exports = invalidate;
@@ -12,6 +12,11 @@ const core = require('soajs.core.modules');
12
12
  const Mongo = core.mongo;
13
13
 
14
14
  const cache_store = 'cache_store';
15
+ const cache_invalidation = 'cache_invalidation';
16
+
17
+ //NOTE: an invalidation is remembered long enough to outlive any request that started before it,
18
+ // so that request cannot store what it read before the change
19
+ const INVALIDATION_RETENTION = 15 * 60 * 1000;
15
20
 
16
21
  let model = {
17
22
  'mongo': null,
@@ -35,6 +40,27 @@ let model = {
35
40
  log.debug("Cache TTL index created: " + index);
36
41
  }
37
42
  });
43
+ model.mongo.createIndex(cache_store, { 'l1': 1, 'service': 1, 'api': 1, 'user': 1 }, {}, (err, index) => {
44
+ if (err) {
45
+ log.error("Cache invalidation lookup index creation failed: " + err.message);
46
+ } else {
47
+ log.debug("Cache invalidation lookup index created: " + index);
48
+ }
49
+ });
50
+ model.mongo.createIndex(cache_invalidation, { 'l1': 1, 'service': 1, 'api': 1, 'user': 1 }, { unique: true }, (err, index) => {
51
+ if (err) {
52
+ log.error("Cache invalidation index creation failed: " + err.message);
53
+ } else {
54
+ log.debug("Cache invalidation index created: " + index);
55
+ }
56
+ });
57
+ model.mongo.createIndex(cache_invalidation, { 'expiresAt': 1 }, { expireAfterSeconds: 0 }, (err, index) => {
58
+ if (err) {
59
+ log.error("Cache invalidation TTL index creation failed: " + err.message);
60
+ } else {
61
+ log.debug("Cache invalidation TTL index created: " + index);
62
+ }
63
+ });
38
64
  }
39
65
  },
40
66
 
@@ -58,7 +84,15 @@ let model = {
58
84
  }
59
85
  },
60
86
 
61
- 'set': async (key, response, ttl) => {
87
+ /**
88
+ * @param key {l1, l2}
89
+ * @param response {statusCode, headers, body}
90
+ * @param ttl
91
+ * @param meta {service, api, user, startedAt} what an invalidation matches on, user is null for a
92
+ * tenant scoped entry
93
+ * @returns {Promise<boolean>} false when not stored
94
+ */
95
+ 'set': async (key, response, ttl, meta) => {
62
96
  let condition = {
63
97
  'l1': key.l1,
64
98
  'l2': key.l2
@@ -70,13 +104,73 @@ let model = {
70
104
  'cachedAt': new Date(),
71
105
  'expiresAt': new Date(Date.now() + ttl)
72
106
  };
107
+ if (meta) {
108
+ doc.service = meta.service;
109
+ doc.api = meta.api;
110
+ doc.user = meta.user || null;
111
+ }
73
112
  let options = { 'upsert': true };
74
113
  let s = { '$set': doc };
75
114
  try {
115
+ //NOTE: a request that started before an invalidation covering it may have read the data
116
+ // before the change, its response is passed on but not stored
117
+ if (meta && meta.startedAt) {
118
+ let invalidated = await model.mongo.findOne(cache_invalidation, {
119
+ 'l1': key.l1,
120
+ 'service': meta.service,
121
+ 'api': { '$in': [meta.api, null] },
122
+ 'user': { '$in': [doc.user, null] },
123
+ 'clearedAt': { '$gt': new Date(meta.startedAt) }
124
+ });
125
+ if (invalidated) {
126
+ return false;
127
+ }
128
+ }
76
129
  await model.mongo.updateOne(cache_store, condition, s, options);
130
+ return true;
77
131
  } catch (e) {
78
132
  model.log.error('Cache set:', e.message);
133
+ return false;
134
+ }
135
+ },
136
+
137
+ /**
138
+ * Remove every cached variant of the matching entries, all query strings and path params.
139
+ *
140
+ * NOTE: the invalidation is recorded before the entries are removed, a request that read before the
141
+ * change and stores after the removal is caught by set
142
+ *
143
+ * @param criteria {tenant, service, api, user} api and user are optional, without them every api of
144
+ * the service, for every user and the tenant scoped entries
145
+ * @returns {Promise<number>} the number of entries removed
146
+ */
147
+ 'invalidateMatching': async (criteria) => {
148
+ let marker = {
149
+ 'l1': criteria.tenant,
150
+ 'service': criteria.service,
151
+ 'api': criteria.api || null,
152
+ 'user': criteria.user || null
153
+ };
154
+ let now = new Date();
155
+ await model.mongo.updateOne(cache_invalidation, marker, {
156
+ '$set': Object.assign({}, marker, {
157
+ 'clearedAt': now,
158
+ 'expiresAt': new Date(now.getTime() + INVALIDATION_RETENTION)
159
+ })
160
+ }, { 'upsert': true });
161
+
162
+ let condition = {
163
+ 'l1': criteria.tenant,
164
+ 'service': criteria.service
165
+ };
166
+ if (criteria.api) {
167
+ condition.api = criteria.api;
168
+ }
169
+ if (criteria.user) {
170
+ condition.user = criteria.user;
79
171
  }
172
+ let response = await model.mongo.deleteMany(cache_store, condition);
173
+ return (response && response.deletedCount) || 0;
80
174
  },
81
175
 
82
176
  'invalidate': async (key) => {
@@ -11,6 +11,7 @@
11
11
  const get = (p, o) => p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o);
12
12
 
13
13
  const { httpRequestLight, proxyRequestMonitor } = require("../../lib/request.js");
14
+ const idempotency_mw = require("../idempotency/index.js");
14
15
 
15
16
  // Monitor buffer for batch uploads
16
17
  let monitorBuffer = [];
@@ -395,6 +396,11 @@ module.exports = (configuration) => {
395
396
  };
396
397
  }
397
398
 
399
+ //NOTE: from here the outcome of the service decides the key, the release on finish in the
400
+ // idempotency middleware stands down
401
+ if (idempotencyContext) {
402
+ idempotencyContext.proxied = true;
403
+ }
398
404
  proxyRequestMonitor(req, res, requestOptions, extraOptions)
399
405
  .then(() => {
400
406
  // Save to cache if applicable
@@ -405,17 +411,23 @@ module.exports = (configuration) => {
405
411
  headers: cacheResponseHeaders,
406
412
  body: cacheBody
407
413
  };
408
- cacheContext.model.set(cacheContext.key, cacheData, cacheContext.ttl);
414
+ cacheContext.model.set(cacheContext.key, cacheData, cacheContext.ttl, cacheContext.meta);
409
415
  }
410
416
  // Complete idempotency if applicable
417
+ //NOTE: only an answer of the service is replayed, a 5xx means it could not answer, release
418
+ // the key so the retry reaches the service again
411
419
  if (idempotencyContext) {
412
- let idempotencyBody = Buffer.concat(idempotencyResponseChunks).toString('utf8');
413
- let idempotencyData = {
414
- statusCode: idempotencyResponseStatusCode,
415
- headers: idempotencyResponseHeaders,
416
- body: idempotencyBody
417
- };
418
- idempotencyContext.model.complete(idempotencyContext.key, idempotencyData, idempotencyContext.ttl);
420
+ if (idempotencyResponseStatusCode < 500) {
421
+ let idempotencyBody = Buffer.concat(idempotencyResponseChunks).toString('utf8');
422
+ let idempotencyData = {
423
+ statusCode: idempotencyResponseStatusCode,
424
+ headers: idempotencyResponseHeaders,
425
+ body: idempotencyBody
426
+ };
427
+ idempotencyContext.model.complete(idempotencyContext.key, idempotencyData, idempotencyContext.ttl);
428
+ } else {
429
+ idempotency_mw.unlock(idempotencyContext.model, idempotencyContext.key, idempotencyContext.lockId, req.soajs.log);
430
+ }
419
431
  }
420
432
  if (monitor && !monitor_service_blacklist && monitor.req_response) {
421
433
  monitoObj.time.res_end = new Date().getTime();
@@ -430,7 +442,7 @@ module.exports = (configuration) => {
430
442
  }
431
443
  // Unlock idempotency on error
432
444
  if (idempotencyContext) {
433
- idempotencyContext.model.unlock(idempotencyContext.key);
445
+ idempotency_mw.unlock(idempotencyContext.model, idempotencyContext.key, idempotencyContext.lockId, req.soajs.log);
434
446
  }
435
447
  if (!req.soajs.controller.monitorEndingReq) {
436
448
  req.soajs.controllerResponse(core.error.getError(135));
@@ -8,6 +8,7 @@
8
8
  * found in the LICENSE file at the root of this repository
9
9
  */
10
10
 
11
+ const crypto = require('crypto');
11
12
  const get = (p, o) => p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o);
12
13
  const memory = require('./model/memory.js');
13
14
  const mongo = require('./model/mongo.js');
@@ -56,6 +57,21 @@ function matchesApi(method, path, apis) {
56
57
  return false;
57
58
  }
58
59
 
60
+ /**
61
+ * Release a key this request locked, only while it is still in flight and still ours
62
+ * @param modelObj memory or mongo
63
+ * @param key
64
+ * @param lockId
65
+ * @param log
66
+ */
67
+ function unlock(modelObj, key, lockId, log) {
68
+ Promise.resolve()
69
+ .then(() => modelObj.unlock(key, lockId))
70
+ .catch((err) => {
71
+ log.error('Idempotency unlock:', err.message);
72
+ });
73
+ }
74
+
59
75
  module.exports = function (configuration) {
60
76
  model.memory.init(configuration.log);
61
77
  model.mongo.init(configuration.log, configuration.gatewayDB);
@@ -131,6 +147,7 @@ module.exports = function (configuration) {
131
147
  'l2': idempotencyKey
132
148
  };
133
149
 
150
+ let lockId = null;
134
151
  const processIdempotency = async () => {
135
152
  try {
136
153
  const existing = await model[modelType].get(key);
@@ -152,7 +169,8 @@ module.exports = function (configuration) {
152
169
  }
153
170
  }
154
171
 
155
- const locked = await model[modelType].lock(key, ttl);
172
+ lockId = crypto.randomUUID();
173
+ const locked = await model[modelType].lock(key, ttl, lockId);
156
174
  if (!locked) {
157
175
  req.soajs.controllerResponse({
158
176
  'status': 409,
@@ -163,18 +181,37 @@ module.exports = function (configuration) {
163
181
  }
164
182
 
165
183
  // Store idempotency context for gotoService to use
166
- req.soajs.idempotencyContext = {
184
+ const context = {
167
185
  key: key,
168
186
  ttl: ttl,
187
+ lockId: lockId,
169
188
  modelType: modelType,
170
189
  model: model[modelType],
171
- log: configuration.log
190
+ log: configuration.log,
191
+ proxied: false
172
192
  };
193
+ req.soajs.idempotencyContext = context;
194
+
195
+ //NOTE: a request answered before it reaches the service, throttled, refused or dropped by
196
+ // the client, must not keep the key in flight until the ttl, release it once the response
197
+ // is over. A proxied request is completed or released by gotoService, never here.
198
+ let released = false;
199
+ const release = () => {
200
+ if (released || context.proxied) {
201
+ return;
202
+ }
203
+ released = true;
204
+ unlock(model[modelType], key, lockId, configuration.log);
205
+ };
206
+ res.on('finish', release);
207
+ res.on('close', release);
173
208
 
174
209
  next();
175
210
  } catch (err) {
176
211
  configuration.log.error('Idempotency error:', err);
177
- model[modelType].unlock(key).catch(() => {});
212
+ if (lockId) {
213
+ unlock(model[modelType], key, lockId, configuration.log);
214
+ }
178
215
  next();
179
216
  }
180
217
  };
@@ -185,3 +222,4 @@ module.exports = function (configuration) {
185
222
 
186
223
  // Export model for external use
187
224
  module.exports.model = model;
225
+ module.exports.unlock = unlock;