Skip to content

Commit 8f8f825

Browse files
committed
feat: delete inactive users and index
1 parent ad65a3d commit 8f8f825

File tree

10 files changed

+547
-39
lines changed

10 files changed

+547
-39
lines changed
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Inactive user cleanup
2+
3+
## Overview and motivation
4+
For users who didn't pass the activation process, the service using TTL keyspace cleanup and only `Internal Data` deleted.
5+
A lot of linked keys remain in the database, and this leads to keyspace pollution.
6+
For better data handling and clean database structure, I introduce some changes in service logic:
7+
8+
## General defs
9+
- `inactive-users`
10+
Redis database sorted set key. Assigned to `USER_ACTIVATE` constant.
11+
Record contains `userId` as value and `timestamp` as score.
12+
- `user-audiences` [Described here](update_metadata_lua.md#audience-list-update)
13+
- `deleteInactivatedUsers` Redis script, handling all cleanup logic.
14+
15+
## Organization Members
16+
The `organization add member` process doesn't have validation whether the user passed activation and allows
17+
inviting inactive users into an organization. The script checks whether inactivated user assigned to any organization
18+
and deletes users from organization members and user->organization bindings.
19+
20+
## Registration and activation
21+
Every Activation and Registration request event executes `users:cleanup` hook.
22+
The Activation request executes the hook first this strategy saves from inactive
23+
users that hit TTL but tried to pass the activation process.
24+
The Registration request executes the hook after `username` exists check.
25+
26+
## Registration process
27+
When the user succeeds registration but activation not requested, the new entry added to `inactive-users`.
28+
Record contains `userId` and `current timestamp`.
29+
30+
## Activation process
31+
When the user succeeds activation `userId`,the entry deleted from `inactive-users`.
32+
33+
## `users:cleanup` hook `cleanUsers(suppress?)`
34+
`suppress` parameter defines function error behavior. If parameter set, the function throws errors,
35+
otherwise, function calls `log.error` with `error.message` as message.
36+
Default value is `true`. IMHO User shouldn't know about our problems.
37+
38+
Other option, is to define new config parameter as object and move `config.deleteInactiveAccounts` into it:
39+
```javascript
40+
const conf = {
41+
deleteInactiveUsers: {
42+
ttl: seconds, // replaces deleteInactiveAccounts
43+
suppressErrors: true || false,
44+
},
45+
}
46+
```
47+
Calls `deleteInactivatedUsers` script with TTL parameter from `service.config.deleteInactiveAccounts`.
48+
When script finished, calls TokenManager to delete Action tokens(`USER_ACTION_*`, ``).
49+
*NOTE*: Should we update TokenManager to add support of pipeline?
50+
51+
## Redis Delete Inactive User Script
52+
When the service connects to the Redis server and fires event "plugin:connect:$redisType" `utils/inactive/defineCommand.js` executed.
53+
Function rendering `deleteInactivatedUsers.lua.hbs` and evals resulting script into IORedis.
54+
The Script using a dozen constants, keys, and templates, so all these values rendered inside of the template using template context.
55+
Returns list of deleted users.
56+
57+
*NOTE*: Using experimental `fs.promises.readFile` API function. On `node` 10.x it's an experimental feature,
58+
on `node` >= 11.x function becomes stable without any changes in API.
59+
60+
#### deleteInactivatedUsers `USERS_ACTIVATED` `TTL` as seconds
61+
##### Script paramerters:
62+
1. KEYS[1] Sorted Set name containing the list of users that didn't pass activation.
63+
2. ARGS[1] TTL in seconds.
64+
65+
##### When started:
66+
1. Gets UserId's from ZSET `USERS_ACTIVATED` where score < `now() - TTL * 1000` and iterates over them.
67+
2. Gets dependent userData such as username, alias, and SSO provider information used in delete procedure and calls [Delete process](#delete-process).
68+
3. Deletes processed user ids from `USER_ACTIVATED` key.
69+
70+
##### Delete process
71+
The main logic is based on `actions/removeUsers.js`.
72+
Using the username, id, alias and SSO providers fields, script checks and removes dependent data from the database:
73+
* Alias to id binding.
74+
* Username to id binding.
75+
* All assigned metadata. Key names rendered from the template and `user-audiences`.
76+
* SSO provider to id binding. Using `SSO_PROVIDERS` items as the field name decodes and extracts UID's from Internal data.
77+
* User tokens.
78+
* Private and public id indexes
79+
* Links and data used in Organization assignment
80+

src/actions/activate.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const jwt = require('../utils/jwt.js');
55
const { getInternalData } = require('../utils/userData');
66
const getMetadata = require('../utils/getMetadata');
77
const handlePipeline = require('../utils/pipelineError.js');
8-
const { cleanInactiveUsersIndex } = require('../utils/inactiveUsers');
8+
const InactiveUser = require('../utils/inactive-user');
99

1010
const {
1111
USERS_INDEX,
@@ -183,7 +183,7 @@ async function activateAction({ params }) {
183183
// TODO: add security logs
184184
// var remoteip = request.params.remoteip;
185185
const { token, username } = params;
186-
const { log, config } = this;
186+
const { log, config, redis, dlock, tokenManager } = this;
187187
const audience = params.audience || config.defaultAudience;
188188

189189
log.debug('incoming request params %j', params);
@@ -197,8 +197,8 @@ async function activateAction({ params }) {
197197
erase: config.token.erase,
198198
};
199199

200-
// TODO: REMOVEME: Execute `DeleteInactiveUsers` LUA script
201-
await cleanInactiveUsersIndex.call(this);
200+
const inactiveUsers = new InactiveUser(redis);
201+
await inactiveUsers.cleanInactive(config.deleteInactiveAccounts, { dlock, tokenManager });
202202

203203
return Promise
204204
.bind(context)

src/actions/register.js

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,12 @@ const checkLimits = require('../utils/checkIpLimits');
2222
const challenge = require('../utils/challenges/challenge');
2323
const handlePipeline = require('../utils/pipelineError');
2424
const hashPassword = require('../utils/register/password/hash');
25-
const { cleanInactiveUsersIndex } = require('../utils/inactiveUsers');
25+
const InactiveUser = require('../utils/inactive-user');
2626

2727
const {
2828
USERS_REF,
2929
USERS_INDEX,
3030
USERS_SSO_TO_ID,
31-
USERS_INACTIVATED,
3231
USERS_DATA,
3332
USERS_USERNAME_TO_ID,
3433
USERS_ACTIVE_FLAG,
@@ -151,6 +150,8 @@ async function performRegistration({ service, params }) {
151150
const {
152151
config,
153152
redis,
153+
dlock,
154+
tokenManager,
154155
} = service;
155156

156157
// do verifications of DB state
@@ -175,8 +176,8 @@ async function performRegistration({ service, params }) {
175176
await verifySSO(service, params);
176177
}
177178

178-
// TODO: REMOVEME: Execute `DeleteInactiveUsers` LUA script
179-
await cleanInactiveUsersIndex.call(service);
179+
const inactiveUsers = new InactiveUser(redis);
180+
await inactiveUsers.cleanInactive(config.deleteInactiveAccounts, { dlock, tokenManager });
180181

181182
const [creatorAudience] = audience;
182183
const defaultAudience = last(audience);
@@ -214,11 +215,7 @@ async function performRegistration({ service, params }) {
214215
pipeline.hset(USERS_USERNAME_TO_ID, username, userId);
215216

216217
if (activate === false && config.deleteInactiveAccounts >= 0) {
217-
/* TODO Remove this line when last task in group merged */
218-
pipeline.expire(userDataKey, config.deleteInactiveAccounts);
219-
220-
/* Add user id to the inactive users index */
221-
redis.zadd(USERS_INACTIVATED, created, userId);
218+
InactiveUser.add(pipeline, userId, created);
222219
}
223220

224221
await pipeline.exec().then(handlePipeline);

src/constants.js

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,6 @@ module.exports = exports = {
77
USERS_REFERRAL_INDEX: 'users-referral',
88
ORGANIZATIONS_INDEX: 'organization-iterator-set',
99

10-
/* inactive user ids set */
11-
USERS_INACTIVATED: 'users-inactivated',
12-
1310
// id mapping
1411
USERS_ALIAS_TO_ID: 'users-alias',
1512
USERS_SSO_TO_ID: 'users-sso-hash',

src/utils/inactive-user.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
const Promise = require('bluebird');
2+
const UserActions = require('./user/actions');
3+
const DeleteOrgMemberCommand = require('./organization/redis/command/delete-member-from-organizations');
4+
5+
class InactiveUser {
6+
static REDIS_KEY = 'users-inactivated';
7+
8+
/**
9+
* @param {ioredis | pipeline}redis
10+
*/
11+
constructor(redis) {
12+
this.redis = redis;
13+
}
14+
15+
async cleanInactive(userTTL, deps) {
16+
const { dlock, tokenManager } = deps;
17+
18+
const lock = await dlock
19+
.once('delete-inactive-users')
20+
.catch({ name: 'LockAcquisitionError' }, () => { return null; });
21+
22+
if (lock === null) {
23+
return null;
24+
}
25+
26+
const users = await this.get(userTTL);
27+
const userActions = new UserActions(this.redis);
28+
const userEmails = await userActions.getUserEmail(users);
29+
30+
const deleteUserWork = [];
31+
for (const userId of users) {
32+
deleteUserWork.push(userActions.delete(userId));
33+
}
34+
await Promise.all(deleteUserWork);
35+
await lock.release().reflect();
36+
37+
const deleteOrgMemberCommand = new DeleteOrgMemberCommand(this.redis);
38+
const work = [];
39+
40+
for (const userEmail of userEmails) {
41+
work.push(deleteOrgMemberCommand.execute(userEmail));
42+
// ignore errors, sometimes tokens empty
43+
work.push(UserActions.deleteUserTokens(userEmail, tokenManager).reflect());
44+
}
45+
await Promise.all(work);
46+
47+
return users.length;
48+
}
49+
50+
add(userId, createTime) {
51+
return this.redis.zadd(InactiveUser.REDIS_KEY, createTime, userId);
52+
}
53+
54+
get(interval) {
55+
const expire = Date.now() - (interval * 1000);
56+
return this.redis.zrangebyscore(InactiveUser.REDIS_KEY, '-inf', expire);
57+
}
58+
59+
remove(userId) {
60+
return this.redis.zrem(InactiveUser.REDIS_KEY, userId);
61+
}
62+
63+
cleanup(interval) {
64+
const expire = Date.now() - (interval * 1000);
65+
return this.redis.zremrangebyscore(InactiveUser.REDIS_KEY, '-inf', expire);
66+
}
67+
68+
/* Static methods */
69+
static add(redis, userId, createTime) {
70+
return new InactiveUser(redis).add(userId, createTime);
71+
}
72+
73+
static remove(redis, userId) {
74+
return new InactiveUser(redis).add(userId);
75+
}
76+
}
77+
78+
79+
module.exports = InactiveUser;

src/utils/inactiveUsers/index.js

Lines changed: 0 additions & 23 deletions
This file was deleted.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
const { hasOwnProperty } = Object;
2+
const key = require('../../../key');
3+
4+
const {
5+
ORGANIZATIONS_MEMBERS,
6+
USERS_ORGANIZATIONS,
7+
ORGANIZATIONS_INVITATIONS_INDEX,
8+
} = require('../../../../constants');
9+
10+
11+
const luaScript = `
12+
local usersOrganizationsKeyTemplate = KEYS[1]
13+
local orgMembersKeyTemplate = KEYS[2]
14+
local orgMemberKeyTemplate = KEYS[3]
15+
16+
local orgMemberTemplate = ARGV[1]
17+
local username = ARGV[2]
18+
19+
-- indexes
20+
local organizationsInvitationIndex = '{{ keys.ORGANIZATIONS_INVITATIONS_INDEX }}'
21+
22+
-- key from template generator
23+
local function makeKey(template, templateValues)
24+
local str = template
25+
for param, value in pairs(templateValues) do
26+
str = str:gsub('{'..param..'}', value, 1)
27+
end
28+
return str
29+
end
30+
31+
32+
local userOrganizationsKey = makeKey(usersOrganizationsKeyTemplate, { username = username })
33+
local organizationIds = redis.call("HKEYS", userOrganizationsKey)
34+
35+
redis.call('SREM', organizationsInvitationIndex, username)
36+
37+
for _, orgId in pairs(organizationIds) do
38+
local organizationMembersKey = makeKey(orgMembersKeyTemplate, { orgid = orgId})
39+
local organizationMemberKey = makeKey(orgMemberKeyTemplate, { orgid = orgId, username = username })
40+
local organizationMember = makeKey(orgMemberTemplate, {orgid = orgId, username = username })
41+
42+
redis.call("DEL", organizationMemberKey)
43+
redis.call("HDEL", userOrganizationsKey, orgId)
44+
redis.call("ZREM", organizationMembersKey, organizationMember )
45+
end
46+
`;
47+
48+
49+
class DeleteMemberFromOrganizations {
50+
static name = 'deleteMemberFromOrganizations';
51+
52+
constructor(redis) {
53+
this.redis = redis;
54+
if (!this.isScriptLoaded()) {
55+
this.loadScript();
56+
}
57+
}
58+
59+
loadScript() {
60+
this.redis.defineCommand(DeleteMemberFromOrganizations.name, { lua: luaScript });
61+
}
62+
63+
isScriptLoaded() {
64+
return hasOwnProperty.call(this.redis, DeleteMemberFromOrganizations.name);
65+
}
66+
67+
execute(userId) {
68+
const scriptKeys = DeleteMemberFromOrganizations.getKeys();
69+
const orgMemberTemplate = key('{orgid}', ORGANIZATIONS_MEMBERS, '{username}');
70+
71+
return this.redis[DeleteMemberFromOrganizations.name](scriptKeys.length, ...scriptKeys, orgMemberTemplate, userId);
72+
}
73+
74+
static getKeys() {
75+
return [
76+
key('{username}', USERS_ORGANIZATIONS),
77+
key('{orgid}', ORGANIZATIONS_MEMBERS),
78+
key('{orgid}', ORGANIZATIONS_MEMBERS, '{username}'),
79+
ORGANIZATIONS_INVITATIONS_INDEX,
80+
];
81+
}
82+
}
83+
84+
module.exports = DeleteMemberFromOrganizations;

0 commit comments

Comments
 (0)