2648 lines
114 KiB
JavaScript
2648 lines
114 KiB
JavaScript
|
|
/**
|
||
|
|
* 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<void[]> to Promise<void>
|
||
|
|
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<void[]> to Promise<void>
|
||
|
|
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<number, Set<string>>
|
||
|
|
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<number, Set<string>>
|
||
|
|
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
|