soajs.controller 4.3.4 → 4.3.6
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/Gruntfile.js +1 -1
- package/docs/configuration-custom-registry.md +159 -9
- package/docs/middleware/cache.md +275 -0
- package/docs/middleware/idempotency.md +209 -0
- package/docs/request-flow.md +35 -4
- package/lib/request.js +5 -0
- package/mw/cache/index.js +175 -0
- package/mw/cache/model/memory.js +121 -0
- package/mw/cache/model/mongo.js +98 -0
- package/mw/gotoService/redirectToService.js +77 -16
- package/mw/idempotency/index.js +171 -0
- package/mw/idempotency/model/memory.js +132 -0
- package/mw/idempotency/model/mongo.js +116 -0
- package/mw/traffic/model/memory.js +4 -0
- package/package.json +1 -1
- package/server/controller.js +14 -0
- package/test/unit/index.js +4 -0
- package/test/unit/mw/cache/index.js +578 -0
- package/test/unit/mw/cache/memory.js +143 -0
- package/test/unit/mw/idempotency/index.js +354 -0
- package/test/unit/mw/idempotency/memory.js +151 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# Idempotency Middleware
|
|
2
|
+
|
|
3
|
+
**File:** `mw/idempotency/index.js`
|
|
4
|
+
|
|
5
|
+
**Order:** 17 (after MT middleware)
|
|
6
|
+
|
|
7
|
+
The Idempotency middleware prevents duplicate write operations by tracking requests via `Idempotency-Key` headers.
|
|
8
|
+
|
|
9
|
+
## Purpose
|
|
10
|
+
|
|
11
|
+
- Prevents duplicate side effects from retried requests
|
|
12
|
+
- Stores and replays responses for completed requests
|
|
13
|
+
- Returns 409 Conflict for in-flight duplicate requests
|
|
14
|
+
- Supports memory and MongoDB storage backends
|
|
15
|
+
|
|
16
|
+
## Flow
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
Incoming Request
|
|
20
|
+
│
|
|
21
|
+
▼
|
|
22
|
+
┌─────────────────────────┐
|
|
23
|
+
│ Check Idempotency-Key │ Header present?
|
|
24
|
+
└────────┬────────────────┘
|
|
25
|
+
│
|
|
26
|
+
├─── Missing ──▶ next() (backwards compatible)
|
|
27
|
+
│
|
|
28
|
+
▼
|
|
29
|
+
┌─────────────────────────┐
|
|
30
|
+
│ Validate UUID v4 │ Format check
|
|
31
|
+
└────────┬────────────────┘
|
|
32
|
+
│
|
|
33
|
+
├─── Invalid ──▶ 400 Bad Request (code: 180)
|
|
34
|
+
│
|
|
35
|
+
▼
|
|
36
|
+
┌─────────────────────────┐
|
|
37
|
+
│ Check Configuration │ Service enabled? API matches?
|
|
38
|
+
└────────┬────────────────┘
|
|
39
|
+
│
|
|
40
|
+
├─── Not configured ──▶ next()
|
|
41
|
+
│
|
|
42
|
+
▼
|
|
43
|
+
┌─────────────────────────┐
|
|
44
|
+
│ Lookup Existing Key │ Check store
|
|
45
|
+
└────────┬────────────────┘
|
|
46
|
+
│
|
|
47
|
+
├─── Found (completed) ──▶ Return cached response
|
|
48
|
+
│
|
|
49
|
+
├─── Found (in_flight) ──▶ 409 Conflict (code: 181)
|
|
50
|
+
│
|
|
51
|
+
└─── Not found ──▶ Lock key → Execute → Store response
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Configuration
|
|
55
|
+
|
|
56
|
+
**Path:** `registry.custom.gateway.value.idempotency`
|
|
57
|
+
|
|
58
|
+
```javascript
|
|
59
|
+
{
|
|
60
|
+
"idempotency": {
|
|
61
|
+
"model": "memory", // Storage: "memory" or "mongo"
|
|
62
|
+
"services": { // Service configurations
|
|
63
|
+
"av": { // Service name
|
|
64
|
+
"enabled": true, // Enable for this service
|
|
65
|
+
"ttl": 60000, // Time-to-live in ms (default: 60s)
|
|
66
|
+
"apis": [ // APIs to protect
|
|
67
|
+
"POST /calls",
|
|
68
|
+
"PUT /call/p2p/ended",
|
|
69
|
+
"PUT /call/p2p/rejected"
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
"payment": {
|
|
73
|
+
"enabled": true,
|
|
74
|
+
"ttl": 120000,
|
|
75
|
+
"apis": [
|
|
76
|
+
"POST /charge",
|
|
77
|
+
"POST /refund"
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Field | Type | Default | Description |
|
|
86
|
+
|-------|------|---------|-------------|
|
|
87
|
+
| `model` | string | `"memory"` | Storage backend: `"memory"` or `"mongo"` |
|
|
88
|
+
| `services.[name].enabled` | boolean | `false` | Enable idempotency for this service |
|
|
89
|
+
| `services.[name].ttl` | number | `60000` | Key expiration time in milliseconds |
|
|
90
|
+
| `services.[name].apis` | string[] | `[]` | API patterns to protect (e.g., `"POST /path"`) |
|
|
91
|
+
|
|
92
|
+
## API Pattern Matching
|
|
93
|
+
|
|
94
|
+
APIs are matched using `METHOD /path` format with support for path parameters:
|
|
95
|
+
|
|
96
|
+
```javascript
|
|
97
|
+
"apis": [
|
|
98
|
+
"POST /av/calls", // Exact match
|
|
99
|
+
"PUT /av/call/:id/ended", // :id matches any segment
|
|
100
|
+
"DELETE /users/:userId/posts/:postId" // Multiple params
|
|
101
|
+
]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Storage Backends
|
|
105
|
+
|
|
106
|
+
### Memory (Default)
|
|
107
|
+
|
|
108
|
+
- Fast, in-process storage
|
|
109
|
+
- Lost on restart
|
|
110
|
+
- Good for single-instance deployments
|
|
111
|
+
- Automatic cleanup of expired entries
|
|
112
|
+
|
|
113
|
+
### MongoDB
|
|
114
|
+
|
|
115
|
+
- Persistent storage
|
|
116
|
+
- Shared across instances
|
|
117
|
+
- Required for multi-instance deployments
|
|
118
|
+
- Uses TTL index for automatic expiration
|
|
119
|
+
|
|
120
|
+
## Client Usage
|
|
121
|
+
|
|
122
|
+
Clients must include the `Idempotency-Key` header with a UUID v4:
|
|
123
|
+
|
|
124
|
+
```http
|
|
125
|
+
POST /av/calls HTTP/1.1
|
|
126
|
+
Host: api.example.com
|
|
127
|
+
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
|
|
128
|
+
Content-Type: application/json
|
|
129
|
+
|
|
130
|
+
{"caller": "user1", "callee": "user2"}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Response Scenarios
|
|
134
|
+
|
|
135
|
+
### New Request (First Time)
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
Request processed normally, response cached
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Replay (Completed)
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
HTTP/1.1 200 OK
|
|
145
|
+
(Cached response body returned, no side effects)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Replay (In-Flight)
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
HTTP/1.1 409 Conflict
|
|
152
|
+
Content-Type: application/json
|
|
153
|
+
|
|
154
|
+
{
|
|
155
|
+
"result": false,
|
|
156
|
+
"errors": {
|
|
157
|
+
"codes": [181],
|
|
158
|
+
"details": [{
|
|
159
|
+
"code": 181,
|
|
160
|
+
"message": "Request with this Idempotency-Key is still being processed."
|
|
161
|
+
}]
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Invalid Key Format
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
HTTP/1.1 400 Bad Request
|
|
170
|
+
Content-Type: application/json
|
|
171
|
+
|
|
172
|
+
{
|
|
173
|
+
"result": false,
|
|
174
|
+
"errors": {
|
|
175
|
+
"codes": [180],
|
|
176
|
+
"details": [{
|
|
177
|
+
"code": 180,
|
|
178
|
+
"message": "Invalid Idempotency-Key format. Expected UUID v4."
|
|
179
|
+
}]
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Key Structure
|
|
185
|
+
|
|
186
|
+
```javascript
|
|
187
|
+
{
|
|
188
|
+
"l1": "tenant_id", // Tenant isolation
|
|
189
|
+
"l2": "idempotency_key" // Client-provided UUID
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Keys are isolated per tenant to prevent cross-tenant collisions.
|
|
194
|
+
|
|
195
|
+
## Error Codes
|
|
196
|
+
|
|
197
|
+
| Code | HTTP Status | Description |
|
|
198
|
+
|------|-------------|-------------|
|
|
199
|
+
| 180 | 400 | Invalid Idempotency-Key format (not UUID v4) |
|
|
200
|
+
| 181 | 409 | Request with this key is still being processed |
|
|
201
|
+
|
|
202
|
+
## Usage Notes
|
|
203
|
+
|
|
204
|
+
- Only applies to non-GET requests (POST, PUT, DELETE, PATCH)
|
|
205
|
+
- GET requests always pass through (idempotent by nature)
|
|
206
|
+
- Keys expire after TTL (default 60 seconds)
|
|
207
|
+
- Clients should generate a new UUID for each unique operation
|
|
208
|
+
- Same UUID = same operation (for retries)
|
|
209
|
+
- MongoDB backend required for horizontal scaling
|
package/docs/request-flow.md
CHANGED
|
@@ -47,13 +47,25 @@ The complete request lifecycle through the SOAJS Controller Gateway.
|
|
|
47
47
|
│ │
|
|
48
48
|
▼ │
|
|
49
49
|
┌──────────────┐ │
|
|
50
|
-
│ 7.
|
|
50
|
+
│ 7.Idempotency│ Check Idempotency-Key header │
|
|
51
|
+
│ Check │ Return cached response or lock key │
|
|
52
|
+
└──────┬───────┘ │
|
|
53
|
+
│ │
|
|
54
|
+
▼ │
|
|
55
|
+
┌──────────────┐ │
|
|
56
|
+
│ 8. Traffic │ Rate limiting with configurable │
|
|
51
57
|
│ Control │ strategies and storage backends │
|
|
58
|
+
└──────┬───────┘ │
|
|
59
|
+
│ │
|
|
60
|
+
▼ │
|
|
61
|
+
┌──────────────┐ │
|
|
62
|
+
│ 9. Response │ Check cache for GET requests │
|
|
63
|
+
│ Caching │ Return cached response or proceed │
|
|
52
64
|
└──────┬───────┘ │
|
|
53
65
|
│ │
|
|
54
66
|
▼ │
|
|
55
67
|
┌──────────────────────────────────────────────────────────────────┬──────┘
|
|
56
|
-
│
|
|
68
|
+
│ 10. PROXY TO SERVICE │
|
|
57
69
|
│ │
|
|
58
70
|
│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐ │
|
|
59
71
|
│ │ Resolve Host │───▶│ Build Request │───▶│ HTTP Proxy │ │
|
|
@@ -120,14 +132,33 @@ The complete request lifecycle through the SOAJS Controller Gateway.
|
|
|
120
132
|
- ACL enforcement
|
|
121
133
|
- OAuth token validation
|
|
122
134
|
|
|
123
|
-
### 7.
|
|
135
|
+
### 7. Idempotency Check
|
|
136
|
+
|
|
137
|
+
**Middleware:** `mw/idempotency/index.js`
|
|
138
|
+
|
|
139
|
+
- Checks for `Idempotency-Key` header (UUID v4)
|
|
140
|
+
- Returns cached response for completed requests
|
|
141
|
+
- Returns 409 Conflict for in-flight requests
|
|
142
|
+
- Locks key and proceeds for new requests
|
|
143
|
+
- Stores response after completion
|
|
144
|
+
|
|
145
|
+
### 8. Traffic Control
|
|
124
146
|
|
|
125
147
|
**Middleware:** `mw/traffic/index.js`
|
|
126
148
|
|
|
127
149
|
- Rate limiting per strategy
|
|
128
150
|
- Response headers (X-RateLimit-*)
|
|
129
151
|
|
|
130
|
-
###
|
|
152
|
+
### 9. Response Caching
|
|
153
|
+
|
|
154
|
+
**Middleware:** `mw/cache/index.js`
|
|
155
|
+
|
|
156
|
+
- Caches GET API responses
|
|
157
|
+
- Returns cached response on cache hit
|
|
158
|
+
- Adds X-Cache and X-Cache-Age headers
|
|
159
|
+
- Stores response after backend call
|
|
160
|
+
|
|
161
|
+
### 10. Proxy to Service
|
|
131
162
|
|
|
132
163
|
**Middleware:** `mw/gotoService/*`
|
|
133
164
|
|
package/lib/request.js
CHANGED
|
@@ -409,6 +409,11 @@ function proxyRequestMonitor(req, res, options, extraOptions = {}) {
|
|
|
409
409
|
}
|
|
410
410
|
let resContentType = proxyRes.headers['content-type'];
|
|
411
411
|
|
|
412
|
+
// Emit statusCode event for caching
|
|
413
|
+
if (extraOptions && extraOptions.events && extraOptions.events.statusCode) {
|
|
414
|
+
extraOptions.events.statusCode(proxyRes.statusCode, proxyRes.headers);
|
|
415
|
+
}
|
|
416
|
+
|
|
412
417
|
if (extraOptions && extraOptions.events && extraOptions.events.response) {
|
|
413
418
|
extraOptions.events.response(resContentType);
|
|
414
419
|
}
|
|
@@ -0,0 +1,175 @@
|
|
|
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 crypto = require('crypto');
|
|
12
|
+
const get = (p, o) => p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o);
|
|
13
|
+
const memory = require('./model/memory.js');
|
|
14
|
+
const mongo = require('./model/mongo.js');
|
|
15
|
+
|
|
16
|
+
let model = { memory, mongo };
|
|
17
|
+
|
|
18
|
+
function hashQuery(query) {
|
|
19
|
+
if (!query || Object.keys(query).length === 0) {
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
const sortedQuery = Object.keys(query).sort().reduce((obj, key) => {
|
|
23
|
+
obj[key] = query[key];
|
|
24
|
+
return obj;
|
|
25
|
+
}, {});
|
|
26
|
+
return crypto.createHash('md5').update(JSON.stringify(sortedQuery)).digest('hex');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function matchesApi(method, path, apis) {
|
|
30
|
+
if (!apis || typeof apis !== 'object') {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const requestSignature = `${method} ${path}`;
|
|
34
|
+
|
|
35
|
+
if (apis[requestSignature]) {
|
|
36
|
+
return apis[requestSignature];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const api in apis) {
|
|
40
|
+
if (apis.hasOwnProperty(api)) {
|
|
41
|
+
const [apiMethod, apiPath] = api.split(' ');
|
|
42
|
+
if (apiMethod !== method) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const apiParts = apiPath.split('/');
|
|
46
|
+
const pathParts = path.split('/');
|
|
47
|
+
if (apiParts.length !== pathParts.length) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
let matches = true;
|
|
51
|
+
for (let i = 0; i < apiParts.length; i++) {
|
|
52
|
+
if (apiParts[i].startsWith(':')) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (apiParts[i] !== pathParts[i]) {
|
|
56
|
+
matches = false;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (matches) {
|
|
61
|
+
return apis[api];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Export model for use by gotoService
|
|
69
|
+
module.exports = function (configuration) {
|
|
70
|
+
model.memory.init(configuration.log);
|
|
71
|
+
model.mongo.init(configuration.log, configuration.gatewayDB);
|
|
72
|
+
|
|
73
|
+
return (req, res, next) => {
|
|
74
|
+
if (req.method.toUpperCase() !== 'GET') {
|
|
75
|
+
return next();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!req.soajs || !req.soajs.tenant || !req.soajs.controller || !req.soajs.controller.serviceParams) {
|
|
79
|
+
return next();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const serviceName = req.soajs.controller.serviceParams.name;
|
|
83
|
+
// Strip service prefix from pathname to get API path (e.g., /marketplace/items -> /items)
|
|
84
|
+
const serviceInfo = req.soajs.controller.serviceParams.serviceInfo;
|
|
85
|
+
const apiPath = "/" + serviceInfo.slice(2).join("/");
|
|
86
|
+
const method = req.method.toUpperCase();
|
|
87
|
+
|
|
88
|
+
const cacheConfig = get(["soajs", "registry", "custom", "gateway", "value", "cache"], req);
|
|
89
|
+
if (!cacheConfig) {
|
|
90
|
+
return next();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const serviceConfig = cacheConfig.services && cacheConfig.services[serviceName];
|
|
94
|
+
if (!serviceConfig || !serviceConfig.enabled) {
|
|
95
|
+
return next();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const apiConfig = matchesApi(method, apiPath, serviceConfig.apis);
|
|
99
|
+
if (!apiConfig || !apiConfig.enabled) {
|
|
100
|
+
return next();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const modelType = cacheConfig.model || 'memory';
|
|
104
|
+
if (!model[modelType]) {
|
|
105
|
+
configuration.log.warn('Cache: Unknown model [' + modelType + ']. It can only be [memory || mongo]');
|
|
106
|
+
return next();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Determine caching scope.
|
|
110
|
+
// Public APIs (no access_token required) return the same response for the whole
|
|
111
|
+
// tenant, so they are cached per tenant ("tenant"). Private APIs may return a
|
|
112
|
+
// different response per logged-in user, so they must be cached per tenant + user
|
|
113
|
+
// ("tenant_user") to avoid serving one user's response to another.
|
|
114
|
+
// Scope can be overridden per API in the custom registry via apiConfig.scope.
|
|
115
|
+
// Default: public -> "tenant", private -> "tenant_user".
|
|
116
|
+
const isAPIPublic = req.soajs.controller.serviceParams.isAPIPublic;
|
|
117
|
+
const userId = req.soajs.uracDriver && req.soajs.uracDriver.id;
|
|
118
|
+
|
|
119
|
+
let scope = apiConfig.scope;
|
|
120
|
+
if (scope !== 'tenant' && scope !== 'tenant_user') {
|
|
121
|
+
if (scope) {
|
|
122
|
+
configuration.log.warn('Cache: Unknown scope [' + scope + '] for API [' + method + ' ' + apiPath + ']. It can only be [tenant || tenant_user]. Falling back to default.');
|
|
123
|
+
}
|
|
124
|
+
scope = isAPIPublic ? 'tenant' : 'tenant_user';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Safety: a user-scoped entry without a resolved user would leak across users.
|
|
128
|
+
// Skip caching rather than risk serving the wrong user's response.
|
|
129
|
+
if (scope === 'tenant_user' && !userId) {
|
|
130
|
+
return next();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const ttl = apiConfig.ttl || cacheConfig.defaultTTL || 300000;
|
|
134
|
+
const queryHash = hashQuery(req.query);
|
|
135
|
+
const userPart = (scope === 'tenant_user') ? `:u:${userId}` : '';
|
|
136
|
+
const key = {
|
|
137
|
+
'l1': req.soajs.tenant.id,
|
|
138
|
+
'l2': `${serviceName}:GET:${apiPath}:${queryHash}${userPart}`
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const processCache = async () => {
|
|
142
|
+
try {
|
|
143
|
+
const cached = await model[modelType].get(key);
|
|
144
|
+
|
|
145
|
+
if (cached && cached.response) {
|
|
146
|
+
res.setHeader('X-Cache', 'HIT');
|
|
147
|
+
res.setHeader('X-Cache-Age', Math.floor((Date.now() - cached.cachedAt) / 1000));
|
|
148
|
+
res.writeHead(cached.response.statusCode, cached.response.headers);
|
|
149
|
+
res.end(cached.response.body);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Store cache context for gotoService to use
|
|
154
|
+
req.soajs.cacheContext = {
|
|
155
|
+
key: key,
|
|
156
|
+
ttl: ttl,
|
|
157
|
+
modelType: modelType,
|
|
158
|
+
model: model[modelType],
|
|
159
|
+
log: configuration.log
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
res.setHeader('X-Cache', 'MISS');
|
|
163
|
+
next();
|
|
164
|
+
} catch (err) {
|
|
165
|
+
configuration.log.error('Cache error:', err);
|
|
166
|
+
next();
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
processCache();
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Export model for external use
|
|
175
|
+
module.exports.model = model;
|
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
let dataHolder = {};
|
|
12
|
+
let cleanupInterval = null;
|
|
13
|
+
|
|
14
|
+
let model = {
|
|
15
|
+
'log': null,
|
|
16
|
+
|
|
17
|
+
'init': (log) => {
|
|
18
|
+
model.log = log;
|
|
19
|
+
dataHolder = {};
|
|
20
|
+
|
|
21
|
+
if (cleanupInterval) {
|
|
22
|
+
clearInterval(cleanupInterval);
|
|
23
|
+
}
|
|
24
|
+
const CLEANUP_INTERVAL = process.env.SOAJS_CACHE_CLEANUP_INTERVAL || 60000;
|
|
25
|
+
cleanupInterval = setInterval(() => {
|
|
26
|
+
model.cleanupExpiredEntries();
|
|
27
|
+
}, CLEANUP_INTERVAL);
|
|
28
|
+
// Do not keep the event loop alive solely for the cleanup timer.
|
|
29
|
+
if (cleanupInterval.unref) {
|
|
30
|
+
cleanupInterval.unref();
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
'cleanupExpiredEntries': () => {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
let removedCount = 0;
|
|
37
|
+
|
|
38
|
+
for (const l1Key in dataHolder) {
|
|
39
|
+
if (dataHolder.hasOwnProperty(l1Key)) {
|
|
40
|
+
const tenantData = dataHolder[l1Key];
|
|
41
|
+
|
|
42
|
+
for (const l2Key in tenantData) {
|
|
43
|
+
if (tenantData.hasOwnProperty(l2Key)) {
|
|
44
|
+
const entry = tenantData[l2Key];
|
|
45
|
+
if (entry.expiresAt && now > entry.expiresAt) {
|
|
46
|
+
delete tenantData[l2Key];
|
|
47
|
+
removedCount++;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (Object.keys(tenantData).length === 0) {
|
|
53
|
+
delete dataHolder[l1Key];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (removedCount > 0 && model.log) {
|
|
59
|
+
model.log.debug(`Cache cleanup: removed ${removedCount} expired entries`);
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
'get': (key) => {
|
|
64
|
+
if (!dataHolder[key.l1]) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const entry = dataHolder[key.l1][key.l2];
|
|
68
|
+
if (!entry) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
|
72
|
+
delete dataHolder[key.l1][key.l2];
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return entry;
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
'set': (key, response, ttl) => {
|
|
79
|
+
if (!dataHolder[key.l1]) {
|
|
80
|
+
dataHolder[key.l1] = {};
|
|
81
|
+
}
|
|
82
|
+
dataHolder[key.l1][key.l2] = {
|
|
83
|
+
'response': response,
|
|
84
|
+
'cachedAt': Date.now(),
|
|
85
|
+
'expiresAt': Date.now() + ttl
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
'invalidate': (key) => {
|
|
90
|
+
if (dataHolder[key.l1] && dataHolder[key.l1][key.l2]) {
|
|
91
|
+
delete dataHolder[key.l1][key.l2];
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
'invalidateTenant': (tenantId) => {
|
|
96
|
+
if (dataHolder[tenantId]) {
|
|
97
|
+
delete dataHolder[tenantId];
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
'shutdown': () => {
|
|
102
|
+
if (cleanupInterval) {
|
|
103
|
+
clearInterval(cleanupInterval);
|
|
104
|
+
cleanupInterval = null;
|
|
105
|
+
}
|
|
106
|
+
dataHolder = {};
|
|
107
|
+
if (model.log) {
|
|
108
|
+
model.log.info('Cache memory model shutdown complete');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
if (typeof process !== 'undefined') {
|
|
114
|
+
const shutdownHandler = () => {
|
|
115
|
+
model.shutdown();
|
|
116
|
+
};
|
|
117
|
+
process.once('SIGTERM', shutdownHandler);
|
|
118
|
+
process.once('SIGINT', shutdownHandler);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = model;
|
|
@@ -0,0 +1,98 @@
|
|
|
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 core = require('soajs.core.modules');
|
|
12
|
+
const Mongo = core.mongo;
|
|
13
|
+
|
|
14
|
+
const cache_store = 'cache_store';
|
|
15
|
+
|
|
16
|
+
let model = {
|
|
17
|
+
'mongo': null,
|
|
18
|
+
'log': null,
|
|
19
|
+
|
|
20
|
+
'init': (log, dbConfiguration) => {
|
|
21
|
+
model.log = log;
|
|
22
|
+
if (!model.mongo) {
|
|
23
|
+
model.mongo = new Mongo(dbConfiguration);
|
|
24
|
+
model.mongo.createIndex(cache_store, { 'l1': 1, 'l2': 1 }, { unique: true }, (err, index) => {
|
|
25
|
+
log.debug("Cache index: " + index + " created with error: " + err);
|
|
26
|
+
});
|
|
27
|
+
model.mongo.createIndex(cache_store, { 'expiresAt': 1 }, { expireAfterSeconds: 0 }, (err, index) => {
|
|
28
|
+
log.debug("Cache TTL index: " + index + " created with error: " + err);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
'get': async (key) => {
|
|
34
|
+
let condition = {
|
|
35
|
+
'l1': key.l1,
|
|
36
|
+
'l2': key.l2
|
|
37
|
+
};
|
|
38
|
+
try {
|
|
39
|
+
const entry = await model.mongo.findOne(cache_store, condition);
|
|
40
|
+
if (!entry) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
if (entry.expiresAt && Date.now() > entry.expiresAt.getTime()) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return entry;
|
|
47
|
+
} catch (e) {
|
|
48
|
+
model.log.error('Cache get:', e.message);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
'set': async (key, response, ttl) => {
|
|
54
|
+
let condition = {
|
|
55
|
+
'l1': key.l1,
|
|
56
|
+
'l2': key.l2
|
|
57
|
+
};
|
|
58
|
+
let doc = {
|
|
59
|
+
'l1': key.l1,
|
|
60
|
+
'l2': key.l2,
|
|
61
|
+
'response': response,
|
|
62
|
+
'cachedAt': new Date(),
|
|
63
|
+
'expiresAt': new Date(Date.now() + ttl)
|
|
64
|
+
};
|
|
65
|
+
let options = { 'upsert': true };
|
|
66
|
+
let s = { '$set': doc };
|
|
67
|
+
try {
|
|
68
|
+
await model.mongo.updateOne(cache_store, condition, s, options);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
model.log.error('Cache set:', e.message);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
'invalidate': async (key) => {
|
|
75
|
+
let condition = {
|
|
76
|
+
'l1': key.l1,
|
|
77
|
+
'l2': key.l2
|
|
78
|
+
};
|
|
79
|
+
try {
|
|
80
|
+
await model.mongo.deleteOne(cache_store, condition);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
model.log.error('Cache invalidate:', e.message);
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
'invalidateTenant': async (tenantId) => {
|
|
87
|
+
let condition = {
|
|
88
|
+
'l1': tenantId
|
|
89
|
+
};
|
|
90
|
+
try {
|
|
91
|
+
await model.mongo.deleteMany(cache_store, condition);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
model.log.error('Cache invalidateTenant:', e.message);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
module.exports = model;
|