mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +09:00
fix(kv): bound multi key fan out by pipelining per hash slot (#2313)
This commit is contained in:
1 parent
3b5b20c139
commit
cf3af50464
14 files changed
+506
-86
No files matched your search
@@ -2,27 +2,9 @@
|
||||
|
||||
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
|
||||
import type {IKVPipeline, IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {computeHashSlot} from '@pkgs/kv_client/src/KVHashSlots';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function hashSlot(key: string): number {
|
||||
let hashed = key;
|
||||
const start = key.indexOf('{');
|
||||
if (start !== -1) {
|
||||
const end = key.indexOf('}', start + 1);
|
||||
if (end > start + 1) {
|
||||
hashed = key.slice(start + 1, end);
|
||||
}
|
||||
}
|
||||
let crc = 0;
|
||||
for (let index = 0; index < hashed.length; index += 1) {
|
||||
crc ^= (hashed.charCodeAt(index) & 0xff) << 8;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
crc = (crc & 0x8000) === 0 ? (crc << 1) & 0xffff : ((crc << 1) ^ 0x1021) & 0xffff;
|
||||
}
|
||||
}
|
||||
return crc % 16384;
|
||||
}
|
||||
|
||||
function createRecordingProvider(): {
|
||||
client: IKVProvider;
|
||||
commands: Array<Array<string>>;
|
||||
@@ -36,6 +18,7 @@ function createRecordingProvider(): {
|
||||
setex: async (key: string) => {
|
||||
commands.push([key]);
|
||||
},
|
||||
isClustered: () => true,
|
||||
pipeline: () => {
|
||||
const keys: Array<string> = [];
|
||||
commands.push(keys);
|
||||
@@ -61,7 +44,7 @@ describe('KVCacheProvider cluster hash slots', () => {
|
||||
const {client, commands} = createRecordingProvider();
|
||||
const provider = new KVCacheProvider({client});
|
||||
|
||||
expect(hashSlot('cache:alpha')).not.toBe(hashSlot('cache:beta'));
|
||||
expect(computeHashSlot('cache:alpha')).not.toBe(computeHashSlot('cache:beta'));
|
||||
|
||||
await provider.mset([
|
||||
{key: 'cache:alpha', value: 1, ttlSeconds: 60},
|
||||
@@ -69,6 +52,6 @@ describe('KVCacheProvider cluster hash slots', () => {
|
||||
]);
|
||||
|
||||
expect(commands.flat().sort()).toEqual(['cache:alpha', 'cache:beta']);
|
||||
expect(commands.filter((keys) => new Set(keys.map(hashSlot)).size > 1)).toEqual([]);
|
||||
expect(commands.filter((keys) => new Set(keys.map(computeHashSlot)).size > 1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
|
||||
import type {IKVPipeline, IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {computeHashSlot} from '@pkgs/kv_client/src/KVHashSlots';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const MAX_CONCURRENT_ROUND_TRIPS = 16;
|
||||
|
||||
interface RecordingProvider {
|
||||
client: IKVProvider;
|
||||
batches: Array<Array<string>>;
|
||||
peakInFlight: number;
|
||||
}
|
||||
|
||||
function createRecordingProvider(clustered: boolean): RecordingProvider {
|
||||
const recorder: RecordingProvider = {
|
||||
client: {} as IKVProvider,
|
||||
batches: [],
|
||||
peakInFlight: 0,
|
||||
};
|
||||
let inFlight = 0;
|
||||
const trackRoundTrip = async (keys: Array<string>): Promise<void> => {
|
||||
recorder.batches.push(keys);
|
||||
inFlight += 1;
|
||||
recorder.peakInFlight = Math.max(recorder.peakInFlight, inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
inFlight -= 1;
|
||||
};
|
||||
recorder.client = {
|
||||
isClustered: () => clustered,
|
||||
set: async (key: string) => {
|
||||
await trackRoundTrip([key]);
|
||||
return 'OK';
|
||||
},
|
||||
setex: async (key: string) => {
|
||||
await trackRoundTrip([key]);
|
||||
},
|
||||
pipeline: () => {
|
||||
const keys: Array<string> = [];
|
||||
const batch = {
|
||||
set: (key: string) => {
|
||||
keys.push(key);
|
||||
return batch;
|
||||
},
|
||||
setex: (key: string) => {
|
||||
keys.push(key);
|
||||
return batch;
|
||||
},
|
||||
exec: async () => {
|
||||
await trackRoundTrip(keys);
|
||||
return [];
|
||||
},
|
||||
} as unknown as IKVPipeline;
|
||||
return batch;
|
||||
},
|
||||
} as unknown as IKVProvider;
|
||||
return recorder;
|
||||
}
|
||||
|
||||
function createEntries(count: number): Array<{key: string; value: number; ttlSeconds: number}> {
|
||||
return Array.from({length: count}, (_unused, index) => ({
|
||||
key: `cache:entry:${index}`,
|
||||
value: index,
|
||||
ttlSeconds: 60,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('KVCacheProvider multi entry write fan out', () => {
|
||||
it('writes every entry in one round trip outside cluster mode', async () => {
|
||||
const recorder = createRecordingProvider(false);
|
||||
const provider = new KVCacheProvider({client: recorder.client});
|
||||
|
||||
await provider.mset(createEntries(1000));
|
||||
|
||||
expect(recorder.batches.map((keys) => keys.length)).toEqual([1000]);
|
||||
expect(recorder.peakInFlight).toBe(1);
|
||||
});
|
||||
|
||||
it('surfaces a failed command inside a batched write', async () => {
|
||||
const client = {
|
||||
isClustered: () => false,
|
||||
pipeline: () => {
|
||||
const batch = {
|
||||
set: () => batch,
|
||||
setex: () => batch,
|
||||
exec: async () => [[new Error('write rejected'), null]],
|
||||
} as unknown as IKVPipeline;
|
||||
return batch;
|
||||
},
|
||||
} as unknown as IKVProvider;
|
||||
const provider = new KVCacheProvider({client});
|
||||
|
||||
await expect(provider.mset(createEntries(2))).rejects.toThrow('write rejected');
|
||||
});
|
||||
|
||||
it('bounds concurrent round trips when entries span hash slots', async () => {
|
||||
const recorder = createRecordingProvider(true);
|
||||
const provider = new KVCacheProvider({client: recorder.client});
|
||||
|
||||
await provider.mset(createEntries(1000));
|
||||
|
||||
expect(recorder.peakInFlight).toBeLessThanOrEqual(MAX_CONCURRENT_ROUND_TRIPS);
|
||||
expect(recorder.batches.filter((keys) => new Set(keys.map(computeHashSlot)).size > 1)).toEqual([]);
|
||||
expect(recorder.batches.flat().length).toBe(1000);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user