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

@ -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++
})
}
}