Skip to content

Commit fb28c34

Browse files
sitozzzdkoviazin
andauthored
feat(global)!: INFRA-791 KV storage adapter with keyPrefix (#5719)
* feat(global): INFRA-791 add script for redis key migration & custom redis adapter * fix(condo): INFRA-791 move redis rename script execution to the migration side * docs(global): INFRA-791 add migration guide for major versions * refactor(global): INFRA-791 extract keyPrefix from package name * refactor(keystone): INFRA-791 add ability to set redis prefix via env variable * fix(keystone): INFRA-791 make full copy of env inside redis test * fix(keystone): INFRA-791 add more validations for keyPrefix * fix(global): INFRA-791 persist redis migration inside migration & make prefix script more configurable * test(keystone): INFRA-791 add test for redis adapter fallback options * fix(keystone): INFRA-791 remove ability to set custom key via env * fix(condo): INFRA-791 rewrite rename logic * chore(condo): INFRA-791 rebase with origin & remove redis migration * fix(keystone): INFRA-791 use custom property for bull redis prefix * refactor(global): INFRA-791 remove redisIndexes from prepare script * fix(global): INFRA-791 properly set bull prefix * feat(global): INFRA-791 add option to use valkey cluster * fix(global): INFRA-791 run valkey for open-source part of ci * fix(global): INFRA-791 add ability to setup single node valkey * fix(keystone): INFRA-791 rename default redis specific envs * test(global): INFRA-791 legacy support * chore(global): INFRA-791 temp change ci target 2 current branch * fix(global): INFRA-791 config os related ci checks * fix(global): INFRA-791 prepare script issue * fix(global): INFRA-791 use global key-value adapter for session storage * fix(global): INFRA-791 wrap kv initial value with string * fix(global): INFRA-791 push valkey to own registry for tests * fix(global): INFRA-791 ci tag command typo * fix(global): INFRA-791 remove image push * fix(global): INFRA-791 add special option to setup kv cluster env * fix(global): INFRA-791 rollback ci target branch * chore(global): INFRA-791 rollback condo tests script setup * test(keystone): INFRA-791 ignore cluster specific tests * fix(condo): INFRA-791 hashtag replaceOrganizationEmployeeRole for cluster support * chore(global): INFRA-791 rebase with origin * fix(global): INFRA-791 oidc redis adapter cluster support * chore(global): INFRA-791 cleanup PR #1 * chore(global): INFRA-791 rollback bin/prepare cluster options * fix(keystone): INFRA-791 rename redis util usages after rebase * fix(global): INFRA-791 rollbacks & final review adjustments * test(keystone): INFRA-791 basic kv adapter tests --------- Co-authored-by: Dmitry Koviazin <[email protected]>
1 parent 3f59e8a commit fb28c34

File tree

14 files changed

+239
-47
lines changed

14 files changed

+239
-47
lines changed

Diff for: .github/workflows/_nodejs.condo.core.tests.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ jobs:
4343
env:
4444
REGISTRY: ${{ secrets.DOCKER_REGISTRY }}/doma/utils/
4545
run: |
46-
docker-compose up -d redis postgresdb-master postgresdb-replica
46+
docker-compose up -d redis postgresdb
4747
4848
- name: run tests
4949
run: |

Diff for: .github/workflows/nodejs.condo.ci.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -532,4 +532,4 @@ jobs:
532532
npm i -g turbo
533533
yarn install --immutable
534534
- name: Test webhooks utils
535-
run: yarn workspace @open-condo/webhooks jest
535+
run: yarn workspace @open-condo/webhooks jest

Diff for: README.md

+4
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ And then start it using:
159159
yarn workspace @app/condo worker
160160
```
161161

162+
## Major version migration guide
163+
164+
Check [migration.md](docs/migration.md)
165+
162166
## Developing
163167

164168
Check [developing.md](docs/develop.md)

Diff for: apps/condo/domains/miniapp/schema/B2BAccessToken.test.js

+9-10
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@
55
const { faker } = require('@faker-js/faker')
66
const dayjs = require('dayjs')
77
const { gql } = require('graphql-tag')
8-
const IORedis = require('ioredis')
98
const pick = require('lodash/pick')
109

11-
const conf = require('@open-condo/config')
10+
const { getKVClient } = require('@open-condo/keystone/kv')
1211
const {
1312
makeLoggedInAdminClient, makeClient, expectValuesOfCommonFields,
1413
expectToThrowAuthenticationErrorToObj, expectToThrowAuthenticationErrorToObjects,
@@ -411,7 +410,7 @@ describe('B2BAccessToken', () => {
411410
describe('Real-life cases', () => {
412411

413412
test('Can not update deleted access token to store it in redis', async () => {
414-
const redisClient = new IORedis(conf.REDIS_URL)
413+
const redisClient = getKVClient()
415414
const [createdToken] = await createTestB2BAccessTokenAdmin(admin, b2bAppContext, scopedRightSet)
416415
expect(createdToken).toBeDefined()
417416
expect(createdToken).toHaveProperty('sessionId')
@@ -465,9 +464,9 @@ describe('B2BAccessToken', () => {
465464
const [accessTokenByAdmin] = await B2BAccessTokenReadonlyAdmin.getAll(admin, { sessionId: accessToken.sessionId })
466465
expect(accessTokenByAdmin.id).toEqual(accessToken.id)
467466
})
468-
467+
469468
test('SessionId is encrypted', async () => {
470-
const redisClient = new IORedis(conf.REDIS_URL)
469+
const redisClient = getKVClient()
471470
const [createdToken] = await createTestB2BAccessTokenAdmin(admin, b2bAppContext, scopedRightSet)
472471
expect(createdToken).toHaveProperty('sessionId')
473472
const session = await redisClient.get(`sess:${createdToken.sessionId}`)
@@ -487,7 +486,7 @@ describe('B2BAccessToken', () => {
487486
await B2BAccessToken.getOne(client, { id: createdToken.id })
488487
}, ['objs', 0, 'token'])
489488
})
490-
489+
491490
describe('Token', () => {
492491

493492
let scopedRightSet
@@ -512,7 +511,7 @@ describe('B2BAccessToken', () => {
512511
await updateTestB2BAppAccessRightSet(support, globalRightSet.id, { deletedAt: null, canReadOrganizations: true })
513512
await updateTestB2BAppAccessRightSet(support, scopedRightSet.id, { deletedAt: null, canReadOrganizations: true })
514513
})
515-
514+
516515
describe('Authentication', () => {
517516

518517
test('Can\'t logout with token', async () => {
@@ -560,7 +559,7 @@ describe('B2BAccessToken', () => {
560559
})
561560

562561
})
563-
562+
564563
test('Can access only connected organization', async () => {
565564
const [accessToken] = await createTestB2BAccessToken(admin, b2bAppContext, scopedRightSet)
566565
const anonymous = await makeClient()
@@ -596,7 +595,7 @@ describe('B2BAccessToken', () => {
596595
const repeatOrganizations = await Organization.getAll(anonymous, {})
597596
expect(repeatOrganizations).toHaveLength(0)
598597
})
599-
598+
600599
describe('Deleting related objects', () => {
601600

602601
beforeEach(async () => {
@@ -619,7 +618,7 @@ describe('B2BAccessToken', () => {
619618
await Organization.getAll(anonymous, {})
620619
})
621620
})
622-
621+
623622
test('Deleting B2BAppContext leads to session removal', async () => {
624623
const [accessToken] = await createTestB2BAccessToken(admin, b2bAppContext, scopedRightSet)
625624
const anonymous = await makeClient()

Diff for: apps/condo/domains/organization/schema/ReplaceOrganizationEmployeeRoleService.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,8 @@ const ReplaceOrganizationEmployeeRoleService = new GQLCustomSchema('ReplaceOrgan
145145
let lock
146146

147147
try {
148-
const oldRoleLockKey = `replaceOrganizationEmployeeRole:${oldRoleId}`
149-
const newRoleLockKey = `replaceOrganizationEmployeeRole:${newRoleId}`
148+
const oldRoleLockKey = `{replaceOrganizationEmployeeRole}:${oldRoleId}`
149+
const newRoleLockKey = `{replaceOrganizationEmployeeRole}:${newRoleId}`
150150
lock = await rLock.acquire([oldRoleLockKey, newRoleLockKey], LOCK_DURATION_IN_SEC, {
151151
retryCount: 0,
152152
})

Diff for: apps/condo/domains/user/oidc/adapter/RedisAdapter.js

+11-7
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ class RedisAdapter {
9191
? { payload: JSON.stringify(payload) } : JSON.stringify(payload)
9292

9393
const multi = this.redis.multi()
94+
const multiGrant = this.redis.multi()
95+
const multiUser = this.redis.multi()
96+
const multiUid = this.redis.multi()
9497
multi[CONSUMABLE.has(this.name) ? 'hmset' : 'set'](key, store)
9598

9699
if (expiresIn) {
@@ -99,28 +102,29 @@ class RedisAdapter {
99102

100103
if (GRANTABLE.has(this.name) && payload.grantId) {
101104
const grantKey = grantKeyFor(payload.grantId)
102-
multi.rpush(grantKey, key)
105+
multiGrant.rpush(grantKey, key)
106+
103107
// if you're seeing grant key lists growing out of acceptable proportions consider using LTRIM
104108
// here to trim the list to an appropriate length
105109
const ttl = await this.redis.ttl(grantKey)
106110
if (expiresIn > ttl) {
107-
multi.expire(grantKey, expiresIn)
111+
multiGrant.expire(grantKey, expiresIn)
108112
}
109113
}
110114

111115
if (payload.userCode) {
112116
const userCodeKey = userCodeKeyFor(payload.userCode)
113-
multi.set(userCodeKey, id)
114-
multi.expire(userCodeKey, expiresIn)
117+
multiUser.set(userCodeKey, id)
118+
multiUser.expire(userCodeKey, expiresIn)
115119
}
116120

117121
if (payload.uid) {
118122
const uidKey = uidKeyFor(payload.uid)
119-
multi.set(uidKey, id)
120-
multi.expire(uidKey, expiresIn)
123+
multiUid.set(uidKey, id)
124+
multiUid.expire(uidKey, expiresIn)
121125
}
122126

123-
await multi.exec()
127+
await Promise.all([multi.exec(), multiGrant.exec(), multiUser.exec(), multiUid.exec()])
124128
}
125129

126130
async revokeByGrantId (grantId) { // eslint-disable-line class-methods-use-this

Diff for: docs/migration.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -189,4 +189,4 @@ SET data_version 2
189189
will be running at the same time there's risk, that some information about tasks will be lost / corrupted.
190190
**So make sure to kill old apps first and start new ones right after**.
191191
2. New apps (3.x) must point to new redis instance, so make sure to change `KV_URL` / `REDIS_URL` accordingly
192-
7) Check everything working good. After that you can down your old Redis instance
192+
7) Check everything working good. After that you can down your old Redis instance

Diff for: package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767
"dependencies": {
6868
"@sentry/nextjs": "^6.19.7",
6969
"@sentry/node": "^7.102.1",
70-
"bull": "^4.8.4",
70+
"bull": "^4.16.5",
7171
"bull-repl": "^0.27.2",
7272
"commitlint-plugin-function-rules": "^1.3.2",
7373
"dd-trace": "4.46.0"

Diff for: packages/keystone/kv.js

+48-3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
const fs = require('fs')
2+
const path = require('path')
3+
const { fileURLToPath } = require('url')
4+
15
const IORedis = require('ioredis')
26

37
const conf = require('@open-condo/config')
@@ -7,15 +11,48 @@ const { getLogger } = require('./logging')
711
const KV_CLIENTS = {}
812
const logger = getLogger('kv')
913

14+
const getKVPrefix = () => {
15+
const toPath = urlOrPath => urlOrPath instanceof URL ? fileURLToPath(urlOrPath) : urlOrPath
16+
17+
const cwd = process.cwd()
18+
let stopAt = 'apps'
19+
let directory = path.resolve(toPath(cwd) ?? '')
20+
const { root } = path.parse(directory)
21+
stopAt = path.resolve(directory, toPath(stopAt) ?? root)
22+
let packageJsonPath
23+
24+
while (directory && directory !== stopAt && directory !== root) {
25+
packageJsonPath = path.isAbsolute('package.json') ? 'package.json' : path.join(directory, 'package.json')
26+
27+
try {
28+
const stats = fs.statSync(packageJsonPath, { throwIfNoEntry: false })
29+
if (stats?.isFile()) {
30+
break
31+
}
32+
} catch (e) { console.error(e) }
33+
34+
directory = path.dirname(directory)
35+
}
36+
37+
return require(packageJsonPath).name.split('/')
38+
.pop()
39+
.replace(/:/g, '')
40+
.replace(/-/g, '_').toLowerCase() + ':'
41+
}
42+
43+
const PREFIX = getKVPrefix()
44+
1045
/**
1146
* If you really need to use key-value storage client then you should use this function!
1247
*
1348
* @param {string} name -- name of key-value storage client or the task purpose (we can use a different KV_URL for each name)
1449
* @param {string} purpose -- regular / subscriber key-value storage client mode (read details: https://github.com/luin/ioredis#pubsub); you can also use it if you need a two kv client with different settings; For example, the Bull is required three different kv clients
15-
* @param {Object} opts -- key-value storage config to customize some client options; But please don't use it!
50+
* @param {Object} opts -- client config such as internal API for storages and custom @open-condo specific ones
51+
* @param {Object} opts.kvOptions -- key-value storage config to customize some client options; But please don't use it!
52+
* @param {boolean} opts.ignorePrefix -- special option for disable prepending app specific prefix to all key operations. Using this flag in the enabled state should only be done if you understand how the cluster works, as well as eliminating incompatibility with third-party libraries by setting your own prefix
1653
* @return {import('ioredis')}
1754
*/
18-
function getKVClient (name = 'default', purpose = 'regular', opts = {}) {
55+
function getKVClient (name = 'default', purpose = 'regular', opts = { kvOptions: {}, ignorePrefix: false }) {
1956
const clientKey = name + ':' + purpose
2057

2158
logger.info({ msg: 'getKVClient', clientKey, opts })
@@ -29,14 +66,21 @@ function getKVClient (name = 'default', purpose = 'regular', opts = {}) {
2966
const kvUrl = conf[kvEnvName] || conf[redisEnvName] || conf.KV_URL || conf.REDIS_URL
3067

3168
if (!kvUrl) throw new Error(`No KV_URL or REDIS_URL env! You need to set ${kvEnvName} / KV_URL / ${redisEnvName} / REDIS_URL env`)
69+
3270
// BUILD STEP! OR SOME CASE WITH REDIS_URL=undefined
3371
if (kvUrl === 'undefined') return undefined
34-
const client = new IORedis(kvUrl, { connectionName: clientKey, ...opts })
72+
73+
const clientOptions = { connectionName: clientKey, ...opts.kvOptions }
74+
if (!opts.ignorePrefix) clientOptions['keyPrefix'] = PREFIX
75+
76+
const client = new IORedis(kvUrl, clientOptions)
77+
3578
client.on('connect', () => logger.info({ msg: 'connect', clientKey }))
3679
client.on('close', () => logger.info({ msg: 'close', clientKey }))
3780
client.on('reconnecting', (waitTime) => logger.info({ msg: 'reconnecting', clientKey, waitTime }))
3881
client.on('error', (error) => logger.error({ msg: 'error', clientKey, error }))
3982
client.on('end', () => logger.error({ msg: 'end', clientKey }))
83+
4084
KV_CLIENTS[clientKey] = client
4185
}
4286

@@ -45,4 +89,5 @@ function getKVClient (name = 'default', purpose = 'regular', opts = {}) {
4589

4690
module.exports = {
4791
getKVClient,
92+
getKVPrefix,
4893
}

Diff for: packages/keystone/kv.spec.js

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
const Redis = require('ioredis')
2+
3+
const { getKVPrefix } = require('./kv')
4+
5+
describe('Key value adapter', () => {
6+
const OLD_ENV = JSON.parse(JSON.stringify(process.env))
7+
let client
8+
let nonPrefixedClient
9+
let moduleName
10+
11+
beforeAll(() => {
12+
process.env.REDIS_URL = 'redis://127.0.0.1:6379'
13+
nonPrefixedClient = new Redis(process.env.REDIS_URL)
14+
moduleName = require(process.cwd() + '/package.json').name.split('/').pop() + ':'
15+
16+
jest.resetModules()
17+
18+
const { getKVClient } = require('./kv')
19+
client = getKVClient('test')
20+
})
21+
22+
afterAll(async () => {
23+
await client.flushdb()
24+
await client.disconnect()
25+
await nonPrefixedClient.disconnect()
26+
})
27+
28+
afterAll(async () => {
29+
process.env = { ...OLD_ENV }
30+
})
31+
32+
test('prefix should be the name of root package json', () => {
33+
expect(getKVPrefix()).toEqual(moduleName)
34+
})
35+
36+
test('key-value keyPrefix should be module specific', () => {
37+
expect(client.options.keyPrefix).toMatch(moduleName)
38+
})
39+
40+
test('default key-value client set all keys with prefix', async () => {
41+
await client.set('test1', 'result1')
42+
const result = await client.get('test1')
43+
expect(result).toMatch('result1')
44+
45+
const result1 = await nonPrefixedClient.get('test1')
46+
expect(result1).toBeNull()
47+
48+
const result2 = await nonPrefixedClient.get(`${moduleName}test1`)
49+
expect(result2).toMatch(result)
50+
})
51+
52+
test('pipeline/multi operations should work as expected', async () => {
53+
const [[incrError, incrValue], [ttlError, ttlValue]] = await client
54+
.multi()
55+
.incrby('incrTest', 1)
56+
.ttl('incrTest')
57+
.exec()
58+
59+
expect(incrError).toBeNull()
60+
expect(ttlError).toBeNull()
61+
expect(incrValue).toEqual(1)
62+
expect(ttlValue).toEqual(-1)
63+
})
64+
65+
test('should work with oidc adapter', async () => {
66+
await client.rpush('testList', 1)
67+
const range = await client.lrange('testList', 0, -1)
68+
expect(range).toEqual(expect.arrayContaining(['1']))
69+
const multi = client.multi()
70+
await multi.rpush('testList', 2).expire('testList', 0).exec()
71+
72+
const expiredKey = await client.keys('testList')
73+
expect(expiredKey).toHaveLength(0)
74+
})
75+
})

0 commit comments

Comments
 (0)