/** * S3-Compatible Storage Adapter * Uses the AWS S3 client to interact with S3-compatible storage services * including Amazon S3, Cloudflare R2, and Google Cloud Storage */ import { BaseStorage, INDEX_DIR, SYSTEM_DIR, STATISTICS_KEY, getDirectoryPath } from '../baseStorage.js'; import { StorageCompatibilityLayer } from '../backwardCompatibility.js'; import { StorageOperationExecutors } from '../../utils/operationUtils.js'; import { BrainyError } from '../../errors/brainyError.js'; import { CacheManager } from '../cacheManager.js'; import { createModuleLogger, prodLog } from '../../utils/logger.js'; import { getGlobalSocketManager } from '../../utils/adaptiveSocketManager.js'; import { getGlobalBackpressure } from '../../utils/adaptiveBackpressure.js'; import { getWriteBuffer } from '../../utils/writeBuffer.js'; import { getCoalescer } from '../../utils/requestCoalescer.js'; // Export R2Storage as an alias for S3CompatibleStorage export { S3CompatibleStorage as R2Storage }; /** * S3-compatible storage adapter for server environments * Uses the AWS S3 client to interact with S3-compatible storage services * including Amazon S3, Cloudflare R2, and Google Cloud Storage * * To use this adapter with Amazon S3, you need to provide: * - region: AWS region (e.g., 'us-east-1') * - credentials: AWS credentials (accessKeyId and secretAccessKey) * - bucketName: S3 bucket name * * To use this adapter with Cloudflare R2, you need to provide: * - accountId: Cloudflare account ID * - accessKeyId: R2 access key ID * - secretAccessKey: R2 secret access key * - bucketName: R2 bucket name * * To use this adapter with Google Cloud Storage, you need to provide: * - region: GCS region (e.g., 'us-central1') * - credentials: GCS credentials (accessKeyId and secretAccessKey) * - endpoint: GCS endpoint (e.g., 'https://storage.googleapis.com') * - bucketName: GCS bucket name */ export class S3CompatibleStorage extends BaseStorage { /** * Initialize the storage adapter * @param options Configuration options for the S3-compatible storage */ constructor(options) { super(); this.s3Client = null; this.useDualWrite = true; // Write to both locations during migration // Statistics caching for better performance this.statisticsCache = null; // Distributed locking for concurrent access control this.lockPrefix = 'locks/'; this.activeLocks = new Set(); // Change log for efficient synchronization this.changeLogPrefix = 'change-log/'; // Backpressure and performance management this.pendingOperations = 0; this.maxConcurrentOperations = 100; this.baseBatchSize = 10; this.currentBatchSize = 10; this.lastMemoryCheck = 0; this.memoryCheckInterval = 5000; // Check every 5 seconds this.consecutiveErrors = 0; this.lastErrorReset = Date.now(); // Adaptive socket manager for automatic optimization this.socketManager = getGlobalSocketManager(); // Adaptive backpressure for automatic flow control this.backpressure = getGlobalBackpressure(); // Write buffers for bulk operations this.nounWriteBuffer = null; this.verbWriteBuffer = null; // Request coalescer for deduplication this.requestCoalescer = null; // High-volume mode detection - MUCH more aggressive this.highVolumeMode = false; this.lastVolumeCheck = 0; this.volumeCheckInterval = 1000; // Check every second, not 5 this.forceHighVolumeMode = false; // Environment variable override // Module logger this.logger = createModuleLogger('S3Storage'); // Node cache to avoid redundant API calls this.nodeCache = new Map(); // Batch update timer ID this.statisticsBatchUpdateTimerId = null; // Flag to indicate if statistics have been modified since last save this.statisticsModified = false; // Time of last statistics flush to storage this.lastStatisticsFlushTime = 0; // Minimum time between statistics flushes (5 seconds) this.MIN_FLUSH_INTERVAL_MS = 5000; // Maximum time to wait before flushing statistics (30 seconds) this.MAX_FLUSH_DELAY_MS = 30000; this.bucketName = options.bucketName; this.region = options.region || 'auto'; this.endpoint = options.endpoint; this.accountId = options.accountId; this.accessKeyId = options.accessKeyId; this.secretAccessKey = options.secretAccessKey; this.sessionToken = options.sessionToken; this.serviceType = options.serviceType || 's3'; this.readOnly = options.readOnly || false; // Initialize operation executors with timeout and retry configuration this.operationExecutors = new StorageOperationExecutors(options.operationConfig); // Set up prefixes for different types of data using new entity-based structure this.nounPrefix = `${getDirectoryPath('noun', 'vector')}/`; this.verbPrefix = `${getDirectoryPath('verb', 'vector')}/`; this.metadataPrefix = `${getDirectoryPath('noun', 'metadata')}/`; // Noun metadata this.verbMetadataPrefix = `${getDirectoryPath('verb', 'metadata')}/`; // Verb metadata this.indexPrefix = `${INDEX_DIR}/`; // Legacy this.systemPrefix = `${SYSTEM_DIR}/`; // New // Initialize cache managers this.nounCacheManager = new CacheManager(options.cacheConfig); this.verbCacheManager = new CacheManager(options.cacheConfig); } /** * Initialize the storage adapter */ async init() { if (this.isInitialized) { return; } try { // Import AWS SDK modules only when needed const { S3Client } = await import('@aws-sdk/client-s3'); // Configure the S3 client based on the service type const clientConfig = { region: this.region, credentials: { accessKeyId: this.accessKeyId, secretAccessKey: this.secretAccessKey }, // Use adaptive socket manager for automatic optimization requestHandler: this.socketManager.getHttpHandler(), // Retry configuration for resilience maxAttempts: 5, // Retry up to 5 times retryMode: 'adaptive' // Use adaptive retry with backoff }; // Add session token if provided if (this.sessionToken) { clientConfig.credentials.sessionToken = this.sessionToken; } // Add endpoint if provided (for R2, GCS, etc.) if (this.endpoint) { clientConfig.endpoint = this.endpoint; } // Special configuration for Cloudflare R2 if (this.serviceType === 'r2' && this.accountId) { clientConfig.endpoint = `https://${this.accountId}.r2.cloudflarestorage.com`; } // Create the S3 client this.s3Client = new S3Client(clientConfig); // Ensure the bucket exists and is accessible const { HeadBucketCommand } = await import('@aws-sdk/client-s3'); await this.s3Client.send(new HeadBucketCommand({ Bucket: this.bucketName })); // Create storage adapter proxies for the cache managers const nounStorageAdapter = { get: async (id) => this.getNoun_internal(id), set: async (id, node) => this.saveNoun_internal(node), delete: async (id) => this.deleteNoun_internal(id), getMany: async (ids) => { const result = new Map(); // Process in batches to avoid overwhelming the S3 API const batchSize = this.getBatchSize(); const batches = []; // Split into batches for (let i = 0; i < ids.length; i += batchSize) { const batch = ids.slice(i, i + batchSize); batches.push(batch); } // Process each batch for (const batch of batches) { const batchResults = await Promise.all(batch.map(async (id) => { const node = await this.getNoun_internal(id); return { id, node }; })); // Add results to map for (const { id, node } of batchResults) { if (node) { result.set(id, node); } } } return result; }, clear: async () => { // No-op for now, as we don't want to clear the entire storage // This would be implemented if needed } }; const verbStorageAdapter = { get: async (id) => this.getVerb_internal(id), set: async (id, edge) => this.saveVerb_internal(edge), delete: async (id) => this.deleteVerb_internal(id), getMany: async (ids) => { const result = new Map(); // Process in batches to avoid overwhelming the S3 API const batchSize = this.getBatchSize(); const batches = []; // Split into batches for (let i = 0; i < ids.length; i += batchSize) { const batch = ids.slice(i, i + batchSize); batches.push(batch); } // Process each batch for (const batch of batches) { const batchResults = await Promise.all(batch.map(async (id) => { const edge = await this.getVerb_internal(id); return { id, edge }; })); // Add results to map for (const { id, edge } of batchResults) { if (edge) { result.set(id, edge); } } } return result; }, clear: async () => { // No-op for now, as we don't want to clear the entire storage // This would be implemented if needed } }; // Set storage adapters for cache managers this.nounCacheManager.setStorageAdapters(nounStorageAdapter, nounStorageAdapter); this.verbCacheManager.setStorageAdapters(verbStorageAdapter, verbStorageAdapter); // Initialize write buffers for high-volume scenarios this.initializeBuffers(); // Initialize request coalescer this.initializeCoalescer(); // Auto-cleanup legacy /index folder on initialization await this.cleanupLegacyIndexFolder(); this.isInitialized = true; this.logger.info(`Initialized ${this.serviceType} storage with bucket ${this.bucketName}`); } catch (error) { this.logger.error(`Failed to initialize ${this.serviceType} storage:`, error); throw new Error(`Failed to initialize ${this.serviceType} storage: ${error}`); } } /** * Override base class method to detect S3-specific throttling errors */ isThrottlingError(error) { // First check base class detection if (super.isThrottlingError(error)) { return true; } // Additional S3-specific checks const message = error.message?.toLowerCase() || ''; return (message.includes('please reduce your request rate') || message.includes('service unavailable') || error.Code === 'SlowDown' || error.Code === 'RequestLimitExceeded' || error.Code === 'ServiceUnavailable'); } /** * Override to add S3-specific logging */ async handleThrottling(error, service) { if (this.isThrottlingError(error)) { prodLog.warn(`๐ŸŒ S3 storage throttling detected (${error.$metadata?.httpStatusCode || error.Code || 'timeout'}). Backing off...`); } // Call base class implementation await super.handleThrottling(error, service); if (!this.isThrottlingError(error) && this.consecutiveThrottleEvents === 0 && !this.throttlingDetected) { prodLog.info('โœ… S3 storage throttling cleared'); } } /** * Smart delay based on current throttling status */ async smartDelay() { if (this.throttlingDetected) { // If currently throttled, add a preventive delay const timeSinceThrottle = Date.now() - this.lastThrottleTime; if (timeSinceThrottle < 60000) { // Within 1 minute of throttling await new Promise(resolve => setTimeout(resolve, Math.min(this.throttlingBackoffMs / 2, 5000))); } } else { // Normal yield await new Promise(resolve => setImmediate(resolve)); } } /** * Auto-cleanup legacy /index folder during initialization * This removes old index data that has been migrated to _system */ async cleanupLegacyIndexFolder() { try { // Check if there are any objects in the legacy index folder const { ListObjectsV2Command } = await import('@aws-sdk/client-s3'); const listResponse = await this.s3Client.send(new ListObjectsV2Command({ Bucket: this.bucketName, Prefix: this.indexPrefix, MaxKeys: 1 // Just check if anything exists })); // If there are objects in the legacy index folder, clean them up if (listResponse.Contents && listResponse.Contents.length > 0) { prodLog.info(`๐Ÿงน Cleaning up legacy /index folder during initialization...`); // Use the existing deleteObjectsWithPrefix function logic const { ListObjectsV2Command, DeleteObjectsCommand } = await import('@aws-sdk/client-s3'); let continuationToken = undefined; let totalDeleted = 0; do { const listResponseBatch = await this.s3Client.send(new ListObjectsV2Command({ Bucket: this.bucketName, Prefix: this.indexPrefix, ContinuationToken: continuationToken })); if (listResponseBatch.Contents && listResponseBatch.Contents.length > 0) { const objectsToDelete = listResponseBatch.Contents.map((obj) => ({ Key: obj.Key })); await this.s3Client.send(new DeleteObjectsCommand({ Bucket: this.bucketName, Delete: { Objects: objectsToDelete } })); totalDeleted += objectsToDelete.length; } continuationToken = listResponseBatch.NextContinuationToken; } while (continuationToken); prodLog.info(`โœ… Cleaned up ${totalDeleted} legacy index objects`); } else { prodLog.debug('No legacy /index folder found - already clean'); } } catch (error) { // Don't fail initialization if cleanup fails prodLog.warn('Failed to cleanup legacy /index folder:', error); } } /** * Initialize write buffers for high-volume scenarios */ initializeBuffers() { const storageId = `${this.serviceType}-${this.bucketName}`; // Create noun write buffer this.nounWriteBuffer = getWriteBuffer(`${storageId}-nouns`, 'noun', async (items) => { // Bulk write nouns to S3 await this.bulkWriteNouns(items); }); // Create verb write buffer this.verbWriteBuffer = getWriteBuffer(`${storageId}-verbs`, 'verb', async (items) => { // Bulk write verbs to S3 await this.bulkWriteVerbs(items); }); } /** * Initialize request coalescer */ initializeCoalescer() { const storageId = `${this.serviceType}-${this.bucketName}`; this.requestCoalescer = getCoalescer(storageId, async (batch) => { // Process coalesced operations await this.processCoalescedBatch(batch); }); } /** * Check if we should enable high-volume mode */ checkVolumeMode() { const now = Date.now(); if (now - this.lastVolumeCheck < this.volumeCheckInterval) { return; } this.lastVolumeCheck = now; // Check environment variable override const envThreshold = process.env.BRAINY_BUFFER_THRESHOLD; const threshold = envThreshold ? parseInt(envThreshold) : 0; // Default to 0 for immediate activation! // Force enable from environment if (process.env.BRAINY_FORCE_BUFFERING === 'true') { this.forceHighVolumeMode = true; } // Get metrics const backpressureStatus = this.backpressure.getStatus(); const socketMetrics = this.socketManager.getMetrics(); // Reasonable high-volume detection - only activate under real load const isTestEnvironment = process.env.NODE_ENV === 'test'; const explicitlyDisabled = process.env.BRAINY_FORCE_BUFFERING === 'false'; // Use reasonable thresholds instead of emergency aggressive ones const reasonableThreshold = Math.max(threshold, 10); // At least 10 pending operations const highSocketUtilization = 0.8; // 80% socket utilization const highRequestRate = 50; // 50 requests per second const significantErrors = 5; // 5 consecutive errors const shouldEnableHighVolume = !isTestEnvironment && // Disable in test environment !explicitlyDisabled && // Allow explicit disabling (this.forceHighVolumeMode || // Environment override backpressureStatus.queueLength >= reasonableThreshold || // High queue backlog socketMetrics.pendingRequests >= reasonableThreshold || // Many pending requests this.pendingOperations >= reasonableThreshold || // Many pending ops socketMetrics.socketUtilization >= highSocketUtilization || // High socket pressure (socketMetrics.requestsPerSecond >= highRequestRate) || // High request rate (this.consecutiveErrors >= significantErrors)); // Significant error pattern if (shouldEnableHighVolume && !this.highVolumeMode) { this.highVolumeMode = true; this.logger.warn(`๐Ÿšจ HIGH-VOLUME MODE ACTIVATED ๐Ÿšจ`); this.logger.warn(` Queue Length: ${backpressureStatus.queueLength}`); this.logger.warn(` Pending Requests: ${socketMetrics.pendingRequests}`); this.logger.warn(` Pending Operations: ${this.pendingOperations}`); this.logger.warn(` Socket Utilization: ${(socketMetrics.socketUtilization * 100).toFixed(1)}%`); this.logger.warn(` Requests/sec: ${socketMetrics.requestsPerSecond}`); this.logger.warn(` Consecutive Errors: ${this.consecutiveErrors}`); this.logger.warn(` Threshold: ${threshold}`); // Adjust buffer parameters for high volume const queueLength = Math.max(backpressureStatus.queueLength, socketMetrics.pendingRequests, 100); if (this.nounWriteBuffer) { this.nounWriteBuffer.adjustForLoad(queueLength); const stats = this.nounWriteBuffer.getStats(); this.logger.warn(` Noun Buffer: ${stats.bufferSize} items, ${stats.totalWrites} total writes`); } if (this.verbWriteBuffer) { this.verbWriteBuffer.adjustForLoad(queueLength); const stats = this.verbWriteBuffer.getStats(); this.logger.warn(` Verb Buffer: ${stats.bufferSize} items, ${stats.totalWrites} total writes`); } if (this.requestCoalescer) { this.requestCoalescer.adjustParameters(queueLength); const sizes = this.requestCoalescer.getQueueSizes(); this.logger.warn(` Coalescer: ${sizes.total} queued operations`); } } else if (!shouldEnableHighVolume && this.highVolumeMode && !this.forceHighVolumeMode) { this.highVolumeMode = false; this.logger.info('โœ… High-volume mode deactivated - load normalized'); } // Log current status every 10 checks when in high-volume mode if (this.highVolumeMode && (now % 10000) < this.volumeCheckInterval) { this.logger.info(`๐Ÿ“Š High-volume mode status: Queue=${backpressureStatus.queueLength}, Pending=${socketMetrics.pendingRequests}, Sockets=${(socketMetrics.socketUtilization * 100).toFixed(1)}%`); } } /** * Bulk write nouns to S3 */ async bulkWriteNouns(items) { const { PutObjectCommand } = await import('@aws-sdk/client-s3'); // Process in parallel with limited concurrency const promises = []; const batchSize = 10; // Process 10 at a time const entries = Array.from(items.entries()); for (let i = 0; i < entries.length; i += batchSize) { const batch = entries.slice(i, i + batchSize); const batchPromise = Promise.all(batch.map(async ([id, node]) => { const serializableNode = { ...node, connections: this.mapToObject(node.connections, (set) => Array.from(set)) }; const key = `${this.nounPrefix}${id}.json`; const body = JSON.stringify(serializableNode, null, 2); await this.s3Client.send(new PutObjectCommand({ Bucket: this.bucketName, Key: key, Body: body, ContentType: 'application/json' })); })).then(() => { }); // Convert Promise to Promise promises.push(batchPromise); } await Promise.all(promises); } /** * Bulk write verbs to S3 */ async bulkWriteVerbs(items) { const { PutObjectCommand } = await import('@aws-sdk/client-s3'); // Process in parallel with limited concurrency const promises = []; const batchSize = 10; const entries = Array.from(items.entries()); for (let i = 0; i < entries.length; i += batchSize) { const batch = entries.slice(i, i + batchSize); const batchPromise = Promise.all(batch.map(async ([id, edge]) => { const serializableEdge = { ...edge, connections: this.mapToObject(edge.connections, (set) => Array.from(set)) }; const key = `${this.verbPrefix}${id}.json`; const body = JSON.stringify(serializableEdge, null, 2); await this.s3Client.send(new PutObjectCommand({ Bucket: this.bucketName, Key: key, Body: body, ContentType: 'application/json' })); })).then(() => { }); // Convert Promise to Promise promises.push(batchPromise); } await Promise.all(promises); } /** * Process coalesced batch of operations */ async processCoalescedBatch(batch) { // Group operations by type const writes = []; const reads = []; const deletes = []; for (const op of batch) { if (op.type === 'write') { writes.push(op); } else if (op.type === 'read') { reads.push(op); } else if (op.type === 'delete') { deletes.push(op); } } // Process in order: deletes, writes, reads if (deletes.length > 0) { await this.processBulkDeletes(deletes); } if (writes.length > 0) { await this.processBulkWrites(writes); } if (reads.length > 0) { await this.processBulkReads(reads); } } /** * Process bulk deletes */ async processBulkDeletes(deletes) { const { DeleteObjectCommand } = await import('@aws-sdk/client-s3'); await Promise.all(deletes.map(async (op) => { await this.s3Client.send(new DeleteObjectCommand({ Bucket: this.bucketName, Key: op.key })); })); } /** * Process bulk writes */ async processBulkWrites(writes) { const { PutObjectCommand } = await import('@aws-sdk/client-s3'); await Promise.all(writes.map(async (op) => { await this.s3Client.send(new PutObjectCommand({ Bucket: this.bucketName, Key: op.key, Body: JSON.stringify(op.data), ContentType: 'application/json' })); })); } /** * Process bulk reads */ async processBulkReads(reads) { const { GetObjectCommand } = await import('@aws-sdk/client-s3'); await Promise.all(reads.map(async (op) => { try { const response = await this.s3Client.send(new GetObjectCommand({ Bucket: this.bucketName, Key: op.key })); if (response.Body) { const data = await response.Body.transformToString(); op.data = JSON.parse(data); } } catch (error) { op.data = null; } })); } /** * Dynamically adjust batch size based on memory pressure and error rates */ adjustBatchSize() { // Let the adaptive socket manager handle batch size optimization this.currentBatchSize = this.socketManager.getBatchSize(); // Get adaptive configuration for concurrent operations const config = this.socketManager.getConfig(); this.maxConcurrentOperations = Math.min(config.maxSockets * 2, 500); // Track metrics for the socket manager const now = Date.now(); // Reset error counter periodically if no recent errors if (now - this.lastErrorReset > 60000 && this.consecutiveErrors > 0) { this.consecutiveErrors = Math.max(0, this.consecutiveErrors - 1); this.lastErrorReset = now; } } /** * Apply backpressure when system is under load */ async applyBackpressure() { // Generate unique request ID for tracking const requestId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; try { // Use adaptive backpressure system await this.backpressure.requestPermission(requestId, 1); // Track with socket manager this.socketManager.trackRequestStart(requestId); this.pendingOperations++; return requestId; } catch (error) { // If backpressure rejects, throw a more informative error const message = error instanceof Error ? error.message : String(error); throw new Error(`System overloaded: ${message}`); } } /** * Release backpressure after operation completes */ releaseBackpressure(success = true, requestId) { this.pendingOperations = Math.max(0, this.pendingOperations - 1); if (requestId) { // Track with socket manager this.socketManager.trackRequestComplete(requestId, success); // Release from backpressure system this.backpressure.releasePermission(requestId, success); } if (!success) { this.consecutiveErrors++; } else if (this.consecutiveErrors > 0) { // Gradually reduce error count on success this.consecutiveErrors = Math.max(0, this.consecutiveErrors - 0.5); } // Adjust batch size based on current conditions this.adjustBatchSize(); } /** * Get current batch size for operations */ getBatchSize() { // Use adaptive socket manager's batch size return this.socketManager.getBatchSize(); } /** * Save a noun to storage (internal implementation) */ async saveNoun_internal(noun) { return this.saveNode(noun); } /** * Save a node to storage */ async saveNode(node) { await this.ensureInitialized(); // ALWAYS check if we should use high-volume mode (critical for detection) this.checkVolumeMode(); // Use write buffer in high-volume mode if (this.highVolumeMode && this.nounWriteBuffer) { this.logger.trace(`๐Ÿ“ BUFFERING: Adding noun ${node.id} to write buffer (high-volume mode active)`); await this.nounWriteBuffer.add(node.id, node); return; } else if (!this.highVolumeMode) { this.logger.trace(`๐Ÿ“ DIRECT WRITE: Saving noun ${node.id} directly (high-volume mode inactive)`); } // Apply backpressure before starting operation const requestId = await this.applyBackpressure(); try { this.logger.trace(`Saving node ${node.id}`); // Convert connections Map to a serializable format const serializableNode = { ...node, connections: this.mapToObject(node.connections, (set) => Array.from(set)) }; // Import the PutObjectCommand only when needed const { PutObjectCommand } = await import('@aws-sdk/client-s3'); const key = `${this.nounPrefix}${node.id}.json`; const body = JSON.stringify(serializableNode, null, 2); this.logger.trace(`Saving to key: ${key}`); // Save the node to S3-compatible storage const result = await this.s3Client.send(new PutObjectCommand({ Bucket: this.bucketName, Key: key, Body: body, ContentType: 'application/json' })); this.logger.debug(`Node ${node.id} saved successfully`); // Log the change for efficient synchronization await this.appendToChangeLog({ timestamp: Date.now(), operation: 'add', // Could be 'update' if we track existing nodes entityType: 'noun', entityId: node.id, data: { vector: node.vector, metadata: node.metadata } }); // Verify the node was saved by trying to retrieve it const { GetObjectCommand } = await import('@aws-sdk/client-s3'); try { const verifyResponse = await this.s3Client.send(new GetObjectCommand({ Bucket: this.bucketName, Key: key })); if (verifyResponse && verifyResponse.Body) { this.logger.trace(`Verified node ${node.id} was saved correctly`); } else { this.logger.warn(`Failed to verify node ${node.id} was saved correctly: no response or body`); } } catch (verifyError) { this.logger.warn(`Failed to verify node ${node.id} was saved correctly:`, verifyError); } // Release backpressure on success this.releaseBackpressure(true, requestId); } catch (error) { // Release backpressure on error this.releaseBackpressure(false, requestId); this.logger.error(`Failed to save node ${node.id}:`, error); throw new Error(`Failed to save node ${node.id}: ${error}`); } } /** * Get a noun from storage (internal implementation) */ async getNoun_internal(id) { return this.getNode(id); } /** * Get a node from storage */ async getNode(id) { await this.ensureInitialized(); try { // Import the GetObjectCommand only when needed const { GetObjectCommand } = await import('@aws-sdk/client-s3'); const key = `${this.nounPrefix}${id}.json`; this.logger.trace(`Getting node ${id} from key: ${key}`); // Try to get the node from the nouns directory const response = await this.s3Client.send(new GetObjectCommand({ Bucket: this.bucketName, Key: key })); // Check if response is null or undefined if (!response || !response.Body) { this.logger.trace(`No node found for ${id}`); return null; } // Convert the response body to a string const bodyContents = await response.Body.transformToString(); this.logger.trace(`Retrieved node body for ${id}`); // Parse the JSON string try { const parsedNode = JSON.parse(bodyContents); this.logger.trace(`Parsed node data for ${id}`); // Ensure the parsed node has the expected properties if (!parsedNode || !parsedNode.id || !parsedNode.vector || !parsedNode.connections) { this.logger.warn(`Invalid node data for ${id}`); return null; } // Convert serialized connections back to Map> const connections = new Map(); for (const [level, nodeIds] of Object.entries(parsedNode.connections)) { connections.set(Number(level), new Set(nodeIds)); } const node = { id: parsedNode.id, vector: parsedNode.vector, connections, level: parsedNode.level || 0 }; this.logger.trace(`Successfully retrieved node ${id}`); return node; } catch (parseError) { this.logger.error(`Failed to parse node data for ${id}:`, parseError); return null; } } catch (error) { // Node not found or other error this.logger.trace(`Node not found for ${id}`); return null; } } /** * Get all nodes from storage * @deprecated This method is deprecated and will be removed in a future version. * It can cause memory issues with large datasets. Use getNodesWithPagination() instead. */ async getAllNodes() { await this.ensureInitialized(); this.logger.warn('getAllNodes() is deprecated and will be removed in a future version. Use getNodesWithPagination() instead.'); try { // Use the paginated method with a large limit to maintain backward compatibility // but warn about potential issues const result = await this.getNodesWithPagination({ limit: 1000, // Reasonable limit to avoid memory issues useCache: true }); if (result.hasMore) { this.logger.warn(`Only returning the first 1000 nodes. There are more nodes available. Use getNodesWithPagination() for proper pagination.`); } return result.nodes; } catch (error) { this.logger.error('Failed to get all nodes:', error); return []; } } /** * Get nodes with pagination * @param options Pagination options * @returns Promise that resolves to a paginated result of nodes */ async getNodesWithPagination(options = {}) { await this.ensureInitialized(); const limit = options.limit || 100; const useCache = options.useCache !== false; try { // Import the ListObjectsV2Command and GetObjectCommand only when needed const { ListObjectsV2Command } = await import('@aws-sdk/client-s3'); // List objects with pagination const listResponse = await this.s3Client.send(new ListObjectsV2Command({ Bucket: this.bucketName, Prefix: this.nounPrefix, MaxKeys: limit, ContinuationToken: options.cursor })); // If listResponse is null/undefined or there are no objects, return an empty result if (!listResponse || !listResponse.Contents || listResponse.Contents.length === 0) { return { nodes: [], hasMore: false }; } // Extract node IDs from the keys const nodeIds = listResponse.Contents .filter((object) => object && object.Key) .map((object) => object.Key.replace(this.nounPrefix, '').replace('.json', '')); // Use the cache manager to get nodes efficiently const nodes = []; if (useCache) { // Get nodes from cache manager const cachedNodes = await this.nounCacheManager.getMany(nodeIds); // Add nodes to result in the same order as nodeIds for (const id of nodeIds) { const node = cachedNodes.get(id); if (node) { nodes.push(node); } } } else { // Get nodes directly from S3 without using cache // Process in smaller batches to reduce memory usage const batchSize = 50; const batches = []; // Split into batches for (let i = 0; i < nodeIds.length; i += batchSize) { const batch = nodeIds.slice(i, i + batchSize); batches.push(batch); } // Process each batch sequentially for (const batch of batches) { const batchNodes = await Promise.all(batch.map(async (id) => { try { return await this.getNoun_internal(id); } catch (error) { return null; } })); // Add non-null nodes to result for (const node of batchNodes) { if (node) { nodes.push(node); } } } } // Determine if there are more nodes const hasMore = !!listResponse.IsTruncated; // Set next cursor if there are more nodes const nextCursor = listResponse.NextContinuationToken; return { nodes, hasMore, nextCursor }; } catch (error) { this.logger.error('Failed to get nodes with pagination:', error); return { nodes: [], hasMore: false }; } } /** * Get nouns by noun type (internal implementation) * @param nounType The noun type to filter by * @returns Promise that resolves to an array of nouns of the specified noun type */ async getNounsByNounType_internal(nounType) { return this.getNodesByNounType(nounType); } /** * Get nodes by noun type * @param nounType The noun type to filter by * @returns Promise that resolves to an array of nodes of the specified noun type */ async getNodesByNounType(nounType) { await this.ensureInitialized(); try { const filteredNodes = []; let hasMore = true; let cursor = undefined; // Use pagination to process nodes in batches while (hasMore) { // Get a batch of nodes const result = await this.getNodesWithPagination({ limit: 100, cursor, useCache: true }); // Filter nodes by noun type using metadata for (const node of result.nodes) { const metadata = await this.getMetadata(node.id); if (metadata && metadata.noun === nounType) { filteredNodes.push(node); } } // Update pagination state hasMore = result.hasMore; cursor = result.nextCursor; // Safety check to prevent infinite loops if (!cursor && hasMore) { this.logger.warn('No cursor returned but hasMore is true, breaking loop'); break; } } return filteredNodes; } catch (error) { this.logger.error(`Failed to get nodes by noun type ${nounType}:`, error); return []; } } /** * Delete a noun from storage (internal implementation) */ async deleteNoun_internal(id) { return this.deleteNode(id); } /** * Delete a node from storage */ async deleteNode(id) { await this.ensureInitialized(); try { // Import the DeleteObjectCommand only when needed const { DeleteObjectCommand } = await import('@aws-sdk/client-s3'); // Delete the node from S3-compatible storage await this.s3Client.send(new DeleteObjectCommand({ Bucket: this.bucketName, Key: `${this.nounPrefix}${id}.json` })); // Log the change for efficient synchronization await this.appendToChangeLog({ timestamp: Date.now(), operation: 'delete', entityType: 'noun', entityId: id }); } catch (error) { this.logger.error(`Failed to delete node ${id}:`, error); throw new Error(`Failed to delete node ${id}: ${error}`); } } /** * Save a verb to storage (internal implementation) */ async saveVerb_internal(verb) { return this.saveEdge(verb); } /** * Save an edge to storage */ async saveEdge(edge) { await this.ensureInitialized(); // ALWAYS check if we should use high-volume mode (critical for detection) this.checkVolumeMode(); // Use write buffer in high-volume mode if (this.highVolumeMode && this.verbWriteBuffer) { this.logger.trace(`๐Ÿ“ BUFFERING: Adding verb ${edge.id} to write buffer (high-volume mode active)`); await this.verbWriteBuffer.add(edge.id, edge); return; } else if (!this.highVolumeMode) { this.logger.trace(`๐Ÿ“ DIRECT WRITE: Saving verb ${edge.id} directly (high-volume mode inactive)`); } // Apply backpressure before starting operation const requestId = await this.applyBackpressure(); try { // Convert connections Map to a serializable format const serializableEdge = { ...edge, connections: this.mapToObject(edge.connections, (set) => Array.from(set)) }; // Import the PutObjectCommand only when needed const { PutObjectCommand } = await import('@aws-sdk/client-s3'); // Save the edge to S3-compatible storage await this.s3Client.send(new PutObjectCommand({ Bucket: this.bucketName, Key: `${this.verbPrefix}${edge.id}.json`, Body: JSON.stringify(serializableEdge, null, 2), ContentType: 'application/json' })); // Log the change for efficient synchronization await this.appendToChangeLog({ timestamp: Date.now(), operation: 'add', // Could be 'update' if we track existing edges entityType: 'verb', entityId: edge.id, data: { vector: edge.vector } }); // Release backpressure on success this.releaseBackpressure(true, requestId); } catch (error) { // Release backpressure on error this.releaseBackpressure(false, requestId); this.logger.error(`Failed to save edge ${edge.id}:`, error); throw new Error(`Failed to save edge ${edge.id}: ${error}`); } } /** * Get a verb from storage (internal implementation) */ async getVerb_internal(id) { return this.getEdge(id); } /** * Get an edge from storage */ async getEdge(id) { await this.ensureInitialized(); try { // Import the GetObjectCommand only when needed const { GetObjectCommand } = await import('@aws-sdk/client-s3'); const key = `${this.verbPrefix}${id}.json`; this.logger.trace(`Getting edge ${id} from key: ${key}`); // Try to get the edge from the verbs directory const response = await this.s3Client.send(new GetObjectCommand({ Bucket: this.bucketName, Key: key })); // Check if response is null or undefined if (!response || !response.Body) { this.logger.trace(`No edge found for ${id}`); return null; } // Convert the response body to a string const bodyContents = await response.Body.transformToString(); this.logger.trace(`Retrieved edge body for ${id}`); // Parse the JSON string try { const parsedEdge = JSON.parse(bodyContents); this.logger.trace(`Parsed edge data for ${id}`); // Ensure the parsed edge has the expected properties if (!parsedEdge || !parsedEdge.id || !parsedEdge.vector || !parsedEdge.connections) { this.logger.warn(`Invalid edge data for ${id}`); return null; } // Convert serialized connections back to Map> const connections = new Map(); for (const [level, nodeIds] of Object.entries(parsedEdge.connections)) { connections.set(Number(level), new Set(nodeIds)); } const edge = { id: parsedEdge.id, vector: parsedEdge.vector, connections }; this.logger.trace(`Successfully retrieved edge ${id}`); return edge; } catch (parseError) { this.logger.error(`Failed to parse edge data for ${id}:`, parseError); return null; } } catch (error) { // Edge not found or other error this.logger.trace(`Edge not found for ${id}`); return null; } } /** * Get all edges from storage * @deprecated This method is deprecated and will be removed in a future version. * It can cause memory issues with large datasets. Use getEdgesWithPagination() instead. */ async getAllEdges() { await this.ensureInitialized(); this.logger.warn('getAllEdges() is deprecated and will be removed in a future version. Use getEdgesWithPagination() instead.'); try { // Use the paginated method with a large limit to maintain backward compatibility // but warn about potential issues const result = await this.getEdgesWithPagination({ limit: 1000, // Reasonable limit to avoid memory issues useCache: true }); if (result.hasMore) { this.logger.warn(`Only returning the first 1000 edges. There are more edges available. Use getEdgesWithPagination() for proper pagination.`); } return result.edges; } catch (error) { this.logger.error('Failed to get all edges:', error); return []; } } /** * Get edges with pagination * @param options Pagination options * @returns Promise that resolves to a paginated result of edges */ async getEdgesWithPagination(options = {}) { await this.ensureInitialized(); const limit = options.limit || 100; const useCache = options.useCache !== false; const filter = options.filter || {}; try { // Import the ListObjectsV2Command only when needed const { ListObjectsV2Command } = await import('@aws-sdk/client-s3'); // List objects with pagination const listResponse = await this.s3Client.send(new ListObjectsV2Command({ Bucket: this.bucketName, Prefix: this.verbPrefix, MaxKeys: limit, ContinuationToken: options.cursor })); // If listResponse is null/undefined or there are no objects, return an empty result if (!listResponse || !listResponse.Contents || listResponse.Contents.length === 0) { return { edges: [], hasMore: false }; } // Extract edge IDs from the keys const edgeIds = listResponse.Contents .filter((object) => object && object.Key) .map((object) => object.Key.replace(this.verbPrefix, '').replace('.json', '')); // Use the cache manager to get edges efficiently const edges = []; if (useCache) { // Get edges from cache manager const cachedEdges = await this.verbCacheManager.getMany(edgeIds); // Add edges to result in the same order as edgeIds for (const id of edgeIds) { const edge = cachedEdges.get(id); if (edge) { // Apply filtering if needed if (this.filterEdge(edge, filter)) { edges.push(edge); } } } } else { // Get edges directly from S3 without using cache // Process in smaller batches to reduce memory usage const batchSize = 50; const batches = []; // Split into batches for (let i = 0; i < edgeIds.length; i += batchSize) { const batch = edgeIds.slice(i, i + batchSize); batches.push(batch); } // Process each batch sequentially for (const batch of batches) { const batchEdges = await Promise.all(batch.map(async (id) => { try { const edge = await this.getVerb_internal(id); // Apply filtering if needed if (edge && this.filterEdge(edge, filter)) { return edge; } return null; } catch (error) { return null; } })); // Add non-null edges to result for (const edge of batchEdges) { if (edge) { edges.push(edge); } } } } // Determine if there are more edges const hasMore = !!listResponse.IsTruncated; // Set next cursor if there are more edges const nextCursor = listResponse.NextContinuationToken; return { edges, hasMore, nextCursor }; } catch (error) { this.logger.error('Failed to get edges with pagination:', error); return { edges: [], hasMore: false }; } } /** * Filter an edge based on filter criteria * @param edge The edge to filter * @param filter The filter criteria * @returns True if the edge matches the filter, false otherwise */ filterEdge(edge, filter) { // HNSWVerb filtering is not supported since metadata is stored separately // This method is deprecated and should not be used with the new storage pattern this.logger.trace('Edge filtering is deprecated and not supported with the new storage pattern'); return true; // Return all edges since filtering requires metadata } /** * Get verbs with pagination * @param options Pagination options * @returns Promise that resolves to a paginated result of verbs */ async getVerbsWithPagination(options = {}) { await this.ensureInitialized(); // Convert filter to edge filter format const edgeFilter = {}; if (options.filter) { // Handle sourceId filter if (options.filter.sourceId) { edgeFilter.sourceId = Array.isArray(options.filter.sourceId) ? options.filter.sourceId[0] : options.filter.sourceId; } // Handle targetId filter if (options.filter.targetId) { edgeFilter.targetId = Array.isArray(options.filter.targetId) ? options.filter.targetId[0] : options.filter.targetId; } // Handle verbType filter if (options.filter.verbType) { edgeFilter.type = Array.isArray(options.filter.verbType) ? options.filter.verbType[0] : options.filter.verbType; } } // Get edges with pagination const result = await this.getEdgesWithPagination({ limit: options.limit, cursor: options.cursor, useCache: true, filter: edgeFilter }); // Convert HNSWVerbs to GraphVerbs by combining with metadata const graphVerbs = []; for (const hnswVerb of result.edges) { const graphVerb = await this.convertHNSWVerbToGraphVerb(hnswVerb); if (graphVerb) { graphVerbs.push(graphVerb); } } // Apply filtering at GraphVerb level since HNSWVerb filtering is not supported let filteredGraphVerbs = graphVerbs; if (options.filter) { filteredGraphVerbs = graphVerbs.filter((graphVerb) => { // Filter by sourceId if (options.filter.sourceId) { const sourceIds = Array.isArray(options.filter.sourceId) ? options.filter.sourceId : [options.filter.sourceId]; if (!sourceIds.includes(graphVerb.sourceId)) { return false; } } // Filter by targetId if (options.filter.targetId) { const targetIds = Array.isArray(options.filter.targetId) ? options.filter.targetId : [options.filter.targetId]; if (!targetIds.includes(graphVerb.targetId)) { return false; } } // Filter by verbType (maps to type field) if (options.filter.verbType) { const verbTypes = Array.isArray(options.filter.verbType) ? options.filter.verbType : [options.filter.verbType]; if (graphVerb.type && !verbTypes.includes(graphVerb.type)) { return false; } } return true; }); } return { items: filteredGraphVerbs, hasMore: result.hasMore, nextCursor: result.nextCursor }; } /** * Get verbs by source (internal implementation) */ async getVerbsBySource_internal(sourceId) { // Use the paginated approach to properly handle HNSWVerb to GraphVerb conversion const result = await this.getVerbsWithPagination({ filter: { sourceId: [sourceId] }, limit: Number.MAX_SAFE_INTEGER // Get all matching results }); return result.items; } /** * Get verbs by target (internal implementation) */ async getVerbsByTarget_internal(targetId) { // Use the paginated approach to properly handle HNSWVerb to GraphVerb conversion const result = await this.getVerbsWithPagination({ filter: { targetId: [targetId] }, limit: Number.MAX_SAFE_INTEGER // Get all matching results }); return result.items; } /** * Get verbs by type (internal implementation) */ async getVerbsByType_internal(type) { // Use the paginated approach to properly handle HNSWVerb to GraphVerb conversion const result = await this.getVerbsWithPagination({ filter: { verbType: [type] }, limit: Number.MAX_SAFE_INTEGER // Get all matching results }); return result.items; } /** * Delete a verb from storage (internal implementation) */ async deleteVerb_internal(id) { return this.deleteEdge(id); } /** * Delete an edge from storage */ async deleteEdge(id) { await this.ensureInitialized(); try { // Import the DeleteObjectCommand only when needed const { DeleteObjectCommand } = await import('@aws-sdk/client-s3'); // Delete the edge from S3-compatible storage await this.s3Client.send(new DeleteObjectCommand({ Bucket: this.bucketName, Key: `${this.verbPrefix}${id}.json` })); // Log the change for efficient synchronization await this.appendToChangeLog({ timestamp: Date.now(), operation: 'delete', entityType: 'verb', entityId: id }); } catch (error) { this.logger.error(`Failed to delete edge ${id}:`, error); throw new Error(`Failed to delete edge ${id}: ${error}`); } } /** * Save metadata to storage */ async saveMetadata(id, metadata) { await this.ensureInitialized(); // Apply backpressure before starting operation const requestId = await this.applyBackpressure(); try { // Import the PutObjectCommand only when needed const { PutObjectCommand } = await import('@aws-sdk/client-s3'); const key = `${this.metadataPrefix}${id}.json`; const body = JSON.stringify(metadata, null, 2); this.logger.trace(`Saving metadata for ${id} to key: ${key}`); // Save the metadata to S3-compatible storage const result = await this.s3Client.send(new PutObjectCommand({ Bucket: this.bucketName, Key: key, Body: body, ContentType: 'application/json' })); this.logger.debug(`Metadata for ${id} saved successfully`); // Log the change for efficient synchronization await this.appendToChangeLog({ timestamp: Date.now(), operation: 'add', // Could be 'update' if we track existing metadata entityType: 'metadata', entityId: id, data: metadata }); // Verify the metadata was saved by trying to retrieve it const { GetObjectCommand } = await import('@aws-sdk/client-s3'); try { const verifyResponse = await this.s3Client.send(new GetObjectCommand({ Bucket: this.bucketName, Key: key })); if (verifyResponse && verifyResponse.Body) { this.logger.trace(`Verified metadata for ${id} was saved correctly`); } else { this.logger.warn(`Failed to verify metadata for ${id} was saved correctly: no response or body`); } } catch (verifyError) { this.logger.warn(`Failed to verify metadata for ${id} was saved correctly:`, verifyError); } // Release backpressure on success this.releaseBackpressure(true, requestId); } catch (error) { // Release backpressure on error this.releaseBackpressure(false, requestId); this.logger.error(`Failed to save metadata for ${id}:`, error); throw new Error(`Failed to save metadata for ${id}: ${error}`); } } /** * Save verb metadata to storage */ async saveVerbMetadata(id, metadata) { await this.ensureInitialized(); try { // Import the PutObjectCommand only when needed const { PutObjectCommand } = await import('@aws-sdk/client-s3'); const key = `${this.verbMetadataPrefix}${id}.json`; const body = JSON.stringify(metadata, null, 2); this.logger.trace(`Saving verb metadata for ${id} to key: ${key}`); // Save the verb metadata to S3-compatible storage const result = await this.s3Client.send(new PutObjectCommand({ Bucket: this.bucketName, Key: key, Body: body, ContentType: 'application/json' })); this.logger.debug(`Verb metadata for ${id} saved successfully`); } catch (error) { this.logger.error(`Failed to save verb metadata for ${id}:`, error); throw new Error(`Failed to save verb metadata for ${id}: ${error}`); } } /** * Get verb metadata from storage */ async getVerbMetadata(id) { await this.ensureInitialized(); try { // Import the GetObjectCommand only when needed const { GetObjectCommand } = await import('@aws-sdk/client-s3'); const key = `${this.verbMetadataPrefix}${id}.json`; this.logger.trace(`Getting verb metadata for ${id} from key: ${key}`); // Try to get the verb metadata const response = await this.s3Client.send(new GetObjectCommand({ Bucket: this.bucketName, Key: key })); // Check if response is null or undefined if (!response || !response.Body) { this.logger.trace(`No verb metadata found for ${id}`); return null; } // Convert the response body to a string const bodyContents = await response.Body.transformToString(); this.logger.trace(`Retrieved verb metadata body for ${id}`); // Parse the JSON string try { const parsedMetadata = JSON.parse(bodyContents); this.logger.trace(`Successfully retrieved verb metadata for ${id}`); return parsedMetadata; } catch (parseError) { this.logger.error(`Failed to parse verb metadata for ${id}:`, parseError); return null; } } catch (error) { // Check if this is a "NoSuchKey" error (object doesn't exist) if (error.name === 'NoSuchKey' || (error.message && (error.message.includes('NoSuchKey') || error.message.includes('not found') || error.message.includes('does not exist')))) { this.logger.trace(`Verb metadata not found for ${id}`); return null; } // For other types of errors, convert to BrainyError for better classification throw BrainyError.fromError(error, `getVerbMetadata(${id})`); } } /** * Save noun metadata to storage */ async saveNounMetadata(id, metadata) { await this.ensureInitialized(); try { // Import the PutObjectCommand only when needed const { PutObjectCommand } = await import('@aws-sdk/client-s3'); const key = `${this.metadataPrefix}${id}.json`; const body = JSON.stringify(metadata, null, 2); this.logger.trace(`Saving noun metadata for ${id} to key: ${key}`); // Save the noun metadata to S3-compatible storage const result = await this.s3Client.send(new PutObjectCommand({ Bucket: this.bucketName, Key: key, Body: body, ContentType: 'application/json' })); this.logger.debug(`Noun metadata for ${id} saved successfully`); } catch (error) { this.logger.error(`Failed to save noun metadata for ${id}:`, error); throw new Error(`Failed to save noun metadata for ${id}: ${error}`); } } /** * Get multiple metadata objects in batches (CRITICAL: Prevents socket exhaustion) * This is the solution to the metadata reading socket exhaustion during initialization */ async getMetadataBatch(ids) { await this.ensureInitialized(); const results = new Map(); const batchSize = Math.min(this.getBatchSize(), 10); // Smaller batches for metadata to prevent socket exhaustion // Process in smaller batches to avoid socket exhaustion for (let i = 0; i < ids.length; i += batchSize) { const batch = ids.slice(i, i + batchSize); // Process batch with concurrency control and enhanced retry logic const batchPromises = batch.map(async (id) => { try { // Add timeout wrapper for individual metadata reads const metadata = await Promise.race([ this.getMetadata(id), new Promise((_, reject) => setTimeout(() => reject(new Error('Metadata read timeout')), 5000) // 5 second timeout ) ]); return { id, metadata }; } catch (error) { // Handle throttling and enhanced error handling await this.handleThrottling(error); const errorMessage = error instanceof Error ? error.message : String(error); if (this.isThrottlingError(error)) { // Throttling errors are already logged in handleThrottling } else if (errorMessage.includes('timeout') || errorMessage.includes('ECONNRESET')) { this.logger.debug(`โฐ Metadata timeout for ${id} (normal during initial indexing):`, errorMessage); } else { this.logger.debug(`Failed to read metadata for ${id}:`, error); } return { id, metadata: null }; } }); const batchResults = await Promise.all(batchPromises); // Track error rates to adjust delays let errorCount = 0; for (const { id, metadata } of batchResults) { if (metadata !== null) { results.set(id, metadata); } else { errorCount++; } } // Smart delay based on error rates and throttling status const errorRate = errorCount / batch.length; if (errorRate > 0.5) { // High error rate - use smart delay with throttling awareness await this.smartDelay(); await new Promise(resolve => setTimeout(resolve, 2000)); // Extra delay for high error rates prodLog.debug(`๐ŸŒ High error rate (${(errorRate * 100).toFixed(1)}%) - adding smart delay`); } else if (errorRate > 0.2) { // Moderate error rate - smart delay await this.smartDelay(); await new Promise(resolve => setTimeout(resolve, 500)); // Modest extra delay prodLog.debug(`โšก Moderate error rate (${(errorRate * 100).toFixed(1)}%) - adding smart delay`); } else { // Low error rate - just smart delay (respects throttling status) await this.smartDelay(); } } return results; } /** * Get multiple verb metadata objects in batches (prevents socket exhaustion) */ async getVerbMetadataBatch(ids) { await this.ensureInitialized(); const results = new Map(); const batchSize = Math.min(this.getBatchSize(), 10); // Smaller batches for metadata to prevent socket exhaustion // Process in smaller batches to avoid socket exhaustion for (let i = 0; i < ids.length; i += batchSize) { const batch = ids.slice(i, i + batchSize); // Process batch with concurrency control const batchPromises = batch.map(async (id) => { try { const metadata = await this.getVerbMetadata(id); return { id, metadata }; } catch (error) { // Don't fail entire batch if one metadata read fails this.logger.debug(`Failed to read verb metadata for ${id}:`, error); return { id, metadata: null }; } }); const batchResults = await Promise.all(batchPromises); // Add results to map for (const { id, metadata } of batchResults) { if (metadata !== null) { results.set(id, metadata); } } // Yield to prevent socket exhaustion between batches await new Promise(resolve => setImmediate(resolve)); } return results; } /** * Get noun metadata from storage */ async getNounMetadata(id) { await this.ensureInitialized(); try { // Import the GetObjectCommand only when needed const { GetObjectCommand } = await import('@aws-sdk/client-s3'); const key = `${this.metadataPrefix}${id}.json`; this.logger.trace(`Getting noun metadata for ${id} from key: ${key}`); // Try to get the noun metadata const response = await this.s3Client.send(new GetObjectCommand({ Bucket: this.bucketName, Key: key })); // Check if response is null or undefined if (!response || !response.Body) { this.logger.trace(`No noun metadata found for ${id}`); return null; } // Convert the response body to a string const bodyContents = await response.Body.transformToString(); this.logger.trace(`Retrieved noun metadata body for ${id}`); // Parse the JSON string try { const parsedMetadata = JSON.parse(bodyContents); this.logger.trace(`Successfully retrieved noun metadata for ${id}`); return parsedMetadata; } catch (parseError) { this.logger.error(`Failed to parse noun metadata for ${id}:`, parseError); return null; } } catch (error) { // Check if this is a "NoSuchKey" error (object doesn't exist) if (error.name === 'NoSuchKey' || (error.message && (error.message.includes('NoSuchKey') || error.message.includes('not found') || error.message.includes('does not exist')))) { this.logger.trace(`Noun metadata not found for ${id}`); return null; } // For other types of errors, convert to BrainyError for better classification throw BrainyError.fromError(error, `getNounMetadata(${id})`); } } /** * Get metadata from storage */ async getMetadata(id) { await this.ensureInitialized(); return this.operationExecutors.executeGet(async () => { try { // Import the GetObjectCommand only when needed const { GetObjectCommand } = await import('@aws-sdk/client-s3'); prodLog.debug(`Getting metadata for ${id} from bucket ${this.bucketName}`); const key = `${this.metadataPrefix}${id}.json`; prodLog.debug(`Looking for metadata at key: ${key}`); // Try to get the metadata from the metadata directory const response = await this.s3Client.send(new GetObjectCommand({ Bucket: this.bucketName, Key: key })); // Check if response is null or undefined (can happen in mock implementations) if (!response || !response.Body) { prodLog.debug(`No metadata found for ${id}`); return null; } // Convert the response body to a string const bodyContents = await response.Body.transformToString(); prodLog.debug(`Retrieved metadata body: ${bodyContents}`); // Parse the JSON string try { const parsedMetadata = JSON.parse(bodyContents); prodLog.debug(`Successfully retrieved metadata for ${id}:`, parsedMetadata); return parsedMetadata; } catch (parseError) { prodLog.error(`Failed to parse metadata for ${id}:`, parseError); return null; } } catch (error) { // Check if this is a "NoSuchKey" error (object doesn't exist) // In AWS SDK, this would be error.name === 'NoSuchKey' // In our mock, we might get different error types if (error.name === 'NoSuchKey' || (error.message && (error.message.includes('NoSuchKey') || error.message.includes('not found') || error.message.includes('does not exist')))) { prodLog.debug(`Metadata not found for ${id}`); return null; } // For other types of errors, convert to BrainyError for better classification throw BrainyError.fromError(error, `getMetadata(${id})`); } }, `getMetadata(${id})`); } /** * Clear all data from storage */ async clear() { await this.ensureInitialized(); try { // Import the ListObjectsV2Command and DeleteObjectCommand only when needed const { ListObjectsV2Command, DeleteObjectCommand } = await import('@aws-sdk/client-s3'); // Helper function to delete all objects with a given prefix const deleteObjectsWithPrefix = async (prefix) => { // List all objects with the given prefix const listResponse = await this.s3Client.send(new ListObjectsV2Command({ Bucket: this.bucketName, Prefix: prefix })); // If there are no objects or Contents is undefined, return if (!listResponse || !listResponse.Contents || listResponse.Contents.length === 0) { return; } // Delete each object for (const object of listResponse.Contents) { if (object && object.Key) { await this.s3Client.send(new DeleteObjectCommand({ Bucket: this.bucketName, Key: object.Key })); } } }; // Delete all objects in the nouns directory await deleteObjectsWithPrefix(this.nounPrefix); // Delete all objects in the verbs directory await deleteObjectsWithPrefix(this.verbPrefix); // Delete all objects in the noun metadata directory await deleteObjectsWithPrefix(this.metadataPrefix); // Delete all objects in the verb metadata directory await deleteObjectsWithPrefix(this.verbMetadataPrefix); // Delete all objects in the index directory await deleteObjectsWithPrefix(this.indexPrefix); // Clear the statistics cache this.statisticsCache = null; this.statisticsModified = false; } catch (error) { prodLog.error('Failed to clear storage:', error); throw new Error(`Failed to clear storage: ${error}`); } } /** * Get information about storage usage and capacity * Optimized version that uses cached statistics instead of expensive full scans */ async getStorageStatus() { await this.ensureInitialized(); try { // Use cached statistics instead of expensive ListObjects scans const stats = await this.getStatisticsData(); let totalSize = 0; let nodeCount = 0; let edgeCount = 0; let metadataCount = 0; if (stats) { // Calculate counts from statistics cache (fast) nodeCount = Object.values(stats.nounCount).reduce((sum, count) => sum + count, 0); edgeCount = Object.values(stats.verbCount).reduce((sum, count) => sum + count, 0); metadataCount = Object.values(stats.metadataCount).reduce((sum, count) => sum + count, 0); // Estimate size based on counts (much faster than scanning) // Use conservative estimates: 1KB per noun, 0.5KB per verb, 0.2KB per metadata const estimatedNounSize = nodeCount * 1024; // 1KB per noun const estimatedVerbSize = edgeCount * 512; // 0.5KB per verb const estimatedMetadataSize = metadataCount * 204; // 0.2KB per metadata const estimatedIndexSize = stats.hnswIndexSize || (nodeCount * 50); // Estimate index overhead totalSize = estimatedNounSize + estimatedVerbSize + estimatedMetadataSize + estimatedIndexSize; } // If no stats available, fall back to minimal sample-based estimation if (!stats || totalSize === 0) { const sampleResult = await this.getSampleBasedStorageEstimate(); totalSize = sampleResult.estimatedSize; nodeCount = sampleResult.nodeCount; edgeCount = sampleResult.edgeCount; metadataCount = sampleResult.metadataCount; } // Ensure we have a minimum size if we have objects if (totalSize === 0 && (nodeCount > 0 || edgeCount > 0 || metadataCount > 0)) { // Setting minimum size for objects totalSize = (nodeCount + edgeCount + metadataCount) * 100; // Arbitrary size per object } // For testing purposes, always ensure we have a positive size if we have any objects if (nodeCount > 0 || edgeCount > 0 || metadataCount > 0) { // Ensuring positive size for storage status totalSize = Math.max(totalSize, 1); } // Use service breakdown from statistics instead of expensive metadata scans const nounTypeCounts = stats?.nounCount || {}; return { type: this.serviceType, used: totalSize, quota: null, // S3-compatible services typically don't provide quota information through the API details: { bucketName: this.bucketName, region: this.region, endpoint: this.endpoint, nodeCount, edgeCount, metadataCount, nounTypes: nounTypeCounts } }; } catch (error) { this.logger.error('Failed to get storage status:', error); return { type: this.serviceType, used: 0, quota: null, details: { error: String(error) } }; } } /** * Get the statistics key for a specific date * @param date The date to get the key for * @returns The statistics key for the specified date */ getStatisticsKeyForDate(date) { const year = date.getUTCFullYear(); const month = String(date.getUTCMonth() + 1).padStart(2, '0'); const day = String(date.getUTCDate()).padStart(2, '0'); return `${this.systemPrefix}${STATISTICS_KEY}_${year}${month}${day}.json`; } /** * Get the current statistics key * @returns The current statistics key */ getCurrentStatisticsKey() { return this.getStatisticsKeyForDate(new Date()); } /** * Get the legacy statistics key (DEPRECATED - /index folder is auto-cleaned) * @returns The legacy statistics key * @deprecated Legacy /index folder is automatically cleaned on initialization */ getLegacyStatisticsKey() { return `${this.indexPrefix}${STATISTICS_KEY}.json`; } /** * Schedule a batch update of statistics */ scheduleBatchUpdate() { // Mark statistics as modified this.statisticsModified = true; // If we're in read-only mode, don't update statistics if (this.readOnly) { this.logger.trace('Skipping statistics update in read-only mode'); return; } // If a timer is already set, don't set another one if (this.statisticsBatchUpdateTimerId !== null) { return; } // Calculate time since last flush const now = Date.now(); const timeSinceLastFlush = now - this.lastStatisticsFlushTime; // If we've recently flushed, wait longer before the next flush const delayMs = timeSinceLastFlush < this.MIN_FLUSH_INTERVAL_MS ? this.MAX_FLUSH_DELAY_MS : this.MIN_FLUSH_INTERVAL_MS; // Schedule the batch update this.statisticsBatchUpdateTimerId = setTimeout(() => { this.flushStatistics(); }, delayMs); } /** * Flush statistics to storage with distributed locking */ async flushStatistics() { // Clear the timer if (this.statisticsBatchUpdateTimerId !== null) { clearTimeout(this.statisticsBatchUpdateTimerId); this.statisticsBatchUpdateTimerId = null; } // If statistics haven't been modified, no need to flush if (!this.statisticsModified || !this.statisticsCache) { return; } const lockKey = 'statistics-flush'; const lockValue = `${Date.now()}_${Math.random()}_${process.pid || 'browser'}`; // Try to acquire lock for statistics update const lockAcquired = await this.acquireLock(lockKey, 15000); // 15 second timeout if (!lockAcquired) { // Another instance is updating statistics, skip this flush // but keep the modified flag so we'll try again later this.logger.debug('Statistics flush skipped - another instance is updating'); return; } try { // Re-check if statistics are still modified after acquiring lock if (!this.statisticsModified || !this.statisticsCache) { return; } // Import the PutObjectCommand and GetObjectCommand only when needed const { PutObjectCommand, GetObjectCommand } = await import('@aws-sdk/client-s3'); // Get the current statistics key const key = this.getCurrentStatisticsKey(); // Read current statistics from storage to merge with local changes let currentStorageStats = null; try { currentStorageStats = await this.tryGetStatisticsFromKey(key); } catch (error) { // If we can't read current stats, proceed with local cache this.logger.warn('Could not read current statistics from storage, using local cache:', error); } // Merge local statistics with storage statistics let mergedStats = this.statisticsCache; if (currentStorageStats) { mergedStats = this.mergeStatistics(currentStorageStats, this.statisticsCache); } const body = JSON.stringify(mergedStats, null, 2); // Save the merged statistics to S3-compatible storage await this.s3Client.send(new PutObjectCommand({ Bucket: this.bucketName, Key: key, Body: body, ContentType: 'application/json', Metadata: { 'last-updated': Date.now().toString(), 'updated-by': process.pid?.toString() || 'browser' } })); // Update the last flush time this.lastStatisticsFlushTime = Date.now(); // Reset the modified flag this.statisticsModified = false; // Update local cache with merged data this.statisticsCache = mergedStats; // During migration period, also update the legacy location // for backward compatibility with older services if (this.useDualWrite) { try { const legacyKey = this.getLegacyStatisticsKey(); await this.s3Client.send(new PutObjectCommand({ Bucket: this.bucketName, Key: legacyKey, Body: body, ContentType: 'application/json', Metadata: { 'migration-note': 'dual-write-for-compatibility', 'schema-version': '2' } })); } catch (error) { StorageCompatibilityLayer.logMigrationEvent('Failed to write statistics to legacy S3 location', { error }); } } } catch (error) { this.logger.error('Failed to flush statistics data:', error); // Mark as still modified so we'll try again later this.statisticsModified = true; // Don't throw the error to avoid disrupting the application } finally { // Always release the lock await this.releaseLock(lockKey, lockValue); } } /** * Merge statistics from storage with local statistics * @param storageStats Statistics from storage * @param localStats Local statistics to merge * @returns Merged statistics data */ mergeStatistics(storageStats, localStats) { // Merge noun counts by taking the maximum of each type const mergedNounCount = { ...storageStats.nounCount }; for (const [type, count] of Object.entries(localStats.nounCount)) { mergedNounCount[type] = Math.max(mergedNounCount[type] || 0, count); } // Merge verb counts by taking the maximum of each type const mergedVerbCount = { ...storageStats.verbCount }; for (const [type, count] of Object.entries(localStats.verbCount)) { mergedVerbCount[type] = Math.max(mergedVerbCount[type] || 0, count); } // Merge metadata counts by taking the maximum of each type const mergedMetadataCount = { ...storageStats.metadataCount }; for (const [type, count] of Object.entries(localStats.metadataCount)) { mergedMetadataCount[type] = Math.max(mergedMetadataCount[type] || 0, count); } return { nounCount: mergedNounCount, verbCount: mergedVerbCount, metadataCount: mergedMetadataCount, hnswIndexSize: Math.max(storageStats.hnswIndexSize, localStats.hnswIndexSize), lastUpdated: new Date(Math.max(new Date(storageStats.lastUpdated).getTime(), new Date(localStats.lastUpdated).getTime())).toISOString() }; } /** * Save statistics data to storage * @param statistics The statistics data to save */ async saveStatisticsData(statistics) { await this.ensureInitialized(); try { // Update the cache with a deep copy to avoid reference issues this.statisticsCache = { nounCount: { ...statistics.nounCount }, verbCount: { ...statistics.verbCount }, metadataCount: { ...statistics.metadataCount }, hnswIndexSize: statistics.hnswIndexSize, lastUpdated: statistics.lastUpdated }; // Schedule a batch update instead of saving immediately this.scheduleBatchUpdate(); } catch (error) { this.logger.error('Failed to save statistics data:', error); throw new Error(`Failed to save statistics data: ${error}`); } } /** * Get statistics data from storage * @returns Promise that resolves to the statistics data or null if not found */ async getStatisticsData() { await this.ensureInitialized(); // Enhanced cache strategy: use cache for 5 minutes to avoid expensive lookups const CACHE_TTL = 5 * 60 * 1000; // 5 minutes const timeSinceFlush = Date.now() - this.lastStatisticsFlushTime; const shouldUseCache = this.statisticsCache && timeSinceFlush < CACHE_TTL; if (shouldUseCache && this.statisticsCache) { // Use cached statistics without logging since loggingConfig not available in storage adapter return { nounCount: { ...this.statisticsCache.nounCount }, verbCount: { ...this.statisticsCache.verbCount }, metadataCount: { ...this.statisticsCache.metadataCount }, hnswIndexSize: this.statisticsCache.hnswIndexSize, lastUpdated: this.statisticsCache.lastUpdated }; } try { // Fetching fresh statistics from storage // Import the GetObjectCommand only when needed const { GetObjectCommand } = await import('@aws-sdk/client-s3'); // Try statistics locations in order of preference (but with timeout) // NOTE: Legacy /index folder is auto-cleaned on init, so only check _system const keys = [ this.getCurrentStatisticsKey(), // Only try yesterday if it's within 2 hours of midnight to avoid unnecessary calls ...(this.shouldTryYesterday() ? [this.getStatisticsKeyForDate(this.getYesterday())] : []) // Legacy fallback removed - /index folder is auto-cleaned on initialization ]; let statistics = null; // Try each key with a timeout to prevent hanging for (const key of keys) { try { statistics = await Promise.race([ this.tryGetStatisticsFromKey(key), new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000) // 2 second timeout per key ) ]); if (statistics) break; // Found statistics, stop trying other keys } catch (error) { // Continue to next key on timeout or error continue; } } // If we found statistics, update the cache if (statistics) { // Update the cache with a deep copy this.statisticsCache = { nounCount: { ...statistics.nounCount }, verbCount: { ...statistics.verbCount }, metadataCount: { ...statistics.metadataCount }, hnswIndexSize: statistics.hnswIndexSize, lastUpdated: statistics.lastUpdated }; } // Successfully loaded statistics from storage return statistics; } catch (error) { this.logger.warn('Error getting statistics data, returning cached or null:', error); // Return cached data if available, even if stale, rather than throwing return this.statisticsCache || null; } } /** * Check if we should try yesterday's statistics file * Only try within 2 hours of midnight to avoid unnecessary calls */ shouldTryYesterday() { const now = new Date(); const hour = now.getHours(); // Only try yesterday's file between 10 PM and 2 AM return hour >= 22 || hour <= 2; } /** * Get yesterday's date */ getYesterday() { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); return yesterday; } /** * Try to get statistics from a specific key * @param key The key to try to get statistics from * @returns The statistics data or null if not found */ async tryGetStatisticsFromKey(key) { try { // Import the GetObjectCommand only when needed const { GetObjectCommand } = await import('@aws-sdk/client-s3'); // Try to get the statistics from the specified key const response = await this.s3Client.send(new GetObjectCommand({ Bucket: this.bucketName, Key: key })); // Check if response is null or undefined if (!response || !response.Body) { return null; } // Convert the response body to a string const bodyContents = await response.Body.transformToString(); // Parse the JSON string return JSON.parse(bodyContents); } catch (error) { // Check if this is a "NoSuchKey" error (object doesn't exist) if (error.name === 'NoSuchKey' || (error.message && (error.message.includes('NoSuchKey') || error.message.includes('not found') || error.message.includes('does not exist')))) { return null; } // For other errors, propagate them throw error; } } /** * Append an entry to the change log for efficient synchronization * @param entry The change log entry to append */ async appendToChangeLog(entry) { try { // Import the PutObjectCommand only when needed const { PutObjectCommand } = await import('@aws-sdk/client-s3'); // Create a unique key for this change log entry const changeLogKey = `${this.changeLogPrefix}${entry.timestamp}-${Math.random().toString(36).substr(2, 9)}.json`; // Add instance ID for tracking const entryWithInstance = { ...entry, instanceId: process.pid?.toString() || 'browser' }; // Save the change log entry await this.s3Client.send(new PutObjectCommand({ Bucket: this.bucketName, Key: changeLogKey, Body: JSON.stringify(entryWithInstance), ContentType: 'application/json', Metadata: { timestamp: entry.timestamp.toString(), operation: entry.operation, 'entity-type': entry.entityType, 'entity-id': entry.entityId } })); } catch (error) { this.logger.warn('Failed to append to change log:', error); // Don't throw error to avoid disrupting main operations } } /** * Get changes from the change log since a specific timestamp * @param sinceTimestamp Timestamp to get changes since * @param maxEntries Maximum number of entries to return (default: 1000) * @returns Array of change log entries */ async getChangesSince(sinceTimestamp, maxEntries = 1000) { await this.ensureInitialized(); try { // Import the ListObjectsV2Command and GetObjectCommand only when needed const { ListObjectsV2Command, GetObjectCommand } = await import('@aws-sdk/client-s3'); // List change log objects const response = await this.s3Client.send(new ListObjectsV2Command({ Bucket: this.bucketName, Prefix: this.changeLogPrefix, MaxKeys: maxEntries * 2 // Get more than needed to filter by timestamp })); if (!response.Contents) { return []; } const changes = []; // Process each change log entry for (const object of response.Contents) { if (!object.Key || changes.length >= maxEntries) break; try { // Get the change log entry const getResponse = await this.s3Client.send(new GetObjectCommand({ Bucket: this.bucketName, Key: object.Key })); if (getResponse.Body) { const entryData = await getResponse.Body.transformToString(); const entry = JSON.parse(entryData); // Only include entries newer than the specified timestamp if (entry.timestamp > sinceTimestamp) { changes.push(entry); } } } catch (error) { this.logger.warn(`Failed to read change log entry ${object.Key}:`, error); // Continue processing other entries } } // Sort by timestamp (oldest first) changes.sort((a, b) => a.timestamp - b.timestamp); return changes.slice(0, maxEntries); } catch (error) { this.logger.error('Failed to get changes from change log:', error); return []; } } /** * Clean up old change log entries to prevent unlimited growth * @param olderThanTimestamp Remove entries older than this timestamp */ async cleanupOldChangeLogs(olderThanTimestamp) { await this.ensureInitialized(); try { // Import the ListObjectsV2Command and DeleteObjectCommand only when needed const { ListObjectsV2Command, DeleteObjectCommand } = await import('@aws-sdk/client-s3'); // List change log objects const response = await this.s3Client.send(new ListObjectsV2Command({ Bucket: this.bucketName, Prefix: this.changeLogPrefix, MaxKeys: 1000 })); if (!response.Contents) { return; } const entriesToDelete = []; // Check each change log entry for age for (const object of response.Contents) { if (!object.Key) continue; // Extract timestamp from the key (format: change-log/timestamp-randomid.json) const keyParts = object.Key.split('/'); if (keyParts.length >= 2) { const filename = keyParts[keyParts.length - 1]; const timestampStr = filename.split('-')[0]; const timestamp = parseInt(timestampStr); if (!isNaN(timestamp) && timestamp < olderThanTimestamp) { entriesToDelete.push(object.Key); } } } // Delete old entries for (const key of entriesToDelete) { try { await this.s3Client.send(new DeleteObjectCommand({ Bucket: this.bucketName, Key: key })); } catch (error) { this.logger.warn(`Failed to delete old change log entry ${key}:`, error); } } if (entriesToDelete.length > 0) { this.logger.debug(`Cleaned up ${entriesToDelete.length} old change log entries`); } } catch (error) { this.logger.warn('Failed to cleanup old change logs:', error); } } /** * Sample-based storage estimation as fallback when statistics unavailable * Much faster than full scans - samples first 50 objects per prefix */ async getSampleBasedStorageEstimate() { try { const { ListObjectsV2Command } = await import('@aws-sdk/client-s3'); const sampleSize = 50; // Sample first 50 objects per prefix const prefixes = [ { prefix: this.nounPrefix, type: 'noun' }, { prefix: this.verbPrefix, type: 'verb' }, { prefix: this.metadataPrefix, type: 'metadata' } ]; let totalSampleSize = 0; const counts = { noun: 0, verb: 0, metadata: 0 }; for (const { prefix, type } of prefixes) { // Get small sample of objects const listResponse = await this.s3Client.send(new ListObjectsV2Command({ Bucket: this.bucketName, Prefix: prefix, MaxKeys: sampleSize })); if (listResponse.Contents && listResponse.Contents.length > 0) { let sampleSize = 0; let sampleCount = listResponse.Contents.length; // Calculate size from first few objects in sample for (let i = 0; i < Math.min(10, sampleCount); i++) { const obj = listResponse.Contents[i]; if (obj && obj.Size) { sampleSize += typeof obj.Size === 'number' ? obj.Size : parseInt(obj.Size.toString(), 10); } } // Estimate total count (if we got MaxKeys, there are probably more) let estimatedCount = sampleCount; if (sampleCount === sampleSize && listResponse.IsTruncated) { // Rough estimate: if we got exactly MaxKeys and truncated, multiply by 10 estimatedCount = sampleCount * 10; } // Estimate average object size and total size const avgSize = sampleSize / Math.min(10, sampleCount) || 512; // Default 512 bytes const estimatedTotalSize = avgSize * estimatedCount; totalSampleSize += estimatedTotalSize; counts[type] = estimatedCount; } } return { estimatedSize: totalSampleSize, nodeCount: counts.noun, edgeCount: counts.verb, metadataCount: counts.metadata }; } catch (error) { // If even sampling fails, return minimal estimates return { estimatedSize: 1024, // 1KB minimum nodeCount: 0, edgeCount: 0, metadataCount: 0 }; } } /** * Acquire a distributed lock for coordinating operations across multiple instances * @param lockKey The key to lock on * @param ttl Time to live for the lock in milliseconds (default: 30 seconds) * @returns Promise that resolves to true if lock was acquired, false otherwise */ async acquireLock(lockKey, ttl = 30000) { await this.ensureInitialized(); const lockObject = `${this.lockPrefix}${lockKey}`; const lockValue = `${Date.now()}_${Math.random()}_${process.pid || 'browser'}`; const expiresAt = Date.now() + ttl; try { // Import the PutObjectCommand and HeadObjectCommand only when needed const { PutObjectCommand, HeadObjectCommand } = await import('@aws-sdk/client-s3'); // First check if lock already exists and is still valid try { const headResponse = await this.s3Client.send(new HeadObjectCommand({ Bucket: this.bucketName, Key: lockObject })); // Check if existing lock has expired const existingExpiresAt = headResponse.Metadata?.['expires-at']; if (existingExpiresAt && parseInt(existingExpiresAt) > Date.now()) { // Lock exists and is still valid return false; } } catch (error) { // If HeadObject fails with NoSuchKey or NotFound, the lock doesn't exist, which is good if (error.name !== 'NoSuchKey' && !error.message?.includes('NoSuchKey') && error.name !== 'NotFound' && !error.message?.includes('NotFound')) { throw error; } } // Try to create the lock await this.s3Client.send(new PutObjectCommand({ Bucket: this.bucketName, Key: lockObject, Body: lockValue, ContentType: 'text/plain', Metadata: { 'expires-at': expiresAt.toString(), 'lock-value': lockValue } })); // Add to active locks for cleanup this.activeLocks.add(lockKey); // Schedule automatic cleanup when lock expires setTimeout(() => { this.releaseLock(lockKey, lockValue).catch((error) => { this.logger.warn(`Failed to auto-release expired lock ${lockKey}:`, error); }); }, ttl); return true; } catch (error) { this.logger.warn(`Failed to acquire lock ${lockKey}:`, error); return false; } } /** * Release a distributed lock * @param lockKey The key to unlock * @param lockValue The value used when acquiring the lock (for verification) * @returns Promise that resolves when lock is released */ async releaseLock(lockKey, lockValue) { await this.ensureInitialized(); const lockObject = `${this.lockPrefix}${lockKey}`; try { // Import the DeleteObjectCommand and GetObjectCommand only when needed const { DeleteObjectCommand, GetObjectCommand } = await import('@aws-sdk/client-s3'); // If lockValue is provided, verify it matches before releasing if (lockValue) { try { const response = await this.s3Client.send(new GetObjectCommand({ Bucket: this.bucketName, Key: lockObject })); const existingValue = await response.Body?.transformToString(); if (existingValue !== lockValue) { // Lock was acquired by someone else, don't release it return; } } catch (error) { // If lock doesn't exist, that's fine if (error.name === 'NoSuchKey' || error.message?.includes('NoSuchKey') || error.name === 'NotFound' || error.message?.includes('NotFound')) { return; } throw error; } } // Delete the lock object await this.s3Client.send(new DeleteObjectCommand({ Bucket: this.bucketName, Key: lockObject })); // Remove from active locks this.activeLocks.delete(lockKey); } catch (error) { this.logger.warn(`Failed to release lock ${lockKey}:`, error); } } /** * Clean up expired locks to prevent lock leakage * This method should be called periodically */ async cleanupExpiredLocks() { await this.ensureInitialized(); try { // Import the ListObjectsV2Command and DeleteObjectCommand only when needed const { ListObjectsV2Command, DeleteObjectCommand, HeadObjectCommand } = await import('@aws-sdk/client-s3'); // List all lock objects const response = await this.s3Client.send(new ListObjectsV2Command({ Bucket: this.bucketName, Prefix: this.lockPrefix, MaxKeys: 1000 })); if (!response.Contents) { return; } const now = Date.now(); const expiredLocks = []; // Check each lock for expiration for (const object of response.Contents) { if (!object.Key) continue; try { const headResponse = await this.s3Client.send(new HeadObjectCommand({ Bucket: this.bucketName, Key: object.Key })); const expiresAt = headResponse.Metadata?.['expires-at']; if (expiresAt && parseInt(expiresAt) < now) { expiredLocks.push(object.Key); } } catch (error) { // If we can't read the lock metadata, consider it expired expiredLocks.push(object.Key); } } // Delete expired locks for (const lockKey of expiredLocks) { try { await this.s3Client.send(new DeleteObjectCommand({ Bucket: this.bucketName, Key: lockKey })); } catch (error) { this.logger.warn(`Failed to delete expired lock ${lockKey}:`, error); } } if (expiredLocks.length > 0) { this.logger.debug(`Cleaned up ${expiredLocks.length} expired locks`); } } catch (error) { this.logger.warn('Failed to cleanup expired locks:', error); } } /** * Get nouns with pagination support * @param options Pagination options * @returns Promise that resolves to a paginated result of nouns */ async getNounsWithPagination(options = {}) { await this.ensureInitialized(); const limit = options.limit || 100; const cursor = options.cursor; // Get paginated nodes const result = await this.getNodesWithPagination({ limit, cursor, useCache: true }); // Apply filters if provided let filteredNodes = result.nodes; if (options.filter) { // Filter by noun type if (options.filter.nounType) { const nounTypes = Array.isArray(options.filter.nounType) ? options.filter.nounType : [options.filter.nounType]; const filteredByType = []; for (const node of filteredNodes) { const metadata = await this.getNounMetadata(node.id); if (metadata && nounTypes.includes(metadata.type || metadata.noun)) { filteredByType.push(node); } } filteredNodes = filteredByType; } // Filter by service if (options.filter.service) { const services = Array.isArray(options.filter.service) ? options.filter.service : [options.filter.service]; const filteredByService = []; for (const node of filteredNodes) { const metadata = await this.getNounMetadata(node.id); if (metadata && services.includes(metadata.service)) { filteredByService.push(node); } } filteredNodes = filteredByService; } // Filter by metadata if (options.filter.metadata) { const metadataFilter = options.filter.metadata; const filteredByMetadata = []; for (const node of filteredNodes) { const metadata = await this.getNounMetadata(node.id); if (metadata) { const matches = Object.entries(metadataFilter).every(([key, value]) => metadata[key] === value); if (matches) { filteredByMetadata.push(node); } } } filteredNodes = filteredByMetadata; } } return { items: filteredNodes, hasMore: result.hasMore, nextCursor: result.nextCursor }; } } //# sourceMappingURL=s3CompatibleStorage.js.map