diff --git a/src/entities/game-channel.ts b/src/entities/game-channel.ts index 5d370d56..230f18a5 100644 --- a/src/entities/game-channel.ts +++ b/src/entities/game-channel.ts @@ -39,7 +39,7 @@ export default class GameChannel { @Property() private: boolean = false - @ManyToOne(() => Game) + @ManyToOne(() => Game, { eager: true }) game: Game @Required({ @@ -66,6 +66,12 @@ export default class GameChannel { @Property({ onUpdate: () => new Date() }) updatedAt: Date = new Date() + static getSearchCacheKey(game: Game, wildcard = false) { + let key = `channels-search-${game.id}` + if (wildcard) key += '-*' + return key + } + constructor(game: Game) { this.game = game } diff --git a/src/services/game-channel.service.ts b/src/services/game-channel.service.ts index 9691e394..8203515d 100644 --- a/src/services/game-channel.service.ts +++ b/src/services/game-channel.service.ts @@ -11,6 +11,7 @@ import buildErrorResponse from '../lib/errors/buildErrorResponse' 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 Game from '../entities/game' const itemsPerPage = DEFAULT_PAGE_SIZE @@ -30,69 +31,76 @@ export default class GameChannelService extends Service { const em: EntityManager = req.ctx.em const game: Game = req.ctx.state.game - - const query = em.qb(GameChannel, 'gc') - .select('gc.*') - .orderBy({ totalMessages: QueryOrder.DESC }) - .limit(itemsPerPage + 1) - .offset(Number(page) * itemsPerPage) - - if (search) { - query.andWhere({ - $or: [ - { name: { $like: `%${search}%` } }, - { - owner: { identifier: { $like: `%${search}%` } } - } - ] - }) - } - - if (req.ctx.state.user.api) { - query.andWhere({ - private: false - }) - } - - if (propKey) { - if (propValue) { + const searchComponent = search ? encodeURIComponent(search) : 'no-search' + const cacheKey = `${GameChannel.getSearchCacheKey(game)}-${searchComponent}-${page}-${propKey}-${propValue}` + + return withResponseCache({ + key: cacheKey, + ttl: 600 + }, async () => { + const query = em.qb(GameChannel, 'gc') + .select('gc.*') + .orderBy({ totalMessages: QueryOrder.DESC }) + .limit(itemsPerPage + 1) + .offset(Number(page) * itemsPerPage) + + if (search) { query.andWhere({ - props: { - $some: { - key: propKey, - value: propValue + $or: [ + { name: { $like: `%${search}%` } }, + { + owner: { identifier: { $like: `%${search}%` } } } - } + ] }) - } else { + } + + if (req.ctx.state.user.api) { query.andWhere({ - props: { - $some: { - key: propKey - } - } + private: false }) } - } - const [channels, count] = await query - .andWhere({ game }) - .getResultAndCount() + if (propKey) { + if (propValue) { + query.andWhere({ + props: { + $some: { + key: propKey, + value: propValue + } + } + }) + } else { + query.andWhere({ + props: { + $some: { + key: propKey + } + } + }) + } + } - await em.populate(channels, ['owner']) + const [channels, count] = await query + .andWhere({ game }) + .getResultAndCount() - const channelPromises = channels.slice(0, itemsPerPage) - .map((channel) => channel.toJSONWithCount(req.ctx.state.includeDevData)) + await em.populate(channels, ['owner']) - return { - status: 200, - body: { - channels: await Promise.all(channelPromises), - count, - itemsPerPage, - isLastPage: channels.length <= itemsPerPage + const channelPromises = channels.slice(0, itemsPerPage) + .map((channel) => channel.toJSONWithCount(req.ctx.state.includeDevData)) + + return { + status: 200, + body: { + channels: await Promise.all(channelPromises), + count, + itemsPerPage, + isLastPage: channels.length <= itemsPerPage + } } - } + }) } @Route({ diff --git a/src/subscribers/game-channel.subscriber.ts b/src/subscribers/game-channel.subscriber.ts new file mode 100644 index 00000000..0449f167 --- /dev/null +++ b/src/subscribers/game-channel.subscriber.ts @@ -0,0 +1,33 @@ +import { EventArgs, EventSubscriber } 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 + } + + await deferClearResponseCache(GameChannel.getSearchCacheKey(channel.game, true)) + } + + afterCreate(args: EventArgs) { + void this.clearSearchCacheKey(args.entity) + } + + afterUpdate(args: EventArgs): void | Promise { + 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..e456dd2c --- /dev/null +++ b/tests/subscribers/game-channel.subscriber.test.ts @@ -0,0 +1,266 @@ +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 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) + }) + }) +})