Skip to content

Commit aab8d63

Browse files
Async local storage example as CLS replacement
1 parent a0b3038 commit aab8d63

4 files changed

Lines changed: 260 additions & 14 deletions

File tree

‎ASYNC_LOCAL_STORAGE_MIGRATION.md‎

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
# AsyncLocalStorage Migration Plan
2+
3+
This document outlines the minimal changes needed to switch from `cls-hooked` / `express-http-context` to Node.js built-in `AsyncLocalStorage`.
4+
5+
## Summary
6+
7+
- **Files to modify:** 2
8+
- **Lines changed:** ~20
9+
- **No changes needed to:** Connectors, controllers, handlers, resolvers, tests
10+
11+
---
12+
13+
## File 1: `src/util/cls.js`
14+
15+
### BEFORE (current):
16+
```javascript
17+
'use strict';
18+
19+
const httpContext = require('express-http-context');
20+
21+
const P = require('bluebird');
22+
const sequelize = require('sequelize');
23+
const clsBluebird = require('cls-bluebird');
24+
clsBluebird(httpContext.ns, P);
25+
sequelize.useCLS(httpContext.ns);
26+
27+
exports.middleware = function (req, res, next) {
28+
httpContext.set('req', req);
29+
next();
30+
};
31+
32+
exports.getReq = function () {
33+
return httpContext.get('req');
34+
};
35+
36+
exports.patchMiddleware = function (fn) {
37+
return function (req, res, next) {
38+
return fn(req, res, httpContext.ns.bind(next));
39+
};
40+
};
41+
```
42+
43+
### AFTER (AsyncLocalStorage):
44+
```javascript
45+
'use strict';
46+
47+
const { AsyncLocalStorage } = require('async_hooks');
48+
49+
const asyncLocalStorage = new AsyncLocalStorage();
50+
51+
// NOTE: Removed sequelize.useCLS() - not needed because we pass transactions explicitly
52+
// NOTE: Removed cls-bluebird - not needed with AsyncLocalStorage
53+
54+
exports.middleware = function (req, res, next) {
55+
asyncLocalStorage.run({ req }, next);
56+
};
57+
58+
exports.getReq = function () {
59+
const store = asyncLocalStorage.getStore();
60+
return store ? store.req : undefined;
61+
};
62+
63+
exports.patchMiddleware = function (fn) {
64+
// AsyncLocalStorage automatically propagates context - no manual binding needed
65+
return fn;
66+
};
67+
```
68+
69+
---
70+
71+
## File 2: `src/routes.js`
72+
73+
### BEFORE (current):
74+
```javascript
75+
// Line 8
76+
const httpContext = require('express-http-context');
77+
78+
// Lines 66-67
79+
app.use(httpContext.middleware);
80+
app.use(cls.middleware);
81+
```
82+
83+
### AFTER:
84+
```javascript
85+
// Line 8 - REMOVE THIS LINE
86+
// const httpContext = require('express-http-context');
87+
88+
// Lines 66-67 - REMOVE httpContext.middleware, keep cls.middleware
89+
// app.use(httpContext.middleware); // DELETE THIS LINE
90+
app.use(cls.middleware); // KEEP THIS LINE
91+
```
92+
93+
---
94+
95+
## Packages that can be removed from package.json (optional, can do later):
96+
97+
```json
98+
{
99+
"dependencies": {
100+
"cls-bluebird": "...", // Can remove
101+
"express-http-context": "..." // Can remove
102+
}
103+
}
104+
```
105+
106+
Note: Keep these packages for now until you verify everything works. Remove in a follow-up PR.
107+
108+
---
109+
110+
## Why this works:
111+
112+
1. **AsyncLocalStorage is built into Node.js** (since v12.17) - no external package needed
113+
2. **Works with native Promises** - compatible with axios
114+
3. **`cls.getReq()` still works** - same API, different implementation
115+
4. **Sequelize doesn't need CLS** - your code already passes `{transaction}` explicitly (see verification below)
116+
5. **No connector/controller changes needed** - they keep calling `this.getForwardedHeaders()` which calls `cls.getReq()` internally
117+
118+
---
119+
120+
## Verification: Sequelize transactions are passed explicitly
121+
122+
The `sequelize.useCLS()` feature auto-binds transactions to queries within a transaction callback. However, this codebase **does not rely on it** - all transactions are passed explicitly.
123+
124+
### Transaction blocks and their operations:
125+
126+
| Location | Operations | Transaction passed? |
127+
|----------|-----------|---------------------|
128+
| `exports.create` (L167) | `db.remediation.create`, `storeNewActions` | ✓ Yes |
129+
| `exports.patch` (L210) | `db.remediation.findOne`, `storeNewActions`, `.save` | ✓ Yes |
130+
| `exports.patchIssue` (L263) | `db.issue.findOne`, `.save`, `remediationUpdated` | ✓ Yes |
131+
| `findAndDestroy` (L313) | `findOne`, `.destroy`, `remediationUpdated` | ✓ Yes |
132+
| `findAllAndDestroy` (L334) | `findAll`, `.destroy`, `remediationUpdated` | ✓ Yes |
133+
134+
### Helper functions that receive transaction:
135+
136+
- `storeNewActions(remediation, add, transaction)` - all db operations pass `{transaction}`
137+
- `remediationUpdated(req, transaction)` - passes `{transaction}` to its update
138+
139+
### Operations outside transactions (no transaction needed):
140+
141+
These are single atomic operations that don't require transactional consistency:
142+
143+
- `insertRHCPlaybookRun` - single create
144+
- `insertDispatcherRuns` - single bulkCreate
145+
- `updateDispatcherRuns` - single update
146+
- `storePlaybookDefinition` - single create
147+
148+
### Files checked:
149+
150+
- `src/remediations/controller.write.js` - all transaction blocks verified
151+
- `src/remediations/remediations.queries.js` - no transactions (single operations)
152+
- `src/generator/generator.controller.js` - no transactions (single operations)
153+
- `src/admin/admin.controller.js` - no transactions
154+
155+
**Conclusion:** Removing `sequelize.useCLS()` is safe - it was never being relied upon
156+
157+
---
158+
159+
## Testing:
160+
161+
After making these changes:
162+
1. Run the test suite: `npm test`
163+
2. Verify HTTP context is available in connectors (headers are forwarded correctly)
164+
3. Verify database transactions still work (they use explicit `{transaction}` already)
165+
166+
---
167+
168+
## Comparison with explicit `req` passing approach:
169+
170+
| Approach | Files Changed | Complexity |
171+
|----------|---------------|------------|
172+
| AsyncLocalStorage | ~2 files | Simple |
173+
| Explicit `req` passing | ~50+ files | Complex but more explicit |
174+
175+
Both approaches work. AsyncLocalStorage is simpler but keeps implicit context.
176+
Explicit `req` passing is more work but results in cleaner architecture.

‎src/routes.js‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ const express = require('express');
55
const log = require('./util/log');
66
const pinoHttp = require('pino-http');
77
const prettyJson = require('./middleware/prettyJson');
8-
const httpContext = require('express-http-context');
98
const identity = require('./middleware/identity/impl');
109
const userIdentity = require('./middleware/identity/userIdentity');
1110
const identitySwitcher = require('./middleware/identity/switcher');
@@ -63,7 +62,6 @@ module.exports = async function (app) {
6362
});
6463
/*eslint-enable no-unused-vars*/
6564

66-
app.use(httpContext.middleware);
6765
app.use(cls.middleware);
6866
app.use(prettyJson);
6967

‎src/util/cls.js‎

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,18 @@
11
'use strict';
22

3-
const httpContext = require('express-http-context');
3+
const { AsyncLocalStorage } = require('async_hooks');
44

5-
const P = require('bluebird');
6-
const sequelize = require('sequelize');
7-
const clsBluebird = require('cls-bluebird');
8-
clsBluebird(httpContext.ns, P);
9-
sequelize.useCLS(httpContext.ns);
5+
const asyncLocalStorage = new AsyncLocalStorage();
106

117
exports.middleware = function (req, res, next) {
12-
httpContext.set('req', req);
13-
next();
8+
asyncLocalStorage.run({ req }, next);
149
};
1510

1611
exports.getReq = function () {
17-
return httpContext.get('req');
12+
const store = asyncLocalStorage.getStore();
13+
return store ? store.req : undefined;
1814
};
1915

2016
exports.patchMiddleware = function (fn) {
21-
return function (req, res, next) {
22-
return fn(req, res, httpContext.ns.bind(next));
23-
};
17+
return fn;
2418
};

‎src/util/cls.unit.js‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
'use strict';
2+
3+
const cls = require('./cls');
4+
5+
describe('AsyncLocalStorage context isolation', function () {
6+
test('maintains separate context for concurrent requests', async () => {
7+
const results = [];
8+
9+
// Simulate 3 concurrent requests with different identities
10+
const request1 = new Promise(resolve => {
11+
const mockReq1 = { id: 'request-1', identity: { user: 'alice' } };
12+
cls.middleware(mockReq1, {}, async () => {
13+
// Simulate async work
14+
await new Promise(r => setTimeout(r, 50));
15+
results.push({ expected: 'request-1', actual: cls.getReq().id });
16+
resolve();
17+
});
18+
});
19+
20+
const request2 = new Promise(resolve => {
21+
const mockReq2 = { id: 'request-2', identity: { user: 'bob' } };
22+
cls.middleware(mockReq2, {}, async () => {
23+
// Simulate async work (shorter delay)
24+
await new Promise(r => setTimeout(r, 20));
25+
results.push({ expected: 'request-2', actual: cls.getReq().id });
26+
resolve();
27+
});
28+
});
29+
30+
const request3 = new Promise(resolve => {
31+
const mockReq3 = { id: 'request-3', identity: { user: 'charlie' } };
32+
cls.middleware(mockReq3, {}, async () => {
33+
// Simulate async work (longest delay)
34+
await new Promise(r => setTimeout(r, 80));
35+
results.push({ expected: 'request-3', actual: cls.getReq().id });
36+
resolve();
37+
});
38+
});
39+
40+
await Promise.all([request1, request2, request3]);
41+
42+
// Each request should have received its own context, not another's
43+
results.forEach(result => {
44+
expect(result.actual).toBe(result.expected);
45+
});
46+
});
47+
48+
test('context is undefined outside middleware', () => {
49+
expect(cls.getReq()).toBeUndefined();
50+
});
51+
52+
test('nested async operations preserve context', async () => {
53+
const mockReq = { id: 'nested-test', data: 'test-value' };
54+
55+
await new Promise(resolve => {
56+
cls.middleware(mockReq, {}, async () => {
57+
// Level 1
58+
expect(cls.getReq().id).toBe('nested-test');
59+
60+
await Promise.all([
61+
// Level 2 - parallel promises
62+
(async () => {
63+
await new Promise(r => setTimeout(r, 10));
64+
expect(cls.getReq().id).toBe('nested-test');
65+
})(),
66+
(async () => {
67+
await new Promise(r => setTimeout(r, 20));
68+
expect(cls.getReq().id).toBe('nested-test');
69+
})()
70+
]);
71+
72+
// After parallel work
73+
expect(cls.getReq().id).toBe('nested-test');
74+
resolve();
75+
});
76+
});
77+
});
78+
});

0 commit comments

Comments
 (0)