Major release: Enterprise-scale cost optimization and performance features Features: - Cloud storage lifecycle management (GCS Autoclass, AWS Intelligent-Tiering, Azure) - Batch operations (1000x faster deletions: 533 entities/sec vs 0.5/sec) - FileSystem compression (60-80% space savings with gzip) - OPFS quota monitoring for browser storage - Enhanced CLI system (47 commands, 9 storage management commands) Cost Impact: - Up to 96% storage cost savings - $138,000/year → $5,940/year @ 500TB scale Breaking Changes: NONE - 100% backward compatible - All new features are opt-in - No migration required
2328 lines
75 KiB
TypeScript
2328 lines
75 KiB
TypeScript
/**
|
|
* Azure Blob Storage Adapter (Native)
|
|
* Uses the native @azure/storage-blob library for optimal performance and authentication
|
|
*
|
|
* Supports multiple authentication methods:
|
|
* 1. DefaultAzureCredential (Managed Identity) - Automatic in Azure environments
|
|
* 2. Connection String
|
|
* 3. Storage Account Key
|
|
* 4. SAS Token
|
|
* 5. Azure AD (OAuth2) via DefaultAzureCredential
|
|
*
|
|
* v4.0.0: Fully compatible with metadata/vector separation architecture
|
|
*/
|
|
|
|
import {
|
|
GraphVerb,
|
|
HNSWNoun,
|
|
HNSWVerb,
|
|
NounMetadata,
|
|
VerbMetadata,
|
|
HNSWNounWithMetadata,
|
|
HNSWVerbWithMetadata,
|
|
StatisticsData
|
|
} from '../../coreTypes.js'
|
|
import {
|
|
BaseStorage,
|
|
NOUNS_DIR,
|
|
VERBS_DIR,
|
|
METADATA_DIR,
|
|
INDEX_DIR,
|
|
SYSTEM_DIR,
|
|
STATISTICS_KEY,
|
|
getDirectoryPath
|
|
} from '../baseStorage.js'
|
|
import { BrainyError } from '../../errors/brainyError.js'
|
|
import { CacheManager } from '../cacheManager.js'
|
|
import { createModuleLogger, prodLog } from '../../utils/logger.js'
|
|
import { getGlobalBackpressure } from '../../utils/adaptiveBackpressure.js'
|
|
import { getWriteBuffer, WriteBuffer } from '../../utils/writeBuffer.js'
|
|
import { getCoalescer, RequestCoalescer } from '../../utils/requestCoalescer.js'
|
|
import { getShardIdFromUuid, getAllShardIds, getShardIdByIndex, TOTAL_SHARDS } from '../sharding.js'
|
|
|
|
// Type aliases for better readability
|
|
type HNSWNode = HNSWNoun
|
|
type Edge = HNSWVerb
|
|
|
|
// Azure SDK types - dynamically imported to avoid issues in browser environments
|
|
type BlobServiceClient = any
|
|
type ContainerClient = any
|
|
type BlockBlobClient = any
|
|
|
|
// Azure Blob Storage API limits
|
|
const MAX_AZURE_PAGE_SIZE = 5000
|
|
|
|
/**
|
|
* Native Azure Blob Storage adapter for server environments
|
|
* Uses the @azure/storage-blob library with DefaultAzureCredential
|
|
*
|
|
* Authentication priority:
|
|
* 1. DefaultAzureCredential (Managed Identity) - if no credentials provided
|
|
* 2. Connection String - if connectionString provided
|
|
* 3. Storage Account Key - if accountName + accountKey provided
|
|
* 4. SAS Token - if accountName + sasToken provided
|
|
*/
|
|
export class AzureBlobStorage extends BaseStorage {
|
|
private blobServiceClient: BlobServiceClient | null = null
|
|
private containerClient: ContainerClient | null = null
|
|
private containerName: string
|
|
private accountName?: string
|
|
private accountKey?: string
|
|
private connectionString?: string
|
|
private sasToken?: string
|
|
|
|
// Prefixes for different types of data
|
|
private nounPrefix: string
|
|
private verbPrefix: string
|
|
private metadataPrefix: string // Noun metadata
|
|
private verbMetadataPrefix: string // Verb metadata
|
|
private systemPrefix: string // System data (_system)
|
|
|
|
// Statistics caching for better performance
|
|
protected statisticsCache: StatisticsData | null = null
|
|
|
|
// Backpressure and performance management
|
|
private pendingOperations: number = 0
|
|
private consecutiveErrors: number = 0
|
|
private lastErrorReset: number = Date.now()
|
|
|
|
// Adaptive backpressure for automatic flow control
|
|
private backpressure = getGlobalBackpressure()
|
|
|
|
// Write buffers for bulk operations
|
|
private nounWriteBuffer: WriteBuffer<HNSWNode> | null = null
|
|
private verbWriteBuffer: WriteBuffer<Edge> | null = null
|
|
|
|
// Request coalescer for deduplication
|
|
private requestCoalescer: RequestCoalescer | null = null
|
|
|
|
// High-volume mode detection
|
|
private highVolumeMode = false
|
|
private lastVolumeCheck = 0
|
|
private volumeCheckInterval = 1000 // Check every second
|
|
private forceHighVolumeMode = false // Environment variable override
|
|
|
|
// Multi-level cache manager for efficient data access
|
|
private nounCacheManager: CacheManager<HNSWNode>
|
|
private verbCacheManager: CacheManager<Edge>
|
|
|
|
// Module logger
|
|
private logger = createModuleLogger('AzureBlobStorage')
|
|
|
|
/**
|
|
* Initialize the storage adapter
|
|
* @param options Configuration options for Azure Blob Storage
|
|
*/
|
|
constructor(options: {
|
|
containerName: string
|
|
|
|
// Connection String authentication (highest priority)
|
|
connectionString?: string
|
|
|
|
// Account + Key authentication
|
|
accountName?: string
|
|
accountKey?: string
|
|
|
|
// SAS Token authentication
|
|
sasToken?: string
|
|
|
|
// Cache and operation configuration
|
|
cacheConfig?: {
|
|
hotCacheMaxSize?: number
|
|
hotCacheEvictionThreshold?: number
|
|
warmCacheTTL?: number
|
|
}
|
|
readOnly?: boolean
|
|
}) {
|
|
super()
|
|
this.containerName = options.containerName
|
|
this.connectionString = options.connectionString
|
|
this.accountName = options.accountName
|
|
this.accountKey = options.accountKey
|
|
this.sasToken = options.sasToken
|
|
this.readOnly = options.readOnly || false
|
|
|
|
// Set up prefixes for different types of data using 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.systemPrefix = `${SYSTEM_DIR}/` // System data
|
|
|
|
// Initialize cache managers
|
|
this.nounCacheManager = new CacheManager<HNSWNode>(options.cacheConfig)
|
|
this.verbCacheManager = new CacheManager<Edge>(options.cacheConfig)
|
|
|
|
// Check for high-volume mode override
|
|
if (typeof process !== 'undefined' && process.env?.BRAINY_FORCE_HIGH_VOLUME === 'true') {
|
|
this.forceHighVolumeMode = true
|
|
this.highVolumeMode = true
|
|
prodLog.info('🚀 High-volume mode FORCED via BRAINY_FORCE_HIGH_VOLUME environment variable')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize the storage adapter
|
|
*/
|
|
public async init(): Promise<void> {
|
|
if (this.isInitialized) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
// Import Azure Storage SDK only when needed
|
|
const { BlobServiceClient } = await import('@azure/storage-blob')
|
|
|
|
// Configure the Azure Blob Storage client based on available credentials
|
|
// Priority 1: Connection String
|
|
if (this.connectionString) {
|
|
this.blobServiceClient = BlobServiceClient.fromConnectionString(this.connectionString)
|
|
prodLog.info('🔐 Azure: Using Connection String')
|
|
}
|
|
// Priority 2: Account Name + Key
|
|
else if (this.accountName && this.accountKey) {
|
|
const { StorageSharedKeyCredential } = await import('@azure/storage-blob')
|
|
const sharedKeyCredential = new StorageSharedKeyCredential(
|
|
this.accountName,
|
|
this.accountKey
|
|
)
|
|
this.blobServiceClient = new BlobServiceClient(
|
|
`https://${this.accountName}.blob.core.windows.net`,
|
|
sharedKeyCredential
|
|
)
|
|
prodLog.info('🔐 Azure: Using Account Key')
|
|
}
|
|
// Priority 3: SAS Token
|
|
else if (this.accountName && this.sasToken) {
|
|
this.blobServiceClient = new BlobServiceClient(
|
|
`https://${this.accountName}.blob.core.windows.net${this.sasToken}`
|
|
)
|
|
prodLog.info('🔐 Azure: Using SAS Token')
|
|
}
|
|
// Priority 4: DefaultAzureCredential (Managed Identity)
|
|
else if (this.accountName) {
|
|
const { DefaultAzureCredential } = await import('@azure/identity')
|
|
const credential = new DefaultAzureCredential()
|
|
this.blobServiceClient = new BlobServiceClient(
|
|
`https://${this.accountName}.blob.core.windows.net`,
|
|
credential
|
|
)
|
|
prodLog.info('🔐 Azure: Using DefaultAzureCredential (Managed Identity)')
|
|
}
|
|
else {
|
|
throw new Error('Azure Blob Storage requires either connectionString, accountName+accountKey, accountName+sasToken, or accountName (for Managed Identity)')
|
|
}
|
|
|
|
// Get reference to the container
|
|
this.containerClient = this.blobServiceClient.getContainerClient(this.containerName)
|
|
|
|
// Create container if it doesn't exist
|
|
const exists = await this.containerClient.exists()
|
|
if (!exists) {
|
|
await this.containerClient.create()
|
|
prodLog.info(`✅ Created Azure container: ${this.containerName}`)
|
|
} else {
|
|
prodLog.info(`✅ Connected to Azure container: ${this.containerName}`)
|
|
}
|
|
|
|
// Initialize write buffers for high-volume mode
|
|
const storageId = `azure-${this.containerName}`
|
|
this.nounWriteBuffer = getWriteBuffer<HNSWNode>(
|
|
`${storageId}-nouns`,
|
|
'noun',
|
|
async (items) => {
|
|
await this.flushNounBuffer(items)
|
|
}
|
|
)
|
|
|
|
this.verbWriteBuffer = getWriteBuffer<Edge>(
|
|
`${storageId}-verbs`,
|
|
'verb',
|
|
async (items) => {
|
|
await this.flushVerbBuffer(items)
|
|
}
|
|
)
|
|
|
|
// Initialize request coalescer for deduplication
|
|
this.requestCoalescer = getCoalescer(
|
|
storageId,
|
|
async (batch) => {
|
|
// Process coalesced operations (placeholder for future optimization)
|
|
this.logger.trace(`Processing coalesced batch: ${batch.length} items`)
|
|
}
|
|
)
|
|
|
|
// Initialize counts from storage
|
|
await this.initializeCounts()
|
|
|
|
// Clear any stale cache entries from previous runs
|
|
prodLog.info('🧹 Clearing cache from previous run to prevent cache poisoning')
|
|
this.nounCacheManager.clear()
|
|
this.verbCacheManager.clear()
|
|
prodLog.info('✅ Cache cleared - starting fresh')
|
|
|
|
this.isInitialized = true
|
|
} catch (error) {
|
|
this.logger.error('Failed to initialize Azure Blob Storage:', error)
|
|
throw new Error(`Failed to initialize Azure Blob Storage: ${error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the Azure blob name for a noun using UUID-based sharding
|
|
*
|
|
* Uses first 2 hex characters of UUID for consistent sharding.
|
|
* Path format: entities/nouns/vectors/{shardId}/{uuid}.json
|
|
*
|
|
* @example
|
|
* getNounKey('ab123456-1234-5678-9abc-def012345678')
|
|
* // returns 'entities/nouns/vectors/ab/ab123456-1234-5678-9abc-def012345678.json'
|
|
*/
|
|
private getNounKey(id: string): string {
|
|
const shardId = getShardIdFromUuid(id)
|
|
return `${this.nounPrefix}${shardId}/${id}.json`
|
|
}
|
|
|
|
/**
|
|
* Get the Azure blob name for a verb using UUID-based sharding
|
|
*
|
|
* Uses first 2 hex characters of UUID for consistent sharding.
|
|
* Path format: entities/verbs/vectors/{shardId}/{uuid}.json
|
|
*
|
|
* @example
|
|
* getVerbKey('cd987654-4321-8765-cba9-fed543210987')
|
|
* // returns 'entities/verbs/vectors/cd/cd987654-4321-8765-cba9-fed543210987.json'
|
|
*/
|
|
private getVerbKey(id: string): string {
|
|
const shardId = getShardIdFromUuid(id)
|
|
return `${this.verbPrefix}${shardId}/${id}.json`
|
|
}
|
|
|
|
/**
|
|
* Override base class method to detect Azure-specific throttling errors
|
|
*/
|
|
protected isThrottlingError(error: any): boolean {
|
|
// First check base class detection
|
|
if (super.isThrottlingError(error)) {
|
|
return true
|
|
}
|
|
|
|
// Azure-specific throttling detection
|
|
const statusCode = error.statusCode || error.code
|
|
const message = error.message?.toLowerCase() || ''
|
|
|
|
return (
|
|
statusCode === 429 || // Too Many Requests
|
|
statusCode === 503 || // Service Unavailable
|
|
statusCode === 'ServerBusy' ||
|
|
statusCode === 'IngressOverLimit' ||
|
|
statusCode === 'EgressOverLimit' ||
|
|
message.includes('throttl') ||
|
|
message.includes('rate limit') ||
|
|
message.includes('too many requests')
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Override base class to enable smart batching for cloud storage
|
|
*
|
|
* Azure Blob Storage is cloud storage with network latency (~50ms per write).
|
|
* Smart batching reduces writes from 1000 ops → 100 batches.
|
|
*
|
|
* @returns true (Azure is cloud storage)
|
|
*/
|
|
protected isCloudStorage(): boolean {
|
|
return true // Azure benefits from batching
|
|
}
|
|
|
|
/**
|
|
* Apply backpressure before starting an operation
|
|
* @returns Request ID for tracking
|
|
*/
|
|
private async applyBackpressure(): Promise<string> {
|
|
const requestId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
|
await this.backpressure.requestPermission(requestId, 1)
|
|
this.pendingOperations++
|
|
return requestId
|
|
}
|
|
|
|
/**
|
|
* Release backpressure after completing an operation
|
|
* @param success Whether the operation succeeded
|
|
* @param requestId Request ID from applyBackpressure()
|
|
*/
|
|
private releaseBackpressure(success: boolean = true, requestId?: string): void {
|
|
this.pendingOperations = Math.max(0, this.pendingOperations - 1)
|
|
if (requestId) {
|
|
this.backpressure.releasePermission(requestId, success)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if high-volume mode should be enabled
|
|
*/
|
|
private checkVolumeMode(): void {
|
|
if (this.forceHighVolumeMode) {
|
|
return // Already forced on
|
|
}
|
|
|
|
const now = Date.now()
|
|
if (now - this.lastVolumeCheck < this.volumeCheckInterval) {
|
|
return
|
|
}
|
|
|
|
this.lastVolumeCheck = now
|
|
|
|
// Enable high-volume mode if we have many pending operations
|
|
const shouldEnable = this.pendingOperations > 20
|
|
|
|
if (shouldEnable && !this.highVolumeMode) {
|
|
this.highVolumeMode = true
|
|
prodLog.info('🚀 High-volume mode ENABLED (pending operations:', this.pendingOperations, ')')
|
|
} else if (!shouldEnable && this.highVolumeMode && !this.forceHighVolumeMode) {
|
|
this.highVolumeMode = false
|
|
prodLog.info('🐌 High-volume mode DISABLED (pending operations:', this.pendingOperations, ')')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Flush noun buffer to Azure
|
|
*/
|
|
private async flushNounBuffer(items: Map<string, HNSWNode>): Promise<void> {
|
|
const writes = Array.from(items.values()).map(async (noun) => {
|
|
try {
|
|
await this.saveNodeDirect(noun)
|
|
} catch (error) {
|
|
this.logger.error(`Failed to flush noun ${noun.id}:`, error)
|
|
}
|
|
})
|
|
|
|
await Promise.all(writes)
|
|
}
|
|
|
|
/**
|
|
* Flush verb buffer to Azure
|
|
*/
|
|
private async flushVerbBuffer(items: Map<string, Edge>): Promise<void> {
|
|
const writes = Array.from(items.values()).map(async (verb) => {
|
|
try {
|
|
await this.saveEdgeDirect(verb)
|
|
} catch (error) {
|
|
this.logger.error(`Failed to flush verb ${verb.id}:`, error)
|
|
}
|
|
})
|
|
|
|
await Promise.all(writes)
|
|
}
|
|
|
|
/**
|
|
* Save a noun to storage (internal implementation)
|
|
*/
|
|
protected async saveNoun_internal(noun: HNSWNoun): Promise<void> {
|
|
return this.saveNode(noun)
|
|
}
|
|
|
|
/**
|
|
* Save a node to storage
|
|
*/
|
|
protected async saveNode(node: HNSWNode): Promise<void> {
|
|
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)`)
|
|
}
|
|
|
|
// Direct write in normal mode
|
|
await this.saveNodeDirect(node)
|
|
}
|
|
|
|
/**
|
|
* Save a node directly to Azure (bypass buffer)
|
|
*/
|
|
private async saveNodeDirect(node: HNSWNode): Promise<void> {
|
|
// 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
|
|
// CRITICAL: Only save lightweight vector data (no metadata)
|
|
// Metadata is saved separately via saveNounMetadata() (2-file system)
|
|
const serializableNode = {
|
|
id: node.id,
|
|
vector: node.vector,
|
|
connections: Object.fromEntries(
|
|
Array.from(node.connections.entries()).map(([level, nounIds]) => [
|
|
level,
|
|
Array.from(nounIds)
|
|
])
|
|
),
|
|
level: node.level || 0
|
|
// NO metadata field - saved separately for scalability
|
|
}
|
|
|
|
// Get the Azure blob name with UUID-based sharding
|
|
const blobName = this.getNounKey(node.id)
|
|
|
|
// Save to Azure Blob Storage
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(blobName)
|
|
await blockBlobClient.upload(
|
|
JSON.stringify(serializableNode, null, 2),
|
|
JSON.stringify(serializableNode).length,
|
|
{
|
|
blobHTTPHeaders: { blobContentType: 'application/json' }
|
|
}
|
|
)
|
|
|
|
// CRITICAL FIX: Only cache nodes with non-empty vectors
|
|
// This prevents cache pollution from HNSW's lazy-loading nodes (vector: [])
|
|
if (node.vector && Array.isArray(node.vector) && node.vector.length > 0) {
|
|
this.nounCacheManager.set(node.id, node)
|
|
}
|
|
// Note: Empty vectors are intentional during HNSW lazy mode - not logged
|
|
|
|
// Increment noun count
|
|
const metadata = await this.getNounMetadata(node.id)
|
|
if (metadata && metadata.type) {
|
|
await this.incrementEntityCountSafe(metadata.type as string)
|
|
}
|
|
|
|
this.logger.trace(`Node ${node.id} saved successfully`)
|
|
this.releaseBackpressure(true, requestId)
|
|
} catch (error: any) {
|
|
this.releaseBackpressure(false, requestId)
|
|
|
|
// Handle throttling
|
|
if (this.isThrottlingError(error)) {
|
|
await this.handleThrottling(error)
|
|
throw error // Re-throw for retry at higher level
|
|
}
|
|
|
|
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)
|
|
* v4.0.0: Returns ONLY vector data (no metadata field)
|
|
* Base class combines with metadata via getNoun() -> HNSWNounWithMetadata
|
|
*/
|
|
protected async getNoun_internal(id: string): Promise<HNSWNoun | null> {
|
|
// v4.0.0: Return ONLY vector data (no metadata field)
|
|
const node = await this.getNode(id)
|
|
if (!node) {
|
|
return null
|
|
}
|
|
|
|
// Return pure vector structure
|
|
return node
|
|
}
|
|
|
|
/**
|
|
* Get a node from storage
|
|
*/
|
|
protected async getNode(id: string): Promise<HNSWNode | null> {
|
|
await this.ensureInitialized()
|
|
|
|
// Check cache first
|
|
const cached: HNSWNode | null = await this.nounCacheManager.get(id)
|
|
|
|
// Validate cached object before returning
|
|
if (cached !== undefined && cached !== null) {
|
|
// Validate cached object has required fields (including non-empty vector!)
|
|
if (!cached.id || !cached.vector || !Array.isArray(cached.vector) || cached.vector.length === 0) {
|
|
// Invalid cache detected - log and auto-recover
|
|
prodLog.warn(`[Azure] Invalid cached object for ${id.substring(0, 8)} (${
|
|
!cached.id ? 'missing id' :
|
|
!cached.vector ? 'missing vector' :
|
|
!Array.isArray(cached.vector) ? 'vector not array' :
|
|
'empty vector'
|
|
}) - removing from cache and reloading`)
|
|
this.nounCacheManager.delete(id)
|
|
// Fall through to load from Azure
|
|
} else {
|
|
// Valid cache hit
|
|
this.logger.trace(`Cache hit for noun ${id}`)
|
|
return cached
|
|
}
|
|
} else if (cached === null) {
|
|
prodLog.warn(`[Azure] Cache contains null for ${id.substring(0, 8)} - reloading from storage`)
|
|
}
|
|
|
|
// Apply backpressure
|
|
const requestId = await this.applyBackpressure()
|
|
|
|
try {
|
|
this.logger.trace(`Getting node ${id}`)
|
|
|
|
// Get the Azure blob name with UUID-based sharding
|
|
const blobName = this.getNounKey(id)
|
|
|
|
// Download from Azure Blob Storage
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(blobName)
|
|
const downloadResponse = await blockBlobClient.download(0)
|
|
const downloaded = await this.streamToBuffer(downloadResponse.readableStreamBody!)
|
|
|
|
// Parse JSON
|
|
const data = JSON.parse(downloaded.toString())
|
|
|
|
// Convert serialized connections back to Map<number, Set<string>>
|
|
const connections = new Map<number, Set<string>>()
|
|
for (const [level, nounIds] of Object.entries(data.connections || {})) {
|
|
connections.set(Number(level), new Set(nounIds as string[]))
|
|
}
|
|
|
|
// CRITICAL: Only return lightweight vector data (no metadata)
|
|
// Metadata is retrieved separately via getNounMetadata() (2-file system)
|
|
const node: HNSWNode = {
|
|
id: data.id,
|
|
vector: data.vector,
|
|
connections,
|
|
level: data.level || 0
|
|
// NO metadata field - retrieved separately for scalability
|
|
}
|
|
|
|
// CRITICAL FIX: Only cache valid nodes with non-empty vectors (never cache null or empty)
|
|
if (node && node.id && node.vector && Array.isArray(node.vector) && node.vector.length > 0) {
|
|
this.nounCacheManager.set(id, node)
|
|
} else {
|
|
prodLog.warn(`[Azure] Not caching invalid node ${id.substring(0, 8)} (missing id/vector or empty vector)`)
|
|
}
|
|
|
|
this.logger.trace(`Successfully retrieved node ${id}`)
|
|
this.releaseBackpressure(true, requestId)
|
|
return node
|
|
} catch (error: any) {
|
|
this.releaseBackpressure(false, requestId)
|
|
|
|
// Check if this is a "not found" error
|
|
if (error.statusCode === 404 || error.code === 'BlobNotFound') {
|
|
this.logger.trace(`Node not found: ${id}`)
|
|
// CRITICAL FIX: Do NOT cache null values
|
|
return null
|
|
}
|
|
|
|
// Handle throttling
|
|
if (this.isThrottlingError(error)) {
|
|
await this.handleThrottling(error)
|
|
throw error
|
|
}
|
|
|
|
// All other errors should throw, not return null
|
|
this.logger.error(`Failed to get node ${id}:`, error)
|
|
throw BrainyError.fromError(error, `getNoun(${id})`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a noun from storage (internal implementation)
|
|
*/
|
|
protected async deleteNoun_internal(id: string): Promise<void> {
|
|
await this.ensureInitialized()
|
|
|
|
const requestId = await this.applyBackpressure()
|
|
|
|
try {
|
|
this.logger.trace(`Deleting noun ${id}`)
|
|
|
|
// Get the Azure blob name
|
|
const blobName = this.getNounKey(id)
|
|
|
|
// Delete from Azure
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(blobName)
|
|
await blockBlobClient.delete()
|
|
|
|
// Remove from cache
|
|
this.nounCacheManager.delete(id)
|
|
|
|
// Decrement noun count
|
|
const metadata = await this.getNounMetadata(id)
|
|
if (metadata && metadata.type) {
|
|
await this.decrementEntityCountSafe(metadata.type as string)
|
|
}
|
|
|
|
this.logger.trace(`Noun ${id} deleted successfully`)
|
|
this.releaseBackpressure(true, requestId)
|
|
} catch (error: any) {
|
|
this.releaseBackpressure(false, requestId)
|
|
|
|
if (error.statusCode === 404 || error.code === 'BlobNotFound') {
|
|
// Already deleted
|
|
this.logger.trace(`Noun ${id} not found (already deleted)`)
|
|
return
|
|
}
|
|
|
|
// Handle throttling
|
|
if (this.isThrottlingError(error)) {
|
|
await this.handleThrottling(error)
|
|
throw error
|
|
}
|
|
|
|
this.logger.error(`Failed to delete noun ${id}:`, error)
|
|
throw new Error(`Failed to delete noun ${id}: ${error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write an object to a specific path in Azure
|
|
* Primitive operation required by base class
|
|
* @protected
|
|
*/
|
|
protected async writeObjectToPath(path: string, data: any): Promise<void> {
|
|
await this.ensureInitialized()
|
|
|
|
try {
|
|
this.logger.trace(`Writing object to path: ${path}`)
|
|
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(path)
|
|
const content = JSON.stringify(data, null, 2)
|
|
await blockBlobClient.upload(content, content.length, {
|
|
blobHTTPHeaders: { blobContentType: 'application/json' }
|
|
})
|
|
|
|
this.logger.trace(`Object written successfully to ${path}`)
|
|
} catch (error) {
|
|
this.logger.error(`Failed to write object to ${path}:`, error)
|
|
throw new Error(`Failed to write object to ${path}: ${error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read an object from a specific path in Azure
|
|
* Primitive operation required by base class
|
|
* @protected
|
|
*/
|
|
protected async readObjectFromPath(path: string): Promise<any | null> {
|
|
await this.ensureInitialized()
|
|
|
|
try {
|
|
this.logger.trace(`Reading object from path: ${path}`)
|
|
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(path)
|
|
const downloadResponse = await blockBlobClient.download(0)
|
|
const downloaded = await this.streamToBuffer(downloadResponse.readableStreamBody!)
|
|
|
|
const data = JSON.parse(downloaded.toString())
|
|
|
|
this.logger.trace(`Object read successfully from ${path}`)
|
|
return data
|
|
} catch (error: any) {
|
|
// Check if this is a "not found" error
|
|
if (error.statusCode === 404 || error.code === 'BlobNotFound') {
|
|
this.logger.trace(`Object not found at ${path}`)
|
|
return null
|
|
}
|
|
|
|
this.logger.error(`Failed to read object from ${path}:`, error)
|
|
throw BrainyError.fromError(error, `readObjectFromPath(${path})`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete an object from a specific path in Azure
|
|
* Primitive operation required by base class
|
|
* @protected
|
|
*/
|
|
protected async deleteObjectFromPath(path: string): Promise<void> {
|
|
await this.ensureInitialized()
|
|
|
|
try {
|
|
this.logger.trace(`Deleting object at path: ${path}`)
|
|
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(path)
|
|
await blockBlobClient.delete()
|
|
|
|
this.logger.trace(`Object deleted successfully from ${path}`)
|
|
} catch (error: any) {
|
|
// If already deleted (404), treat as success
|
|
if (error.statusCode === 404 || error.code === 'BlobNotFound') {
|
|
this.logger.trace(`Object at ${path} not found (already deleted)`)
|
|
return
|
|
}
|
|
|
|
this.logger.error(`Failed to delete object from ${path}:`, error)
|
|
throw new Error(`Failed to delete object from ${path}: ${error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Batch delete multiple blobs from Azure Blob Storage
|
|
* Deletes up to 256 blobs per batch (Azure limit)
|
|
* Handles throttling, retries, and partial failures
|
|
*
|
|
* @param keys - Array of blob names (paths) to delete
|
|
* @param options - Configuration options for batch deletion
|
|
* @returns Statistics about successful and failed deletions
|
|
*/
|
|
public async batchDelete(
|
|
keys: string[],
|
|
options: {
|
|
maxRetries?: number
|
|
retryDelayMs?: number
|
|
continueOnError?: boolean
|
|
} = {}
|
|
): Promise<{
|
|
totalRequested: number
|
|
successfulDeletes: number
|
|
failedDeletes: number
|
|
errors: Array<{ key: string; error: string }>
|
|
}> {
|
|
await this.ensureInitialized()
|
|
|
|
const {
|
|
maxRetries = 3,
|
|
retryDelayMs = 1000,
|
|
continueOnError = true
|
|
} = options
|
|
|
|
if (!keys || keys.length === 0) {
|
|
return {
|
|
totalRequested: 0,
|
|
successfulDeletes: 0,
|
|
failedDeletes: 0,
|
|
errors: []
|
|
}
|
|
}
|
|
|
|
this.logger.info(`Starting batch delete of ${keys.length} blobs`)
|
|
|
|
const stats = {
|
|
totalRequested: keys.length,
|
|
successfulDeletes: 0,
|
|
failedDeletes: 0,
|
|
errors: [] as Array<{ key: string; error: string }>
|
|
}
|
|
|
|
// Chunk keys into batches of max 256 (Azure limit)
|
|
const MAX_BATCH_SIZE = 256
|
|
const batches: string[][] = []
|
|
for (let i = 0; i < keys.length; i += MAX_BATCH_SIZE) {
|
|
batches.push(keys.slice(i, i + MAX_BATCH_SIZE))
|
|
}
|
|
|
|
this.logger.debug(`Split ${keys.length} keys into ${batches.length} batches`)
|
|
|
|
// Process each batch
|
|
for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) {
|
|
const batch = batches[batchIndex]
|
|
let retryCount = 0
|
|
let batchSuccess = false
|
|
|
|
while (retryCount <= maxRetries && !batchSuccess) {
|
|
const requestId = await this.applyBackpressure()
|
|
|
|
try {
|
|
const { BlobBatchClient } = await import('@azure/storage-blob')
|
|
|
|
this.logger.debug(
|
|
`Processing batch ${batchIndex + 1}/${batches.length} with ${batch.length} blobs (attempt ${retryCount + 1}/${maxRetries + 1})`
|
|
)
|
|
|
|
// Create batch client
|
|
const batchClient = this.containerClient!.getBlobBatchClient()
|
|
|
|
// Execute batch delete
|
|
const deletePromises = batch.map((key) => {
|
|
const blobClient = this.containerClient!.getBlockBlobClient(key)
|
|
return blobClient.url
|
|
})
|
|
|
|
// Use batch delete
|
|
const batchDeleteResponse = await batchClient.deleteBlobs(
|
|
batch.map(key => this.containerClient!.getBlockBlobClient(key).url),
|
|
{
|
|
// Additional options can be added here
|
|
}
|
|
)
|
|
|
|
this.logger.debug(
|
|
`Batch ${batchIndex + 1} completed`
|
|
)
|
|
|
|
// Process results
|
|
for (let i = 0; i < batch.length; i++) {
|
|
const key = batch[i]
|
|
const subResponse = batchDeleteResponse.subResponses[i]
|
|
|
|
if (subResponse.status === 202 || subResponse.status === 404) {
|
|
// 202 Accepted = successful delete
|
|
// 404 Not Found = already deleted (treat as success)
|
|
stats.successfulDeletes++
|
|
|
|
if (subResponse.status === 404) {
|
|
this.logger.trace(`Blob ${key} already deleted (404)`)
|
|
}
|
|
} else {
|
|
// Deletion failed
|
|
stats.failedDeletes++
|
|
stats.errors.push({
|
|
key,
|
|
error: `HTTP ${subResponse.status}: ${subResponse.errorCode || 'Unknown error'}`
|
|
})
|
|
|
|
this.logger.error(
|
|
`Failed to delete ${key}: ${subResponse.status} - ${subResponse.errorCode}`
|
|
)
|
|
}
|
|
}
|
|
|
|
this.releaseBackpressure(true, requestId)
|
|
batchSuccess = true
|
|
} catch (error: any) {
|
|
this.releaseBackpressure(false, requestId)
|
|
|
|
// Handle throttling
|
|
if (this.isThrottlingError(error)) {
|
|
this.logger.warn(
|
|
`Batch ${batchIndex + 1} throttled, waiting before retry...`
|
|
)
|
|
await this.handleThrottling(error)
|
|
retryCount++
|
|
|
|
if (retryCount <= maxRetries) {
|
|
const delay = retryDelayMs * Math.pow(2, retryCount - 1) // Exponential backoff
|
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Handle other errors
|
|
this.logger.error(
|
|
`Batch ${batchIndex + 1} failed (attempt ${retryCount + 1}/${maxRetries + 1}):`,
|
|
error
|
|
)
|
|
|
|
if (retryCount < maxRetries) {
|
|
retryCount++
|
|
const delay = retryDelayMs * Math.pow(2, retryCount - 1)
|
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
continue
|
|
}
|
|
|
|
// Max retries exceeded
|
|
if (continueOnError) {
|
|
// Mark all keys in this batch as failed and continue to next batch
|
|
for (const key of batch) {
|
|
stats.failedDeletes++
|
|
stats.errors.push({
|
|
key,
|
|
error: error.message || String(error)
|
|
})
|
|
}
|
|
this.logger.error(
|
|
`Batch ${batchIndex + 1} failed after ${maxRetries} retries, continuing to next batch`
|
|
)
|
|
batchSuccess = true // Mark as "handled" to move to next batch
|
|
} else {
|
|
// Stop processing and throw error
|
|
throw BrainyError.storage(
|
|
`Batch delete failed at batch ${batchIndex + 1}/${batches.length} after ${maxRetries} retries. Total: ${stats.successfulDeletes} deleted, ${stats.failedDeletes} failed`,
|
|
error instanceof Error ? error : undefined
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
this.logger.info(
|
|
`Batch delete completed: ${stats.successfulDeletes}/${stats.totalRequested} successful, ${stats.failedDeletes} failed`
|
|
)
|
|
|
|
return stats
|
|
}
|
|
|
|
/**
|
|
* List all objects under a specific prefix in Azure
|
|
* Primitive operation required by base class
|
|
* @protected
|
|
*/
|
|
protected async listObjectsUnderPath(prefix: string): Promise<string[]> {
|
|
await this.ensureInitialized()
|
|
|
|
try {
|
|
this.logger.trace(`Listing objects under prefix: ${prefix}`)
|
|
|
|
const paths: string[] = []
|
|
for await (const blob of this.containerClient!.listBlobsFlat({ prefix })) {
|
|
if (blob.name) {
|
|
paths.push(blob.name)
|
|
}
|
|
}
|
|
|
|
this.logger.trace(`Found ${paths.length} objects under ${prefix}`)
|
|
return paths
|
|
} catch (error) {
|
|
this.logger.error(`Failed to list objects under ${prefix}:`, error)
|
|
throw new Error(`Failed to list objects under ${prefix}: ${error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper: Convert Azure stream to buffer
|
|
*/
|
|
private async streamToBuffer(readableStream: NodeJS.ReadableStream): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks: Buffer[] = []
|
|
readableStream.on('data', (data) => {
|
|
chunks.push(data instanceof Buffer ? data : Buffer.from(data))
|
|
})
|
|
readableStream.on('end', () => {
|
|
resolve(Buffer.concat(chunks))
|
|
})
|
|
readableStream.on('error', reject)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Save a verb to storage (internal implementation)
|
|
*/
|
|
protected async saveVerb_internal(verb: HNSWVerb): Promise<void> {
|
|
return this.saveEdge(verb)
|
|
}
|
|
|
|
/**
|
|
* Save an edge to storage
|
|
*/
|
|
protected async saveEdge(edge: Edge): Promise<void> {
|
|
await this.ensureInitialized()
|
|
|
|
// Check volume mode
|
|
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`)
|
|
await this.verbWriteBuffer.add(edge.id, edge)
|
|
return
|
|
}
|
|
|
|
// Direct write in normal mode
|
|
await this.saveEdgeDirect(edge)
|
|
}
|
|
|
|
/**
|
|
* Save an edge directly to Azure (bypass buffer)
|
|
*/
|
|
private async saveEdgeDirect(edge: Edge): Promise<void> {
|
|
const requestId = await this.applyBackpressure()
|
|
|
|
try {
|
|
this.logger.trace(`Saving edge ${edge.id}`)
|
|
|
|
// Convert connections Map to serializable format
|
|
// ARCHITECTURAL FIX: Include core relational fields in verb vector file
|
|
// These fields are essential for 90% of operations - no metadata lookup needed
|
|
const serializableEdge = {
|
|
id: edge.id,
|
|
vector: edge.vector,
|
|
connections: Object.fromEntries(
|
|
Array.from(edge.connections.entries()).map(([level, verbIds]) => [
|
|
level,
|
|
Array.from(verbIds)
|
|
])
|
|
),
|
|
|
|
// CORE RELATIONAL DATA (v4.0.0)
|
|
verb: edge.verb,
|
|
sourceId: edge.sourceId,
|
|
targetId: edge.targetId,
|
|
|
|
// User metadata (if any) - saved separately for scalability
|
|
// metadata field is saved separately via saveVerbMetadata()
|
|
}
|
|
|
|
// Get the Azure blob name with UUID-based sharding
|
|
const blobName = this.getVerbKey(edge.id)
|
|
|
|
// Save to Azure
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(blobName)
|
|
await blockBlobClient.upload(
|
|
JSON.stringify(serializableEdge, null, 2),
|
|
JSON.stringify(serializableEdge).length,
|
|
{
|
|
blobHTTPHeaders: { blobContentType: 'application/json' }
|
|
}
|
|
)
|
|
|
|
// Update cache
|
|
this.verbCacheManager.set(edge.id, edge)
|
|
|
|
// Increment verb count
|
|
const metadata = await this.getVerbMetadata(edge.id)
|
|
if (metadata && metadata.type) {
|
|
await this.incrementVerbCount(metadata.type as string)
|
|
}
|
|
|
|
this.logger.trace(`Edge ${edge.id} saved successfully`)
|
|
this.releaseBackpressure(true, requestId)
|
|
} catch (error: any) {
|
|
this.releaseBackpressure(false, requestId)
|
|
|
|
if (this.isThrottlingError(error)) {
|
|
await this.handleThrottling(error)
|
|
throw error
|
|
}
|
|
|
|
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)
|
|
* v4.0.0: Returns ONLY vector + core relational fields (no metadata field)
|
|
* Base class combines with metadata via getVerb() -> HNSWVerbWithMetadata
|
|
*/
|
|
protected async getVerb_internal(id: string): Promise<HNSWVerb | null> {
|
|
// v4.0.0: Return ONLY vector + core relational data (no metadata field)
|
|
const edge = await this.getEdge(id)
|
|
if (!edge) {
|
|
return null
|
|
}
|
|
|
|
// Return pure vector + core fields structure
|
|
return edge
|
|
}
|
|
|
|
/**
|
|
* Get an edge from storage
|
|
*/
|
|
protected async getEdge(id: string): Promise<Edge | null> {
|
|
await this.ensureInitialized()
|
|
|
|
// Check cache first
|
|
const cached = this.verbCacheManager.get(id)
|
|
if (cached) {
|
|
this.logger.trace(`Cache hit for verb ${id}`)
|
|
return cached
|
|
}
|
|
|
|
const requestId = await this.applyBackpressure()
|
|
|
|
try {
|
|
this.logger.trace(`Getting edge ${id}`)
|
|
|
|
// Get the Azure blob name with UUID-based sharding
|
|
const blobName = this.getVerbKey(id)
|
|
|
|
// Download from Azure
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(blobName)
|
|
const downloadResponse = await blockBlobClient.download(0)
|
|
const downloaded = await this.streamToBuffer(downloadResponse.readableStreamBody!)
|
|
|
|
// Parse JSON
|
|
const data = JSON.parse(downloaded.toString())
|
|
|
|
// Convert serialized connections back to Map
|
|
const connections = new Map<number, Set<string>>()
|
|
for (const [level, verbIds] of Object.entries(data.connections || {})) {
|
|
connections.set(Number(level), new Set(verbIds as string[]))
|
|
}
|
|
|
|
// v4.0.0: Return HNSWVerb with core relational fields (NO metadata field)
|
|
const edge: Edge = {
|
|
id: data.id,
|
|
vector: data.vector,
|
|
connections,
|
|
|
|
// CORE RELATIONAL DATA (read from vector file)
|
|
verb: data.verb,
|
|
sourceId: data.sourceId,
|
|
targetId: data.targetId
|
|
|
|
// ✅ NO metadata field in v4.0.0
|
|
// User metadata retrieved separately via getVerbMetadata()
|
|
}
|
|
|
|
// Update cache
|
|
this.verbCacheManager.set(id, edge)
|
|
|
|
this.logger.trace(`Successfully retrieved edge ${id}`)
|
|
this.releaseBackpressure(true, requestId)
|
|
return edge
|
|
} catch (error: any) {
|
|
this.releaseBackpressure(false, requestId)
|
|
|
|
// Check if this is a "not found" error
|
|
if (error.statusCode === 404 || error.code === 'BlobNotFound') {
|
|
this.logger.trace(`Edge not found: ${id}`)
|
|
return null
|
|
}
|
|
|
|
if (this.isThrottlingError(error)) {
|
|
await this.handleThrottling(error)
|
|
throw error
|
|
}
|
|
|
|
this.logger.error(`Failed to get edge ${id}:`, error)
|
|
throw BrainyError.fromError(error, `getVerb(${id})`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a verb from storage (internal implementation)
|
|
*/
|
|
protected async deleteVerb_internal(id: string): Promise<void> {
|
|
await this.ensureInitialized()
|
|
|
|
const requestId = await this.applyBackpressure()
|
|
|
|
try {
|
|
this.logger.trace(`Deleting verb ${id}`)
|
|
|
|
// Get the Azure blob name
|
|
const blobName = this.getVerbKey(id)
|
|
|
|
// Delete from Azure
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(blobName)
|
|
await blockBlobClient.delete()
|
|
|
|
// Remove from cache
|
|
this.verbCacheManager.delete(id)
|
|
|
|
// Decrement verb count
|
|
const metadata = await this.getVerbMetadata(id)
|
|
if (metadata && metadata.type) {
|
|
await this.decrementVerbCount(metadata.type as string)
|
|
}
|
|
|
|
this.logger.trace(`Verb ${id} deleted successfully`)
|
|
this.releaseBackpressure(true, requestId)
|
|
} catch (error: any) {
|
|
this.releaseBackpressure(false, requestId)
|
|
|
|
if (error.statusCode === 404 || error.code === 'BlobNotFound') {
|
|
// Already deleted
|
|
this.logger.trace(`Verb ${id} not found (already deleted)`)
|
|
return
|
|
}
|
|
|
|
if (this.isThrottlingError(error)) {
|
|
await this.handleThrottling(error)
|
|
throw error
|
|
}
|
|
|
|
this.logger.error(`Failed to delete verb ${id}:`, error)
|
|
throw new Error(`Failed to delete verb ${id}: ${error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get nouns with pagination
|
|
* v4.0.0: Returns HNSWNounWithMetadata[] (includes metadata field)
|
|
* Iterates through all UUID-based shards (00-ff) for consistent pagination
|
|
*/
|
|
public async getNounsWithPagination(options: {
|
|
limit?: number
|
|
cursor?: string
|
|
filter?: {
|
|
nounType?: string | string[]
|
|
service?: string | string[]
|
|
metadata?: Record<string, any>
|
|
}
|
|
} = {}): Promise<{
|
|
items: HNSWNounWithMetadata[]
|
|
totalCount?: number
|
|
hasMore: boolean
|
|
nextCursor?: string
|
|
}> {
|
|
await this.ensureInitialized()
|
|
|
|
const limit = options.limit || 100
|
|
|
|
// Simplified implementation for Azure (can be optimized similar to GCS)
|
|
const items: HNSWNounWithMetadata[] = []
|
|
const iterator = this.containerClient!.listBlobsFlat({ prefix: this.nounPrefix })
|
|
|
|
let count = 0
|
|
for await (const blob of iterator) {
|
|
if (count >= limit) break
|
|
if (!blob.name || !blob.name.endsWith('.json')) continue
|
|
|
|
// Extract UUID from blob name
|
|
const parts = blob.name.split('/')
|
|
const fileName = parts[parts.length - 1]
|
|
const id = fileName.replace('.json', '')
|
|
|
|
const node = await this.getNode(id)
|
|
if (!node) continue
|
|
|
|
const metadata = await this.getNounMetadata(id)
|
|
if (!metadata) continue
|
|
|
|
// Apply filters if provided
|
|
if (options.filter) {
|
|
if (options.filter.nounType) {
|
|
const nounTypes = Array.isArray(options.filter.nounType)
|
|
? options.filter.nounType
|
|
: [options.filter.nounType]
|
|
|
|
const nounType = (metadata as any).type || (metadata as any).noun
|
|
if (!nounType || !nounTypes.includes(nounType)) {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
// Combine node with metadata
|
|
items.push({
|
|
...node,
|
|
metadata
|
|
})
|
|
|
|
count++
|
|
}
|
|
|
|
return {
|
|
items,
|
|
totalCount: this.totalNounCount,
|
|
hasMore: false,
|
|
nextCursor: undefined
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get nouns by noun type (internal implementation)
|
|
*/
|
|
protected async getNounsByNounType_internal(nounType: string): Promise<HNSWNoun[]> {
|
|
const result = await this.getNounsWithPagination({
|
|
limit: 10000, // Large limit for backward compatibility
|
|
filter: { nounType }
|
|
})
|
|
|
|
return result.items
|
|
}
|
|
|
|
/**
|
|
* Get verbs by source ID (internal implementation)
|
|
*/
|
|
protected async getVerbsBySource_internal(sourceId: string): Promise<HNSWVerbWithMetadata[]> {
|
|
// Simplified: scan all verbs and filter
|
|
const items: HNSWVerbWithMetadata[] = []
|
|
const iterator = this.containerClient!.listBlobsFlat({ prefix: this.verbPrefix })
|
|
|
|
for await (const blob of iterator) {
|
|
if (!blob.name || !blob.name.endsWith('.json')) continue
|
|
|
|
const parts = blob.name.split('/')
|
|
const fileName = parts[parts.length - 1]
|
|
const id = fileName.replace('.json', '')
|
|
|
|
const verb = await this.getEdge(id)
|
|
if (!verb || verb.sourceId !== sourceId) continue
|
|
|
|
const metadata = await this.getVerbMetadata(id)
|
|
items.push({
|
|
...verb,
|
|
metadata: metadata || {}
|
|
})
|
|
}
|
|
|
|
return items
|
|
}
|
|
|
|
/**
|
|
* Get verbs by target ID (internal implementation)
|
|
*/
|
|
protected async getVerbsByTarget_internal(targetId: string): Promise<HNSWVerbWithMetadata[]> {
|
|
// Simplified: scan all verbs and filter
|
|
const items: HNSWVerbWithMetadata[] = []
|
|
const iterator = this.containerClient!.listBlobsFlat({ prefix: this.verbPrefix })
|
|
|
|
for await (const blob of iterator) {
|
|
if (!blob.name || !blob.name.endsWith('.json')) continue
|
|
|
|
const parts = blob.name.split('/')
|
|
const fileName = parts[parts.length - 1]
|
|
const id = fileName.replace('.json', '')
|
|
|
|
const verb = await this.getEdge(id)
|
|
if (!verb || verb.targetId !== targetId) continue
|
|
|
|
const metadata = await this.getVerbMetadata(id)
|
|
items.push({
|
|
...verb,
|
|
metadata: metadata || {}
|
|
})
|
|
}
|
|
|
|
return items
|
|
}
|
|
|
|
/**
|
|
* Get verbs by type (internal implementation)
|
|
*/
|
|
protected async getVerbsByType_internal(type: string): Promise<HNSWVerbWithMetadata[]> {
|
|
// Simplified: scan all verbs and filter
|
|
const items: HNSWVerbWithMetadata[] = []
|
|
const iterator = this.containerClient!.listBlobsFlat({ prefix: this.verbPrefix })
|
|
|
|
for await (const blob of iterator) {
|
|
if (!blob.name || !blob.name.endsWith('.json')) continue
|
|
|
|
const parts = blob.name.split('/')
|
|
const fileName = parts[parts.length - 1]
|
|
const id = fileName.replace('.json', '')
|
|
|
|
const verb = await this.getEdge(id)
|
|
if (!verb || verb.verb !== type) continue
|
|
|
|
const metadata = await this.getVerbMetadata(id)
|
|
items.push({
|
|
...verb,
|
|
metadata: metadata || {}
|
|
})
|
|
}
|
|
|
|
return items
|
|
}
|
|
|
|
/**
|
|
* Clear all data from storage
|
|
*/
|
|
public async clear(): Promise<void> {
|
|
await this.ensureInitialized()
|
|
|
|
try {
|
|
this.logger.info('🧹 Clearing all data from Azure container...')
|
|
|
|
// Delete all blobs in container
|
|
for await (const blob of this.containerClient!.listBlobsFlat()) {
|
|
if (blob.name) {
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(blob.name)
|
|
await blockBlobClient.delete()
|
|
}
|
|
}
|
|
|
|
// Clear caches
|
|
this.nounCacheManager.clear()
|
|
this.verbCacheManager.clear()
|
|
|
|
// Reset counts
|
|
this.totalNounCount = 0
|
|
this.totalVerbCount = 0
|
|
this.entityCounts.clear()
|
|
this.verbCounts.clear()
|
|
|
|
this.logger.info('✅ All data cleared from Azure')
|
|
} catch (error) {
|
|
this.logger.error('Failed to clear Azure storage:', error)
|
|
throw new Error(`Failed to clear Azure storage: ${error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get storage status
|
|
*/
|
|
public async getStorageStatus(): Promise<{
|
|
type: string
|
|
used: number
|
|
quota: number | null
|
|
details?: Record<string, any>
|
|
}> {
|
|
await this.ensureInitialized()
|
|
|
|
try {
|
|
const properties = await this.containerClient!.getProperties()
|
|
|
|
return {
|
|
type: 'azure',
|
|
used: 0, // Azure doesn't provide usage info easily
|
|
quota: null, // No quota in Azure Blob Storage
|
|
details: {
|
|
container: this.containerName,
|
|
lastModified: properties.lastModified,
|
|
etag: properties.etag
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.logger.error('Failed to get storage status:', error)
|
|
return {
|
|
type: 'azure',
|
|
used: 0,
|
|
quota: null
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save statistics data to storage
|
|
*/
|
|
protected async saveStatisticsData(statistics: StatisticsData): Promise<void> {
|
|
await this.ensureInitialized()
|
|
|
|
try {
|
|
const key = `${this.systemPrefix}${STATISTICS_KEY}.json`
|
|
|
|
this.logger.trace(`Saving statistics to ${key}`)
|
|
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(key)
|
|
const content = JSON.stringify(statistics, null, 2)
|
|
await blockBlobClient.upload(content, content.length, {
|
|
blobHTTPHeaders: { blobContentType: 'application/json' }
|
|
})
|
|
|
|
this.logger.trace('Statistics saved successfully')
|
|
} catch (error) {
|
|
this.logger.error('Failed to save statistics:', error)
|
|
throw new Error(`Failed to save statistics: ${error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get statistics data from storage
|
|
*/
|
|
protected async getStatisticsData(): Promise<StatisticsData | null> {
|
|
await this.ensureInitialized()
|
|
|
|
try {
|
|
const key = `${this.systemPrefix}${STATISTICS_KEY}.json`
|
|
|
|
this.logger.trace(`Getting statistics from ${key}`)
|
|
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(key)
|
|
const downloadResponse = await blockBlobClient.download(0)
|
|
const downloaded = await this.streamToBuffer(downloadResponse.readableStreamBody!)
|
|
|
|
const statistics = JSON.parse(downloaded.toString())
|
|
|
|
this.logger.trace('Statistics retrieved successfully')
|
|
|
|
// CRITICAL FIX: Populate totalNodes and totalEdges from in-memory counts
|
|
return {
|
|
...statistics,
|
|
totalNodes: this.totalNounCount,
|
|
totalEdges: this.totalVerbCount,
|
|
lastUpdated: new Date().toISOString()
|
|
}
|
|
} catch (error: any) {
|
|
if (error.statusCode === 404 || error.code === 'BlobNotFound') {
|
|
// Statistics file doesn't exist yet (first restart)
|
|
this.logger.trace('Statistics file not found - returning minimal stats with counts')
|
|
return {
|
|
nounCount: {},
|
|
verbCount: {},
|
|
metadataCount: {},
|
|
hnswIndexSize: 0,
|
|
totalNodes: this.totalNounCount,
|
|
totalEdges: this.totalVerbCount,
|
|
totalMetadata: 0,
|
|
lastUpdated: new Date().toISOString()
|
|
}
|
|
}
|
|
|
|
this.logger.error('Failed to get statistics:', error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize counts from storage
|
|
*/
|
|
protected async initializeCounts(): Promise<void> {
|
|
const key = `${this.systemPrefix}counts.json`
|
|
|
|
try {
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(key)
|
|
const downloadResponse = await blockBlobClient.download(0)
|
|
const downloaded = await this.streamToBuffer(downloadResponse.readableStreamBody!)
|
|
|
|
const counts = JSON.parse(downloaded.toString())
|
|
|
|
this.totalNounCount = counts.totalNounCount || 0
|
|
this.totalVerbCount = counts.totalVerbCount || 0
|
|
this.entityCounts = new Map(Object.entries(counts.entityCounts || {})) as Map<string, number>
|
|
this.verbCounts = new Map(Object.entries(counts.verbCounts || {})) as Map<string, number>
|
|
|
|
prodLog.info(`📊 Loaded counts from storage: ${this.totalNounCount} nouns, ${this.totalVerbCount} verbs`)
|
|
} catch (error: any) {
|
|
if (error.statusCode === 404 || error.code === 'BlobNotFound') {
|
|
// No counts file yet - initialize from scan (first-time setup)
|
|
prodLog.info('📊 No counts file found - this is normal for first init')
|
|
await this.initializeCountsFromScan()
|
|
} else {
|
|
// CRITICAL FIX: Don't silently fail on network/permission errors
|
|
this.logger.error('❌ CRITICAL: Failed to load counts from Azure:', error)
|
|
prodLog.error(`❌ Error loading ${key}: ${error.message}`)
|
|
|
|
// Try to recover by scanning the container
|
|
prodLog.warn('⚠️ Attempting recovery by scanning Azure container...')
|
|
await this.initializeCountsFromScan()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize counts from storage scan (expensive - only for first-time init)
|
|
*/
|
|
private async initializeCountsFromScan(): Promise<void> {
|
|
try {
|
|
prodLog.info('📊 Scanning Azure container to initialize counts...')
|
|
|
|
// Count nouns
|
|
let nounCount = 0
|
|
for await (const blob of this.containerClient!.listBlobsFlat({ prefix: this.nounPrefix })) {
|
|
if (blob.name && blob.name.endsWith('.json')) {
|
|
nounCount++
|
|
}
|
|
}
|
|
this.totalNounCount = nounCount
|
|
|
|
// Count verbs
|
|
let verbCount = 0
|
|
for await (const blob of this.containerClient!.listBlobsFlat({ prefix: this.verbPrefix })) {
|
|
if (blob.name && blob.name.endsWith('.json')) {
|
|
verbCount++
|
|
}
|
|
}
|
|
this.totalVerbCount = verbCount
|
|
|
|
// Save initial counts
|
|
if (this.totalNounCount > 0 || this.totalVerbCount > 0) {
|
|
await this.persistCounts()
|
|
prodLog.info(`✅ Initialized counts from scan: ${this.totalNounCount} nouns, ${this.totalVerbCount} verbs`)
|
|
} else {
|
|
prodLog.warn(`⚠️ No entities found during container scan. Check that entities exist and prefixes are correct.`)
|
|
}
|
|
} catch (error) {
|
|
// CRITICAL FIX: Don't silently fail - this prevents data loss scenarios
|
|
this.logger.error('❌ CRITICAL: Failed to initialize counts from Azure container scan:', error)
|
|
throw new Error(`Failed to initialize Azure storage counts: ${error}. This prevents container restarts from working correctly.`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Persist counts to storage
|
|
*/
|
|
protected async persistCounts(): Promise<void> {
|
|
try {
|
|
const key = `${this.systemPrefix}counts.json`
|
|
|
|
const counts = {
|
|
totalNounCount: this.totalNounCount,
|
|
totalVerbCount: this.totalVerbCount,
|
|
entityCounts: Object.fromEntries(this.entityCounts),
|
|
verbCounts: Object.fromEntries(this.verbCounts),
|
|
lastUpdated: new Date().toISOString()
|
|
}
|
|
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(key)
|
|
const content = JSON.stringify(counts, null, 2)
|
|
await blockBlobClient.upload(content, content.length, {
|
|
blobHTTPHeaders: { blobContentType: 'application/json' }
|
|
})
|
|
} catch (error) {
|
|
this.logger.error('Error persisting counts:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a noun's vector for HNSW rebuild
|
|
*/
|
|
public async getNounVector(id: string): Promise<number[] | null> {
|
|
await this.ensureInitialized()
|
|
const noun = await this.getNode(id)
|
|
return noun ? noun.vector : null
|
|
}
|
|
|
|
/**
|
|
* Save HNSW graph data for a noun
|
|
*/
|
|
public async saveHNSWData(nounId: string, hnswData: {
|
|
level: number
|
|
connections: Record<string, string[]>
|
|
}): Promise<void> {
|
|
await this.ensureInitialized()
|
|
|
|
try {
|
|
const shard = getShardIdFromUuid(nounId)
|
|
const key = `entities/nouns/hnsw/${shard}/${nounId}.json`
|
|
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(key)
|
|
const content = JSON.stringify(hnswData, null, 2)
|
|
await blockBlobClient.upload(content, content.length, {
|
|
blobHTTPHeaders: { blobContentType: 'application/json' }
|
|
})
|
|
} catch (error) {
|
|
this.logger.error(`Failed to save HNSW data for ${nounId}:`, error)
|
|
throw new Error(`Failed to save HNSW data for ${nounId}: ${error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get HNSW graph data for a noun
|
|
*/
|
|
public async getHNSWData(nounId: string): Promise<{
|
|
level: number
|
|
connections: Record<string, string[]>
|
|
} | null> {
|
|
await this.ensureInitialized()
|
|
|
|
try {
|
|
const shard = getShardIdFromUuid(nounId)
|
|
const key = `entities/nouns/hnsw/${shard}/${nounId}.json`
|
|
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(key)
|
|
const downloadResponse = await blockBlobClient.download(0)
|
|
const downloaded = await this.streamToBuffer(downloadResponse.readableStreamBody!)
|
|
|
|
return JSON.parse(downloaded.toString())
|
|
} catch (error: any) {
|
|
if (error.statusCode === 404 || error.code === 'BlobNotFound') {
|
|
return null
|
|
}
|
|
|
|
this.logger.error(`Failed to get HNSW data for ${nounId}:`, error)
|
|
throw new Error(`Failed to get HNSW data for ${nounId}: ${error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save HNSW system data (entry point, max level)
|
|
*/
|
|
public async saveHNSWSystem(systemData: {
|
|
entryPointId: string | null
|
|
maxLevel: number
|
|
}): Promise<void> {
|
|
await this.ensureInitialized()
|
|
|
|
try {
|
|
const key = `${this.systemPrefix}hnsw-system.json`
|
|
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(key)
|
|
const content = JSON.stringify(systemData, null, 2)
|
|
await blockBlobClient.upload(content, content.length, {
|
|
blobHTTPHeaders: { blobContentType: 'application/json' }
|
|
})
|
|
} catch (error) {
|
|
this.logger.error('Failed to save HNSW system data:', error)
|
|
throw new Error(`Failed to save HNSW system data: ${error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get HNSW system data (entry point, max level)
|
|
*/
|
|
public async getHNSWSystem(): Promise<{
|
|
entryPointId: string | null
|
|
maxLevel: number
|
|
} | null> {
|
|
await this.ensureInitialized()
|
|
|
|
try {
|
|
const key = `${this.systemPrefix}hnsw-system.json`
|
|
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(key)
|
|
const downloadResponse = await blockBlobClient.download(0)
|
|
const downloaded = await this.streamToBuffer(downloadResponse.readableStreamBody!)
|
|
|
|
return JSON.parse(downloaded.toString())
|
|
} catch (error: any) {
|
|
if (error.statusCode === 404 || error.code === 'BlobNotFound') {
|
|
return null
|
|
}
|
|
|
|
this.logger.error('Failed to get HNSW system data:', error)
|
|
throw new Error(`Failed to get HNSW system data: ${error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the access tier for a specific blob (v4.0.0 cost optimization)
|
|
* Azure Blob Storage tiers:
|
|
* - Hot: $0.0184/GB/month - Frequently accessed data
|
|
* - Cool: $0.01/GB/month - Infrequently accessed data (45% cheaper)
|
|
* - Archive: $0.00099/GB/month - Rarely accessed data (99% cheaper!)
|
|
*
|
|
* @param blobName - Name of the blob to change tier
|
|
* @param tier - Target access tier ('Hot', 'Cool', or 'Archive')
|
|
* @returns Promise that resolves when tier is set
|
|
*
|
|
* @example
|
|
* // Move old vectors to Archive tier (99% cost savings)
|
|
* await storage.setBlobTier('entities/nouns/vectors/ab/old-id.json', 'Archive')
|
|
*/
|
|
public async setBlobTier(
|
|
blobName: string,
|
|
tier: 'Hot' | 'Cool' | 'Archive'
|
|
): Promise<void> {
|
|
await this.ensureInitialized()
|
|
|
|
try {
|
|
this.logger.info(`Setting blob tier for ${blobName} to ${tier}`)
|
|
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(blobName)
|
|
await blockBlobClient.setAccessTier(tier)
|
|
|
|
this.logger.info(`Successfully set ${blobName} to ${tier} tier`)
|
|
} catch (error: any) {
|
|
if (error.statusCode === 404 || error.code === 'BlobNotFound') {
|
|
throw new Error(`Blob not found: ${blobName}`)
|
|
}
|
|
|
|
this.logger.error(`Failed to set tier for ${blobName}:`, error)
|
|
throw new Error(`Failed to set blob tier: ${error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the current access tier for a blob
|
|
*
|
|
* @param blobName - Name of the blob
|
|
* @returns Promise that resolves to the current tier or null if not found
|
|
*
|
|
* @example
|
|
* const tier = await storage.getBlobTier('entities/nouns/vectors/ab/id.json')
|
|
* console.log(`Current tier: ${tier}`) // 'Hot', 'Cool', or 'Archive'
|
|
*/
|
|
public async getBlobTier(blobName: string): Promise<string | null> {
|
|
await this.ensureInitialized()
|
|
|
|
try {
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(blobName)
|
|
const properties = await blockBlobClient.getProperties()
|
|
|
|
return properties.accessTier || null
|
|
} catch (error: any) {
|
|
if (error.statusCode === 404 || error.code === 'BlobNotFound') {
|
|
return null
|
|
}
|
|
|
|
this.logger.error(`Failed to get tier for ${blobName}:`, error)
|
|
throw new Error(`Failed to get blob tier: ${error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set access tier for multiple blobs in batch (v4.0.0 cost optimization)
|
|
* Efficiently move large numbers of blobs between tiers for cost optimization
|
|
*
|
|
* @param blobs - Array of blob names and their target tiers
|
|
* @param options - Configuration options
|
|
* @returns Promise with statistics about tier changes
|
|
*
|
|
* @example
|
|
* // Move old data to Archive tier for 99% cost savings
|
|
* const oldBlobs = await storage.listObjectsUnderPath('entities/nouns/vectors/')
|
|
* await storage.setBlobTierBatch(
|
|
* oldBlobs.map(name => ({ blobName: name, tier: 'Archive' }))
|
|
* )
|
|
*/
|
|
public async setBlobTierBatch(
|
|
blobs: Array<{ blobName: string; tier: 'Hot' | 'Cool' | 'Archive' }>,
|
|
options: {
|
|
maxRetries?: number
|
|
retryDelayMs?: number
|
|
continueOnError?: boolean
|
|
} = {}
|
|
): Promise<{
|
|
totalRequested: number
|
|
successfulChanges: number
|
|
failedChanges: number
|
|
errors: Array<{ blobName: string; error: string }>
|
|
}> {
|
|
await this.ensureInitialized()
|
|
|
|
const {
|
|
maxRetries = 3,
|
|
retryDelayMs = 1000,
|
|
continueOnError = true
|
|
} = options
|
|
|
|
if (!blobs || blobs.length === 0) {
|
|
return {
|
|
totalRequested: 0,
|
|
successfulChanges: 0,
|
|
failedChanges: 0,
|
|
errors: []
|
|
}
|
|
}
|
|
|
|
this.logger.info(`Starting batch tier change for ${blobs.length} blobs`)
|
|
|
|
const stats = {
|
|
totalRequested: blobs.length,
|
|
successfulChanges: 0,
|
|
failedChanges: 0,
|
|
errors: [] as Array<{ blobName: string; error: string }>
|
|
}
|
|
|
|
// Process each blob (Azure doesn't have batch tier API, so we parallelize)
|
|
const CONCURRENT_LIMIT = 10 // Limit concurrent operations to avoid throttling
|
|
|
|
for (let i = 0; i < blobs.length; i += CONCURRENT_LIMIT) {
|
|
const batch = blobs.slice(i, i + CONCURRENT_LIMIT)
|
|
|
|
const promises = batch.map(async ({ blobName, tier }) => {
|
|
let retryCount = 0
|
|
|
|
while (retryCount <= maxRetries) {
|
|
try {
|
|
await this.setBlobTier(blobName, tier)
|
|
return { blobName, success: true, error: null }
|
|
} catch (error: any) {
|
|
// Handle throttling
|
|
if (this.isThrottlingError(error)) {
|
|
this.logger.warn(`Tier change throttled for ${blobName}, retrying...`)
|
|
await this.handleThrottling(error)
|
|
retryCount++
|
|
|
|
if (retryCount <= maxRetries) {
|
|
const delay = retryDelayMs * Math.pow(2, retryCount - 1)
|
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Other errors
|
|
if (retryCount < maxRetries) {
|
|
retryCount++
|
|
const delay = retryDelayMs * Math.pow(2, retryCount - 1)
|
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
continue
|
|
}
|
|
|
|
// Max retries exceeded
|
|
return {
|
|
blobName,
|
|
success: false,
|
|
error: error.message || String(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Should never reach here, but TypeScript needs a return
|
|
return {
|
|
blobName,
|
|
success: false,
|
|
error: 'Max retries exceeded'
|
|
}
|
|
})
|
|
|
|
const results = await Promise.all(promises)
|
|
|
|
for (const result of results) {
|
|
if (result.success) {
|
|
stats.successfulChanges++
|
|
} else {
|
|
stats.failedChanges++
|
|
if (result.error) {
|
|
stats.errors.push({
|
|
blobName: result.blobName,
|
|
error: result.error
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
this.logger.info(
|
|
`Batch tier change completed: ${stats.successfulChanges}/${stats.totalRequested} successful, ${stats.failedChanges} failed`
|
|
)
|
|
|
|
return stats
|
|
}
|
|
|
|
/**
|
|
* Check if a blob in Archive tier has been rehydrated and is ready to read
|
|
* Archive tier blobs must be rehydrated before they can be read
|
|
*
|
|
* @param blobName - Name of the blob to check
|
|
* @returns Promise that resolves to rehydration status
|
|
*
|
|
* @example
|
|
* const status = await storage.checkRehydrationStatus('entities/nouns/vectors/ab/id.json')
|
|
* if (status.isRehydrated) {
|
|
* // Blob is ready to read
|
|
* const data = await storage.readObjectFromPath('entities/nouns/vectors/ab/id.json')
|
|
* }
|
|
*/
|
|
public async checkRehydrationStatus(blobName: string): Promise<{
|
|
isArchived: boolean
|
|
isRehydrating: boolean
|
|
isRehydrated: boolean
|
|
rehydratePriority?: string
|
|
}> {
|
|
await this.ensureInitialized()
|
|
|
|
try {
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(blobName)
|
|
const properties = await blockBlobClient.getProperties()
|
|
|
|
const tier = properties.accessTier
|
|
const archiveStatus = properties.archiveStatus
|
|
|
|
return {
|
|
isArchived: tier === 'Archive',
|
|
isRehydrating: archiveStatus === 'rehydrate-pending-to-hot' || archiveStatus === 'rehydrate-pending-to-cool',
|
|
isRehydrated: tier === 'Hot' || tier === 'Cool',
|
|
rehydratePriority: properties.rehydratePriority
|
|
}
|
|
} catch (error: any) {
|
|
if (error.statusCode === 404 || error.code === 'BlobNotFound') {
|
|
throw new Error(`Blob not found: ${blobName}`)
|
|
}
|
|
|
|
this.logger.error(`Failed to check rehydration status for ${blobName}:`, error)
|
|
throw new Error(`Failed to check rehydration status: ${error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rehydrate an archived blob (move from Archive to Hot or Cool tier)
|
|
* Note: Rehydration can take several hours depending on priority
|
|
*
|
|
* @param blobName - Name of the blob to rehydrate
|
|
* @param targetTier - Target tier after rehydration ('Hot' or 'Cool')
|
|
* @param priority - Rehydration priority ('Standard' or 'High')
|
|
* Standard: Up to 15 hours, cheaper
|
|
* High: Up to 1 hour, more expensive
|
|
* @returns Promise that resolves when rehydration is initiated
|
|
*
|
|
* @example
|
|
* // Rehydrate with standard priority (cheaper, slower)
|
|
* await storage.rehydrateBlob('entities/nouns/vectors/ab/id.json', 'Cool', 'Standard')
|
|
*
|
|
* // Check status
|
|
* const status = await storage.checkRehydrationStatus('entities/nouns/vectors/ab/id.json')
|
|
* console.log(`Rehydrating: ${status.isRehydrating}`)
|
|
*/
|
|
public async rehydrateBlob(
|
|
blobName: string,
|
|
targetTier: 'Hot' | 'Cool',
|
|
priority: 'Standard' | 'High' = 'Standard'
|
|
): Promise<void> {
|
|
await this.ensureInitialized()
|
|
|
|
try {
|
|
this.logger.info(`Rehydrating blob ${blobName} to ${targetTier} tier with ${priority} priority`)
|
|
|
|
const blockBlobClient = this.containerClient!.getBlockBlobClient(blobName)
|
|
|
|
// Set tier with rehydration priority
|
|
await blockBlobClient.setAccessTier(targetTier, {
|
|
rehydratePriority: priority
|
|
})
|
|
|
|
this.logger.info(`Successfully initiated rehydration for ${blobName}`)
|
|
} catch (error: any) {
|
|
if (error.statusCode === 404 || error.code === 'BlobNotFound') {
|
|
throw new Error(`Blob not found: ${blobName}`)
|
|
}
|
|
|
|
this.logger.error(`Failed to rehydrate blob ${blobName}:`, error)
|
|
throw new Error(`Failed to rehydrate blob: ${error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set lifecycle management policy for automatic tier transitions and deletions (v4.0.0)
|
|
* Automates cost optimization by moving old data to cheaper tiers or deleting it
|
|
*
|
|
* Azure Lifecycle Management rules run once per day and apply to the entire container.
|
|
* Rules are evaluated against blob properties like lastModifiedTime and lastAccessTime.
|
|
*
|
|
* @param options - Lifecycle policy configuration
|
|
* @returns Promise that resolves when policy is set
|
|
*
|
|
* @example
|
|
* // Auto-archive old vectors for 99% cost savings
|
|
* await storage.setLifecyclePolicy({
|
|
* rules: [
|
|
* {
|
|
* name: 'archiveOldVectors',
|
|
* enabled: true,
|
|
* type: 'Lifecycle',
|
|
* definition: {
|
|
* filters: {
|
|
* blobTypes: ['blockBlob'],
|
|
* prefixMatch: ['entities/nouns/vectors/']
|
|
* },
|
|
* actions: {
|
|
* baseBlob: {
|
|
* tierToCool: { daysAfterModificationGreaterThan: 30 },
|
|
* tierToArchive: { daysAfterModificationGreaterThan: 90 },
|
|
* delete: { daysAfterModificationGreaterThan: 365 }
|
|
* }
|
|
* }
|
|
* }
|
|
* }
|
|
* ]
|
|
* })
|
|
*/
|
|
public async setLifecyclePolicy(options: {
|
|
rules: Array<{
|
|
name: string
|
|
enabled: boolean
|
|
type: 'Lifecycle'
|
|
definition: {
|
|
filters: {
|
|
blobTypes: string[]
|
|
prefixMatch?: string[]
|
|
}
|
|
actions: {
|
|
baseBlob: {
|
|
tierToCool?: { daysAfterModificationGreaterThan: number }
|
|
tierToArchive?: { daysAfterModificationGreaterThan: number }
|
|
delete?: { daysAfterModificationGreaterThan: number }
|
|
}
|
|
}
|
|
}
|
|
}>
|
|
}): Promise<void> {
|
|
await this.ensureInitialized()
|
|
|
|
if (!this.accountName) {
|
|
throw new Error('Lifecycle policies require accountName to be configured')
|
|
}
|
|
|
|
try {
|
|
this.logger.info(`Setting lifecycle policy with ${options.rules.length} rules`)
|
|
|
|
const { BlobServiceClient } = await import('@azure/storage-blob')
|
|
|
|
// Get blob service client
|
|
let blobServiceClient: any
|
|
if (this.connectionString) {
|
|
blobServiceClient = BlobServiceClient.fromConnectionString(this.connectionString)
|
|
} else if (this.accountName && this.accountKey) {
|
|
const { StorageSharedKeyCredential } = await import('@azure/storage-blob')
|
|
const credential = new StorageSharedKeyCredential(this.accountName, this.accountKey)
|
|
blobServiceClient = new BlobServiceClient(
|
|
`https://${this.accountName}.blob.core.windows.net`,
|
|
credential
|
|
)
|
|
} else if (this.accountName && this.sasToken) {
|
|
blobServiceClient = new BlobServiceClient(
|
|
`https://${this.accountName}.blob.core.windows.net${this.sasToken}`
|
|
)
|
|
} else if (this.accountName) {
|
|
const { DefaultAzureCredential } = await import('@azure/identity')
|
|
const credential = new DefaultAzureCredential()
|
|
blobServiceClient = new BlobServiceClient(
|
|
`https://${this.accountName}.blob.core.windows.net`,
|
|
credential
|
|
)
|
|
} else {
|
|
throw new Error('Cannot set lifecycle policy without valid authentication')
|
|
}
|
|
|
|
// Get service properties to modify lifecycle policy
|
|
const serviceProperties = await blobServiceClient.getProperties()
|
|
|
|
// Format rules according to Azure's expected structure
|
|
const lifecyclePolicy = {
|
|
rules: options.rules.map(rule => ({
|
|
enabled: rule.enabled,
|
|
name: rule.name,
|
|
type: rule.type,
|
|
definition: {
|
|
filters: {
|
|
blobTypes: rule.definition.filters.blobTypes,
|
|
...(rule.definition.filters.prefixMatch && {
|
|
prefixMatch: rule.definition.filters.prefixMatch
|
|
})
|
|
},
|
|
actions: {
|
|
baseBlob: {
|
|
...(rule.definition.actions.baseBlob.tierToCool && {
|
|
tierToCool: rule.definition.actions.baseBlob.tierToCool
|
|
}),
|
|
...(rule.definition.actions.baseBlob.tierToArchive && {
|
|
tierToArchive: rule.definition.actions.baseBlob.tierToArchive
|
|
}),
|
|
...(rule.definition.actions.baseBlob.delete && {
|
|
delete: rule.definition.actions.baseBlob.delete
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}))
|
|
}
|
|
|
|
// Set the lifecycle management policy
|
|
await blobServiceClient.setProperties({
|
|
...serviceProperties,
|
|
blobAnalyticsLogging: serviceProperties.blobAnalyticsLogging,
|
|
hourMetrics: serviceProperties.hourMetrics,
|
|
minuteMetrics: serviceProperties.minuteMetrics,
|
|
cors: serviceProperties.cors,
|
|
deleteRetentionPolicy: serviceProperties.deleteRetentionPolicy,
|
|
staticWebsite: serviceProperties.staticWebsite,
|
|
// Set lifecycle policy
|
|
lifecyclePolicy
|
|
})
|
|
|
|
this.logger.info(`Successfully set lifecycle policy with ${options.rules.length} rules`)
|
|
} catch (error: any) {
|
|
this.logger.error('Failed to set lifecycle policy:', error)
|
|
throw new Error(`Failed to set lifecycle policy: ${error.message || error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the current lifecycle management policy
|
|
*
|
|
* @returns Promise that resolves to the current policy or null if not set
|
|
*
|
|
* @example
|
|
* const policy = await storage.getLifecyclePolicy()
|
|
* if (policy) {
|
|
* console.log(`Found ${policy.rules.length} lifecycle rules`)
|
|
* }
|
|
*/
|
|
public async getLifecyclePolicy(): Promise<{
|
|
rules: Array<{
|
|
name: string
|
|
enabled: boolean
|
|
type: string
|
|
definition: {
|
|
filters: {
|
|
blobTypes: string[]
|
|
prefixMatch?: string[]
|
|
}
|
|
actions: {
|
|
baseBlob: {
|
|
tierToCool?: { daysAfterModificationGreaterThan: number }
|
|
tierToArchive?: { daysAfterModificationGreaterThan: number }
|
|
delete?: { daysAfterModificationGreaterThan: number }
|
|
}
|
|
}
|
|
}
|
|
}>
|
|
} | null> {
|
|
await this.ensureInitialized()
|
|
|
|
if (!this.accountName) {
|
|
throw new Error('Lifecycle policies require accountName to be configured')
|
|
}
|
|
|
|
try {
|
|
this.logger.info('Getting lifecycle policy')
|
|
|
|
const { BlobServiceClient } = await import('@azure/storage-blob')
|
|
|
|
// Get blob service client
|
|
let blobServiceClient: any
|
|
if (this.connectionString) {
|
|
blobServiceClient = BlobServiceClient.fromConnectionString(this.connectionString)
|
|
} else if (this.accountName && this.accountKey) {
|
|
const { StorageSharedKeyCredential } = await import('@azure/storage-blob')
|
|
const credential = new StorageSharedKeyCredential(this.accountName, this.accountKey)
|
|
blobServiceClient = new BlobServiceClient(
|
|
`https://${this.accountName}.blob.core.windows.net`,
|
|
credential
|
|
)
|
|
} else if (this.accountName && this.sasToken) {
|
|
blobServiceClient = new BlobServiceClient(
|
|
`https://${this.accountName}.blob.core.windows.net${this.sasToken}`
|
|
)
|
|
} else if (this.accountName) {
|
|
const { DefaultAzureCredential } = await import('@azure/identity')
|
|
const credential = new DefaultAzureCredential()
|
|
blobServiceClient = new BlobServiceClient(
|
|
`https://${this.accountName}.blob.core.windows.net`,
|
|
credential
|
|
)
|
|
} else {
|
|
throw new Error('Cannot get lifecycle policy without valid authentication')
|
|
}
|
|
|
|
// Get service properties
|
|
const serviceProperties = await blobServiceClient.getProperties()
|
|
|
|
if (!serviceProperties.lifecyclePolicy || !serviceProperties.lifecyclePolicy.rules) {
|
|
this.logger.info('No lifecycle policy configured')
|
|
return null
|
|
}
|
|
|
|
this.logger.info(`Found lifecycle policy with ${serviceProperties.lifecyclePolicy.rules.length} rules`)
|
|
|
|
return serviceProperties.lifecyclePolicy
|
|
} catch (error: any) {
|
|
this.logger.error('Failed to get lifecycle policy:', error)
|
|
throw new Error(`Failed to get lifecycle policy: ${error.message || error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove the lifecycle management policy
|
|
* All automatic tier transitions and deletions will stop
|
|
*
|
|
* @returns Promise that resolves when policy is removed
|
|
*
|
|
* @example
|
|
* await storage.removeLifecyclePolicy()
|
|
* console.log('Lifecycle policy removed - auto-archival disabled')
|
|
*/
|
|
public async removeLifecyclePolicy(): Promise<void> {
|
|
await this.ensureInitialized()
|
|
|
|
if (!this.accountName) {
|
|
throw new Error('Lifecycle policies require accountName to be configured')
|
|
}
|
|
|
|
try {
|
|
this.logger.info('Removing lifecycle policy')
|
|
|
|
const { BlobServiceClient } = await import('@azure/storage-blob')
|
|
|
|
// Get blob service client
|
|
let blobServiceClient: any
|
|
if (this.connectionString) {
|
|
blobServiceClient = BlobServiceClient.fromConnectionString(this.connectionString)
|
|
} else if (this.accountName && this.accountKey) {
|
|
const { StorageSharedKeyCredential } = await import('@azure/storage-blob')
|
|
const credential = new StorageSharedKeyCredential(this.accountName, this.accountKey)
|
|
blobServiceClient = new BlobServiceClient(
|
|
`https://${this.accountName}.blob.core.windows.net`,
|
|
credential
|
|
)
|
|
} else if (this.accountName && this.sasToken) {
|
|
blobServiceClient = new BlobServiceClient(
|
|
`https://${this.accountName}.blob.core.windows.net${this.sasToken}`
|
|
)
|
|
} else if (this.accountName) {
|
|
const { DefaultAzureCredential } = await import('@azure/identity')
|
|
const credential = new DefaultAzureCredential()
|
|
blobServiceClient = new BlobServiceClient(
|
|
`https://${this.accountName}.blob.core.windows.net`,
|
|
credential
|
|
)
|
|
} else {
|
|
throw new Error('Cannot remove lifecycle policy without valid authentication')
|
|
}
|
|
|
|
// Get service properties
|
|
const serviceProperties = await blobServiceClient.getProperties()
|
|
|
|
// Set properties without lifecycle policy (removes it)
|
|
await blobServiceClient.setProperties({
|
|
...serviceProperties,
|
|
blobAnalyticsLogging: serviceProperties.blobAnalyticsLogging,
|
|
hourMetrics: serviceProperties.hourMetrics,
|
|
minuteMetrics: serviceProperties.minuteMetrics,
|
|
cors: serviceProperties.cors,
|
|
deleteRetentionPolicy: serviceProperties.deleteRetentionPolicy,
|
|
staticWebsite: serviceProperties.staticWebsite,
|
|
// Remove lifecycle policy by not including it
|
|
lifecyclePolicy: undefined
|
|
})
|
|
|
|
this.logger.info('Successfully removed lifecycle policy')
|
|
} catch (error: any) {
|
|
this.logger.error('Failed to remove lifecycle policy:', error)
|
|
throw new Error(`Failed to remove lifecycle policy: ${error.message || error}`)
|
|
}
|
|
}
|
|
}
|