Merge pull request #11798 from Budibase/BUDI-7456/user_cache_bulk_get
User cache bulk get
This commit is contained in:
commit
aa24e6ccc5
|
@ -0,0 +1,145 @@
|
||||||
|
import { User } from "@budibase/types"
|
||||||
|
import { generator, structures } from "../../../tests"
|
||||||
|
import { DBTestConfiguration } from "../../../tests/extra"
|
||||||
|
import { getUsers } from "../user"
|
||||||
|
import { getGlobalDB } from "../../context"
|
||||||
|
import _ from "lodash"
|
||||||
|
|
||||||
|
import * as redis from "../../redis/init"
|
||||||
|
import { UserDB } from "../../users"
|
||||||
|
|
||||||
|
const config = new DBTestConfiguration()
|
||||||
|
|
||||||
|
describe("user cache", () => {
|
||||||
|
describe("getUsers", () => {
|
||||||
|
const users: User[] = []
|
||||||
|
beforeAll(async () => {
|
||||||
|
const userCount = 10
|
||||||
|
const userIds = generator.arrayOf(() => generator.guid(), {
|
||||||
|
min: userCount,
|
||||||
|
max: userCount,
|
||||||
|
})
|
||||||
|
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
const db = getGlobalDB()
|
||||||
|
for (const userId of userIds) {
|
||||||
|
const user = structures.users.user({ _id: userId })
|
||||||
|
await db.put(user)
|
||||||
|
users.push(user)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
|
||||||
|
const redisClient = await redis.getUserClient()
|
||||||
|
await redisClient.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("when no user is in cache, all of them are retrieved from db", async () => {
|
||||||
|
const usersToRequest = _.sampleSize(users, 5)
|
||||||
|
|
||||||
|
const userIdsToRequest = usersToRequest.map(x => x._id!)
|
||||||
|
|
||||||
|
jest.spyOn(UserDB, "bulkGet")
|
||||||
|
|
||||||
|
const results = await config.doInTenant(() => getUsers(userIdsToRequest))
|
||||||
|
|
||||||
|
expect(results.users).toHaveLength(5)
|
||||||
|
expect(results).toEqual({
|
||||||
|
users: usersToRequest.map(u => ({
|
||||||
|
...u,
|
||||||
|
budibaseAccess: true,
|
||||||
|
_rev: expect.any(String),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(UserDB.bulkGet).toBeCalledTimes(1)
|
||||||
|
expect(UserDB.bulkGet).toBeCalledWith(userIdsToRequest)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("on a second all, all of them are retrieved from cache", async () => {
|
||||||
|
const usersToRequest = _.sampleSize(users, 5)
|
||||||
|
|
||||||
|
const userIdsToRequest = usersToRequest.map(x => x._id!)
|
||||||
|
|
||||||
|
jest.spyOn(UserDB, "bulkGet")
|
||||||
|
|
||||||
|
await config.doInTenant(() => getUsers(userIdsToRequest))
|
||||||
|
const resultsFromCache = await config.doInTenant(() =>
|
||||||
|
getUsers(userIdsToRequest)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(resultsFromCache.users).toHaveLength(5)
|
||||||
|
expect(resultsFromCache).toEqual({
|
||||||
|
users: expect.arrayContaining(
|
||||||
|
usersToRequest.map(u => ({
|
||||||
|
...u,
|
||||||
|
budibaseAccess: true,
|
||||||
|
_rev: expect.any(String),
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(UserDB.bulkGet).toBeCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("when some users are cached, only the missing ones are retrieved from db", async () => {
|
||||||
|
const usersToRequest = _.sampleSize(users, 5)
|
||||||
|
|
||||||
|
const userIdsToRequest = usersToRequest.map(x => x._id!)
|
||||||
|
|
||||||
|
jest.spyOn(UserDB, "bulkGet")
|
||||||
|
|
||||||
|
await config.doInTenant(() =>
|
||||||
|
getUsers([userIdsToRequest[0], userIdsToRequest[3]])
|
||||||
|
)
|
||||||
|
;(UserDB.bulkGet as jest.Mock).mockClear()
|
||||||
|
|
||||||
|
const results = await config.doInTenant(() => getUsers(userIdsToRequest))
|
||||||
|
|
||||||
|
expect(results.users).toHaveLength(5)
|
||||||
|
expect(results).toEqual({
|
||||||
|
users: expect.arrayContaining(
|
||||||
|
usersToRequest.map(u => ({
|
||||||
|
...u,
|
||||||
|
budibaseAccess: true,
|
||||||
|
_rev: expect.any(String),
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(UserDB.bulkGet).toBeCalledTimes(1)
|
||||||
|
expect(UserDB.bulkGet).toBeCalledWith([
|
||||||
|
userIdsToRequest[1],
|
||||||
|
userIdsToRequest[2],
|
||||||
|
userIdsToRequest[4],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("requesting existing and unexisting ids will return found ones", async () => {
|
||||||
|
const usersToRequest = _.sampleSize(users, 3)
|
||||||
|
const missingIds = [generator.guid(), generator.guid()]
|
||||||
|
|
||||||
|
const userIdsToRequest = _.shuffle([
|
||||||
|
...missingIds,
|
||||||
|
...usersToRequest.map(x => x._id!),
|
||||||
|
])
|
||||||
|
|
||||||
|
const results = await config.doInTenant(() => getUsers(userIdsToRequest))
|
||||||
|
|
||||||
|
expect(results.users).toHaveLength(3)
|
||||||
|
expect(results).toEqual({
|
||||||
|
users: expect.arrayContaining(
|
||||||
|
usersToRequest.map(u => ({
|
||||||
|
...u,
|
||||||
|
budibaseAccess: true,
|
||||||
|
_rev: expect.any(String),
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
notFoundIds: expect.arrayContaining(missingIds),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -6,6 +6,7 @@ import env from "../environment"
|
||||||
import * as accounts from "../accounts"
|
import * as accounts from "../accounts"
|
||||||
import { UserDB } from "../users"
|
import { UserDB } from "../users"
|
||||||
import { sdk } from "@budibase/shared-core"
|
import { sdk } from "@budibase/shared-core"
|
||||||
|
import { User } from "@budibase/types"
|
||||||
|
|
||||||
const EXPIRY_SECONDS = 3600
|
const EXPIRY_SECONDS = 3600
|
||||||
|
|
||||||
|
@ -27,6 +28,35 @@ async function populateFromDB(userId: string, tenantId: string) {
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function populateUsersFromDB(
|
||||||
|
userIds: string[]
|
||||||
|
): Promise<{ users: User[]; notFoundIds?: string[] }> {
|
||||||
|
const getUsersResponse = await UserDB.bulkGet(userIds)
|
||||||
|
|
||||||
|
// Handle missed user ids
|
||||||
|
const notFoundIds = userIds.filter((uid, i) => !getUsersResponse[i])
|
||||||
|
|
||||||
|
const users = getUsersResponse.filter(x => x)
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
users.map(async (user: any) => {
|
||||||
|
user.budibaseAccess = true
|
||||||
|
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
|
||||||
|
const account = await accounts.getAccount(user.email)
|
||||||
|
if (account) {
|
||||||
|
user.account = account
|
||||||
|
user.accountPortalAccess = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (notFoundIds.length) {
|
||||||
|
return { users, notFoundIds }
|
||||||
|
}
|
||||||
|
return { users }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the requested user by id.
|
* Get the requested user by id.
|
||||||
* Use redis cache to first read the user.
|
* Use redis cache to first read the user.
|
||||||
|
@ -77,6 +107,36 @@ export async function getUser(
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the requested users by id.
|
||||||
|
* Use redis cache to first read the users.
|
||||||
|
* If not present fallback to loading the users directly and re-caching.
|
||||||
|
* @param {*} userIds the ids of the user to get
|
||||||
|
* @param {*} tenantId the tenant of the users to get
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export async function getUsers(
|
||||||
|
userIds: string[]
|
||||||
|
): Promise<{ users: User[]; notFoundIds?: string[] }> {
|
||||||
|
const client = await redis.getUserClient()
|
||||||
|
// try cache
|
||||||
|
let usersFromCache = await client.bulkGet(userIds)
|
||||||
|
const missingUsersFromCache = userIds.filter(uid => !usersFromCache[uid])
|
||||||
|
const users = Object.values(usersFromCache)
|
||||||
|
let notFoundIds
|
||||||
|
|
||||||
|
if (missingUsersFromCache.length) {
|
||||||
|
const usersFromDb = await populateUsersFromDB(missingUsersFromCache)
|
||||||
|
|
||||||
|
notFoundIds = usersFromDb.notFoundIds
|
||||||
|
for (const userToCache of usersFromDb.users) {
|
||||||
|
await client.store(userToCache._id!, userToCache, EXPIRY_SECONDS)
|
||||||
|
}
|
||||||
|
users.push(...usersFromDb.users)
|
||||||
|
}
|
||||||
|
return { users, notFoundIds: notFoundIds }
|
||||||
|
}
|
||||||
|
|
||||||
export async function invalidateUser(userId: string) {
|
export async function invalidateUser(userId: string) {
|
||||||
const client = await redis.getUserClient()
|
const client = await redis.getUserClient()
|
||||||
await client.delete(userId)
|
await client.delete(userId)
|
||||||
|
|
|
@ -102,6 +102,7 @@ describe("sso", () => {
|
||||||
|
|
||||||
// modified external id to match user format
|
// modified external id to match user format
|
||||||
ssoUser._id = "us_" + details.userId
|
ssoUser._id = "us_" + details.userId
|
||||||
|
delete ssoUser.userId
|
||||||
|
|
||||||
// new sso user won't have a password
|
// new sso user won't have a password
|
||||||
delete ssoUser.password
|
delete ssoUser.password
|
||||||
|
|
|
@ -250,7 +250,7 @@ class RedisWrapper {
|
||||||
const prefixedKeys = keys.map(key => addDbPrefix(db, key))
|
const prefixedKeys = keys.map(key => addDbPrefix(db, key))
|
||||||
let response = await this.getClient().mget(prefixedKeys)
|
let response = await this.getClient().mget(prefixedKeys)
|
||||||
if (Array.isArray(response)) {
|
if (Array.isArray(response)) {
|
||||||
let final: any = {}
|
let final: Record<string, any> = {}
|
||||||
let count = 0
|
let count = 0
|
||||||
for (let result of response) {
|
for (let result of response) {
|
||||||
if (result) {
|
if (result) {
|
||||||
|
|
|
@ -1,19 +0,0 @@
|
||||||
import { User } from "@budibase/types"
|
|
||||||
import { generator } from "./generator"
|
|
||||||
import { uuid } from "./common"
|
|
||||||
|
|
||||||
export const newEmail = () => {
|
|
||||||
return `${uuid()}@test.com`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const user = (userProps?: any): User => {
|
|
||||||
return {
|
|
||||||
email: newEmail(),
|
|
||||||
password: "test",
|
|
||||||
roles: { app_test: "admin" },
|
|
||||||
firstName: generator.first(),
|
|
||||||
lastName: generator.last(),
|
|
||||||
pictureUrl: "http://test.com",
|
|
||||||
...userProps,
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -13,8 +13,7 @@ import {
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { generator } from "./generator"
|
import { generator } from "./generator"
|
||||||
import { email, uuid } from "./common"
|
import { email, uuid } from "./common"
|
||||||
import * as shared from "./shared"
|
import * as users from "./users"
|
||||||
import { user } from "./shared"
|
|
||||||
import sample from "lodash/sample"
|
import sample from "lodash/sample"
|
||||||
|
|
||||||
export function OAuth(): OAuth2 {
|
export function OAuth(): OAuth2 {
|
||||||
|
@ -26,7 +25,7 @@ export function OAuth(): OAuth2 {
|
||||||
|
|
||||||
export function authDetails(userDoc?: User): SSOAuthDetails {
|
export function authDetails(userDoc?: User): SSOAuthDetails {
|
||||||
if (!userDoc) {
|
if (!userDoc) {
|
||||||
userDoc = user()
|
userDoc = users.user()
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = userDoc._id || uuid()
|
const userId = userDoc._id || uuid()
|
||||||
|
@ -52,7 +51,7 @@ export function providerType(): SSOProviderType {
|
||||||
|
|
||||||
export function ssoProfile(user?: User): SSOProfile {
|
export function ssoProfile(user?: User): SSOProfile {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
user = shared.user()
|
user = users.user()
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: user._id!,
|
id: user._id!,
|
||||||
|
|
|
@ -4,11 +4,33 @@ import {
|
||||||
BuilderUser,
|
BuilderUser,
|
||||||
SSOAuthDetails,
|
SSOAuthDetails,
|
||||||
SSOUser,
|
SSOUser,
|
||||||
|
User,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { user } from "./shared"
|
|
||||||
import { authDetails } from "./sso"
|
import { authDetails } from "./sso"
|
||||||
|
import { uuid } from "./common"
|
||||||
|
import { generator } from "./generator"
|
||||||
|
import { tenant } from "."
|
||||||
|
import { generateGlobalUserID } from "../../../../src/docIds"
|
||||||
|
|
||||||
export { user, newEmail } from "./shared"
|
export const newEmail = () => {
|
||||||
|
return `${uuid()}@test.com`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const user = (userProps?: Partial<Omit<User, "userId">>): User => {
|
||||||
|
const userId = userProps?._id || generateGlobalUserID()
|
||||||
|
return {
|
||||||
|
_id: userId,
|
||||||
|
userId,
|
||||||
|
email: newEmail(),
|
||||||
|
password: "test",
|
||||||
|
roles: { app_test: "admin" },
|
||||||
|
firstName: generator.first(),
|
||||||
|
lastName: generator.last(),
|
||||||
|
pictureUrl: "http://test.com",
|
||||||
|
tenantId: tenant.id(),
|
||||||
|
...userProps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const adminUser = (userProps?: any): AdminUser => {
|
export const adminUser = (userProps?: any): AdminUser => {
|
||||||
return {
|
return {
|
||||||
|
|
Loading…
Reference in New Issue