2025-10-08 14:08:43 -07:00
|
|
|
/**
|
|
|
|
|
* Google Cloud Storage Adapter (Native)
|
|
|
|
|
* Uses the native @google-cloud/storage library for optimal performance and authentication
|
|
|
|
|
*
|
|
|
|
|
* Supports multiple authentication methods:
|
|
|
|
|
* 1. Application Default Credentials (ADC) - Automatic in Cloud Run/GCE
|
|
|
|
|
* 2. Service Account Key File
|
|
|
|
|
* 3. Service Account Credentials Object
|
|
|
|
|
* 4. HMAC Keys (fallback for backward compatibility)
|
|
|
|
|
*/
|
|
|
|
|
|
2025-10-17 12:29:27 -07:00
|
|
|
import {
|
|
|
|
|
GraphVerb,
|
|
|
|
|
HNSWNoun,
|
|
|
|
|
HNSWVerb,
|
|
|
|
|
NounMetadata,
|
|
|
|
|
VerbMetadata,
|
|
|
|
|
HNSWNounWithMetadata,
|
|
|
|
|
HNSWVerbWithMetadata,
|
fix(storage): v4.8.0 metadata architecture refactoring - FIXES VFS bug
CRITICAL FIX: VFS bug that persisted through v4.5.1-v4.7.4 is NOW FIXED.
Root Cause:
- Storage adapters were not properly extracting standard fields from metadata
- This caused getVerbsBySource_internal() to return 0 relationships despite relationships existing
- VFS PathResolver couldn't navigate directory structure
Solution - Metadata Architecture Refactoring:
1. Move standard fields to top-level of HNSWNounWithMetadata and HNSWVerbWithMetadata
- type, createdAt, updatedAt, confidence, weight, service, data, createdBy
2. Update all 9 storage adapters to extract standard fields from metadata on load
3. Maintain backward compatibility at storage layer (metadata files unchanged)
Changes:
- src/coreTypes.ts: Update HNSWNounWithMetadata and HNSWVerbWithMetadata interfaces
- Add top-level standard fields
- Change data type from unknown to Record<string, any>
- Add confidence field to GraphVerb
- src/storage/baseStorage.ts: Add type cast pattern for standard field extraction
- src/storage/adapters/*.ts: Fix all 9 adapters (memoryStorage, fileSystemStorage, gcsStorage,
s3CompatibleStorage, r2Storage, opfsStorage, azureBlobStorage, typeAwareStorageAdapter)
- Extract standard fields from metadata on load
- Place at top-level of returned entities
- src/api/DataAPI.ts: Read fields from top-level instead of metadata
- src/graph/graphAdjacencyIndex.ts: Convert HNSWVerbWithMetadata to GraphVerb format
- src/utils/metadataIndex.ts: Fix typo (metadata → entityOrMetadata)
- src/types/brainy.types.ts: Add createdBy field to AddParams
- src/types/graphTypes.ts: Add service field to GraphVerb
Test Results:
✅ VFS bug FIXED - vfs.readdir('/') now returns files (was returning empty array)
✅ getVerbsBySource_internal() now returns relationships correctly
✅ Build succeeds with ZERO compilation errors
✅ 95.7% of tests pass (954/997)
Breaking Changes:
- None - backward compatibility maintained at storage layer
Version: 4.8.0
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 15:43:49 -07:00
|
|
|
StatisticsData,
|
|
|
|
|
NounType
|
2025-10-17 12:29:27 -07:00
|
|
|
} from '../../coreTypes.js'
|
2025-10-08 14:08:43 -07:00
|
|
|
import {
|
|
|
|
|
BaseStorage,
|
2025-10-30 08:54:04 -07:00
|
|
|
StorageBatchConfig,
|
2025-10-08 14:08:43 -07:00
|
|
|
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'
|
2026-01-31 09:09:36 -08:00
|
|
|
import { MetadataWriteBuffer } from '../../utils/metadataWriteBuffer.js'
|
2025-10-08 14:08:43 -07:00
|
|
|
import { getGlobalSocketManager } from '../../utils/adaptiveSocketManager.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'
|
2026-01-07 12:51:05 -08:00
|
|
|
import { InitMode } from './baseStorageAdapter.js'
|
2025-10-08 14:08:43 -07:00
|
|
|
|
|
|
|
|
// Type aliases for better readability
|
|
|
|
|
type HNSWNode = HNSWNoun
|
|
|
|
|
type Edge = HNSWVerb
|
|
|
|
|
|
|
|
|
|
// GCS client types - dynamically imported to avoid issues in browser environments
|
|
|
|
|
type Storage = any
|
|
|
|
|
type Bucket = any
|
|
|
|
|
type File = any
|
|
|
|
|
|
2025-10-10 14:49:53 -07:00
|
|
|
// GCS API limits
|
|
|
|
|
// Maximum value for maxResults parameter in GCS API calls
|
|
|
|
|
// Values above this cause "Invalid unsigned integer" errors
|
|
|
|
|
const MAX_GCS_PAGE_SIZE = 5000
|
|
|
|
|
|
2025-10-08 14:08:43 -07:00
|
|
|
/**
|
|
|
|
|
* Native Google Cloud Storage adapter for server environments
|
|
|
|
|
* Uses the @google-cloud/storage library with Application Default Credentials
|
|
|
|
|
*
|
|
|
|
|
* Authentication priority:
|
|
|
|
|
* 1. Application Default Credentials (if no credentials provided)
|
|
|
|
|
* 2. Service Account Key File (if keyFilename provided)
|
|
|
|
|
* 3. Service Account Credentials Object (if credentials provided)
|
|
|
|
|
* 4. HMAC Keys (if accessKeyId/secretAccessKey provided)
|
2025-11-05 17:01:44 -08:00
|
|
|
*
|
2026-01-27 15:38:21 -08:00
|
|
|
* Type-aware storage now built into BaseStorage
|
2025-11-05 17:01:44 -08:00
|
|
|
* - Removed 10 *_internal method overrides (now inherit from BaseStorage's type-first implementation)
|
|
|
|
|
* - Removed 2 pagination method overrides (getNounsWithPagination, getVerbsWithPagination)
|
|
|
|
|
* - Updated HNSW methods to use BaseStorage's getNoun/saveNoun (type-first paths)
|
|
|
|
|
* - All operations now use type-first paths: entities/nouns/{type}/vectors/{shard}/{id}.json
|
2025-10-08 14:08:43 -07:00
|
|
|
*/
|
|
|
|
|
export class GcsStorage extends BaseStorage {
|
|
|
|
|
private storage: Storage | null = null
|
|
|
|
|
private bucket: Bucket | null = null
|
|
|
|
|
private bucketName: string
|
|
|
|
|
private keyFilename?: string
|
|
|
|
|
private credentials?: object
|
|
|
|
|
private accessKeyId?: string
|
|
|
|
|
private secretAccessKey?: 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 maxConcurrentOperations: number = 100
|
|
|
|
|
private baseBatchSize: number = 10
|
|
|
|
|
private currentBatchSize: number = 10
|
|
|
|
|
private lastMemoryCheck: number = 0
|
|
|
|
|
private memoryCheckInterval: number = 5000 // Check every 5 seconds
|
|
|
|
|
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
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Write buffering always enabled for consistent performance
|
2025-12-02 13:43:04 -08:00
|
|
|
// Removes dynamic mode switching complexity - cloud storage always benefits from batching
|
2025-10-08 14:08:43 -07:00
|
|
|
|
|
|
|
|
// Multi-level cache manager for efficient data access
|
|
|
|
|
private nounCacheManager: CacheManager<HNSWNode>
|
|
|
|
|
private verbCacheManager: CacheManager<Edge>
|
|
|
|
|
|
|
|
|
|
// Module logger
|
|
|
|
|
private logger = createModuleLogger('GcsStorage')
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// HNSW mutex locks to prevent read-modify-write races
|
2025-11-05 17:01:44 -08:00
|
|
|
private hnswLocks = new Map<string, Promise<void>>()
|
|
|
|
|
|
2025-10-20 11:11:54 -07:00
|
|
|
// Configuration options
|
|
|
|
|
private skipInitialScan: boolean = false
|
|
|
|
|
private skipCountsFile: boolean = false
|
|
|
|
|
|
2025-10-08 14:08:43 -07:00
|
|
|
/**
|
|
|
|
|
* Initialize the storage adapter
|
2026-01-07 12:51:05 -08:00
|
|
|
*
|
2025-10-08 14:08:43 -07:00
|
|
|
* @param options Configuration options for Google Cloud Storage
|
2026-01-07 12:51:05 -08:00
|
|
|
*
|
|
|
|
|
* @example Zero-config (recommended) - auto-detects Cloud Run/Lambda for fast init
|
|
|
|
|
* ```typescript
|
|
|
|
|
* const storage = new GcsStorage({ bucketName: 'my-bucket' })
|
|
|
|
|
* await storage.init() // <200ms in Cloud Run, blocking locally
|
|
|
|
|
* ```
|
|
|
|
|
*
|
|
|
|
|
* @example Force progressive mode for all environments
|
|
|
|
|
* ```typescript
|
|
|
|
|
* const storage = new GcsStorage({
|
|
|
|
|
* bucketName: 'my-bucket',
|
|
|
|
|
* initMode: 'progressive' // Always <200ms init
|
|
|
|
|
* })
|
|
|
|
|
* ```
|
|
|
|
|
*
|
|
|
|
|
* @example Force strict validation (useful for tests)
|
|
|
|
|
* ```typescript
|
|
|
|
|
* const storage = new GcsStorage({
|
|
|
|
|
* bucketName: 'my-bucket',
|
|
|
|
|
* initMode: 'strict' // Always validate during init
|
|
|
|
|
* })
|
|
|
|
|
* ```
|
2025-10-08 14:08:43 -07:00
|
|
|
*/
|
|
|
|
|
constructor(options: {
|
|
|
|
|
bucketName: string
|
|
|
|
|
|
|
|
|
|
// Service account authentication
|
|
|
|
|
keyFilename?: string
|
|
|
|
|
credentials?: object
|
|
|
|
|
|
|
|
|
|
// HMAC authentication (backward compatibility)
|
|
|
|
|
accessKeyId?: string
|
|
|
|
|
secretAccessKey?: string
|
|
|
|
|
|
2026-01-07 12:51:05 -08:00
|
|
|
/**
|
2026-01-27 15:38:21 -08:00
|
|
|
* Initialization mode for fast cold starts
|
2026-01-07 12:51:05 -08:00
|
|
|
*
|
|
|
|
|
* - `'auto'` (default): Progressive in cloud environments (Cloud Run, Lambda),
|
|
|
|
|
* strict locally. Zero-config optimization.
|
|
|
|
|
* - `'progressive'`: Always use fast init (<200ms). Bucket validation and
|
|
|
|
|
* count loading happen in background. First write validates bucket.
|
|
|
|
|
* - `'strict'`: Traditional blocking init. Validates bucket and loads counts
|
|
|
|
|
* before init() returns.
|
|
|
|
|
*
|
|
|
|
|
*/
|
|
|
|
|
initMode?: InitMode
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @deprecated Use `initMode: 'progressive'` instead.
|
2026-01-27 15:38:21 -08:00
|
|
|
* Will be removed in a future version.
|
2026-01-07 12:51:05 -08:00
|
|
|
*/
|
2025-10-20 11:11:54 -07:00
|
|
|
skipInitialScan?: boolean
|
2026-01-07 12:51:05 -08:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @deprecated Use `initMode: 'progressive'` instead.
|
2026-01-27 15:38:21 -08:00
|
|
|
* Will be removed in a future version.
|
2026-01-07 12:51:05 -08:00
|
|
|
*/
|
2025-10-20 11:11:54 -07:00
|
|
|
skipCountsFile?: boolean
|
|
|
|
|
|
2025-10-08 14:08:43 -07:00
|
|
|
// Cache and operation configuration
|
|
|
|
|
cacheConfig?: {
|
|
|
|
|
hotCacheMaxSize?: number
|
|
|
|
|
hotCacheEvictionThreshold?: number
|
|
|
|
|
warmCacheTTL?: number
|
|
|
|
|
}
|
|
|
|
|
readOnly?: boolean
|
|
|
|
|
}) {
|
|
|
|
|
super()
|
|
|
|
|
this.bucketName = options.bucketName
|
|
|
|
|
this.keyFilename = options.keyFilename
|
|
|
|
|
this.credentials = options.credentials
|
|
|
|
|
this.accessKeyId = options.accessKeyId
|
|
|
|
|
this.secretAccessKey = options.secretAccessKey
|
2026-01-07 12:51:05 -08:00
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Handle initMode and deprecated skip* flags
|
2026-01-07 12:51:05 -08:00
|
|
|
if (options.initMode) {
|
|
|
|
|
this.initMode = options.initMode
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Deprecation warnings for skip* flags
|
|
|
|
|
if (options.skipInitialScan) {
|
|
|
|
|
console.warn(
|
|
|
|
|
'[GcsStorage] DEPRECATION WARNING: skipInitialScan is deprecated. ' +
|
2026-01-27 15:38:21 -08:00
|
|
|
'Use initMode: "progressive" instead. Will be removed in a future version.'
|
2026-01-07 12:51:05 -08:00
|
|
|
)
|
|
|
|
|
this.skipInitialScan = true
|
|
|
|
|
}
|
|
|
|
|
if (options.skipCountsFile) {
|
|
|
|
|
console.warn(
|
|
|
|
|
'[GcsStorage] DEPRECATION WARNING: skipCountsFile is deprecated. ' +
|
2026-01-27 15:38:21 -08:00
|
|
|
'Use initMode: "progressive" instead. Will be removed in a future version.'
|
2026-01-07 12:51:05 -08:00
|
|
|
)
|
|
|
|
|
this.skipCountsFile = true
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-08 14:08:43 -07:00
|
|
|
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)
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Write buffering always enabled - no env var check needed
|
2026-01-31 09:09:36 -08:00
|
|
|
|
|
|
|
|
// Initialize metadata write buffer for cloud rate limit protection
|
|
|
|
|
this.metadataWriteBuffer = new MetadataWriteBuffer(
|
|
|
|
|
(path, data) => this.writeObjectToPath(path, data),
|
|
|
|
|
{ maxBufferSize: 200, flushIntervalMs: 200, concurrencyLimit: 10 }
|
|
|
|
|
)
|
2025-10-08 14:08:43 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize the storage adapter
|
2026-01-07 12:51:05 -08:00
|
|
|
*
|
2026-01-27 15:38:21 -08:00
|
|
|
* Supports progressive initialization for fast cold starts
|
2026-01-07 12:51:05 -08:00
|
|
|
*
|
|
|
|
|
* | Mode | Init Time | When |
|
|
|
|
|
* |------|-----------|------|
|
|
|
|
|
* | `progressive` | <200ms | Cloud Run, Lambda, Azure Functions |
|
|
|
|
|
* | `strict` | 100-500ms+ | Local development, tests |
|
|
|
|
|
* | `auto` | Detected | Default - best of both |
|
|
|
|
|
*
|
|
|
|
|
* In progressive mode:
|
|
|
|
|
* - SDK import and bucket reference: ~50ms (unavoidable)
|
|
|
|
|
* - Write buffers and caches: ~10ms
|
|
|
|
|
* - Mark as initialized: READY
|
|
|
|
|
* - Background: validate bucket, load counts
|
|
|
|
|
*
|
|
|
|
|
* First write operation validates bucket existence (lazy validation).
|
2025-10-08 14:08:43 -07:00
|
|
|
*/
|
|
|
|
|
public async init(): Promise<void> {
|
|
|
|
|
if (this.isInitialized) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-01-07 12:51:05 -08:00
|
|
|
// Import Google Cloud Storage SDK only when needed (~50ms)
|
2025-10-08 14:08:43 -07:00
|
|
|
const { Storage } = await import('@google-cloud/storage')
|
|
|
|
|
|
|
|
|
|
// Configure the GCS client based on available credentials
|
|
|
|
|
const clientConfig: any = {}
|
|
|
|
|
|
|
|
|
|
// Priority 1: Service Account Key File
|
|
|
|
|
if (this.keyFilename) {
|
|
|
|
|
clientConfig.keyFilename = this.keyFilename
|
|
|
|
|
prodLog.info('🔐 GCS: Using Service Account Key File')
|
|
|
|
|
}
|
|
|
|
|
// Priority 2: Service Account Credentials Object
|
|
|
|
|
else if (this.credentials) {
|
|
|
|
|
clientConfig.credentials = this.credentials
|
|
|
|
|
prodLog.info('🔐 GCS: Using Service Account Credentials')
|
|
|
|
|
}
|
|
|
|
|
// Priority 3: HMAC Keys (S3 compatibility)
|
|
|
|
|
else if (this.accessKeyId && this.secretAccessKey) {
|
|
|
|
|
clientConfig.credentials = {
|
|
|
|
|
client_email: 'hmac-user@example.com',
|
|
|
|
|
private_key: this.secretAccessKey
|
|
|
|
|
}
|
|
|
|
|
prodLog.warn('⚠️ GCS: Using HMAC keys (consider migrating to ADC)')
|
|
|
|
|
}
|
|
|
|
|
// Priority 4: Application Default Credentials (default)
|
|
|
|
|
else {
|
|
|
|
|
// No credentials needed - ADC will be used automatically
|
|
|
|
|
prodLog.info('🔐 GCS: Using Application Default Credentials (ADC)')
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-07 12:51:05 -08:00
|
|
|
// Create the GCS client (no network calls)
|
2025-10-08 14:08:43 -07:00
|
|
|
this.storage = new Storage(clientConfig)
|
|
|
|
|
|
2026-01-07 12:51:05 -08:00
|
|
|
// Get reference to the bucket (no network calls)
|
2025-10-08 14:08:43 -07:00
|
|
|
this.bucket = this.storage.bucket(this.bucketName)
|
|
|
|
|
|
2026-01-07 12:51:05 -08:00
|
|
|
// Determine initialization mode
|
|
|
|
|
const effectiveMode = this.resolveInitMode()
|
|
|
|
|
const isCloud = this.detectCloudEnvironment()
|
2025-10-08 14:08:43 -07:00
|
|
|
|
2026-01-07 12:51:05 -08:00
|
|
|
prodLog.info(`🚀 GCS init mode: ${effectiveMode} (detected cloud: ${isCloud})`)
|
2025-10-08 14:08:43 -07:00
|
|
|
|
|
|
|
|
// Initialize write buffers for high-volume mode
|
|
|
|
|
const storageId = `gcs-${this.bucketName}`
|
|
|
|
|
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`)
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// CRITICAL FIX: Clear any stale cache entries from previous runs
|
2025-10-13 08:32:36 -07:00
|
|
|
// This prevents cache poisoning from causing silent failures on container restart
|
|
|
|
|
prodLog.info('🧹 Clearing cache from previous run to prevent cache poisoning')
|
|
|
|
|
this.nounCacheManager.clear()
|
|
|
|
|
this.verbCacheManager.clear()
|
|
|
|
|
prodLog.info('✅ Cache cleared - starting fresh')
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Progressive vs Strict initialization
|
2026-01-07 12:51:05 -08:00
|
|
|
if (effectiveMode === 'progressive') {
|
|
|
|
|
// PROGRESSIVE MODE: Fast init, background validation
|
|
|
|
|
// Mark as initialized immediately - ready to accept operations
|
|
|
|
|
// Bucket validation happens lazily on first write
|
|
|
|
|
// Count loading happens in background
|
|
|
|
|
|
|
|
|
|
prodLog.info(`✅ GCS progressive init complete: ${this.bucketName} (validation deferred)`)
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Initialize GraphAdjacencyIndex and type statistics
|
2026-01-07 12:51:05 -08:00
|
|
|
await super.init()
|
|
|
|
|
|
|
|
|
|
// Schedule background tasks (non-blocking)
|
|
|
|
|
this.scheduleBackgroundInit()
|
|
|
|
|
} else {
|
|
|
|
|
// STRICT MODE: Traditional blocking initialization
|
|
|
|
|
// Verify bucket exists and is accessible (blocking)
|
|
|
|
|
const [exists] = await this.bucket.exists()
|
|
|
|
|
if (!exists) {
|
|
|
|
|
throw new Error(`Bucket ${this.bucketName} does not exist or is not accessible`)
|
|
|
|
|
}
|
|
|
|
|
this.bucketValidated = true
|
|
|
|
|
|
|
|
|
|
prodLog.info(`✅ Connected to GCS bucket: ${this.bucketName}`)
|
|
|
|
|
|
|
|
|
|
// Initialize counts from storage (blocking)
|
|
|
|
|
await this.initializeCounts()
|
|
|
|
|
this.countsLoaded = true
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Initialize GraphAdjacencyIndex and type statistics
|
2026-01-07 12:51:05 -08:00
|
|
|
await super.init()
|
|
|
|
|
|
|
|
|
|
// Mark background tasks as complete (nothing to do in background)
|
|
|
|
|
this.backgroundTasksComplete = true
|
|
|
|
|
}
|
2025-10-08 14:08:43 -07:00
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.error('Failed to initialize GCS storage:', error)
|
|
|
|
|
throw new Error(`Failed to initialize GCS storage: ${error}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-07 12:51:05 -08:00
|
|
|
// =============================================
|
2026-01-27 15:38:21 -08:00
|
|
|
// Progressive Initialization
|
2026-01-07 12:51:05 -08:00
|
|
|
// =============================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run background initialization tasks for GCS.
|
|
|
|
|
*
|
|
|
|
|
* Called in progressive mode after init() returns. Performs:
|
|
|
|
|
* 1. Bucket validation (in background)
|
|
|
|
|
* 2. Count loading from storage (in background)
|
|
|
|
|
*
|
|
|
|
|
* These tasks don't block the main thread, allowing fast cold starts.
|
|
|
|
|
*
|
|
|
|
|
* @protected
|
|
|
|
|
* @override
|
|
|
|
|
*/
|
|
|
|
|
protected async runBackgroundInit(): Promise<void> {
|
|
|
|
|
const startTime = Date.now()
|
|
|
|
|
prodLog.info('[GCS Background] Starting background initialization...')
|
|
|
|
|
|
|
|
|
|
// Run validation and count loading in parallel
|
|
|
|
|
const validationPromise = this.validateBucketInBackground()
|
|
|
|
|
const countsPromise = this.loadCountsInBackground()
|
|
|
|
|
|
|
|
|
|
// Wait for both to complete (but we're already initialized)
|
|
|
|
|
await Promise.all([validationPromise, countsPromise])
|
|
|
|
|
|
|
|
|
|
const elapsed = Date.now() - startTime
|
|
|
|
|
prodLog.info(`[GCS Background] Background init complete in ${elapsed}ms`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate bucket existence in background.
|
|
|
|
|
*
|
|
|
|
|
* Stores result in bucketValidated/bucketValidationError for lazy use.
|
|
|
|
|
*
|
|
|
|
|
* @private
|
|
|
|
|
*/
|
|
|
|
|
private async validateBucketInBackground(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const [exists] = await this.bucket!.exists()
|
|
|
|
|
if (exists) {
|
|
|
|
|
this.bucketValidated = true
|
|
|
|
|
prodLog.info(`[GCS Background] Bucket validated: ${this.bucketName}`)
|
|
|
|
|
} else {
|
|
|
|
|
this.bucketValidationError = new Error(
|
|
|
|
|
`Bucket ${this.bucketName} does not exist or is not accessible`
|
|
|
|
|
)
|
|
|
|
|
prodLog.warn(`[GCS Background] Bucket validation failed: ${this.bucketName}`)
|
|
|
|
|
}
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
this.bucketValidationError = new Error(
|
|
|
|
|
`Bucket validation failed: ${error.message || error}`
|
|
|
|
|
)
|
|
|
|
|
prodLog.error(`[GCS Background] Bucket validation error:`, error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Load counts from storage in background.
|
|
|
|
|
*
|
|
|
|
|
* Uses the existing initializeCounts() logic but in background.
|
|
|
|
|
*
|
|
|
|
|
* @private
|
|
|
|
|
*/
|
|
|
|
|
private async loadCountsInBackground(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
await this.initializeCounts()
|
|
|
|
|
this.countsLoaded = true
|
|
|
|
|
prodLog.info(`[GCS Background] Counts loaded: ${this.totalNounCount} nouns, ${this.totalVerbCount} verbs`)
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
// Non-fatal in progressive mode - counts start at 0
|
|
|
|
|
prodLog.warn(`[GCS Background] Failed to load counts (starting at 0): ${error.message}`)
|
|
|
|
|
this.countsLoaded = true // Mark as loaded even on error (0 is valid)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Ensure bucket is validated before write operations.
|
|
|
|
|
*
|
|
|
|
|
* In progressive mode, bucket validation is deferred until the first
|
|
|
|
|
* write operation. This method validates the bucket and caches the
|
|
|
|
|
* result (or error) for subsequent calls.
|
|
|
|
|
*
|
|
|
|
|
* @throws Error if bucket does not exist or is not accessible
|
|
|
|
|
* @protected
|
|
|
|
|
* @override
|
|
|
|
|
*/
|
|
|
|
|
protected async ensureValidatedForWrite(): Promise<void> {
|
|
|
|
|
// If already validated, nothing to do
|
|
|
|
|
if (this.bucketValidated) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If we have a cached validation error from background init, throw it
|
|
|
|
|
if (this.bucketValidationError) {
|
|
|
|
|
throw this.bucketValidationError
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Perform synchronous validation (first write in progressive mode)
|
|
|
|
|
try {
|
|
|
|
|
prodLog.info(`[GCS] Lazy bucket validation on first write: ${this.bucketName}`)
|
|
|
|
|
const [exists] = await this.bucket!.exists()
|
|
|
|
|
if (!exists) {
|
|
|
|
|
const error = new Error(`Bucket ${this.bucketName} does not exist or is not accessible`)
|
|
|
|
|
this.bucketValidationError = error
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
this.bucketValidated = true
|
|
|
|
|
prodLog.info(`[GCS] Bucket validated successfully: ${this.bucketName}`)
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
// Cache the error for fast-fail on subsequent writes
|
|
|
|
|
if (!this.bucketValidationError) {
|
|
|
|
|
this.bucketValidationError = error
|
|
|
|
|
}
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-08 14:08:43 -07:00
|
|
|
/**
|
|
|
|
|
* Get the GCS object key 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 GCS object key 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 GCS-specific throttling errors
|
|
|
|
|
*/
|
|
|
|
|
protected isThrottlingError(error: any): boolean {
|
|
|
|
|
// First check base class detection
|
|
|
|
|
if (super.isThrottlingError(error)) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GCS-specific throttling detection
|
|
|
|
|
const statusCode = error.code
|
|
|
|
|
const message = error.message?.toLowerCase() || ''
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
statusCode === 429 || // Too Many Requests
|
|
|
|
|
statusCode === 503 || // Service Unavailable
|
|
|
|
|
statusCode === 'RATE_LIMIT_EXCEEDED' ||
|
|
|
|
|
message.includes('quota') ||
|
|
|
|
|
message.includes('rate limit') ||
|
|
|
|
|
message.includes('too many requests')
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-09 17:35:01 -07:00
|
|
|
/**
|
2026-01-27 15:38:21 -08:00
|
|
|
* Override base class to enable smart batching for cloud storage
|
2025-10-09 17:35:01 -07:00
|
|
|
*
|
|
|
|
|
* GCS is cloud storage with network latency (~50ms per write).
|
|
|
|
|
* Smart batching reduces writes from 1000 ops → 100 batches.
|
|
|
|
|
*
|
|
|
|
|
* @returns true (GCS is cloud storage)
|
|
|
|
|
*/
|
|
|
|
|
protected isCloudStorage(): boolean {
|
|
|
|
|
return true // GCS benefits from batching
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-08 14:08:43 -07:00
|
|
|
/**
|
|
|
|
|
* 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Removed checkVolumeMode() - write buffering always enabled for cloud storage
|
2025-10-08 14:08:43 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Flush noun buffer to GCS
|
|
|
|
|
*/
|
|
|
|
|
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 GCS
|
|
|
|
|
*/
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Removed saveNoun_internal - now inherit from BaseStorage's type-first implementation
|
2025-10-08 14:08:43 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Save a node to storage
|
2026-01-27 15:38:21 -08:00
|
|
|
* Always uses write buffer for consistent performance
|
2025-10-08 14:08:43 -07:00
|
|
|
*/
|
|
|
|
|
protected async saveNode(node: HNSWNode): Promise<void> {
|
|
|
|
|
await this.ensureInitialized()
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Always use write buffer - cloud storage benefits from batching
|
2025-12-02 13:43:04 -08:00
|
|
|
if (this.nounWriteBuffer) {
|
|
|
|
|
this.logger.trace(`📝 BUFFERING: Adding noun ${node.id} to write buffer`)
|
2025-12-02 13:23:18 -08:00
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Populate cache BEFORE buffering for read-after-write consistency
|
2025-12-02 13:23:18 -08:00
|
|
|
if (node.vector && Array.isArray(node.vector) && node.vector.length > 0) {
|
|
|
|
|
this.nounCacheManager.set(node.id, node)
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-08 14:08:43 -07:00
|
|
|
await this.nounWriteBuffer.add(node.id, node)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-02 13:43:04 -08:00
|
|
|
// Fallback to direct write if buffer not initialized (shouldn't happen after init)
|
2025-10-08 14:08:43 -07:00
|
|
|
await this.saveNodeDirect(node)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Save a node directly to GCS (bypass buffer)
|
2026-01-07 12:51:05 -08:00
|
|
|
*
|
2026-01-27 15:38:21 -08:00
|
|
|
* Performs lazy bucket validation on first write in progressive mode.
|
2025-10-08 14:08:43 -07:00
|
|
|
*/
|
|
|
|
|
private async saveNodeDirect(node: HNSWNode): Promise<void> {
|
2026-01-27 15:38:21 -08:00
|
|
|
// Lazy bucket validation for progressive init
|
2026-01-07 12:51:05 -08:00
|
|
|
await this.ensureValidatedForWrite()
|
|
|
|
|
|
2025-10-08 14:08:43 -07:00
|
|
|
// 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
|
2025-10-10 16:25:51 -07:00
|
|
|
// CRITICAL: Only save lightweight vector data (no metadata)
|
|
|
|
|
// Metadata is saved separately via saveNounMetadata() (2-file system)
|
2025-10-08 14:08:43 -07:00
|
|
|
const serializableNode = {
|
2025-10-10 16:25:51 -07:00
|
|
|
id: node.id,
|
|
|
|
|
vector: node.vector,
|
2025-10-08 14:08:43 -07:00
|
|
|
connections: Object.fromEntries(
|
|
|
|
|
Array.from(node.connections.entries()).map(([level, nounIds]) => [
|
|
|
|
|
level,
|
|
|
|
|
Array.from(nounIds)
|
|
|
|
|
])
|
2025-10-10 16:25:51 -07:00
|
|
|
),
|
|
|
|
|
level: node.level || 0
|
|
|
|
|
// NO metadata field - saved separately for scalability
|
2025-10-08 14:08:43 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get the GCS key with UUID-based sharding
|
|
|
|
|
const key = this.getNounKey(node.id)
|
|
|
|
|
|
|
|
|
|
// Save to GCS
|
|
|
|
|
const file = this.bucket!.file(key)
|
|
|
|
|
await file.save(JSON.stringify(serializableNode, null, 2), {
|
|
|
|
|
contentType: 'application/json',
|
|
|
|
|
resumable: false // For small objects, non-resumable is faster
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// CRITICAL FIX: Only cache nodes with non-empty vectors
|
2025-10-13 09:00:06 -07:00
|
|
|
// 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)
|
|
|
|
|
}
|
2025-10-13 09:23:27 -07:00
|
|
|
// Note: Empty vectors are intentional during HNSW lazy mode - not logged
|
2025-10-08 14:08:43 -07:00
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Count tracking happens in baseStorage.saveNounMetadata_internal
|
fix(storage): resolve count synchronization race condition across all storage adapters
Fixed critical bug where entity and relationship counts were not being tracked correctly
during add(), relate(), and import() operations. The root cause was a race condition where
count increment code tried to read metadata before it was saved to storage.
Core Fixes:
- Modified baseStorage.saveNounMetadata_internal to increment counts AFTER metadata is saved
- Modified baseStorage.saveVerbMetadata_internal to increment verb counts AFTER metadata is saved
- Added verb type to VerbMetadata to avoid circular dependency during count tracking
- Refactored verb count methods to prevent mutex deadlocks (synchronous base + async Safe wrapper)
Storage Adapter Cleanup:
- Removed broken count increment code from FileSystemStorage, GcsStorage, R2Storage, AzureBlobStorage
- Updated MemoryStorage comments to reflect centralized fix
- All count tracking now centralized in baseStorage (fixes ALL adapters automatically)
New Utilities:
- Added rebuildCounts utility to repair corrupted counts.json from actual storage data
- Added comprehensive integration tests for count synchronization across all operations
Verification:
- All 8 storage adapters verified (FileSystem, GCS, Memory, S3Compatible, R2, Azure, OPFS, TypeAware)
- All code paths verified (add, relate, import, batch, update, delete)
- 599 tests passing (no regressions)
- No deadlocks (tests complete in 6s vs 150s+)
Fixes #1 and #2 reported by Workshop team
2025-10-21 10:58:44 -07:00
|
|
|
// This fixes the race condition where metadata didn't exist yet
|
2025-10-08 14:08:43 -07:00
|
|
|
|
|
|
|
|
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}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Removed getNoun_internal - now inherit from BaseStorage's type-first implementation
|
2025-10-08 14:08:43 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get a node from storage
|
|
|
|
|
*/
|
|
|
|
|
protected async getNode(id: string): Promise<HNSWNode | null> {
|
|
|
|
|
await this.ensureInitialized()
|
|
|
|
|
|
2025-10-13 09:23:27 -07:00
|
|
|
// Check cache first
|
2025-10-13 09:00:06 -07:00
|
|
|
const cached: HNSWNode | null = await this.nounCacheManager.get(id)
|
2025-10-13 08:32:36 -07:00
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Validate cached object before returning
|
2025-10-13 08:32:36 -07:00
|
|
|
if (cached !== undefined && cached !== null) {
|
2025-10-13 09:00:06 -07:00
|
|
|
// Validate cached object has required fields (including non-empty vector!)
|
|
|
|
|
if (!cached.id || !cached.vector || !Array.isArray(cached.vector) || cached.vector.length === 0) {
|
2025-10-13 09:23:27 -07:00
|
|
|
// Invalid cache detected - log and auto-recover
|
|
|
|
|
prodLog.warn(`[GCS] 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`)
|
2025-10-13 09:00:06 -07:00
|
|
|
this.nounCacheManager.delete(id)
|
|
|
|
|
// Fall through to load from GCS
|
|
|
|
|
} else {
|
2025-10-13 09:23:27 -07:00
|
|
|
// Valid cache hit
|
2025-10-13 09:00:06 -07:00
|
|
|
this.logger.trace(`Cache hit for noun ${id}`)
|
|
|
|
|
return cached
|
|
|
|
|
}
|
2025-10-13 08:32:36 -07:00
|
|
|
} else if (cached === null) {
|
2025-10-13 09:23:27 -07:00
|
|
|
prodLog.warn(`[GCS] Cache contains null for ${id.substring(0, 8)} - reloading from storage`)
|
2025-10-08 14:08:43 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Apply backpressure
|
|
|
|
|
const requestId = await this.applyBackpressure()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
this.logger.trace(`Getting node ${id}`)
|
|
|
|
|
|
|
|
|
|
// Get the GCS key with UUID-based sharding
|
|
|
|
|
const key = this.getNounKey(id)
|
|
|
|
|
|
|
|
|
|
// Download from GCS
|
|
|
|
|
const file = this.bucket!.file(key)
|
|
|
|
|
const [contents] = await file.download()
|
|
|
|
|
|
|
|
|
|
// Parse JSON
|
|
|
|
|
const data = JSON.parse(contents.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[]))
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-10 16:25:51 -07:00
|
|
|
// CRITICAL: Only return lightweight vector data (no metadata)
|
|
|
|
|
// Metadata is retrieved separately via getNounMetadata() (2-file system)
|
2025-10-08 14:08:43 -07:00
|
|
|
const node: HNSWNode = {
|
|
|
|
|
id: data.id,
|
|
|
|
|
vector: data.vector,
|
|
|
|
|
connections,
|
2025-10-10 16:25:51 -07:00
|
|
|
level: data.level || 0
|
|
|
|
|
// NO metadata field - retrieved separately for scalability
|
2025-10-08 14:08:43 -07:00
|
|
|
}
|
|
|
|
|
|
2025-10-13 09:00:06 -07:00
|
|
|
// 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) {
|
2025-10-13 08:32:36 -07:00
|
|
|
this.nounCacheManager.set(id, node)
|
|
|
|
|
} else {
|
2025-10-13 09:23:27 -07:00
|
|
|
prodLog.warn(`[GCS] Not caching invalid node ${id.substring(0, 8)} (missing id/vector or empty vector)`)
|
2025-10-13 08:32:36 -07:00
|
|
|
}
|
2025-10-08 14:08:43 -07:00
|
|
|
|
|
|
|
|
this.logger.trace(`Successfully retrieved node ${id}`)
|
|
|
|
|
this.releaseBackpressure(true, requestId)
|
|
|
|
|
return node
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
this.releaseBackpressure(false, requestId)
|
|
|
|
|
|
2025-10-11 09:50:29 -07:00
|
|
|
// DIAGNOSTIC LOGGING: Log EVERY error before any conditional checks
|
|
|
|
|
const key = this.getNounKey(id)
|
|
|
|
|
prodLog.error(`[getNode] ❌ EXCEPTION CAUGHT:`)
|
|
|
|
|
prodLog.error(`[getNode] UUID: ${id}`)
|
|
|
|
|
prodLog.error(`[getNode] Path: ${key}`)
|
|
|
|
|
prodLog.error(`[getNode] Bucket: ${this.bucketName}`)
|
|
|
|
|
prodLog.error(`[getNode] Error type: ${error?.constructor?.name || typeof error}`)
|
|
|
|
|
prodLog.error(`[getNode] Error code: ${JSON.stringify(error?.code)}`)
|
|
|
|
|
prodLog.error(`[getNode] Error message: ${error?.message || String(error)}`)
|
|
|
|
|
prodLog.error(`[getNode] Error object:`, JSON.stringify(error, null, 2))
|
|
|
|
|
|
2025-10-08 14:08:43 -07:00
|
|
|
// Check if this is a "not found" error
|
|
|
|
|
if (error.code === 404) {
|
2025-10-13 08:32:36 -07:00
|
|
|
prodLog.warn(`[getNode] Identified as 404 error - returning null WITHOUT caching`)
|
|
|
|
|
// CRITICAL FIX: Do NOT cache null values
|
2025-10-08 14:08:43 -07:00
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle throttling
|
|
|
|
|
if (this.isThrottlingError(error)) {
|
2025-10-11 09:50:29 -07:00
|
|
|
prodLog.warn(`[getNode] Identified as throttling error - rethrowing`)
|
2025-10-08 14:08:43 -07:00
|
|
|
await this.handleThrottling(error)
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-11 09:50:29 -07:00
|
|
|
// All other errors should throw, not return null
|
|
|
|
|
prodLog.error(`[getNode] Unhandled error - rethrowing`)
|
2025-10-08 14:08:43 -07:00
|
|
|
this.logger.error(`Failed to get node ${id}:`, error)
|
|
|
|
|
throw BrainyError.fromError(error, `getNoun(${id})`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Removed deleteNoun_internal - now inherit from BaseStorage's type-first implementation
|
2025-10-08 14:08:43 -07:00
|
|
|
|
|
|
|
|
/**
|
2025-10-09 13:10:06 -07:00
|
|
|
* Write an object to a specific path in GCS
|
|
|
|
|
* Primitive operation required by base class
|
2026-01-07 12:51:05 -08:00
|
|
|
*
|
2026-01-27 15:38:21 -08:00
|
|
|
* Performs lazy bucket validation on first write in progressive mode.
|
2025-10-09 13:10:06 -07:00
|
|
|
* @protected
|
2025-10-08 14:08:43 -07:00
|
|
|
*/
|
2025-10-09 13:10:06 -07:00
|
|
|
protected async writeObjectToPath(path: string, data: any): Promise<void> {
|
2025-10-08 14:08:43 -07:00
|
|
|
await this.ensureInitialized()
|
2026-01-27 15:38:21 -08:00
|
|
|
// Lazy bucket validation for progressive init
|
2026-01-07 12:51:05 -08:00
|
|
|
await this.ensureValidatedForWrite()
|
2025-10-08 14:08:43 -07:00
|
|
|
|
2026-01-31 09:09:36 -08:00
|
|
|
const MAX_RETRIES = 5
|
|
|
|
|
let lastError: any
|
2025-10-08 14:08:43 -07:00
|
|
|
|
2026-01-31 09:09:36 -08:00
|
|
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
|
|
|
try {
|
|
|
|
|
this.logger.trace(`Writing object to path: ${path}`)
|
2025-10-08 14:08:43 -07:00
|
|
|
|
2026-01-31 09:09:36 -08:00
|
|
|
const file = this.bucket!.file(path)
|
|
|
|
|
await file.save(JSON.stringify(data, null, 2), {
|
|
|
|
|
contentType: 'application/json',
|
|
|
|
|
resumable: false
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
this.logger.trace(`Object written successfully to ${path}`)
|
|
|
|
|
if (attempt > 0) {
|
|
|
|
|
this.clearThrottlingState()
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
lastError = error
|
|
|
|
|
|
|
|
|
|
if (this.isThrottlingError(error) && attempt < MAX_RETRIES) {
|
|
|
|
|
const baseDelay = Math.min(100 * Math.pow(2, attempt), 5000)
|
|
|
|
|
const jitter = baseDelay * 0.2 * (Math.random() - 0.5)
|
|
|
|
|
const delay = Math.round(baseDelay + jitter)
|
|
|
|
|
this.logger.warn(
|
|
|
|
|
`Throttled writing ${path} (attempt ${attempt + 1}/${MAX_RETRIES + 1}), retrying in ${delay}ms`
|
|
|
|
|
)
|
|
|
|
|
await this.handleThrottling(error)
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, delay))
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
break
|
|
|
|
|
}
|
2025-10-08 14:08:43 -07:00
|
|
|
}
|
2026-01-31 09:09:36 -08:00
|
|
|
|
|
|
|
|
this.logger.error(`Failed to write object to ${path}:`, lastError)
|
|
|
|
|
throw new Error(`Failed to write object to ${path}: ${lastError}`)
|
2025-10-08 14:08:43 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-09 13:10:06 -07:00
|
|
|
* Read an object from a specific path in GCS
|
|
|
|
|
* Primitive operation required by base class
|
|
|
|
|
* @protected
|
2025-10-08 14:08:43 -07:00
|
|
|
*/
|
2025-10-09 13:10:06 -07:00
|
|
|
protected async readObjectFromPath(path: string): Promise<any | null> {
|
2025-10-08 14:08:43 -07:00
|
|
|
await this.ensureInitialized()
|
|
|
|
|
|
|
|
|
|
try {
|
2025-10-09 13:10:06 -07:00
|
|
|
this.logger.trace(`Reading object from path: ${path}`)
|
2025-10-08 14:08:43 -07:00
|
|
|
|
2025-10-09 13:10:06 -07:00
|
|
|
const file = this.bucket!.file(path)
|
2025-10-08 14:08:43 -07:00
|
|
|
const [contents] = await file.download()
|
|
|
|
|
|
2025-10-09 13:10:06 -07:00
|
|
|
const data = JSON.parse(contents.toString())
|
2025-10-08 14:08:43 -07:00
|
|
|
|
2025-10-09 13:10:06 -07:00
|
|
|
this.logger.trace(`Object read successfully from ${path}`)
|
|
|
|
|
return data
|
2025-10-08 14:08:43 -07:00
|
|
|
} catch (error: any) {
|
|
|
|
|
// Check if this is a "not found" error
|
|
|
|
|
if (error.code === 404) {
|
2025-10-09 13:10:06 -07:00
|
|
|
this.logger.trace(`Object not found at ${path}`)
|
2025-10-08 14:08:43 -07:00
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-09 13:10:06 -07:00
|
|
|
this.logger.error(`Failed to read object from ${path}:`, error)
|
|
|
|
|
throw BrainyError.fromError(error, `readObjectFromPath(${path})`)
|
2025-10-08 14:08:43 -07:00
|
|
|
}
|
feat: add storage-level batch operations to eliminate N+1 query patterns
Implements comprehensive batching infrastructure (brain.batchGet, storage.getNounMetadataBatch, storage.getVerbsBySourceBatch) with native cloud adapter APIs for GCS, S3, R2, and Azure. VFS operations now use parallel breadth-first traversal with batching, reducing directory reads from 22 sequential calls to 2-3 batched calls. Improves cloud storage performance by 90%+ (12.7s → <1s for 12 files). Fully compatible with type-aware storage, sharding, COW, fork(), and all indexes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 08:59:11 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-27 15:38:21 -08:00
|
|
|
* Batch read multiple objects from GCS
|
feat: add storage-level batch operations to eliminate N+1 query patterns
Implements comprehensive batching infrastructure (brain.batchGet, storage.getNounMetadataBatch, storage.getVerbsBySourceBatch) with native cloud adapter APIs for GCS, S3, R2, and Azure. VFS operations now use parallel breadth-first traversal with batching, reducing directory reads from 22 sequential calls to 2-3 batched calls. Improves cloud storage performance by 90%+ (12.7s → <1s for 12 files). Fully compatible with type-aware storage, sharding, COW, fork(), and all indexes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 08:59:11 -08:00
|
|
|
*
|
|
|
|
|
* **Performance**: GCS-optimized parallel downloads
|
|
|
|
|
* - Uses Promise.all() for concurrent requests
|
|
|
|
|
* - Respects GCS rate limits (100 concurrent by default)
|
|
|
|
|
* - Chunks large batches to prevent memory issues
|
|
|
|
|
*
|
|
|
|
|
* **GCS Specifics**:
|
|
|
|
|
* - No true "batch API" - uses parallel GetObject operations
|
|
|
|
|
* - Optimal concurrency: 50-100 concurrent downloads
|
|
|
|
|
* - Each download is a separate HTTPS request
|
|
|
|
|
*
|
|
|
|
|
* @param paths Array of GCS object paths to read
|
|
|
|
|
* @returns Map of path → data (only successful reads included)
|
|
|
|
|
*
|
|
|
|
|
* @public - Called by baseStorage.readBatchFromAdapter()
|
|
|
|
|
*/
|
|
|
|
|
public async readBatch(paths: string[]): Promise<Map<string, any>> {
|
|
|
|
|
await this.ensureInitialized()
|
|
|
|
|
|
|
|
|
|
const results = new Map<string, any>()
|
|
|
|
|
if (paths.length === 0) return results
|
|
|
|
|
|
|
|
|
|
// Get batch configuration for optimal GCS performance
|
|
|
|
|
const batchConfig = this.getBatchConfig()
|
|
|
|
|
const chunkSize = batchConfig.maxConcurrent || 100
|
|
|
|
|
|
|
|
|
|
this.logger.debug(`[GCS Batch] Reading ${paths.length} objects in chunks of ${chunkSize}`)
|
|
|
|
|
|
|
|
|
|
// Process in chunks to respect rate limits and prevent memory issues
|
|
|
|
|
for (let i = 0; i < paths.length; i += chunkSize) {
|
|
|
|
|
const chunk = paths.slice(i, i + chunkSize)
|
|
|
|
|
this.logger.trace(`[GCS Batch] Processing chunk ${Math.floor(i/chunkSize) + 1}/${Math.ceil(paths.length/chunkSize)}`)
|
|
|
|
|
|
|
|
|
|
// Parallel download for this chunk
|
|
|
|
|
const chunkResults = await Promise.allSettled(
|
|
|
|
|
chunk.map(async (path) => {
|
|
|
|
|
try {
|
|
|
|
|
const file = this.bucket!.file(path)
|
|
|
|
|
const [contents] = await file.download()
|
|
|
|
|
const data = JSON.parse(contents.toString())
|
|
|
|
|
return { path, data, success: true }
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
// Silently skip 404s (expected for missing entities)
|
|
|
|
|
if (error.code === 404) {
|
|
|
|
|
return { path, data: null, success: false }
|
|
|
|
|
}
|
|
|
|
|
// Log other errors but don't fail the batch
|
|
|
|
|
this.logger.warn(`[GCS Batch] Failed to read ${path}: ${error.message}`)
|
|
|
|
|
return { path, data: null, success: false }
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Collect successful results
|
|
|
|
|
for (const result of chunkResults) {
|
|
|
|
|
if (result.status === 'fulfilled' && result.value.success && result.value.data !== null) {
|
|
|
|
|
results.set(result.value.path, result.value.data)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.logger.debug(`[GCS Batch] Successfully read ${results.size}/${paths.length} objects`)
|
|
|
|
|
return results
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-27 15:38:21 -08:00
|
|
|
* Get GCS-specific batch configuration
|
feat: add storage-level batch operations to eliminate N+1 query patterns
Implements comprehensive batching infrastructure (brain.batchGet, storage.getNounMetadataBatch, storage.getVerbsBySourceBatch) with native cloud adapter APIs for GCS, S3, R2, and Azure. VFS operations now use parallel breadth-first traversal with batching, reducing directory reads from 22 sequential calls to 2-3 batched calls. Improves cloud storage performance by 90%+ (12.7s → <1s for 12 files). Fully compatible with type-aware storage, sharding, COW, fork(), and all indexes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 08:59:11 -08:00
|
|
|
*
|
|
|
|
|
* GCS performs well with high concurrency due to HTTP/2 multiplexing
|
|
|
|
|
*
|
|
|
|
|
* @public - Overrides BaseStorage.getBatchConfig()
|
|
|
|
|
*/
|
|
|
|
|
public getBatchConfig(): StorageBatchConfig {
|
|
|
|
|
return {
|
|
|
|
|
maxBatchSize: 1000, // GCS can handle large batches
|
|
|
|
|
batchDelayMs: 0, // No rate limiting needed (HTTP/2 handles it)
|
|
|
|
|
maxConcurrent: 100, // Optimal for GCS (tested up to 200)
|
|
|
|
|
supportsParallelWrites: true,
|
|
|
|
|
rateLimit: {
|
|
|
|
|
operationsPerSecond: 1000, // GCS is fast
|
|
|
|
|
burstCapacity: 5000
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-08 14:08:43 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-09 13:10:06 -07:00
|
|
|
* Delete an object from a specific path in GCS
|
|
|
|
|
* Primitive operation required by base class
|
2026-01-07 12:51:05 -08:00
|
|
|
*
|
2026-01-27 15:38:21 -08:00
|
|
|
* Performs lazy bucket validation on first delete in progressive mode.
|
2025-10-09 13:10:06 -07:00
|
|
|
* @protected
|
2025-10-08 14:08:43 -07:00
|
|
|
*/
|
2025-10-09 13:10:06 -07:00
|
|
|
protected async deleteObjectFromPath(path: string): Promise<void> {
|
2025-10-08 14:08:43 -07:00
|
|
|
await this.ensureInitialized()
|
2026-01-27 15:38:21 -08:00
|
|
|
// Lazy bucket validation for progressive init
|
2026-01-07 12:51:05 -08:00
|
|
|
await this.ensureValidatedForWrite()
|
2025-10-08 14:08:43 -07:00
|
|
|
|
|
|
|
|
try {
|
2025-10-09 13:10:06 -07:00
|
|
|
this.logger.trace(`Deleting object at path: ${path}`)
|
2025-10-08 14:08:43 -07:00
|
|
|
|
2025-10-09 13:10:06 -07:00
|
|
|
const file = this.bucket!.file(path)
|
|
|
|
|
await file.delete()
|
2025-10-08 14:08:43 -07:00
|
|
|
|
2025-10-09 13:10:06 -07:00
|
|
|
this.logger.trace(`Object deleted successfully from ${path}`)
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
// If already deleted (404), treat as success
|
|
|
|
|
if (error.code === 404) {
|
|
|
|
|
this.logger.trace(`Object at ${path} not found (already deleted)`)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-10-08 14:08:43 -07:00
|
|
|
|
2025-10-09 13:10:06 -07:00
|
|
|
this.logger.error(`Failed to delete object from ${path}:`, error)
|
|
|
|
|
throw new Error(`Failed to delete object from ${path}: ${error}`)
|
2025-10-08 14:08:43 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-09 13:10:06 -07:00
|
|
|
* List all objects under a specific prefix in GCS
|
|
|
|
|
* Primitive operation required by base class
|
|
|
|
|
* @protected
|
2025-10-08 14:08:43 -07:00
|
|
|
*/
|
2025-10-09 13:10:06 -07:00
|
|
|
protected async listObjectsUnderPath(prefix: string): Promise<string[]> {
|
2025-10-08 14:08:43 -07:00
|
|
|
await this.ensureInitialized()
|
|
|
|
|
|
|
|
|
|
try {
|
2025-10-09 13:10:06 -07:00
|
|
|
this.logger.trace(`Listing objects under prefix: ${prefix}`)
|
2025-10-08 14:08:43 -07:00
|
|
|
|
2025-10-09 13:10:06 -07:00
|
|
|
const [files] = await this.bucket!.getFiles({ prefix })
|
2025-10-08 14:08:43 -07:00
|
|
|
|
2025-10-09 13:10:06 -07:00
|
|
|
const paths = files.map((file: any) => file.name).filter((name: string) => name && name.length > 0)
|
2025-10-08 14:08:43 -07:00
|
|
|
|
2025-10-09 13:10:06 -07:00
|
|
|
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}`)
|
2025-10-08 14:08:43 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Removed saveVerb_internal - now inherit from BaseStorage's type-first implementation
|
2025-10-08 14:08:43 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Save an edge to storage
|
2026-01-27 15:38:21 -08:00
|
|
|
* Always uses write buffer for consistent performance
|
2025-10-08 14:08:43 -07:00
|
|
|
*/
|
|
|
|
|
protected async saveEdge(edge: Edge): Promise<void> {
|
|
|
|
|
await this.ensureInitialized()
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Always use write buffer - cloud storage benefits from batching
|
2025-12-02 13:43:04 -08:00
|
|
|
if (this.verbWriteBuffer) {
|
2025-10-08 14:08:43 -07:00
|
|
|
this.logger.trace(`📝 BUFFERING: Adding verb ${edge.id} to write buffer`)
|
2025-12-02 13:23:18 -08:00
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Populate cache BEFORE buffering for read-after-write consistency
|
2025-12-02 13:23:18 -08:00
|
|
|
this.verbCacheManager.set(edge.id, edge)
|
|
|
|
|
|
2025-10-08 14:08:43 -07:00
|
|
|
await this.verbWriteBuffer.add(edge.id, edge)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-02 13:43:04 -08:00
|
|
|
// Fallback to direct write if buffer not initialized (shouldn't happen after init)
|
2025-10-08 14:08:43 -07:00
|
|
|
await this.saveEdgeDirect(edge)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Save an edge directly to GCS (bypass buffer)
|
2026-01-07 12:51:05 -08:00
|
|
|
*
|
2026-01-27 15:38:21 -08:00
|
|
|
* Performs lazy bucket validation on first write in progressive mode.
|
2025-10-08 14:08:43 -07:00
|
|
|
*/
|
|
|
|
|
private async saveEdgeDirect(edge: Edge): Promise<void> {
|
2026-01-27 15:38:21 -08:00
|
|
|
// Lazy bucket validation for progressive init
|
2026-01-07 12:51:05 -08:00
|
|
|
await this.ensureValidatedForWrite()
|
|
|
|
|
|
2025-10-08 14:08:43 -07:00
|
|
|
const requestId = await this.applyBackpressure()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
this.logger.trace(`Saving edge ${edge.id}`)
|
|
|
|
|
|
|
|
|
|
// Convert connections Map to serializable format
|
2026-01-27 15:38:21 -08:00
|
|
|
// ARCHITECTURAL FIX: Include core relational fields in verb vector file
|
fix: metadata explosion bug - 69K files reduced to ~1K
Critical fix for metadata indexing that was creating 60+ chunk files per entity.
Root cause: Vector embeddings (384-dimensional arrays) were being indexed in
metadata, causing each dimension to create a separate chunk file with numeric
field names ("0", "1", "2", etc.).
Changes:
- Modified extractIndexableFields() to exclude vector/embedding fields
- Added NEVER_INDEX set: ['vector', 'embedding', 'embeddings', 'connections']
- Added safety check to skip arrays > 10 elements
- Preserves small array indexing (tags, categories, roles)
Impact:
- Reduces metadata files from 69,429 → ~1,200 (58x reduction)
- Fixes server initialization hangs
- Fixes metadata batch loading stalling at batch 23
- Fixes VFS getDescendants() hanging with large datasets
- Fixes Graph View UI not loading
Test Results:
- 7/7 integration tests passing
- Verified: 6 chunk files for 10 entities (was 7,210 before fix)
- 611/622 unit tests passing
Files Modified:
- src/utils/metadataIndex.ts - Core fix
- src/coreTypes.ts - HNSWVerb type enforcement with VerbType enum
- src/storage/adapters/* - Include core relational fields in HNSWVerb
- src/storage/adapters/baseStorageAdapter.ts - Type enforcement (HNSWNoun, GraphVerb)
- tests/integration/metadata-vector-exclusion.test.ts - Comprehensive test coverage
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 16:10:31 -07:00
|
|
|
// These fields are essential for 90% of operations - no metadata lookup needed
|
2025-10-08 14:08:43 -07:00
|
|
|
const serializableEdge = {
|
2025-10-10 16:25:51 -07:00
|
|
|
id: edge.id,
|
|
|
|
|
vector: edge.vector,
|
2025-10-08 14:08:43 -07:00
|
|
|
connections: Object.fromEntries(
|
|
|
|
|
Array.from(edge.connections.entries()).map(([level, verbIds]) => [
|
|
|
|
|
level,
|
|
|
|
|
Array.from(verbIds)
|
|
|
|
|
])
|
fix: metadata explosion bug - 69K files reduced to ~1K
Critical fix for metadata indexing that was creating 60+ chunk files per entity.
Root cause: Vector embeddings (384-dimensional arrays) were being indexed in
metadata, causing each dimension to create a separate chunk file with numeric
field names ("0", "1", "2", etc.).
Changes:
- Modified extractIndexableFields() to exclude vector/embedding fields
- Added NEVER_INDEX set: ['vector', 'embedding', 'embeddings', 'connections']
- Added safety check to skip arrays > 10 elements
- Preserves small array indexing (tags, categories, roles)
Impact:
- Reduces metadata files from 69,429 → ~1,200 (58x reduction)
- Fixes server initialization hangs
- Fixes metadata batch loading stalling at batch 23
- Fixes VFS getDescendants() hanging with large datasets
- Fixes Graph View UI not loading
Test Results:
- 7/7 integration tests passing
- Verified: 6 chunk files for 10 entities (was 7,210 before fix)
- 611/622 unit tests passing
Files Modified:
- src/utils/metadataIndex.ts - Core fix
- src/coreTypes.ts - HNSWVerb type enforcement with VerbType enum
- src/storage/adapters/* - Include core relational fields in HNSWVerb
- src/storage/adapters/baseStorageAdapter.ts - Type enforcement (HNSWNoun, GraphVerb)
- tests/integration/metadata-vector-exclusion.test.ts - Comprehensive test coverage
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 16:10:31 -07:00
|
|
|
),
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// CORE RELATIONAL DATA
|
fix: metadata explosion bug - 69K files reduced to ~1K
Critical fix for metadata indexing that was creating 60+ chunk files per entity.
Root cause: Vector embeddings (384-dimensional arrays) were being indexed in
metadata, causing each dimension to create a separate chunk file with numeric
field names ("0", "1", "2", etc.).
Changes:
- Modified extractIndexableFields() to exclude vector/embedding fields
- Added NEVER_INDEX set: ['vector', 'embedding', 'embeddings', 'connections']
- Added safety check to skip arrays > 10 elements
- Preserves small array indexing (tags, categories, roles)
Impact:
- Reduces metadata files from 69,429 → ~1,200 (58x reduction)
- Fixes server initialization hangs
- Fixes metadata batch loading stalling at batch 23
- Fixes VFS getDescendants() hanging with large datasets
- Fixes Graph View UI not loading
Test Results:
- 7/7 integration tests passing
- Verified: 6 chunk files for 10 entities (was 7,210 before fix)
- 611/622 unit tests passing
Files Modified:
- src/utils/metadataIndex.ts - Core fix
- src/coreTypes.ts - HNSWVerb type enforcement with VerbType enum
- src/storage/adapters/* - Include core relational fields in HNSWVerb
- src/storage/adapters/baseStorageAdapter.ts - Type enforcement (HNSWNoun, GraphVerb)
- tests/integration/metadata-vector-exclusion.test.ts - Comprehensive test coverage
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 16:10:31 -07:00
|
|
|
verb: edge.verb,
|
|
|
|
|
sourceId: edge.sourceId,
|
|
|
|
|
targetId: edge.targetId,
|
|
|
|
|
|
|
|
|
|
// User metadata (if any) - saved separately for scalability
|
|
|
|
|
// metadata field is saved separately via saveVerbMetadata()
|
2025-10-08 14:08:43 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get the GCS key with UUID-based sharding
|
|
|
|
|
const key = this.getVerbKey(edge.id)
|
|
|
|
|
|
|
|
|
|
// Save to GCS
|
|
|
|
|
const file = this.bucket!.file(key)
|
|
|
|
|
await file.save(JSON.stringify(serializableEdge, null, 2), {
|
|
|
|
|
contentType: 'application/json',
|
|
|
|
|
resumable: false
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Update cache
|
|
|
|
|
this.verbCacheManager.set(edge.id, edge)
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Count tracking happens in baseStorage.saveVerbMetadata_internal
|
fix(storage): resolve count synchronization race condition across all storage adapters
Fixed critical bug where entity and relationship counts were not being tracked correctly
during add(), relate(), and import() operations. The root cause was a race condition where
count increment code tried to read metadata before it was saved to storage.
Core Fixes:
- Modified baseStorage.saveNounMetadata_internal to increment counts AFTER metadata is saved
- Modified baseStorage.saveVerbMetadata_internal to increment verb counts AFTER metadata is saved
- Added verb type to VerbMetadata to avoid circular dependency during count tracking
- Refactored verb count methods to prevent mutex deadlocks (synchronous base + async Safe wrapper)
Storage Adapter Cleanup:
- Removed broken count increment code from FileSystemStorage, GcsStorage, R2Storage, AzureBlobStorage
- Updated MemoryStorage comments to reflect centralized fix
- All count tracking now centralized in baseStorage (fixes ALL adapters automatically)
New Utilities:
- Added rebuildCounts utility to repair corrupted counts.json from actual storage data
- Added comprehensive integration tests for count synchronization across all operations
Verification:
- All 8 storage adapters verified (FileSystem, GCS, Memory, S3Compatible, R2, Azure, OPFS, TypeAware)
- All code paths verified (add, relate, import, batch, update, delete)
- 599 tests passing (no regressions)
- No deadlocks (tests complete in 6s vs 150s+)
Fixes #1 and #2 reported by Workshop team
2025-10-21 10:58:44 -07:00
|
|
|
// This fixes the race condition where metadata didn't exist yet
|
2025-10-08 14:08:43 -07:00
|
|
|
|
|
|
|
|
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}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Removed getVerb_internal - now inherit from BaseStorage's type-first implementation
|
2025-10-08 14:08:43 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 GCS key with UUID-based sharding
|
|
|
|
|
const key = this.getVerbKey(id)
|
|
|
|
|
|
|
|
|
|
// Download from GCS
|
|
|
|
|
const file = this.bucket!.file(key)
|
|
|
|
|
const [contents] = await file.download()
|
|
|
|
|
|
|
|
|
|
// Parse JSON
|
|
|
|
|
const data = JSON.parse(contents.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[]))
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Return HNSWVerb with core relational fields (NO metadata field)
|
2025-10-08 14:08:43 -07:00
|
|
|
const edge: Edge = {
|
|
|
|
|
id: data.id,
|
|
|
|
|
vector: data.vector,
|
fix: metadata explosion bug - 69K files reduced to ~1K
Critical fix for metadata indexing that was creating 60+ chunk files per entity.
Root cause: Vector embeddings (384-dimensional arrays) were being indexed in
metadata, causing each dimension to create a separate chunk file with numeric
field names ("0", "1", "2", etc.).
Changes:
- Modified extractIndexableFields() to exclude vector/embedding fields
- Added NEVER_INDEX set: ['vector', 'embedding', 'embeddings', 'connections']
- Added safety check to skip arrays > 10 elements
- Preserves small array indexing (tags, categories, roles)
Impact:
- Reduces metadata files from 69,429 → ~1,200 (58x reduction)
- Fixes server initialization hangs
- Fixes metadata batch loading stalling at batch 23
- Fixes VFS getDescendants() hanging with large datasets
- Fixes Graph View UI not loading
Test Results:
- 7/7 integration tests passing
- Verified: 6 chunk files for 10 entities (was 7,210 before fix)
- 611/622 unit tests passing
Files Modified:
- src/utils/metadataIndex.ts - Core fix
- src/coreTypes.ts - HNSWVerb type enforcement with VerbType enum
- src/storage/adapters/* - Include core relational fields in HNSWVerb
- src/storage/adapters/baseStorageAdapter.ts - Type enforcement (HNSWNoun, GraphVerb)
- tests/integration/metadata-vector-exclusion.test.ts - Comprehensive test coverage
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 16:10:31 -07:00
|
|
|
connections,
|
|
|
|
|
|
|
|
|
|
// CORE RELATIONAL DATA (read from vector file)
|
|
|
|
|
verb: data.verb,
|
|
|
|
|
sourceId: data.sourceId,
|
2025-10-17 12:29:27 -07:00
|
|
|
targetId: data.targetId
|
fix: metadata explosion bug - 69K files reduced to ~1K
Critical fix for metadata indexing that was creating 60+ chunk files per entity.
Root cause: Vector embeddings (384-dimensional arrays) were being indexed in
metadata, causing each dimension to create a separate chunk file with numeric
field names ("0", "1", "2", etc.).
Changes:
- Modified extractIndexableFields() to exclude vector/embedding fields
- Added NEVER_INDEX set: ['vector', 'embedding', 'embeddings', 'connections']
- Added safety check to skip arrays > 10 elements
- Preserves small array indexing (tags, categories, roles)
Impact:
- Reduces metadata files from 69,429 → ~1,200 (58x reduction)
- Fixes server initialization hangs
- Fixes metadata batch loading stalling at batch 23
- Fixes VFS getDescendants() hanging with large datasets
- Fixes Graph View UI not loading
Test Results:
- 7/7 integration tests passing
- Verified: 6 chunk files for 10 entities (was 7,210 before fix)
- 611/622 unit tests passing
Files Modified:
- src/utils/metadataIndex.ts - Core fix
- src/coreTypes.ts - HNSWVerb type enforcement with VerbType enum
- src/storage/adapters/* - Include core relational fields in HNSWVerb
- src/storage/adapters/baseStorageAdapter.ts - Type enforcement (HNSWNoun, GraphVerb)
- tests/integration/metadata-vector-exclusion.test.ts - Comprehensive test coverage
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 16:10:31 -07:00
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// ✅ NO metadata field
|
2025-10-17 12:29:27 -07:00
|
|
|
// User metadata retrieved separately via getVerbMetadata()
|
2025-10-08 14:08:43 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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.code === 404) {
|
|
|
|
|
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})`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Removed deleteVerb_internal - now inherit from BaseStorage's type-first implementation
|
2025-10-08 14:08:43 -07:00
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Removed pagination overrides - use BaseStorage's type-first implementation
|
2025-11-05 17:01:44 -08:00
|
|
|
// - getNounsWithPagination, getNodesWithPagination, getVerbsWithPagination
|
|
|
|
|
// - getNouns, getVerbs (public wrappers)
|
2025-10-08 14:08:43 -07:00
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Removed 4 query *_internal methods - now inherit from BaseStorage's type-first implementation
|
2025-11-05 17:01:44 -08:00
|
|
|
// (getNounsByNounType_internal, getVerbsBySource_internal, getVerbsByTarget_internal, getVerbsByType_internal)
|
2025-10-08 14:08:43 -07:00
|
|
|
|
2025-10-10 16:25:51 -07:00
|
|
|
/**
|
|
|
|
|
* Batch fetch metadata for multiple noun IDs (efficient for large queries)
|
|
|
|
|
* Uses smaller batches to prevent GCS socket exhaustion
|
|
|
|
|
* @param ids Array of noun IDs to fetch metadata for
|
|
|
|
|
* @returns Map of ID to metadata
|
|
|
|
|
*/
|
|
|
|
|
public async getMetadataBatch(ids: string[]): Promise<Map<string, any>> {
|
|
|
|
|
await this.ensureInitialized()
|
|
|
|
|
|
|
|
|
|
const results = new Map<string, any>()
|
|
|
|
|
const batchSize = 10 // Smaller batches for metadata to prevent socket exhaustion
|
|
|
|
|
|
|
|
|
|
// Process in smaller batches
|
|
|
|
|
for (let i = 0; i < ids.length; i += batchSize) {
|
|
|
|
|
const batch = ids.slice(i, i + batchSize)
|
|
|
|
|
|
|
|
|
|
const batchPromises = batch.map(async (id) => {
|
|
|
|
|
try {
|
|
|
|
|
// CRITICAL: Use getNounMetadata() instead of deprecated getMetadata()
|
|
|
|
|
// This ensures we fetch from the correct noun metadata store (2-file system)
|
|
|
|
|
const metadata = await this.getNounMetadata(id)
|
|
|
|
|
return { id, metadata }
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
// Handle GCS-specific errors
|
|
|
|
|
if (this.isThrottlingError(error)) {
|
|
|
|
|
await this.handleThrottling(error)
|
|
|
|
|
}
|
|
|
|
|
this.logger.debug(`Failed to read metadata for ${id}:`, error)
|
|
|
|
|
return { id, metadata: null }
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const batchResults = await Promise.all(batchPromises)
|
|
|
|
|
|
|
|
|
|
for (const { id, metadata } of batchResults) {
|
|
|
|
|
if (metadata !== null) {
|
|
|
|
|
results.set(id, metadata)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Small yield between batches to prevent overwhelming GCS
|
|
|
|
|
await new Promise(resolve => setImmediate(resolve))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return results
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-08 14:08:43 -07:00
|
|
|
/**
|
|
|
|
|
* Clear all data from storage
|
|
|
|
|
*/
|
|
|
|
|
public async clear(): Promise<void> {
|
|
|
|
|
await this.ensureInitialized()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
this.logger.info('🧹 Clearing all data from GCS bucket...')
|
|
|
|
|
|
|
|
|
|
// Helper function to delete all objects with a given prefix
|
|
|
|
|
const deleteObjectsWithPrefix = async (prefix: string): Promise<void> => {
|
|
|
|
|
const [files] = await this.bucket!.getFiles({ prefix })
|
|
|
|
|
|
|
|
|
|
if (!files || files.length === 0) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Delete each file
|
|
|
|
|
for (const file of files) {
|
|
|
|
|
await file.delete()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Clear ALL data using correct paths
|
feat: COW always-on architecture + cloud storage clear() fix (v5.11.0)
Major architectural improvements and critical bug fixes:
## COW Always-On Architecture
- Removed cowEnabled flag from BaseStorage (COW cannot be disabled)
- Eliminated marker file system (checkClearMarker, createClearMarker)
- Simplified all code paths to assume COW is always enabled
- COW automatically re-initializes after clear() operations
## Critical Bug Fix: Cloud Storage clear()
- Fixed GCS clear() using correct paths (branches/ instead of entities/nouns/)
- Fixed S3Compatible clear() path structure
- Fixed R2 clear() implementation
- Fixed Azure, FileSystem, OPFS, Memory clear() COW flag handling
- clear() now deletes: branches/, _cow/, _system/
- Result: Cloud buckets can now be fully cleared (previously impossible)
## Container Memory Detection
- Auto-detect Docker/K8s/Cloud Run memory limits (cgroup v1/v2)
- Smart memory allocation (75% graph data, 25% query operations)
- Environment variable support (CLOUD_RUN_MEMORY, MEMORY_LIMIT)
- Production-grade containerized deployment support
## CommitLog streamHistory Feature
- Added streamable commit history with pagination
- Efficient memory usage for large commit histories
- Support for branch filtering and time ranges
## Comprehensive Storage Documentation
- Complete v5.11.0 file structure reference
- Detailed path construction algorithms
- 8 common storage scenarios with examples
- Type-first storage, sharding, COW architecture explained
- Public docs: docs/architecture/data-storage-architecture.md (1063 lines)
## Files Modified (14 files)
- All 8 storage adapters (GCS, S3, R2, Azure, FS, OPFS, Memory, Historical)
- BaseStorage core architecture
- CommitLog with streaming
- Brainy memory configuration
- Parameter validation with container detection
- Storage architecture documentation
## Breaking Changes
NONE - COW was already enabled by default. This removes the ability to disable it.
## Migration
No action required. Upgrade and clear() will work correctly on cloud storage.
## Impact
- Users can now clear cloud storage buckets completely
- No more corrupted buckets after clear() operations
- Container deployments automatically optimize memory allocation
- COW is mandatory and always enabled (safer, simpler)
v5.11.0 - Production ready
2025-11-18 13:44:02 -08:00
|
|
|
// Delete entire branches/ directory (includes ALL entities, ALL types, ALL VFS data, ALL forks)
|
|
|
|
|
await deleteObjectsWithPrefix('branches/')
|
|
|
|
|
|
|
|
|
|
// Delete COW version control data
|
2025-11-11 09:04:56 -08:00
|
|
|
await deleteObjectsWithPrefix('_cow/')
|
|
|
|
|
|
feat: COW always-on architecture + cloud storage clear() fix (v5.11.0)
Major architectural improvements and critical bug fixes:
## COW Always-On Architecture
- Removed cowEnabled flag from BaseStorage (COW cannot be disabled)
- Eliminated marker file system (checkClearMarker, createClearMarker)
- Simplified all code paths to assume COW is always enabled
- COW automatically re-initializes after clear() operations
## Critical Bug Fix: Cloud Storage clear()
- Fixed GCS clear() using correct paths (branches/ instead of entities/nouns/)
- Fixed S3Compatible clear() path structure
- Fixed R2 clear() implementation
- Fixed Azure, FileSystem, OPFS, Memory clear() COW flag handling
- clear() now deletes: branches/, _cow/, _system/
- Result: Cloud buckets can now be fully cleared (previously impossible)
## Container Memory Detection
- Auto-detect Docker/K8s/Cloud Run memory limits (cgroup v1/v2)
- Smart memory allocation (75% graph data, 25% query operations)
- Environment variable support (CLOUD_RUN_MEMORY, MEMORY_LIMIT)
- Production-grade containerized deployment support
## CommitLog streamHistory Feature
- Added streamable commit history with pagination
- Efficient memory usage for large commit histories
- Support for branch filtering and time ranges
## Comprehensive Storage Documentation
- Complete v5.11.0 file structure reference
- Detailed path construction algorithms
- 8 common storage scenarios with examples
- Type-first storage, sharding, COW architecture explained
- Public docs: docs/architecture/data-storage-architecture.md (1063 lines)
## Files Modified (14 files)
- All 8 storage adapters (GCS, S3, R2, Azure, FS, OPFS, Memory, Historical)
- BaseStorage core architecture
- CommitLog with streaming
- Brainy memory configuration
- Parameter validation with container detection
- Storage architecture documentation
## Breaking Changes
NONE - COW was already enabled by default. This removes the ability to disable it.
## Migration
No action required. Upgrade and clear() will work correctly on cloud storage.
## Impact
- Users can now clear cloud storage buckets completely
- No more corrupted buckets after clear() operations
- Container deployments automatically optimize memory allocation
- COW is mandatory and always enabled (safer, simpler)
v5.11.0 - Production ready
2025-11-18 13:44:02 -08:00
|
|
|
// Delete system metadata
|
|
|
|
|
await deleteObjectsWithPrefix('_system/')
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Reset COW managers (but don't disable COW - it's always enabled)
|
feat: COW always-on architecture + cloud storage clear() fix (v5.11.0)
Major architectural improvements and critical bug fixes:
## COW Always-On Architecture
- Removed cowEnabled flag from BaseStorage (COW cannot be disabled)
- Eliminated marker file system (checkClearMarker, createClearMarker)
- Simplified all code paths to assume COW is always enabled
- COW automatically re-initializes after clear() operations
## Critical Bug Fix: Cloud Storage clear()
- Fixed GCS clear() using correct paths (branches/ instead of entities/nouns/)
- Fixed S3Compatible clear() path structure
- Fixed R2 clear() implementation
- Fixed Azure, FileSystem, OPFS, Memory clear() COW flag handling
- clear() now deletes: branches/, _cow/, _system/
- Result: Cloud buckets can now be fully cleared (previously impossible)
## Container Memory Detection
- Auto-detect Docker/K8s/Cloud Run memory limits (cgroup v1/v2)
- Smart memory allocation (75% graph data, 25% query operations)
- Environment variable support (CLOUD_RUN_MEMORY, MEMORY_LIMIT)
- Production-grade containerized deployment support
## CommitLog streamHistory Feature
- Added streamable commit history with pagination
- Efficient memory usage for large commit histories
- Support for branch filtering and time ranges
## Comprehensive Storage Documentation
- Complete v5.11.0 file structure reference
- Detailed path construction algorithms
- 8 common storage scenarios with examples
- Type-first storage, sharding, COW architecture explained
- Public docs: docs/architecture/data-storage-architecture.md (1063 lines)
## Files Modified (14 files)
- All 8 storage adapters (GCS, S3, R2, Azure, FS, OPFS, Memory, Historical)
- BaseStorage core architecture
- CommitLog with streaming
- Brainy memory configuration
- Parameter validation with container detection
- Storage architecture documentation
## Breaking Changes
NONE - COW was already enabled by default. This removes the ability to disable it.
## Migration
No action required. Upgrade and clear() will work correctly on cloud storage.
## Impact
- Users can now clear cloud storage buckets completely
- No more corrupted buckets after clear() operations
- Container deployments automatically optimize memory allocation
- COW is mandatory and always enabled (safer, simpler)
v5.11.0 - Production ready
2025-11-18 13:44:02 -08:00
|
|
|
// COW will re-initialize automatically on next use
|
2025-11-11 09:04:56 -08:00
|
|
|
this.refManager = undefined
|
|
|
|
|
this.blobStorage = undefined
|
|
|
|
|
this.commitLog = undefined
|
2025-11-17 10:44:35 -08:00
|
|
|
|
2025-10-08 14:08:43 -07:00
|
|
|
// 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 GCS')
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.error('Failed to clear GCS storage:', error)
|
|
|
|
|
throw new Error(`Failed to clear GCS storage: ${error}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get storage status
|
|
|
|
|
*/
|
|
|
|
|
public async getStorageStatus(): Promise<{
|
|
|
|
|
type: string
|
|
|
|
|
used: number
|
|
|
|
|
quota: number | null
|
|
|
|
|
details?: Record<string, any>
|
|
|
|
|
}> {
|
|
|
|
|
await this.ensureInitialized()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Get bucket metadata
|
|
|
|
|
const [metadata] = await this.bucket!.getMetadata()
|
|
|
|
|
|
|
|
|
|
return {
|
2025-10-20 11:11:54 -07:00
|
|
|
type: 'gcs', // Consistent with new naming (native SDK is just 'gcs')
|
2025-10-08 14:08:43 -07:00
|
|
|
used: 0, // GCS doesn't provide usage info easily
|
|
|
|
|
quota: null, // No quota in GCS
|
|
|
|
|
details: {
|
|
|
|
|
bucket: this.bucketName,
|
|
|
|
|
location: metadata.location,
|
|
|
|
|
storageClass: metadata.storageClass,
|
2025-10-20 11:11:54 -07:00
|
|
|
created: metadata.timeCreated,
|
|
|
|
|
sdk: 'native' // Indicate we're using native SDK
|
2025-10-08 14:08:43 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.error('Failed to get storage status:', error)
|
|
|
|
|
return {
|
2025-10-20 11:11:54 -07:00
|
|
|
type: 'gcs', // Consistent with new naming
|
2025-10-08 14:08:43 -07:00
|
|
|
used: 0,
|
|
|
|
|
quota: null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-17 10:44:35 -08:00
|
|
|
/**
|
|
|
|
|
* Check if COW has been explicitly disabled via clear()
|
2026-01-27 15:38:21 -08:00
|
|
|
* Fixes bug where clear() doesn't persist across instance restarts
|
2025-11-17 10:44:35 -08:00
|
|
|
* @returns true if marker object exists, false otherwise
|
|
|
|
|
* @protected
|
|
|
|
|
*/
|
|
|
|
|
/**
|
2026-01-27 15:38:21 -08:00
|
|
|
* Removed checkClearMarker() and createClearMarker() methods
|
feat: COW always-on architecture + cloud storage clear() fix (v5.11.0)
Major architectural improvements and critical bug fixes:
## COW Always-On Architecture
- Removed cowEnabled flag from BaseStorage (COW cannot be disabled)
- Eliminated marker file system (checkClearMarker, createClearMarker)
- Simplified all code paths to assume COW is always enabled
- COW automatically re-initializes after clear() operations
## Critical Bug Fix: Cloud Storage clear()
- Fixed GCS clear() using correct paths (branches/ instead of entities/nouns/)
- Fixed S3Compatible clear() path structure
- Fixed R2 clear() implementation
- Fixed Azure, FileSystem, OPFS, Memory clear() COW flag handling
- clear() now deletes: branches/, _cow/, _system/
- Result: Cloud buckets can now be fully cleared (previously impossible)
## Container Memory Detection
- Auto-detect Docker/K8s/Cloud Run memory limits (cgroup v1/v2)
- Smart memory allocation (75% graph data, 25% query operations)
- Environment variable support (CLOUD_RUN_MEMORY, MEMORY_LIMIT)
- Production-grade containerized deployment support
## CommitLog streamHistory Feature
- Added streamable commit history with pagination
- Efficient memory usage for large commit histories
- Support for branch filtering and time ranges
## Comprehensive Storage Documentation
- Complete v5.11.0 file structure reference
- Detailed path construction algorithms
- 8 common storage scenarios with examples
- Type-first storage, sharding, COW architecture explained
- Public docs: docs/architecture/data-storage-architecture.md (1063 lines)
## Files Modified (14 files)
- All 8 storage adapters (GCS, S3, R2, Azure, FS, OPFS, Memory, Historical)
- BaseStorage core architecture
- CommitLog with streaming
- Brainy memory configuration
- Parameter validation with container detection
- Storage architecture documentation
## Breaking Changes
NONE - COW was already enabled by default. This removes the ability to disable it.
## Migration
No action required. Upgrade and clear() will work correctly on cloud storage.
## Impact
- Users can now clear cloud storage buckets completely
- No more corrupted buckets after clear() operations
- Container deployments automatically optimize memory allocation
- COW is mandatory and always enabled (safer, simpler)
v5.11.0 - Production ready
2025-11-18 13:44:02 -08:00
|
|
|
* COW is now always enabled - marker files are no longer used
|
2025-11-17 10:44:35 -08:00
|
|
|
*/
|
|
|
|
|
|
2025-10-08 14:08:43 -07:00
|
|
|
/**
|
|
|
|
|
* 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 file = this.bucket!.file(key)
|
|
|
|
|
await file.save(JSON.stringify(statistics, null, 2), {
|
|
|
|
|
contentType: 'application/json',
|
|
|
|
|
resumable: false
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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 file = this.bucket!.file(key)
|
|
|
|
|
const [contents] = await file.download()
|
|
|
|
|
|
|
|
|
|
const statistics = JSON.parse(contents.toString())
|
|
|
|
|
|
|
|
|
|
this.logger.trace('Statistics retrieved successfully')
|
fix: populate totalNodes/totalEdges in ALL storage adapters for HNSW rebuild
Critical fix for v3.37.1 GCS persistence bug where HNSW rebuild reported
"Preloading 0 vectors" and hung indefinitely during container restarts.
Root Cause:
HNSW rebuild (hnswIndex.ts:835) calls storage.getStatistics() and expects
stats.totalNodes to determine entity count for adaptive caching. When
totalNodes was undefined, entityCount evaluated to 0, causing HNSW to
report "0 vectors" and hang waiting for entities that it couldn't find.
Fixes Applied:
- GcsStorage: Populate totalNodes/totalEdges from this.totalNounCount/totalVerbCount
- MemoryStorage: Populate totalNodes/totalEdges from in-memory counts
- OPFSStorage: Populate totalNodes/totalEdges in ALL return paths (cache, current day, yesterday, legacy)
- S3CompatibleStorage: Populate totalNodes/totalEdges in ALL return paths (cache, fresh stats, error fallback)
Impact:
- ✅ GCS container restarts now work correctly
- ✅ HNSW rebuild correctly detects entity count
- ✅ Adaptive caching strategy works as designed
- ✅ All storage adapters now consistent
Files exist in GCS, counts load correctly, but HNSW couldn't see them
because getStatisticsData() didn't populate the totalNodes field that
HNSW depends on.
Closes: BRAINY_V3_37_1_INDEX_REBUILD_BUG.md
Related: v3.37.0 (metadata reconstruction), v3.37.1 (getNoun_internal fix)
2025-10-10 17:48:40 -07:00
|
|
|
|
|
|
|
|
// CRITICAL FIX: Populate totalNodes and totalEdges from in-memory counts
|
|
|
|
|
// HNSW rebuild depends on these fields to determine entity count
|
|
|
|
|
return {
|
|
|
|
|
...statistics,
|
|
|
|
|
totalNodes: this.totalNounCount,
|
|
|
|
|
totalEdges: this.totalVerbCount,
|
|
|
|
|
lastUpdated: new Date().toISOString()
|
|
|
|
|
}
|
2025-10-08 14:08:43 -07:00
|
|
|
} catch (error: any) {
|
|
|
|
|
if (error.code === 404) {
|
2026-01-27 15:38:21 -08:00
|
|
|
// CRITICAL FIX: Statistics file doesn't exist yet (first restart)
|
2025-10-11 09:05:16 -07:00
|
|
|
// Return minimal stats with counts instead of null
|
|
|
|
|
// This prevents HNSW from seeing entityCount=0 during index rebuild
|
|
|
|
|
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()
|
|
|
|
|
}
|
2025-10-08 14:08:43 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.logger.error('Failed to get statistics:', error)
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize counts from storage
|
|
|
|
|
*/
|
|
|
|
|
protected async initializeCounts(): Promise<void> {
|
2025-10-20 11:11:54 -07:00
|
|
|
// Skip counts file entirely if configured
|
|
|
|
|
if (this.skipCountsFile) {
|
|
|
|
|
prodLog.info('📊 Skipping counts file (skipCountsFile: true)')
|
|
|
|
|
this.totalNounCount = 0
|
|
|
|
|
this.totalVerbCount = 0
|
|
|
|
|
this.entityCounts = new Map()
|
|
|
|
|
this.verbCounts = new Map()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-09 17:12:06 -07:00
|
|
|
const key = `${this.systemPrefix}counts.json`
|
2025-10-08 14:08:43 -07:00
|
|
|
|
2025-10-09 17:12:06 -07:00
|
|
|
try {
|
2025-10-08 14:08:43 -07:00
|
|
|
const file = this.bucket!.file(key)
|
|
|
|
|
const [contents] = await file.download()
|
|
|
|
|
|
|
|
|
|
const counts = JSON.parse(contents.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>
|
|
|
|
|
|
2025-10-09 17:12:06 -07:00
|
|
|
prodLog.info(`📊 Loaded counts from storage: ${this.totalNounCount} nouns, ${this.totalVerbCount} verbs`)
|
2025-10-08 14:08:43 -07:00
|
|
|
} catch (error: any) {
|
|
|
|
|
if (error.code === 404) {
|
2025-10-20 11:11:54 -07:00
|
|
|
// No counts file yet
|
|
|
|
|
if (this.skipInitialScan) {
|
|
|
|
|
prodLog.info('📊 No counts file found - starting with zero counts (skipInitialScan: true)')
|
|
|
|
|
this.totalNounCount = 0
|
|
|
|
|
this.totalVerbCount = 0
|
|
|
|
|
this.entityCounts = new Map()
|
|
|
|
|
this.verbCounts = new Map()
|
|
|
|
|
} else {
|
|
|
|
|
// Initialize from scan (first-time setup or counts not persisted)
|
|
|
|
|
prodLog.info('📊 No counts file found - scanning bucket to initialize counts')
|
|
|
|
|
await this.initializeCountsFromScan()
|
|
|
|
|
}
|
2025-10-08 14:08:43 -07:00
|
|
|
} else {
|
2025-10-09 17:12:06 -07:00
|
|
|
// CRITICAL FIX: Don't silently fail on network/permission errors
|
|
|
|
|
this.logger.error('❌ CRITICAL: Failed to load counts from GCS:', error)
|
|
|
|
|
prodLog.error(`❌ Error loading ${key}: ${error.message}`)
|
|
|
|
|
|
2025-10-20 11:11:54 -07:00
|
|
|
if (this.skipInitialScan) {
|
|
|
|
|
prodLog.warn('⚠️ Starting with zero counts due to error (skipInitialScan: true)')
|
|
|
|
|
this.totalNounCount = 0
|
|
|
|
|
this.totalVerbCount = 0
|
|
|
|
|
this.entityCounts = new Map()
|
|
|
|
|
this.verbCounts = new Map()
|
|
|
|
|
} else {
|
|
|
|
|
// Try to recover by scanning the bucket
|
|
|
|
|
prodLog.warn('⚠️ Attempting recovery by scanning GCS bucket...')
|
|
|
|
|
await this.initializeCountsFromScan()
|
|
|
|
|
}
|
2025-10-08 14:08:43 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize counts from storage scan (expensive - only for first-time init)
|
2025-10-20 11:11:54 -07:00
|
|
|
* Includes timeout handling to prevent Cloud Run startup failures
|
2025-10-08 14:08:43 -07:00
|
|
|
*/
|
|
|
|
|
private async initializeCountsFromScan(): Promise<void> {
|
2025-10-20 11:11:54 -07:00
|
|
|
const SCAN_TIMEOUT_MS = 120000 // 2 minutes timeout
|
|
|
|
|
|
2025-10-08 14:08:43 -07:00
|
|
|
try {
|
2025-10-09 17:12:06 -07:00
|
|
|
prodLog.info('📊 Scanning GCS bucket to initialize counts...')
|
2025-10-09 17:52:28 -07:00
|
|
|
prodLog.info(`🔍 Noun prefix: ${this.nounPrefix}`)
|
|
|
|
|
prodLog.info(`🔍 Verb prefix: ${this.verbPrefix}`)
|
2025-10-20 11:11:54 -07:00
|
|
|
prodLog.info(`⏱️ Timeout: ${SCAN_TIMEOUT_MS / 1000}s (configure skipInitialScan to avoid this)`)
|
|
|
|
|
|
|
|
|
|
// Create timeout promise
|
|
|
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
reject(new Error(`Bucket scan timeout after ${SCAN_TIMEOUT_MS / 1000}s`))
|
|
|
|
|
}, SCAN_TIMEOUT_MS)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Count nouns with timeout
|
|
|
|
|
const nounScanPromise = this.bucket!.getFiles({ prefix: this.nounPrefix })
|
|
|
|
|
const [nounFiles] = await Promise.race([nounScanPromise, timeoutPromise]) as any
|
2025-10-09 17:12:06 -07:00
|
|
|
|
2025-10-09 17:52:28 -07:00
|
|
|
prodLog.info(`🔍 Found ${nounFiles?.length || 0} total files under noun prefix`)
|
|
|
|
|
|
|
|
|
|
const jsonNounFiles = nounFiles?.filter((f: any) => f.name?.endsWith('.json')) || []
|
|
|
|
|
this.totalNounCount = jsonNounFiles.length
|
|
|
|
|
|
|
|
|
|
if (jsonNounFiles.length > 0 && jsonNounFiles.length <= 5) {
|
|
|
|
|
prodLog.info(`📄 Sample noun files: ${jsonNounFiles.slice(0, 5).map((f: any) => f.name).join(', ')}`)
|
|
|
|
|
}
|
2025-10-08 14:08:43 -07:00
|
|
|
|
2025-10-20 11:11:54 -07:00
|
|
|
// Count verbs with timeout
|
|
|
|
|
const verbScanPromise = this.bucket!.getFiles({ prefix: this.verbPrefix })
|
|
|
|
|
const [verbFiles] = await Promise.race([verbScanPromise, timeoutPromise]) as any
|
|
|
|
|
|
2025-10-09 17:52:28 -07:00
|
|
|
prodLog.info(`🔍 Found ${verbFiles?.length || 0} total files under verb prefix`)
|
2025-10-08 14:08:43 -07:00
|
|
|
|
2025-10-09 17:52:28 -07:00
|
|
|
const jsonVerbFiles = verbFiles?.filter((f: any) => f.name?.endsWith('.json')) || []
|
|
|
|
|
this.totalVerbCount = jsonVerbFiles.length
|
2025-10-08 14:08:43 -07:00
|
|
|
|
2025-10-09 17:52:28 -07:00
|
|
|
if (jsonVerbFiles.length > 0 && jsonVerbFiles.length <= 5) {
|
|
|
|
|
prodLog.info(`📄 Sample verb files: ${jsonVerbFiles.slice(0, 5).map((f: any) => f.name).join(', ')}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 bucket scan. Check that entities exist and prefixes are correct.`)
|
|
|
|
|
}
|
2025-10-20 11:11:54 -07:00
|
|
|
} catch (error: any) {
|
|
|
|
|
// Handle timeout specifically
|
|
|
|
|
if (error.message?.includes('Bucket scan timeout')) {
|
|
|
|
|
prodLog.error(`❌ TIMEOUT: Bucket scan exceeded ${SCAN_TIMEOUT_MS / 1000}s limit`)
|
|
|
|
|
prodLog.error(` This typically happens with large buckets in Cloud Run deployments.`)
|
|
|
|
|
prodLog.error(` Solutions:`)
|
|
|
|
|
prodLog.error(` 1. Increase Cloud Run timeout: timeoutSeconds: 600`)
|
|
|
|
|
prodLog.error(` 2. Use skipInitialScan: true in gcsNativeStorage config`)
|
|
|
|
|
prodLog.error(` 3. Pre-create counts file before deployment`)
|
|
|
|
|
prodLog.warn(`⚠️ Starting with zero counts due to timeout`)
|
|
|
|
|
this.totalNounCount = 0
|
|
|
|
|
this.totalVerbCount = 0
|
|
|
|
|
this.entityCounts = new Map()
|
|
|
|
|
this.verbCounts = new Map()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-09 17:12:06 -07:00
|
|
|
// CRITICAL FIX: Don't silently fail - this prevents data loss scenarios
|
|
|
|
|
this.logger.error('❌ CRITICAL: Failed to initialize counts from GCS bucket scan:', error)
|
2025-10-20 11:11:54 -07:00
|
|
|
prodLog.error(` Error: ${error.message || String(error)}`)
|
|
|
|
|
prodLog.warn(`⚠️ Starting with zero counts due to error`)
|
|
|
|
|
this.totalNounCount = 0
|
|
|
|
|
this.totalVerbCount = 0
|
|
|
|
|
this.entityCounts = new Map()
|
|
|
|
|
this.verbCounts = new Map()
|
2025-10-08 14:08:43 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Persist counts to storage
|
|
|
|
|
*/
|
|
|
|
|
protected async persistCounts(): Promise<void> {
|
2025-10-20 11:11:54 -07:00
|
|
|
// Skip if skipCountsFile is enabled
|
|
|
|
|
if (this.skipCountsFile) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-08 14:08:43 -07:00
|
|
|
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 file = this.bucket!.file(key)
|
|
|
|
|
await file.save(JSON.stringify(counts, null, 2), {
|
|
|
|
|
contentType: 'application/json',
|
|
|
|
|
resumable: false
|
|
|
|
|
})
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.error('Error persisting counts:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-10 11:15:17 -07:00
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// HNSW Index Persistence
|
2025-10-10 11:15:17 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get a noun's vector for HNSW rebuild
|
2026-01-27 15:38:21 -08:00
|
|
|
* Uses BaseStorage's getNoun (type-first paths)
|
2025-10-10 11:15:17 -07:00
|
|
|
*/
|
|
|
|
|
public async getNounVector(id: string): Promise<number[] | null> {
|
2025-11-05 17:01:44 -08:00
|
|
|
const noun = await this.getNoun(id)
|
2025-10-10 11:15:17 -07:00
|
|
|
return noun ? noun.vector : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Save HNSW graph data for a noun
|
2025-11-05 17:01:44 -08:00
|
|
|
*
|
2026-01-27 15:38:21 -08:00
|
|
|
* Uses BaseStorage's getNoun/saveNoun (type-first paths)
|
2025-11-05 17:01:44 -08:00
|
|
|
* CRITICAL: Uses mutex locking to prevent read-modify-write races
|
2025-10-10 11:15:17 -07:00
|
|
|
*/
|
|
|
|
|
public async saveHNSWData(nounId: string, hnswData: {
|
|
|
|
|
level: number
|
|
|
|
|
connections: Record<string, string[]>
|
|
|
|
|
}): Promise<void> {
|
2025-11-05 17:01:44 -08:00
|
|
|
const lockKey = `hnsw/${nounId}`
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// CRITICAL FIX: Mutex lock to prevent read-modify-write races
|
2025-11-05 17:01:44 -08:00
|
|
|
// Problem: Without mutex, concurrent operations can:
|
|
|
|
|
// 1. Thread A reads noun (connections: [1,2,3])
|
|
|
|
|
// 2. Thread B reads noun (connections: [1,2,3])
|
|
|
|
|
// 3. Thread A adds connection 4, writes [1,2,3,4]
|
|
|
|
|
// 4. Thread B adds connection 5, writes [1,2,3,5] ← Connection 4 LOST!
|
|
|
|
|
// Solution: Mutex serializes operations per entity (like FileSystem/OPFS adapters)
|
|
|
|
|
// Production scale: Prevents corruption at 1000+ concurrent operations
|
|
|
|
|
|
|
|
|
|
// Wait for any pending operations on this entity
|
|
|
|
|
while (this.hnswLocks.has(lockKey)) {
|
|
|
|
|
await this.hnswLocks.get(lockKey)
|
|
|
|
|
}
|
fix: resolve HNSW concurrency race condition across all storage adapters
Fixes critical P0 bug causing data corruption during bulk imports with 50+ concurrent operations. The non-atomic read-modify-write pattern in saveHNSWData() combined with fire-and-forget neighbor updates was causing 16-32 concurrent writes per entity, resulting in lost HNSW connections and corrupted graph structure.
**Root Cause:**
- saveHNSWData() used non-atomic read-modify-write
- HNSW neighbor updates fired without await (16-32 concurrent writes/entity)
- Popular nodes became hotspots (100 concurrent imports = 3,400 concurrent saveHNSWData calls)
- Result: Lost neighbor connections, 0 search results
**Atomic Write Strategies by Adapter:**
FileSystemStorage:
- Atomic rename with temp files
- Write to {file}.tmp.{timestamp}.{random}
- POSIX-guaranteed atomic rename(temp, final)
GCSStorage:
- Optimistic locking with generation numbers
- preconditionOpts: { ifGenerationMatch }
- 5 retries with exponential backoff (50ms→800ms)
S3/R2/AzureStorage:
- ETag-based optimistic locking
- IfMatch/conditions preconditions
- 5 retries with exponential backoff
MemoryStorage + OPFSStorage:
- Mutex locks per entity path
- Serializes async operations even in single-threaded environments
HNSW Index:
- Changed fire-and-forget .catch() to await
- Serializes 16-32 neighbor updates per entity
- Trade-off: 20-30% slower bulk import vs 100% data integrity
**Sharding Compatibility:**
- ✅ Works with deterministic UUID sharding (256 shards, always on)
- ✅ Works with distributed multi-node sharding (optional)
- ✅ All atomic strategies work in both single-node and distributed deployments
**Index Impact:**
- Only HNSW index modified (saveHNSWData, saveHNSWSystem)
- Other 4 indexes unaffected (Metadata, Graph Adjacency, Deleted Items, Entity ID Mapper)
- No regression risk - isolated code paths
**Testing:**
- 8/8 unit tests passing (real concurrent operations, no mocks)
- Tests verify data integrity after 20 concurrent updates
- Tests verify temp file cleanup and mutex serialization
**Files Modified:**
- All 8 storage adapters (FileSystem, GCS, S3, R2, Azure, Memory, OPFS)
- HNSW Index (neighbor update serialization)
- New test: tests/unit/storage/hnswConcurrency.test.ts (8 passing tests)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 15:24:20 -07:00
|
|
|
|
2025-11-05 17:01:44 -08:00
|
|
|
// Acquire lock
|
|
|
|
|
let releaseLock!: () => void
|
|
|
|
|
const lockPromise = new Promise<void>(resolve => { releaseLock = resolve })
|
|
|
|
|
this.hnswLocks.set(lockKey, lockPromise)
|
fix(storage): CRITICAL - preserve vectors when updating HNSW connections (v4.7.3)
CRITICAL DATA CORRUPTION FIX affecting ALL storage adapters
## Root Cause
When HNSW index updated node connections (adding new neighbors), saveHNSWData()
overwrote the entire node file with ONLY {level, connections}, destroying vector data.
## Impact
- v4.7.2: Broke ALL imports - relate() crashed with "Cannot read properties of undefined"
- Affected ALL storage adapters: FileSystem, GCS, Azure, R2, OPFS, S3Compatible
- VFS imports completely non-functional
- Any multi-entity operation would corrupt existing entity vectors
## The Bug
```typescript
// OLD CODE (v4.7.2) - DESTROYED VECTORS:
async saveHNSWData(id, hnswData) {
await writeFile(path, JSON.stringify(hnswData)) // Only {level, connections}!
}
```
When entity2 was added to HNSW:
1. HNSW found entity1 as neighbor
2. Updated entity1's connections
3. Called saveHNSWData(entity1.id, {level, connections})
4. Overwrote entity1.json with ONLY {level, connections}
5. **entity1.vector and entity1.id were DESTROYED**
## The Fix
```typescript
// NEW CODE (v4.7.3) - PRESERVES ALL DATA:
async saveHNSWData(id, hnswData) {
const existing = await readFile(path)
const updated = {...existing, level: hnswData.level, connections: hnswData.connections}
await writeFile(path, JSON.stringify(updated)) // Preserves id, vector, etc.
}
```
Now READ existing node, UPDATE only HNSW fields, WRITE complete node.
## Files Changed
- src/storage/adapters/fileSystemStorage.ts (line 2590-2626)
- src/storage/adapters/gcsStorage.ts (line 1863-1911)
- src/storage/adapters/azureBlobStorage.ts (line 1638-1682)
- src/storage/adapters/r2Storage.ts (line 999-1029)
- src/storage/adapters/opfsStorage.ts (line 2012-2051)
- src/storage/adapters/s3CompatibleStorage.ts (line 3903-3961)
## Testing
✅ FileSystemStorage: Verified with test-relate-crash.js
✅ All adapters: Compilation successful
✅ Imports: VFS directory creation and relate() working
## Breaking Changes
NONE - This is a critical bug fix
## Migration
Workshop team: Delete brainy-data and reimport with v4.7.3
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 13:07:00 -07:00
|
|
|
|
2025-11-05 17:01:44 -08:00
|
|
|
try {
|
2026-01-27 15:38:21 -08:00
|
|
|
// Use BaseStorage's getNoun (type-first paths)
|
2025-11-05 17:01:44 -08:00
|
|
|
// Read existing noun data (if exists)
|
|
|
|
|
const existingNoun = await this.getNoun(nounId)
|
fix(storage): CRITICAL - preserve vectors when updating HNSW connections (v4.7.3)
CRITICAL DATA CORRUPTION FIX affecting ALL storage adapters
## Root Cause
When HNSW index updated node connections (adding new neighbors), saveHNSWData()
overwrote the entire node file with ONLY {level, connections}, destroying vector data.
## Impact
- v4.7.2: Broke ALL imports - relate() crashed with "Cannot read properties of undefined"
- Affected ALL storage adapters: FileSystem, GCS, Azure, R2, OPFS, S3Compatible
- VFS imports completely non-functional
- Any multi-entity operation would corrupt existing entity vectors
## The Bug
```typescript
// OLD CODE (v4.7.2) - DESTROYED VECTORS:
async saveHNSWData(id, hnswData) {
await writeFile(path, JSON.stringify(hnswData)) // Only {level, connections}!
}
```
When entity2 was added to HNSW:
1. HNSW found entity1 as neighbor
2. Updated entity1's connections
3. Called saveHNSWData(entity1.id, {level, connections})
4. Overwrote entity1.json with ONLY {level, connections}
5. **entity1.vector and entity1.id were DESTROYED**
## The Fix
```typescript
// NEW CODE (v4.7.3) - PRESERVES ALL DATA:
async saveHNSWData(id, hnswData) {
const existing = await readFile(path)
const updated = {...existing, level: hnswData.level, connections: hnswData.connections}
await writeFile(path, JSON.stringify(updated)) // Preserves id, vector, etc.
}
```
Now READ existing node, UPDATE only HNSW fields, WRITE complete node.
## Files Changed
- src/storage/adapters/fileSystemStorage.ts (line 2590-2626)
- src/storage/adapters/gcsStorage.ts (line 1863-1911)
- src/storage/adapters/azureBlobStorage.ts (line 1638-1682)
- src/storage/adapters/r2Storage.ts (line 999-1029)
- src/storage/adapters/opfsStorage.ts (line 2012-2051)
- src/storage/adapters/s3CompatibleStorage.ts (line 3903-3961)
## Testing
✅ FileSystemStorage: Verified with test-relate-crash.js
✅ All adapters: Compilation successful
✅ Imports: VFS directory creation and relate() working
## Breaking Changes
NONE - This is a critical bug fix
## Migration
Workshop team: Delete brainy-data and reimport with v4.7.3
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 13:07:00 -07:00
|
|
|
|
2025-11-05 17:01:44 -08:00
|
|
|
if (!existingNoun) {
|
|
|
|
|
// Noun doesn't exist - cannot update HNSW data for non-existent noun
|
|
|
|
|
throw new Error(`Cannot save HNSW data: noun ${nounId} not found`)
|
|
|
|
|
}
|
fix: resolve HNSW concurrency race condition across all storage adapters
Fixes critical P0 bug causing data corruption during bulk imports with 50+ concurrent operations. The non-atomic read-modify-write pattern in saveHNSWData() combined with fire-and-forget neighbor updates was causing 16-32 concurrent writes per entity, resulting in lost HNSW connections and corrupted graph structure.
**Root Cause:**
- saveHNSWData() used non-atomic read-modify-write
- HNSW neighbor updates fired without await (16-32 concurrent writes/entity)
- Popular nodes became hotspots (100 concurrent imports = 3,400 concurrent saveHNSWData calls)
- Result: Lost neighbor connections, 0 search results
**Atomic Write Strategies by Adapter:**
FileSystemStorage:
- Atomic rename with temp files
- Write to {file}.tmp.{timestamp}.{random}
- POSIX-guaranteed atomic rename(temp, final)
GCSStorage:
- Optimistic locking with generation numbers
- preconditionOpts: { ifGenerationMatch }
- 5 retries with exponential backoff (50ms→800ms)
S3/R2/AzureStorage:
- ETag-based optimistic locking
- IfMatch/conditions preconditions
- 5 retries with exponential backoff
MemoryStorage + OPFSStorage:
- Mutex locks per entity path
- Serializes async operations even in single-threaded environments
HNSW Index:
- Changed fire-and-forget .catch() to await
- Serializes 16-32 neighbor updates per entity
- Trade-off: 20-30% slower bulk import vs 100% data integrity
**Sharding Compatibility:**
- ✅ Works with deterministic UUID sharding (256 shards, always on)
- ✅ Works with distributed multi-node sharding (optional)
- ✅ All atomic strategies work in both single-node and distributed deployments
**Index Impact:**
- Only HNSW index modified (saveHNSWData, saveHNSWSystem)
- Other 4 indexes unaffected (Metadata, Graph Adjacency, Deleted Items, Entity ID Mapper)
- No regression risk - isolated code paths
**Testing:**
- 8/8 unit tests passing (real concurrent operations, no mocks)
- Tests verify data integrity after 20 concurrent updates
- Tests verify temp file cleanup and mutex serialization
**Files Modified:**
- All 8 storage adapters (FileSystem, GCS, S3, R2, Azure, Memory, OPFS)
- HNSW Index (neighbor update serialization)
- New test: tests/unit/storage/hnswConcurrency.test.ts (8 passing tests)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 15:24:20 -07:00
|
|
|
|
2025-11-05 17:01:44 -08:00
|
|
|
// Convert connections from Record to Map format for storage
|
|
|
|
|
const connectionsMap = new Map<number, Set<string>>()
|
|
|
|
|
for (const [level, nodeIds] of Object.entries(hnswData.connections)) {
|
|
|
|
|
connectionsMap.set(Number(level), new Set(nodeIds))
|
|
|
|
|
}
|
fix: resolve HNSW concurrency race condition across all storage adapters
Fixes critical P0 bug causing data corruption during bulk imports with 50+ concurrent operations. The non-atomic read-modify-write pattern in saveHNSWData() combined with fire-and-forget neighbor updates was causing 16-32 concurrent writes per entity, resulting in lost HNSW connections and corrupted graph structure.
**Root Cause:**
- saveHNSWData() used non-atomic read-modify-write
- HNSW neighbor updates fired without await (16-32 concurrent writes/entity)
- Popular nodes became hotspots (100 concurrent imports = 3,400 concurrent saveHNSWData calls)
- Result: Lost neighbor connections, 0 search results
**Atomic Write Strategies by Adapter:**
FileSystemStorage:
- Atomic rename with temp files
- Write to {file}.tmp.{timestamp}.{random}
- POSIX-guaranteed atomic rename(temp, final)
GCSStorage:
- Optimistic locking with generation numbers
- preconditionOpts: { ifGenerationMatch }
- 5 retries with exponential backoff (50ms→800ms)
S3/R2/AzureStorage:
- ETag-based optimistic locking
- IfMatch/conditions preconditions
- 5 retries with exponential backoff
MemoryStorage + OPFSStorage:
- Mutex locks per entity path
- Serializes async operations even in single-threaded environments
HNSW Index:
- Changed fire-and-forget .catch() to await
- Serializes 16-32 neighbor updates per entity
- Trade-off: 20-30% slower bulk import vs 100% data integrity
**Sharding Compatibility:**
- ✅ Works with deterministic UUID sharding (256 shards, always on)
- ✅ Works with distributed multi-node sharding (optional)
- ✅ All atomic strategies work in both single-node and distributed deployments
**Index Impact:**
- Only HNSW index modified (saveHNSWData, saveHNSWSystem)
- Other 4 indexes unaffected (Metadata, Graph Adjacency, Deleted Items, Entity ID Mapper)
- No regression risk - isolated code paths
**Testing:**
- 8/8 unit tests passing (real concurrent operations, no mocks)
- Tests verify data integrity after 20 concurrent updates
- Tests verify temp file cleanup and mutex serialization
**Files Modified:**
- All 8 storage adapters (FileSystem, GCS, S3, R2, Azure, Memory, OPFS)
- HNSW Index (neighbor update serialization)
- New test: tests/unit/storage/hnswConcurrency.test.ts (8 passing tests)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 15:24:20 -07:00
|
|
|
|
2025-11-05 17:01:44 -08:00
|
|
|
// Preserve id and vector, update only HNSW graph metadata
|
|
|
|
|
const updatedNoun: HNSWNoun = {
|
|
|
|
|
...existingNoun,
|
|
|
|
|
level: hnswData.level,
|
|
|
|
|
connections: connectionsMap
|
fix(storage): CRITICAL - preserve vectors when updating HNSW connections (v4.7.3)
CRITICAL DATA CORRUPTION FIX affecting ALL storage adapters
## Root Cause
When HNSW index updated node connections (adding new neighbors), saveHNSWData()
overwrote the entire node file with ONLY {level, connections}, destroying vector data.
## Impact
- v4.7.2: Broke ALL imports - relate() crashed with "Cannot read properties of undefined"
- Affected ALL storage adapters: FileSystem, GCS, Azure, R2, OPFS, S3Compatible
- VFS imports completely non-functional
- Any multi-entity operation would corrupt existing entity vectors
## The Bug
```typescript
// OLD CODE (v4.7.2) - DESTROYED VECTORS:
async saveHNSWData(id, hnswData) {
await writeFile(path, JSON.stringify(hnswData)) // Only {level, connections}!
}
```
When entity2 was added to HNSW:
1. HNSW found entity1 as neighbor
2. Updated entity1's connections
3. Called saveHNSWData(entity1.id, {level, connections})
4. Overwrote entity1.json with ONLY {level, connections}
5. **entity1.vector and entity1.id were DESTROYED**
## The Fix
```typescript
// NEW CODE (v4.7.3) - PRESERVES ALL DATA:
async saveHNSWData(id, hnswData) {
const existing = await readFile(path)
const updated = {...existing, level: hnswData.level, connections: hnswData.connections}
await writeFile(path, JSON.stringify(updated)) // Preserves id, vector, etc.
}
```
Now READ existing node, UPDATE only HNSW fields, WRITE complete node.
## Files Changed
- src/storage/adapters/fileSystemStorage.ts (line 2590-2626)
- src/storage/adapters/gcsStorage.ts (line 1863-1911)
- src/storage/adapters/azureBlobStorage.ts (line 1638-1682)
- src/storage/adapters/r2Storage.ts (line 999-1029)
- src/storage/adapters/opfsStorage.ts (line 2012-2051)
- src/storage/adapters/s3CompatibleStorage.ts (line 3903-3961)
## Testing
✅ FileSystemStorage: Verified with test-relate-crash.js
✅ All adapters: Compilation successful
✅ Imports: VFS directory creation and relate() working
## Breaking Changes
NONE - This is a critical bug fix
## Migration
Workshop team: Delete brainy-data and reimport with v4.7.3
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 13:07:00 -07:00
|
|
|
}
|
2025-11-05 17:01:44 -08:00
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
// Use BaseStorage's saveNoun (type-first paths, atomic write via writeObjectToBranch)
|
2025-11-05 17:01:44 -08:00
|
|
|
await this.saveNoun(updatedNoun)
|
|
|
|
|
} finally {
|
|
|
|
|
// Release lock (ALWAYS runs, even if error thrown)
|
|
|
|
|
this.hnswLocks.delete(lockKey)
|
|
|
|
|
releaseLock()
|
2025-10-10 11:15:17 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get HNSW graph data for a noun
|
2026-01-27 15:38:21 -08:00
|
|
|
* Uses BaseStorage's getNoun (type-first paths)
|
2025-10-10 11:15:17 -07:00
|
|
|
*/
|
|
|
|
|
public async getHNSWData(nounId: string): Promise<{
|
|
|
|
|
level: number
|
|
|
|
|
connections: Record<string, string[]>
|
|
|
|
|
} | null> {
|
2025-11-05 17:01:44 -08:00
|
|
|
const noun = await this.getNoun(nounId)
|
2025-10-10 11:15:17 -07:00
|
|
|
|
2025-11-05 17:01:44 -08:00
|
|
|
if (!noun) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
2025-10-10 11:15:17 -07:00
|
|
|
|
2025-11-05 17:01:44 -08:00
|
|
|
// Convert connections from Map to Record format
|
|
|
|
|
const connectionsRecord: Record<string, string[]> = {}
|
|
|
|
|
if (noun.connections) {
|
|
|
|
|
for (const [level, nodeIds] of noun.connections.entries()) {
|
|
|
|
|
connectionsRecord[String(level)] = Array.from(nodeIds)
|
2025-10-10 11:15:17 -07:00
|
|
|
}
|
2025-11-05 17:01:44 -08:00
|
|
|
}
|
2025-10-10 11:15:17 -07:00
|
|
|
|
2025-11-05 17:01:44 -08:00
|
|
|
return {
|
|
|
|
|
level: noun.level || 0,
|
|
|
|
|
connections: connectionsRecord
|
2025-10-10 11:15:17 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Save HNSW system data (entry point, max level)
|
|
|
|
|
* Storage path: system/hnsw-system.json
|
fix: resolve HNSW concurrency race condition across all storage adapters
Fixes critical P0 bug causing data corruption during bulk imports with 50+ concurrent operations. The non-atomic read-modify-write pattern in saveHNSWData() combined with fire-and-forget neighbor updates was causing 16-32 concurrent writes per entity, resulting in lost HNSW connections and corrupted graph structure.
**Root Cause:**
- saveHNSWData() used non-atomic read-modify-write
- HNSW neighbor updates fired without await (16-32 concurrent writes/entity)
- Popular nodes became hotspots (100 concurrent imports = 3,400 concurrent saveHNSWData calls)
- Result: Lost neighbor connections, 0 search results
**Atomic Write Strategies by Adapter:**
FileSystemStorage:
- Atomic rename with temp files
- Write to {file}.tmp.{timestamp}.{random}
- POSIX-guaranteed atomic rename(temp, final)
GCSStorage:
- Optimistic locking with generation numbers
- preconditionOpts: { ifGenerationMatch }
- 5 retries with exponential backoff (50ms→800ms)
S3/R2/AzureStorage:
- ETag-based optimistic locking
- IfMatch/conditions preconditions
- 5 retries with exponential backoff
MemoryStorage + OPFSStorage:
- Mutex locks per entity path
- Serializes async operations even in single-threaded environments
HNSW Index:
- Changed fire-and-forget .catch() to await
- Serializes 16-32 neighbor updates per entity
- Trade-off: 20-30% slower bulk import vs 100% data integrity
**Sharding Compatibility:**
- ✅ Works with deterministic UUID sharding (256 shards, always on)
- ✅ Works with distributed multi-node sharding (optional)
- ✅ All atomic strategies work in both single-node and distributed deployments
**Index Impact:**
- Only HNSW index modified (saveHNSWData, saveHNSWSystem)
- Other 4 indexes unaffected (Metadata, Graph Adjacency, Deleted Items, Entity ID Mapper)
- No regression risk - isolated code paths
**Testing:**
- 8/8 unit tests passing (real concurrent operations, no mocks)
- Tests verify data integrity after 20 concurrent updates
- Tests verify temp file cleanup and mutex serialization
**Files Modified:**
- All 8 storage adapters (FileSystem, GCS, S3, R2, Azure, Memory, OPFS)
- HNSW Index (neighbor update serialization)
- New test: tests/unit/storage/hnswConcurrency.test.ts (8 passing tests)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 15:24:20 -07:00
|
|
|
*
|
2026-01-27 15:38:21 -08:00
|
|
|
* CRITICAL FIX: Optimistic locking with generation numbers to prevent race conditions
|
2025-10-10 11:15:17 -07:00
|
|
|
*/
|
|
|
|
|
public async saveHNSWSystem(systemData: {
|
|
|
|
|
entryPointId: string | null
|
|
|
|
|
maxLevel: number
|
|
|
|
|
}): Promise<void> {
|
|
|
|
|
await this.ensureInitialized()
|
|
|
|
|
|
fix: resolve HNSW concurrency race condition across all storage adapters
Fixes critical P0 bug causing data corruption during bulk imports with 50+ concurrent operations. The non-atomic read-modify-write pattern in saveHNSWData() combined with fire-and-forget neighbor updates was causing 16-32 concurrent writes per entity, resulting in lost HNSW connections and corrupted graph structure.
**Root Cause:**
- saveHNSWData() used non-atomic read-modify-write
- HNSW neighbor updates fired without await (16-32 concurrent writes/entity)
- Popular nodes became hotspots (100 concurrent imports = 3,400 concurrent saveHNSWData calls)
- Result: Lost neighbor connections, 0 search results
**Atomic Write Strategies by Adapter:**
FileSystemStorage:
- Atomic rename with temp files
- Write to {file}.tmp.{timestamp}.{random}
- POSIX-guaranteed atomic rename(temp, final)
GCSStorage:
- Optimistic locking with generation numbers
- preconditionOpts: { ifGenerationMatch }
- 5 retries with exponential backoff (50ms→800ms)
S3/R2/AzureStorage:
- ETag-based optimistic locking
- IfMatch/conditions preconditions
- 5 retries with exponential backoff
MemoryStorage + OPFSStorage:
- Mutex locks per entity path
- Serializes async operations even in single-threaded environments
HNSW Index:
- Changed fire-and-forget .catch() to await
- Serializes 16-32 neighbor updates per entity
- Trade-off: 20-30% slower bulk import vs 100% data integrity
**Sharding Compatibility:**
- ✅ Works with deterministic UUID sharding (256 shards, always on)
- ✅ Works with distributed multi-node sharding (optional)
- ✅ All atomic strategies work in both single-node and distributed deployments
**Index Impact:**
- Only HNSW index modified (saveHNSWData, saveHNSWSystem)
- Other 4 indexes unaffected (Metadata, Graph Adjacency, Deleted Items, Entity ID Mapper)
- No regression risk - isolated code paths
**Testing:**
- 8/8 unit tests passing (real concurrent operations, no mocks)
- Tests verify data integrity after 20 concurrent updates
- Tests verify temp file cleanup and mutex serialization
**Files Modified:**
- All 8 storage adapters (FileSystem, GCS, S3, R2, Azure, Memory, OPFS)
- HNSW Index (neighbor update serialization)
- New test: tests/unit/storage/hnswConcurrency.test.ts (8 passing tests)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 15:24:20 -07:00
|
|
|
const key = `${this.systemPrefix}hnsw-system.json`
|
|
|
|
|
const file = this.bucket!.file(key)
|
2025-10-10 11:15:17 -07:00
|
|
|
|
fix: resolve HNSW concurrency race condition across all storage adapters
Fixes critical P0 bug causing data corruption during bulk imports with 50+ concurrent operations. The non-atomic read-modify-write pattern in saveHNSWData() combined with fire-and-forget neighbor updates was causing 16-32 concurrent writes per entity, resulting in lost HNSW connections and corrupted graph structure.
**Root Cause:**
- saveHNSWData() used non-atomic read-modify-write
- HNSW neighbor updates fired without await (16-32 concurrent writes/entity)
- Popular nodes became hotspots (100 concurrent imports = 3,400 concurrent saveHNSWData calls)
- Result: Lost neighbor connections, 0 search results
**Atomic Write Strategies by Adapter:**
FileSystemStorage:
- Atomic rename with temp files
- Write to {file}.tmp.{timestamp}.{random}
- POSIX-guaranteed atomic rename(temp, final)
GCSStorage:
- Optimistic locking with generation numbers
- preconditionOpts: { ifGenerationMatch }
- 5 retries with exponential backoff (50ms→800ms)
S3/R2/AzureStorage:
- ETag-based optimistic locking
- IfMatch/conditions preconditions
- 5 retries with exponential backoff
MemoryStorage + OPFSStorage:
- Mutex locks per entity path
- Serializes async operations even in single-threaded environments
HNSW Index:
- Changed fire-and-forget .catch() to await
- Serializes 16-32 neighbor updates per entity
- Trade-off: 20-30% slower bulk import vs 100% data integrity
**Sharding Compatibility:**
- ✅ Works with deterministic UUID sharding (256 shards, always on)
- ✅ Works with distributed multi-node sharding (optional)
- ✅ All atomic strategies work in both single-node and distributed deployments
**Index Impact:**
- Only HNSW index modified (saveHNSWData, saveHNSWSystem)
- Other 4 indexes unaffected (Metadata, Graph Adjacency, Deleted Items, Entity ID Mapper)
- No regression risk - isolated code paths
**Testing:**
- 8/8 unit tests passing (real concurrent operations, no mocks)
- Tests verify data integrity after 20 concurrent updates
- Tests verify temp file cleanup and mutex serialization
**Files Modified:**
- All 8 storage adapters (FileSystem, GCS, S3, R2, Azure, Memory, OPFS)
- HNSW Index (neighbor update serialization)
- New test: tests/unit/storage/hnswConcurrency.test.ts (8 passing tests)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 15:24:20 -07:00
|
|
|
const maxRetries = 5
|
|
|
|
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
|
|
|
try {
|
|
|
|
|
// Get current generation
|
|
|
|
|
let currentGeneration: string | undefined
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const [metadata] = await file.getMetadata()
|
|
|
|
|
currentGeneration = metadata.generation?.toString()
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
// File doesn't exist yet
|
|
|
|
|
if (error.code !== 404) {
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ATOMIC WRITE: Use generation precondition
|
|
|
|
|
await file.save(JSON.stringify(systemData, null, 2), {
|
|
|
|
|
contentType: 'application/json',
|
|
|
|
|
resumable: false,
|
|
|
|
|
preconditionOpts: currentGeneration
|
|
|
|
|
? { ifGenerationMatch: currentGeneration }
|
|
|
|
|
: { ifGenerationMatch: '0' }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Success!
|
|
|
|
|
return
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
// Precondition failed - concurrent modification
|
|
|
|
|
if (error.code === 412) {
|
|
|
|
|
if (attempt === maxRetries - 1) {
|
|
|
|
|
this.logger.error(`Max retries (${maxRetries}) exceeded for HNSW system data`)
|
|
|
|
|
throw new Error('Failed to save HNSW system data: max retries exceeded due to concurrent modifications')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const backoffMs = 50 * Math.pow(2, attempt)
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, backoffMs))
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Other error - rethrow
|
|
|
|
|
this.logger.error('Failed to save HNSW system data:', error)
|
|
|
|
|
throw new Error(`Failed to save HNSW system data: ${error}`)
|
|
|
|
|
}
|
2025-10-10 11:15:17 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get HNSW system data (entry point, max level)
|
|
|
|
|
* Storage path: system/hnsw-system.json
|
|
|
|
|
*/
|
|
|
|
|
public async getHNSWSystem(): Promise<{
|
|
|
|
|
entryPointId: string | null
|
|
|
|
|
maxLevel: number
|
|
|
|
|
} | null> {
|
|
|
|
|
await this.ensureInitialized()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const key = `${this.systemPrefix}hnsw-system.json`
|
|
|
|
|
|
|
|
|
|
const file = this.bucket!.file(key)
|
|
|
|
|
const [contents] = await file.download()
|
|
|
|
|
|
|
|
|
|
return JSON.parse(contents.toString())
|
|
|
|
|
} catch (error: any) {
|
2025-11-25 14:34:19 -08:00
|
|
|
// GCS may return 404 in different formats depending on SDK version
|
|
|
|
|
const is404 =
|
|
|
|
|
error.code === 404 ||
|
|
|
|
|
error.statusCode === 404 ||
|
|
|
|
|
error.status === 404 ||
|
|
|
|
|
error.message?.includes('No such object') ||
|
|
|
|
|
error.message?.includes('not found') ||
|
|
|
|
|
error.message?.includes('404')
|
|
|
|
|
if (is404) {
|
2025-10-10 11:15:17 -07:00
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.logger.error('Failed to get HNSW system data:', error)
|
|
|
|
|
throw new Error(`Failed to get HNSW system data: ${error}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-17 14:47:53 -07:00
|
|
|
|
|
|
|
|
// ============================================================================
|
2026-01-27 15:38:21 -08:00
|
|
|
// GCS Lifecycle Management & Autoclass
|
2025-10-17 14:47:53 -07:00
|
|
|
// Cost optimization through automatic tier transitions and Autoclass
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Set lifecycle policy for automatic tier transitions and deletions
|
|
|
|
|
*
|
|
|
|
|
* GCS Storage Classes:
|
|
|
|
|
* - STANDARD: Hot data, most expensive (~$0.020/GB/month)
|
|
|
|
|
* - NEARLINE: <1 access/month (~$0.010/GB/month, 50% cheaper)
|
|
|
|
|
* - COLDLINE: <1 access/quarter (~$0.004/GB/month, 80% cheaper)
|
|
|
|
|
* - ARCHIVE: <1 access/year (~$0.0012/GB/month, 94% cheaper!)
|
|
|
|
|
*
|
|
|
|
|
* Example usage:
|
|
|
|
|
* ```typescript
|
|
|
|
|
* await storage.setLifecyclePolicy({
|
|
|
|
|
* rules: [
|
|
|
|
|
* {
|
|
|
|
|
* action: { type: 'SetStorageClass', storageClass: 'NEARLINE' },
|
|
|
|
|
* condition: { age: 30 }
|
|
|
|
|
* },
|
|
|
|
|
* {
|
|
|
|
|
* action: { type: 'SetStorageClass', storageClass: 'COLDLINE' },
|
|
|
|
|
* condition: { age: 90 }
|
|
|
|
|
* },
|
|
|
|
|
* {
|
|
|
|
|
* action: { type: 'Delete' },
|
|
|
|
|
* condition: { age: 365 }
|
|
|
|
|
* }
|
|
|
|
|
* ]
|
|
|
|
|
* })
|
|
|
|
|
* ```
|
|
|
|
|
*
|
|
|
|
|
* @param options Lifecycle configuration with rules for transitions and deletions
|
|
|
|
|
*/
|
|
|
|
|
public async setLifecyclePolicy(options: {
|
|
|
|
|
rules: Array<{
|
|
|
|
|
action: {
|
|
|
|
|
type: 'Delete' | 'SetStorageClass'
|
|
|
|
|
storageClass?: 'STANDARD' | 'NEARLINE' | 'COLDLINE' | 'ARCHIVE'
|
|
|
|
|
}
|
|
|
|
|
condition: {
|
|
|
|
|
age?: number // Days since object creation
|
|
|
|
|
createdBefore?: string // ISO 8601 date
|
|
|
|
|
matchesPrefix?: string[]
|
|
|
|
|
matchesSuffix?: string[]
|
|
|
|
|
}
|
|
|
|
|
}>
|
|
|
|
|
}): Promise<void> {
|
|
|
|
|
await this.ensureInitialized()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
this.logger.info(`Setting GCS lifecycle policy with ${options.rules.length} rules`)
|
|
|
|
|
|
|
|
|
|
// GCS lifecycle rules format
|
|
|
|
|
const lifecycleRules = options.rules.map(rule => {
|
|
|
|
|
const gcsRule: any = {
|
|
|
|
|
action: {
|
|
|
|
|
type: rule.action.type
|
|
|
|
|
},
|
|
|
|
|
condition: {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add storage class for SetStorageClass action
|
|
|
|
|
if (rule.action.type === 'SetStorageClass' && rule.action.storageClass) {
|
|
|
|
|
gcsRule.action.storageClass = rule.action.storageClass
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add conditions
|
|
|
|
|
if (rule.condition.age !== undefined) {
|
|
|
|
|
gcsRule.condition.age = rule.condition.age
|
|
|
|
|
}
|
|
|
|
|
if (rule.condition.createdBefore) {
|
|
|
|
|
gcsRule.condition.createdBefore = rule.condition.createdBefore
|
|
|
|
|
}
|
|
|
|
|
if (rule.condition.matchesPrefix) {
|
|
|
|
|
gcsRule.condition.matchesPrefix = rule.condition.matchesPrefix
|
|
|
|
|
}
|
|
|
|
|
if (rule.condition.matchesSuffix) {
|
|
|
|
|
gcsRule.condition.matchesSuffix = rule.condition.matchesSuffix
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return gcsRule
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Update bucket lifecycle configuration
|
|
|
|
|
await this.bucket!.setMetadata({
|
|
|
|
|
lifecycle: {
|
|
|
|
|
rule: lifecycleRules
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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 GCS lifecycle policy: ${error.message || error}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get current lifecycle policy configuration
|
|
|
|
|
*
|
|
|
|
|
* @returns Lifecycle configuration with all rules, or null if no policy is set
|
|
|
|
|
*/
|
|
|
|
|
public async getLifecyclePolicy(): Promise<{
|
|
|
|
|
rules: Array<{
|
|
|
|
|
action: {
|
|
|
|
|
type: string
|
|
|
|
|
storageClass?: string
|
|
|
|
|
}
|
|
|
|
|
condition: {
|
|
|
|
|
age?: number
|
|
|
|
|
createdBefore?: string
|
|
|
|
|
matchesPrefix?: string[]
|
|
|
|
|
matchesSuffix?: string[]
|
|
|
|
|
}
|
|
|
|
|
}>
|
|
|
|
|
} | null> {
|
|
|
|
|
await this.ensureInitialized()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
this.logger.info('Getting GCS lifecycle policy')
|
|
|
|
|
|
|
|
|
|
const [metadata] = await this.bucket!.getMetadata()
|
|
|
|
|
|
|
|
|
|
if (!metadata.lifecycle || !metadata.lifecycle.rule || metadata.lifecycle.rule.length === 0) {
|
|
|
|
|
this.logger.info('No lifecycle policy configured')
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert GCS format to our format
|
|
|
|
|
const rules = metadata.lifecycle.rule.map((rule: any) => ({
|
|
|
|
|
action: {
|
|
|
|
|
type: rule.action.type,
|
|
|
|
|
...(rule.action.storageClass && { storageClass: rule.action.storageClass })
|
|
|
|
|
},
|
|
|
|
|
condition: {
|
|
|
|
|
...(rule.condition.age !== undefined && { age: rule.condition.age }),
|
|
|
|
|
...(rule.condition.createdBefore && { createdBefore: rule.condition.createdBefore }),
|
|
|
|
|
...(rule.condition.matchesPrefix && { matchesPrefix: rule.condition.matchesPrefix }),
|
|
|
|
|
...(rule.condition.matchesSuffix && { matchesSuffix: rule.condition.matchesSuffix })
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
this.logger.info(`Found lifecycle policy with ${rules.length} rules`)
|
|
|
|
|
|
|
|
|
|
return { rules }
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
this.logger.error('Failed to get lifecycle policy:', error)
|
|
|
|
|
throw new Error(`Failed to get GCS lifecycle policy: ${error.message || error}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Remove lifecycle policy from bucket
|
|
|
|
|
*/
|
|
|
|
|
public async removeLifecyclePolicy(): Promise<void> {
|
|
|
|
|
await this.ensureInitialized()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
this.logger.info('Removing GCS lifecycle policy')
|
|
|
|
|
|
|
|
|
|
// Remove lifecycle configuration
|
|
|
|
|
await this.bucket!.setMetadata({
|
|
|
|
|
lifecycle: null
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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 GCS lifecycle policy: ${error.message || error}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Enable Autoclass for automatic storage class optimization
|
|
|
|
|
*
|
|
|
|
|
* GCS Autoclass automatically moves objects between storage classes based on access patterns:
|
|
|
|
|
* - Frequent Access → STANDARD
|
|
|
|
|
* - Infrequent Access (30 days) → NEARLINE
|
|
|
|
|
* - Rarely Accessed (90 days) → COLDLINE
|
|
|
|
|
* - Archive Access (365 days) → ARCHIVE
|
|
|
|
|
*
|
|
|
|
|
* Benefits:
|
|
|
|
|
* - Automatic optimization based on access patterns (no manual rules needed)
|
|
|
|
|
* - No early deletion fees
|
|
|
|
|
* - No retrieval fees for NEARLINE/COLDLINE (only ARCHIVE has retrieval fees)
|
|
|
|
|
* - Up to 94% cost savings automatically
|
|
|
|
|
*
|
|
|
|
|
* Note: Autoclass is a bucket-level feature that requires bucket.update permission.
|
|
|
|
|
* It cannot be enabled per-object or per-prefix.
|
|
|
|
|
*
|
|
|
|
|
* @param options Autoclass configuration
|
|
|
|
|
*/
|
|
|
|
|
public async enableAutoclass(options: {
|
|
|
|
|
terminalStorageClass?: 'NEARLINE' | 'ARCHIVE' // Coldest storage class to use
|
|
|
|
|
} = {}): Promise<void> {
|
|
|
|
|
await this.ensureInitialized()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
this.logger.info('Enabling GCS Autoclass')
|
|
|
|
|
|
|
|
|
|
const autoclassConfig: any = {
|
|
|
|
|
enabled: true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set terminal storage class if specified
|
|
|
|
|
if (options.terminalStorageClass) {
|
|
|
|
|
autoclassConfig.terminalStorageClass = options.terminalStorageClass
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.bucket!.setMetadata({
|
|
|
|
|
autoclass: autoclassConfig
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
this.logger.info(`Successfully enabled Autoclass${options.terminalStorageClass ? ` with terminal class ${options.terminalStorageClass}` : ''}`)
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
this.logger.error('Failed to enable Autoclass:', error)
|
|
|
|
|
throw new Error(`Failed to enable GCS Autoclass: ${error.message || error}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get Autoclass configuration and status
|
|
|
|
|
*
|
|
|
|
|
* @returns Autoclass status, or null if not configured
|
|
|
|
|
*/
|
|
|
|
|
public async getAutoclassStatus(): Promise<{
|
|
|
|
|
enabled: boolean
|
|
|
|
|
terminalStorageClass?: string
|
|
|
|
|
toggleTime?: string
|
|
|
|
|
} | null> {
|
|
|
|
|
await this.ensureInitialized()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
this.logger.info('Getting GCS Autoclass status')
|
|
|
|
|
|
|
|
|
|
const [metadata] = await this.bucket!.getMetadata()
|
|
|
|
|
|
|
|
|
|
if (!metadata.autoclass) {
|
|
|
|
|
this.logger.info('Autoclass not configured')
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const status = {
|
|
|
|
|
enabled: metadata.autoclass.enabled || false,
|
|
|
|
|
...(metadata.autoclass.terminalStorageClass && {
|
|
|
|
|
terminalStorageClass: metadata.autoclass.terminalStorageClass
|
|
|
|
|
}),
|
|
|
|
|
...(metadata.autoclass.toggleTime && {
|
|
|
|
|
toggleTime: metadata.autoclass.toggleTime
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.logger.info(`Autoclass status: ${status.enabled ? 'enabled' : 'disabled'}`)
|
|
|
|
|
|
|
|
|
|
return status
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
this.logger.error('Failed to get Autoclass status:', error)
|
|
|
|
|
throw new Error(`Failed to get GCS Autoclass status: ${error.message || error}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Disable Autoclass for the bucket
|
|
|
|
|
*/
|
|
|
|
|
public async disableAutoclass(): Promise<void> {
|
|
|
|
|
await this.ensureInitialized()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
this.logger.info('Disabling GCS Autoclass')
|
|
|
|
|
|
|
|
|
|
await this.bucket!.setMetadata({
|
|
|
|
|
autoclass: {
|
|
|
|
|
enabled: false
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
this.logger.info('Successfully disabled Autoclass')
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
this.logger.error('Failed to disable Autoclass:', error)
|
|
|
|
|
throw new Error(`Failed to disable GCS Autoclass: ${error.message || error}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-08 14:08:43 -07:00
|
|
|
}
|