-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Added AutoCompleteLruCacheInMem #3564
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
Closed
beatlevic
wants to merge
5
commits into
beatlevic/ghost-context-provider
from
beatlevic/autocomplete-lru-cache-in-mem
Closed
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
e31d0c2
Added AutoCompleteLruCacheInMem
beatlevic 3a19095
Merge branch 'beatlevic/ghost-context-provider' into beatlevic/autoco…
beatlevic d602382
Added some fuzzy matching to AutoCompleteLruCacheInMem
beatlevic 614b69a
Small changes to AutoCompleteLruCacheInMem to be in line with AutoCom…
beatlevic 420b416
Use only instance-level private autocompleteCache
beatlevic 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
247 changes: 247 additions & 0 deletions
247
src/services/continuedev/core/autocomplete/util/AutoCompleteLruCacheInMem.test.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,247 @@ | ||
| import { describe, it, expect, beforeEach } from "vitest" | ||
| import { AutoCompleteLruCacheInMem } from "./AutoCompleteLruCacheInMem" | ||
|
|
||
| describe("AutoCompleteLruCacheInMem", () => { | ||
| let cache: AutoCompleteLruCacheInMem | ||
|
|
||
| beforeEach(async () => { | ||
| cache = await AutoCompleteLruCacheInMem.get() | ||
| }) | ||
|
|
||
| describe("basic operations", () => { | ||
| it("should store and retrieve a value", async () => { | ||
| await cache.put("hello", "world") | ||
| const result = await cache.get("hello") | ||
| expect(result).toBe("world") | ||
| }) | ||
|
|
||
| it("should return undefined for non-existent key", async () => { | ||
| const result = await cache.get("nonexistent") | ||
| expect(result).toBeUndefined() | ||
| }) | ||
|
|
||
| it("should update existing value", async () => { | ||
| await cache.put("key", "value1") | ||
| await cache.put("key", "value2") | ||
| const result = await cache.get("key") | ||
| expect(result).toBe("value2") | ||
| }) | ||
| }) | ||
|
|
||
| describe("exact key matching", () => { | ||
| it("should match exact key and return value", async () => { | ||
| await cache.put("hello", "world") | ||
| const result = await cache.get("hello") | ||
| expect(result).toBe("world") | ||
| }) | ||
|
|
||
| it("should return undefined when key doesn't match exactly", async () => { | ||
| await cache.put("hello", "world") | ||
| const result = await cache.get("goodbye") | ||
| expect(result).toBeUndefined() | ||
| }) | ||
|
|
||
| it("should return undefined for partial key match", async () => { | ||
| await cache.put("hello", "world") | ||
| const result = await cache.get("hel") | ||
| expect(result).toBeUndefined() | ||
| }) | ||
|
|
||
| it("should be case sensitive", async () => { | ||
| await cache.put("Hello", "World") | ||
| const result1 = await cache.get("Hello") | ||
| const result2 = await cache.get("hello") | ||
| expect(result1).toBe("World") | ||
| expect(result2).toBeUndefined() | ||
| }) | ||
| }) | ||
|
|
||
| describe("fuzzy matching", () => { | ||
| it("should return completion when prefix extends a cached key", async () => { | ||
| // Cache "c" -> "ontinue" | ||
| await cache.put("c", "ontinue") | ||
| // Query "co" should return "ntinue" (completion minus what we already have) | ||
| const result = await cache.get("co") | ||
| expect(result).toBe("ntinue") | ||
| }) | ||
|
|
||
| it("should prefer longest matching key", async () => { | ||
| // Cache multiple overlapping keys | ||
| await cache.put("h", "ello world") | ||
| await cache.put("he", "llo world") | ||
| await cache.put("hel", "lo world") | ||
|
|
||
| // Query "hello" should match "hel" (longest key) | ||
| // User typed "hello" = "hel" + "lo", cached completion is "lo world" | ||
| // So return " world" (the part not yet typed) | ||
| const result = await cache.get("hello") | ||
| expect(result).toBe(" world") | ||
| }) | ||
|
|
||
| it("should validate cached completion starts correctly", async () => { | ||
| // Cache "c" -> "ontinue" | ||
| await cache.put("c", "ontinue") | ||
| // Query "cx" doesn't match the completion pattern, should return undefined | ||
| const result = await cache.get("cx") | ||
| expect(result).toBeUndefined() | ||
| }) | ||
|
|
||
| it("should return exact match if available", async () => { | ||
| // Cache both exact and partial keys | ||
| await cache.put("co", "mplete") | ||
| await cache.put("c", "ontinue") | ||
|
|
||
| // Exact match should be preferred | ||
| const result = await cache.get("co") | ||
| expect(result).toBe("mplete") | ||
| }) | ||
|
|
||
| it("should handle multiple partial matches correctly", async () => { | ||
| // Cache overlapping prefixes | ||
| await cache.put("fun", "ction") | ||
| await cache.put("f", "unction") | ||
|
|
||
| // Query "func" should match "fun" (longest) and return "ction" | ||
| const result = await cache.get("func") | ||
| expect(result).toBe("tion") | ||
| }) | ||
|
|
||
| it("should return undefined when no fuzzy match exists", async () => { | ||
| await cache.put("hello", "world") | ||
| // "goodbye" doesn't start with "hello" | ||
| const result = await cache.get("goodbye") | ||
| expect(result).toBeUndefined() | ||
| }) | ||
|
|
||
| it("should handle empty cache for fuzzy matching", async () => { | ||
| const result = await cache.get("anyprefix") | ||
| expect(result).toBeUndefined() | ||
| }) | ||
| }) | ||
|
|
||
| describe("LRU eviction", () => { | ||
| it("should evict oldest entry when capacity is reached", async () => { | ||
| // Create a fresh cache for this test | ||
| const testCache = await AutoCompleteLruCacheInMem.get() | ||
|
|
||
| // Fill cache to capacity (1000 entries) | ||
| for (let i = 0; i < 1000; i++) { | ||
| await testCache.put(`key${i}`, `value${i}`) | ||
| } | ||
|
|
||
| // Add one more entry to trigger eviction | ||
| await testCache.put("newkey", "newvalue") | ||
|
|
||
| // First entry should be evicted (oldest timestamp) | ||
| const result = await testCache.get("key0") | ||
| expect(result).toBeUndefined() | ||
|
|
||
| // New entry should exist | ||
| const newResult = await testCache.get("newkey") | ||
| expect(newResult).toBe("newvalue") | ||
| }) | ||
|
|
||
| it("should update timestamp on cache hit", async () => { | ||
| // Create a fresh cache for this test | ||
| const testCache = await AutoCompleteLruCacheInMem.get() | ||
|
|
||
| // Fill to capacity | ||
| for (let i = 0; i < 1000; i++) { | ||
| await testCache.put(`key${i}`, `value${i}`) | ||
| } | ||
|
|
||
| // Access an early entry to refresh its timestamp | ||
| const refreshedValue = await testCache.get("key5") | ||
| expect(refreshedValue).toBe("value5") | ||
|
|
||
| // Add new entries to trigger evictions | ||
| await testCache.put("new1", "newvalue1") | ||
| await testCache.put("new2", "newvalue2") | ||
|
|
||
| // key5 should still exist (refreshed timestamp) | ||
| const key5Result = await testCache.get("key5") | ||
| expect(key5Result).toBe("value5") | ||
|
|
||
| // key0 should be evicted (oldest timestamp, never accessed) | ||
| const key0Result = await testCache.get("key0") | ||
| expect(key0Result).toBeUndefined() | ||
| }) | ||
| }) | ||
|
|
||
| describe("edge cases", () => { | ||
| it("should handle empty strings", async () => { | ||
| const testCache = await AutoCompleteLruCacheInMem.get() | ||
| await testCache.put("", "empty") | ||
| const result = await testCache.get("") | ||
| expect(result).toBe("empty") | ||
| }) | ||
|
|
||
| it("should handle very long strings", async () => { | ||
| const testCache = await AutoCompleteLruCacheInMem.get() | ||
| const longString = "a".repeat(10000) | ||
| await testCache.put(longString, "completion") | ||
| const result = await testCache.get(longString) | ||
| expect(result).toBe("completion") | ||
| }) | ||
|
|
||
| it("should handle special characters", async () => { | ||
| await cache.put("const x = {", "foo: 'bar'}") | ||
| const result = await cache.get("const x = {") | ||
| expect(result).toBe("foo: 'bar'}") | ||
| }) | ||
|
|
||
| it("should handle unicode characters", async () => { | ||
| await cache.put("emoji 🚀", "rocket") | ||
| const result = await cache.get("emoji 🚀") | ||
| expect(result).toBe("rocket") | ||
| }) | ||
| }) | ||
|
|
||
| describe("concurrent operations", () => { | ||
| it("should handle concurrent put operations", async () => { | ||
| const promises = [] | ||
| for (let i = 0; i < 10; i++) { | ||
| promises.push(cache.put(`concurrent${i}`, `value${i}`)) | ||
| } | ||
| await Promise.all(promises) | ||
|
|
||
| // All values should be stored | ||
| for (let i = 0; i < 10; i++) { | ||
| const result = await cache.get(`concurrent${i}`) | ||
| expect(result).toBe(`value${i}`) | ||
| } | ||
| }) | ||
|
|
||
| it("should handle concurrent get operations", async () => { | ||
| await cache.put("shared", "value") | ||
|
|
||
| const promises = [] | ||
| for (let i = 0; i < 10; i++) { | ||
| promises.push(cache.get("shared")) | ||
| } | ||
| const results = await Promise.all(promises) | ||
|
|
||
| // All gets should return the same value | ||
| results.forEach((result) => { | ||
| expect(result).toBe("value") | ||
| }) | ||
| }) | ||
| }) | ||
|
|
||
| describe("multiple cache instances", () => { | ||
| it("should create separate cache instances", async () => { | ||
| const cache1 = await AutoCompleteLruCacheInMem.get() | ||
| const cache2 = await AutoCompleteLruCacheInMem.get() | ||
|
|
||
| await cache1.put("test", "value1") | ||
| await cache2.put("test", "value2") | ||
|
|
||
| const result1 = await cache1.get("test") | ||
| const result2 = await cache2.get("test") | ||
|
|
||
| // Each instance should have its own data | ||
| expect(result1).toBe("value1") | ||
| expect(result2).toBe("value2") | ||
| }) | ||
| }) | ||
| }) |
77 changes: 77 additions & 0 deletions
77
src/services/continuedev/core/autocomplete/util/AutoCompleteLruCacheInMem.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,77 @@ | ||
| import { LRUCache } from "lru-cache" | ||
|
|
||
| const MAX_PREFIX_LENGTH = 50000 | ||
|
|
||
| function truncatePrefix(input: string, safety: number = 100): string { | ||
| const maxBytes = MAX_PREFIX_LENGTH - safety | ||
| let bytes = 0 | ||
| let startIndex = 0 | ||
|
|
||
| // Count bytes from the end, keeping the most recent typing | ||
| for (let i = input.length - 1; i >= 0; i--) { | ||
beatlevic marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| bytes += new TextEncoder().encode(input[i]).length | ||
| if (bytes > maxBytes) { | ||
| startIndex = i + 1 | ||
| break | ||
| } | ||
| } | ||
|
|
||
| return input.substring(startIndex) | ||
| } | ||
|
|
||
| export class AutoCompleteLruCacheInMem { | ||
| private static capacity = 1000 | ||
| private cache: LRUCache<string, string> | ||
|
|
||
| private constructor() { | ||
| this.cache = new LRUCache<string, string>({ | ||
| max: AutoCompleteLruCacheInMem.capacity, | ||
| }) | ||
| } | ||
|
|
||
| static async get(): Promise<AutoCompleteLruCacheInMem> { | ||
| return new AutoCompleteLruCacheInMem() | ||
beatlevic marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| async get(prefix: string): Promise<string | undefined> { | ||
| const truncated = truncatePrefix(prefix) | ||
|
|
||
| // First try exact match (faster) | ||
| const exactMatch = this.cache.get(truncated) | ||
| if (exactMatch !== undefined) { | ||
| return exactMatch | ||
| } | ||
|
|
||
| // Then try fuzzy matching - find keys where prefix starts with the key | ||
| // If the query is "co" and we have "c" -> "ontinue" in the cache, | ||
| // we should return "ntinue" as the completion. | ||
| // Have to make sure we take the key with longest length for best match | ||
| let bestMatch: { key: string; value: string } | null = null | ||
| let longestKeyLength = 0 | ||
|
|
||
| for (const [key, value] of this.cache.entries()) { | ||
| // Check if truncated prefix starts with this key | ||
| if (truncated.startsWith(key) && key.length > longestKeyLength) { | ||
| bestMatch = { key, value } | ||
| longestKeyLength = key.length | ||
| } | ||
| } | ||
|
|
||
| if (bestMatch) { | ||
| // Validate that the cached completion is a valid completion for the prefix | ||
| if (bestMatch.value.startsWith(truncated.slice(bestMatch.key.length))) { | ||
| // Update LRU timestamp for the matched key by accessing it | ||
| this.cache.get(bestMatch.key) | ||
| // Return the portion of the value that extends beyond the current prefix | ||
| return bestMatch.value.slice(truncated.length - bestMatch.key.length) | ||
| } | ||
| } | ||
|
|
||
| return undefined | ||
| } | ||
|
|
||
| async put(prefix: string, completion: string) { | ||
| const truncated = truncatePrefix(prefix) | ||
| this.cache.set(truncated, completion) | ||
| } | ||
| } | ||
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.
Uh oh!
There was an error while loading. Please reload this page.