feat(pagination): implement cursor-based pagination and enhance search caching

- Added SearchCursor and PaginatedSearchResult interfaces for cursor-based pagination support.
- Introduced SearchCache class to cache search results, improving performance.
- Implemented tests for automatic cache configuration and performance improvements.
- Enhanced existing tests to validate pagination and caching behavior.
This commit is contained in:
David Snelling 2025-08-04 14:25:05 -07:00
parent 91cf1785d9
commit 33afd715a0
8 changed files with 1916 additions and 11 deletions

View file

@ -18,6 +18,8 @@ import {
HNSWConfig,
HNSWNoun,
SearchResult,
SearchCursor,
PaginatedSearchResult,
StorageAdapter,
Vector,
VectorDocument
@ -55,6 +57,8 @@ import {
DomainDetector,
HealthMonitor
} from './distributed/index.js'
import { SearchCache, SearchCacheConfig } from './utils/searchCache.js'
import { CacheAutoConfigurator } from './utils/cacheAutoConfig.js'
export interface BrainyDataConfig {
/**
@ -183,6 +187,12 @@ export interface BrainyDataConfig {
verbose?: boolean
}
/**
* Search result caching configuration
* Improves performance for repeated queries
*/
searchCache?: SearchCacheConfig
/**
* Timeout configuration for async operations
* Controls how long operations wait before timing out
@ -367,6 +377,8 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
private _dimensions: number
private loggingConfig: BrainyDataConfig['logging'] = { verbose: true }
private defaultService: string = 'default'
private searchCache: SearchCache<T>
private cacheAutoConfigurator: CacheAutoConfigurator
// Timeout and retry configuration
private timeoutConfig: BrainyDataConfig['timeouts'] = {}
@ -548,6 +560,33 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
this.distributedConfig = config.distributed
}
}
// Initialize cache auto-configurator first
this.cacheAutoConfigurator = new CacheAutoConfigurator()
// Auto-detect optimal cache configuration if not explicitly provided
let finalSearchCacheConfig = config.searchCache
if (!config.searchCache || Object.keys(config.searchCache).length === 0) {
const autoConfig = this.cacheAutoConfigurator.autoDetectOptimalConfig(
config.storage
)
finalSearchCacheConfig = autoConfig.cacheConfig
// Apply auto-detected real-time update configuration if not explicitly set
if (!config.realtimeUpdates && autoConfig.realtimeConfig.enabled) {
this.realtimeUpdateConfig = {
...this.realtimeUpdateConfig,
...autoConfig.realtimeConfig
}
}
if (this.loggingConfig?.verbose) {
console.log(this.cacheAutoConfigurator.getConfigExplanation(autoConfig))
}
}
// Initialize search cache with final configuration
this.searchCache = new SearchCache<T>(finalSearchCacheConfig)
}
/**
@ -721,6 +760,19 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
}
}
// Cleanup expired cache entries (defensive mechanism for distributed scenarios)
const expiredCount = this.searchCache.cleanupExpiredEntries()
if (expiredCount > 0 && this.loggingConfig?.verbose) {
console.log(`Cleaned up ${expiredCount} expired cache entries`)
}
// Adapt cache configuration based on performance (every few updates)
// Only adapt every 5th update to avoid over-optimization
const updateCount = Math.floor((Date.now() - (this.lastUpdateTime || 0)) / this.realtimeUpdateConfig.interval)
if (updateCount % 5 === 0) {
this.adaptCacheConfiguration()
}
// Update the last update time
this.lastUpdateTime = Date.now()
@ -821,6 +873,14 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
)
}
// Invalidate search cache if any external changes were detected
if (addedCount > 0 || updatedCount > 0 || deletedCount > 0) {
this.searchCache.invalidateOnDataChange('update')
if (this.loggingConfig?.verbose) {
console.log('Search cache invalidated due to external data changes')
}
}
// Update the last known noun count
this.lastKnownNounCount = await this.getNounCount()
} catch (error) {
@ -879,6 +939,14 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
// Update the last known noun count
this.lastKnownNounCount = currentCount
// Invalidate search cache if new nouns were detected
if (newNouns.length > 0) {
this.searchCache.invalidateOnDataChange('add')
if (this.loggingConfig?.verbose) {
console.log('Search cache invalidated due to external data changes')
}
}
if (this.loggingConfig?.verbose && newNouns.length > 0) {
console.log(
`Real-time update: Added ${newNouns.length} new nouns to index using full scan`
@ -1543,6 +1611,9 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
}
}
// Invalidate search cache since data has changed
this.searchCache.invalidateOnDataChange('add')
return id
} catch (error) {
console.error('Failed to add vector:', error)
@ -1832,6 +1903,7 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
options: {
forceEmbed?: boolean // Force using the embedding function even if input is a vector
service?: string // Filter results by the service that created the data
offset?: number // Number of results to skip for pagination (default: 0)
} = {}
): Promise<SearchResult<T>[]> {
// Helper function to filter results by service
@ -1930,13 +2002,20 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
}
}
// Search in the index
const results = await this.index.search(queryVector, k)
// When using offset, we need to fetch more results and then slice
const offset = options.offset || 0
const totalNeeded = k + offset
// Search in the index for totalNeeded results
const results = await this.index.search(queryVector, totalNeeded)
// Skip the offset number of results
const paginatedResults = results.slice(offset, offset + k)
// Get metadata for each result
const searchResults: SearchResult<T>[] = []
for (const [id, score] of results) {
for (const [id, score] of paginatedResults) {
const noun = this.index.getNouns().get(id)
if (!noun) {
continue
@ -1990,8 +2069,9 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
// Sort by distance (ascending)
results.sort((a, b) => a[1] - b[1])
// Take top k results
const topResults = results.slice(0, k)
// Apply offset and take k results
const offset = options.offset || 0
const topResults = results.slice(offset, offset + k)
// Get metadata for each result
const searchResults: SearchResult<T>[] = []
@ -2053,6 +2133,8 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
service?: string // Filter results by the service that created the data
searchField?: string // Optional specific field to search within JSON documents
filter?: { domain?: string } // Filter results by domain
offset?: number // Number of results to skip for pagination (default: 0)
skipCache?: boolean // Skip cache for this search (default: false)
} = {}
): Promise<SearchResult<T>[]> {
const startTime = Date.now()
@ -2115,13 +2197,33 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
// Default behavior (backward compatible): search locally
try {
// Check cache first (transparent to user)
const cacheKey = this.searchCache.getCacheKey(queryVectorOrData, k, options)
const cachedResults = this.searchCache.get(cacheKey)
if (cachedResults) {
// Track cache hit in health monitor
if (this.healthMonitor) {
const latency = Date.now() - startTime
this.healthMonitor.recordRequest(latency, false)
this.healthMonitor.recordCacheAccess(true)
}
return cachedResults
}
// Cache miss - perform actual search
const results = await this.searchLocal(queryVectorOrData, k, options)
// Cache results for future queries (unless explicitly disabled)
if (!options.skipCache) {
this.searchCache.set(cacheKey, results)
}
// Track successful search in health monitor
if (this.healthMonitor) {
const latency = Date.now() - startTime
this.healthMonitor.recordRequest(latency, false)
this.healthMonitor.recordCacheAccess(results.length > 0)
this.healthMonitor.recordCacheAccess(false)
}
return results
@ -2135,6 +2237,79 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
}
}
/**
* Search with cursor-based pagination for better performance on large datasets
* @param queryVectorOrData Query vector or data to search for
* @param k Number of results to return
* @param options Additional options including cursor for pagination
* @returns Paginated search results with cursor for next page
*/
public async searchWithCursor(
queryVectorOrData: Vector | any,
k: number = 10,
options: {
forceEmbed?: boolean
nounTypes?: string[]
includeVerbs?: boolean
service?: string
searchField?: string
filter?: { domain?: string }
cursor?: SearchCursor // For continuing from previous search
skipCache?: boolean
} = {}
): Promise<PaginatedSearchResult<T>> {
// For cursor-based search, we need to fetch more results and filter
const searchK = options.cursor ? k + 20 : k // Get extra results for filtering
// Perform regular search
const allResults = await this.search(queryVectorOrData, searchK, {
...options,
skipCache: options.skipCache
})
let results = allResults
let startIndex = 0
// If cursor provided, find starting position
if (options.cursor) {
startIndex = allResults.findIndex(r =>
r.id === options.cursor!.lastId &&
Math.abs(r.score - options.cursor!.lastScore) < 0.0001
)
if (startIndex >= 0) {
startIndex += 1 // Start after the cursor position
results = allResults.slice(startIndex, startIndex + k)
} else {
// Cursor not found, might be stale - return from beginning
results = allResults.slice(0, k)
startIndex = 0
}
} else {
results = allResults.slice(0, k)
}
// Create cursor for next page
let nextCursor: SearchCursor | undefined
const hasMoreResults = (startIndex + results.length) < allResults.length || allResults.length >= searchK
if (results.length > 0 && hasMoreResults) {
const lastResult = results[results.length - 1]
nextCursor = {
lastId: lastResult.id,
lastScore: lastResult.score,
position: startIndex + results.length
}
}
return {
results,
cursor: nextCursor,
hasMore: !!nextCursor,
totalEstimate: allResults.length > searchK ? undefined : allResults.length
}
}
/**
* Search the local database for similar vectors
* @param queryVectorOrData Query vector or data to search for
@ -2153,6 +2328,8 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
searchField?: string // Optional specific field to search within JSON documents
priorityFields?: string[] // Fields to prioritize when searching JSON documents
filter?: { domain?: string } // Filter results by domain
offset?: number // Number of results to skip for pagination (default: 0)
skipCache?: boolean // Skip cache for this search (default: false)
} = {}
): Promise<SearchResult<T>[]> {
if (!this.isInitialized) {
@ -2216,14 +2393,16 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
options.nounTypes,
{
forceEmbed: options.forceEmbed,
service: options.service
service: options.service,
offset: options.offset
}
)
} else {
// Otherwise, search all GraphNouns
searchResults = await this.searchByNounTypes(queryToUse, k, null, {
forceEmbed: options.forceEmbed,
service: options.service
service: options.service,
offset: options.offset
})
}
@ -2637,6 +2816,9 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
// Ignore
}
// Invalidate search cache since data has changed
this.searchCache.invalidateOnDataChange('delete')
return true
} catch (error) {
console.error(`Failed to delete vector ${id}:`, error)
@ -2746,6 +2928,9 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
const service = this.getServiceName(options)
await this.storage!.incrementStatistic('metadata', service)
// Invalidate search cache since metadata has changed
this.searchCache.invalidateOnDataChange('update')
return true
} catch (error) {
console.error(`Failed to update metadata for vector ${id}:`, error)
@ -3195,6 +3380,9 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
// Update HNSW index size (excluding verbs)
await this.storage!.updateHnswIndexSize(await this.getNounCount())
// Invalidate search cache since verb data has changed
this.searchCache.invalidateOnDataChange('add')
return id
} catch (error) {
console.error('Failed to add verb:', error)
@ -3461,6 +3649,9 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
// Clear storage
await this.storage!.clear()
// Clear search cache since all data has been removed
this.searchCache.invalidateOnDataChange('delete')
} catch (error) {
console.error('Failed to clear vector database:', error)
throw new Error(`Failed to clear vector database: ${error}`)
@ -3474,6 +3665,79 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
return this.index.size()
}
/**
* Get search cache statistics for performance monitoring
* @returns Cache statistics including hit rate and memory usage
*/
public getCacheStats() {
return {
search: this.searchCache.getStats(),
searchMemoryUsage: this.searchCache.getMemoryUsage()
}
}
/**
* Clear search cache manually (useful for testing or memory management)
*/
public clearCache(): void {
this.searchCache.clear()
}
/**
* Adapt cache configuration based on current performance metrics
* This method analyzes usage patterns and automatically optimizes cache settings
* @private
*/
private adaptCacheConfiguration(): void {
const stats = this.searchCache.getStats()
const memoryUsage = this.searchCache.getMemoryUsage()
const currentConfig = this.searchCache.getConfig()
// Prepare performance metrics for adaptation
const performanceMetrics = {
hitRate: stats.hitRate,
avgResponseTime: 50, // Would be measured in real implementation
memoryUsage: memoryUsage,
externalChangesDetected: 0, // Would be tracked from real-time updates
timeSinceLastChange: Date.now() - this.lastUpdateTime
}
// Try to adapt configuration
const newConfig = this.cacheAutoConfigurator.adaptConfiguration(
currentConfig,
performanceMetrics
)
if (newConfig) {
// Apply new cache configuration
this.searchCache.updateConfig(newConfig.cacheConfig)
// Apply new real-time update configuration if needed
if (newConfig.realtimeConfig.enabled !== this.realtimeUpdateConfig.enabled ||
newConfig.realtimeConfig.interval !== this.realtimeUpdateConfig.interval) {
const wasEnabled = this.realtimeUpdateConfig.enabled
this.realtimeUpdateConfig = {
...this.realtimeUpdateConfig,
...newConfig.realtimeConfig
}
// Restart real-time updates with new configuration
if (wasEnabled) {
this.stopRealtimeUpdates()
}
if (this.realtimeUpdateConfig.enabled && this.isInitialized) {
this.startRealtimeUpdates()
}
}
if (this.loggingConfig?.verbose) {
console.log('🔧 Auto-adapted cache configuration:')
console.log(this.cacheAutoConfigurator.getConfigExplanation(newConfig))
}
}
}
/**
* Get the number of nouns in the database (excluding verbs)
* This is used for statistics reporting to match the expected behavior in tests
@ -4167,6 +4431,7 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
storeResults?: boolean // Whether to store the results in the local database (default: true)
service?: string // Filter results by the service that created the data
searchField?: string // Optional specific field to search within JSON documents
offset?: number // Number of results to skip for pagination (default: 0)
} = {}
): Promise<SearchResult<T>[]> {
await this.ensureInitialized()
@ -4198,18 +4463,24 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
)
}
// Search the remote server
// When using offset, fetch more results and slice
const offset = options.offset || 0
const totalNeeded = k + offset
// Search the remote server for totalNeeded results
const searchResult = await this.serverSearchConduit.searchServer(
this.serverConnection.connectionId,
query,
k
totalNeeded
)
if (!searchResult.success) {
throw new Error(`Remote search failed: ${searchResult.error}`)
}
return searchResult.data as SearchResult<T>[]
// Apply offset to remote results
const allResults = searchResult.data as SearchResult<T>[]
return allResults.slice(offset, offset + k)
} catch (error) {
console.error('Failed to search remote server:', error)
throw new Error(`Failed to search remote server: ${error}`)
@ -4233,6 +4504,7 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
localFirst?: boolean // Whether to search local first (default: true)
service?: string // Filter results by the service that created the data
searchField?: string // Optional specific field to search within JSON documents
offset?: number // Number of results to skip for pagination (default: 0)
} = {}
): Promise<SearchResult<T>[]> {
await this.ensureInitialized()

View file

@ -26,6 +26,25 @@ export interface SearchResult<T = any> {
metadata?: T
}
/**
* Cursor for pagination through search results
*/
export interface SearchCursor {
lastId: string
lastScore: number
position: number // For debugging/logging
}
/**
* Paginated search result with cursor support
*/
export interface PaginatedSearchResult<T = any> {
results: SearchResult<T>[]
cursor?: SearchCursor
hasMore: boolean
totalEstimate?: number
}
/**
* Distance function for comparing vectors
*/

View file

@ -0,0 +1,330 @@
/**
* Intelligent cache auto-configuration system
* Adapts cache settings based on environment, usage patterns, and storage type
*/
import { SearchCacheConfig } from './searchCache.js'
import { BrainyDataConfig } from '../brainyData.js'
export interface CacheUsageStats {
totalQueries: number
repeatQueries: number
avgQueryTime: number
memoryPressure: number
storageType: 'memory' | 'opfs' | 's3' | 'filesystem'
isDistributed: boolean
changeFrequency: number // changes per minute
readWriteRatio: number // reads / writes
}
export interface AutoConfigResult {
cacheConfig: SearchCacheConfig
realtimeConfig: NonNullable<BrainyDataConfig['realtimeUpdates']>
reasoning: string[]
}
export class CacheAutoConfigurator {
private stats: CacheUsageStats = {
totalQueries: 0,
repeatQueries: 0,
avgQueryTime: 50,
memoryPressure: 0,
storageType: 'memory',
isDistributed: false,
changeFrequency: 0,
readWriteRatio: 10,
}
private configHistory: AutoConfigResult[] = []
private lastOptimization = 0
/**
* Auto-detect optimal cache configuration based on current conditions
*/
public autoDetectOptimalConfig(
storageConfig?: BrainyDataConfig['storage'],
currentStats?: Partial<CacheUsageStats>
): AutoConfigResult {
// Update stats with current information
if (currentStats) {
this.stats = { ...this.stats, ...currentStats }
}
// Detect environment characteristics
this.detectEnvironment(storageConfig)
// Generate optimal configuration
const result = this.generateOptimalConfig()
// Store for learning
this.configHistory.push(result)
this.lastOptimization = Date.now()
return result
}
/**
* Dynamically adjust configuration based on runtime performance
*/
public adaptConfiguration(
currentConfig: SearchCacheConfig,
performanceMetrics: {
hitRate: number
avgResponseTime: number
memoryUsage: number
externalChangesDetected: number
timeSinceLastChange: number
}
): AutoConfigResult | null {
const reasoning: string[] = []
let needsUpdate = false
// Check if we should update (don't over-optimize)
if (Date.now() - this.lastOptimization < 60000) {
return null // Wait at least 1 minute between optimizations
}
// Analyze performance patterns
const adaptations: Partial<SearchCacheConfig> = {}
// Low hit rate → adjust cache size or TTL
if (performanceMetrics.hitRate < 0.3) {
if (performanceMetrics.externalChangesDetected > 5) {
// Too many external changes → shorter TTL
adaptations.maxAge = Math.max(60000, currentConfig.maxAge! * 0.7)
reasoning.push('Reduced cache TTL due to frequent external changes')
needsUpdate = true
} else {
// Expand cache size for better hit rate
adaptations.maxSize = Math.min(500, (currentConfig.maxSize || 100) * 1.5)
reasoning.push('Increased cache size due to low hit rate')
needsUpdate = true
}
}
// High hit rate but slow responses → might need cache warming
if (performanceMetrics.hitRate > 0.8 && performanceMetrics.avgResponseTime > 100) {
reasoning.push('High hit rate but slow responses - consider cache warming')
}
// Memory pressure → reduce cache size
if (performanceMetrics.memoryUsage > 100 * 1024 * 1024) { // 100MB
adaptations.maxSize = Math.max(20, (currentConfig.maxSize || 100) * 0.7)
reasoning.push('Reduced cache size due to memory pressure')
needsUpdate = true
}
// Recent external changes → adaptive TTL
if (performanceMetrics.timeSinceLastChange < 30000) { // 30 seconds
adaptations.maxAge = Math.max(30000, currentConfig.maxAge! * 0.8)
reasoning.push('Shortened TTL due to recent external changes')
needsUpdate = true
}
if (!needsUpdate) {
return null
}
const newCacheConfig: SearchCacheConfig = {
...currentConfig,
...adaptations
}
const newRealtimeConfig = this.calculateRealtimeConfig()
return {
cacheConfig: newCacheConfig,
realtimeConfig: newRealtimeConfig,
reasoning
}
}
/**
* Get recommended configuration for specific use case
*/
public getRecommendedConfig(useCase: 'high-consistency' | 'balanced' | 'performance-first'): AutoConfigResult {
const configs = {
'high-consistency': {
cache: { maxAge: 120000, maxSize: 50 },
realtime: { interval: 15000, enabled: true },
reasoning: ['Optimized for data consistency and real-time updates']
},
'balanced': {
cache: { maxAge: 300000, maxSize: 100 },
realtime: { interval: 30000, enabled: true },
reasoning: ['Balanced performance and consistency']
},
'performance-first': {
cache: { maxAge: 600000, maxSize: 200 },
realtime: { interval: 60000, enabled: true },
reasoning: ['Optimized for maximum cache performance']
}
}
const config = configs[useCase]
return {
cacheConfig: {
enabled: true,
...config.cache
},
realtimeConfig: {
updateIndex: true,
updateStatistics: true,
...config.realtime
},
reasoning: config.reasoning
}
}
/**
* Learn from usage patterns and improve recommendations
*/
public learnFromUsage(usageData: {
queryPatterns: string[]
responseTime: number
cacheHits: number
totalQueries: number
dataChanges: number
timeWindow: number
}): void {
// Update internal stats for better future recommendations
this.stats.totalQueries += usageData.totalQueries
this.stats.repeatQueries += usageData.cacheHits
this.stats.avgQueryTime = (this.stats.avgQueryTime + usageData.responseTime) / 2
this.stats.changeFrequency = usageData.dataChanges / (usageData.timeWindow / 60000)
// Calculate read/write ratio
const writes = usageData.dataChanges
const reads = usageData.totalQueries
this.stats.readWriteRatio = reads > 0 ? reads / Math.max(writes, 1) : 10
}
private detectEnvironment(storageConfig?: BrainyDataConfig['storage']): void {
// Detect storage type
if (storageConfig?.s3Storage || storageConfig?.customS3Storage) {
this.stats.storageType = 's3'
this.stats.isDistributed = true
} else if (storageConfig?.forceFileSystemStorage) {
this.stats.storageType = 'filesystem'
} else if (storageConfig?.forceMemoryStorage) {
this.stats.storageType = 'memory'
} else {
// Auto-detect browser vs Node.js
this.stats.storageType = typeof window !== 'undefined' ? 'opfs' : 'filesystem'
}
// Detect distributed mode indicators
this.stats.isDistributed = this.stats.isDistributed ||
Boolean(storageConfig?.s3Storage || storageConfig?.customS3Storage)
}
private generateOptimalConfig(): AutoConfigResult {
const reasoning: string[] = []
// Base configuration
let cacheConfig: SearchCacheConfig = {
enabled: true,
maxSize: 100,
maxAge: 300000, // 5 minutes
hitCountWeight: 0.3
}
let realtimeConfig = {
enabled: false,
interval: 60000,
updateIndex: true,
updateStatistics: true
}
// Adjust for storage type
if (this.stats.storageType === 's3' || this.stats.isDistributed) {
cacheConfig.maxAge = 180000 // 3 minutes for distributed
realtimeConfig.enabled = true
realtimeConfig.interval = 30000 // 30 seconds
reasoning.push('Distributed storage detected - enabled real-time updates')
reasoning.push('Reduced cache TTL for distributed consistency')
}
// Adjust for read/write patterns
if (this.stats.readWriteRatio > 20) {
// Read-heavy workload
cacheConfig.maxSize = Math.min(300, (cacheConfig.maxSize || 100) * 2)
cacheConfig.maxAge = Math.min(900000, (cacheConfig.maxAge || 300000) * 1.5) // Up to 15 minutes
reasoning.push('Read-heavy workload detected - increased cache size and TTL')
} else if (this.stats.readWriteRatio < 5) {
// Write-heavy workload
cacheConfig.maxSize = Math.max(50, (cacheConfig.maxSize || 100) * 0.7)
cacheConfig.maxAge = Math.max(60000, (cacheConfig.maxAge || 300000) * 0.6)
reasoning.push('Write-heavy workload detected - reduced cache size and TTL')
}
// Adjust for change frequency
if (this.stats.changeFrequency > 10) { // More than 10 changes per minute
realtimeConfig.interval = Math.max(10000, realtimeConfig.interval * 0.5)
cacheConfig.maxAge = Math.max(30000, (cacheConfig.maxAge || 300000) * 0.5)
reasoning.push('High change frequency detected - increased update frequency')
}
// Memory constraints
if (this.detectMemoryConstraints()) {
cacheConfig.maxSize = Math.max(20, (cacheConfig.maxSize || 100) * 0.6)
reasoning.push('Memory constraints detected - reduced cache size')
}
// Performance optimization
if (this.stats.avgQueryTime > 200) {
cacheConfig.maxSize = Math.min(500, (cacheConfig.maxSize || 100) * 1.5)
reasoning.push('Slow queries detected - increased cache size')
}
return {
cacheConfig,
realtimeConfig,
reasoning
}
}
private calculateRealtimeConfig() {
return {
enabled: this.stats.isDistributed || this.stats.changeFrequency > 1,
interval: this.stats.isDistributed ? 30000 : 60000,
updateIndex: true,
updateStatistics: true
}
}
private detectMemoryConstraints(): boolean {
// Simple heuristic for memory constraints
try {
if (typeof performance !== 'undefined' && 'memory' in performance) {
const memInfo = (performance as any).memory
return memInfo.usedJSHeapSize > memInfo.jsHeapSizeLimit * 0.8
}
} catch (e) {
// Ignore errors
}
// Default assumption for constrained environments
return false
}
/**
* Get human-readable explanation of current configuration
*/
public getConfigExplanation(config: AutoConfigResult): string {
const lines = [
'🤖 Brainy Auto-Configuration:',
'',
`📊 Cache: ${config.cacheConfig.maxSize} queries, ${config.cacheConfig.maxAge! / 1000}s TTL`,
`🔄 Updates: ${config.realtimeConfig.enabled ? `Every ${(config.realtimeConfig.interval || 30000) / 1000}s` : 'Disabled'}`,
'',
'🎯 Optimizations applied:'
]
config.reasoning.forEach(reason => {
lines.push(`${reason}`)
})
return lines.join('\n')
}
}

308
src/utils/searchCache.ts Normal file
View file

@ -0,0 +1,308 @@
/**
* SearchCache - Caches search results for improved performance
*/
import { SearchResult } from '../coreTypes.js'
export interface CacheEntry<T = any> {
results: SearchResult<T>[]
timestamp: number
hits: number
}
export interface SearchCacheConfig {
maxAge?: number // Maximum age in milliseconds (default: 5 minutes)
maxSize?: number // Maximum number of cached queries (default: 100)
enabled?: boolean // Whether caching is enabled (default: true)
hitCountWeight?: number // Weight for hit count in eviction policy (default: 0.3)
}
export class SearchCache<T = any> {
private cache = new Map<string, CacheEntry<T>>()
private maxAge: number
private maxSize: number
private enabled: boolean
private hitCountWeight: number
// Cache statistics
private hits = 0
private misses = 0
private evictions = 0
constructor(config: SearchCacheConfig = {}) {
this.maxAge = config.maxAge ?? 5 * 60 * 1000 // 5 minutes
this.maxSize = config.maxSize ?? 100
this.enabled = config.enabled ?? true
this.hitCountWeight = config.hitCountWeight ?? 0.3
}
/**
* Generate cache key from search parameters
*/
getCacheKey(
query: any,
k: number,
options: Record<string, any> = {}
): string {
// Create a normalized key that ignores order of options
const normalizedOptions = Object.keys(options)
.sort()
.reduce((acc, key) => {
// Skip cache-related options
if (key === 'skipCache' || key === 'useStreaming') return acc
acc[key] = options[key]
return acc
}, {} as Record<string, any>)
return JSON.stringify({
query: typeof query === 'object' ? JSON.stringify(query) : query,
k,
...normalizedOptions
})
}
/**
* Get cached results if available and not expired
*/
get(key: string): SearchResult<T>[] | null {
if (!this.enabled) return null
const entry = this.cache.get(key)
if (!entry) {
this.misses++
return null
}
// Check if expired
if (Date.now() - entry.timestamp > this.maxAge) {
this.cache.delete(key)
this.misses++
return null
}
// Update hit count and statistics
entry.hits++
this.hits++
return entry.results
}
/**
* Cache search results
*/
set(key: string, results: SearchResult<T>[]): void {
if (!this.enabled) return
// Evict if cache is full
if (this.cache.size >= this.maxSize) {
this.evictOldest()
}
this.cache.set(key, {
results: [...results], // Deep copy to prevent mutations
timestamp: Date.now(),
hits: 0
})
}
/**
* Evict the oldest entry based on timestamp and hit count
*/
private evictOldest(): void {
let oldestKey: string | null = null
let oldestScore = Infinity
const now = Date.now()
for (const [key, entry] of this.cache.entries()) {
// Score combines age and inverse hit count
const age = now - entry.timestamp
const hitScore = entry.hits > 0 ? 1 / entry.hits : 1
const score = age + (hitScore * this.hitCountWeight * this.maxAge)
if (score < oldestScore) {
oldestScore = score
oldestKey = key
}
}
if (oldestKey) {
this.cache.delete(oldestKey)
this.evictions++
}
}
/**
* Clear all cached results
*/
clear(): void {
this.cache.clear()
this.hits = 0
this.misses = 0
this.evictions = 0
}
/**
* Invalidate cache entries that might be affected by data changes
*/
invalidate(pattern?: string | RegExp): void {
if (!pattern) {
this.clear()
return
}
const keysToDelete: string[] = []
for (const key of this.cache.keys()) {
const shouldDelete = typeof pattern === 'string'
? key.includes(pattern)
: pattern.test(key)
if (shouldDelete) {
keysToDelete.push(key)
}
}
keysToDelete.forEach(key => this.cache.delete(key))
}
/**
* Smart invalidation for real-time data updates
* Only clears cache if it's getting stale or if data changes significantly
*/
invalidateOnDataChange(changeType?: 'add' | 'update' | 'delete'): void {
// For now, clear all caches on data changes to ensure consistency
// In the future, we could implement more sophisticated invalidation
// based on the type of change and affected data
this.clear()
}
/**
* Check if cache entries have expired and remove them
* This is especially important in distributed scenarios where
* real-time updates might be delayed or missed
*/
cleanupExpiredEntries(): number {
const now = Date.now()
const keysToDelete: string[] = []
for (const [key, entry] of this.cache.entries()) {
if (now - entry.timestamp > this.maxAge) {
keysToDelete.push(key)
}
}
keysToDelete.forEach(key => this.cache.delete(key))
return keysToDelete.length
}
/**
* Get cache statistics
*/
getStats() {
const total = this.hits + this.misses
return {
hits: this.hits,
misses: this.misses,
evictions: this.evictions,
hitRate: total > 0 ? this.hits / total : 0,
size: this.cache.size,
maxSize: this.maxSize,
enabled: this.enabled
}
}
/**
* Enable or disable caching
*/
setEnabled(enabled: boolean): void {
Object.defineProperty(this, 'enabled', { value: enabled, writable: false })
if (!enabled) {
this.clear()
}
}
/**
* Get memory usage estimate in bytes
*/
getMemoryUsage(): number {
let totalSize = 0
for (const [key, entry] of this.cache.entries()) {
// Estimate key size
totalSize += key.length * 2 // UTF-16 characters
// Estimate entry size
totalSize += JSON.stringify(entry.results).length * 2
totalSize += 16 // timestamp + hits (8 bytes each)
}
return totalSize
}
/**
* Get current cache configuration
*/
getConfig(): SearchCacheConfig {
return {
enabled: this.enabled,
maxSize: this.maxSize,
maxAge: this.maxAge,
hitCountWeight: this.hitCountWeight
}
}
/**
* Update cache configuration dynamically
*/
updateConfig(newConfig: Partial<SearchCacheConfig>): void {
if (newConfig.enabled !== undefined) {
this.enabled = newConfig.enabled
}
if (newConfig.maxSize !== undefined) {
this.maxSize = newConfig.maxSize
// Trigger eviction if current size exceeds new limit
this.evictIfNeeded()
}
if (newConfig.maxAge !== undefined) {
this.maxAge = newConfig.maxAge
// Clean up entries that are now expired with new TTL
this.cleanupExpiredEntries()
}
if (newConfig.hitCountWeight !== undefined) {
this.hitCountWeight = newConfig.hitCountWeight
}
}
/**
* Evict entries if cache exceeds maxSize
*/
private evictIfNeeded(): void {
if (this.cache.size <= this.maxSize) {
return
}
// Calculate eviction score for each entry (same logic as existing eviction)
const entries = Array.from(this.cache.entries()).map(([key, entry]) => {
const age = Date.now() - entry.timestamp
const hitCount = entry.hits
// Eviction score: lower is more likely to be evicted
// Combines age and hit count (weighted by hitCountWeight)
const ageScore = age / this.maxAge
const hitScore = 1 / (hitCount + 1) // Inverse of hits (more hits = lower score)
const score = ageScore * (1 - this.hitCountWeight) + hitScore * this.hitCountWeight
return { key, entry, score }
})
// Sort by score (lowest first - these will be evicted)
entries.sort((a, b) => a.score - b.score)
// Evict entries until we're under the limit
const toEvict = entries.slice(0, this.cache.size - this.maxSize)
toEvict.forEach(({ key }) => {
this.cache.delete(key)
this.evictions++
})
}
}