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>
This commit is contained in:
David Snelling 2025-11-19 08:59:11 -08:00
parent d624f39fce
commit 95cbab2e3f
10 changed files with 2162 additions and 81 deletions

View file

@ -1876,6 +1876,301 @@ export abstract class BaseStorage extends BaseStorageAdapter {
return null
}
/**
* Batch fetch noun metadata from storage (v5.12.0 - Cloud Storage Optimization)
*
* **Performance**: Reduces N sequential calls 1-2 batch calls
* - Local storage: N × 10ms 1 × 10ms parallel (N× faster)
* - Cloud storage: N × 300ms 1 × 300ms batch (N× faster)
*
* **Use cases:**
* - VFS tree traversal (fetch all children at once)
* - brain.find() result hydration (batch load entities)
* - brain.getRelations() target entities (eliminate N+1)
* - Import operations (batch existence checks)
*
* @param ids Array of entity IDs to fetch
* @returns Map of id metadata (only successful fetches included)
*
* @example
* ```typescript
* // Before (N+1 pattern)
* for (const id of ids) {
* const metadata = await storage.getNounMetadata(id) // N calls
* }
*
* // After (batched)
* const metadataMap = await storage.getNounMetadataBatch(ids) // 1 call
* for (const id of ids) {
* const metadata = metadataMap.get(id)
* }
* ```
*
* @since v5.12.0
*/
public async getNounMetadataBatch(ids: string[]): Promise<Map<string, NounMetadata>> {
await this.ensureInitialized()
const results = new Map<string, NounMetadata>()
if (ids.length === 0) return results
// Group IDs by cached type for efficient path construction
const idsByType = new Map<NounType, string[]>()
const uncachedIds: string[] = []
for (const id of ids) {
const cachedType = this.nounTypeCache.get(id)
if (cachedType) {
const idsForType = idsByType.get(cachedType) || []
idsForType.push(id)
idsByType.set(cachedType, idsForType)
} else {
uncachedIds.push(id)
}
}
// Build paths for known types
const pathsToFetch: Array<{ path: string; id: string }> = []
for (const [type, typeIds] of idsByType.entries()) {
for (const id of typeIds) {
pathsToFetch.push({
path: getNounMetadataPath(type, id),
id
})
}
}
// For uncached IDs, we need to search across types (expensive but unavoidable)
// Strategy: Try most common types first (Document, Thing, Person), then others
const commonTypes: NounType[] = [NounType.Document, NounType.Thing, NounType.Person, NounType.File]
const commonTypeSet = new Set(commonTypes)
const otherTypes: NounType[] = []
for (let i = 0; i < NOUN_TYPE_COUNT; i++) {
const type = TypeUtils.getNounFromIndex(i)
if (!commonTypeSet.has(type)) {
otherTypes.push(type)
}
}
const searchOrder: NounType[] = [...commonTypes, ...otherTypes]
for (const id of uncachedIds) {
for (const type of searchOrder) {
// Build path manually to avoid type issues
const shard = getShardIdFromUuid(id)
const path = `entities/nouns/${type}/metadata/${shard}/${id}.json`
pathsToFetch.push({ path, id })
}
}
// Batch read all paths
const batchResults = await this.readBatchWithInheritance(pathsToFetch.map(p => p.path))
// Process results and update cache
const foundUncached = new Set<string>()
for (let i = 0; i < pathsToFetch.length; i++) {
const { path, id } = pathsToFetch[i]
const metadata = batchResults.get(path)
if (metadata) {
results.set(id, metadata)
// Cache the type for uncached IDs (only on first find)
if (uncachedIds.includes(id) && !foundUncached.has(id)) {
// Extract type from path: "entities/nouns/metadata/{type}/{shard}/{id}.json"
const parts = path.split('/')
const typeStr = parts[3] // "document", "thing", etc.
// Find matching type by string comparison
for (let i = 0; i < NOUN_TYPE_COUNT; i++) {
const type = TypeUtils.getNounFromIndex(i)
if (type === typeStr) {
this.nounTypeCache.set(id, type)
break
}
}
foundUncached.add(id)
}
}
}
return results
}
/**
* Batch read multiple storage paths with COW inheritance support (v5.12.0)
*
* Core batching primitive that all batch operations build upon.
* Handles write cache, branch inheritance, and adapter-specific batching.
*
* **Performance**:
* - Uses adapter's native batch API when available (GCS, S3, Azure)
* - Falls back to parallel reads for non-batch adapters
* - Respects rate limits via StorageBatchConfig
*
* @param paths Array of storage paths to read
* @param branch Optional branch (defaults to current branch)
* @returns Map of path data (only successful reads included)
*
* @protected - Available to subclasses and batch operations
* @since v5.12.0
*/
protected async readBatchWithInheritance(
paths: string[],
branch?: string
): Promise<Map<string, any>> {
if (paths.length === 0) return new Map()
const targetBranch = branch || this.currentBranch || 'main'
const results = new Map<string, any>()
// Resolve all paths to branch-specific paths
const branchPaths = paths.map(path => ({
original: path,
resolved: this.resolveBranchPath(path, targetBranch)
}))
// Step 1: Check write cache first (synchronous, instant)
const pathsToFetch: string[] = []
const pathMapping = new Map<string, string>() // resolved → original
for (const { original, resolved } of branchPaths) {
const cachedData = this.writeCache.get(resolved)
if (cachedData !== undefined) {
results.set(original, cachedData)
} else {
pathsToFetch.push(resolved)
pathMapping.set(resolved, original)
}
}
if (pathsToFetch.length === 0) {
return results // All in write cache
}
// Step 2: Batch read from adapter
// Check if adapter supports native batch operations
const batchData = await this.readBatchFromAdapter(pathsToFetch)
// Step 3: Process results and handle inheritance for missing items
const missingPaths: string[] = []
for (const [resolvedPath, data] of batchData.entries()) {
const originalPath = pathMapping.get(resolvedPath)
if (originalPath && data !== null) {
results.set(originalPath, data)
}
}
// Identify paths that weren't found
for (const resolvedPath of pathsToFetch) {
if (!batchData.has(resolvedPath) || batchData.get(resolvedPath) === null) {
missingPaths.push(pathMapping.get(resolvedPath)!)
}
}
// Step 4: Handle COW inheritance for missing items (if not on main branch)
if (targetBranch !== 'main' && missingPaths.length > 0) {
// For now, fall back to individual inheritance lookups
// TODO v5.13.0: Optimize inheritance with batch commit walks
for (const originalPath of missingPaths) {
try {
const data = await this.readWithInheritance(originalPath, targetBranch)
if (data !== null) {
results.set(originalPath, data)
}
} catch (error) {
// Skip failed reads (they won't be in results map)
}
}
}
return results
}
/**
* Adapter-level batch read with automatic batching strategy (v5.12.0)
*
* Uses adapter's native batch API when available:
* - GCS: batch API (100 ops)
* - S3/R2: batch operations (1000 ops)
* - Azure: batch API (100 ops)
* - Others: parallel reads via Promise.all()
*
* Automatically chunks large batches based on adapter's maxBatchSize.
*
* @param paths Array of resolved storage paths
* @returns Map of path data
*
* @private
* @since v5.12.0
*/
private async readBatchFromAdapter(paths: string[]): Promise<Map<string, any>> {
if (paths.length === 0) return new Map()
// Check if this class implements batch operations (will be added to cloud adapters)
const selfWithBatch = this as any
if (typeof selfWithBatch.readBatch === 'function') {
// Adapter has native batch support - use it
try {
return await selfWithBatch.readBatch(paths)
} catch (error) {
// Fall back to parallel reads on batch failure
prodLog.warn(`Batch read failed, falling back to parallel: ${error}`)
}
}
// Fallback: Parallel individual reads
// Respect adapter's maxConcurrent limit
const batchConfig = this.getBatchConfig()
const chunkSize = batchConfig.maxConcurrent || 50
const results = new Map<string, any>()
for (let i = 0; i < paths.length; i += chunkSize) {
const chunk = paths.slice(i, i + chunkSize)
const chunkResults = await Promise.allSettled(
chunk.map(async path => ({
path,
data: await this.readObjectFromPath(path)
}))
)
for (const result of chunkResults) {
if (result.status === 'fulfilled' && result.value.data !== null) {
results.set(result.value.path, result.value.data)
}
}
}
return results
}
/**
* Get batch configuration for this storage adapter (v5.12.0)
*
* Override in subclasses to provide adapter-specific batch limits.
* Defaults to conservative limits for safety.
*
* @public - Inherited from BaseStorageAdapter
* @since v5.12.0
*/
public getBatchConfig(): StorageBatchConfig {
// Conservative defaults - adapters should override with their actual limits
return {
maxBatchSize: 100,
batchDelayMs: 0,
maxConcurrent: 50,
supportsParallelWrites: true,
rateLimit: {
operationsPerSecond: 1000,
burstCapacity: 5000
}
}
}
/**
* Delete noun metadata from storage
* v5.4.0: Uses type-first paths (must match saveNounMetadata_internal)
@ -2507,6 +2802,136 @@ export abstract class BaseStorage extends BaseStorageAdapter {
return results
}
/**
* Batch get verbs by source IDs (v5.12.0 - Cloud Storage Optimization)
*
* **Performance**: Eliminates N+1 query pattern for relationship lookups
* - Current: N × getVerbsBySource() = N × (list all verbs + filter)
* - Batched: 1 × list all verbs + filter by N sourceIds
*
* **Use cases:**
* - VFS tree traversal (get Contains edges for multiple directories)
* - brain.getRelations() for multiple entities
* - Graph traversal (fetch neighbors of multiple nodes)
*
* @param sourceIds Array of source entity IDs
* @param verbType Optional verb type filter (e.g., VerbType.Contains for VFS)
* @returns Map of sourceId verbs[]
*
* @example
* ```typescript
* // Before (N+1 pattern)
* for (const dirId of dirIds) {
* const children = await storage.getVerbsBySource(dirId) // N calls
* }
*
* // After (batched)
* const childrenByDir = await storage.getVerbsBySourceBatch(dirIds, VerbType.Contains) // 1 scan
* for (const dirId of dirIds) {
* const children = childrenByDir.get(dirId) || []
* }
* ```
*
* @since v5.12.0
*/
public async getVerbsBySourceBatch(
sourceIds: string[],
verbType?: VerbType
): Promise<Map<string, HNSWVerbWithMetadata[]>> {
await this.ensureInitialized()
const results = new Map<string, HNSWVerbWithMetadata[]>()
if (sourceIds.length === 0) return results
// Initialize empty arrays for all requested sourceIds
for (const sourceId of sourceIds) {
results.set(sourceId, [])
}
// Convert sourceIds to Set for O(1) lookup
const sourceIdSet = new Set(sourceIds)
// Determine which verb types to scan
const typesToScan: VerbType[] = []
if (verbType) {
typesToScan.push(verbType)
} else {
// Scan all verb types
for (let i = 0; i < VERB_TYPE_COUNT; i++) {
typesToScan.push(TypeUtils.getVerbFromIndex(i))
}
}
// Scan verb types and collect matching verbs
for (const type of typesToScan) {
const typeDir = `entities/verbs/${type}/vectors`
try {
// List all verb files of this type
const verbFiles = await this.listObjectsInBranch(typeDir)
// Build paths for batch read
const verbPaths: string[] = []
const metadataPaths: string[] = []
const pathToId = new Map<string, string>()
for (const verbPath of verbFiles) {
if (!verbPath.endsWith('.json')) continue
verbPaths.push(verbPath)
// Extract ID from path: "entities/verbs/{type}/vectors/{shard}/{id}.json"
const parts = verbPath.split('/')
const filename = parts[parts.length - 1]
const verbId = filename.replace('.json', '')
pathToId.set(verbPath, verbId)
// Prepare metadata path
metadataPaths.push(getVerbMetadataPath(type, verbId))
}
// Batch read all verb files for this type
const verbDataMap = await this.readBatchWithInheritance(verbPaths)
const metadataMap = await this.readBatchWithInheritance(metadataPaths)
// Process results
for (const [verbPath, verbData] of verbDataMap.entries()) {
if (!verbData || !verbData.sourceId) continue
// Check if this verb's source is in our requested set
if (!sourceIdSet.has(verbData.sourceId)) continue
// Found matching verb - hydrate with metadata
const verbId = pathToId.get(verbPath)!
const metadataPath = getVerbMetadataPath(type, verbId)
const metadata = metadataMap.get(metadataPath) || {}
const hydratedVerb: HNSWVerbWithMetadata = {
...verbData,
weight: metadata?.weight,
confidence: metadata?.confidence,
createdAt: metadata?.createdAt
? (typeof metadata.createdAt === 'number' ? metadata.createdAt : metadata.createdAt.seconds * 1000)
: Date.now(),
updatedAt: metadata?.updatedAt
? (typeof metadata.updatedAt === 'number' ? metadata.updatedAt : metadata.updatedAt.seconds * 1000)
: Date.now(),
service: metadata?.service,
createdBy: metadata?.createdBy,
metadata: metadata as VerbMetadata
}
// Add to results for this sourceId
const sourceVerbs = results.get(verbData.sourceId)!
sourceVerbs.push(hydratedVerb)
}
} catch (error) {
// Skip types that have no data
}
}
return results
}
/**
* Get verbs by target (COW-aware implementation)
* v5.7.1: Reverted to v5.6.3 implementation to fix circular dependency deadlock