From 46a6612dc0e5eaa470170031012ae247f7a5eec5 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Thu, 5 Jun 2025 13:16:23 -0400 Subject: convert many RedisKVCaches to QuantumKVCache or MemoryKVCache --- .../backend/src/core/ChannelFollowingService.ts | 30 +++++++++++----------- 1 file changed, 15 insertions(+), 15 deletions(-) (limited to 'packages/backend/src/core/ChannelFollowingService.ts') diff --git a/packages/backend/src/core/ChannelFollowingService.ts b/packages/backend/src/core/ChannelFollowingService.ts index 12251595e2..869456998b 100644 --- a/packages/backend/src/core/ChannelFollowingService.ts +++ b/packages/backend/src/core/ChannelFollowingService.ts @@ -9,14 +9,16 @@ import { DI } from '@/di-symbols.js'; import type { ChannelFollowingsRepository } from '@/models/_.js'; import { MiChannel } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; -import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js'; +import { GlobalEvents, GlobalEventService, InternalEventTypes } from '@/core/GlobalEventService.js'; import { bindThis } from '@/decorators.js'; import type { MiLocalUser } from '@/models/User.js'; -import { RedisKVCache } from '@/misc/cache.js'; +import { QuantumKVCache, RedisKVCache } from '@/misc/cache.js'; +import { InternalEventService } from './InternalEventService.js'; @Injectable() export class ChannelFollowingService implements OnModuleInit { - public userFollowingChannelsCache: RedisKVCache>; + // TODO check for regs + public userFollowingChannelsCache: QuantumKVCache>; constructor( @Inject(DI.redis) @@ -27,19 +29,18 @@ export class ChannelFollowingService implements OnModuleInit { private channelFollowingsRepository: ChannelFollowingsRepository, private idService: IdService, private globalEventService: GlobalEventService, + private readonly internalEventService: InternalEventService, ) { - this.userFollowingChannelsCache = new RedisKVCache>(this.redisClient, 'userFollowingChannels', { + this.userFollowingChannelsCache = new QuantumKVCache>(this.internalEventService, 'userFollowingChannels', { lifetime: 1000 * 60 * 30, // 30m - memoryCacheLifetime: 1000 * 60, // 1m fetcher: (key) => this.channelFollowingsRepository.find({ where: { followerId: key }, select: ['followeeId'], }).then(xs => new Set(xs.map(x => x.followeeId))), - toRedisConverter: (value) => JSON.stringify(Array.from(value)), - fromRedisConverter: (value) => new Set(JSON.parse(value)), }); - this.redisForSub.on('message', this.onMessage); + this.internalEventService.on('followChannel', this.onMessage); + this.internalEventService.on('unfollowChannel', this.onMessage); } onModuleInit() { @@ -79,18 +80,15 @@ export class ChannelFollowingService implements OnModuleInit { } @bindThis - private async onMessage(_: string, data: string): Promise { - const obj = JSON.parse(data); - - if (obj.channel === 'internal') { - const { type, body } = obj.message as GlobalEvents['internal']['payload']; + private async onMessage(body: InternalEventTypes[E], type: E): Promise { + { switch (type) { case 'followChannel': { - this.userFollowingChannelsCache.refresh(body.userId); + await this.userFollowingChannelsCache.delete(body.userId); break; } case 'unfollowChannel': { - this.userFollowingChannelsCache.delete(body.userId); + await this.userFollowingChannelsCache.delete(body.userId); break; } } @@ -99,6 +97,8 @@ export class ChannelFollowingService implements OnModuleInit { @bindThis public dispose(): void { + this.internalEventService.off('followChannel', this.onMessage); + this.internalEventService.off('unfollowChannel', this.onMessage); this.userFollowingChannelsCache.dispose(); } -- cgit v1.2.3-freya From 207abaff889291b3878984f81cfe37e9fc465133 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Thu, 5 Jun 2025 14:28:19 -0400 Subject: implement QuantumKVCache.setMany and QuantumKVCache.seleteMany --- .../backend/src/core/ChannelFollowingService.ts | 1 - packages/backend/src/core/GlobalEventService.ts | 2 +- packages/backend/src/core/UserListService.ts | 8 +- packages/backend/src/core/UserMutingService.ts | 4 +- .../backend/src/core/UserRenoteMutingService.ts | 4 +- packages/backend/src/misc/cache.ts | 87 +++++++++--- packages/backend/test/unit/misc/cache.ts | 146 ++++++++++++++++++--- 7 files changed, 201 insertions(+), 51 deletions(-) (limited to 'packages/backend/src/core/ChannelFollowingService.ts') diff --git a/packages/backend/src/core/ChannelFollowingService.ts b/packages/backend/src/core/ChannelFollowingService.ts index 869456998b..26b023179c 100644 --- a/packages/backend/src/core/ChannelFollowingService.ts +++ b/packages/backend/src/core/ChannelFollowingService.ts @@ -17,7 +17,6 @@ import { InternalEventService } from './InternalEventService.js'; @Injectable() export class ChannelFollowingService implements OnModuleInit { - // TODO check for regs public userFollowingChannelsCache: QuantumKVCache>; constructor( diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 763ab8c086..de35e9db19 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -265,7 +265,7 @@ export interface InternalEventTypes { unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; }; userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; }; - quantumCacheUpdated: { name: string, key: string, op: 's' | 'd' }; + quantumCacheUpdated: { name: string, keys: string[], op: 's' | 'd' }; } type EventTypesToEventPayload = EventUnionFromDictionary>>; diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts index 0240184d13..0d2220049a 100644 --- a/packages/backend/src/core/UserListService.ts +++ b/packages/backend/src/core/UserListService.ts @@ -70,16 +70,16 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit { switch (type) { case 'userListMemberAdded': { const { userListId, memberId } = body; - if (this.membersCache.has(userListId)) { - const members = await this.membersCache.get(userListId); + const members = this.membersCache.get(userListId); + if (members) { members.add(memberId); } break; } case 'userListMemberRemoved': { const { userListId, memberId } = body; - if (this.membersCache.has(userListId)) { - const members = await this.membersCache.get(userListId); + const members = this.membersCache.get(userListId); + if (members) { members.delete(memberId); } break; diff --git a/packages/backend/src/core/UserMutingService.ts b/packages/backend/src/core/UserMutingService.ts index 4f72c1863b..c15a979d0f 100644 --- a/packages/backend/src/core/UserMutingService.ts +++ b/packages/backend/src/core/UserMutingService.ts @@ -43,8 +43,6 @@ export class UserMutingService { id: In(mutings.map(m => m.id)), }); - await Promise.all(Array - .from(new Set(mutings.map(m => m.muterId))) - .map(muterId => this.cacheService.userMutingsCache.delete(muterId))); + await this.cacheService.userMutingsCache.deleteMany(mutings.map(m => m.muterId)); } } diff --git a/packages/backend/src/core/UserRenoteMutingService.ts b/packages/backend/src/core/UserRenoteMutingService.ts index 9d5ec164c8..7c0693f216 100644 --- a/packages/backend/src/core/UserRenoteMutingService.ts +++ b/packages/backend/src/core/UserRenoteMutingService.ts @@ -44,8 +44,6 @@ export class UserRenoteMutingService { id: In(mutings.map(m => m.id)), }); - await Promise.all(Array - .from(new Set(mutings.map(m => m.muterId))) - .map(muterId => this.cacheService.renoteMutingsCache.delete(muterId))); + await this.cacheService.renoteMutingsCache.deleteMany(mutings.map(m => m.muterId)); } } diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index 31e6f126b8..22201e243f 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -531,19 +531,54 @@ export class QuantumKVCache implements Iterable<[key: string, value: T]> { this.memoryCache.set(key, value); - await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 's', key }); + await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 's', keys: [key] }); if (this.onSet) { await this.onSet(key, this); } } + /** + * Creates or updates multiple value in the cache, and erases any stale caches across the cluster. + * Fires an onSet for each changed item event after the cache has been updated in all processes. + * Skips if all values are unchanged. + */ + @bindThis + public async setMany(items: Iterable<[key: string, value: T]>): Promise { + const changedKeys: string[] = []; + + for (const item of items) { + if (this.memoryCache.get(item[0]) !== item[1]) { + changedKeys.push(item[0]); + this.memoryCache.set(item[0], item[1]); + } + } + + if (changedKeys.length > 0) { + await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 's', keys: changedKeys }); + + if (this.onSet) { + for (const key of changedKeys) { + await this.onSet(key, this); + } + } + } + } + + /** + * Gets a value from the local memory cache, or returns undefined if not found. + */ + @bindThis + public get(key: string): T | undefined { + return this.memoryCache.get(key); + } + /** * Gets or fetches a value from the cache. * Fires an onSet event, but does not emit an update event to other processes. */ @bindThis - public async get(key: string): Promise { + public async fetch(key: string): Promise { let value = this.memoryCache.get(key); if (value === undefined) { value = await this.fetcher(key, this); @@ -556,15 +591,6 @@ export class QuantumKVCache implements Iterable<[key: string, value: T]> { return value; } - /** - * Alias to get(), included for backwards-compatibility with RedisKVCache. - * @deprecated use get() instead - */ - @bindThis - public async fetch(key: string): Promise { - return await this.get(key); - } - /** * Returns true is a key exists in memory. * This applies to the local subset view, not the cross-cluster cache state. @@ -582,12 +608,35 @@ export class QuantumKVCache implements Iterable<[key: string, value: T]> { public async delete(key: string): Promise { this.memoryCache.delete(key); - await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 'd', key }); + await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 'd', keys: [key] }); if (this.onDelete) { await this.onDelete(key, this); } } + /** + * Deletes multiple values from the cache, and erases any stale caches across the cluster. + * Fires an onDelete event for each key after the cache has been updated in all processes. + * Skips if the input is empty. + */ + @bindThis + public async deleteMany(keys: string[]): Promise { + if (keys.length === 0) { + return; + } + + for (const key of keys) { + this.memoryCache.delete(key); + } + + await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 'd', keys }); + + if (this.onDelete) { + for (const key of keys) { + await this.onDelete(key, this); + } + } + } /** * Refreshes the value of a key from the fetcher, and erases any stale caches across the cluster. @@ -623,14 +672,16 @@ export class QuantumKVCache implements Iterable<[key: string, value: T]> { @bindThis private async onQuantumCacheUpdated(data: InternalEventTypes['quantumCacheUpdated']): Promise { if (data.name === this.name) { - this.memoryCache.delete(data.key); + for (const key of data.keys) { + this.memoryCache.delete(key); - if (data.op === 's' && this.onSet) { - await this.onSet(data.key, this); - } + if (data.op === 's' && this.onSet) { + await this.onSet(key, this); + } - if (data.op === 'd' && this.onDelete) { - await this.onDelete(data.key, this); + if (data.op === 'd' && this.onDelete) { + await this.onDelete(key, this); + } } } } diff --git a/packages/backend/test/unit/misc/cache.ts b/packages/backend/test/unit/misc/cache.ts index 5b242c47d4..0b658618e6 100644 --- a/packages/backend/test/unit/misc/cache.ts +++ b/packages/backend/test/unit/misc/cache.ts @@ -73,7 +73,7 @@ describe(QuantumKVCache, () => { await cache.set('foo', 'bar'); - expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', key: 'foo' }]]); + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }]]); }); it('should call onSet when storing', async () => { @@ -110,13 +110,13 @@ describe(QuantumKVCache, () => { expect(fakeOnSet).toHaveBeenCalledTimes(1); }); - it('should fetch when getting an unknown value', async () => { + it('should fetch an unknown value', async () => { const cache = makeCache({ name: 'fake', fetcher: key => `value#${key}`, }); - const result = await cache.get('foo'); + const result = await cache.fetch('foo'); expect(result).toBe('value#foo'); }); @@ -127,7 +127,7 @@ describe(QuantumKVCache, () => { fetcher: key => `value#${key}`, }); - await cache.get('foo'); + await cache.fetch('foo'); const result = cache.has('foo'); expect(result).toBe(true); @@ -141,7 +141,7 @@ describe(QuantumKVCache, () => { onSet: fakeOnSet, }); - await cache.get('foo'); + await cache.fetch('foo'); expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); }); @@ -152,9 +152,9 @@ describe(QuantumKVCache, () => { fetcher: key => `value#${key}`, }); - await cache.get('foo'); + await cache.fetch('foo'); - expect(fakeInternalEventService._calls).not.toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', key: 'foo' }]]); + expect(fakeInternalEventService._calls).not.toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }]]); }); it('should delete from memory cache', async () => { @@ -186,14 +186,14 @@ describe(QuantumKVCache, () => { await cache.set('foo', 'bar'); await cache.delete('foo'); - expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 'd', key: 'foo' }]]); + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 'd', keys: ['foo'] }]]); }); it('should delete when receiving set event', async () => { const cache = makeCache({ name: 'fake' }); await cache.set('foo', 'bar'); - await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 's', key: 'foo' }); + await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }); const result = cache.has('foo'); expect(result).toBe(false); @@ -206,7 +206,7 @@ describe(QuantumKVCache, () => { onSet: fakeOnSet, }); - await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 's', key: 'foo' }); + await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }); expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); }); @@ -215,7 +215,7 @@ describe(QuantumKVCache, () => { const cache = makeCache({ name: 'fake' }); await cache.set('foo', 'bar'); - await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 'd', key: 'foo' }); + await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 'd', keys: ['foo'] }); const result = cache.has('foo'); expect(result).toBe(false); @@ -229,26 +229,130 @@ describe(QuantumKVCache, () => { }); await cache.set('foo', 'bar'); - await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 'd', key: 'foo' }); + await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 'd', keys: ['foo'] }); expect(fakeOnDelete).toHaveBeenCalledWith('foo', cache); }); - describe('fetch', () => { - it('should perform same logic as get', async () => { + describe('get', () => { + it('should return value if present', async () => { + const cache = makeCache(); + await cache.set('foo', 'bar'); + + const result = cache.get('foo'); + + expect(result).toBe('bar'); + }); + it('should return undefined if missing', () => { + const cache = makeCache(); + + const result = cache.get('foo'); + + expect(result).toBe(undefined); + }); + }); + + describe('setMany', () => { + it('should populate all values', async () => { + const cache = makeCache(); + + await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]); + + expect(cache.has('foo')).toBe(true); + expect(cache.has('alpha')).toBe(true); + }); + + it('should emit one event', async () => { + const cache = makeCache({ + name: 'fake', + }); + + await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]); + + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo', 'alpha'] }]]); + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1); + }); + + it('should call onSet for each item', async () => { const fakeOnSet = jest.fn(() => Promise.resolve()); const cache = makeCache({ name: 'fake', - fetcher: key => `value#${key}`, onSet: fakeOnSet, }); - // noinspection JSDeprecatedSymbols - const result = await cache.fetch('foo'); + await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]); - expect(result).toBe('value#foo'); expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); - expect(fakeInternalEventService._calls).not.toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', key: 'foo' }]]); + expect(fakeOnSet).toHaveBeenCalledWith('alpha', cache); + }); + + it('should emit events only for changed items', async () => { + const fakeOnSet = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + onSet: fakeOnSet, + }); + + await cache.set('foo', 'bar'); + fakeOnSet.mockClear(); + fakeInternalEventService._reset(); + + await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]); + + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['alpha'] }]]); + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1); + expect(fakeOnSet).toHaveBeenCalledWith('alpha', cache); + expect(fakeOnSet).toHaveBeenCalledTimes(1); + }); + }); + + describe('deleteMany', () => { + it('should remove keys from memory cache', async () => { + const cache = makeCache(); + + await cache.set('foo', 'bar'); + await cache.set('alpha', 'omega'); + await cache.deleteMany(['foo', 'alpha']); + + expect(cache.has('foo')).toBe(false); + expect(cache.has('alpha')).toBe(false); + }); + + it('should emit only one event', async () => { + const cache = makeCache({ + name: 'fake', + }); + + await cache.deleteMany(['foo', 'alpha']); + + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 'd', keys: ['foo', 'alpha'] }]]); + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1); + }); + + it('should call onDelete for each key', async () => { + const fakeOnDelete = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + onDelete: fakeOnDelete, + }); + + await cache.deleteMany(['foo', 'alpha']); + + expect(fakeOnDelete).toHaveBeenCalledWith('foo', cache); + expect(fakeOnDelete).toHaveBeenCalledWith('alpha', cache); + }); + + it('should do nothing if no keys are provided', async () => { + const fakeOnDelete = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + onDelete: fakeOnDelete, + }); + + await cache.deleteMany([]); + + expect(fakeOnDelete).not.toHaveBeenCalled(); + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0); }); }); @@ -296,7 +400,7 @@ describe(QuantumKVCache, () => { onSet: fakeOnSet, }); - await cache.refresh('foo') + await cache.refresh('foo'); expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); }); @@ -309,7 +413,7 @@ describe(QuantumKVCache, () => { await cache.refresh('foo'); - expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', key: 'foo' }]]); + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }]]); }); }); -- cgit v1.2.3-freya From 0c84d73294cb85a2126696abadb37003f3c08d7b Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 6 Jun 2025 12:26:43 -0400 Subject: move QuantumKVCache to a separate file --- packages/backend/src/core/CacheService.ts | 3 +- .../backend/src/core/ChannelFollowingService.ts | 2 +- .../backend/src/core/PushNotificationService.ts | 2 +- packages/backend/src/core/UserListService.ts | 2 +- packages/backend/src/misc/QuantumKVCache.ts | 318 +++++++++++ packages/backend/src/misc/cache.ts | 311 ----------- packages/backend/test/misc/noOpCaches.ts | 3 +- packages/backend/test/unit/misc/QuantumKVCache.ts | 596 ++++++++++++++++++++ packages/backend/test/unit/misc/cache.ts | 597 --------------------- 9 files changed, 921 insertions(+), 913 deletions(-) create mode 100644 packages/backend/src/misc/QuantumKVCache.ts create mode 100644 packages/backend/test/unit/misc/QuantumKVCache.ts delete mode 100644 packages/backend/test/unit/misc/cache.ts (limited to 'packages/backend/src/core/ChannelFollowingService.ts') diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index 2c136eac2b..e59857b4ce 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -7,7 +7,8 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; import { In, IsNull } from 'typeorm'; import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing, MiNote } from '@/models/_.js'; -import { MemoryKVCache, QuantumKVCache, RedisKVCache } from '@/misc/cache.js'; +import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; +import { QuantumKVCache } from '@/misc/QuantumKVCache.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; diff --git a/packages/backend/src/core/ChannelFollowingService.ts b/packages/backend/src/core/ChannelFollowingService.ts index 26b023179c..430711fef1 100644 --- a/packages/backend/src/core/ChannelFollowingService.ts +++ b/packages/backend/src/core/ChannelFollowingService.ts @@ -12,7 +12,7 @@ import { IdService } from '@/core/IdService.js'; import { GlobalEvents, GlobalEventService, InternalEventTypes } from '@/core/GlobalEventService.js'; import { bindThis } from '@/decorators.js'; import type { MiLocalUser } from '@/models/User.js'; -import { QuantumKVCache, RedisKVCache } from '@/misc/cache.js'; +import { QuantumKVCache } from '@/misc/QuantumKVCache.js'; import { InternalEventService } from './InternalEventService.js'; @Injectable() diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index 38bc5e3901..e3f10d4504 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -12,7 +12,7 @@ import type { Packed } from '@/misc/json-schema.js'; import { getNoteSummary } from '@/misc/get-note-summary.js'; import type { MiMeta, MiSwSubscription, SwSubscriptionsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; -import { QuantumKVCache, RedisKVCache } from '@/misc/cache.js'; +import { QuantumKVCache } from '@/misc/QuantumKVCache.js'; import { InternalEventService } from '@/core/InternalEventService.js'; // Defined also packages/sw/types.ts#L13 diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts index 0d2220049a..b4486b9808 100644 --- a/packages/backend/src/core/UserListService.ts +++ b/packages/backend/src/core/UserListService.ts @@ -17,7 +17,7 @@ import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { QueueService } from '@/core/QueueService.js'; -import { QuantumKVCache, RedisKVCache } from '@/misc/cache.js'; +import { QuantumKVCache } from '@/misc/QuantumKVCache.js'; import { RoleService } from '@/core/RoleService.js'; import { SystemAccountService } from '@/core/SystemAccountService.js'; import { InternalEventService } from '@/core/InternalEventService.js'; diff --git a/packages/backend/src/misc/QuantumKVCache.ts b/packages/backend/src/misc/QuantumKVCache.ts new file mode 100644 index 0000000000..6b36789f5e --- /dev/null +++ b/packages/backend/src/misc/QuantumKVCache.ts @@ -0,0 +1,318 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { InternalEventService } from '@/core/InternalEventService.js'; +import { bindThis } from '@/decorators.js'; +import { InternalEventTypes } from '@/core/GlobalEventService.js'; +import { MemoryKVCache } from '@/misc/cache.js'; + +export interface QuantumKVOpts { + /** + * Memory cache lifetime in milliseconds. + */ + lifetime: number; + + /** + * Callback to fetch the value for a key that wasn't found in the cache. + * May be synchronous or async. + */ + fetcher: (key: string, cache: QuantumKVCache) => T | Promise; + + /** + * Optional callback when a value is created or changed in the cache, either locally or elsewhere in the cluster. + * This is called *after* the cache state is updated. + * May be synchronous or async. + */ + onSet?: (key: string, cache: QuantumKVCache) => void | Promise; + + /** + * Optional callback when a value is deleted from the cache, either locally or elsewhere in the cluster. + * This is called *after* the cache state is updated. + * May be synchronous or async. + */ + onDelete?: (key: string, cache: QuantumKVCache) => void | Promise; +} + +/** + * QuantumKVCache is a lifetime-bounded memory cache (like MemoryKVCache) with automatic cross-cluster synchronization via Redis. + * All nodes in the cluster are guaranteed to have a *subset* view of the current accurate state, though individual processes may have different items in their local cache. + * This ensures that a call to get() will never return stale data. + */ +export class QuantumKVCache implements Iterable<[key: string, value: T]> { + private readonly memoryCache: MemoryKVCache; + + public readonly fetcher: QuantumKVOpts['fetcher']; + public readonly onSet: QuantumKVOpts['onSet']; + public readonly onDelete: QuantumKVOpts['onDelete']; + + /** + * @param internalEventService Service bus to synchronize events. + * @param name Unique name of the cache - must be the same in all processes. + * @param opts Cache options + */ + constructor( + private readonly internalEventService: InternalEventService, + private readonly name: string, + opts: QuantumKVOpts, + ) { + this.memoryCache = new MemoryKVCache(opts.lifetime); + this.fetcher = opts.fetcher; + this.onSet = opts.onSet; + this.onDelete = opts.onDelete; + + this.internalEventService.on('quantumCacheUpdated', this.onQuantumCacheUpdated, { + // Ignore our own events, otherwise we'll immediately erase any set value. + ignoreLocal: true, + }); + } + + /** + * The number of items currently in memory. + * This applies to the local subset view, not the cross-cluster cache state. + */ + public get size() { + return this.memoryCache.size; + } + + /** + * Iterates all [key, value] pairs in memory. + * This applies to the local subset view, not the cross-cluster cache state. + */ + @bindThis + public *entries(): Generator<[key: string, value: T]> { + for (const entry of this.memoryCache.entries) { + yield [entry[0], entry[1].value]; + } + } + + /** + * Iterates all keys in memory. + * This applies to the local subset view, not the cross-cluster cache state. + */ + @bindThis + public *keys() { + for (const entry of this.memoryCache.entries) { + yield entry[0]; + } + } + + /** + * Iterates all values pairs in memory. + * This applies to the local subset view, not the cross-cluster cache state. + */ + @bindThis + public *values() { + for (const entry of this.memoryCache.entries) { + yield entry[1].value; + } + } + + /** + * Creates or updates a value in the cache, and erases any stale caches across the cluster. + * Fires an onSet event after the cache has been updated in all processes. + * Skips if the value is unchanged. + */ + @bindThis + public async set(key: string, value: T): Promise { + if (this.memoryCache.get(key) === value) { + return; + } + + this.memoryCache.set(key, value); + + await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 's', keys: [key] }); + + if (this.onSet) { + await this.onSet(key, this); + } + } + + /** + * Creates or updates multiple value in the cache, and erases any stale caches across the cluster. + * Fires an onSet for each changed item event after the cache has been updated in all processes. + * Skips if all values are unchanged. + */ + @bindThis + public async setMany(items: Iterable<[key: string, value: T]>): Promise { + const changedKeys: string[] = []; + + for (const item of items) { + if (this.memoryCache.get(item[0]) !== item[1]) { + changedKeys.push(item[0]); + this.memoryCache.set(item[0], item[1]); + } + } + + if (changedKeys.length > 0) { + await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 's', keys: changedKeys }); + + if (this.onSet) { + for (const key of changedKeys) { + await this.onSet(key, this); + } + } + } + } + + /** + * Adds a value to the local memory cache without notifying other process. + * Neither a Redis event nor onSet callback will be fired, as the value has not actually changed. + * This should only be used when the value is known to be current, like after fetching from the database. + */ + @bindThis + public add(key: string, value: T): void { + this.memoryCache.set(key, value); + } + + /** + * Adds multiple values to the local memory cache without notifying other process. + * Neither a Redis event nor onSet callback will be fired, as the value has not actually changed. + * This should only be used when the value is known to be current, like after fetching from the database. + */ + @bindThis + public addMany(items: Iterable<[key: string, value: T]>): void { + for (const [key, value] of items) { + this.memoryCache.set(key, value); + } + } + + /** + * Gets a value from the local memory cache, or returns undefined if not found. + */ + @bindThis + public get(key: string): T | undefined { + return this.memoryCache.get(key); + } + + /** + * Gets or fetches a value from the cache. + * Fires an onSet event, but does not emit an update event to other processes. + */ + @bindThis + public async fetch(key: string): Promise { + let value = this.memoryCache.get(key); + if (value === undefined) { + value = await this.fetcher(key, this); + this.memoryCache.set(key, value); + + if (this.onSet) { + await this.onSet(key, this); + } + } + return value; + } + + /** + * Returns true is a key exists in memory. + * This applies to the local subset view, not the cross-cluster cache state. + */ + @bindThis + public has(key: string): boolean { + return this.memoryCache.get(key) !== undefined; + } + + /** + * Deletes a value from the cache, and erases any stale caches across the cluster. + * Fires an onDelete event after the cache has been updated in all processes. + */ + @bindThis + public async delete(key: string): Promise { + this.memoryCache.delete(key); + + await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 'd', keys: [key] }); + + if (this.onDelete) { + await this.onDelete(key, this); + } + } + /** + * Deletes multiple values from the cache, and erases any stale caches across the cluster. + * Fires an onDelete event for each key after the cache has been updated in all processes. + * Skips if the input is empty. + */ + @bindThis + public async deleteMany(keys: string[]): Promise { + if (keys.length === 0) { + return; + } + + for (const key of keys) { + this.memoryCache.delete(key); + } + + await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 'd', keys }); + + if (this.onDelete) { + for (const key of keys) { + await this.onDelete(key, this); + } + } + } + + /** + * Refreshes the value of a key from the fetcher, and erases any stale caches across the cluster. + * Fires an onSet event after the cache has been updated in all processes. + */ + @bindThis + public async refresh(key: string): Promise { + const value = await this.fetcher(key, this); + await this.set(key, value); + return value; + } + + /** + * Erases all entries from the local memory cache. + * Does not send any events or update other processes. + */ + @bindThis + public clear() { + this.memoryCache.clear(); + } + + /** + * Removes expired cache entries from the local view. + * Does not send any events or update other processes. + */ + @bindThis + public gc() { + this.memoryCache.gc(); + } + + /** + * Erases all data and disconnects from the cluster. + * This *must* be called when shutting down to prevent memory leaks! + */ + @bindThis + public dispose() { + this.internalEventService.off('quantumCacheUpdated', this.onQuantumCacheUpdated); + + this.memoryCache.dispose(); + } + + @bindThis + private async onQuantumCacheUpdated(data: InternalEventTypes['quantumCacheUpdated']): Promise { + if (data.name === this.name) { + for (const key of data.keys) { + this.memoryCache.delete(key); + + if (data.op === 's' && this.onSet) { + await this.onSet(key, this); + } + + if (data.op === 'd' && this.onDelete) { + await this.onDelete(key, this); + } + } + } + } + + /** + * Iterates all [key, value] pairs in memory. + * This applies to the local subset view, not the cross-cluster cache state. + */ + [Symbol.iterator](): Iterator<[key: string, value: T]> { + return this.entries(); + } +} diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index 0a1cf6adb4..932c0b409a 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -422,314 +422,3 @@ export class MemorySingleCache { return value; } } - -// TODO move to separate file - -export interface QuantumKVOpts { - /** - * Memory cache lifetime in milliseconds. - */ - lifetime: number; - - /** - * Callback to fetch the value for a key that wasn't found in the cache. - * May be synchronous or async. - */ - fetcher: (key: string, cache: QuantumKVCache) => T | Promise; - - /** - * Optional callback when a value is created or changed in the cache, either locally or elsewhere in the cluster. - * This is called *after* the cache state is updated. - * May be synchronous or async. - */ - onSet?: (key: string, cache: QuantumKVCache) => void | Promise; - - /** - * Optional callback when a value is deleted from the cache, either locally or elsewhere in the cluster. - * This is called *after* the cache state is updated. - * May be synchronous or async. - */ - onDelete?: (key: string, cache: QuantumKVCache) => void | Promise; -} - -/** - * QuantumKVCache is a lifetime-bounded memory cache (like MemoryKVCache) with automatic cross-cluster synchronization via Redis. - * All nodes in the cluster are guaranteed to have a *subset* view of the current accurate state, though individual processes may have different items in their local cache. - * This ensures that a call to get() will never return stale data. - */ -export class QuantumKVCache implements Iterable<[key: string, value: T]> { - private readonly memoryCache: MemoryKVCache; - - public readonly fetcher: QuantumKVOpts['fetcher']; - public readonly onSet: QuantumKVOpts['onSet']; - public readonly onDelete: QuantumKVOpts['onDelete']; - - /** - * @param internalEventService Service bus to synchronize events. - * @param name Unique name of the cache - must be the same in all processes. - * @param opts Cache options - */ - constructor( - private readonly internalEventService: InternalEventService, - private readonly name: string, - opts: QuantumKVOpts, - ) { - this.memoryCache = new MemoryKVCache(opts.lifetime); - this.fetcher = opts.fetcher; - this.onSet = opts.onSet; - this.onDelete = opts.onDelete; - - this.internalEventService.on('quantumCacheUpdated', this.onQuantumCacheUpdated, { - // Ignore our own events, otherwise we'll immediately erase any set value. - ignoreLocal: true, - }); - } - - /** - * The number of items currently in memory. - * This applies to the local subset view, not the cross-cluster cache state. - */ - public get size() { - return this.memoryCache.size; - } - - /** - * Iterates all [key, value] pairs in memory. - * This applies to the local subset view, not the cross-cluster cache state. - */ - @bindThis - public *entries(): Generator<[key: string, value: T]> { - for (const entry of this.memoryCache.entries) { - yield [entry[0], entry[1].value]; - } - } - - /** - * Iterates all keys in memory. - * This applies to the local subset view, not the cross-cluster cache state. - */ - @bindThis - public *keys() { - for (const entry of this.memoryCache.entries) { - yield entry[0]; - } - } - - /** - * Iterates all values pairs in memory. - * This applies to the local subset view, not the cross-cluster cache state. - */ - @bindThis - public *values() { - for (const entry of this.memoryCache.entries) { - yield entry[1].value; - } - } - - /** - * Creates or updates a value in the cache, and erases any stale caches across the cluster. - * Fires an onSet event after the cache has been updated in all processes. - * Skips if the value is unchanged. - */ - @bindThis - public async set(key: string, value: T): Promise { - if (this.memoryCache.get(key) === value) { - return; - } - - this.memoryCache.set(key, value); - - await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 's', keys: [key] }); - - if (this.onSet) { - await this.onSet(key, this); - } - } - - /** - * Creates or updates multiple value in the cache, and erases any stale caches across the cluster. - * Fires an onSet for each changed item event after the cache has been updated in all processes. - * Skips if all values are unchanged. - */ - @bindThis - public async setMany(items: Iterable<[key: string, value: T]>): Promise { - const changedKeys: string[] = []; - - for (const item of items) { - if (this.memoryCache.get(item[0]) !== item[1]) { - changedKeys.push(item[0]); - this.memoryCache.set(item[0], item[1]); - } - } - - if (changedKeys.length > 0) { - await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 's', keys: changedKeys }); - - if (this.onSet) { - for (const key of changedKeys) { - await this.onSet(key, this); - } - } - } - } - - /** - * Adds a value to the local memory cache without notifying other process. - * Neither a Redis event nor onSet callback will be fired, as the value has not actually changed. - * This should only be used when the value is known to be current, like after fetching from the database. - */ - @bindThis - public add(key: string, value: T): void { - this.memoryCache.set(key, value); - } - - /** - * Adds multiple values to the local memory cache without notifying other process. - * Neither a Redis event nor onSet callback will be fired, as the value has not actually changed. - * This should only be used when the value is known to be current, like after fetching from the database. - */ - @bindThis - public addMany(items: Iterable<[key: string, value: T]>): void { - for (const [key, value] of items) { - this.memoryCache.set(key, value); - } - } - - /** - * Gets a value from the local memory cache, or returns undefined if not found. - */ - @bindThis - public get(key: string): T | undefined { - return this.memoryCache.get(key); - } - - /** - * Gets or fetches a value from the cache. - * Fires an onSet event, but does not emit an update event to other processes. - */ - @bindThis - public async fetch(key: string): Promise { - let value = this.memoryCache.get(key); - if (value === undefined) { - value = await this.fetcher(key, this); - this.memoryCache.set(key, value); - - if (this.onSet) { - await this.onSet(key, this); - } - } - return value; - } - - /** - * Returns true is a key exists in memory. - * This applies to the local subset view, not the cross-cluster cache state. - */ - @bindThis - public has(key: string): boolean { - return this.memoryCache.get(key) !== undefined; - } - - /** - * Deletes a value from the cache, and erases any stale caches across the cluster. - * Fires an onDelete event after the cache has been updated in all processes. - */ - @bindThis - public async delete(key: string): Promise { - this.memoryCache.delete(key); - - await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 'd', keys: [key] }); - - if (this.onDelete) { - await this.onDelete(key, this); - } - } - /** - * Deletes multiple values from the cache, and erases any stale caches across the cluster. - * Fires an onDelete event for each key after the cache has been updated in all processes. - * Skips if the input is empty. - */ - @bindThis - public async deleteMany(keys: string[]): Promise { - if (keys.length === 0) { - return; - } - - for (const key of keys) { - this.memoryCache.delete(key); - } - - await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 'd', keys }); - - if (this.onDelete) { - for (const key of keys) { - await this.onDelete(key, this); - } - } - } - - /** - * Refreshes the value of a key from the fetcher, and erases any stale caches across the cluster. - * Fires an onSet event after the cache has been updated in all processes. - */ - @bindThis - public async refresh(key: string): Promise { - const value = await this.fetcher(key, this); - await this.set(key, value); - return value; - } - - /** - * Erases all entries from the local memory cache. - * Does not send any events or update other processes. - */ - @bindThis - public clear() { - this.memoryCache.clear(); - } - - /** - * Removes expired cache entries from the local view. - * Does not send any events or update other processes. - */ - @bindThis - public gc() { - this.memoryCache.gc(); - } - - /** - * Erases all data and disconnects from the cluster. - * This *must* be called when shutting down to prevent memory leaks! - */ - @bindThis - public dispose() { - this.internalEventService.off('quantumCacheUpdated', this.onQuantumCacheUpdated); - - this.memoryCache.dispose(); - } - - @bindThis - private async onQuantumCacheUpdated(data: InternalEventTypes['quantumCacheUpdated']): Promise { - if (data.name === this.name) { - for (const key of data.keys) { - this.memoryCache.delete(key); - - if (data.op === 's' && this.onSet) { - await this.onSet(key, this); - } - - if (data.op === 'd' && this.onDelete) { - await this.onDelete(key, this); - } - } - } - } - - /** - * Iterates all [key, value] pairs in memory. - * This applies to the local subset view, not the cross-cluster cache state. - */ - [Symbol.iterator](): Iterator<[key: string, value: T]> { - return this.entries(); - } -} diff --git a/packages/backend/test/misc/noOpCaches.ts b/packages/backend/test/misc/noOpCaches.ts index 373c7bddcc..c05632239b 100644 --- a/packages/backend/test/misc/noOpCaches.ts +++ b/packages/backend/test/misc/noOpCaches.ts @@ -8,7 +8,8 @@ import { Inject } from '@nestjs/common'; import { FakeInternalEventService } from './FakeInternalEventService.js'; import type { BlockingsRepository, FollowingsRepository, MiFollowing, MiUser, MiUserProfile, MutingsRepository, RenoteMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { MiLocalUser } from '@/models/User.js'; -import { MemoryKVCache, MemorySingleCache, QuantumKVCache, QuantumKVOpts, RedisKVCache, RedisSingleCache } from '@/misc/cache.js'; +import { MemoryKVCache, MemorySingleCache, RedisKVCache, RedisSingleCache } from '@/misc/cache.js'; +import { QuantumKVCache, QuantumKVOpts } from '@/misc/QuantumKVCache.js'; import { CacheService, CachedTranslationEntity, FollowStats } from '@/core/CacheService.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; diff --git a/packages/backend/test/unit/misc/QuantumKVCache.ts b/packages/backend/test/unit/misc/QuantumKVCache.ts new file mode 100644 index 0000000000..72997494ce --- /dev/null +++ b/packages/backend/test/unit/misc/QuantumKVCache.ts @@ -0,0 +1,596 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { jest } from '@jest/globals'; +import { FakeInternalEventService } from '../../misc/FakeInternalEventService.js'; +import { QuantumKVCache, QuantumKVOpts } from '@/misc/QuantumKVCache.js'; + +describe(QuantumKVCache, () => { + let fakeInternalEventService: FakeInternalEventService; + let madeCaches: { dispose: () => void }[]; + + function makeCache(opts?: Partial> & { name?: string }): QuantumKVCache { + const _opts = { + name: 'test', + lifetime: Infinity, + fetcher: () => { throw new Error('not implemented'); }, + } satisfies QuantumKVOpts & { name: string }; + + if (opts) { + Object.assign(_opts, opts); + } + + const cache = new QuantumKVCache(fakeInternalEventService, _opts.name, _opts); + madeCaches.push(cache); + return cache; + } + + beforeEach(() => { + madeCaches = []; + fakeInternalEventService = new FakeInternalEventService(); + }); + + afterEach(() => { + madeCaches.forEach(cache => { + cache.dispose(); + }); + }); + + it('should connect on construct', () => { + makeCache(); + + expect(fakeInternalEventService._calls).toContainEqual(['on', ['quantumCacheUpdated', expect.anything(), { ignoreLocal: true }]]); + }); + + it('should disconnect on dispose', () => { + const cache = makeCache(); + + cache.dispose(); + + const callback = fakeInternalEventService._calls + .find(c => c[0] === 'on' && c[1][0] === 'quantumCacheUpdated') + ?.[1][1]; + expect(fakeInternalEventService._calls).toContainEqual(['off', ['quantumCacheUpdated', callback]]); + }); + + it('should store in memory cache', async () => { + const cache = makeCache(); + + await cache.set('foo', 'bar'); + await cache.set('alpha', 'omega'); + + const result1 = await cache.get('foo'); + const result2 = await cache.get('alpha'); + + expect(result1).toBe('bar'); + expect(result2).toBe('omega'); + }); + + it('should emit event when storing', async () => { + const cache = makeCache({ name: 'fake' }); + + await cache.set('foo', 'bar'); + + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }]]); + }); + + it('should call onSet when storing', async () => { + const fakeOnSet = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + onSet: fakeOnSet, + }); + + await cache.set('foo', 'bar'); + + expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); + }); + + it('should not emit event when storing unchanged value', async () => { + const cache = makeCache({ name: 'fake' }); + + await cache.set('foo', 'bar'); + await cache.set('foo', 'bar'); + + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1); + }); + + it('should not call onSet when storing unchanged value', async () => { + const fakeOnSet = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + onSet: fakeOnSet, + }); + + await cache.set('foo', 'bar'); + await cache.set('foo', 'bar'); + + expect(fakeOnSet).toHaveBeenCalledTimes(1); + }); + + it('should fetch an unknown value', async () => { + const cache = makeCache({ + name: 'fake', + fetcher: key => `value#${key}`, + }); + + const result = await cache.fetch('foo'); + + expect(result).toBe('value#foo'); + }); + + it('should store fetched value in memory cache', async () => { + const cache = makeCache({ + name: 'fake', + fetcher: key => `value#${key}`, + }); + + await cache.fetch('foo'); + + const result = cache.has('foo'); + expect(result).toBe(true); + }); + + it('should call onSet when fetching', async () => { + const fakeOnSet = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + fetcher: key => `value#${key}`, + onSet: fakeOnSet, + }); + + await cache.fetch('foo'); + + expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); + }); + + it('should not emit event when fetching', async () => { + const cache = makeCache({ + name: 'fake', + fetcher: key => `value#${key}`, + }); + + await cache.fetch('foo'); + + expect(fakeInternalEventService._calls).not.toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }]]); + }); + + it('should delete from memory cache', async () => { + const cache = makeCache(); + + await cache.set('foo', 'bar'); + await cache.delete('foo'); + + const result = cache.has('foo'); + expect(result).toBe(false); + }); + + it('should call onDelete when deleting', async () => { + const fakeOnDelete = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + onDelete: fakeOnDelete, + }); + + await cache.set('foo', 'bar'); + await cache.delete('foo'); + + expect(fakeOnDelete).toHaveBeenCalledWith('foo', cache); + }); + + it('should emit event when deleting', async () => { + const cache = makeCache({ name: 'fake' }); + + await cache.set('foo', 'bar'); + await cache.delete('foo'); + + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 'd', keys: ['foo'] }]]); + }); + + it('should delete when receiving set event', async () => { + const cache = makeCache({ name: 'fake' }); + await cache.set('foo', 'bar'); + + await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }); + + const result = cache.has('foo'); + expect(result).toBe(false); + }); + + it('should call onSet when receiving set event', async () => { + const fakeOnSet = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + onSet: fakeOnSet, + }); + + await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }); + + expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); + }); + + it('should delete when receiving delete event', async () => { + const cache = makeCache({ name: 'fake' }); + await cache.set('foo', 'bar'); + + await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 'd', keys: ['foo'] }); + + const result = cache.has('foo'); + expect(result).toBe(false); + }); + + it('should call onDelete when receiving delete event', async () => { + const fakeOnDelete = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + onDelete: fakeOnDelete, + }); + await cache.set('foo', 'bar'); + + await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 'd', keys: ['foo'] }); + + expect(fakeOnDelete).toHaveBeenCalledWith('foo', cache); + }); + + describe('get', () => { + it('should return value if present', async () => { + const cache = makeCache(); + await cache.set('foo', 'bar'); + + const result = cache.get('foo'); + + expect(result).toBe('bar'); + }); + it('should return undefined if missing', () => { + const cache = makeCache(); + + const result = cache.get('foo'); + + expect(result).toBe(undefined); + }); + }); + + describe('setMany', () => { + it('should populate all values', async () => { + const cache = makeCache(); + + await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]); + + expect(cache.has('foo')).toBe(true); + expect(cache.has('alpha')).toBe(true); + }); + + it('should emit one event', async () => { + const cache = makeCache({ + name: 'fake', + }); + + await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]); + + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo', 'alpha'] }]]); + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1); + }); + + it('should call onSet for each item', async () => { + const fakeOnSet = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + onSet: fakeOnSet, + }); + + await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]); + + expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); + expect(fakeOnSet).toHaveBeenCalledWith('alpha', cache); + }); + + it('should emit events only for changed items', async () => { + const fakeOnSet = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + onSet: fakeOnSet, + }); + + await cache.set('foo', 'bar'); + fakeOnSet.mockClear(); + fakeInternalEventService._reset(); + + await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]); + + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['alpha'] }]]); + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1); + expect(fakeOnSet).toHaveBeenCalledWith('alpha', cache); + expect(fakeOnSet).toHaveBeenCalledTimes(1); + }); + }); + + describe('deleteMany', () => { + it('should remove keys from memory cache', async () => { + const cache = makeCache(); + + await cache.set('foo', 'bar'); + await cache.set('alpha', 'omega'); + await cache.deleteMany(['foo', 'alpha']); + + expect(cache.has('foo')).toBe(false); + expect(cache.has('alpha')).toBe(false); + }); + + it('should emit only one event', async () => { + const cache = makeCache({ + name: 'fake', + }); + + await cache.deleteMany(['foo', 'alpha']); + + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 'd', keys: ['foo', 'alpha'] }]]); + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1); + }); + + it('should call onDelete for each key', async () => { + const fakeOnDelete = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + onDelete: fakeOnDelete, + }); + + await cache.deleteMany(['foo', 'alpha']); + + expect(fakeOnDelete).toHaveBeenCalledWith('foo', cache); + expect(fakeOnDelete).toHaveBeenCalledWith('alpha', cache); + }); + + it('should do nothing if no keys are provided', async () => { + const fakeOnDelete = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + onDelete: fakeOnDelete, + }); + + await cache.deleteMany([]); + + expect(fakeOnDelete).not.toHaveBeenCalled(); + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0); + }); + }); + + describe('refresh', () => { + it('should populate the value', async () => { + const cache = makeCache({ + name: 'fake', + fetcher: key => `value#${key}`, + }); + + await cache.refresh('foo'); + + const result = cache.has('foo'); + expect(result).toBe(true); + }); + + it('should return the value', async () => { + const cache = makeCache({ + name: 'fake', + fetcher: key => `value#${key}`, + }); + + const result = await cache.refresh('foo'); + + expect(result).toBe('value#foo'); + }); + + it('should replace the value if it exists', async () => { + const cache = makeCache({ + name: 'fake', + fetcher: key => `value#${key}`, + }); + + await cache.set('foo', 'bar'); + const result = await cache.refresh('foo'); + + expect(result).toBe('value#foo'); + }); + + it('should call onSet', async () => { + const fakeOnSet = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + name: 'fake', + fetcher: key => `value#${key}`, + onSet: fakeOnSet, + }); + + await cache.refresh('foo'); + + expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); + }); + + it('should emit event', async () => { + const cache = makeCache({ + name: 'fake', + fetcher: key => `value#${key}`, + }); + + await cache.refresh('foo'); + + expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }]]); + }); + }); + + describe('add', () => { + it('should add the item', () => { + const cache = makeCache(); + cache.add('foo', 'bar'); + expect(cache.has('foo')).toBe(true); + }); + + it('should not emit event', () => { + const cache = makeCache({ + name: 'fake', + }); + + cache.add('foo', 'bar'); + + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0); + }); + + it('should not call onSet', () => { + const fakeOnSet = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + onSet: fakeOnSet, + }); + + cache.add('foo', 'bar'); + + expect(fakeOnSet).not.toHaveBeenCalled(); + }); + }); + + describe('addMany', () => { + it('should add all items', () => { + const cache = makeCache(); + + cache.addMany([['foo', 'bar'], ['alpha', 'omega']]); + + expect(cache.has('foo')).toBe(true); + expect(cache.has('alpha')).toBe(true); + }); + + it('should not emit event', () => { + const cache = makeCache({ + name: 'fake', + }); + + cache.addMany([['foo', 'bar'], ['alpha', 'omega']]); + + expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0); + }); + + it('should not call onSet', () => { + const fakeOnSet = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + onSet: fakeOnSet, + }); + + cache.addMany([['foo', 'bar'], ['alpha', 'omega']]); + + expect(fakeOnSet).not.toHaveBeenCalled(); + }); + }); + + describe('has', () => { + it('should return false when empty', () => { + const cache = makeCache(); + const result = cache.has('foo'); + expect(result).toBe(false); + }); + + it('should return false when value is not in memory', async () => { + const cache = makeCache(); + await cache.set('foo', 'bar'); + + const result = cache.has('alpha'); + + expect(result).toBe(false); + }); + + it('should return true when value is in memory', async () => { + const cache = makeCache(); + await cache.set('foo', 'bar'); + + const result = cache.has('foo'); + + expect(result).toBe(true); + }); + }); + + describe('size', () => { + it('should return 0 when empty', () => { + const cache = makeCache(); + expect(cache.size).toBe(0); + }); + + it('should return correct size when populated', async () => { + const cache = makeCache(); + await cache.set('foo', 'bar'); + + expect(cache.size).toBe(1); + }); + }); + + describe('entries', () => { + it('should return empty when empty', () => { + const cache = makeCache(); + + const result = Array.from(cache.entries()); + + expect(result).toHaveLength(0); + }); + + it('should return all entries when populated', async () => { + const cache = makeCache(); + await cache.set('foo', 'bar'); + + const result = Array.from(cache.entries()); + + expect(result).toEqual([['foo', 'bar']]); + }); + }); + + describe('keys', () => { + it('should return empty when empty', () => { + const cache = makeCache(); + + const result = Array.from(cache.keys()); + + expect(result).toHaveLength(0); + }); + + it('should return all keys when populated', async () => { + const cache = makeCache(); + await cache.set('foo', 'bar'); + + const result = Array.from(cache.keys()); + + expect(result).toEqual(['foo']); + }); + }); + + describe('values', () => { + it('should return empty when empty', () => { + const cache = makeCache(); + + const result = Array.from(cache.values()); + + expect(result).toHaveLength(0); + }); + + it('should return all values when populated', async () => { + const cache = makeCache(); + await cache.set('foo', 'bar'); + + const result = Array.from(cache.values()); + + expect(result).toEqual(['bar']); + }); + }); + + describe('[Symbol.iterator]', () => { + it('should return empty when empty', () => { + const cache = makeCache(); + + const result = Array.from(cache); + + expect(result).toHaveLength(0); + }); + + it('should return all entries when populated', async () => { + const cache = makeCache(); + await cache.set('foo', 'bar'); + + const result = Array.from(cache); + + expect(result).toEqual([['foo', 'bar']]); + }); + }); +}); diff --git a/packages/backend/test/unit/misc/cache.ts b/packages/backend/test/unit/misc/cache.ts deleted file mode 100644 index e24f6d4dcc..0000000000 --- a/packages/backend/test/unit/misc/cache.ts +++ /dev/null @@ -1,597 +0,0 @@ -/* - * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { jest } from '@jest/globals'; -import { FakeInternalEventService } from '../../misc/FakeInternalEventService.js'; -import { QuantumKVCache, QuantumKVOpts } from '@/misc/cache.js'; - -describe(QuantumKVCache, () => { - let fakeInternalEventService: FakeInternalEventService; - let madeCaches: { dispose: () => void }[]; - - function makeCache(opts?: Partial> & { name?: string }): QuantumKVCache { - const _opts = { - name: 'test', - lifetime: Infinity, - fetcher: () => { throw new Error('not implemented'); }, - } satisfies QuantumKVOpts & { name: string }; - - if (opts) { - Object.assign(_opts, opts); - } - - const cache = new QuantumKVCache(fakeInternalEventService, _opts.name, _opts); - madeCaches.push(cache); - return cache; - } - - beforeEach(() => { - madeCaches = []; - fakeInternalEventService = new FakeInternalEventService(); - }); - - afterEach(() => { - madeCaches.forEach(cache => { - cache.dispose(); - }); - }); - - it('should connect on construct', () => { - makeCache(); - - expect(fakeInternalEventService._calls).toContainEqual(['on', ['quantumCacheUpdated', expect.anything(), { ignoreLocal: true }]]); - }); - - it('should disconnect on dispose', () => { - const cache = makeCache(); - - cache.dispose(); - - const callback = fakeInternalEventService._calls - .find(c => c[0] === 'on' && c[1][0] === 'quantumCacheUpdated') - ?.[1][1]; - expect(fakeInternalEventService._calls).toContainEqual(['off', ['quantumCacheUpdated', callback]]); - }); - - it('should store in memory cache', async () => { - const cache = makeCache(); - - await cache.set('foo', 'bar'); - await cache.set('alpha', 'omega'); - - const result1 = await cache.get('foo'); - const result2 = await cache.get('alpha'); - - expect(result1).toBe('bar'); - expect(result2).toBe('omega'); - }); - - it('should emit event when storing', async () => { - const cache = makeCache({ name: 'fake' }); - - await cache.set('foo', 'bar'); - - expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }]]); - }); - - it('should call onSet when storing', async () => { - const fakeOnSet = jest.fn(() => Promise.resolve()); - const cache = makeCache({ - name: 'fake', - onSet: fakeOnSet, - }); - - await cache.set('foo', 'bar'); - - expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); - }); - - it('should not emit event when storing unchanged value', async () => { - const cache = makeCache({ name: 'fake' }); - - await cache.set('foo', 'bar'); - await cache.set('foo', 'bar'); - - expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1); - }); - - it('should not call onSet when storing unchanged value', async () => { - const fakeOnSet = jest.fn(() => Promise.resolve()); - const cache = makeCache({ - name: 'fake', - onSet: fakeOnSet, - }); - - await cache.set('foo', 'bar'); - await cache.set('foo', 'bar'); - - expect(fakeOnSet).toHaveBeenCalledTimes(1); - }); - - it('should fetch an unknown value', async () => { - const cache = makeCache({ - name: 'fake', - fetcher: key => `value#${key}`, - }); - - const result = await cache.fetch('foo'); - - expect(result).toBe('value#foo'); - }); - - it('should store fetched value in memory cache', async () => { - const cache = makeCache({ - name: 'fake', - fetcher: key => `value#${key}`, - }); - - await cache.fetch('foo'); - - const result = cache.has('foo'); - expect(result).toBe(true); - }); - - it('should call onSet when fetching', async () => { - const fakeOnSet = jest.fn(() => Promise.resolve()); - const cache = makeCache({ - name: 'fake', - fetcher: key => `value#${key}`, - onSet: fakeOnSet, - }); - - await cache.fetch('foo'); - - expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); - }); - - it('should not emit event when fetching', async () => { - const cache = makeCache({ - name: 'fake', - fetcher: key => `value#${key}`, - }); - - await cache.fetch('foo'); - - expect(fakeInternalEventService._calls).not.toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }]]); - }); - - it('should delete from memory cache', async () => { - const cache = makeCache(); - - await cache.set('foo', 'bar'); - await cache.delete('foo'); - - const result = cache.has('foo'); - expect(result).toBe(false); - }); - - it('should call onDelete when deleting', async () => { - const fakeOnDelete = jest.fn(() => Promise.resolve()); - const cache = makeCache({ - name: 'fake', - onDelete: fakeOnDelete, - }); - - await cache.set('foo', 'bar'); - await cache.delete('foo'); - - expect(fakeOnDelete).toHaveBeenCalledWith('foo', cache); - }); - - it('should emit event when deleting', async () => { - const cache = makeCache({ name: 'fake' }); - - await cache.set('foo', 'bar'); - await cache.delete('foo'); - - expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 'd', keys: ['foo'] }]]); - }); - - it('should delete when receiving set event', async () => { - const cache = makeCache({ name: 'fake' }); - await cache.set('foo', 'bar'); - - await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }); - - const result = cache.has('foo'); - expect(result).toBe(false); - }); - - it('should call onSet when receiving set event', async () => { - const fakeOnSet = jest.fn(() => Promise.resolve()); - const cache = makeCache({ - name: 'fake', - onSet: fakeOnSet, - }); - - await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }); - - expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); - }); - - it('should delete when receiving delete event', async () => { - const cache = makeCache({ name: 'fake' }); - await cache.set('foo', 'bar'); - - await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 'd', keys: ['foo'] }); - - const result = cache.has('foo'); - expect(result).toBe(false); - }); - - it('should call onDelete when receiving delete event', async () => { - const fakeOnDelete = jest.fn(() => Promise.resolve()); - const cache = makeCache({ - name: 'fake', - onDelete: fakeOnDelete, - }); - await cache.set('foo', 'bar'); - - await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 'd', keys: ['foo'] }); - - expect(fakeOnDelete).toHaveBeenCalledWith('foo', cache); - }); - - describe('get', () => { - it('should return value if present', async () => { - const cache = makeCache(); - await cache.set('foo', 'bar'); - - const result = cache.get('foo'); - - expect(result).toBe('bar'); - }); - it('should return undefined if missing', () => { - const cache = makeCache(); - - const result = cache.get('foo'); - - expect(result).toBe(undefined); - }); - }); - - describe('setMany', () => { - it('should populate all values', async () => { - const cache = makeCache(); - - await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]); - - expect(cache.has('foo')).toBe(true); - expect(cache.has('alpha')).toBe(true); - }); - - it('should emit one event', async () => { - const cache = makeCache({ - name: 'fake', - }); - - await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]); - - expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo', 'alpha'] }]]); - expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1); - }); - - it('should call onSet for each item', async () => { - const fakeOnSet = jest.fn(() => Promise.resolve()); - const cache = makeCache({ - name: 'fake', - onSet: fakeOnSet, - }); - - await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]); - - expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); - expect(fakeOnSet).toHaveBeenCalledWith('alpha', cache); - }); - - it('should emit events only for changed items', async () => { - const fakeOnSet = jest.fn(() => Promise.resolve()); - const cache = makeCache({ - name: 'fake', - onSet: fakeOnSet, - }); - - await cache.set('foo', 'bar'); - fakeOnSet.mockClear(); - fakeInternalEventService._reset(); - - await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]); - - expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['alpha'] }]]); - expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1); - expect(fakeOnSet).toHaveBeenCalledWith('alpha', cache); - expect(fakeOnSet).toHaveBeenCalledTimes(1); - }); - }); - - describe('deleteMany', () => { - it('should remove keys from memory cache', async () => { - const cache = makeCache(); - - await cache.set('foo', 'bar'); - await cache.set('alpha', 'omega'); - await cache.deleteMany(['foo', 'alpha']); - - expect(cache.has('foo')).toBe(false); - expect(cache.has('alpha')).toBe(false); - }); - - it('should emit only one event', async () => { - const cache = makeCache({ - name: 'fake', - }); - - await cache.deleteMany(['foo', 'alpha']); - - expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 'd', keys: ['foo', 'alpha'] }]]); - expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1); - }); - - it('should call onDelete for each key', async () => { - const fakeOnDelete = jest.fn(() => Promise.resolve()); - const cache = makeCache({ - name: 'fake', - onDelete: fakeOnDelete, - }); - - await cache.deleteMany(['foo', 'alpha']); - - expect(fakeOnDelete).toHaveBeenCalledWith('foo', cache); - expect(fakeOnDelete).toHaveBeenCalledWith('alpha', cache); - }); - - it('should do nothing if no keys are provided', async () => { - const fakeOnDelete = jest.fn(() => Promise.resolve()); - const cache = makeCache({ - name: 'fake', - onDelete: fakeOnDelete, - }); - - await cache.deleteMany([]); - - expect(fakeOnDelete).not.toHaveBeenCalled(); - expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0); - }); - }); - - describe('refresh', () => { - it('should populate the value', async () => { - const cache = makeCache({ - name: 'fake', - fetcher: key => `value#${key}`, - }); - - await cache.refresh('foo'); - - const result = cache.has('foo'); - expect(result).toBe(true); - }); - - it('should return the value', async () => { - const cache = makeCache({ - name: 'fake', - fetcher: key => `value#${key}`, - }); - - const result = await cache.refresh('foo'); - - expect(result).toBe('value#foo'); - }); - - it('should replace the value if it exists', async () => { - const cache = makeCache({ - name: 'fake', - fetcher: key => `value#${key}`, - }); - - await cache.set('foo', 'bar'); - const result = await cache.refresh('foo'); - - expect(result).toBe('value#foo'); - }); - - it('should call onSet', async () => { - const fakeOnSet = jest.fn(() => Promise.resolve()); - const cache = makeCache({ - name: 'fake', - fetcher: key => `value#${key}`, - onSet: fakeOnSet, - }); - - await cache.refresh('foo'); - - expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); - }); - - it('should emit event', async () => { - const cache = makeCache({ - name: 'fake', - fetcher: key => `value#${key}`, - }); - - await cache.refresh('foo'); - - expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }]]); - }); - }); - - describe('add', () => { - it('should add the item', () => { - const cache = makeCache(); - cache.add('foo', 'bar'); - expect(cache.has('foo')).toBe(true); - }); - - it('should not emit event', () => { - const cache = makeCache({ - name: 'fake', - }); - - cache.add('foo', 'bar'); - - expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0); - }); - - it('should not call onSet', () => { - const fakeOnSet = jest.fn(() => Promise.resolve()); - const cache = makeCache({ - onSet: fakeOnSet, - }); - - cache.add('foo', 'bar'); - - expect(fakeOnSet).not.toHaveBeenCalled(); - }); - }); - - describe('addMany', () => { - it('should add all items', () => { - const cache = makeCache(); - - cache.addMany([['foo', 'bar'], ['alpha', 'omega']]); - - expect(cache.has('foo')).toBe(true); - expect(cache.has('alpha')).toBe(true); - }); - - - it('should not emit event', () => { - const cache = makeCache({ - name: 'fake', - }); - - cache.addMany([['foo', 'bar'], ['alpha', 'omega']]); - - expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0); - }); - - it('should not call onSet', () => { - const fakeOnSet = jest.fn(() => Promise.resolve()); - const cache = makeCache({ - onSet: fakeOnSet, - }); - - cache.addMany([['foo', 'bar'], ['alpha', 'omega']]); - - expect(fakeOnSet).not.toHaveBeenCalled(); - }); - }); - - describe('has', () => { - it('should return false when empty', () => { - const cache = makeCache(); - const result = cache.has('foo'); - expect(result).toBe(false); - }); - - it('should return false when value is not in memory', async () => { - const cache = makeCache(); - await cache.set('foo', 'bar'); - - const result = cache.has('alpha'); - - expect(result).toBe(false); - }); - - it('should return true when value is in memory', async () => { - const cache = makeCache(); - await cache.set('foo', 'bar'); - - const result = cache.has('foo'); - - expect(result).toBe(true); - }); - }); - - describe('size', () => { - it('should return 0 when empty', () => { - const cache = makeCache(); - expect(cache.size).toBe(0); - }); - - it('should return correct size when populated', async () => { - const cache = makeCache(); - await cache.set('foo', 'bar'); - - expect(cache.size).toBe(1); - }); - }); - - describe('entries', () => { - it('should return empty when empty', () => { - const cache = makeCache(); - - const result = Array.from(cache.entries()); - - expect(result).toHaveLength(0); - }); - - it('should return all entries when populated', async () => { - const cache = makeCache(); - await cache.set('foo', 'bar'); - - const result = Array.from(cache.entries()); - - expect(result).toEqual([['foo', 'bar']]); - }); - }); - - describe('keys', () => { - it('should return empty when empty', () => { - const cache = makeCache(); - - const result = Array.from(cache.keys()); - - expect(result).toHaveLength(0); - }); - - it('should return all keys when populated', async () => { - const cache = makeCache(); - await cache.set('foo', 'bar'); - - const result = Array.from(cache.keys()); - - expect(result).toEqual(['foo']); - }); - }); - - describe('values', () => { - it('should return empty when empty', () => { - const cache = makeCache(); - - const result = Array.from(cache.values()); - - expect(result).toHaveLength(0); - }); - - it('should return all values when populated', async () => { - const cache = makeCache(); - await cache.set('foo', 'bar'); - - const result = Array.from(cache.values()); - - expect(result).toEqual(['bar']); - }); - }); - - describe('[Symbol.iterator]', () => { - it('should return empty when empty', () => { - const cache = makeCache(); - - const result = Array.from(cache); - - expect(result).toHaveLength(0); - }); - - it('should return all entries when populated', async () => { - const cache = makeCache(); - await cache.set('foo', 'bar'); - - const result = Array.from(cache); - - expect(result).toEqual([['foo', 'bar']]); - }); - }); -}); -- cgit v1.2.3-freya