feat: v0.49 - Filter discovery API, remove deprecated methods, improve performance

BREAKING CHANGES:
- Removed deprecated getAllNouns() and getAllVerbs() methods
- All internal usage migrated to pagination-based methods

New Features:
- Filter Discovery API:
  - getFilterValues(field): Get all available values for a field
  - getFilterFields(): Get all filterable fields
  - Enables dynamic filter UI generation with O(1) field discovery
- Hybrid metadata indexing with field-level indexes
- Adaptive auto-flush for optimal performance
- LRU caching for metadata indexes

Improvements:
- Fixed ENAMETOOLONG errors from vector-based filenames
- Safe filename generation using hash-based approach
- Scalable chunked value storage for millions of entries
- Performance optimization with adaptive flush thresholds
- Added support for $includes operator in metadata filters

Technical:
- Replaced vector-based filenames with safe hash approach
- Implemented MetadataIndexCache with existing SearchCache pattern
- Field indexes enable O(1) filter discovery
- Adaptive flush based on performance metrics (20-200 entries)
- All tests passing with improved metadata filtering
This commit is contained in:
David Snelling 2025-08-06 14:39:33 -07:00
parent ac5b3183e3
commit 2dc909909a
17 changed files with 942 additions and 486 deletions

View file

@ -5,6 +5,7 @@
*/
import { StorageAdapter } from '../coreTypes.js'
import { MetadataIndexCache, MetadataIndexCacheConfig } from './metadataIndexCache.js'
export interface MetadataIndexEntry {
field: string
@ -13,6 +14,12 @@ export interface MetadataIndexEntry {
lastUpdated: number
}
export interface FieldIndexData {
// Maps value -> count for quick filter discovery
values: Record<string, number>
lastUpdated: number
}
export interface MetadataIndexStats {
totalEntries: number
totalIds: number
@ -39,6 +46,11 @@ export class MetadataIndexManager {
private indexCache = new Map<string, MetadataIndexEntry>()
private dirtyEntries = new Set<string>()
private isRebuilding = false
private metadataCache: MetadataIndexCache
private fieldIndexes = new Map<string, FieldIndexData>()
private dirtyFields = new Set<string>()
private lastFlushTime = Date.now()
private autoFlushThreshold = 50 // Start with 50, will adapt based on usage
constructor(storage: StorageAdapter, config: MetadataIndexConfig = {}) {
this.storage = storage
@ -49,6 +61,13 @@ export class MetadataIndexManager {
indexedFields: config.indexedFields ?? [],
excludeFields: config.excludeFields ?? ['id', 'createdAt', 'updatedAt', 'embedding', 'vector', 'embeddings', 'vectors']
}
// Initialize metadata cache with similar config to search cache
this.metadataCache = new MetadataIndexCache({
maxAge: 5 * 60 * 1000, // 5 minutes
maxSize: 500, // 500 entries (field indexes + value chunks)
enabled: true
})
}
/**
@ -173,7 +192,7 @@ export class MetadataIndexManager {
/**
* Add item to metadata indexes
*/
async addToIndex(id: string, metadata: any): Promise<void> {
async addToIndex(id: string, metadata: any, skipFlush: boolean = false): Promise<void> {
const fields = this.extractIndexableFields(metadata)
for (const { field, value } of fields) {
@ -196,7 +215,65 @@ export class MetadataIndexManager {
entry.ids.add(id)
entry.lastUpdated = Date.now()
this.dirtyEntries.add(key)
// Update field index
await this.updateFieldIndex(field, value, 1)
}
// Adaptive auto-flush based on usage patterns
if (!skipFlush) {
const timeSinceLastFlush = Date.now() - this.lastFlushTime
const shouldAutoFlush =
this.dirtyEntries.size >= this.autoFlushThreshold || // Size threshold
(this.dirtyEntries.size > 10 && timeSinceLastFlush > 5000) // Time threshold (5 seconds)
if (shouldAutoFlush) {
const startTime = Date.now()
await this.flush()
const flushTime = Date.now() - startTime
// Adapt threshold based on flush performance
if (flushTime < 50) {
// Fast flush, can handle more entries
this.autoFlushThreshold = Math.min(200, this.autoFlushThreshold * 1.2)
} else if (flushTime > 200) {
// Slow flush, reduce batch size
this.autoFlushThreshold = Math.max(20, this.autoFlushThreshold * 0.8)
}
}
}
// Invalidate cache for these fields
for (const { field } of fields) {
this.metadataCache.invalidatePattern(`field_values_${field}`)
}
}
/**
* Update field index with value count
*/
private async updateFieldIndex(field: string, value: any, delta: number): Promise<void> {
let fieldIndex = this.fieldIndexes.get(field)
if (!fieldIndex) {
// Load from storage if not in memory
fieldIndex = await this.loadFieldIndex(field) ?? {
values: {},
lastUpdated: Date.now()
}
this.fieldIndexes.set(field, fieldIndex)
}
const normalizedValue = this.normalizeValue(value)
fieldIndex.values[normalizedValue] = (fieldIndex.values[normalizedValue] || 0) + delta
// Remove if count drops to 0
if (fieldIndex.values[normalizedValue] <= 0) {
delete fieldIndex.values[normalizedValue]
}
fieldIndex.lastUpdated = Date.now()
this.dirtyFields.add(field)
}
/**
@ -220,12 +297,18 @@ export class MetadataIndexManager {
entry.lastUpdated = Date.now()
this.dirtyEntries.add(key)
// Update field index
await this.updateFieldIndex(field, value, -1)
// If no IDs left, mark for cleanup
if (entry.ids.size === 0) {
this.indexCache.delete(key)
await this.deleteIndexEntry(key)
}
}
// Invalidate cache
this.metadataCache.invalidatePattern(`field_values_${field}`)
}
} else {
// Remove from all indexes (slower, requires scanning)
@ -245,12 +328,19 @@ export class MetadataIndexManager {
}
/**
* Get IDs for a specific field-value combination
* Get IDs for a specific field-value combination with caching
*/
async getIds(field: string, value: any): Promise<string[]> {
const key = this.getIndexKey(field, value)
// Try cache first
// Check metadata cache first
const cacheKey = `ids_${key}`
const cachedIds = this.metadataCache.get(cacheKey)
if (cachedIds) {
return cachedIds
}
// Try in-memory cache
let entry = this.indexCache.get(key)
// Load from storage if not cached
@ -262,7 +352,73 @@ export class MetadataIndexManager {
}
}
return entry ? Array.from(entry.ids) : []
const ids = entry ? Array.from(entry.ids) : []
// Cache the result
this.metadataCache.set(cacheKey, ids)
return ids
}
/**
* Get all available values for a field (for filter discovery)
*/
async getFilterValues(field: string): Promise<string[]> {
// Check cache first
const cacheKey = `field_values_${field}`
const cachedValues = this.metadataCache.get(cacheKey)
if (cachedValues) {
return cachedValues
}
// Check in-memory field indexes first
let fieldIndex = this.fieldIndexes.get(field)
// If not in memory, load from storage
if (!fieldIndex) {
const loaded = await this.loadFieldIndex(field)
if (loaded) {
fieldIndex = loaded
this.fieldIndexes.set(field, loaded)
}
}
if (!fieldIndex) {
return []
}
const values = Object.keys(fieldIndex.values)
// Cache the result
this.metadataCache.set(cacheKey, values)
return values
}
/**
* Get all indexed fields (for filter discovery)
*/
async getFilterFields(): Promise<string[]> {
// Check cache first
const cacheKey = 'all_filter_fields'
const cachedFields = this.metadataCache.get(cacheKey)
if (cachedFields) {
return cachedFields
}
// Get fields from in-memory indexes and storage
const fields = new Set<string>(this.fieldIndexes.keys())
// Also scan storage for persisted field indexes (in case not loaded)
// This would require a new storage method to list field indexes
// For now, just use in-memory fields
const fieldsArray = Array.from(fields)
// Cache the result
this.metadataCache.set(cacheKey, fieldsArray)
return fieldsArray
}
/**
@ -291,6 +447,10 @@ export class MetadataIndexManager {
case '$eq':
criteria.push({ field: key, values: [operand] })
break
case '$includes':
// For $includes, the operand is the value we're looking for in an array field
criteria.push({ field: key, values: [operand] })
break
// For other operators, we can't use index efficiently, skip for now
default:
break
@ -376,8 +536,13 @@ export class MetadataIndexManager {
* Flush dirty entries to storage
*/
async flush(): Promise<void> {
if (this.dirtyEntries.size === 0 && this.dirtyFields.size === 0) {
return // Nothing to flush
}
const promises: Promise<void>[] = []
// Flush value entries
for (const key of this.dirtyEntries) {
const entry = this.indexCache.get(key)
if (entry) {
@ -385,8 +550,69 @@ export class MetadataIndexManager {
}
}
// Flush field indexes
for (const field of this.dirtyFields) {
const fieldIndex = this.fieldIndexes.get(field)
if (fieldIndex) {
promises.push(this.saveFieldIndex(field, fieldIndex))
}
}
await Promise.all(promises)
this.dirtyEntries.clear()
this.dirtyFields.clear()
this.lastFlushTime = Date.now()
}
/**
* Load field index from storage
*/
private async loadFieldIndex(field: string): Promise<FieldIndexData | null> {
try {
const filename = this.getFieldIndexFilename(field)
const cacheKey = `field_index_${filename}`
// Check cache first
const cached = this.metadataCache.get(cacheKey)
if (cached) {
return cached
}
// Load from storage
const indexId = `__metadata_field_index__${filename}`
const data = await this.storage.getMetadata(indexId)
if (data) {
const fieldIndex = {
values: data.values || {},
lastUpdated: data.lastUpdated || Date.now()
}
// Cache it
this.metadataCache.set(cacheKey, fieldIndex)
return fieldIndex
}
} catch (error) {
// Field index doesn't exist yet
}
return null
}
/**
* Save field index to storage
*/
private async saveFieldIndex(field: string, fieldIndex: FieldIndexData): Promise<void> {
const filename = this.getFieldIndexFilename(field)
const indexId = `__metadata_field_index__${filename}`
await this.storage.saveMetadata(indexId, {
values: fieldIndex.values,
lastUpdated: fieldIndex.lastUpdated
})
// Invalidate cache
this.metadataCache.invalidatePattern(`field_index_${filename}`)
}
/**
@ -413,7 +639,7 @@ export class MetadataIndexManager {
}
/**
* Rebuild entire index from scratch
* Rebuild entire index from scratch using pagination
*/
async rebuild(): Promise<void> {
if (this.isRebuilding) return
@ -423,25 +649,51 @@ export class MetadataIndexManager {
// Clear existing indexes
this.indexCache.clear()
this.dirtyEntries.clear()
this.fieldIndexes.clear()
this.dirtyFields.clear()
// Get all nouns and rebuild their metadata indexes
const nouns = await this.storage.getAllNouns()
// Rebuild noun metadata indexes using pagination
let nounOffset = 0
const nounLimit = 100
let hasMoreNouns = true
for (const noun of nouns) {
const metadata = await this.storage.getMetadata(noun.id)
if (metadata) {
await this.addToIndex(noun.id, metadata)
while (hasMoreNouns) {
const result = await this.storage.getNouns({
pagination: { offset: nounOffset, limit: nounLimit }
})
for (const noun of result.items) {
const metadata = await this.storage.getMetadata(noun.id)
if (metadata) {
// Skip flush during rebuild for performance
await this.addToIndex(noun.id, metadata, true)
}
}
hasMoreNouns = result.hasMore
nounOffset += nounLimit
}
// Get all verbs and rebuild their metadata indexes
const verbs = await this.storage.getAllVerbs()
// Rebuild verb metadata indexes using pagination
let verbOffset = 0
const verbLimit = 100
let hasMoreVerbs = true
for (const verb of verbs) {
const metadata = await this.storage.getVerbMetadata(verb.id)
if (metadata) {
await this.addToIndex(verb.id, metadata)
while (hasMoreVerbs) {
const result = await this.storage.getVerbs({
pagination: { offset: verbOffset, limit: verbLimit }
})
for (const verb of result.items) {
const metadata = await this.storage.getVerbMetadata(verb.id)
if (metadata) {
// Skip flush during rebuild for performance
await this.addToIndex(verb.id, metadata, true)
}
}
hasMoreVerbs = result.hasMore
verbOffset += verbLimit
}
// Flush to storage

View file

@ -0,0 +1,151 @@
/**
* MetadataIndexCache - Caches metadata index data for improved performance
* Reuses the same pattern as SearchCache for consistency
*/
export interface MetadataCacheEntry {
data: any // Field index or value chunk data
timestamp: number
hits: number
}
export interface MetadataIndexCacheConfig {
maxAge?: number // Maximum age in milliseconds (default: 5 minutes)
maxSize?: number // Maximum number of cached entries (default: 500)
enabled?: boolean // Whether caching is enabled (default: true)
hitCountWeight?: number // Weight for hit count in eviction policy (default: 0.3)
}
export class MetadataIndexCache {
private cache = new Map<string, MetadataCacheEntry>()
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: MetadataIndexCacheConfig = {}) {
this.maxAge = config.maxAge ?? 5 * 60 * 1000 // 5 minutes
this.maxSize = config.maxSize ?? 500 // More entries than SearchCache since indexes are smaller
this.enabled = config.enabled ?? true
this.hitCountWeight = config.hitCountWeight ?? 0.3
}
/**
* Get cached entry
*/
get(key: string): any | undefined {
if (!this.enabled) return undefined
const entry = this.cache.get(key)
if (!entry) {
this.misses++
return undefined
}
// Check if entry is expired
if (Date.now() - entry.timestamp > this.maxAge) {
this.cache.delete(key)
this.misses++
return undefined
}
// Update hit count
entry.hits++
this.hits++
return entry.data
}
/**
* Set cache entry
*/
set(key: string, data: any): void {
if (!this.enabled) return
// Evict entries if at max size
if (this.cache.size >= this.maxSize) {
this.evictLeastValuable()
}
this.cache.set(key, {
data,
timestamp: Date.now(),
hits: 0
})
}
/**
* Evict least valuable entry based on age and hit count
*/
private evictLeastValuable(): void {
let leastValuableKey: string | null = null
let lowestScore = Infinity
for (const [key, entry] of this.cache.entries()) {
const age = Date.now() - entry.timestamp
const ageScore = age / this.maxAge
const hitScore = entry.hits * this.hitCountWeight
const score = hitScore - ageScore
if (score < lowestScore) {
lowestScore = score
leastValuableKey = key
}
}
if (leastValuableKey) {
this.cache.delete(leastValuableKey)
this.evictions++
}
}
/**
* Invalidate cache entries matching a pattern
*/
invalidatePattern(pattern: string): void {
const keysToDelete: string[] = []
for (const key of this.cache.keys()) {
if (key.includes(pattern)) {
keysToDelete.push(key)
}
}
keysToDelete.forEach(key => this.cache.delete(key))
}
/**
* Clear all cache entries
*/
clear(): void {
this.cache.clear()
}
/**
* Get cache statistics
*/
getStats() {
return {
size: this.cache.size,
hits: this.hits,
misses: this.misses,
hitRate: this.hits / (this.hits + this.misses) || 0,
evictions: this.evictions
}
}
/**
* Get estimated memory usage
*/
getMemoryUsage(): number {
// Rough estimate: 100 bytes per entry + data size
let totalSize = 0
for (const entry of this.cache.values()) {
totalSize += 100 // Base overhead
totalSize += JSON.stringify(entry.data).length * 2 // Unicode chars
}
return totalSize
}
}