brainy/dist/utils/metadataIndex.js
David Snelling f8c45f2d8d Initial commit: Brainy - Multi-Dimensional AI Database
Open source vector database with HNSW indexing, graph relationships,
and metadata facets. Features CLI with professional augmentation registry
integration for discovering extensions and capabilities.
2025-08-18 17:35:06 -07:00

770 lines
No EOL
31 KiB
JavaScript

/**
* Metadata Index System
* Maintains inverted indexes for fast metadata filtering
* Automatically updates indexes when data changes
*/
import { MetadataIndexCache } from './metadataIndexCache.js';
import { prodLog } from './logger.js';
/**
* Manages metadata indexes for fast filtering
* Maintains inverted indexes: field+value -> list of IDs
*/
export class MetadataIndexManager {
constructor(storage, config = {}) {
this.indexCache = new Map();
this.dirtyEntries = new Set();
this.isRebuilding = false;
this.fieldIndexes = new Map();
this.dirtyFields = new Set();
this.lastFlushTime = Date.now();
this.autoFlushThreshold = 10; // Start with 10 for more frequent non-blocking flushes
this.storage = storage;
this.config = {
maxIndexSize: config.maxIndexSize ?? 10000,
rebuildThreshold: config.rebuildThreshold ?? 0.1,
autoOptimize: config.autoOptimize ?? true,
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
});
}
/**
* Get index key for field and value
*/
getIndexKey(field, value) {
const normalizedValue = this.normalizeValue(value);
return `${field}:${normalizedValue}`;
}
/**
* Generate field index filename for filter discovery
*/
getFieldIndexFilename(field) {
return `field_${field}`;
}
/**
* Generate value chunk filename for scalable storage
*/
getValueChunkFilename(field, value, chunkIndex = 0) {
const normalizedValue = this.normalizeValue(value);
const safeValue = this.makeSafeFilename(normalizedValue);
return `${field}_${safeValue}_chunk${chunkIndex}`;
}
/**
* Make a value safe for use in filenames
*/
makeSafeFilename(value) {
// Replace unsafe characters and limit length
return value
.replace(/[^a-zA-Z0-9-_]/g, '_')
.substring(0, 50)
.toLowerCase();
}
/**
* Normalize value for consistent indexing
*/
normalizeValue(value) {
if (value === null || value === undefined)
return '__NULL__';
if (typeof value === 'boolean')
return value ? '__TRUE__' : '__FALSE__';
if (typeof value === 'number')
return value.toString();
if (Array.isArray(value)) {
const joined = value.map(v => this.normalizeValue(v)).join(',');
// Hash very long array values to avoid filesystem limits
if (joined.length > 100) {
return this.hashValue(joined);
}
return joined;
}
const stringValue = String(value).toLowerCase().trim();
// Hash very long string values to avoid filesystem limits
if (stringValue.length > 100) {
return this.hashValue(stringValue);
}
return stringValue;
}
/**
* Create a short hash for long values to avoid filesystem filename limits
*/
hashValue(value) {
// Simple hash function to create shorter keys
let hash = 0;
for (let i = 0; i < value.length; i++) {
const char = value.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return `__HASH_${Math.abs(hash).toString(36)}`;
}
/**
* Check if field should be indexed
*/
shouldIndexField(field) {
if (this.config.excludeFields.includes(field))
return false;
if (this.config.indexedFields.length > 0) {
return this.config.indexedFields.includes(field);
}
return true;
}
/**
* Extract indexable field-value pairs from metadata
*/
extractIndexableFields(metadata) {
const fields = [];
const extract = (obj, prefix = '') => {
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (!this.shouldIndexField(fullKey))
continue;
if (value && typeof value === 'object' && !Array.isArray(value)) {
// Recurse into nested objects
extract(value, fullKey);
}
else {
// Index this field
fields.push({ field: fullKey, value });
// If it's an array, also index each element
if (Array.isArray(value)) {
for (const item of value) {
fields.push({ field: fullKey, value: item });
}
}
}
}
};
if (metadata && typeof metadata === 'object') {
extract(metadata);
}
return fields;
}
/**
* Add item to metadata indexes
*/
async addToIndex(id, metadata, skipFlush = false) {
const fields = this.extractIndexableFields(metadata);
for (let i = 0; i < fields.length; i++) {
const { field, value } = fields[i];
const key = this.getIndexKey(field, value);
// Get or create index entry
let entry = this.indexCache.get(key);
if (!entry) {
const loadedEntry = await this.loadIndexEntry(key);
entry = loadedEntry ?? {
field,
value: this.normalizeValue(value),
ids: new Set(),
lastUpdated: Date.now()
};
this.indexCache.set(key, entry);
}
// Add ID to entry
entry.ids.add(id);
entry.lastUpdated = Date.now();
this.dirtyEntries.add(key);
// Update field index
await this.updateFieldIndex(field, value, 1);
// Yield to event loop every 5 fields to prevent blocking
if (i % 5 === 4) {
await this.yieldToEventLoop();
}
}
// 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);
}
// Yield to event loop after flush to prevent blocking
await this.yieldToEventLoop();
}
}
// Invalidate cache for these fields
for (const { field } of fields) {
this.metadataCache.invalidatePattern(`field_values_${field}`);
}
}
/**
* Update field index with value count
*/
async updateFieldIndex(field, value, delta) {
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);
}
/**
* Remove item from metadata indexes
*/
async removeFromIndex(id, metadata) {
if (metadata) {
// Remove from specific field indexes
const fields = this.extractIndexableFields(metadata);
for (const { field, value } of fields) {
const key = this.getIndexKey(field, value);
let entry = this.indexCache.get(key);
if (!entry) {
const loadedEntry = await this.loadIndexEntry(key);
entry = loadedEntry ?? undefined;
}
if (entry) {
entry.ids.delete(id);
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)
for (const [key, entry] of this.indexCache.entries()) {
if (entry.ids.has(id)) {
entry.ids.delete(id);
entry.lastUpdated = Date.now();
this.dirtyEntries.add(key);
if (entry.ids.size === 0) {
this.indexCache.delete(key);
await this.deleteIndexEntry(key);
}
}
}
}
}
/**
* Get IDs for a specific field-value combination with caching
*/
async getIds(field, value) {
const key = this.getIndexKey(field, value);
// 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
if (!entry) {
const loadedEntry = await this.loadIndexEntry(key);
if (loadedEntry) {
entry = loadedEntry;
this.indexCache.set(key, entry);
}
}
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) {
// 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() {
// 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(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;
}
/**
* Convert MongoDB-style filter to simple field-value criteria for indexing
*/
convertFilterToCriteria(filter) {
const criteria = [];
if (!filter || typeof filter !== 'object') {
return criteria;
}
for (const [key, value] of Object.entries(filter)) {
// Skip logical operators for now - handle them separately
if (key.startsWith('$'))
continue;
if (value && typeof value === 'object' && !Array.isArray(value)) {
// Handle MongoDB operators
for (const [op, operand] of Object.entries(value)) {
switch (op) {
case '$in':
if (Array.isArray(operand)) {
criteria.push({ field: key, values: operand });
}
break;
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;
}
}
}
else {
// Direct value or array
const values = Array.isArray(value) ? value : [value];
criteria.push({ field: key, values });
}
}
return criteria;
}
/**
* Get IDs matching MongoDB-style metadata filter using indexes where possible
*/
async getIdsForFilter(filter) {
if (!filter || Object.keys(filter).length === 0) {
return [];
}
// Handle logical operators
if (filter.$and && Array.isArray(filter.$and)) {
// For $and, we need intersection of all sub-filters
const allIds = [];
for (const subFilter of filter.$and) {
const subIds = await this.getIdsForFilter(subFilter);
allIds.push(subIds);
}
if (allIds.length === 0)
return [];
if (allIds.length === 1)
return allIds[0];
// Intersection of all sets
return allIds.reduce((intersection, currentSet) => intersection.filter(id => currentSet.includes(id)));
}
if (filter.$or && Array.isArray(filter.$or)) {
// For $or, we need union of all sub-filters
const unionIds = new Set();
for (const subFilter of filter.$or) {
const subIds = await this.getIdsForFilter(subFilter);
subIds.forEach(id => unionIds.add(id));
}
return Array.from(unionIds);
}
// Handle regular field filters
const criteria = this.convertFilterToCriteria(filter);
const idSets = [];
for (const { field, values } of criteria) {
const unionIds = new Set();
for (const value of values) {
const ids = await this.getIds(field, value);
ids.forEach(id => unionIds.add(id));
}
idSets.push(Array.from(unionIds));
}
if (idSets.length === 0)
return [];
if (idSets.length === 1)
return idSets[0];
// Intersection of all field criteria (implicit $and)
return idSets.reduce((intersection, currentSet) => intersection.filter(id => currentSet.includes(id)));
}
/**
* Get IDs matching multiple criteria (intersection) - LEGACY METHOD
* @deprecated Use getIdsForFilter instead
*/
async getIdsForCriteria(criteria) {
return this.getIdsForFilter(criteria);
}
/**
* Flush dirty entries to storage (non-blocking version)
*/
async flush() {
if (this.dirtyEntries.size === 0 && this.dirtyFields.size === 0) {
return; // Nothing to flush
}
// Process in smaller batches to avoid blocking
const BATCH_SIZE = 20;
const allPromises = [];
// Flush value entries in batches
const dirtyEntriesArray = Array.from(this.dirtyEntries);
for (let i = 0; i < dirtyEntriesArray.length; i += BATCH_SIZE) {
const batch = dirtyEntriesArray.slice(i, i + BATCH_SIZE);
const batchPromises = batch.map(key => {
const entry = this.indexCache.get(key);
return entry ? this.saveIndexEntry(key, entry) : Promise.resolve();
});
allPromises.push(...batchPromises);
// Yield to event loop between batches
if (i + BATCH_SIZE < dirtyEntriesArray.length) {
await this.yieldToEventLoop();
}
}
// Flush field indexes in batches
const dirtyFieldsArray = Array.from(this.dirtyFields);
for (let i = 0; i < dirtyFieldsArray.length; i += BATCH_SIZE) {
const batch = dirtyFieldsArray.slice(i, i + BATCH_SIZE);
const batchPromises = batch.map(field => {
const fieldIndex = this.fieldIndexes.get(field);
return fieldIndex ? this.saveFieldIndex(field, fieldIndex) : Promise.resolve();
});
allPromises.push(...batchPromises);
// Yield to event loop between batches
if (i + BATCH_SIZE < dirtyFieldsArray.length) {
await this.yieldToEventLoop();
}
}
// Wait for all operations to complete
await Promise.all(allPromises);
this.dirtyEntries.clear();
this.dirtyFields.clear();
this.lastFlushTime = Date.now();
}
/**
* Yield control back to the Node.js event loop
* Prevents blocking during long-running operations
*/
async yieldToEventLoop() {
return new Promise(resolve => setImmediate(resolve));
}
/**
* Load field index from storage
*/
async loadFieldIndex(field) {
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
*/
async saveFieldIndex(field, fieldIndex) {
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}`);
}
/**
* Get index statistics
*/
async getStats() {
const fields = new Set();
let totalEntries = 0;
let totalIds = 0;
for (const entry of this.indexCache.values()) {
fields.add(entry.field);
totalEntries++;
totalIds += entry.ids.size;
}
return {
totalEntries,
totalIds,
fieldsIndexed: Array.from(fields),
lastRebuild: 0, // TODO: track rebuild timestamp
indexSize: totalEntries * 100 // rough estimate
};
}
/**
* Rebuild entire index from scratch using pagination
* Non-blocking version that yields control back to event loop
*/
async rebuild() {
if (this.isRebuilding)
return;
this.isRebuilding = true;
try {
prodLog.info('🔄 Starting non-blocking metadata index rebuild with batch processing to prevent socket exhaustion...');
prodLog.info(`📊 Storage adapter: ${this.storage.constructor.name}`);
prodLog.info(`🔧 Batch processing available: ${!!this.storage.getMetadataBatch}`);
// Clear existing indexes
this.indexCache.clear();
this.dirtyEntries.clear();
this.fieldIndexes.clear();
this.dirtyFields.clear();
// Rebuild noun metadata indexes using pagination
let nounOffset = 0;
const nounLimit = 25; // Even smaller batches during initialization to prevent socket exhaustion
let hasMoreNouns = true;
let totalNounsProcessed = 0;
while (hasMoreNouns) {
const result = await this.storage.getNouns({
pagination: { offset: nounOffset, limit: nounLimit }
});
// CRITICAL FIX: Use batch metadata reading to prevent socket exhaustion
const nounIds = result.items.map(noun => noun.id);
let metadataBatch;
if (this.storage.getMetadataBatch) {
// Use batch reading if available (prevents socket exhaustion)
prodLog.info(`📦 Processing metadata batch ${Math.floor(totalNounsProcessed / nounLimit) + 1} (${nounIds.length} items)...`);
metadataBatch = await this.storage.getMetadataBatch(nounIds);
const successRate = ((metadataBatch.size / nounIds.length) * 100).toFixed(1);
prodLog.info(`✅ Batch loaded ${metadataBatch.size}/${nounIds.length} metadata objects (${successRate}% success)`);
}
else {
// Fallback to individual calls with strict concurrency control
prodLog.warn(`⚠️ FALLBACK: Storage adapter missing getMetadataBatch - using individual calls with concurrency limit`);
metadataBatch = new Map();
const CONCURRENCY_LIMIT = 3; // Very conservative limit
for (let i = 0; i < nounIds.length; i += CONCURRENCY_LIMIT) {
const batch = nounIds.slice(i, i + CONCURRENCY_LIMIT);
const batchPromises = batch.map(async (id) => {
try {
const metadata = await this.storage.getMetadata(id);
return { id, metadata };
}
catch (error) {
prodLog.debug(`Failed to read metadata for ${id}:`, error);
return { id, metadata: null };
}
});
const batchResults = await Promise.all(batchPromises);
for (const { id, metadata } of batchResults) {
if (metadata) {
metadataBatch.set(id, metadata);
}
}
// Yield between batches to prevent socket exhaustion
await this.yieldToEventLoop();
}
}
// Process the metadata batch
for (const noun of result.items) {
const metadata = metadataBatch.get(noun.id);
if (metadata) {
// Skip flush during rebuild for performance
await this.addToIndex(noun.id, metadata, true);
}
}
// Yield after processing the entire batch
await this.yieldToEventLoop();
totalNounsProcessed += result.items.length;
hasMoreNouns = result.hasMore;
nounOffset += nounLimit;
// Progress logging and event loop yield after each batch
if (totalNounsProcessed % 100 === 0 || !hasMoreNouns) {
prodLog.debug(`📊 Indexed ${totalNounsProcessed} nouns...`);
}
await this.yieldToEventLoop();
}
// Rebuild verb metadata indexes using pagination
let verbOffset = 0;
const verbLimit = 25; // Even smaller batches during initialization to prevent socket exhaustion
let hasMoreVerbs = true;
let totalVerbsProcessed = 0;
while (hasMoreVerbs) {
const result = await this.storage.getVerbs({
pagination: { offset: verbOffset, limit: verbLimit }
});
// CRITICAL FIX: Use batch verb metadata reading to prevent socket exhaustion
const verbIds = result.items.map(verb => verb.id);
let verbMetadataBatch;
if (this.storage.getVerbMetadataBatch) {
// Use batch reading if available (prevents socket exhaustion)
verbMetadataBatch = await this.storage.getVerbMetadataBatch(verbIds);
prodLog.debug(`📦 Batch loaded ${verbMetadataBatch.size}/${verbIds.length} verb metadata objects`);
}
else {
// Fallback to individual calls with strict concurrency control
verbMetadataBatch = new Map();
const CONCURRENCY_LIMIT = 3; // Very conservative limit to prevent socket exhaustion
for (let i = 0; i < verbIds.length; i += CONCURRENCY_LIMIT) {
const batch = verbIds.slice(i, i + CONCURRENCY_LIMIT);
const batchPromises = batch.map(async (id) => {
try {
const metadata = await this.storage.getVerbMetadata(id);
return { id, metadata };
}
catch (error) {
prodLog.debug(`Failed to read verb metadata for ${id}:`, error);
return { id, metadata: null };
}
});
const batchResults = await Promise.all(batchPromises);
for (const { id, metadata } of batchResults) {
if (metadata) {
verbMetadataBatch.set(id, metadata);
}
}
// Yield between batches to prevent socket exhaustion
await this.yieldToEventLoop();
}
}
// Process the verb metadata batch
for (const verb of result.items) {
const metadata = verbMetadataBatch.get(verb.id);
if (metadata) {
// Skip flush during rebuild for performance
await this.addToIndex(verb.id, metadata, true);
}
}
// Yield after processing the entire batch
await this.yieldToEventLoop();
totalVerbsProcessed += result.items.length;
hasMoreVerbs = result.hasMore;
verbOffset += verbLimit;
// Progress logging and event loop yield after each batch
if (totalVerbsProcessed % 100 === 0 || !hasMoreVerbs) {
prodLog.debug(`🔗 Indexed ${totalVerbsProcessed} verbs...`);
}
await this.yieldToEventLoop();
}
// Flush to storage with final yield
prodLog.debug('💾 Flushing metadata index to storage...');
await this.flush();
await this.yieldToEventLoop();
prodLog.info(`✅ Metadata index rebuild completed! Processed ${totalNounsProcessed} nouns and ${totalVerbsProcessed} verbs`);
prodLog.info(`🎯 Initial indexing may show minor socket timeouts - this is expected and doesn't affect data processing`);
}
finally {
this.isRebuilding = false;
}
}
/**
* Load index entry from storage using safe filenames
*/
async loadIndexEntry(key) {
try {
// Extract field and value from key
const [field, value] = key.split(':', 2);
const filename = this.getValueChunkFilename(field, value);
// Load from metadata indexes directory with safe filename
const indexId = `__metadata_index__${filename}`;
const data = await this.storage.getMetadata(indexId);
if (data) {
return {
field: data.field,
value: data.value,
ids: new Set(data.ids || []),
lastUpdated: data.lastUpdated || Date.now()
};
}
}
catch (error) {
// Index entry doesn't exist yet
}
return null;
}
/**
* Save index entry to storage using safe filenames
*/
async saveIndexEntry(key, entry) {
const data = {
field: entry.field,
value: entry.value,
ids: Array.from(entry.ids),
lastUpdated: entry.lastUpdated
};
// Extract field and value from key for safe filename generation
const [field, value] = key.split(':', 2);
const filename = this.getValueChunkFilename(field, value);
// Store metadata indexes with safe filename
const indexId = `__metadata_index__${filename}`;
await this.storage.saveMetadata(indexId, data);
}
/**
* Delete index entry from storage using safe filenames
*/
async deleteIndexEntry(key) {
try {
const [field, value] = key.split(':', 2);
const filename = this.getValueChunkFilename(field, value);
const indexId = `__metadata_index__${filename}`;
await this.storage.saveMetadata(indexId, null);
}
catch (error) {
// Entry might not exist
}
}
}
//# sourceMappingURL=metadataIndex.js.map