From 32a3ed9daff108dbda444e86e52dabb26d8cf780 Mon Sep 17 00:00:00 2001 From: pajgo Date: Fri, 20 Sep 2019 16:41:05 +0600 Subject: [PATCH 01/12] feat: update user and organization metadata --- .../user_and_organization_meta_update.md | 77 +++++++++ scripts/updateMetadata.lua | 141 ++++++++++++++++ src/constants.js | 2 + src/utils/setOrganizationMetadata.js | 30 ++-- src/utils/updateMetadata.js | 152 +++++++----------- test/suites/updateMetadata.js | 32 ++++ 6 files changed, 325 insertions(+), 109 deletions(-) create mode 100644 rfcs/inactive_users/user_and_organization_meta_update.md create mode 100644 scripts/updateMetadata.lua diff --git a/rfcs/inactive_users/user_and_organization_meta_update.md b/rfcs/inactive_users/user_and_organization_meta_update.md new file mode 100644 index 000000000..e65780b70 --- /dev/null +++ b/rfcs/inactive_users/user_and_organization_meta_update.md @@ -0,0 +1,77 @@ +# User/Organization metadata update rework +## Overview and Motivation +When user or organization metadata needs to be updated, the Service uses the Redis pipeline javascript code. +For each assigned meta hash always exists a single `audience`, but there is no list of `audiences` assigned to the user or company. +To achieve easier audience tracking and a combined metadata update, I advise using a Lua based script. + +## Audience lists +Audiences stored in sets formed from `USERS_AUDIENCE` or `ORGANISATION_AUDIENCE` constants and `Id` +(eg: `{ms-users}10110110111!audiences`). Both keys contain `audience` names that are currently have assigned values. + +## utils/updateMetadata.js +Almost all logic in this file removed and ported into LUA Script. +This Function checks the consistency of the provided `opts`. If `opts.metadata` and `opts.audiences` are objects, script transforming them to an array containing these objects. Checks count of meta operations and audiences to equal each other. +Organization meta update request `utils/setOrganizationMetadata.js` uses the same functionality, so the same changes applied to it. + +After commands execution result returned from the script, decoded from JSON string. + +## script/updateMetadata.lua +Script repeats all logic including custom scripts support. + +### Script parameters: +1. KEYS[1] Audiences key template. +2. KEYS[2] used as metadata key template, eg: "{ms-users}{id}!metadata!{audience}". +3. ARGV[1] Id - organization or user-id. +4. ARGV[2] JSON encoded opts parameter opts.{script, metadata, audiences}. + +### Depending on metadata or script set: +If `opt.metadata` set: + * Script starts iterating audiences. + * On each audience, creates metadata key from provided template. + * Iterates operations from `opt.metadata`, based on index of `opts.audiences`. + ```javascript + const opts = { + audiences: ['first', 'second'], + metadata: [{ + // first audience commands + }, { + // second audience commands + }], + } + ``` + Commands execute in order: `audiences[0]` => `metadata[0]`,`audiences[1]` => `metadata[1]`, + +If `opt.script` set: +* Script iterates `audiences` and creates metadata keys from provided template + * Iterates `opt.script`: + * EVAL's script from `script.lua` and executes with params generated from: metadata keys(look to the previous step) + and passed `script.argv`. + * If script evaluation fails, script returns redis.error witch description. + +When operations/scripts processed, the script forms JSON object like +```javascript +const metaResponse = [ + //forEach audience + { + '$incr': { + field: 'result', // result returned from HINCRBY command + }, + '$remove': intCount, // count of deleted fields + '$set': "OK", // or cmd hset result. + }, +]; + +const scriptResponse = { + 'scriptName': [ + // values returned from script + ], +}; +``` + +### Audience list update +When all update operations succeeded: +* Script get's current list of user's or organization's audiences from HSET `KEYS[1]`, +unions them with `opts.audiences` and generates full list metadata keys. +* Iterates over them to check whether some data exists. +* If no data exists, the script deletes the corresponding audience from HSET `KEYS[1]`. + diff --git a/scripts/updateMetadata.lua b/scripts/updateMetadata.lua new file mode 100644 index 000000000..a34846cdb --- /dev/null +++ b/scripts/updateMetadata.lua @@ -0,0 +1,141 @@ +local audienceKeyTemplate = KEYS[1] +local metaDataTemplate = KEYS[2] +local Id = ARGV[1] +local updateOptsJson = ARGV[2] + +redis.replicate_commands() + +local updateOpts = cjson.decode(updateOptsJson) + +local function loadScript(code, environment) + if setfenv and loadstring then + local f = assert(loadstring(code)) + setfenv(f,environment) + return f + else + return assert(load(code, nil,"t",environment)) + end +end + +local function tablesUniqueItems(...) + local args = {...} + local tableWithUniqueItems = {} + for _, passedTable in pairs(args) do + for __, keyName in pairs(passedTable) do + tableWithUniqueItems[keyName] = keyName + end + end + return tableWithUniqueItems +end + +local function makeKey (template, id, audience) + local str = template:gsub('{id}', id, 1) + if audience ~= nil then + str = str:gsub('{audience}', audience, 1) + end + return str +end + +-- +-- available ops definition +-- +local function opSet(metaKey, args) + local setArgs = {} + local result = {} + + for field, value in pairs(args) do + table.insert(setArgs, field) + table.insert(setArgs, value) + end + + local callResult = redis.call("HMSET", metaKey, unpack(setArgs)) + result[1] = callResult.ok + return result +end + +local function opRemove(metaKey, args) + local result = 0; + for i, field in pairs(args) do + result = result + redis.call("HDEL", metaKey, field) + end + return result +end + +local function opIncr(metaKey, args) + local result = {} + for field, incrVal in pairs(args) do + result[field] = redis.call("HINCRBY", metaKey, field, incrVal) + end + return result +end + +-- operations index +local metaOps = { + ['$set'] = opSet, + ['$remove'] = opRemove, + ['$incr'] = opIncr +} + +-- +-- Script body +-- +local scriptResult = {} + +local keysToProcess = {}; +for i, audience in ipairs(updateOpts.audiences) do + local key = makeKey(metaDataTemplate, Id, audience) + table.insert(keysToProcess, i, key); +end + +if updateOpts.metaOps then + for i, op in ipairs(updateOpts.metaOps) do + local targetOpKey = keysToProcess[i] + local metaProcessResult = {}; + + for opName, opArg in pairs(op) do + local processFn = metaOps[opName]; + + if processFn == nil then + return redis.error_reply("Unsupported command:" .. opName) + end + if type(opArg) ~= "table" then + return redis.error_reply("Args for ".. opName .." must be and array") + end + + metaProcessResult[opName] = processFn(targetOpKey, opArg) + end + table.insert(scriptResult, metaProcessResult) + end + +elseif updateOpts.scripts then + local env = {}; + -- allow read access to this script scope + setmetatable(env,{__index=_G}) + + for i, script in pairs(updateOpts.scripts) do + env.ARGV = script.argv + env.KEYS = keysToProcess + local fn = loadScript(script.lua, env) + scriptResult[script.name] = fn() + end + +end + +local audienceKey = makeKey(audienceKeyTemplate, Id) +local audiences = redis.call("SMEMBERS", audienceKey) +local processedAudiences = updateOpts.audiences +local uniqueAudiences = tablesUniqueItems(audiences, processedAudiences) + +for _, audience in pairs(uniqueAudiences) do + local metaKey = makeKey(metaDataTemplate, Id, audience) + local dataLen = redis.call("HLEN", metaKey) + + if (dataLen > 0) then + redis.call("SADD", audienceKey, audience) + else + redis.call("SREM", audienceKey, audience) + end +end + + +return cjson.encode(scriptResult) diff --git a/src/constants.js b/src/constants.js index 525284ecc..10f2033a2 100644 --- a/src/constants.js +++ b/src/constants.js @@ -18,6 +18,7 @@ module.exports = exports = { // hashes USERS_DATA: 'data', USERS_METADATA: 'metadata', + USERS_AUDIENCE: 'users-audiences', USERS_TOKENS: 'tokens', USERS_API_TOKENS: 'api-tokens', USERS_API_TOKENS_ZSET: 'api-tokens-set', @@ -26,6 +27,7 @@ module.exports = exports = { USERS_ORGANIZATIONS: 'user-organizations', ORGANIZATIONS_DATA: 'data', ORGANIZATIONS_METADATA: 'metadata', + ORGANIZATIONS_AUDIENCE: 'organization-audiences', ORGANIZATIONS_MEMBERS: 'members', // standard JWT with TTL diff --git a/src/utils/setOrganizationMetadata.js b/src/utils/setOrganizationMetadata.js index a9f9b47db..82ef4c7bd 100644 --- a/src/utils/setOrganizationMetadata.js +++ b/src/utils/setOrganizationMetadata.js @@ -3,9 +3,18 @@ const Promise = require('bluebird'); const is = require('is'); const { HttpStatusError } = require('common-errors'); const redisKey = require('../utils/key.js'); -const handlePipeline = require('../utils/pipelineError.js'); -const { handleAudience } = require('../utils/updateMetadata.js'); -const { ORGANIZATIONS_METADATA } = require('../constants.js'); +const { prepareOps } = require('./updateMetadata'); +const { ORGANIZATIONS_METADATA, ORGANIZATIONS_AUDIENCE } = require('../constants.js'); + +const JSONStringify = (data) => JSON.stringify(data); + +function callUpdateMetadataScript(redis, id, ops) { + const audienceKeyTemplate = redisKey('{id}', ORGANIZATIONS_AUDIENCE); + const metaDataTemplate = redisKey('{id}', ORGANIZATIONS_METADATA, '{audience}'); + + return redis + .updateMetadata(2, audienceKeyTemplate, metaDataTemplate, id, JSONStringify(ops)); +} /** * Updates metadata on a organization object @@ -19,20 +28,17 @@ async function setOrganizationMetadata(opts) { } = opts; const audiences = is.array(audience) ? audience : [audience]; - // keys - const keys = audiences.map((aud) => redisKey(organizationId, ORGANIZATIONS_METADATA, aud)); - // if we have meta, then we can if (metadata) { - const pipe = redis.pipeline(); - const metaOps = is.array(metadata) ? metadata : [metadata]; - - if (metaOps.length !== audiences.length) { + const rawMetaOps = is.array(metadata) ? metadata : [metadata]; + if (rawMetaOps.length !== audiences.length) { return Promise.reject(new HttpStatusError(400, 'audiences must match metadata entries')); } - metaOps.forEach((meta, idx) => handleAudience(pipe, keys[idx], meta)); - return pipe.exec().then(handlePipeline); + const metaOps = rawMetaOps.map((opBlock) => prepareOps(opBlock)); + + const scriptOpts = { metaOps, audiences }; + return callUpdateMetadataScript(redis, organizationId, scriptOpts); } return true; diff --git a/src/utils/updateMetadata.js b/src/utils/updateMetadata.js index 6fa5f56b8..9ab68f57b 100644 --- a/src/utils/updateMetadata.js +++ b/src/utils/updateMetadata.js @@ -1,98 +1,55 @@ /* eslint-disable no-mixed-operators */ const Promise = require('bluebird'); -const mapValues = require('lodash/mapValues'); const is = require('is'); const { HttpStatusError } = require('common-errors'); +const mapValues = require('lodash/mapValues'); const redisKey = require('../utils/key.js'); -const sha256 = require('./sha256.js'); -const handlePipeline = require('../utils/pipelineError.js'); -const { USERS_METADATA } = require('../constants.js'); +const { USERS_METADATA, USERS_AUDIENCE } = require('../constants.js'); const JSONStringify = (data) => JSON.stringify(data); +const JSONParse = (data) => JSON.parse(data); +const has = Object.prototype.hasOwnProperty; -/** - * Process metadata update operation for a passed audience - * @param {Object} pipeline - * @param {String} audience - * @param {Object} metadata - */ -function handleAudience(pipeline, key, metadata) { - const { $remove } = metadata; - const $removeOps = $remove && $remove.length || 0; - if ($removeOps > 0) { - pipeline.hdel(key, $remove); - } - - const { $set } = metadata; - const $setKeys = $set && Object.keys($set); - const $setLength = $setKeys && $setKeys.length || 0; - if ($setLength > 0) { - pipeline.hmset(key, mapValues($set, JSONStringify)); - } +function callUpdateMetadataScript(redis, userId, ops) { + const audienceKeyTemplate = redisKey('{id}', USERS_AUDIENCE); + const metaDataTemplate = redisKey('{id}', USERS_METADATA, '{audience}'); - const { $incr } = metadata; - const $incrFields = $incr && Object.keys($incr); - const $incrLength = $incrFields && $incrFields.length || 0; - if ($incrLength > 0) { - $incrFields.forEach((fieldName) => { - pipeline.hincrby(key, fieldName, $incr[fieldName]); - }); - } - - return { - $removeOps, $setLength, $incrLength, $incrFields, - }; + return redis + .updateMetadata(2, audienceKeyTemplate, metaDataTemplate, userId, JSONStringify(ops)); } -/** - * Maps updateMetadata ops - * @param {Array} responses - * @param {Array} operations - * @return {Object|Array} - */ -function mapMetaResponse(operations, responses) { - let cursor = 0; - return Promise - .map(operations, (props) => { - const { - $removeOps, $setLength, $incrLength, $incrFields, - } = props; - const output = {}; - - if ($removeOps > 0) { - output.$remove = responses[cursor]; - cursor += 1; - } - - if ($setLength > 0) { - output.$set = responses[cursor]; - cursor += 1; - } - - if ($incrLength > 0) { - const $incrResponse = output.$incr = {}; - $incrFields.forEach((fieldName) => { - $incrResponse[fieldName] = responses[cursor]; - cursor += 1; - }); +// Stabilizes Lua script response +function mapUpdateResponse(jsonStr) { + const decodedData = JSONParse(jsonStr); + const result = []; + + decodedData.forEach((metaResult) => { + const opResult = {}; + for (const [key, ops] of Object.entries(metaResult)) { + if (ops.length !== undefined && ops.length === 1) { + [opResult[key]] = ops; + } else { + opResult[key] = ops; } + } + result.push(opResult); + }); - return output; - }) - .then((ops) => (ops.length > 1 ? ops : ops[0])); + return result.length > 1 ? result : result[0]; } /** - * Handle script, mutually exclusive with metadata - * @param {Array} scriptKeys - * @param {Array} responses + * Encodes operation field values ito json string + * If encoding performed in LUA script using CJSON lib, empty arrays become empty objects. + * This breaks logic + * @param metaOps + * @returns {*} */ -function mapScriptResponse(scriptKeys, responses) { - const output = {}; - scriptKeys.forEach((fieldName, idx) => { - output[fieldName] = responses[idx]; - }); - return output; +function prepareOps(ops) { + if (has.call(ops, '$set')) { + ops.$set = mapValues(ops.$set, JSONStringify); + } + return ops; } /** @@ -107,38 +64,39 @@ function updateMetadata(opts) { } = opts; const audiences = is.array(audience) ? audience : [audience]; - // keys - const keys = audiences.map((aud) => redisKey(userId, USERS_METADATA, aud)); + let scriptOpts = { + audiences, + }; - // if we have meta, then we can if (metadata) { - const pipe = redis.pipeline(); - const metaOps = is.array(metadata) ? metadata : [metadata]; - - if (metaOps.length !== audiences.length) { + const rawMetaOps = is.array(metadata) ? metadata : [metadata]; + if (rawMetaOps.length !== audiences.length) { return Promise.reject(new HttpStatusError(400, 'audiences must match metadata entries')); } - const operations = metaOps.map((meta, idx) => handleAudience(pipe, keys[idx], meta)); - return pipe.exec() - .then(handlePipeline) - .then((res) => mapMetaResponse(operations, res)); + const metaOps = rawMetaOps.map((opBlock) => prepareOps(opBlock)); + + scriptOpts = { metaOps, ...scriptOpts }; + return callUpdateMetadataScript(redis, userId, scriptOpts) + .then(mapUpdateResponse); } // dynamic scripts const $scriptKeys = Object.keys(script); const scripts = $scriptKeys.map((scriptName) => { const { lua, argv = [] } = script[scriptName]; - const sha = sha256(lua); - const name = `ms_users_${sha}`; - if (!is.fn(redis[name])) { - redis.defineCommand(name, { lua }); - } - return redis[name](keys.length, keys, argv); + return { + lua, + argv, + name: scriptName, + }; }); - return Promise.all(scripts).then((res) => mapScriptResponse($scriptKeys, res)); + scriptOpts = { scripts, ...scriptOpts }; + return callUpdateMetadataScript(redis, userId, scriptOpts) + .then((result) => JSONParse(result)); } -updateMetadata.handleAudience = handleAudience; +updateMetadata.callUpdateMetadataScript = callUpdateMetadataScript; +updateMetadata.prepareOps = prepareOps; module.exports = updateMetadata; diff --git a/test/suites/updateMetadata.js b/test/suites/updateMetadata.js index ef0a67265..485c14263 100644 --- a/test/suites/updateMetadata.js +++ b/test/suites/updateMetadata.js @@ -64,6 +64,7 @@ describe('#updateMetadata', function getMetadataSuite() { $incr: { b: 2, }, + $remove: ['c'], }, { $incr: { @@ -106,4 +107,35 @@ describe('#updateMetadata', function getMetadataSuite() { ]); }); }); + + it('must be able to run dynamic scripts / namespace fully available', function test() { + const dispatch = simpleDispatcher(this.users.router); + const lua = ` + local t = {} + table.insert(t, "foo") + local jsonDec = cjson.decode('{"bar": 1}') + local typeCheck = type(t) + return {jsonDec.bar, redis.call("TIME"), typeCheck, unpack(t)} + `; + + const params = { + username, + audience: [audience], + script: { + check: { + lua, + argv: ['nom-nom'], + }, + }, + }; + + return dispatch('users.updateMetadata', params) + .reflect() + .then(inspectPromise()) + .then(({ check }) => { + const [jsonVal, redisTime] = check; + expect(jsonVal).to.be.eq(1); + expect(redisTime).to.be.an('array'); + }); + }); }); From f652f37744f27c7b18bcbcfb82f0cfd15fd46ecb Mon Sep 17 00:00:00 2001 From: pajgo Date: Fri, 27 Sep 2019 18:24:42 +0600 Subject: [PATCH 02/12] feat: update user and organization metadata * promise to async --- src/utils/updateMetadata.js | 15 ++++++------- test/suites/updateMetadata.js | 40 ++++++++++++++--------------------- 2 files changed, 23 insertions(+), 32 deletions(-) diff --git a/src/utils/updateMetadata.js b/src/utils/updateMetadata.js index 9ab68f57b..353c52ddd 100644 --- a/src/utils/updateMetadata.js +++ b/src/utils/updateMetadata.js @@ -1,5 +1,4 @@ /* eslint-disable no-mixed-operators */ -const Promise = require('bluebird'); const is = require('is'); const { HttpStatusError } = require('common-errors'); const mapValues = require('lodash/mapValues'); @@ -57,7 +56,7 @@ function prepareOps(ops) { * @param {Object} opts * @return {Promise} */ -function updateMetadata(opts) { +async function updateMetadata(opts) { const { redis } = this; const { userId, audience, metadata, script, @@ -71,14 +70,14 @@ function updateMetadata(opts) { if (metadata) { const rawMetaOps = is.array(metadata) ? metadata : [metadata]; if (rawMetaOps.length !== audiences.length) { - return Promise.reject(new HttpStatusError(400, 'audiences must match metadata entries')); + throw new HttpStatusError(400, 'audiences must match metadata entries'); } const metaOps = rawMetaOps.map((opBlock) => prepareOps(opBlock)); - scriptOpts = { metaOps, ...scriptOpts }; - return callUpdateMetadataScript(redis, userId, scriptOpts) - .then(mapUpdateResponse); + + const updateResult = await callUpdateMetadataScript(redis, userId, scriptOpts); + return mapUpdateResponse(updateResult); } // dynamic scripts @@ -93,8 +92,8 @@ function updateMetadata(opts) { }); scriptOpts = { scripts, ...scriptOpts }; - return callUpdateMetadataScript(redis, userId, scriptOpts) - .then((result) => JSONParse(result)); + const updateResult = await callUpdateMetadataScript(redis, userId, scriptOpts); + return JSONParse(updateResult); } updateMetadata.callUpdateMetadataScript = callUpdateMetadataScript; diff --git a/test/suites/updateMetadata.js b/test/suites/updateMetadata.js index 485c14263..e46659731 100644 --- a/test/suites/updateMetadata.js +++ b/test/suites/updateMetadata.js @@ -83,8 +83,7 @@ describe('#updateMetadata', function getMetadataSuite() { }); }); - it('must be able to run dynamic scripts', function test() { - const dispatch = simpleDispatcher(this.users.router); + it('must be able to run dynamic scripts', async function test() { const params = { username, audience: [audience, extra], @@ -96,26 +95,23 @@ describe('#updateMetadata', function getMetadataSuite() { }, }; - return dispatch('users.updateMetadata', params) - .reflect() - .then(inspectPromise()) - .then((data) => { - expect(data.balance).to.be.deep.eq([ - `{ms-users}${this.userId}!metadata!${audience}`, - `{ms-users}${this.userId}!metadata!${extra}`, - 'nom-nom', - ]); - }); + const updated = await this.dispatch('users.updateMetadata', params); + + expect(updated.balance).to.be.deep.eq([ + `{ms-users}${this.userId}!metadata!${audience}`, + `{ms-users}${this.userId}!metadata!${extra}`, + 'nom-nom', + ]); }); - it('must be able to run dynamic scripts / namespace fully available', function test() { - const dispatch = simpleDispatcher(this.users.router); + it('must be able to run dynamic scripts / default namespace available', async function test() { const lua = ` local t = {} table.insert(t, "foo") local jsonDec = cjson.decode('{"bar": 1}') local typeCheck = type(t) - return {jsonDec.bar, redis.call("TIME"), typeCheck, unpack(t)} + redis.call("SET", "fookey", 777); + return {jsonDec.bar, redis.call("TIME"), redis.call("GET", "fookey"), typeCheck, unpack(t)} `; const params = { @@ -128,14 +124,10 @@ describe('#updateMetadata', function getMetadataSuite() { }, }, }; - - return dispatch('users.updateMetadata', params) - .reflect() - .then(inspectPromise()) - .then(({ check }) => { - const [jsonVal, redisTime] = check; - expect(jsonVal).to.be.eq(1); - expect(redisTime).to.be.an('array'); - }); + const updated = await this.dispatch('users.updateMetadata', params); + const [jsonVal, redisTime, keyValue] = updated.check; + expect(jsonVal).to.be.eq(1); + expect(redisTime).to.be.an('array'); + expect(keyValue).to.be.eq('777'); }); }); From 2e15b1848b6a5a6dae829e0c9f2384b785d14b03 Mon Sep 17 00:00:00 2001 From: pajgo Date: Wed, 2 Oct 2019 17:57:05 +0600 Subject: [PATCH 03/12] feat: update user and organization metadata * code re --- scripts/updateMetadata.lua | 90 ++++++++++++++++++++++------ src/utils/setOrganizationMetadata.js | 13 ++-- src/utils/updateMetadata.js | 22 ++++--- 3 files changed, 89 insertions(+), 36 deletions(-) diff --git a/scripts/updateMetadata.lua b/scripts/updateMetadata.lua index a34846cdb..ebd3e26fb 100644 --- a/scripts/updateMetadata.lua +++ b/scripts/updateMetadata.lua @@ -1,22 +1,38 @@ +-- script replicates commands instead of own body +-- call of HMSET command is determined as 'non deterministic command' +-- and redis refuses to run it without this. +redis.replicate_commands() + local audienceKeyTemplate = KEYS[1] local metaDataTemplate = KEYS[2] local Id = ARGV[1] local updateOptsJson = ARGV[2] -redis.replicate_commands() +local function isValidString(val) + if type(val) == 'string' and string.len(val) > 0 then + return true + end + return false +end + +assert(isValidString(audienceKeyTemplate), 'incorrect `audienceKeyTemplate` key') +assert(isValidString(metaDataTemplate), 'incorrect `metaDataTemplate` key') +assert(isValidString(Id), 'incorrect `id` argument') +assert(isValidString(updateOptsJson), 'incorrect `updateJson` argument') local updateOpts = cjson.decode(updateOptsJson) local function loadScript(code, environment) if setfenv and loadstring then local f = assert(loadstring(code)) - setfenv(f,environment) + setfenv(f, environment) return f else - return assert(load(code, nil,"t",environment)) + return assert(load(code, nil, "t", environment)) end end +-- creates array with unique items from passed arrays local function tablesUniqueItems(...) local args = {...} local tableWithUniqueItems = {} @@ -28,6 +44,7 @@ local function tablesUniqueItems(...) return tableWithUniqueItems end +-- create key from passed template, id and audience local function makeKey (template, id, audience) local str = template:gsub('{id}', id, 1) if audience ~= nil then @@ -39,6 +56,9 @@ end -- -- available ops definition -- + +-- $set: { field: value, field2: value, field3: value } +-- { HMSETResponse } local function opSet(metaKey, args) local setArgs = {} local result = {} @@ -53,14 +73,18 @@ local function opSet(metaKey, args) return result end +-- $remove: [ 'field', 'field2' ] +-- { deletedFieldsCount } - if no fields deleted or there was no such fields counter not incrementing local function opRemove(metaKey, args) local result = 0; - for i, field in pairs(args) do + for _, field in pairs(args) do result = result + redis.call("HDEL", metaKey, field) end return result end +-- $incr: { field: incrValue, field2: incrValue } +-- { field: newValue } local function opIncr(metaKey, args) local result = {} for field, incrVal in pairs(args) do @@ -81,61 +105,91 @@ local metaOps = { -- local scriptResult = {} +-- get list of keys to update +-- generate them from passed audiences and metaData key template local keysToProcess = {}; -for i, audience in ipairs(updateOpts.audiences) do +for index, audience in ipairs(updateOpts.audiences) do local key = makeKey(metaDataTemplate, Id, audience) - table.insert(keysToProcess, i, key); + table.insert(keysToProcess, index, key); end +-- process meta update operations if updateOpts.metaOps then - for i, op in ipairs(updateOpts.metaOps) do - local targetOpKey = keysToProcess[i] + -- iterate over metadata hash field + for index, op in ipairs(updateOpts.metaOps) do + local targetOpKey = keysToProcess[index] local metaProcessResult = {}; + -- iterate over commands and apply them for opName, opArg in pairs(op) do local processFn = metaOps[opName]; if processFn == nil then return redis.error_reply("Unsupported command:" .. opName) end + if type(opArg) ~= "table" then - return redis.error_reply("Args for ".. opName .." must be and array") + return redis.error_reply("Args for " .. opName .. " must be and array") end + -- store command execution result metaProcessResult[opName] = processFn(targetOpKey, opArg) end + + -- store execution result of commands block table.insert(scriptResult, metaProcessResult) end +-- process passed scripts elseif updateOpts.scripts then - local env = {}; - -- allow read access to this script scope - setmetatable(env,{__index=_G}) + -- iterate over scripts and execute them in sandbox + for _, script in pairs(updateOpts.scripts) do + local env = {}; - for i, script in pairs(updateOpts.scripts) do + -- allow read access to this script scope + -- env recreated for each script to avoid scope mixing + setmetatable(env, { __index=_G }) + + -- override params to be sure that script works like it was executed like from `redis.eval` command env.ARGV = script.argv env.KEYS = keysToProcess + + -- evaluate script and bind to custom env local fn = loadScript(script.lua, env) + -- run script and save result scriptResult[script.name] = fn() end end +-- +-- Audience tracking +-- + local audienceKey = makeKey(audienceKeyTemplate, Id) +-- get saved audience list local audiences = redis.call("SMEMBERS", audienceKey) -local processedAudiences = updateOpts.audiences -local uniqueAudiences = tablesUniqueItems(audiences, processedAudiences) +-- create list containing saved and possibly new audiences +local uniqueAudiences = tablesUniqueItems(audiences, updateOpts.audiences) + +-- iterate over final audience list for _, audience in pairs(uniqueAudiences) do + -- get size of metaKey local metaKey = makeKey(metaDataTemplate, Id, audience) - local dataLen = redis.call("HLEN", metaKey) + local keyLen = redis.call("HLEN", metaKey) - if (dataLen > 0) then + -- if key has data add it to the audience set + -- set members unique, so duplicates not appear + + -- if key empty or not exists (HLEN will return 0) + -- delete audience from list + if (keyLen > 0) then redis.call("SADD", audienceKey, audience) else redis.call("SREM", audienceKey, audience) end end - +-- respond with json encoded string return cjson.encode(scriptResult) diff --git a/src/utils/setOrganizationMetadata.js b/src/utils/setOrganizationMetadata.js index 82ef4c7bd..b58a0e6d4 100644 --- a/src/utils/setOrganizationMetadata.js +++ b/src/utils/setOrganizationMetadata.js @@ -1,14 +1,16 @@ /* eslint-disable no-mixed-operators */ const Promise = require('bluebird'); -const is = require('is'); const { HttpStatusError } = require('common-errors'); const redisKey = require('../utils/key.js'); const { prepareOps } = require('./updateMetadata'); + const { ORGANIZATIONS_METADATA, ORGANIZATIONS_AUDIENCE } = require('../constants.js'); const JSONStringify = (data) => JSON.stringify(data); -function callUpdateMetadataScript(redis, id, ops) { +function updateMetadataScript(id, ops) { + const { redis } = this; + const audienceKeyTemplate = redisKey('{id}', ORGANIZATIONS_AUDIENCE); const metaDataTemplate = redisKey('{id}', ORGANIZATIONS_METADATA, '{audience}'); @@ -22,15 +24,14 @@ function callUpdateMetadataScript(redis, id, ops) { * @return {Promise} */ async function setOrganizationMetadata(opts) { - const { redis } = this; const { organizationId, audience, metadata, } = opts; - const audiences = is.array(audience) ? audience : [audience]; + const audiences = Array.isArray(audience) ? audience : [audience]; // if we have meta, then we can if (metadata) { - const rawMetaOps = is.array(metadata) ? metadata : [metadata]; + const rawMetaOps = Array.isArray(metadata) ? metadata : [metadata]; if (rawMetaOps.length !== audiences.length) { return Promise.reject(new HttpStatusError(400, 'audiences must match metadata entries')); } @@ -38,7 +39,7 @@ async function setOrganizationMetadata(opts) { const metaOps = rawMetaOps.map((opBlock) => prepareOps(opBlock)); const scriptOpts = { metaOps, audiences }; - return callUpdateMetadataScript(redis, organizationId, scriptOpts); + return updateMetadataScript.call(this, organizationId, scriptOpts); } return true; diff --git a/src/utils/updateMetadata.js b/src/utils/updateMetadata.js index 353c52ddd..563937c9e 100644 --- a/src/utils/updateMetadata.js +++ b/src/utils/updateMetadata.js @@ -1,15 +1,16 @@ /* eslint-disable no-mixed-operators */ -const is = require('is'); const { HttpStatusError } = require('common-errors'); const mapValues = require('lodash/mapValues'); const redisKey = require('../utils/key.js'); + const { USERS_METADATA, USERS_AUDIENCE } = require('../constants.js'); const JSONStringify = (data) => JSON.stringify(data); const JSONParse = (data) => JSON.parse(data); const has = Object.prototype.hasOwnProperty; -function callUpdateMetadataScript(redis, userId, ops) { +function updateMetadataScript(userId, ops) { + const { redis } = this; const audienceKeyTemplate = redisKey('{id}', USERS_AUDIENCE); const metaDataTemplate = redisKey('{id}', USERS_METADATA, '{audience}'); @@ -20,18 +21,17 @@ function callUpdateMetadataScript(redis, userId, ops) { // Stabilizes Lua script response function mapUpdateResponse(jsonStr) { const decodedData = JSONParse(jsonStr); - const result = []; - decodedData.forEach((metaResult) => { + const result = decodedData.map((metaResult) => { const opResult = {}; for (const [key, ops] of Object.entries(metaResult)) { - if (ops.length !== undefined && ops.length === 1) { + if (Array.isArray(ops) && ops.length === 1) { [opResult[key]] = ops; } else { opResult[key] = ops; } } - result.push(opResult); + return opResult; }); return result.length > 1 ? result : result[0]; @@ -57,18 +57,17 @@ function prepareOps(ops) { * @return {Promise} */ async function updateMetadata(opts) { - const { redis } = this; const { userId, audience, metadata, script, } = opts; - const audiences = is.array(audience) ? audience : [audience]; + const audiences = Array.isArray(audience) ? audience : [audience]; let scriptOpts = { audiences, }; if (metadata) { - const rawMetaOps = is.array(metadata) ? metadata : [metadata]; + const rawMetaOps = Array.isArray(metadata) ? metadata : [metadata]; if (rawMetaOps.length !== audiences.length) { throw new HttpStatusError(400, 'audiences must match metadata entries'); } @@ -76,7 +75,7 @@ async function updateMetadata(opts) { const metaOps = rawMetaOps.map((opBlock) => prepareOps(opBlock)); scriptOpts = { metaOps, ...scriptOpts }; - const updateResult = await callUpdateMetadataScript(redis, userId, scriptOpts); + const updateResult = await updateMetadataScript.call(this, userId, scriptOpts); return mapUpdateResponse(updateResult); } @@ -92,10 +91,9 @@ async function updateMetadata(opts) { }); scriptOpts = { scripts, ...scriptOpts }; - const updateResult = await callUpdateMetadataScript(redis, userId, scriptOpts); + const updateResult = await updateMetadataScript.call(this, userId, scriptOpts); return JSONParse(updateResult); } -updateMetadata.callUpdateMetadataScript = callUpdateMetadataScript; updateMetadata.prepareOps = prepareOps; module.exports = updateMetadata; From 156bba62170ea866c3d53df40319703cfb4bb5bf Mon Sep 17 00:00:00 2001 From: Pavel Rogovoi <51755949+pajgo@users.noreply.github.com> Date: Thu, 10 Oct 2019 22:08:13 +0600 Subject: [PATCH 04/12] feat: update user and organization metadata * remove key validation --- scripts/updateMetadata.lua | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/updateMetadata.lua b/scripts/updateMetadata.lua index ebd3e26fb..507d75b0e 100644 --- a/scripts/updateMetadata.lua +++ b/scripts/updateMetadata.lua @@ -15,8 +15,6 @@ local function isValidString(val) return false end -assert(isValidString(audienceKeyTemplate), 'incorrect `audienceKeyTemplate` key') -assert(isValidString(metaDataTemplate), 'incorrect `metaDataTemplate` key') assert(isValidString(Id), 'incorrect `id` argument') assert(isValidString(updateOptsJson), 'incorrect `updateJson` argument') From 2275f7bfbccf4471816bc4e06b9461cf6b35b1f5 Mon Sep 17 00:00:00 2001 From: pajgo Date: Fri, 20 Sep 2019 16:41:05 +0600 Subject: [PATCH 05/12] feat: update user and organization metadata --- .../user_and_organization_meta_update.md | 77 +++++++++ scripts/updateMetadata.lua | 141 ++++++++++++++++ src/constants.js | 2 + src/utils/setOrganizationMetadata.js | 30 ++-- src/utils/updateMetadata.js | 152 +++++++----------- test/suites/updateMetadata.js | 32 ++++ 6 files changed, 325 insertions(+), 109 deletions(-) create mode 100644 rfcs/inactive_users/user_and_organization_meta_update.md create mode 100644 scripts/updateMetadata.lua diff --git a/rfcs/inactive_users/user_and_organization_meta_update.md b/rfcs/inactive_users/user_and_organization_meta_update.md new file mode 100644 index 000000000..e65780b70 --- /dev/null +++ b/rfcs/inactive_users/user_and_organization_meta_update.md @@ -0,0 +1,77 @@ +# User/Organization metadata update rework +## Overview and Motivation +When user or organization metadata needs to be updated, the Service uses the Redis pipeline javascript code. +For each assigned meta hash always exists a single `audience`, but there is no list of `audiences` assigned to the user or company. +To achieve easier audience tracking and a combined metadata update, I advise using a Lua based script. + +## Audience lists +Audiences stored in sets formed from `USERS_AUDIENCE` or `ORGANISATION_AUDIENCE` constants and `Id` +(eg: `{ms-users}10110110111!audiences`). Both keys contain `audience` names that are currently have assigned values. + +## utils/updateMetadata.js +Almost all logic in this file removed and ported into LUA Script. +This Function checks the consistency of the provided `opts`. If `opts.metadata` and `opts.audiences` are objects, script transforming them to an array containing these objects. Checks count of meta operations and audiences to equal each other. +Organization meta update request `utils/setOrganizationMetadata.js` uses the same functionality, so the same changes applied to it. + +After commands execution result returned from the script, decoded from JSON string. + +## script/updateMetadata.lua +Script repeats all logic including custom scripts support. + +### Script parameters: +1. KEYS[1] Audiences key template. +2. KEYS[2] used as metadata key template, eg: "{ms-users}{id}!metadata!{audience}". +3. ARGV[1] Id - organization or user-id. +4. ARGV[2] JSON encoded opts parameter opts.{script, metadata, audiences}. + +### Depending on metadata or script set: +If `opt.metadata` set: + * Script starts iterating audiences. + * On each audience, creates metadata key from provided template. + * Iterates operations from `opt.metadata`, based on index of `opts.audiences`. + ```javascript + const opts = { + audiences: ['first', 'second'], + metadata: [{ + // first audience commands + }, { + // second audience commands + }], + } + ``` + Commands execute in order: `audiences[0]` => `metadata[0]`,`audiences[1]` => `metadata[1]`, + +If `opt.script` set: +* Script iterates `audiences` and creates metadata keys from provided template + * Iterates `opt.script`: + * EVAL's script from `script.lua` and executes with params generated from: metadata keys(look to the previous step) + and passed `script.argv`. + * If script evaluation fails, script returns redis.error witch description. + +When operations/scripts processed, the script forms JSON object like +```javascript +const metaResponse = [ + //forEach audience + { + '$incr': { + field: 'result', // result returned from HINCRBY command + }, + '$remove': intCount, // count of deleted fields + '$set': "OK", // or cmd hset result. + }, +]; + +const scriptResponse = { + 'scriptName': [ + // values returned from script + ], +}; +``` + +### Audience list update +When all update operations succeeded: +* Script get's current list of user's or organization's audiences from HSET `KEYS[1]`, +unions them with `opts.audiences` and generates full list metadata keys. +* Iterates over them to check whether some data exists. +* If no data exists, the script deletes the corresponding audience from HSET `KEYS[1]`. + diff --git a/scripts/updateMetadata.lua b/scripts/updateMetadata.lua new file mode 100644 index 000000000..a34846cdb --- /dev/null +++ b/scripts/updateMetadata.lua @@ -0,0 +1,141 @@ +local audienceKeyTemplate = KEYS[1] +local metaDataTemplate = KEYS[2] +local Id = ARGV[1] +local updateOptsJson = ARGV[2] + +redis.replicate_commands() + +local updateOpts = cjson.decode(updateOptsJson) + +local function loadScript(code, environment) + if setfenv and loadstring then + local f = assert(loadstring(code)) + setfenv(f,environment) + return f + else + return assert(load(code, nil,"t",environment)) + end +end + +local function tablesUniqueItems(...) + local args = {...} + local tableWithUniqueItems = {} + for _, passedTable in pairs(args) do + for __, keyName in pairs(passedTable) do + tableWithUniqueItems[keyName] = keyName + end + end + return tableWithUniqueItems +end + +local function makeKey (template, id, audience) + local str = template:gsub('{id}', id, 1) + if audience ~= nil then + str = str:gsub('{audience}', audience, 1) + end + return str +end + +-- +-- available ops definition +-- +local function opSet(metaKey, args) + local setArgs = {} + local result = {} + + for field, value in pairs(args) do + table.insert(setArgs, field) + table.insert(setArgs, value) + end + + local callResult = redis.call("HMSET", metaKey, unpack(setArgs)) + result[1] = callResult.ok + return result +end + +local function opRemove(metaKey, args) + local result = 0; + for i, field in pairs(args) do + result = result + redis.call("HDEL", metaKey, field) + end + return result +end + +local function opIncr(metaKey, args) + local result = {} + for field, incrVal in pairs(args) do + result[field] = redis.call("HINCRBY", metaKey, field, incrVal) + end + return result +end + +-- operations index +local metaOps = { + ['$set'] = opSet, + ['$remove'] = opRemove, + ['$incr'] = opIncr +} + +-- +-- Script body +-- +local scriptResult = {} + +local keysToProcess = {}; +for i, audience in ipairs(updateOpts.audiences) do + local key = makeKey(metaDataTemplate, Id, audience) + table.insert(keysToProcess, i, key); +end + +if updateOpts.metaOps then + for i, op in ipairs(updateOpts.metaOps) do + local targetOpKey = keysToProcess[i] + local metaProcessResult = {}; + + for opName, opArg in pairs(op) do + local processFn = metaOps[opName]; + + if processFn == nil then + return redis.error_reply("Unsupported command:" .. opName) + end + if type(opArg) ~= "table" then + return redis.error_reply("Args for ".. opName .." must be and array") + end + + metaProcessResult[opName] = processFn(targetOpKey, opArg) + end + table.insert(scriptResult, metaProcessResult) + end + +elseif updateOpts.scripts then + local env = {}; + -- allow read access to this script scope + setmetatable(env,{__index=_G}) + + for i, script in pairs(updateOpts.scripts) do + env.ARGV = script.argv + env.KEYS = keysToProcess + local fn = loadScript(script.lua, env) + scriptResult[script.name] = fn() + end + +end + +local audienceKey = makeKey(audienceKeyTemplate, Id) +local audiences = redis.call("SMEMBERS", audienceKey) +local processedAudiences = updateOpts.audiences +local uniqueAudiences = tablesUniqueItems(audiences, processedAudiences) + +for _, audience in pairs(uniqueAudiences) do + local metaKey = makeKey(metaDataTemplate, Id, audience) + local dataLen = redis.call("HLEN", metaKey) + + if (dataLen > 0) then + redis.call("SADD", audienceKey, audience) + else + redis.call("SREM", audienceKey, audience) + end +end + + +return cjson.encode(scriptResult) diff --git a/src/constants.js b/src/constants.js index 525284ecc..10f2033a2 100644 --- a/src/constants.js +++ b/src/constants.js @@ -18,6 +18,7 @@ module.exports = exports = { // hashes USERS_DATA: 'data', USERS_METADATA: 'metadata', + USERS_AUDIENCE: 'users-audiences', USERS_TOKENS: 'tokens', USERS_API_TOKENS: 'api-tokens', USERS_API_TOKENS_ZSET: 'api-tokens-set', @@ -26,6 +27,7 @@ module.exports = exports = { USERS_ORGANIZATIONS: 'user-organizations', ORGANIZATIONS_DATA: 'data', ORGANIZATIONS_METADATA: 'metadata', + ORGANIZATIONS_AUDIENCE: 'organization-audiences', ORGANIZATIONS_MEMBERS: 'members', // standard JWT with TTL diff --git a/src/utils/setOrganizationMetadata.js b/src/utils/setOrganizationMetadata.js index a9f9b47db..82ef4c7bd 100644 --- a/src/utils/setOrganizationMetadata.js +++ b/src/utils/setOrganizationMetadata.js @@ -3,9 +3,18 @@ const Promise = require('bluebird'); const is = require('is'); const { HttpStatusError } = require('common-errors'); const redisKey = require('../utils/key.js'); -const handlePipeline = require('../utils/pipelineError.js'); -const { handleAudience } = require('../utils/updateMetadata.js'); -const { ORGANIZATIONS_METADATA } = require('../constants.js'); +const { prepareOps } = require('./updateMetadata'); +const { ORGANIZATIONS_METADATA, ORGANIZATIONS_AUDIENCE } = require('../constants.js'); + +const JSONStringify = (data) => JSON.stringify(data); + +function callUpdateMetadataScript(redis, id, ops) { + const audienceKeyTemplate = redisKey('{id}', ORGANIZATIONS_AUDIENCE); + const metaDataTemplate = redisKey('{id}', ORGANIZATIONS_METADATA, '{audience}'); + + return redis + .updateMetadata(2, audienceKeyTemplate, metaDataTemplate, id, JSONStringify(ops)); +} /** * Updates metadata on a organization object @@ -19,20 +28,17 @@ async function setOrganizationMetadata(opts) { } = opts; const audiences = is.array(audience) ? audience : [audience]; - // keys - const keys = audiences.map((aud) => redisKey(organizationId, ORGANIZATIONS_METADATA, aud)); - // if we have meta, then we can if (metadata) { - const pipe = redis.pipeline(); - const metaOps = is.array(metadata) ? metadata : [metadata]; - - if (metaOps.length !== audiences.length) { + const rawMetaOps = is.array(metadata) ? metadata : [metadata]; + if (rawMetaOps.length !== audiences.length) { return Promise.reject(new HttpStatusError(400, 'audiences must match metadata entries')); } - metaOps.forEach((meta, idx) => handleAudience(pipe, keys[idx], meta)); - return pipe.exec().then(handlePipeline); + const metaOps = rawMetaOps.map((opBlock) => prepareOps(opBlock)); + + const scriptOpts = { metaOps, audiences }; + return callUpdateMetadataScript(redis, organizationId, scriptOpts); } return true; diff --git a/src/utils/updateMetadata.js b/src/utils/updateMetadata.js index 6fa5f56b8..9ab68f57b 100644 --- a/src/utils/updateMetadata.js +++ b/src/utils/updateMetadata.js @@ -1,98 +1,55 @@ /* eslint-disable no-mixed-operators */ const Promise = require('bluebird'); -const mapValues = require('lodash/mapValues'); const is = require('is'); const { HttpStatusError } = require('common-errors'); +const mapValues = require('lodash/mapValues'); const redisKey = require('../utils/key.js'); -const sha256 = require('./sha256.js'); -const handlePipeline = require('../utils/pipelineError.js'); -const { USERS_METADATA } = require('../constants.js'); +const { USERS_METADATA, USERS_AUDIENCE } = require('../constants.js'); const JSONStringify = (data) => JSON.stringify(data); +const JSONParse = (data) => JSON.parse(data); +const has = Object.prototype.hasOwnProperty; -/** - * Process metadata update operation for a passed audience - * @param {Object} pipeline - * @param {String} audience - * @param {Object} metadata - */ -function handleAudience(pipeline, key, metadata) { - const { $remove } = metadata; - const $removeOps = $remove && $remove.length || 0; - if ($removeOps > 0) { - pipeline.hdel(key, $remove); - } - - const { $set } = metadata; - const $setKeys = $set && Object.keys($set); - const $setLength = $setKeys && $setKeys.length || 0; - if ($setLength > 0) { - pipeline.hmset(key, mapValues($set, JSONStringify)); - } +function callUpdateMetadataScript(redis, userId, ops) { + const audienceKeyTemplate = redisKey('{id}', USERS_AUDIENCE); + const metaDataTemplate = redisKey('{id}', USERS_METADATA, '{audience}'); - const { $incr } = metadata; - const $incrFields = $incr && Object.keys($incr); - const $incrLength = $incrFields && $incrFields.length || 0; - if ($incrLength > 0) { - $incrFields.forEach((fieldName) => { - pipeline.hincrby(key, fieldName, $incr[fieldName]); - }); - } - - return { - $removeOps, $setLength, $incrLength, $incrFields, - }; + return redis + .updateMetadata(2, audienceKeyTemplate, metaDataTemplate, userId, JSONStringify(ops)); } -/** - * Maps updateMetadata ops - * @param {Array} responses - * @param {Array} operations - * @return {Object|Array} - */ -function mapMetaResponse(operations, responses) { - let cursor = 0; - return Promise - .map(operations, (props) => { - const { - $removeOps, $setLength, $incrLength, $incrFields, - } = props; - const output = {}; - - if ($removeOps > 0) { - output.$remove = responses[cursor]; - cursor += 1; - } - - if ($setLength > 0) { - output.$set = responses[cursor]; - cursor += 1; - } - - if ($incrLength > 0) { - const $incrResponse = output.$incr = {}; - $incrFields.forEach((fieldName) => { - $incrResponse[fieldName] = responses[cursor]; - cursor += 1; - }); +// Stabilizes Lua script response +function mapUpdateResponse(jsonStr) { + const decodedData = JSONParse(jsonStr); + const result = []; + + decodedData.forEach((metaResult) => { + const opResult = {}; + for (const [key, ops] of Object.entries(metaResult)) { + if (ops.length !== undefined && ops.length === 1) { + [opResult[key]] = ops; + } else { + opResult[key] = ops; } + } + result.push(opResult); + }); - return output; - }) - .then((ops) => (ops.length > 1 ? ops : ops[0])); + return result.length > 1 ? result : result[0]; } /** - * Handle script, mutually exclusive with metadata - * @param {Array} scriptKeys - * @param {Array} responses + * Encodes operation field values ito json string + * If encoding performed in LUA script using CJSON lib, empty arrays become empty objects. + * This breaks logic + * @param metaOps + * @returns {*} */ -function mapScriptResponse(scriptKeys, responses) { - const output = {}; - scriptKeys.forEach((fieldName, idx) => { - output[fieldName] = responses[idx]; - }); - return output; +function prepareOps(ops) { + if (has.call(ops, '$set')) { + ops.$set = mapValues(ops.$set, JSONStringify); + } + return ops; } /** @@ -107,38 +64,39 @@ function updateMetadata(opts) { } = opts; const audiences = is.array(audience) ? audience : [audience]; - // keys - const keys = audiences.map((aud) => redisKey(userId, USERS_METADATA, aud)); + let scriptOpts = { + audiences, + }; - // if we have meta, then we can if (metadata) { - const pipe = redis.pipeline(); - const metaOps = is.array(metadata) ? metadata : [metadata]; - - if (metaOps.length !== audiences.length) { + const rawMetaOps = is.array(metadata) ? metadata : [metadata]; + if (rawMetaOps.length !== audiences.length) { return Promise.reject(new HttpStatusError(400, 'audiences must match metadata entries')); } - const operations = metaOps.map((meta, idx) => handleAudience(pipe, keys[idx], meta)); - return pipe.exec() - .then(handlePipeline) - .then((res) => mapMetaResponse(operations, res)); + const metaOps = rawMetaOps.map((opBlock) => prepareOps(opBlock)); + + scriptOpts = { metaOps, ...scriptOpts }; + return callUpdateMetadataScript(redis, userId, scriptOpts) + .then(mapUpdateResponse); } // dynamic scripts const $scriptKeys = Object.keys(script); const scripts = $scriptKeys.map((scriptName) => { const { lua, argv = [] } = script[scriptName]; - const sha = sha256(lua); - const name = `ms_users_${sha}`; - if (!is.fn(redis[name])) { - redis.defineCommand(name, { lua }); - } - return redis[name](keys.length, keys, argv); + return { + lua, + argv, + name: scriptName, + }; }); - return Promise.all(scripts).then((res) => mapScriptResponse($scriptKeys, res)); + scriptOpts = { scripts, ...scriptOpts }; + return callUpdateMetadataScript(redis, userId, scriptOpts) + .then((result) => JSONParse(result)); } -updateMetadata.handleAudience = handleAudience; +updateMetadata.callUpdateMetadataScript = callUpdateMetadataScript; +updateMetadata.prepareOps = prepareOps; module.exports = updateMetadata; diff --git a/test/suites/updateMetadata.js b/test/suites/updateMetadata.js index ef0a67265..485c14263 100644 --- a/test/suites/updateMetadata.js +++ b/test/suites/updateMetadata.js @@ -64,6 +64,7 @@ describe('#updateMetadata', function getMetadataSuite() { $incr: { b: 2, }, + $remove: ['c'], }, { $incr: { @@ -106,4 +107,35 @@ describe('#updateMetadata', function getMetadataSuite() { ]); }); }); + + it('must be able to run dynamic scripts / namespace fully available', function test() { + const dispatch = simpleDispatcher(this.users.router); + const lua = ` + local t = {} + table.insert(t, "foo") + local jsonDec = cjson.decode('{"bar": 1}') + local typeCheck = type(t) + return {jsonDec.bar, redis.call("TIME"), typeCheck, unpack(t)} + `; + + const params = { + username, + audience: [audience], + script: { + check: { + lua, + argv: ['nom-nom'], + }, + }, + }; + + return dispatch('users.updateMetadata', params) + .reflect() + .then(inspectPromise()) + .then(({ check }) => { + const [jsonVal, redisTime] = check; + expect(jsonVal).to.be.eq(1); + expect(redisTime).to.be.an('array'); + }); + }); }); From 12b83e13c1d5fbb6f942935bf03c11184c2620c5 Mon Sep 17 00:00:00 2001 From: pajgo Date: Fri, 27 Sep 2019 18:24:42 +0600 Subject: [PATCH 06/12] feat: update user and organization metadata * promise to async --- src/utils/updateMetadata.js | 15 ++++++------- test/suites/updateMetadata.js | 40 ++++++++++++++--------------------- 2 files changed, 23 insertions(+), 32 deletions(-) diff --git a/src/utils/updateMetadata.js b/src/utils/updateMetadata.js index 9ab68f57b..353c52ddd 100644 --- a/src/utils/updateMetadata.js +++ b/src/utils/updateMetadata.js @@ -1,5 +1,4 @@ /* eslint-disable no-mixed-operators */ -const Promise = require('bluebird'); const is = require('is'); const { HttpStatusError } = require('common-errors'); const mapValues = require('lodash/mapValues'); @@ -57,7 +56,7 @@ function prepareOps(ops) { * @param {Object} opts * @return {Promise} */ -function updateMetadata(opts) { +async function updateMetadata(opts) { const { redis } = this; const { userId, audience, metadata, script, @@ -71,14 +70,14 @@ function updateMetadata(opts) { if (metadata) { const rawMetaOps = is.array(metadata) ? metadata : [metadata]; if (rawMetaOps.length !== audiences.length) { - return Promise.reject(new HttpStatusError(400, 'audiences must match metadata entries')); + throw new HttpStatusError(400, 'audiences must match metadata entries'); } const metaOps = rawMetaOps.map((opBlock) => prepareOps(opBlock)); - scriptOpts = { metaOps, ...scriptOpts }; - return callUpdateMetadataScript(redis, userId, scriptOpts) - .then(mapUpdateResponse); + + const updateResult = await callUpdateMetadataScript(redis, userId, scriptOpts); + return mapUpdateResponse(updateResult); } // dynamic scripts @@ -93,8 +92,8 @@ function updateMetadata(opts) { }); scriptOpts = { scripts, ...scriptOpts }; - return callUpdateMetadataScript(redis, userId, scriptOpts) - .then((result) => JSONParse(result)); + const updateResult = await callUpdateMetadataScript(redis, userId, scriptOpts); + return JSONParse(updateResult); } updateMetadata.callUpdateMetadataScript = callUpdateMetadataScript; diff --git a/test/suites/updateMetadata.js b/test/suites/updateMetadata.js index 485c14263..e46659731 100644 --- a/test/suites/updateMetadata.js +++ b/test/suites/updateMetadata.js @@ -83,8 +83,7 @@ describe('#updateMetadata', function getMetadataSuite() { }); }); - it('must be able to run dynamic scripts', function test() { - const dispatch = simpleDispatcher(this.users.router); + it('must be able to run dynamic scripts', async function test() { const params = { username, audience: [audience, extra], @@ -96,26 +95,23 @@ describe('#updateMetadata', function getMetadataSuite() { }, }; - return dispatch('users.updateMetadata', params) - .reflect() - .then(inspectPromise()) - .then((data) => { - expect(data.balance).to.be.deep.eq([ - `{ms-users}${this.userId}!metadata!${audience}`, - `{ms-users}${this.userId}!metadata!${extra}`, - 'nom-nom', - ]); - }); + const updated = await this.dispatch('users.updateMetadata', params); + + expect(updated.balance).to.be.deep.eq([ + `{ms-users}${this.userId}!metadata!${audience}`, + `{ms-users}${this.userId}!metadata!${extra}`, + 'nom-nom', + ]); }); - it('must be able to run dynamic scripts / namespace fully available', function test() { - const dispatch = simpleDispatcher(this.users.router); + it('must be able to run dynamic scripts / default namespace available', async function test() { const lua = ` local t = {} table.insert(t, "foo") local jsonDec = cjson.decode('{"bar": 1}') local typeCheck = type(t) - return {jsonDec.bar, redis.call("TIME"), typeCheck, unpack(t)} + redis.call("SET", "fookey", 777); + return {jsonDec.bar, redis.call("TIME"), redis.call("GET", "fookey"), typeCheck, unpack(t)} `; const params = { @@ -128,14 +124,10 @@ describe('#updateMetadata', function getMetadataSuite() { }, }, }; - - return dispatch('users.updateMetadata', params) - .reflect() - .then(inspectPromise()) - .then(({ check }) => { - const [jsonVal, redisTime] = check; - expect(jsonVal).to.be.eq(1); - expect(redisTime).to.be.an('array'); - }); + const updated = await this.dispatch('users.updateMetadata', params); + const [jsonVal, redisTime, keyValue] = updated.check; + expect(jsonVal).to.be.eq(1); + expect(redisTime).to.be.an('array'); + expect(keyValue).to.be.eq('777'); }); }); From cc9f2f65890f2653f16b2a8f9ac676263b182329 Mon Sep 17 00:00:00 2001 From: pajgo Date: Wed, 2 Oct 2019 17:57:05 +0600 Subject: [PATCH 07/12] feat: update user and organization metadata * code re --- scripts/updateMetadata.lua | 90 ++++++++++++++++++++++------ src/utils/setOrganizationMetadata.js | 13 ++-- src/utils/updateMetadata.js | 22 ++++--- 3 files changed, 89 insertions(+), 36 deletions(-) diff --git a/scripts/updateMetadata.lua b/scripts/updateMetadata.lua index a34846cdb..ebd3e26fb 100644 --- a/scripts/updateMetadata.lua +++ b/scripts/updateMetadata.lua @@ -1,22 +1,38 @@ +-- script replicates commands instead of own body +-- call of HMSET command is determined as 'non deterministic command' +-- and redis refuses to run it without this. +redis.replicate_commands() + local audienceKeyTemplate = KEYS[1] local metaDataTemplate = KEYS[2] local Id = ARGV[1] local updateOptsJson = ARGV[2] -redis.replicate_commands() +local function isValidString(val) + if type(val) == 'string' and string.len(val) > 0 then + return true + end + return false +end + +assert(isValidString(audienceKeyTemplate), 'incorrect `audienceKeyTemplate` key') +assert(isValidString(metaDataTemplate), 'incorrect `metaDataTemplate` key') +assert(isValidString(Id), 'incorrect `id` argument') +assert(isValidString(updateOptsJson), 'incorrect `updateJson` argument') local updateOpts = cjson.decode(updateOptsJson) local function loadScript(code, environment) if setfenv and loadstring then local f = assert(loadstring(code)) - setfenv(f,environment) + setfenv(f, environment) return f else - return assert(load(code, nil,"t",environment)) + return assert(load(code, nil, "t", environment)) end end +-- creates array with unique items from passed arrays local function tablesUniqueItems(...) local args = {...} local tableWithUniqueItems = {} @@ -28,6 +44,7 @@ local function tablesUniqueItems(...) return tableWithUniqueItems end +-- create key from passed template, id and audience local function makeKey (template, id, audience) local str = template:gsub('{id}', id, 1) if audience ~= nil then @@ -39,6 +56,9 @@ end -- -- available ops definition -- + +-- $set: { field: value, field2: value, field3: value } +-- { HMSETResponse } local function opSet(metaKey, args) local setArgs = {} local result = {} @@ -53,14 +73,18 @@ local function opSet(metaKey, args) return result end +-- $remove: [ 'field', 'field2' ] +-- { deletedFieldsCount } - if no fields deleted or there was no such fields counter not incrementing local function opRemove(metaKey, args) local result = 0; - for i, field in pairs(args) do + for _, field in pairs(args) do result = result + redis.call("HDEL", metaKey, field) end return result end +-- $incr: { field: incrValue, field2: incrValue } +-- { field: newValue } local function opIncr(metaKey, args) local result = {} for field, incrVal in pairs(args) do @@ -81,61 +105,91 @@ local metaOps = { -- local scriptResult = {} +-- get list of keys to update +-- generate them from passed audiences and metaData key template local keysToProcess = {}; -for i, audience in ipairs(updateOpts.audiences) do +for index, audience in ipairs(updateOpts.audiences) do local key = makeKey(metaDataTemplate, Id, audience) - table.insert(keysToProcess, i, key); + table.insert(keysToProcess, index, key); end +-- process meta update operations if updateOpts.metaOps then - for i, op in ipairs(updateOpts.metaOps) do - local targetOpKey = keysToProcess[i] + -- iterate over metadata hash field + for index, op in ipairs(updateOpts.metaOps) do + local targetOpKey = keysToProcess[index] local metaProcessResult = {}; + -- iterate over commands and apply them for opName, opArg in pairs(op) do local processFn = metaOps[opName]; if processFn == nil then return redis.error_reply("Unsupported command:" .. opName) end + if type(opArg) ~= "table" then - return redis.error_reply("Args for ".. opName .." must be and array") + return redis.error_reply("Args for " .. opName .. " must be and array") end + -- store command execution result metaProcessResult[opName] = processFn(targetOpKey, opArg) end + + -- store execution result of commands block table.insert(scriptResult, metaProcessResult) end +-- process passed scripts elseif updateOpts.scripts then - local env = {}; - -- allow read access to this script scope - setmetatable(env,{__index=_G}) + -- iterate over scripts and execute them in sandbox + for _, script in pairs(updateOpts.scripts) do + local env = {}; - for i, script in pairs(updateOpts.scripts) do + -- allow read access to this script scope + -- env recreated for each script to avoid scope mixing + setmetatable(env, { __index=_G }) + + -- override params to be sure that script works like it was executed like from `redis.eval` command env.ARGV = script.argv env.KEYS = keysToProcess + + -- evaluate script and bind to custom env local fn = loadScript(script.lua, env) + -- run script and save result scriptResult[script.name] = fn() end end +-- +-- Audience tracking +-- + local audienceKey = makeKey(audienceKeyTemplate, Id) +-- get saved audience list local audiences = redis.call("SMEMBERS", audienceKey) -local processedAudiences = updateOpts.audiences -local uniqueAudiences = tablesUniqueItems(audiences, processedAudiences) +-- create list containing saved and possibly new audiences +local uniqueAudiences = tablesUniqueItems(audiences, updateOpts.audiences) + +-- iterate over final audience list for _, audience in pairs(uniqueAudiences) do + -- get size of metaKey local metaKey = makeKey(metaDataTemplate, Id, audience) - local dataLen = redis.call("HLEN", metaKey) + local keyLen = redis.call("HLEN", metaKey) - if (dataLen > 0) then + -- if key has data add it to the audience set + -- set members unique, so duplicates not appear + + -- if key empty or not exists (HLEN will return 0) + -- delete audience from list + if (keyLen > 0) then redis.call("SADD", audienceKey, audience) else redis.call("SREM", audienceKey, audience) end end - +-- respond with json encoded string return cjson.encode(scriptResult) diff --git a/src/utils/setOrganizationMetadata.js b/src/utils/setOrganizationMetadata.js index 82ef4c7bd..b58a0e6d4 100644 --- a/src/utils/setOrganizationMetadata.js +++ b/src/utils/setOrganizationMetadata.js @@ -1,14 +1,16 @@ /* eslint-disable no-mixed-operators */ const Promise = require('bluebird'); -const is = require('is'); const { HttpStatusError } = require('common-errors'); const redisKey = require('../utils/key.js'); const { prepareOps } = require('./updateMetadata'); + const { ORGANIZATIONS_METADATA, ORGANIZATIONS_AUDIENCE } = require('../constants.js'); const JSONStringify = (data) => JSON.stringify(data); -function callUpdateMetadataScript(redis, id, ops) { +function updateMetadataScript(id, ops) { + const { redis } = this; + const audienceKeyTemplate = redisKey('{id}', ORGANIZATIONS_AUDIENCE); const metaDataTemplate = redisKey('{id}', ORGANIZATIONS_METADATA, '{audience}'); @@ -22,15 +24,14 @@ function callUpdateMetadataScript(redis, id, ops) { * @return {Promise} */ async function setOrganizationMetadata(opts) { - const { redis } = this; const { organizationId, audience, metadata, } = opts; - const audiences = is.array(audience) ? audience : [audience]; + const audiences = Array.isArray(audience) ? audience : [audience]; // if we have meta, then we can if (metadata) { - const rawMetaOps = is.array(metadata) ? metadata : [metadata]; + const rawMetaOps = Array.isArray(metadata) ? metadata : [metadata]; if (rawMetaOps.length !== audiences.length) { return Promise.reject(new HttpStatusError(400, 'audiences must match metadata entries')); } @@ -38,7 +39,7 @@ async function setOrganizationMetadata(opts) { const metaOps = rawMetaOps.map((opBlock) => prepareOps(opBlock)); const scriptOpts = { metaOps, audiences }; - return callUpdateMetadataScript(redis, organizationId, scriptOpts); + return updateMetadataScript.call(this, organizationId, scriptOpts); } return true; diff --git a/src/utils/updateMetadata.js b/src/utils/updateMetadata.js index 353c52ddd..563937c9e 100644 --- a/src/utils/updateMetadata.js +++ b/src/utils/updateMetadata.js @@ -1,15 +1,16 @@ /* eslint-disable no-mixed-operators */ -const is = require('is'); const { HttpStatusError } = require('common-errors'); const mapValues = require('lodash/mapValues'); const redisKey = require('../utils/key.js'); + const { USERS_METADATA, USERS_AUDIENCE } = require('../constants.js'); const JSONStringify = (data) => JSON.stringify(data); const JSONParse = (data) => JSON.parse(data); const has = Object.prototype.hasOwnProperty; -function callUpdateMetadataScript(redis, userId, ops) { +function updateMetadataScript(userId, ops) { + const { redis } = this; const audienceKeyTemplate = redisKey('{id}', USERS_AUDIENCE); const metaDataTemplate = redisKey('{id}', USERS_METADATA, '{audience}'); @@ -20,18 +21,17 @@ function callUpdateMetadataScript(redis, userId, ops) { // Stabilizes Lua script response function mapUpdateResponse(jsonStr) { const decodedData = JSONParse(jsonStr); - const result = []; - decodedData.forEach((metaResult) => { + const result = decodedData.map((metaResult) => { const opResult = {}; for (const [key, ops] of Object.entries(metaResult)) { - if (ops.length !== undefined && ops.length === 1) { + if (Array.isArray(ops) && ops.length === 1) { [opResult[key]] = ops; } else { opResult[key] = ops; } } - result.push(opResult); + return opResult; }); return result.length > 1 ? result : result[0]; @@ -57,18 +57,17 @@ function prepareOps(ops) { * @return {Promise} */ async function updateMetadata(opts) { - const { redis } = this; const { userId, audience, metadata, script, } = opts; - const audiences = is.array(audience) ? audience : [audience]; + const audiences = Array.isArray(audience) ? audience : [audience]; let scriptOpts = { audiences, }; if (metadata) { - const rawMetaOps = is.array(metadata) ? metadata : [metadata]; + const rawMetaOps = Array.isArray(metadata) ? metadata : [metadata]; if (rawMetaOps.length !== audiences.length) { throw new HttpStatusError(400, 'audiences must match metadata entries'); } @@ -76,7 +75,7 @@ async function updateMetadata(opts) { const metaOps = rawMetaOps.map((opBlock) => prepareOps(opBlock)); scriptOpts = { metaOps, ...scriptOpts }; - const updateResult = await callUpdateMetadataScript(redis, userId, scriptOpts); + const updateResult = await updateMetadataScript.call(this, userId, scriptOpts); return mapUpdateResponse(updateResult); } @@ -92,10 +91,9 @@ async function updateMetadata(opts) { }); scriptOpts = { scripts, ...scriptOpts }; - const updateResult = await callUpdateMetadataScript(redis, userId, scriptOpts); + const updateResult = await updateMetadataScript.call(this, userId, scriptOpts); return JSONParse(updateResult); } -updateMetadata.callUpdateMetadataScript = callUpdateMetadataScript; updateMetadata.prepareOps = prepareOps; module.exports = updateMetadata; From 5f726e4eccdc14d1925bf7e36a7877c393710ed6 Mon Sep 17 00:00:00 2001 From: Pavel Rogovoi <51755949+pajgo@users.noreply.github.com> Date: Thu, 10 Oct 2019 22:08:13 +0600 Subject: [PATCH 08/12] feat: update user and organization metadata * remove key validation --- scripts/updateMetadata.lua | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/updateMetadata.lua b/scripts/updateMetadata.lua index ebd3e26fb..507d75b0e 100644 --- a/scripts/updateMetadata.lua +++ b/scripts/updateMetadata.lua @@ -15,8 +15,6 @@ local function isValidString(val) return false end -assert(isValidString(audienceKeyTemplate), 'incorrect `audienceKeyTemplate` key') -assert(isValidString(metaDataTemplate), 'incorrect `metaDataTemplate` key') assert(isValidString(Id), 'incorrect `id` argument') assert(isValidString(updateOptsJson), 'incorrect `updateJson` argument') From 9343154b234f01a08bcc3a0264d2b336be2a5ea3 Mon Sep 17 00:00:00 2001 From: pajgo Date: Mon, 21 Oct 2019 19:06:12 +0600 Subject: [PATCH 09/12] feat: update meta -> as own class --- scripts/updateMetadata.lua | 44 +++--- src/utils/metadata/redis/update-metadata.js | 131 ++++++++++++++++++ src/utils/setOrganizationMetadata.js | 39 +----- src/utils/updateMetadata.js | 113 +-------------- test/suites/updateMetadata.js | 110 --------------- .../utils/metadata/redis/update-metadata.js | 119 ++++++++++++++++ 6 files changed, 290 insertions(+), 266 deletions(-) create mode 100644 src/utils/metadata/redis/update-metadata.js create mode 100644 test/suites/utils/metadata/redis/update-metadata.js diff --git a/scripts/updateMetadata.lua b/scripts/updateMetadata.lua index 46f5ac28b..f04293548 100644 --- a/scripts/updateMetadata.lua +++ b/scripts/updateMetadata.lua @@ -1,3 +1,11 @@ +-- Script performs User/Organization metadata update and tracks used audiences +-- KEYS[1] = Audience Key template in format `{id}someExtraText{audience}` - Key stores currently used audiences associated with metadata +-- KEYS[2] = Metadata Key template in format `{id}myAvesomeMEtaKey{audience}` - Key stores metadata +-- `{id}` and `{audience}` will be replaced with real values on script runtime + +-- ARGV[1] = Id of the User/Organization which is going to be updated +-- ARGV[2] = JsonString with list of operations to execute on the metadata of the provided Id + -- script replicates commands instead of own body -- call of HMSET command is determined as 'non deterministic command' -- and redis refuses to run it without this. @@ -10,6 +18,9 @@ local updateOptsJson = ARGV[2] local scriptResult = { err = nil, ok = {}} +-- +-- Param Validation +-- local function isValidString(val) if type(val) == 'string' and string.len(val) > 0 then return true @@ -22,7 +33,12 @@ assert(isValidString(updateOptsJson), 'incorrect `updateJson` argument') local updateOpts = cjson.decode(updateOptsJson) -local function loadScript(code, environment) +-- +-- Internal functions +-- + +-- evaluates provided script +local function evalLuaScript(code, environment) if setfenv and loadstring then local f = assert(loadstring(code)) setfenv(f, environment) @@ -45,7 +61,7 @@ local function getUniqueItemsFromTables(...) end -- create key from passed template, id and audience -local function makeKey (template, id, audience) +local function makeRedisKey (template, id, audience) local str = template:gsub('{id}', id, 1) if audience ~= nil then str = str:gsub('{audience}', audience, 1) @@ -53,12 +69,8 @@ local function makeKey (template, id, audience) return str end -local function isCommandError(resultOrError) - return (type(resultOrError) == 'table' and resultOrError['err'] ~= nil) -end - -local function checkForCommandError(result, command, args) - if isCommandError(result) == true then +local function getResultOrSaveError(result, command, args) + if type(result) == 'table' and result['err'] ~= nil then if (scriptResult['err'] == nil) then scriptResult['err'] = {} end @@ -75,7 +87,7 @@ local function checkForCommandError(result, command, args) end -- --- available ops definition +-- available Meta Operations definition -- -- $set: { field: value, field2: value, field3: value } @@ -89,7 +101,7 @@ local function opSet(metaKey, args) end local cmdResult = redis.pcall("HMSET", metaKey, unpack(setArgs)) - cmdResult = checkForCommandError(cmdResult, "HMSET", setArgs) + cmdResult = getResultOrSaveError(cmdResult, "HMSET", setArgs) if cmdResult ~= nil then return cmdResult.ok end @@ -103,7 +115,7 @@ local function opRemove(metaKey, args) local result = 0; for _, field in pairs(args) do local cmdResult = redis.pcall("HDEL", metaKey, field) - result = result + checkForCommandError(cmdResult, "HDEL", { metaKey, field }) + result = result + getResultOrSaveError(cmdResult, "HDEL", { metaKey, field }) end return result end @@ -115,7 +127,7 @@ local function opIncr(metaKey, args) for field, incrVal in pairs(args) do -- TODO fix err local cmdResult = redis.pcall("HINCRBY", metaKey, field, incrVal) - cmdResult = checkForCommandError(cmdResult, "HINCRBY", { metaKey, field, incrVal }) + cmdResult = getResultOrSaveError(cmdResult, "HINCRBY", { metaKey, field, incrVal }) result[field] = cmdResult end -- if #result > 0 then @@ -141,7 +153,7 @@ local metaOps = { -- generate them from passed audiences and metaData key template local keysToProcess = {}; for index, audience in ipairs(updateOpts.audiences) do - local key = makeKey(metaDataTemplate, Id, audience) + local key = makeRedisKey(metaDataTemplate, Id, audience) table.insert(keysToProcess, index, key); end @@ -181,7 +193,7 @@ elseif updateOpts.scripts then env.KEYS = keysToProcess -- evaluate script and bind to custom env - local fn = loadScript(script.lua, env) + local fn = evalLuaScript(script.lua, env) -- run script and save result local status, result = pcall(fn) @@ -207,7 +219,7 @@ end -- Audience tracking -- -local audienceKey = makeKey(audienceKeyTemplate, Id) +local audienceKey = makeRedisKey(audienceKeyTemplate, Id) -- get saved audience list local audiences = redis.call("SMEMBERS", audienceKey) @@ -217,7 +229,7 @@ local uniqueAudiences = getUniqueItemsFromTables(audiences, updateOpts.audiences -- iterate over final audience list for _, audience in pairs(uniqueAudiences) do -- get size of metaKey - local metaKey = makeKey(metaDataTemplate, Id, audience) + local metaKey = makeRedisKey(metaDataTemplate, Id, audience) local keyLen = redis.call("HLEN", metaKey) -- if key has data add it to the audience set diff --git a/src/utils/metadata/redis/update-metadata.js b/src/utils/metadata/redis/update-metadata.js new file mode 100644 index 000000000..2eb0f00b9 --- /dev/null +++ b/src/utils/metadata/redis/update-metadata.js @@ -0,0 +1,131 @@ +const { HttpStatusError } = require('common-errors'); +const { RedisError } = require('common-errors').data; +const mapValues = require('lodash/mapValues'); + +/** + * Class wraps User/Organization metadata update using atomic LUA script + */ +class UpdateMetadata { + /** + * @param redis + * @param metadataKeyTemplate + * @param audienceKeyTemplate + */ + constructor(redis, metadataKeyTemplate, audienceKeyTemplate) { + this.redis = redis; + this.audienceKeyTemplate = audienceKeyTemplate; + this.metadataKeyTemplate = metadataKeyTemplate; + } + + callLuaScript(id, ops) { + return this.redis + .updateMetadata(2, this.audienceKeyTemplate, this.metadataKeyTemplate, id, JSON.stringify(ops)); + } + + /** + * Updates metadata on a user object + * @param {Object} opts + * @return {Promise} + */ + async update(opts) { + const { + id, audience, metadata, script, + } = opts; + const audiences = Array.isArray(audience) ? audience : [audience]; + + let scriptOpts = { + audiences, + }; + + if (metadata) { + const rawMetaOps = Array.isArray(metadata) ? metadata : [metadata]; + if (rawMetaOps.length !== audiences.length) { + throw new HttpStatusError(400, 'audiences must match metadata entries'); + } + + const metaOps = rawMetaOps.map((opBlock) => UpdateMetadata.prepareOperations(opBlock)); + scriptOpts = { metaOps, ...scriptOpts }; + + const updateJsonResult = await this.callLuaScript(id, scriptOpts); + return UpdateMetadata.processOpUpdateResponse(updateJsonResult); + } + + // dynamic scripts + const $scriptKeys = Object.keys(script); + const scripts = $scriptKeys.map((scriptName) => { + const { lua, argv = [] } = script[scriptName]; + return { + lua, + argv, + name: scriptName, + }; + }); + + scriptOpts = { scripts, ...scriptOpts }; + const updateResultJson = await this.callLuaScript(id, scriptOpts); + return UpdateMetadata.processLuaUpdateResponse(updateResultJson); + } + + /** + * Process results returned from LUA script when subset of Meta Operations passed + * @param jsonStr + * @returns {*} + */ + static processOpUpdateResponse(jsonStr) { + const decodedData = JSON.parse(jsonStr); + + if (decodedData.err !== undefined) { + const errors = Object.entries(decodedData.err); + const message = errors.map(([, error]) => error.err).join('; '); + + throw new RedisError(message, decodedData.err); + } + + const result = decodedData.ok.map((metaResult) => { + const opResult = {}; + for (const [key, ops] of Object.entries(metaResult)) { + if (Array.isArray(ops) && ops.length === 1) { + [opResult[key]] = ops; + } else { + opResult[key] = ops; + } + } + return opResult; + }); + + return result.length > 1 ? result : result[0]; + } + + /** + * Process results returned from LUA script when subset of LUA scripts passed + * @param jsonStr + * @returns {Response.ok|((value: any, message?: (string | Error)) => void)|string|boolean} + */ + static processLuaUpdateResponse(jsonStr) { + const decodedData = JSON.parse(jsonStr); + + if (decodedData.err !== undefined) { + const errors = Object.entries(decodedData.err); + const message = errors.map(([, error]) => `Script: ${error.script} Failed with error: ${error.err}`).join('; '); + throw new RedisError(message, decodedData.err); + } + + return decodedData.ok; + } + + /** + * Encodes operation field values ito json string + * If encoding performed in LUA script using CJSON lib, empty arrays become empty objects. + * This breaks logic + * @param metaOps + * @returns {*} + */ + static prepareOperations(ops) { + if (Object.hasOwnProperty.call(ops, '$set')) { + ops.$set = mapValues(ops.$set, JSON.stringify); + } + return ops; + } +} + +module.exports = UpdateMetadata; diff --git a/src/utils/setOrganizationMetadata.js b/src/utils/setOrganizationMetadata.js index b58a0e6d4..843f64e70 100644 --- a/src/utils/setOrganizationMetadata.js +++ b/src/utils/setOrganizationMetadata.js @@ -1,22 +1,9 @@ /* eslint-disable no-mixed-operators */ -const Promise = require('bluebird'); -const { HttpStatusError } = require('common-errors'); +const MetaUpdate = require('../utils/metadata/redis/update-metadata'); const redisKey = require('../utils/key.js'); -const { prepareOps } = require('./updateMetadata'); const { ORGANIZATIONS_METADATA, ORGANIZATIONS_AUDIENCE } = require('../constants.js'); -const JSONStringify = (data) => JSON.stringify(data); - -function updateMetadataScript(id, ops) { - const { redis } = this; - - const audienceKeyTemplate = redisKey('{id}', ORGANIZATIONS_AUDIENCE); - const metaDataTemplate = redisKey('{id}', ORGANIZATIONS_METADATA, '{audience}'); - - return redis - .updateMetadata(2, audienceKeyTemplate, metaDataTemplate, id, JSONStringify(ops)); -} /** * Updates metadata on a organization object @@ -24,25 +11,11 @@ function updateMetadataScript(id, ops) { * @return {Promise} */ async function setOrganizationMetadata(opts) { - const { - organizationId, audience, metadata, - } = opts; - const audiences = Array.isArray(audience) ? audience : [audience]; - - // if we have meta, then we can - if (metadata) { - const rawMetaOps = Array.isArray(metadata) ? metadata : [metadata]; - if (rawMetaOps.length !== audiences.length) { - return Promise.reject(new HttpStatusError(400, 'audiences must match metadata entries')); - } - - const metaOps = rawMetaOps.map((opBlock) => prepareOps(opBlock)); - - const scriptOpts = { metaOps, audiences }; - return updateMetadataScript.call(this, organizationId, scriptOpts); - } - - return true; + const audienceKeyTemplate = redisKey('{id}', ORGANIZATIONS_AUDIENCE); + const metaDataTemplate = redisKey('{id}', ORGANIZATIONS_METADATA, '{audience}'); + const metaUpdater = new MetaUpdate(this.redis, metaDataTemplate, audienceKeyTemplate); + const { organizationId, ...restOpts } = opts; + return metaUpdater.update({ id: organizationId, ...restOpts }); } module.exports = setOrganizationMetadata; diff --git a/src/utils/updateMetadata.js b/src/utils/updateMetadata.js index 799da8679..0f862b0e9 100644 --- a/src/utils/updateMetadata.js +++ b/src/utils/updateMetadata.js @@ -1,121 +1,20 @@ /* eslint-disable no-mixed-operators */ -const { HttpStatusError } = require('common-errors'); -const mapValues = require('lodash/mapValues'); -const { RedisError } = require('common-errors').data; - const redisKey = require('../utils/key.js'); +const MetaUpdate = require('../utils/metadata/redis/update-metadata'); const { USERS_METADATA, USERS_AUDIENCE } = require('../constants.js'); -const JSONStringify = (data) => JSON.stringify(data); -const JSONParse = (data) => JSON.parse(data); -const has = Object.prototype.hasOwnProperty; - -function updateMetadataScript(userId, ops) { - const { redis } = this; - const audienceKeyTemplate = redisKey('{id}', USERS_AUDIENCE); - const metaDataTemplate = redisKey('{id}', USERS_METADATA, '{audience}'); - - return redis - .updateMetadata(2, audienceKeyTemplate, metaDataTemplate, userId, JSONStringify(ops)); -} - -// Stabilizes Lua script response -function processUpdateScriptResponse(jsonStr) { - const decodedData = JSONParse(jsonStr); - - if (decodedData.err !== undefined) { - const errors = Object.entries(decodedData.err); - const message = errors.map(([, error]) => error.err).join('; '); - - throw new RedisError(message, decodedData.err); - } - - const result = decodedData.ok.map((metaResult) => { - const opResult = {}; - for (const [key, ops] of Object.entries(metaResult)) { - if (Array.isArray(ops) && ops.length === 1) { - [opResult[key]] = ops; - } else { - opResult[key] = ops; - } - } - return opResult; - }); - - return result.length > 1 ? result : result[0]; -} - -function processUpdateScriptLuaResponse(jsonStr) { - const decodedData = JSONParse(jsonStr); - - if (decodedData.err !== undefined) { - const errors = Object.entries(decodedData.err); - const message = errors.map(([, error]) => `Script: ${error.script} Failed with error: ${error.err}`).join('; '); - throw new RedisError(message, decodedData.err); - } - - return decodedData.ok; -} - -/** - * Encodes operation field values ito json string - * If encoding performed in LUA script using CJSON lib, empty arrays become empty objects. - * This breaks logic - * @param metaOps - * @returns {*} - */ -function prepareOps(ops) { - if (has.call(ops, '$set')) { - ops.$set = mapValues(ops.$set, JSONStringify); - } - return ops; -} - /** * Updates metadata on a user object * @param {Object} opts * @return {Promise} */ async function updateMetadata(opts) { - const { - userId, audience, metadata, script, - } = opts; - const audiences = Array.isArray(audience) ? audience : [audience]; - - let scriptOpts = { - audiences, - }; - - if (metadata) { - const rawMetaOps = Array.isArray(metadata) ? metadata : [metadata]; - if (rawMetaOps.length !== audiences.length) { - throw new HttpStatusError(400, 'audiences must match metadata entries'); - } - - const metaOps = rawMetaOps.map((opBlock) => prepareOps(opBlock)); - scriptOpts = { metaOps, ...scriptOpts }; - - const updateResult = await updateMetadataScript.call(this, userId, scriptOpts); - return processUpdateScriptResponse(updateResult); - } - - // dynamic scripts - const $scriptKeys = Object.keys(script); - const scripts = $scriptKeys.map((scriptName) => { - const { lua, argv = [] } = script[scriptName]; - return { - lua, - argv, - name: scriptName, - }; - }); - - scriptOpts = { scripts, ...scriptOpts }; - const updateResult = await updateMetadataScript.call(this, userId, scriptOpts); - - return processUpdateScriptLuaResponse(updateResult); + const audienceKeyTemplate = redisKey('{id}', USERS_AUDIENCE); + const metaDataTemplate = redisKey('{id}', USERS_METADATA, '{audience}'); + const metaUpdater = new MetaUpdate(this.redis, metaDataTemplate, audienceKeyTemplate); + const { userId, ...restOpts } = opts; + return metaUpdater.update({ id: userId, ...restOpts }); } -updateMetadata.prepareOps = prepareOps; module.exports = updateMetadata; diff --git a/test/suites/updateMetadata.js b/test/suites/updateMetadata.js index 7890bf923..3ec6733df 100644 --- a/test/suites/updateMetadata.js +++ b/test/suites/updateMetadata.js @@ -131,114 +131,4 @@ describe('#updateMetadata', function getMetadataSuite() { expect(redisTime).to.be.an('array'); expect(keyValue).to.be.eq('777'); }); - - // direct access test suite. Validator doesn't allow us to use incorrect arguments - describe('it must behave like js updateMetadata', function mimicTest() { - const util = require('util'); - const { RedisError } = require('common-errors').data; - let updateMetadata; - - beforeEach('setUserProps', async function setUserProps() { - const params = { - userId: this.userId, - audience: [ - audience, - ], - metadata: [ - { - $set: { - x: 10, - b: 12, - c: 'cval', - }, - }, - ], - }; - - updateMetadata = require('../../src/utils/updateMetadata.js').bind(this.users); - await updateMetadata(params); - }); - - // should error if one of the commands failed to run - // BUT other commands must be executed - it('update using changesets behaves like pipeline', async function test() { - const params = { - userId: this.userId, - audience: [ - audience, - ], - metadata: [ - { - $set: { - x: 10, - y: null, - }, - $incr: { - b: 2, - d: 'asf', - }, - $remove: ['c'], - }, - ], - }; - - let updateError; - try { - await updateMetadata(params); - } catch (e) { - updateError = e; - } - console.log(util.inspect(updateError, { depth: null })); - - expect(updateError).to.be.an.instanceof(RedisError, 'should throw error'); - - const redisUserMetaKey = `${this.userId}!metadata!${audience}`; - const userData = await this.users.redis.hgetall(redisUserMetaKey); - - expect(userData).to.include({ - x: '10', - b: '14', - }); - - expect(userData).to.not.include({ - c: 'cval', - }); - }); - - it('update executes all LUA scripts despite on the script error', async function test() { - const luaScript = ` - redis.call("SET", '{ms-users}myTestKey', 777) - return ARGV[1] - `; - - const params = { - userId: this.userId, - audience: [audience], - script: { - firstScript: { - lua: 'return foo', - }, - secondScript: { - lua: luaScript, - argv: ['777'], - }, - }, - }; - - let updateError; - - try { - await updateMetadata(params); - } catch (e) { - updateError = e; - } - console.log(util.inspect(updateError, { depth: null })); - - expect(updateError).to.be.an.instanceof(RedisError, 'should throw error'); - - const testKeyContents = await this.users.redis.get('myTestKey'); - expect(testKeyContents).to.be.equal('777'); - }); - }); - }); diff --git a/test/suites/utils/metadata/redis/update-metadata.js b/test/suites/utils/metadata/redis/update-metadata.js new file mode 100644 index 000000000..9a1b32fbb --- /dev/null +++ b/test/suites/utils/metadata/redis/update-metadata.js @@ -0,0 +1,119 @@ +const { expect } = require('chai'); +const { RedisError } = require('common-errors').data; + +const UpdateMetaData = require('../../../../../src/utils/updateMetadata'); +// direct access test suite. Validator doesn't allow us to use incorrect arguments +describe('#updateMetadata LUA script', function updateMetadataLuaSuite() { + const username = 'v@makeomatic.ru'; + const audience = '*.localhost'; + let updateMetadata; + + beforeEach(global.startService.bind(this)); + afterEach(global.clearRedis.bind(this)); + + beforeEach(async () => { + await this.dispatch('users.register', { username, password: '123', audience }) + .tap(({ user }) => { this.userId = user.id; }); + }); + + + beforeEach('setUserProps', async () => { + const params = { + userId: this.userId, + audience: [ + audience, + ], + metadata: [ + { + $set: { + x: 10, + b: 12, + c: 'cval', + }, + }, + ], + }; + + updateMetadata = UpdateMetaData.bind(this.users); + await updateMetadata(params); + }); + + // should error if one of the commands failed to run + // BUT other commands must be executed + it('behaves like Redis pipeline using MetaOperations', async () => { + const params = { + userId: this.userId, + audience: [ + audience, + ], + metadata: [ + { + $set: { + x: 10, + y: null, + }, + $incr: { + b: 2, + d: 'asf', + }, + $remove: ['c'], + }, + ], + }; + + let updateError; + try { + await updateMetadata(params); + } catch (e) { + updateError = e; + } + expect(updateError).to.be.an.instanceof(RedisError, 'should throw error'); + + const redisUserMetaKey = `${this.userId}!metadata!${audience}`; + const userData = await this.users.redis.hgetall(redisUserMetaKey); + + expect(userData).to.include({ x: '10', b: '14' }); + expect(userData).to.not.include({ c: 'cval' }); + }); + + it('executes LUA scripts despite on some of the scripts error', async () => { + const luaScript = ` + redis.call("SET", '{ms-users}myTestKey' .. ARGV[1], ARGV[1]) + return ARGV[1] + `; + + const params = { + userId: this.userId, + audience: [audience], + script: { + firstScript: { + lua: 'return foo', + }, + secondScript: { + lua: luaScript, + argv: ['777'], + }, + thirdScript: { + lua: luaScript, + argv: ['888'], + }, + }, + }; + + let updateError; + + try { + await updateMetadata(params); + } catch (e) { + updateError = e; + } + + expect(updateError).to.be.an.instanceof(RedisError, 'should throw error'); + + const testKeyContents = await this.users.redis.get('myTestKey777'); + expect(testKeyContents).to.be.equal('777'); + + const secondTestKeyContents = await this.users.redis.get('myTestKey888'); + expect(secondTestKeyContents).to.be.equal('888'); + }); +}); From ce395596d5e9de90318b3fb6da39372a6a344096 Mon Sep 17 00:00:00 2001 From: pajgo Date: Mon, 21 Oct 2019 20:44:22 +0600 Subject: [PATCH 10/12] feat: update meta -> update meta classes --- scripts/updateMetadata.lua | 6 +- src/actions/register.js | 4 +- src/actions/updateMetadata.js | 15 +++-- src/auth/oauth/utils/attach.js | 30 ++++----- src/auth/oauth/utils/detach.js | 30 ++++----- src/custom/cappasity-users-activate.js | 7 ++- src/custom/rfx-create-room-on-activate.js | 8 ++- src/utils/metadata/redis/update-metadata.js | 1 + .../metadata/update-organization-metadata.js | 25 ++++++++ src/utils/metadata/update-user-metadata.js | 25 ++++++++ .../registerOrganizationMembers.js | 5 +- src/utils/updateMetadata.js | 20 ------ .../utils/metadata/redis/update-metadata.js | 62 +++++++++++++------ 13 files changed, 154 insertions(+), 84 deletions(-) create mode 100644 src/utils/metadata/update-organization-metadata.js create mode 100644 src/utils/metadata/update-user-metadata.js delete mode 100644 src/utils/updateMetadata.js diff --git a/scripts/updateMetadata.lua b/scripts/updateMetadata.lua index f04293548..d3acc7e44 100644 --- a/scripts/updateMetadata.lua +++ b/scripts/updateMetadata.lua @@ -1,5 +1,5 @@ -- Script performs User/Organization metadata update and tracks used audiences --- KEYS[1] = Audience Key template in format `{id}someExtraText{audience}` - Key stores currently used audiences associated with metadata +-- KEYS[1] = Audience Key template in format `{id}someExtraText` - Key stores currently used audiences associated with metadata -- KEYS[2] = Metadata Key template in format `{id}myAvesomeMEtaKey{audience}` - Key stores metadata -- `{id}` and `{audience}` will be replaced with real values on script runtime @@ -100,6 +100,10 @@ local function opSet(metaKey, args) table.insert(setArgs, value) end + if #setArgs < 1 then + return nil + end + local cmdResult = redis.pcall("HMSET", metaKey, unpack(setArgs)) cmdResult = getResultOrSaveError(cmdResult, "HMSET", setArgs) if cmdResult ~= nil then diff --git a/src/actions/register.js b/src/actions/register.js index ba0413630..5183f63b5 100644 --- a/src/actions/register.js +++ b/src/actions/register.js @@ -9,7 +9,7 @@ const reduce = require('lodash/reduce'); const last = require('lodash/last'); // internal deps -const setMetadata = require('../utils/updateMetadata'); +const SetUserMetadata = require('../utils/metadata/update-user-metadata'); const redisKey = require('../utils/key'); const jwt = require('../utils/jwt'); const isDisposable = require('../utils/isDisposable'); @@ -213,7 +213,7 @@ async function performRegistration({ service, params }) { await pipeline.exec().then(handlePipeline); - await setMetadata.call(service, { + await new SetUserMetadata(service.redis).update({ userId, audience, metadata: audience.map((metaAudience) => ({ diff --git a/src/actions/updateMetadata.js b/src/actions/updateMetadata.js index 450762c99..a0ef282bc 100644 --- a/src/actions/updateMetadata.js +++ b/src/actions/updateMetadata.js @@ -1,6 +1,6 @@ const omit = require('lodash/omit'); const Promise = require('bluebird'); -const updateMetadata = require('../utils/updateMetadata.js'); +const UpdateUserMetadata = require('../utils/metadata/update-user-metadata'); const { getUserId } = require('../utils/userData'); /** @@ -19,12 +19,15 @@ const { getUserId } = require('../utils/userData'); * @apiParam (Payload) {Object} [script] - if present will be called with passed metadata keys & username, provides direct scripting access. * Be careful with granting access to this function. */ -module.exports = function updateMetadataAction(request) { - return Promise +module.exports = async function updateMetadataAction(request) { + const userId = await Promise .bind(this, request.params.username) - .then(getUserId) - .then((userId) => ({ ...omit(request.params, 'username'), userId })) - .then(updateMetadata); + .then(getUserId); + + const updateUserMetadata = new UpdateUserMetadata(this.redis); + const updateParams = { ...omit(request.params, 'username'), userId }; + + return updateUserMetadata.update(updateParams); }; module.exports.transports = [require('@microfleet/core').ActionTransport.amqp]; diff --git a/src/auth/oauth/utils/attach.js b/src/auth/oauth/utils/attach.js index 1e1569c44..5fbff120e 100644 --- a/src/auth/oauth/utils/attach.js +++ b/src/auth/oauth/utils/attach.js @@ -1,13 +1,13 @@ const get = require('lodash/get'); const redisKey = require('../../../utils/key'); -const updateMetadata = require('../../../utils/updateMetadata'); +const UpdateUserMetadata = require('../../../utils/metadata/update-user-metadata'); const handlePipeline = require('../../../utils/pipelineError'); const { USERS_SSO_TO_ID, USERS_DATA, } = require('../../../constants'); -module.exports = function attach(account, user) { +module.exports = async function attach(account, user) { const { redis, config } = this; const { id: userId } = user; const { @@ -23,17 +23,19 @@ module.exports = function attach(account, user) { // link uid to user id pipeline.hset(USERS_SSO_TO_ID, uid, userId); - return pipeline.exec().then(handlePipeline) - .bind(this) - .return({ - userId, - audience, - metadata: { - $set: { - [provider]: profile, - }, + await pipeline.exec().then(handlePipeline); + + const updateMetadata = new UpdateUserMetadata(redis); + const updateParams = { + userId, + audience, + metadata: { + $set: { + [provider]: profile, }, - }) - .then(updateMetadata) - .return(profile); + }, + }; + await updateMetadata.update(updateParams); + + return profile; }; diff --git a/src/auth/oauth/utils/detach.js b/src/auth/oauth/utils/detach.js index f18cfd11f..c24fe0d6a 100644 --- a/src/auth/oauth/utils/detach.js +++ b/src/auth/oauth/utils/detach.js @@ -2,7 +2,7 @@ const Errors = require('common-errors'); const get = require('../../../utils/get-value'); const redisKey = require('../../../utils/key'); -const updateMetadata = require('../../../utils/updateMetadata'); +const UpdateUserMetadata = require('../../../utils/metadata/update-user-metadata'); const handlePipeline = require('../../../utils/pipelineError'); const { @@ -10,7 +10,7 @@ const { USERS_DATA, } = require('../../../constants'); -module.exports = function detach(provider, userData) { +module.exports = async function detach(provider, userData) { const { id: userId } = userData; const { redis, config } = this; const audience = get(config, 'jwt.defaultAudience'); @@ -28,16 +28,18 @@ module.exports = function detach(provider, userData) { // delete account reference pipeline.hdel(USERS_SSO_TO_ID, uid); - return pipeline.exec().then(handlePipeline) - .bind(this) - .return({ - userId, - audience, - metadata: { - $remove: [ - provider, - ], - }, - }) - .then(updateMetadata); + await pipeline.exec().then(handlePipeline); + + const updateMetadata = new UpdateUserMetadata(redis); + const updateParams = { + userId, + audience, + metadata: { + $remove: [ + provider, + ], + }, + }; + + return updateMetadata.update(updateParams); }; diff --git a/src/custom/cappasity-users-activate.js b/src/custom/cappasity-users-activate.js index ab9826edd..69974299e 100644 --- a/src/custom/cappasity-users-activate.js +++ b/src/custom/cappasity-users-activate.js @@ -1,6 +1,6 @@ const find = require('lodash/find'); const moment = require('moment'); -const setMetadata = require('../utils/updateMetadata.js'); +const UpdateUserMetadata = require('../utils/metadata/update-user-metadata'); /** * Adds metadata from billing into usermix @@ -13,6 +13,7 @@ module.exports = function mixPlan(userId, params) { const { payments } = config; const route = [payments.prefix, payments.routes.planGet].join('.'); const id = 'free'; + const updateMetadata = new UpdateUserMetadata(this.redis); return amqp .publishAndWait(route, id, { timeout: 5000 }) @@ -20,7 +21,7 @@ module.exports = function mixPlan(userId, params) { .then(function mix(plan) { const subscription = find(plan.subs, ['name', 'month']); const nextCycle = moment().add(1, 'month').valueOf(); - const update = { + const updateParams = { userId, audience, metadata: { @@ -36,6 +37,6 @@ module.exports = function mixPlan(userId, params) { }, }; - return setMetadata.call(this, update); + return updateMetadata.update(updateParams); }); }; diff --git a/src/custom/rfx-create-room-on-activate.js b/src/custom/rfx-create-room-on-activate.js index 10429ce16..74c9b6af9 100644 --- a/src/custom/rfx-create-room-on-activate.js +++ b/src/custom/rfx-create-room-on-activate.js @@ -1,6 +1,6 @@ const is = require('is'); const Promise = require('bluebird'); -const setMetadata = require('../utils/updateMetadata.js'); +const UpdateUserMetadata = require('../utils/metadata/update-user-metadata'); /** * @param {String} username @@ -22,10 +22,12 @@ function createRoom(userId, params, metadata) { name: `${metadata[audience].stationName} | ${metadata[audience].stationSchool}`, }; + const updateMetadata = new UpdateUserMetadata(this.redis); + return amqp.publishAndWait(route, roomParams, { timeout: 5000 }) .bind(this) .then((room) => { - const update = { + const updateParams = { userId, audience, metadata: { @@ -35,7 +37,7 @@ function createRoom(userId, params, metadata) { }, }; - return setMetadata.call(this, update); + return updateMetadata.update(updateParams); }); } diff --git a/src/utils/metadata/redis/update-metadata.js b/src/utils/metadata/redis/update-metadata.js index 2eb0f00b9..4c7befdea 100644 --- a/src/utils/metadata/redis/update-metadata.js +++ b/src/utils/metadata/redis/update-metadata.js @@ -47,6 +47,7 @@ class UpdateMetadata { scriptOpts = { metaOps, ...scriptOpts }; const updateJsonResult = await this.callLuaScript(id, scriptOpts); + console.log(updateJsonResult, scriptOpts); return UpdateMetadata.processOpUpdateResponse(updateJsonResult); } diff --git a/src/utils/metadata/update-organization-metadata.js b/src/utils/metadata/update-organization-metadata.js new file mode 100644 index 000000000..6dabed949 --- /dev/null +++ b/src/utils/metadata/update-organization-metadata.js @@ -0,0 +1,25 @@ +const redisKey = require('../key'); + +const MetaUpdate = require('./redis/update-metadata'); +const { ORGANIZATIONS_METADATA, ORGANIZATIONS_AUDIENCE } = require('../../constants'); + +class UpdateOrganizationMetadata { + constructor(redis) { + this.redis = redis; + const audienceKeyTemplate = redisKey('{id}', ORGANIZATIONS_AUDIENCE); + const metaDataTemplate = redisKey('{id}', ORGANIZATIONS_METADATA, '{audience}'); + this.metaUpdater = new MetaUpdate(this.redis, metaDataTemplate, audienceKeyTemplate); + } + + /** + * Updates metadata on a organization object + * @param {Object} opts + * @return {Promise} + */ + update(opts) { + const { organizationId, ...restOpts } = opts; + return this.metaUpdater.update({ id: organizationId, ...restOpts }); + } +} + +module.exports = UpdateOrganizationMetadata; diff --git a/src/utils/metadata/update-user-metadata.js b/src/utils/metadata/update-user-metadata.js new file mode 100644 index 000000000..e4002dc2e --- /dev/null +++ b/src/utils/metadata/update-user-metadata.js @@ -0,0 +1,25 @@ +const redisKey = require('../key'); + +const MetaUpdate = require('./redis/update-metadata'); +const { USERS_METADATA, USERS_AUDIENCE } = require('../../constants'); + +class UpdateUserMetadata { + constructor(redis) { + this.redis = redis; + const audienceKeyTemplate = redisKey('{id}', USERS_AUDIENCE); + const metaDataTemplate = redisKey('{id}', USERS_METADATA, '{audience}'); + this.metaUpdater = new MetaUpdate(this.redis, metaDataTemplate, audienceKeyTemplate); + } + + /** + * Updates metadata on a user object + * @param {Object} opts + * @return {Promise} + */ + update(opts) { + const { userId, ...restOpts } = opts; + return this.metaUpdater.update({ id: userId, ...restOpts }); + } +} + +module.exports = UpdateUserMetadata; diff --git a/src/utils/organization/registerOrganizationMembers.js b/src/utils/organization/registerOrganizationMembers.js index a97c1fa2a..bb318a469 100644 --- a/src/utils/organization/registerOrganizationMembers.js +++ b/src/utils/organization/registerOrganizationMembers.js @@ -14,7 +14,7 @@ const { USERS_ID_FIELD, } = require('../../constants.js'); const scrypt = require('../scrypt'); -const setMetadata = require('../updateMetadata'); +const UpdateUserMetadata = require('../metadata/update-user-metadata'); async function registerOrganizationMember(member) { const { redis, config } = this; @@ -36,7 +36,8 @@ async function registerOrganizationMember(member) { pipeline.hset(USERS_USERNAME_TO_ID, email, userId); await pipeline.exec().then(handlePipeline); - await setMetadata.call(this, { + const updateUserMetadata = new UpdateUserMetadata(redis); + await updateUserMetadata.update({ userId, audience, metadata: [{ diff --git a/src/utils/updateMetadata.js b/src/utils/updateMetadata.js deleted file mode 100644 index 0f862b0e9..000000000 --- a/src/utils/updateMetadata.js +++ /dev/null @@ -1,20 +0,0 @@ -/* eslint-disable no-mixed-operators */ -const redisKey = require('../utils/key.js'); - -const MetaUpdate = require('../utils/metadata/redis/update-metadata'); -const { USERS_METADATA, USERS_AUDIENCE } = require('../constants.js'); - -/** - * Updates metadata on a user object - * @param {Object} opts - * @return {Promise} - */ -async function updateMetadata(opts) { - const audienceKeyTemplate = redisKey('{id}', USERS_AUDIENCE); - const metaDataTemplate = redisKey('{id}', USERS_METADATA, '{audience}'); - const metaUpdater = new MetaUpdate(this.redis, metaDataTemplate, audienceKeyTemplate); - const { userId, ...restOpts } = opts; - return metaUpdater.update({ id: userId, ...restOpts }); -} - -module.exports = updateMetadata; diff --git a/test/suites/utils/metadata/redis/update-metadata.js b/test/suites/utils/metadata/redis/update-metadata.js index 9a1b32fbb..2adfddebe 100644 --- a/test/suites/utils/metadata/redis/update-metadata.js +++ b/test/suites/utils/metadata/redis/update-metadata.js @@ -1,27 +1,23 @@ const { expect } = require('chai'); const { RedisError } = require('common-errors').data; -const UpdateMetaData = require('../../../../../src/utils/updateMetadata'); +const UpdateMetaData = require('../../../../../src/utils/metadata/redis/update-metadata'); // direct access test suite. Validator doesn't allow us to use incorrect arguments describe('#updateMetadata LUA script', function updateMetadataLuaSuite() { - const username = 'v@makeomatic.ru'; + const id = '7777777777777'; const audience = '*.localhost'; - let updateMetadata; - - beforeEach(global.startService.bind(this)); - afterEach(global.clearRedis.bind(this)); - - beforeEach(async () => { - await this.dispatch('users.register', { username, password: '123', audience }) - .tap(({ user }) => { this.userId = user.id; }); - }); + let metaUpdater; + before(global.startService.bind(this)); + afterEach(global.clearRedis.bind(this, true)); + after(global.clearRedis.bind(this)); beforeEach('setUserProps', async () => { const params = { - userId: this.userId, + id, audience: [ audience, + '*.extra', ], metadata: [ { @@ -30,19 +26,47 @@ describe('#updateMetadata LUA script', function updateMetadataLuaSuite() { b: 12, c: 'cval', }, + }, { + $set: { + x: 20, + b: 22, + c: 'xval', + }, }, ], }; - updateMetadata = UpdateMetaData.bind(this.users); - await updateMetadata(params); + metaUpdater = new UpdateMetaData(this.users.redis, '{id}:testMeta:{audience}', '{id}:audience'); + await metaUpdater.update(params); + }); + + it('tracks audienceList', async () => { + const audiencesList = await this.users.redis.smembers(`${id}:audience`); + expect(audiencesList).to.be.deep.equal(['*.localhost', '*.extra']); + }); + + it('tracks audienceList after remove', async () => { + await metaUpdater.update({ + id, + audience: [ + '*.extra', + ], + metadata: [ + { + $remove: ['x','c','b'], + }, + ], + }); + + const audiencesList = await this.users.redis.smembers(`${id}:audience`); + expect(audiencesList).to.be.deep.equal(['*.localhost']); }); // should error if one of the commands failed to run // BUT other commands must be executed it('behaves like Redis pipeline using MetaOperations', async () => { const params = { - userId: this.userId, + id, audience: [ audience, ], @@ -63,13 +87,13 @@ describe('#updateMetadata LUA script', function updateMetadataLuaSuite() { let updateError; try { - await updateMetadata(params); + await metaUpdater.update(params); } catch (e) { updateError = e; } expect(updateError).to.be.an.instanceof(RedisError, 'should throw error'); - const redisUserMetaKey = `${this.userId}!metadata!${audience}`; + const redisUserMetaKey = `${id}:testMeta:${audience}`; const userData = await this.users.redis.hgetall(redisUserMetaKey); expect(userData).to.include({ x: '10', b: '14' }); @@ -83,7 +107,7 @@ describe('#updateMetadata LUA script', function updateMetadataLuaSuite() { `; const params = { - userId: this.userId, + id, audience: [audience], script: { firstScript: { @@ -103,7 +127,7 @@ describe('#updateMetadata LUA script', function updateMetadataLuaSuite() { let updateError; try { - await updateMetadata(params); + await metaUpdater.update(params); } catch (e) { updateError = e; } From 5d1c940d7faca98823c566e14d69be6d6a33f1ec Mon Sep 17 00:00:00 2001 From: pajgo Date: Tue, 22 Oct 2019 13:45:57 +0600 Subject: [PATCH 11/12] feat: update user and organization metadata --- src/actions/register.js | 4 ++-- src/actions/updateMetadata.js | 6 +++--- src/auth/oauth/utils/attach.js | 6 +++--- src/auth/oauth/utils/detach.js | 6 +++--- src/custom/cappasity-users-activate.js | 6 +++--- src/custom/rfx-create-room-on-activate.js | 6 +++--- ...date-organization-metadata.js => organization.js} | 4 ++-- src/utils/metadata/redis/update-metadata.js | 2 -- .../metadata/{update-user-metadata.js => user.js} | 4 ++-- .../organization/registerOrganizationMembers.js | 6 +++--- src/utils/setOrganizationMetadata.js | 12 ++---------- test/suites/utils/metadata/redis/update-metadata.js | 11 ++++++++++- 12 files changed, 36 insertions(+), 37 deletions(-) rename src/utils/metadata/{update-organization-metadata.js => organization.js} (90%) rename src/utils/metadata/{update-user-metadata.js => user.js} (91%) diff --git a/src/actions/register.js b/src/actions/register.js index 5183f63b5..bcc2f4f38 100644 --- a/src/actions/register.js +++ b/src/actions/register.js @@ -9,7 +9,7 @@ const reduce = require('lodash/reduce'); const last = require('lodash/last'); // internal deps -const SetUserMetadata = require('../utils/metadata/update-user-metadata'); +const UserMetadata = require('../utils/metadata/user'); const redisKey = require('../utils/key'); const jwt = require('../utils/jwt'); const isDisposable = require('../utils/isDisposable'); @@ -213,7 +213,7 @@ async function performRegistration({ service, params }) { await pipeline.exec().then(handlePipeline); - await new SetUserMetadata(service.redis).update({ + await new UserMetadata(service.redis).update({ userId, audience, metadata: audience.map((metaAudience) => ({ diff --git a/src/actions/updateMetadata.js b/src/actions/updateMetadata.js index a0ef282bc..47862873a 100644 --- a/src/actions/updateMetadata.js +++ b/src/actions/updateMetadata.js @@ -1,6 +1,6 @@ const omit = require('lodash/omit'); const Promise = require('bluebird'); -const UpdateUserMetadata = require('../utils/metadata/update-user-metadata'); +const UserMetadata = require('../utils/metadata/user'); const { getUserId } = require('../utils/userData'); /** @@ -24,10 +24,10 @@ module.exports = async function updateMetadataAction(request) { .bind(this, request.params.username) .then(getUserId); - const updateUserMetadata = new UpdateUserMetadata(this.redis); + const userMetadata = new UserMetadata(this.redis); const updateParams = { ...omit(request.params, 'username'), userId }; - return updateUserMetadata.update(updateParams); + return userMetadata.update(updateParams); }; module.exports.transports = [require('@microfleet/core').ActionTransport.amqp]; diff --git a/src/auth/oauth/utils/attach.js b/src/auth/oauth/utils/attach.js index 5fbff120e..82974e37a 100644 --- a/src/auth/oauth/utils/attach.js +++ b/src/auth/oauth/utils/attach.js @@ -1,6 +1,6 @@ const get = require('lodash/get'); const redisKey = require('../../../utils/key'); -const UpdateUserMetadata = require('../../../utils/metadata/update-user-metadata'); +const UserMetadata = require('../../../utils/metadata/user'); const handlePipeline = require('../../../utils/pipelineError'); const { USERS_SSO_TO_ID, @@ -25,7 +25,7 @@ module.exports = async function attach(account, user) { await pipeline.exec().then(handlePipeline); - const updateMetadata = new UpdateUserMetadata(redis); + const userMetadata = new UserMetadata(redis); const updateParams = { userId, audience, @@ -35,7 +35,7 @@ module.exports = async function attach(account, user) { }, }, }; - await updateMetadata.update(updateParams); + await userMetadata.update(updateParams); return profile; }; diff --git a/src/auth/oauth/utils/detach.js b/src/auth/oauth/utils/detach.js index c24fe0d6a..8aabafb3a 100644 --- a/src/auth/oauth/utils/detach.js +++ b/src/auth/oauth/utils/detach.js @@ -2,7 +2,7 @@ const Errors = require('common-errors'); const get = require('../../../utils/get-value'); const redisKey = require('../../../utils/key'); -const UpdateUserMetadata = require('../../../utils/metadata/update-user-metadata'); +const UserMetadata = require('../../../utils/metadata/user'); const handlePipeline = require('../../../utils/pipelineError'); const { @@ -30,7 +30,7 @@ module.exports = async function detach(provider, userData) { await pipeline.exec().then(handlePipeline); - const updateMetadata = new UpdateUserMetadata(redis); + const userMetadata = new UserMetadata(redis); const updateParams = { userId, audience, @@ -41,5 +41,5 @@ module.exports = async function detach(provider, userData) { }, }; - return updateMetadata.update(updateParams); + return userMetadata.update(updateParams); }; diff --git a/src/custom/cappasity-users-activate.js b/src/custom/cappasity-users-activate.js index 69974299e..42b4cdf01 100644 --- a/src/custom/cappasity-users-activate.js +++ b/src/custom/cappasity-users-activate.js @@ -1,6 +1,6 @@ const find = require('lodash/find'); const moment = require('moment'); -const UpdateUserMetadata = require('../utils/metadata/update-user-metadata'); +const UserMetadata = require('../utils/metadata/user'); /** * Adds metadata from billing into usermix @@ -13,7 +13,7 @@ module.exports = function mixPlan(userId, params) { const { payments } = config; const route = [payments.prefix, payments.routes.planGet].join('.'); const id = 'free'; - const updateMetadata = new UpdateUserMetadata(this.redis); + const userMetadata = new UserMetadata(this.redis); return amqp .publishAndWait(route, id, { timeout: 5000 }) @@ -37,6 +37,6 @@ module.exports = function mixPlan(userId, params) { }, }; - return updateMetadata.update(updateParams); + return userMetadata.update(updateParams); }); }; diff --git a/src/custom/rfx-create-room-on-activate.js b/src/custom/rfx-create-room-on-activate.js index 74c9b6af9..469b525e2 100644 --- a/src/custom/rfx-create-room-on-activate.js +++ b/src/custom/rfx-create-room-on-activate.js @@ -1,6 +1,6 @@ const is = require('is'); const Promise = require('bluebird'); -const UpdateUserMetadata = require('../utils/metadata/update-user-metadata'); +const UserMetadata = require('../utils/metadata/user'); /** * @param {String} username @@ -22,7 +22,7 @@ function createRoom(userId, params, metadata) { name: `${metadata[audience].stationName} | ${metadata[audience].stationSchool}`, }; - const updateMetadata = new UpdateUserMetadata(this.redis); + const userMetadata = new UserMetadata(this.redis); return amqp.publishAndWait(route, roomParams, { timeout: 5000 }) .bind(this) @@ -37,7 +37,7 @@ function createRoom(userId, params, metadata) { }, }; - return updateMetadata.update(updateParams); + return userMetadata.update(updateParams); }); } diff --git a/src/utils/metadata/update-organization-metadata.js b/src/utils/metadata/organization.js similarity index 90% rename from src/utils/metadata/update-organization-metadata.js rename to src/utils/metadata/organization.js index 6dabed949..0ef39d51a 100644 --- a/src/utils/metadata/update-organization-metadata.js +++ b/src/utils/metadata/organization.js @@ -3,7 +3,7 @@ const redisKey = require('../key'); const MetaUpdate = require('./redis/update-metadata'); const { ORGANIZATIONS_METADATA, ORGANIZATIONS_AUDIENCE } = require('../../constants'); -class UpdateOrganizationMetadata { +class Organization { constructor(redis) { this.redis = redis; const audienceKeyTemplate = redisKey('{id}', ORGANIZATIONS_AUDIENCE); @@ -22,4 +22,4 @@ class UpdateOrganizationMetadata { } } -module.exports = UpdateOrganizationMetadata; +module.exports = Organization; diff --git a/src/utils/metadata/redis/update-metadata.js b/src/utils/metadata/redis/update-metadata.js index 4c7befdea..6ba17a445 100644 --- a/src/utils/metadata/redis/update-metadata.js +++ b/src/utils/metadata/redis/update-metadata.js @@ -45,9 +45,7 @@ class UpdateMetadata { const metaOps = rawMetaOps.map((opBlock) => UpdateMetadata.prepareOperations(opBlock)); scriptOpts = { metaOps, ...scriptOpts }; - const updateJsonResult = await this.callLuaScript(id, scriptOpts); - console.log(updateJsonResult, scriptOpts); return UpdateMetadata.processOpUpdateResponse(updateJsonResult); } diff --git a/src/utils/metadata/update-user-metadata.js b/src/utils/metadata/user.js similarity index 91% rename from src/utils/metadata/update-user-metadata.js rename to src/utils/metadata/user.js index e4002dc2e..a0e3cc3ea 100644 --- a/src/utils/metadata/update-user-metadata.js +++ b/src/utils/metadata/user.js @@ -3,7 +3,7 @@ const redisKey = require('../key'); const MetaUpdate = require('./redis/update-metadata'); const { USERS_METADATA, USERS_AUDIENCE } = require('../../constants'); -class UpdateUserMetadata { +class User { constructor(redis) { this.redis = redis; const audienceKeyTemplate = redisKey('{id}', USERS_AUDIENCE); @@ -22,4 +22,4 @@ class UpdateUserMetadata { } } -module.exports = UpdateUserMetadata; +module.exports = User; diff --git a/src/utils/organization/registerOrganizationMembers.js b/src/utils/organization/registerOrganizationMembers.js index bb318a469..d5d8161a0 100644 --- a/src/utils/organization/registerOrganizationMembers.js +++ b/src/utils/organization/registerOrganizationMembers.js @@ -14,7 +14,7 @@ const { USERS_ID_FIELD, } = require('../../constants.js'); const scrypt = require('../scrypt'); -const UpdateUserMetadata = require('../metadata/update-user-metadata'); +const UserMetadata = require('../metadata/user'); async function registerOrganizationMember(member) { const { redis, config } = this; @@ -36,8 +36,8 @@ async function registerOrganizationMember(member) { pipeline.hset(USERS_USERNAME_TO_ID, email, userId); await pipeline.exec().then(handlePipeline); - const updateUserMetadata = new UpdateUserMetadata(redis); - await updateUserMetadata.update({ + const userMetadata = new UserMetadata(redis); + await userMetadata.update({ userId, audience, metadata: [{ diff --git a/src/utils/setOrganizationMetadata.js b/src/utils/setOrganizationMetadata.js index 843f64e70..2a2f8eba1 100644 --- a/src/utils/setOrganizationMetadata.js +++ b/src/utils/setOrganizationMetadata.js @@ -1,9 +1,5 @@ /* eslint-disable no-mixed-operators */ -const MetaUpdate = require('../utils/metadata/redis/update-metadata'); -const redisKey = require('../utils/key.js'); - -const { ORGANIZATIONS_METADATA, ORGANIZATIONS_AUDIENCE } = require('../constants.js'); - +const OrganizationMetadata = require('../utils/metadata/organization'); /** * Updates metadata on a organization object @@ -11,11 +7,7 @@ const { ORGANIZATIONS_METADATA, ORGANIZATIONS_AUDIENCE } = require('../constants * @return {Promise} */ async function setOrganizationMetadata(opts) { - const audienceKeyTemplate = redisKey('{id}', ORGANIZATIONS_AUDIENCE); - const metaDataTemplate = redisKey('{id}', ORGANIZATIONS_METADATA, '{audience}'); - const metaUpdater = new MetaUpdate(this.redis, metaDataTemplate, audienceKeyTemplate); - const { organizationId, ...restOpts } = opts; - return metaUpdater.update({ id: organizationId, ...restOpts }); + return new OrganizationMetadata(this.redis).update(opts); } module.exports = setOrganizationMetadata; diff --git a/test/suites/utils/metadata/redis/update-metadata.js b/test/suites/utils/metadata/redis/update-metadata.js index 2adfddebe..c056ae7f2 100644 --- a/test/suites/utils/metadata/redis/update-metadata.js +++ b/test/suites/utils/metadata/redis/update-metadata.js @@ -40,6 +40,15 @@ describe('#updateMetadata LUA script', function updateMetadataLuaSuite() { await metaUpdater.update(params); }); + it('sets meta', async () => { + const redisUserMetaKey = `${id}:testMeta:${audience}`; + const userDataAudience = await this.users.redis.hgetall(redisUserMetaKey); + expect(userDataAudience).to.be.deep.equal({ x: '10', c: '"cval"', b: '12' }); + + const userDataExtraAudience = await this.users.redis.hgetall(`${id}:testMeta:*.extra`); + expect(userDataExtraAudience).to.be.deep.equal({ x: '20', c: '"xval"', b: '22' }); + }); + it('tracks audienceList', async () => { const audiencesList = await this.users.redis.smembers(`${id}:audience`); expect(audiencesList).to.be.deep.equal(['*.localhost', '*.extra']); @@ -53,7 +62,7 @@ describe('#updateMetadata LUA script', function updateMetadataLuaSuite() { ], metadata: [ { - $remove: ['x','c','b'], + $remove: ['x', 'c', 'b'], }, ], }); From fdfbba63a2dbc449906a7854620739dc32d64c0f Mon Sep 17 00:00:00 2001 From: pajgo Date: Tue, 22 Oct 2019 14:22:13 +0600 Subject: [PATCH 12/12] feat: update user and organization metadata * test fix -> sentinel response order --- test/suites/utils/metadata/redis/update-metadata.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/suites/utils/metadata/redis/update-metadata.js b/test/suites/utils/metadata/redis/update-metadata.js index c056ae7f2..bf21c85bd 100644 --- a/test/suites/utils/metadata/redis/update-metadata.js +++ b/test/suites/utils/metadata/redis/update-metadata.js @@ -43,15 +43,15 @@ describe('#updateMetadata LUA script', function updateMetadataLuaSuite() { it('sets meta', async () => { const redisUserMetaKey = `${id}:testMeta:${audience}`; const userDataAudience = await this.users.redis.hgetall(redisUserMetaKey); - expect(userDataAudience).to.be.deep.equal({ x: '10', c: '"cval"', b: '12' }); + expect(userDataAudience).to.include({ x: '10', c: '"cval"', b: '12' }); const userDataExtraAudience = await this.users.redis.hgetall(`${id}:testMeta:*.extra`); - expect(userDataExtraAudience).to.be.deep.equal({ x: '20', c: '"xval"', b: '22' }); + expect(userDataExtraAudience).to.include({ x: '20', c: '"xval"', b: '22' }); }); it('tracks audienceList', async () => { const audiencesList = await this.users.redis.smembers(`${id}:audience`); - expect(audiencesList).to.be.deep.equal(['*.localhost', '*.extra']); + expect(audiencesList).to.include.members(['*.localhost', '*.extra']); }); it('tracks audienceList after remove', async () => {