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.
- package/.github/workflows/ci.yml +4 -1
- package/.github/workflows/release.yml +4 -1
- package/docs/maintenance-routes.md +31 -0
- package/docs/middleware/cache.md +74 -1
- package/docs/middleware/idempotency.md +31 -2
- package/mw/cache/index.js +24 -5
- package/mw/cache/invalidate.js +71 -0
- package/mw/cache/model/mongo.js +95 -1
- package/mw/gotoService/redirectToService.js +21 -9
- package/mw/idempotency/index.js +42 -4
- package/mw/idempotency/model/memory.js +12 -2
- package/mw/idempotency/model/mongo.js +10 -3
- package/package.json +1 -1
- package/server/maintenance.js +41 -0
- package/test/data/provision/customRegistry/gateway.js +47 -0
- package/test/data/provision/environment/dev.js +9 -0
- package/test/data/provision/hosts/idempotent.js +12 -0
- package/test/data/provision/hosts/spaces.js +12 -0
- package/test/data/provision/marketplace/idempotent.js +37 -0
- package/test/data/provision/marketplace/spaces.js +36 -0
- package/test/data/provision/oauth/token/second_owner_token.js +72 -0
- package/test/data/provision/products/dsbrd.js +49 -0
- package/test/data/provision/urac/users/second_owner.js +61 -0
- package/test/integration/UC6-idempotency/index.js +197 -0
- package/test/integration/UC7-cache-invalidate/index.js +209 -0
- package/test/integration/index.js +8 -0
- package/test/unit/index.js +3 -0
- package/test/unit/mw/cache/index.js +122 -0
- package/test/unit/mw/cache/invalidate.js +99 -0
- package/test/unit/mw/cache/mongo.js +104 -0
- package/test/unit/mw/idempotency/index.js +122 -0
- package/test/unit/mw/idempotency/memory.js +36 -4
- package/test/unit/mw/idempotency/mongo.js +62 -0
package/.github/workflows/ci.yml
CHANGED
|
@@ -17,7 +17,10 @@ jobs:
|
|
|
17
17
|
|
|
18
18
|
services:
|
|
19
19
|
mongodb:
|
|
20
|
-
|
|
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
|
-
|
|
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
|
package/docs/middleware/cache.md
CHANGED
|
@@ -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,
|
|
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
|
|
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 =
|
|
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
|
|
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;
|
package/mw/cache/model/mongo.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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.
|
|
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));
|
package/mw/idempotency/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|