implement QuantumKVCache.setMany and QuantumKVCache.seleteMany

This commit is contained in:
Hazelnoot 2025-06-05 14:28:19 -04:00
parent 46a6612dc0
commit 207abaff88
7 changed files with 201 additions and 51 deletions

View file

@ -17,7 +17,6 @@ import { InternalEventService } from './InternalEventService.js';
@Injectable() @Injectable()
export class ChannelFollowingService implements OnModuleInit { export class ChannelFollowingService implements OnModuleInit {
// TODO check for regs
public userFollowingChannelsCache: QuantumKVCache<Set<string>>; public userFollowingChannelsCache: QuantumKVCache<Set<string>>;
constructor( constructor(

View file

@ -265,7 +265,7 @@ export interface InternalEventTypes {
unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; }; userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; };
userListMemberRemoved: { 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<T> = EventUnionFromDictionary<UndefinedAsNullAll<SerializedAll<T>>>; type EventTypesToEventPayload<T> = EventUnionFromDictionary<UndefinedAsNullAll<SerializedAll<T>>>;

View file

@ -70,16 +70,16 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit {
switch (type) { switch (type) {
case 'userListMemberAdded': { case 'userListMemberAdded': {
const { userListId, memberId } = body; const { userListId, memberId } = body;
if (this.membersCache.has(userListId)) { const members = this.membersCache.get(userListId);
const members = await this.membersCache.get(userListId); if (members) {
members.add(memberId); members.add(memberId);
} }
break; break;
} }
case 'userListMemberRemoved': { case 'userListMemberRemoved': {
const { userListId, memberId } = body; const { userListId, memberId } = body;
if (this.membersCache.has(userListId)) { const members = this.membersCache.get(userListId);
const members = await this.membersCache.get(userListId); if (members) {
members.delete(memberId); members.delete(memberId);
} }
break; break;

View file

@ -43,8 +43,6 @@ export class UserMutingService {
id: In(mutings.map(m => m.id)), id: In(mutings.map(m => m.id)),
}); });
await Promise.all(Array await this.cacheService.userMutingsCache.deleteMany(mutings.map(m => m.muterId));
.from(new Set(mutings.map(m => m.muterId)))
.map(muterId => this.cacheService.userMutingsCache.delete(muterId)));
} }
} }

View file

@ -44,8 +44,6 @@ export class UserRenoteMutingService {
id: In(mutings.map(m => m.id)), id: In(mutings.map(m => m.id)),
}); });
await Promise.all(Array await this.cacheService.renoteMutingsCache.deleteMany(mutings.map(m => m.muterId));
.from(new Set(mutings.map(m => m.muterId)))
.map(muterId => this.cacheService.renoteMutingsCache.delete(muterId)));
} }
} }

View file

@ -531,19 +531,54 @@ export class QuantumKVCache<T> implements Iterable<[key: string, value: T]> {
this.memoryCache.set(key, value); 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) { if (this.onSet) {
await this.onSet(key, this); 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<void> {
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. * Gets or fetches a value from the cache.
* Fires an onSet event, but does not emit an update event to other processes. * Fires an onSet event, but does not emit an update event to other processes.
*/ */
@bindThis @bindThis
public async get(key: string): Promise<T> { public async fetch(key: string): Promise<T> {
let value = this.memoryCache.get(key); let value = this.memoryCache.get(key);
if (value === undefined) { if (value === undefined) {
value = await this.fetcher(key, this); value = await this.fetcher(key, this);
@ -556,15 +591,6 @@ export class QuantumKVCache<T> implements Iterable<[key: string, value: T]> {
return value; return value;
} }
/**
* Alias to get(), included for backwards-compatibility with RedisKVCache.
* @deprecated use get() instead
*/
@bindThis
public async fetch(key: string): Promise<T> {
return await this.get(key);
}
/** /**
* Returns true is a key exists in memory. * Returns true is a key exists in memory.
* This applies to the local subset view, not the cross-cluster cache state. * This applies to the local subset view, not the cross-cluster cache state.
@ -582,12 +608,35 @@ export class QuantumKVCache<T> implements Iterable<[key: string, value: T]> {
public async delete(key: string): Promise<void> { public async delete(key: string): Promise<void> {
this.memoryCache.delete(key); 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) { if (this.onDelete) {
await this.onDelete(key, this); 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<void> {
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. * Refreshes the value of a key from the fetcher, and erases any stale caches across the cluster.
@ -623,14 +672,16 @@ export class QuantumKVCache<T> implements Iterable<[key: string, value: T]> {
@bindThis @bindThis
private async onQuantumCacheUpdated(data: InternalEventTypes['quantumCacheUpdated']): Promise<void> { private async onQuantumCacheUpdated(data: InternalEventTypes['quantumCacheUpdated']): Promise<void> {
if (data.name === this.name) { 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) { if (data.op === 's' && this.onSet) {
await this.onSet(data.key, this); await this.onSet(key, this);
} }
if (data.op === 'd' && this.onDelete) { if (data.op === 'd' && this.onDelete) {
await this.onDelete(data.key, this); await this.onDelete(key, this);
}
} }
} }
} }

View file

@ -73,7 +73,7 @@ describe(QuantumKVCache, () => {
await cache.set('foo', 'bar'); 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 () => { it('should call onSet when storing', async () => {
@ -110,13 +110,13 @@ describe(QuantumKVCache, () => {
expect(fakeOnSet).toHaveBeenCalledTimes(1); expect(fakeOnSet).toHaveBeenCalledTimes(1);
}); });
it('should fetch when getting an unknown value', async () => { it('should fetch an unknown value', async () => {
const cache = makeCache<string>({ const cache = makeCache<string>({
name: 'fake', name: 'fake',
fetcher: key => `value#${key}`, fetcher: key => `value#${key}`,
}); });
const result = await cache.get('foo'); const result = await cache.fetch('foo');
expect(result).toBe('value#foo'); expect(result).toBe('value#foo');
}); });
@ -127,7 +127,7 @@ describe(QuantumKVCache, () => {
fetcher: key => `value#${key}`, fetcher: key => `value#${key}`,
}); });
await cache.get('foo'); await cache.fetch('foo');
const result = cache.has('foo'); const result = cache.has('foo');
expect(result).toBe(true); expect(result).toBe(true);
@ -141,7 +141,7 @@ describe(QuantumKVCache, () => {
onSet: fakeOnSet, onSet: fakeOnSet,
}); });
await cache.get('foo'); await cache.fetch('foo');
expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); expect(fakeOnSet).toHaveBeenCalledWith('foo', cache);
}); });
@ -152,9 +152,9 @@ describe(QuantumKVCache, () => {
fetcher: key => `value#${key}`, 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 () => { it('should delete from memory cache', async () => {
@ -186,14 +186,14 @@ describe(QuantumKVCache, () => {
await cache.set('foo', 'bar'); await cache.set('foo', 'bar');
await cache.delete('foo'); 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 () => { it('should delete when receiving set event', async () => {
const cache = makeCache<string>({ name: 'fake' }); const cache = makeCache<string>({ name: 'fake' });
await cache.set('foo', 'bar'); 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'); const result = cache.has('foo');
expect(result).toBe(false); expect(result).toBe(false);
@ -206,7 +206,7 @@ describe(QuantumKVCache, () => {
onSet: fakeOnSet, 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); expect(fakeOnSet).toHaveBeenCalledWith('foo', cache);
}); });
@ -215,7 +215,7 @@ describe(QuantumKVCache, () => {
const cache = makeCache<string>({ name: 'fake' }); const cache = makeCache<string>({ name: 'fake' });
await cache.set('foo', 'bar'); 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'); const result = cache.has('foo');
expect(result).toBe(false); expect(result).toBe(false);
@ -229,26 +229,130 @@ describe(QuantumKVCache, () => {
}); });
await cache.set('foo', 'bar'); 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); expect(fakeOnDelete).toHaveBeenCalledWith('foo', cache);
}); });
describe('fetch', () => { describe('get', () => {
it('should perform same logic as get', async () => { it('should return value if present', async () => {
const cache = makeCache<string>();
await cache.set('foo', 'bar');
const result = cache.get('foo');
expect(result).toBe('bar');
});
it('should return undefined if missing', () => {
const cache = makeCache<string>();
const result = cache.get('foo');
expect(result).toBe(undefined);
});
});
describe('setMany', () => {
it('should populate all values', async () => {
const cache = makeCache<string>();
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<string>({
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 fakeOnSet = jest.fn(() => Promise.resolve());
const cache = makeCache<string>({ const cache = makeCache<string>({
name: 'fake', name: 'fake',
fetcher: key => `value#${key}`,
onSet: fakeOnSet, onSet: fakeOnSet,
}); });
// noinspection JSDeprecatedSymbols await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]);
const result = await cache.fetch('foo');
expect(result).toBe('value#foo');
expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); 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<string>({
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<string>();
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<string>({
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<string>({
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<string>({
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, onSet: fakeOnSet,
}); });
await cache.refresh('foo') await cache.refresh('foo');
expect(fakeOnSet).toHaveBeenCalledWith('foo', cache); expect(fakeOnSet).toHaveBeenCalledWith('foo', cache);
}); });
@ -309,7 +413,7 @@ describe(QuantumKVCache, () => {
await cache.refresh('foo'); 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'] }]]);
}); });
}); });