From ac074a564d45d14671cb7ac48911e9d92803f8fc Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Sun, 30 Nov 2025 10:52:55 +0000 Subject: [PATCH] add game channel subscriber for clearing cache --- src/services/game-channel.service.ts | 5 - src/subscribers/game-channel.subscriber.ts | 37 +++ src/subscribers/index.ts | 2 + .../game-channel.subscriber.test.ts | 302 ++++++++++++++++++ 4 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 src/subscribers/game-channel.subscriber.ts create mode 100644 tests/subscribers/game-channel.subscriber.test.ts diff --git a/src/services/game-channel.service.ts b/src/services/game-channel.service.ts index 6eeeb4aa..8203515d 100644 --- a/src/services/game-channel.service.ts +++ b/src/services/game-channel.service.ts @@ -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 @@ -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, @@ -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')) { @@ -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) { diff --git a/src/subscribers/game-channel.subscriber.ts b/src/subscribers/game-channel.subscriber.ts new file mode 100644 index 00000000..a89813da --- /dev/null +++ b/src/subscribers/game-channel.subscriber.ts @@ -0,0 +1,37 @@ +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(entity: GameChannel | GameChannelProp) { + 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 wrap(channel).populate(['game']) + } + + await deferClearResponseCache(GameChannel.getSearchCacheKey(channel.game, true)) + } + + afterCreate(args: EventArgs) { + void this.clearSearchCacheKey(args.entity) + } + + afterUpdate(args: EventArgs) { + void this.clearSearchCacheKey(args.entity) + } + + afterDelete(args: EventArgs) { + void this.clearSearchCacheKey(args.entity) + } +} diff --git a/src/subscribers/index.ts b/src/subscribers/index.ts index 9b26828f..0d2d22ba 100644 --- a/src/subscribers/index.ts +++ b/src/subscribers/index.ts @@ -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 ] diff --git a/tests/subscribers/game-channel.subscriber.test.ts b/tests/subscribers/game-channel.subscriber.test.ts new file mode 100644 index 00000000..60d42313 --- /dev/null +++ b/tests/subscribers/game-channel.subscriber.test.ts @@ -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) + }) + }) +})