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
5 changes: 0 additions & 5 deletions src/services/game-channel.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { captureException } from '@sentry/node'
import { pageValidation } from '../lib/pagination/pageValidation'
import { DEFAULT_PAGE_SIZE } from '../lib/pagination/itemsPerPage'
import { withResponseCache } from '../lib/perf/responseCache'
import { deferClearResponseCache } from '../lib/perf/responseCacheQueue'
import Game from '../entities/game'

const itemsPerPage = DEFAULT_PAGE_SIZE
Expand Down Expand Up @@ -159,7 +158,6 @@ export default class GameChannelService extends Service {
}

await em.persistAndFlush(channel)
await deferClearResponseCache(GameChannel.getSearchCacheKey(channel.game, true))

await channel.sendMessageToMembers(req.ctx.wss, 'v1.channels.player-joined', {
channel,
Expand Down Expand Up @@ -252,8 +250,6 @@ export default class GameChannelService extends Service {
}

if (changedProperties.length > 0) {
await deferClearResponseCache(GameChannel.getSearchCacheKey(channel.game, true))

// don't send this message if the only thing that changed is the owner
// that is covered by the ownership transferred message
if (!(changedProperties.length === 1 && changedProperties[0] === 'ownerAliasId')) {
Expand Down Expand Up @@ -300,7 +296,6 @@ export default class GameChannelService extends Service {
const em: EntityManager = req.ctx.em
const channel: GameChannel = req.ctx.state.channel

await deferClearResponseCache(GameChannel.getSearchCacheKey(channel.game, true))
await channel.sendDeletedMessage(req.ctx.wss)

if (!req.ctx.state.user.api) {
Expand Down
38 changes: 38 additions & 0 deletions src/subscribers/game-channel.subscriber.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { EventArgs, EventSubscriber, wrap } from '@mikro-orm/mysql'
import { deferClearResponseCache } from '../lib/perf/responseCacheQueue'
import GameChannel from '../entities/game-channel'
import GameChannelProp from '../entities/game-channel-prop'

export class GameChannelSubscriber implements EventSubscriber {
getSubscribedEntities() {
return [GameChannel, GameChannelProp]
}

async clearSearchCacheKey(args: EventArgs<GameChannel | GameChannelProp>) {
const { entity, em } = args
const channel = entity instanceof GameChannel ? entity : entity.gameChannel

if (!channel) {
// can happen when a prop is being deleted, the reference to the channel is gone
return
}

if (!wrap(channel.game).isInitialized()) {
await em.fork().populate(channel, ['game'])
}

await deferClearResponseCache(GameChannel.getSearchCacheKey(channel.game, true))
}

afterCreate(args: EventArgs<GameChannel | GameChannelProp>) {
void this.clearSearchCacheKey(args)
}

afterUpdate(args: EventArgs<GameChannel | GameChannelProp>) {
void this.clearSearchCacheKey(args)
}

afterDelete(args: EventArgs<GameChannel | GameChannelProp>) {
void this.clearSearchCacheKey(args)
}
}
2 changes: 2 additions & 0 deletions src/subscribers/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { GameChannelSubscriber } from './game-channel.subscriber'
import { LeaderboardEntrySubscriber } from './leaderboard-entry.subscriber'
import { PlayerGameStatSubscriber } from './player-game-stat.subscriber'

export const subscribers = [
GameChannelSubscriber,
LeaderboardEntrySubscriber,
PlayerGameStatSubscriber
]
302 changes: 302 additions & 0 deletions tests/subscribers/game-channel.subscriber.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
import request from 'supertest'
import { APIKeyScope } from '../../src/entities/api-key'
import PlayerFactory from '../fixtures/PlayerFactory'
import GameChannelFactory from '../fixtures/GameChannelFactory'
import createAPIKeyAndToken from '../utils/createAPIKeyAndToken'

describe('GameChannel subscriber', () => {
describe('cache invalidation on create', () => {
it('should invalidate the channel search cache when a new channel is created', async () => {
const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_GAME_CHANNELS, APIKeyScope.WRITE_GAME_CHANNELS])
const player = await new PlayerFactory([apiKey.game]).one()
await em.persistAndFlush([player])

// populate the cache with empty list
const res1 = await request(app)
.get('/v1/game-channels')
.set('x-talo-alias', String(player.aliases[0].id))
.auth(token, { type: 'bearer' })
.expect(200)

expect(res1.body.channels).toHaveLength(0)

// create a channel - this should clear the cache
await request(app)
.post('/v1/game-channels')
.send({ name: 'Test Channel' })
.auth(token, { type: 'bearer' })
.set('x-talo-alias', String(player.aliases[0].id))
.expect(200)

// should return the new channel (not cached empty list)
const res2 = await request(app)
.get('/v1/game-channels')
.set('x-talo-alias', String(player.aliases[0].id))
.auth(token, { type: 'bearer' })
.expect(200)

expect(res2.body.channels).toHaveLength(1)
expect(res2.body.channels[0].name).toBe('Test Channel')
})

it('should invalidate cache when multiple channels are created', async () => {
const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_GAME_CHANNELS, APIKeyScope.WRITE_GAME_CHANNELS])
const player = await new PlayerFactory([apiKey.game]).one()
await em.persistAndFlush([player])

// populate the cache with empty list
const res1 = await request(app)
.get('/v1/game-channels')
.set('x-talo-alias', String(player.aliases[0].id))
.auth(token, { type: 'bearer' })
.expect(200)

expect(res1.body.channels).toHaveLength(0)

// create first channel
await request(app)
.post('/v1/game-channels')
.send({ name: 'Channel 1' })
.auth(token, { type: 'bearer' })
.set('x-talo-alias', String(player.aliases[0].id))
.expect(200)

// should show 1 channel
const res2 = await request(app)
.get('/v1/game-channels')
.set('x-talo-alias', String(player.aliases[0].id))
.auth(token, { type: 'bearer' })
.expect(200)

expect(res2.body.channels).toHaveLength(1)

// create second channel
await request(app)
.post('/v1/game-channels')
.send({ name: 'Channel 2' })
.auth(token, { type: 'bearer' })
.set('x-talo-alias', String(player.aliases[0].id))
.expect(200)

// should show 2 channels
const res3 = await request(app)
.get('/v1/game-channels')
.set('x-talo-alias', String(player.aliases[0].id))
.auth(token, { type: 'bearer' })
.expect(200)

expect(res3.body.channels).toHaveLength(2)
})
})

describe('cache invalidation on update', () => {
it('should invalidate the channel search cache when a channel is updated', async () => {
const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_GAME_CHANNELS, APIKeyScope.WRITE_GAME_CHANNELS])
const player = await new PlayerFactory([apiKey.game]).one()
const channel = await new GameChannelFactory(apiKey.game).state(() => ({ owner: player.aliases[0], name: 'Original Name' })).one()
channel.members.add(player.aliases[0])
await em.persistAndFlush([player, channel])

// populate cache with original name
const res1 = await request(app)
.get('/v1/game-channels')
.set('x-talo-alias', String(player.aliases[0].id))
.auth(token, { type: 'bearer' })
.expect(200)

expect(res1.body.channels).toHaveLength(1)
expect(res1.body.channels[0].name).toBe('Original Name')

// update the channel
await request(app)
.put(`/v1/game-channels/${channel.id}`)
.send({ name: 'Updated Name' })
.auth(token, { type: 'bearer' })
.set('x-talo-alias', String(player.aliases[0].id))
.expect(200)

// should return the updated channel (not cached original)
const res2 = await request(app)
.get('/v1/game-channels')
.set('x-talo-alias', String(player.aliases[0].id))
.auth(token, { type: 'bearer' })
.expect(200)

expect(res2.body.channels).toHaveLength(1)
expect(res2.body.channels[0].name).toBe('Updated Name')
})

it('should invalidate cache when channel props are updated', async () => {
const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_GAME_CHANNELS, APIKeyScope.WRITE_GAME_CHANNELS])
const player = await new PlayerFactory([apiKey.game]).one()
const channel = await new GameChannelFactory(apiKey.game).state(() => ({
owner: player.aliases[0],
name: 'Test Channel'
})).one()
channel.members.add(player.aliases[0])
channel.setProps([{ key: 'level', value: '1' }])
await em.persistAndFlush([player, channel])

// populate cache
const res1 = await request(app)
.get('/v1/game-channels')
.set('x-talo-alias', String(player.aliases[0].id))
.auth(token, { type: 'bearer' })
.expect(200)

expect(res1.body.channels).toHaveLength(1)
expect(res1.body.channels[0].props).toHaveLength(1)
expect(res1.body.channels[0].props[0].value).toBe('1')

// update channel props
await request(app)
.put(`/v1/game-channels/${channel.id}`)
.send({ props: [{ key: 'level', value: '5' }] })
.auth(token, { type: 'bearer' })
.set('x-talo-alias', String(player.aliases[0].id))
.expect(200)

// should return the updated props
const res2 = await request(app)
.get('/v1/game-channels')
.set('x-talo-alias', String(player.aliases[0].id))
.auth(token, { type: 'bearer' })
.expect(200)

expect(res2.body.channels).toHaveLength(1)
expect(res2.body.channels[0].props).toHaveLength(1)
expect(res2.body.channels[0].props[0].value).toBe('5')
})
})

describe('cache invalidation on delete', () => {
it('should invalidate the channel search cache when a channel is deleted', async () => {
const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_GAME_CHANNELS, APIKeyScope.WRITE_GAME_CHANNELS])
const player = await new PlayerFactory([apiKey.game]).one()
const channel = await new GameChannelFactory(apiKey.game).state(() => ({ owner: player.aliases[0] })).one()
channel.members.add(player.aliases[0])
await em.persistAndFlush([player, channel])

// populate cache with the channel
const res1 = await request(app)
.get('/v1/game-channels')
.set('x-talo-alias', String(player.aliases[0].id))
.auth(token, { type: 'bearer' })
.expect(200)

expect(res1.body.channels).toHaveLength(1)

// delete the channel
await request(app)
.delete(`/v1/game-channels/${channel.id}`)
.auth(token, { type: 'bearer' })
.set('x-talo-alias', String(player.aliases[0].id))
.expect(204)

// should return empty list (not cached result)
const res2 = await request(app)
.get('/v1/game-channels')
.set('x-talo-alias', String(player.aliases[0].id))
.auth(token, { type: 'bearer' })
.expect(200)

expect(res2.body.channels).toHaveLength(0)
})

it('should invalidate cache when auto-cleanup deletes a channel', async () => {
const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_GAME_CHANNELS, APIKeyScope.WRITE_GAME_CHANNELS])
const player = await new PlayerFactory([apiKey.game]).one()
const channel = await new GameChannelFactory(apiKey.game).state(() => ({
owner: player.aliases[0],
autoCleanup: true
})).one()
channel.members.add(player.aliases[0])
await em.persistAndFlush([player, channel])

// populate cache
const res1 = await request(app)
.get('/v1/game-channels')
.set('x-talo-alias', String(player.aliases[0].id))
.auth(token, { type: 'bearer' })
.expect(200)

expect(res1.body.channels).toHaveLength(1)

// leave the channel - this should auto-cleanup and delete it
await request(app)
.post(`/v1/game-channels/${channel.id}/leave`)
.auth(token, { type: 'bearer' })
.set('x-talo-alias', String(player.aliases[0].id))
.expect(204)

// should return empty list (channel was auto-deleted)
const res2 = await request(app)
.get('/v1/game-channels')
.set('x-talo-alias', String(player.aliases[0].id))
.auth(token, { type: 'bearer' })
.expect(200)

expect(res2.body.channels).toHaveLength(0)
})
})

describe('cache isolation by game', () => {
it('should only invalidate cache for the affected game', async () => {
const [apiKey1, token1] = await createAPIKeyAndToken([APIKeyScope.READ_GAME_CHANNELS, APIKeyScope.WRITE_GAME_CHANNELS])
const [apiKey2, token2] = await createAPIKeyAndToken([APIKeyScope.READ_GAME_CHANNELS, APIKeyScope.WRITE_GAME_CHANNELS])

const player1 = await new PlayerFactory([apiKey1.game]).one()
const player2 = await new PlayerFactory([apiKey2.game]).one()

const channel1 = await new GameChannelFactory(apiKey1.game).state(() => ({ owner: player1.aliases[0] })).one()
const channel2 = await new GameChannelFactory(apiKey2.game).state(() => ({ owner: player2.aliases[0] })).one()

channel1.members.add(player1.aliases[0])
channel2.members.add(player2.aliases[0])

await em.persistAndFlush([player1, player2, channel1, channel2])

// cache both games
const res1Game1 = await request(app)
.get('/v1/game-channels')
.set('x-talo-alias', String(player1.aliases[0].id))
.auth(token1, { type: 'bearer' })
.expect(200)

const res1Game2 = await request(app)
.get('/v1/game-channels')
.set('x-talo-alias', String(player2.aliases[0].id))
.auth(token2, { type: 'bearer' })
.expect(200)

expect(res1Game1.body.channels).toHaveLength(1)
expect(res1Game2.body.channels).toHaveLength(1)

// create a new channel in game 1
await request(app)
.post('/v1/game-channels')
.send({ name: 'New Channel' })
.auth(token1, { type: 'bearer' })
.set('x-talo-alias', String(player1.aliases[0].id))
.expect(200)

// game 1 cache should be invalidated
const res2Game1 = await request(app)
.get('/v1/game-channels')
.set('x-talo-alias', String(player1.aliases[0].id))
.auth(token1, { type: 'bearer' })
.expect(200)

expect(res2Game1.body.channels).toHaveLength(2)

// game 2 should still have the same data (cache not affected)
const res2Game2 = await request(app)
.get('/v1/game-channels')
.set('x-talo-alias', String(player2.aliases[0].id))
.auth(token2, { type: 'bearer' })
.expect(200)

expect(res2Game2.body.channels).toHaveLength(1)
})
})
})
Loading