Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions src/entities/game-channel-storage-prop.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Entity, Index, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql'
import Redis from 'ioredis'
import { isArrayKey } from '../lib/props/sanitiseProps'
import GameChannel from './game-channel'
import PlayerAlias from './player-alias'
import { MAX_KEY_LENGTH, MAX_VALUE_LENGTH } from './prop'
Expand Down Expand Up @@ -43,10 +44,38 @@ export default class GameChannelStorageProp {
this.value = value
}

persistToRedis(redis: Redis) {
const redisKey = GameChannelStorageProp.getRedisKey(this.gameChannel.id, this.key)
static flatten(
props: GameChannelStorageProp[],
): ReturnType<GameChannelStorageProp['toJSON']> | null {
if (props.length === 0) {
return null
}

if (props.length === 1 && !isArrayKey(props[0].key)) {
return props[0].toJSON()
}

const sorted = [...props].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
const representative = sorted[0].toJSON()
representative.value = JSON.stringify(sorted.map((p) => p.value))
return representative
}

static persistToRedis({
redis,
channelId,
key,
props,
}: {
redis: Redis
channelId: number
key: string
props: GameChannelStorageProp[]
}) {
const flattened = GameChannelStorageProp.flatten(props)
const redisKey = GameChannelStorageProp.getRedisKey(channelId, key)
const expirationSeconds = GameChannelStorageProp.redisExpirationSeconds
return redis.set(redisKey, JSON.stringify(this), 'EX', expirationSeconds)
return redis.set(redisKey, JSON.stringify(flattened), 'EX', expirationSeconds)
}

toJSON() {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/props/sanitiseProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const MAX_ARRAY_LENGTH = 1000

type UnsanitisedProp = { key: string; value: string | null }

function isArrayKey(key: string): boolean {
export function isArrayKey(key: string): boolean {
return key.endsWith('[]')
}

Expand Down
22 changes: 12 additions & 10 deletions src/routes/api/game-channel/get-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,33 +39,35 @@ export const getStorageRoute = apiRoute({
return ctx.throw(403, 'This player is not a member of the channel')
}

let result: GameChannelStorageProp | null = null

const redis: Redis = ctx.redis
const cachedProp = await redis.get(GameChannelStorageProp.getRedisKey(channel.id, propKey))
const cached = await redis.get(GameChannelStorageProp.getRedisKey(channel.id, propKey))

if (cachedProp) {
if (cached) {
return {
status: 200,
body: {
prop: JSON.parse(cachedProp),
prop: JSON.parse(cached),
},
}
}

result = await em.repo(GameChannelStorageProp).findOne({
const results = await em.repo(GameChannelStorageProp).find({
gameChannel: channel,
key: propKey,
})

if (result) {
await result.persistToRedis(redis)
}
const prop = GameChannelStorageProp.flatten(results)
await GameChannelStorageProp.persistToRedis({
redis,
channelId: channel.id,
key: propKey,
props: results,
})

return {
status: 200,
body: {
prop: result,
prop,
},
}
},
Expand Down
52 changes: 30 additions & 22 deletions src/routes/api/game-channel/list-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { requireScopes } from '../../../middleware/policy-middleware'
import { loadChannel } from './common'
import { listStorageDocs } from './docs'

type FlattenedProp = ReturnType<typeof GameChannelStorageProp.flatten>

export const listStorageRoute = apiRoute({
method: 'get',
path: '/:id/storage/list',
Expand Down Expand Up @@ -50,19 +52,18 @@ export const listStorageRoute = apiRoute({
return ctx.throw(403, 'This player is not a member of the channel')
}

const keys = propKeys
const redisKeys = keys.map((key) => GameChannelStorageProp.getRedisKey(channel.id, key))
const cachedProps = await redis.mget(...redisKeys)
const redisKeys = propKeys.map((key) => GameChannelStorageProp.getRedisKey(channel.id, key))
const cachedValues = await redis.mget(...redisKeys)

const resultMap = new Map<string, GameChannelStorageProp>()
const resultMap = new Map<string, FlattenedProp>()
const missingKeys: string[] = []

cachedProps.forEach((cachedProp, index) => {
const originalKey = keys[index]
if (cachedProp) {
resultMap.set(originalKey, JSON.parse(cachedProp))
cachedValues.forEach((cached, index) => {
const key = propKeys[index]
if (cached) {
resultMap.set(key, JSON.parse(cached))
} else {
missingKeys.push(originalKey)
missingKeys.push(key)
}
})

Expand All @@ -72,24 +73,31 @@ export const listStorageRoute = apiRoute({
key: { $in: missingKeys },
})

// cache the results using a single operation
if (propsFromDB.length > 0) {
const pipeline = redis.pipeline()
const propsByKey = new Map<string, GameChannelStorageProp[]>()
for (const prop of propsFromDB) {
const group = propsByKey.get(prop.key) ?? []
group.push(prop)
propsByKey.set(prop.key, group)
}

for (const prop of propsFromDB) {
resultMap.set(prop.key, prop)
const redisKey = GameChannelStorageProp.getRedisKey(channel.id, prop.key)
const expirationSeconds = GameChannelStorageProp.redisExpirationSeconds
pipeline.set(redisKey, JSON.stringify(prop), 'EX', expirationSeconds)
}
const pipeline = redis.pipeline()

await pipeline.exec()
for (const key of missingKeys) {
const rows = propsByKey.get(key) ?? []
const flattened = GameChannelStorageProp.flatten(rows)
resultMap.set(key, flattened)
const redisKey = GameChannelStorageProp.getRedisKey(channel.id, key)
const expirationSeconds = GameChannelStorageProp.redisExpirationSeconds
pipeline.set(redisKey, JSON.stringify(flattened), 'EX', expirationSeconds)
}

await pipeline.exec()
}

const props = keys
.map((key) => resultMap.get(key))
.filter((prop): prop is GameChannelStorageProp => prop !== undefined)
const props = propKeys.flatMap((key) => {
const prop = resultMap.get(key)
return prop ? [prop] : []
})

return {
status: 200,
Expand Down
Loading
Loading