Fixes critical scalability bottleneck where metadata was stored in non-sharded
directories, causing performance degradation at scale (1M+ entities).
Changes:
- Add UUID-based sharding to metadata operations in S3Compatible, FileSystem, and OpFS storage
- Implement complete UUID-based sharding for OpFS storage (nouns, verbs, metadata)
- Update pagination methods to iterate through all 256 UUID-based shards
- Add integration tests verifying sharding behavior across storage adapters
Impact:
- Metadata now scales to millions of entities without directory bottlenecks
- All storage adapters now use consistent UUID-based sharding (256 buckets: 00-ff)
- Improves GCS/S3/R2/OpFS performance at scale
- Path format: entities/{type}/{subtype}/{shard}/{id}.json
Breaking change: Requires data migration for existing S3/GCS/R2/OpFS deployments.
See .strategy/UNIFIED-UUID-SHARDING.md for migration guidance.
390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
/**
|
||
* GCS Persistence Bug Fix Test
|
||
*
|
||
* This test verifies that the critical GCS persistence bug is fixed:
|
||
* - Data writes to GCS successfully
|
||
* - Data loads back on init() after restart
|
||
* - Sharding is properly handled
|
||
* - getStats() returns correct counts
|
||
* - find() returns correct results
|
||
*
|
||
* Bug Report: /home/dpsifr/Projects/brain-cloud/BRAINY_GCS_PERSISTENCE_BUG_REPORT.md
|
||
*/
|
||
|
||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||
import { Brainy } from '../../src/brainy.js'
|
||
import { MemoryStorage } from '../../src/storage/adapters/memoryStorage.js'
|
||
import { S3CompatibleStorage } from '../../src/storage/adapters/s3CompatibleStorage.js'
|
||
import { randomUUID } from 'node:crypto'
|
||
|
||
describe('GCS Persistence Bug Fix - Sharded Storage', () => {
|
||
// Mock S3 client for testing
|
||
let mockS3Objects: Map<string, any> = new Map()
|
||
|
||
// Helper to create mock S3 client
|
||
function createMockS3Client() {
|
||
return {
|
||
send: async (command: any) => {
|
||
const commandName = command.constructor.name
|
||
console.log(`[Mock S3] ${commandName}:`, command.input)
|
||
|
||
if (commandName === 'HeadBucketCommand') {
|
||
// Bucket exists
|
||
return {}
|
||
}
|
||
|
||
if (commandName === 'ListObjectsV2Command') {
|
||
const prefix = command.input.Prefix || ''
|
||
const maxKeys = command.input.MaxKeys || 1000
|
||
const continuationToken = command.input.ContinuationToken
|
||
|
||
// Filter objects by prefix
|
||
const allKeys = Array.from(mockS3Objects.keys())
|
||
const matchingKeys = allKeys.filter(key => key.startsWith(prefix)).sort()
|
||
|
||
console.log(`[Mock S3] List: Prefix="${prefix}", Total keys=${allKeys.length}, Matching=${matchingKeys.length}`)
|
||
if (matchingKeys.length > 0) {
|
||
console.log(`[Mock S3] First match: ${matchingKeys[0]}`)
|
||
}
|
||
|
||
// Apply pagination
|
||
let startIndex = 0
|
||
if (continuationToken) {
|
||
startIndex = parseInt(continuationToken)
|
||
}
|
||
|
||
const endIndex = Math.min(startIndex + maxKeys, matchingKeys.length)
|
||
const pageKeys = matchingKeys.slice(startIndex, endIndex)
|
||
|
||
const contents = pageKeys.map(key => ({
|
||
Key: key,
|
||
LastModified: new Date(),
|
||
Size: JSON.stringify(mockS3Objects.get(key)).length
|
||
}))
|
||
|
||
return {
|
||
Contents: contents,
|
||
IsTruncated: endIndex < matchingKeys.length,
|
||
NextContinuationToken: endIndex < matchingKeys.length ? String(endIndex) : undefined
|
||
}
|
||
}
|
||
|
||
if (commandName === 'PutObjectCommand') {
|
||
const key = command.input.Key
|
||
const body = command.input.Body
|
||
|
||
mockS3Objects.set(key, JSON.parse(body))
|
||
return { ETag: '"mock-etag"' }
|
||
}
|
||
|
||
if (commandName === 'GetObjectCommand') {
|
||
const key = command.input.Key
|
||
const data = mockS3Objects.get(key)
|
||
|
||
if (!data) {
|
||
console.log(`[Mock S3] GetObject MISS: ${key}`)
|
||
const error: any = new Error('NoSuchKey')
|
||
error.name = 'NoSuchKey'
|
||
throw error
|
||
}
|
||
console.log(`[Mock S3] GetObject HIT: ${key}`)
|
||
|
||
// Mock AWS SDK v3 response
|
||
const bodyString = JSON.stringify(data)
|
||
return {
|
||
Body: {
|
||
transformToString: async () => bodyString,
|
||
// Also support direct buffer reading
|
||
async *[Symbol.asyncIterator]() {
|
||
yield Buffer.from(bodyString)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (commandName === 'DeleteObjectCommand') {
|
||
const key = command.input.Key
|
||
mockS3Objects.delete(key)
|
||
return {}
|
||
}
|
||
|
||
throw new Error(`Unsupported command: ${commandName}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
it('should write and read data with UUID-based sharding', async () => {
|
||
// Reset mock storage
|
||
mockS3Objects.clear()
|
||
|
||
// Create storage (sharding is automatic via UUID prefixes)
|
||
const storage = new S3CompatibleStorage({
|
||
bucketName: 'test-bucket',
|
||
region: 'us-central1',
|
||
endpoint: 'https://storage.googleapis.com',
|
||
accessKeyId: 'test-key',
|
||
secretAccessKey: 'test-secret',
|
||
serviceType: 'gcs'
|
||
})
|
||
|
||
// Mock the S3 client
|
||
;(storage as any).s3Client = createMockS3Client()
|
||
;(storage as any).isInitialized = true
|
||
|
||
// Note: Sharding is now automatic based on UUID prefix (no setup needed)
|
||
|
||
// ========== PHASE 1: Write Data ==========
|
||
console.log('\n📝 Phase 1: Writing data with UUID-based sharding...')
|
||
|
||
// Generate proper UUIDs for testing
|
||
const testData = [
|
||
{ id: randomUUID(), data: 'Alice', type: 'user', metadata: { name: 'Alice' } },
|
||
{ id: randomUUID(), data: 'Bob', type: 'user', metadata: { name: 'Bob' } },
|
||
{ id: randomUUID(), data: 'Charlie', type: 'user', metadata: { name: 'Charlie' } }
|
||
]
|
||
|
||
for (const item of testData) {
|
||
const noun = {
|
||
id: item.id,
|
||
vector: [0.1, 0.2, 0.3],
|
||
connections: new Map(),
|
||
layer: 0
|
||
}
|
||
await storage.saveNoun(noun)
|
||
await storage.saveNounMetadata(item.id, {
|
||
type: item.type,
|
||
data: item.data,
|
||
...item.metadata
|
||
})
|
||
}
|
||
|
||
console.log(`✅ Wrote ${testData.length} entities`)
|
||
console.log(`📊 Objects in mock storage: ${mockS3Objects.size}`)
|
||
|
||
// Verify data was written to UUID-sharded paths (entities/nouns/vectors/{shard}/)
|
||
const shardedKeys = Array.from(mockS3Objects.keys()).filter(k =>
|
||
k.match(/entities\/nouns\/vectors\/[0-9a-f]{2}\//)
|
||
)
|
||
console.log(`🔑 UUID-sharded keys: ${shardedKeys.length}`)
|
||
expect(shardedKeys.length).toBeGreaterThan(0)
|
||
|
||
// Log shard distribution
|
||
const shards = new Set(shardedKeys.map(k => k.match(/entities\/nouns\/vectors\/([0-9a-f]{2})\//)?.[1]))
|
||
console.log(`📁 Data distributed across ${shards.size} shards: ${Array.from(shards).join(', ')}`)
|
||
|
||
// ========== PHASE 2: Read Data (Simulates Container Restart) ==========
|
||
console.log('\n🔄 Phase 2: Reading data after restart...')
|
||
|
||
// List nouns (this should work with sharding)
|
||
const result = await storage.getNouns({ pagination: { limit: 100 } })
|
||
|
||
console.log(`✅ Found ${result.items.length} entities`)
|
||
console.log(`📊 Total count: ${result.totalCount}`)
|
||
|
||
// Verify results
|
||
expect(result.items.length).toBe(testData.length)
|
||
expect(result.totalCount).toBe(testData.length)
|
||
|
||
// Verify each entity can be retrieved
|
||
for (const item of testData) {
|
||
const noun = await storage.getNoun(item.id)
|
||
expect(noun).toBeTruthy()
|
||
expect(noun!.id).toBe(item.id)
|
||
}
|
||
|
||
console.log('✅ All entities retrieved successfully')
|
||
})
|
||
|
||
it('should handle pagination across UUID shards', async () => {
|
||
// Reset mock storage
|
||
mockS3Objects.clear()
|
||
|
||
// Create storage (sharding is automatic)
|
||
const storage = new S3CompatibleStorage({
|
||
bucketName: 'test-bucket',
|
||
region: 'us-central1',
|
||
accessKeyId: 'test-key',
|
||
secretAccessKey: 'test-secret',
|
||
serviceType: 'gcs'
|
||
})
|
||
|
||
;(storage as any).s3Client = createMockS3Client()
|
||
;(storage as any).isInitialized = true
|
||
|
||
// Write 10 entities with proper UUIDs
|
||
console.log('\n📝 Writing 10 entities with UUID-based sharding...')
|
||
for (let i = 0; i < 10; i++) {
|
||
const id = randomUUID()
|
||
const noun = {
|
||
id,
|
||
vector: [0.1 * i, 0.2 * i, 0.3 * i],
|
||
connections: new Map(),
|
||
layer: 0
|
||
}
|
||
await storage.saveNoun(noun)
|
||
}
|
||
|
||
// Read with pagination (limit: 3)
|
||
console.log('\n🔄 Reading with pagination (limit: 3)...')
|
||
|
||
let allEntities: any[] = []
|
||
let cursor: string | undefined
|
||
let page = 0
|
||
|
||
do {
|
||
const result = await storage.getNouns({
|
||
pagination: { limit: 3, cursor }
|
||
})
|
||
|
||
page++
|
||
console.log(`📄 Page ${page}: ${result.items.length} entities, hasMore: ${result.hasMore}`)
|
||
|
||
allEntities.push(...result.items)
|
||
cursor = result.nextCursor
|
||
|
||
// Safety check to prevent infinite loops
|
||
expect(page).toBeLessThan(20)
|
||
} while (cursor)
|
||
|
||
console.log(`✅ Loaded ${allEntities.length} total entities across ${page} pages`)
|
||
|
||
// Verify all entities were loaded
|
||
expect(allEntities.length).toBe(10)
|
||
})
|
||
|
||
it('should return correct totalCount on first call', async () => {
|
||
// Reset mock storage
|
||
mockS3Objects.clear()
|
||
|
||
// Create storage (sharding automatic)
|
||
const storage = new S3CompatibleStorage({
|
||
bucketName: 'test-bucket',
|
||
region: 'us-central1',
|
||
accessKeyId: 'test-key',
|
||
secretAccessKey: 'test-secret',
|
||
serviceType: 'gcs'
|
||
})
|
||
|
||
;(storage as any).s3Client = createMockS3Client()
|
||
;(storage as any).isInitialized = true
|
||
|
||
// Write 5 entities with UUIDs
|
||
for (let i = 0; i < 5; i++) {
|
||
await storage.saveNoun({
|
||
id: randomUUID(),
|
||
vector: [0.1, 0.2, 0.3],
|
||
connections: new Map(),
|
||
layer: 0
|
||
})
|
||
}
|
||
|
||
// Get first page
|
||
const result = await storage.getNouns({ pagination: { limit: 2 } })
|
||
|
||
console.log(`📊 First page: ${result.items.length} items, totalCount: ${result.totalCount}`)
|
||
|
||
// totalCount should be set on first call
|
||
expect(result.totalCount).toBe(5)
|
||
expect(result.items.length).toBeLessThanOrEqual(2)
|
||
})
|
||
|
||
it('should work with S3 storage type (UUID sharding still active)', async () => {
|
||
// Reset mock storage
|
||
mockS3Objects.clear()
|
||
|
||
// Create S3 storage (sharding is still automatic via UUID)
|
||
const storage = new S3CompatibleStorage({
|
||
bucketName: 'test-bucket',
|
||
region: 'us-central1',
|
||
accessKeyId: 'test-key',
|
||
secretAccessKey: 'test-secret',
|
||
serviceType: 's3'
|
||
})
|
||
|
||
;(storage as any).s3Client = createMockS3Client()
|
||
;(storage as any).isInitialized = true
|
||
|
||
// Note: UUID-based sharding is always enabled regardless of service type
|
||
|
||
// Write data
|
||
console.log('\n📝 Writing data to S3 with UUID sharding...')
|
||
for (let i = 0; i < 3; i++) {
|
||
await storage.saveNoun({
|
||
id: randomUUID(),
|
||
vector: [0.1, 0.2, 0.3],
|
||
connections: new Map(),
|
||
layer: 0
|
||
})
|
||
}
|
||
|
||
// Verify data was written to UUID-sharded paths
|
||
const shardedKeys = Array.from(mockS3Objects.keys()).filter(k =>
|
||
k.match(/entities\/nouns\/vectors\/[0-9a-f]{2}\//)
|
||
)
|
||
console.log(`🔑 UUID-sharded keys: ${shardedKeys.length}`)
|
||
expect(shardedKeys.length).toBeGreaterThan(0)
|
||
|
||
// Read data
|
||
const result = await storage.getNouns({ pagination: { limit: 100 } })
|
||
|
||
console.log(`✅ Found ${result.items.length} entities without sharding`)
|
||
|
||
expect(result.items.length).toBe(3)
|
||
expect(result.totalCount).toBe(3)
|
||
})
|
||
})
|
||
|
||
describe('GCS Persistence Bug Fix - End-to-End with Brainy', () => {
|
||
it('should persist data across Brainy restarts (simulated)', async () => {
|
||
console.log('\n🧠 Testing full Brainy persistence cycle...')
|
||
|
||
// Shared storage state (simulates persistent GCS bucket)
|
||
const persistentStorage = new Map<string, any>()
|
||
|
||
// Helper to create Brainy instance with persistent storage
|
||
const createBrain = async () => {
|
||
// Use memory storage as a proxy (in real test, would use real S3/GCS)
|
||
const brain = new Brainy({
|
||
storage: { type: 'memory' },
|
||
embeddingProvider: 'mock',
|
||
silent: true
|
||
})
|
||
|
||
await brain.init()
|
||
return brain
|
||
}
|
||
|
||
// ========== INSTANCE 1: Write Data ==========
|
||
console.log('\n📝 Instance 1: Writing data...')
|
||
const brain1 = await createBrain()
|
||
|
||
const id1 = await brain1.add({
|
||
data: 'Test User',
|
||
type: 'user',
|
||
metadata: {
|
||
email: 'test@example.com',
|
||
name: 'Test User'
|
||
}
|
||
})
|
||
|
||
const stats1 = brain1.getStats()
|
||
console.log(`✅ Instance 1 stats: ${stats1.entities.total} entities`)
|
||
expect(stats1.entities.total).toBe(1)
|
||
|
||
// ========== INSTANCE 2: Read Data (Simulates Restart) ==========
|
||
console.log('\n🔄 Instance 2: Reading data after restart...')
|
||
|
||
// Note: With memory storage, data is lost on restart
|
||
// This test demonstrates the concept - real GCS test would use actual S3CompatibleStorage
|
||
const brain2 = await createBrain()
|
||
|
||
const stats2 = brain2.getStats()
|
||
console.log(`📊 Instance 2 stats: ${stats2.entities.total} entities`)
|
||
|
||
// With memory storage, this will be 0 (expected for this test)
|
||
// With GCS storage + our fix, this should be 1
|
||
console.log('ℹ️ Note: This test uses memory storage. GCS storage would persist data.')
|
||
})
|
||
})
|
||
|
||
console.log('\n✅ GCS Persistence Bug Fix Tests Complete')
|