Repository navigation
♻️ feat: Share Pull Request Lookups Across Replicas Through Redis #16988
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
014e232
feat: Share Pull Request Lookups Across Replicas Through Redis
berry-13 5fe6e29
fix: Bound the Shared Pull Request Cache and Drop Commands at Their D…
berry-13 e08be11
fix: Pass the Cache Config to the Pull Request Store and Check It Bef…
berry-13 b91c1e8
fix: Keep Batch Lookups Apart by Cache and Sharing Policy
berry-13 9e5951b
fix: Share Only Found Pull Requests and Rate Limits, Never Moving The…
berry-13 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
69 changes: 69 additions & 0 deletions
69
packages/api/src/cache/__tests__/pullRequests.cache_integration.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import type { PullRequestLookupStore } from '~/pulls/types'; | ||
| import { closeRedisClients } from './redisClients.helper'; | ||
|
|
||
| describe('pull request lookup store against Redis', () => { | ||
| const testPrefix = 'PullRequestStore-Integration-Test'; | ||
| let redisClients: Awaited<typeof import('../redisClients')>; | ||
| let store: PullRequestLookupStore; | ||
|
|
||
| beforeAll(async () => { | ||
| process.env.USE_REDIS = process.env.USE_REDIS ?? 'true'; | ||
| process.env.REDIS_URI = process.env.REDIS_URI ?? 'redis://127.0.0.1:6379'; | ||
| process.env.REDIS_KEY_PREFIX = process.env.REDIS_KEY_PREFIX ?? testPrefix; | ||
| jest.resetModules(); | ||
| redisClients = await import('../redisClients'); | ||
| const { cacheConfig } = await import('../cacheConfig'); | ||
| const { createPullRequestLookupStore } = await import('../pullRequests'); | ||
| await redisClients.keyvRedisClientReady; | ||
| const created = createPullRequestLookupStore({ | ||
| client: redisClients.keyvRedisClient, | ||
| config: cacheConfig, | ||
| }); | ||
| if (created == null) throw new Error('Redis store is not available'); | ||
| store = created; | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| const client = redisClients.keyvRedisClient; | ||
| if (client == null || !('scanIterator' in client)) return; | ||
| const { cacheConfig } = await import('../cacheConfig'); | ||
| const match = `${cacheConfig.REDIS_KEY_PREFIX}${cacheConfig.GLOBAL_PREFIX_SEPARATOR}PULL_REQUESTS:*`; | ||
| for await (const page of client.scanIterator({ MATCH: match })) { | ||
| await Promise.all(page.map((key: string) => client.del(key))); | ||
| } | ||
| }); | ||
|
|
||
| afterAll(async () => { | ||
| await closeRedisClients(); | ||
| }); | ||
|
|
||
| it('keeps the later value for one owner whatever order the writes arrive in', async () => { | ||
| await store.put('slot', { key: 'owner', order: 3_600_000 }, 60_000, 1_000); | ||
| await store.put('slot', { key: 'owner', order: 10_000 }, 60_000, 1_000); | ||
| await expect(store.get('slot', 1_000)).resolves.toEqual({ key: 'owner', order: 3_600_000 }); | ||
| await store.put('slot', { key: 'owner', order: 7_200_000 }, 60_000, 1_000); | ||
| await expect(store.get('slot', 1_000)).resolves.toEqual({ key: 'owner', order: 7_200_000 }); | ||
| }); | ||
|
|
||
| it('lets another owner take a slot, as a fixed slot count requires', async () => { | ||
| await store.put('slot', { key: 'first', order: 3_600_000 }, 60_000, 1_000); | ||
| await store.put('slot', { key: 'second', order: 10 }, 60_000, 1_000); | ||
| await expect(store.get('slot', 1_000)).resolves.toEqual({ key: 'second', order: 10 }); | ||
| }); | ||
|
|
||
| it('replaces a value it cannot read', async () => { | ||
| const client = redisClients.keyvRedisClient; | ||
| if (client == null) throw new Error('Redis client is not initialized'); | ||
| const { cacheConfig } = await import('../cacheConfig'); | ||
| const key = `${cacheConfig.REDIS_KEY_PREFIX}${cacheConfig.GLOBAL_PREFIX_SEPARATOR}PULL_REQUESTS:slot`; | ||
| await client.set(key, 'not json'); | ||
| await store.put('slot', { key: 'owner', order: 1 }, 60_000, 1_000); | ||
| await expect(store.get('slot', 1_000)).resolves.toEqual({ key: 'owner', order: 1 }); | ||
| }); | ||
|
|
||
| it('expires a value with its lifetime', async () => { | ||
| await store.put('expiring', { key: 'owner', order: 1 }, 50, 1_000); | ||
| await new Promise((resolve) => setTimeout(resolve, 120)); | ||
| await expect(store.get('expiring', 1_000)).resolves.toBeUndefined(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| import { createServer } from 'node:net'; | ||
| import { CacheKeys } from 'librechat-data-provider'; | ||
| import { createClient, TimeoutError } from '@redis/client'; | ||
| import type { PullRequestStoreClient } from './pullRequests'; | ||
| import type { PullRequestStoreConfig } from './pullRequests'; | ||
| import { createPullRequestLookupStore } from './pullRequests'; | ||
|
|
||
| const base: PullRequestStoreConfig = { | ||
| USE_REDIS: true, | ||
| FORCED_IN_MEMORY_CACHE_NAMESPACES: [], | ||
| REDIS_KEY_PREFIX: '', | ||
| GLOBAL_PREFIX_SEPARATOR: '::', | ||
| }; | ||
|
|
||
| function fakeClient() { | ||
| const values = new Map<string, string>(); | ||
| const commands = { | ||
| get: jest.fn(async (key: string) => values.get(key) ?? null), | ||
| eval: jest.fn(async (_script: string, options: { keys: string[]; arguments: string[] }) => { | ||
| values.set(options.keys[0], options.arguments[0]); | ||
| return 1; | ||
| }), | ||
| }; | ||
| const client = { withCommandOptions: jest.fn(() => commands) }; | ||
| return { client, commands, values }; | ||
| } | ||
|
|
||
| /** A port nothing listens on, so a client there stays disconnected. */ | ||
| async function closedPort(): Promise<number> { | ||
| const server = createServer(); | ||
| await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); | ||
| const address = server.address(); | ||
| await new Promise<void>((resolve) => server.close(() => resolve())); | ||
| if (address == null || typeof address === 'string') throw new Error('no port'); | ||
| return address.port; | ||
| } | ||
|
|
||
| describe('createPullRequestLookupStore', () => { | ||
| it('is absent without Redis, so nothing is shared', () => { | ||
| const config = { ...base, USE_REDIS: false }; | ||
| expect(createPullRequestLookupStore({ client: fakeClient().client, config })).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('is absent without a Redis client', () => { | ||
| expect(createPullRequestLookupStore({ client: null, config: base })).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('is absent when the namespace is forced in memory', () => { | ||
| const config = { ...base, FORCED_IN_MEMORY_CACHE_NAMESPACES: [CacheKeys.PULL_REQUESTS] }; | ||
| expect(createPullRequestLookupStore({ client: fakeClient().client, config })).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('round-trips JSON under the namespace with the lifetime and the deadline', async () => { | ||
| const { client, commands } = fakeClient(); | ||
| const config = { ...base, REDIS_KEY_PREFIX: 'tenant' }; | ||
| const store = createPullRequestLookupStore({ client, config }); | ||
| await store?.put('k', { key: 'owner', order: 2 }, 5_000.4, 40); | ||
| const key = `tenant::${CacheKeys.PULL_REQUESTS}:k`; | ||
| expect(commands.eval).toHaveBeenCalledWith(expect.any(String), { | ||
| keys: [key], | ||
| arguments: ['{"key":"owner","order":2}', '5001', 'owner', '2'], | ||
| }); | ||
| expect(client.withCommandOptions).toHaveBeenLastCalledWith({ timeout: 40 }); | ||
| await expect(store?.get('k', 30)).resolves.toEqual({ key: 'owner', order: 2 }); | ||
| expect(client.withCommandOptions).toHaveBeenLastCalledWith({ timeout: 30 }); | ||
| await expect(store?.get('missing', 30)).resolves.toBeUndefined(); | ||
| }); | ||
|
|
||
| it('rejects a failed command', async () => { | ||
| const { client, commands } = fakeClient(); | ||
| commands.get.mockRejectedValueOnce(new Error('down')); | ||
| const store = createPullRequestLookupStore({ client, config: base }); | ||
| await expect(store?.get('k', 30)).rejects.toThrow('down'); | ||
| }); | ||
|
|
||
| it('drops a command queued while Redis is unreachable at its deadline', async () => { | ||
| const redis = createClient({ | ||
| socket: { host: '127.0.0.1', port: await closedPort(), reconnectStrategy: () => 60_000 }, | ||
| }); | ||
| redis.on('error', () => undefined); | ||
| /** Never settles: the client keeps waiting to reconnect until it is destroyed. */ | ||
| void redis.connect().catch(() => undefined); | ||
| try { | ||
| /** The production client, typed as the store sees it, with no cast. */ | ||
| const client: PullRequestStoreClient = redis; | ||
| const store = createPullRequestLookupStore({ client, config: base }); | ||
| const started = Date.now(); | ||
| await expect(store?.get('k', 50)).rejects.toBeInstanceOf(TimeoutError); | ||
| const value = { key: 'owner', order: 1 }; | ||
| await expect(store?.put('k', value, 1_000, 50)).rejects.toBeInstanceOf(TimeoutError); | ||
| expect(Date.now() - started).toBeLessThan(2_000); | ||
| } finally { | ||
| redis.destroy(); | ||
| } | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| import { CacheKeys } from 'librechat-data-provider'; | ||
| import type { PullRequestLookupStore } from '~/pulls/types'; | ||
| import type { cacheConfig } from './cacheConfig'; | ||
|
|
||
| /** The commands the store sends; the single-node and the cluster client both provide them. */ | ||
| type StoreCommands = { | ||
| get(key: string): Promise<string | null>; | ||
| eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>; | ||
| }; | ||
|
|
||
| /** | ||
| * Writes ARGV[1] for ARGV[2] ms unless the key holds a value for the same owner (ARGV[3]) with an | ||
| * order at least ARGV[4]. One script, so the check and the write cannot interleave with another | ||
| * replica's: a slower writer never replaces a newer answer, a shorter rate limit never a longer. | ||
| */ | ||
| const PUT_SCRIPT = ` | ||
| local held = redis.call('GET', KEYS[1]) | ||
| if held then | ||
| local ok, value = pcall(cjson.decode, held) | ||
| if ok and type(value) == 'table' and value.key == ARGV[3] | ||
| and tonumber(value.order) and tonumber(value.order) >= tonumber(ARGV[4]) then | ||
| return 0 | ||
| end | ||
| end | ||
| redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2]) | ||
| return 1 | ||
| `; | ||
|
|
||
| export type PullRequestStoreClient = { | ||
| withCommandOptions(options: { timeout: number }): StoreCommands; | ||
| }; | ||
|
|
||
| /** The deployment's cache settings the store follows: whether Redis is on, and its key prefix. */ | ||
| export type PullRequestStoreConfig = Pick< | ||
| typeof cacheConfig, | ||
| 'USE_REDIS' | 'FORCED_IN_MEMORY_CACHE_NAMESPACES' | 'REDIS_KEY_PREFIX' | 'GLOBAL_PREFIX_SEPARATOR' | ||
| >; | ||
|
|
||
| /** | ||
| * The store pull request lookups share between replicas, or `undefined` when there is nothing to | ||
| * share it through: without Redis, or with the namespace forced in memory, each replica's own | ||
| * cache already is the whole cache. | ||
| * | ||
| * Every command carries the caller's timeout as its deadline. A command still unsent when it | ||
| * passes, such as one held in the offline queue while Redis is disconnected, is taken out of the | ||
| * queue and rejected, so a Redis outage does not pile up commands to replay on reconnect. | ||
| */ | ||
| export function createPullRequestLookupStore({ | ||
| client, | ||
| config, | ||
| }: { | ||
| client: PullRequestStoreClient | null; | ||
| config: PullRequestStoreConfig; | ||
| }): PullRequestLookupStore | undefined { | ||
| if ( | ||
| client == null || | ||
| !config.USE_REDIS || | ||
| config.FORCED_IN_MEMORY_CACHE_NAMESPACES?.includes(CacheKeys.PULL_REQUESTS) | ||
| ) { | ||
| return undefined; | ||
| } | ||
| const prefix = config.REDIS_KEY_PREFIX | ||
| ? `${config.REDIS_KEY_PREFIX}${config.GLOBAL_PREFIX_SEPARATOR}${CacheKeys.PULL_REQUESTS}:` | ||
| : `${CacheKeys.PULL_REQUESTS}:`; | ||
| return { | ||
| get: async (key, timeoutMs) => { | ||
| const raw = await client.withCommandOptions({ timeout: timeoutMs }).get(prefix + key); | ||
| return raw == null ? undefined : JSON.parse(raw); | ||
| }, | ||
| put: async (key, value, ttlMs, timeoutMs) => { | ||
| await client.withCommandOptions({ timeout: timeoutMs }).eval(PUT_SCRIPT, { | ||
| keys: [prefix + key], | ||
| arguments: [ | ||
| JSON.stringify(value), | ||
| String(Math.max(1, Math.ceil(ttlMs))), | ||
| value.key, | ||
| String(value.order), | ||
| ], | ||
| }); | ||
| }, | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When concurrent batch requests use the same token and lane but resolve different
sharedCacheorsharedCacheTimeoutMssettings,createConversationPullRequestsHandlergives them the sameshareKeybecause that key omits both newly added fields. The second request therefore joins the first request's promise beforecreatePullRequestLookupcan apply its policy-awareflightkey, so the first principal's toggle and timeout control both requests—for example, an enabled caller can skip Redis by joining a disabled caller. Fresh evidence after the inner lookup fix is the earlier coalescing layer inhandler.ts, whoseshareKeyincludes the answer policy but not these two fields.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in be5b06e: the batch share key now includes cacheMaxEntries, cacheMaxCredentials, sharedCache and sharedCacheTimeoutMs; covered by handler.spec 'does not join a lookup started with another %s' (sharing setting, shared cache deadline, cache capacity). The commits cited above for this PR are now 3dcfc32 and 1905b1c after a restack.