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>
This commit is contained in:
parent
fc307bc215
commit
46e74827c4
8 changed files with 180 additions and 349 deletions
|
|
@ -1642,14 +1642,39 @@ export class AzureBlobStorage extends BaseStorage {
|
|||
await this.ensureInitialized()
|
||||
|
||||
try {
|
||||
// CRITICAL FIX (v4.7.3): Must preserve existing node data (id, vector) when updating HNSW metadata
|
||||
const shard = getShardIdFromUuid(nounId)
|
||||
const key = `entities/nouns/hnsw/${shard}/${nounId}.json`
|
||||
|
||||
const blockBlobClient = this.containerClient!.getBlockBlobClient(key)
|
||||
const content = JSON.stringify(hnswData, null, 2)
|
||||
await blockBlobClient.upload(content, content.length, {
|
||||
blobHTTPHeaders: { blobContentType: 'application/json' }
|
||||
})
|
||||
|
||||
try {
|
||||
// Read existing node data
|
||||
const downloadResponse = await blockBlobClient.download(0)
|
||||
const existingData = await this.streamToBuffer(downloadResponse.readableStreamBody!)
|
||||
const existingNode = JSON.parse(existingData.toString())
|
||||
|
||||
// Preserve id and vector, update only HNSW graph metadata
|
||||
const updatedNode = {
|
||||
...existingNode,
|
||||
level: hnswData.level,
|
||||
connections: hnswData.connections
|
||||
}
|
||||
|
||||
const content = JSON.stringify(updatedNode, null, 2)
|
||||
await blockBlobClient.upload(content, content.length, {
|
||||
blobHTTPHeaders: { blobContentType: 'application/json' }
|
||||
})
|
||||
} catch (error: any) {
|
||||
// If node doesn't exist yet, create it with just HNSW data
|
||||
if (error.statusCode === 404 || error.code === 'BlobNotFound') {
|
||||
const content = JSON.stringify(hnswData, null, 2)
|
||||
await blockBlobClient.upload(content, content.length, {
|
||||
blobHTTPHeaders: { blobContentType: 'application/json' }
|
||||
})
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to save HNSW data for ${nounId}:`, error)
|
||||
throw new Error(`Failed to save HNSW data for ${nounId}: ${error}`)
|
||||
|
|
|
|||
|
|
@ -2579,13 +2579,36 @@ export class FileSystemStorage extends BaseStorage {
|
|||
}): Promise<void> {
|
||||
await this.ensureInitialized()
|
||||
|
||||
// Use sharded path for HNSW data
|
||||
const shard = nounId.substring(0, 2).toLowerCase()
|
||||
const hnswDir = path.join(this.rootDir, 'entities', 'nouns', 'hnsw', shard)
|
||||
await this.ensureDirectoryExists(hnswDir)
|
||||
// CRITICAL FIX (v4.7.3): Must preserve existing node data (id, vector) when updating HNSW metadata
|
||||
// Previous implementation overwrote the entire file, destroying vector data
|
||||
// Now we READ the existing node, UPDATE only connections/level, then WRITE back the complete node
|
||||
|
||||
const filePath = path.join(hnswDir, `${nounId}.json`)
|
||||
await fs.promises.writeFile(filePath, JSON.stringify(hnswData, null, 2))
|
||||
const filePath = this.getNodePath(nounId)
|
||||
|
||||
try {
|
||||
// Read existing node data
|
||||
const existingData = await fs.promises.readFile(filePath, 'utf-8')
|
||||
const existingNode = JSON.parse(existingData)
|
||||
|
||||
// Preserve id and vector, update only HNSW graph metadata
|
||||
const updatedNode = {
|
||||
...existingNode, // Preserve all existing fields (id, vector, etc.)
|
||||
level: hnswData.level,
|
||||
connections: hnswData.connections
|
||||
}
|
||||
|
||||
// Write back the COMPLETE node with updated HNSW data
|
||||
await fs.promises.writeFile(filePath, JSON.stringify(updatedNode, null, 2))
|
||||
} catch (error: any) {
|
||||
// If node doesn't exist yet, create it with just HNSW data
|
||||
// This should only happen during initial node creation
|
||||
if (error.code === 'ENOENT') {
|
||||
await this.ensureDirectoryExists(path.dirname(filePath))
|
||||
await fs.promises.writeFile(filePath, JSON.stringify(hnswData, null, 2))
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1867,15 +1867,43 @@ export class GcsStorage extends BaseStorage {
|
|||
await this.ensureInitialized()
|
||||
|
||||
try {
|
||||
// Use sharded path for HNSW data
|
||||
// CRITICAL FIX (v4.7.3): Must preserve existing node data (id, vector) when updating HNSW metadata
|
||||
// Previous implementation overwrote the entire file, destroying vector data
|
||||
// Now we READ the existing node, UPDATE only connections/level, then WRITE back the complete node
|
||||
|
||||
const shard = getShardIdFromUuid(nounId)
|
||||
const key = `entities/nouns/hnsw/${shard}/${nounId}.json`
|
||||
|
||||
const file = this.bucket!.file(key)
|
||||
await file.save(JSON.stringify(hnswData, null, 2), {
|
||||
contentType: 'application/json',
|
||||
resumable: false
|
||||
})
|
||||
|
||||
try {
|
||||
// Read existing node data
|
||||
const [existingData] = await file.download()
|
||||
const existingNode = JSON.parse(existingData.toString())
|
||||
|
||||
// Preserve id and vector, update only HNSW graph metadata
|
||||
const updatedNode = {
|
||||
...existingNode, // Preserve all existing fields (id, vector, etc.)
|
||||
level: hnswData.level,
|
||||
connections: hnswData.connections
|
||||
}
|
||||
|
||||
// Write back the COMPLETE node with updated HNSW data
|
||||
await file.save(JSON.stringify(updatedNode, null, 2), {
|
||||
contentType: 'application/json',
|
||||
resumable: false
|
||||
})
|
||||
} catch (error: any) {
|
||||
// If node doesn't exist yet, create it with just HNSW data
|
||||
// This should only happen during initial node creation
|
||||
if (error.code === 404) {
|
||||
await file.save(JSON.stringify(hnswData, null, 2), {
|
||||
contentType: 'application/json',
|
||||
resumable: false
|
||||
})
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to save HNSW data for ${nounId}:`, error)
|
||||
throw new Error(`Failed to save HNSW data for ${nounId}: ${error}`)
|
||||
|
|
|
|||
|
|
@ -2016,20 +2016,34 @@ export class OPFSStorage extends BaseStorage {
|
|||
await this.ensureInitialized()
|
||||
|
||||
try {
|
||||
// Get or create the hnsw directory under nouns
|
||||
// CRITICAL FIX (v4.7.3): Must preserve existing node data (id, vector) when updating HNSW metadata
|
||||
const hnswDir = await this.nounsDir!.getDirectoryHandle('hnsw', { create: true })
|
||||
|
||||
// Use sharded path for HNSW data
|
||||
const shard = getShardIdFromUuid(nounId)
|
||||
const shardDir = await hnswDir.getDirectoryHandle(shard, { create: true })
|
||||
|
||||
// Create or get the file in the shard directory
|
||||
const fileHandle = await shardDir.getFileHandle(`${nounId}.json`, { create: true })
|
||||
|
||||
// Write the HNSW data to the file
|
||||
const writable = await fileHandle.createWritable()
|
||||
await writable.write(JSON.stringify(hnswData, null, 2))
|
||||
await writable.close()
|
||||
try {
|
||||
// Read existing node data
|
||||
const file = await fileHandle.getFile()
|
||||
const existingData = await file.text()
|
||||
const existingNode = JSON.parse(existingData)
|
||||
|
||||
// Preserve id and vector, update only HNSW graph metadata
|
||||
const updatedNode = {
|
||||
...existingNode,
|
||||
level: hnswData.level,
|
||||
connections: hnswData.connections
|
||||
}
|
||||
|
||||
const writable = await fileHandle.createWritable()
|
||||
await writable.write(JSON.stringify(updatedNode, null, 2))
|
||||
await writable.close()
|
||||
} catch (error) {
|
||||
// If node doesn't exist or read fails, create with just HNSW data
|
||||
const writable = await fileHandle.createWritable()
|
||||
await writable.write(JSON.stringify(hnswData, null, 2))
|
||||
await writable.close()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to save HNSW data for ${nounId}:`, error)
|
||||
throw new Error(`Failed to save HNSW data for ${nounId}: ${error}`)
|
||||
|
|
|
|||
|
|
@ -1002,10 +1002,30 @@ export class R2Storage extends BaseStorage {
|
|||
}): Promise<void> {
|
||||
await this.ensureInitialized()
|
||||
|
||||
// CRITICAL FIX (v4.7.3): Must preserve existing node data (id, vector) when updating HNSW metadata
|
||||
const shard = getShardIdFromUuid(nounId)
|
||||
const key = `entities/nouns/hnsw/${shard}/${nounId}.json`
|
||||
|
||||
await this.writeObjectToPath(key, hnswData)
|
||||
try {
|
||||
// Read existing node data
|
||||
const existingNode = await this.readObjectFromPath(key)
|
||||
|
||||
if (existingNode) {
|
||||
// Preserve id and vector, update only HNSW graph metadata
|
||||
const updatedNode = {
|
||||
...existingNode,
|
||||
level: hnswData.level,
|
||||
connections: hnswData.connections
|
||||
}
|
||||
await this.writeObjectToPath(key, updatedNode)
|
||||
} else {
|
||||
// Node doesn't exist yet, create with just HNSW data
|
||||
await this.writeObjectToPath(key, hnswData)
|
||||
}
|
||||
} catch (error) {
|
||||
// If read fails, create with just HNSW data
|
||||
await this.writeObjectToPath(key, hnswData)
|
||||
}
|
||||
}
|
||||
|
||||
public async getHNSWData(nounId: string): Promise<{
|
||||
|
|
|
|||
|
|
@ -3907,20 +3907,53 @@ export class S3CompatibleStorage extends BaseStorage {
|
|||
await this.ensureInitialized()
|
||||
|
||||
try {
|
||||
const { PutObjectCommand } = await import('@aws-sdk/client-s3')
|
||||
const { PutObjectCommand, GetObjectCommand } = await import('@aws-sdk/client-s3')
|
||||
|
||||
// Use sharded path for HNSW data
|
||||
// CRITICAL FIX (v4.7.3): Must preserve existing node data (id, vector) when updating HNSW metadata
|
||||
const shard = getShardIdFromUuid(nounId)
|
||||
const key = `entities/nouns/hnsw/${shard}/${nounId}.json`
|
||||
|
||||
await this.s3Client!.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
Body: JSON.stringify(hnswData, null, 2),
|
||||
ContentType: 'application/json'
|
||||
})
|
||||
)
|
||||
try {
|
||||
// Read existing node data
|
||||
const getResponse = await this.s3Client!.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key
|
||||
})
|
||||
)
|
||||
const existingData = await getResponse.Body!.transformToString()
|
||||
const existingNode = JSON.parse(existingData)
|
||||
|
||||
// Preserve id and vector, update only HNSW graph metadata
|
||||
const updatedNode = {
|
||||
...existingNode,
|
||||
level: hnswData.level,
|
||||
connections: hnswData.connections
|
||||
}
|
||||
|
||||
await this.s3Client!.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
Body: JSON.stringify(updatedNode, null, 2),
|
||||
ContentType: 'application/json'
|
||||
})
|
||||
)
|
||||
} catch (error: any) {
|
||||
// If node doesn't exist yet, create it with just HNSW data
|
||||
if (error.name === 'NoSuchKey' || error.Code === 'NoSuchKey') {
|
||||
await this.s3Client!.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
Body: JSON.stringify(hnswData, null, 2),
|
||||
ContentType: 'application/json'
|
||||
})
|
||||
)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to save HNSW data for ${nounId}:`, error)
|
||||
throw new Error(`Failed to save HNSW data for ${nounId}: ${error}`)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue