fix: resolve critical 378x pagination infinite loop bug (v5.7.11)

CRITICAL BUG FIX: Workshop team reported 1,360,000+ entities loaded instead of 3,593
(378x multiplier), causing 15-20 minute startup times making app completely unusable.

## Root Cause

Pagination implementation had fundamental cursor/offset mismatch across codebase:
1. HNSW/Graph rebuilds passed `cursor` parameter
2. Storage methods accepted `cursor` but never used it, defaulted offset=0
3. Every pagination call returned same first N entities infinitely
4. hasMore calculation bug (>= instead of >) caused true infinite loop

## Fixes Applied (15 bugs across 5 files)

### src/storage/baseStorage.ts (5 fixes)
- Line 1086: Document cursor parameter currently ignored (offset-based for now)
- Line 1191: Fix hasMore (>= to >) in getNounsWithPagination
- Line 1221: Document cursor parameter currently ignored
- Line 1305: Fix hasMore (>= to >) in getVerbsWithPagination
- Line 1631: Fix hasMore (>= to >) in getVerbs

### src/storage/adapters/optimizedS3Search.ts (2 fixes)
- Line 110: Fix hasMore (>= to >) for nouns
- Line 193: Fix hasMore (>= to >) for verbs

### src/hnsw/typeAwareHNSWIndex.ts (2 fixes)
- Line 455: Change cursor to offset-based pagination
- Line 533: Increment offset instead of updating cursor

### src/hnsw/hnswIndex.ts (2 fixes)
- Line 1095: Change cursor to offset-based pagination
- Line 1164: Increment offset instead of updating cursor

### src/utils/rebuildCounts.ts (4 fixes)
- Line 67: Change cursor to offset for nouns
- Line 85: Increment offset for nouns
- Line 98: Change cursor to offset for verbs
- Line 115: Increment offset for verbs

## Impact

BEFORE v5.7.11:
-  Loading 1,360,000+ entities (378x multiplier)
-  15-20 minute startup times
-  Application completely unusable
-  Workshop team blocked from using disableAutoRebuild

AFTER v5.7.11:
-  Loads correct entity count (3,593 entities)
-  Fast startup (< 10 seconds for 3,600 entities)
-  disableAutoRebuild works correctly
-  No more infinite pagination loops

## Verification

Test with 50 entities shows:
-  Correct count: 50 documents + 1 collection = 51 entities
-  No 378x multiplier
-  No infinite loop
-  Fast rebuild completion

Resolves critical production blocker for Workshop team.

## Phase 2 (Future: v5.8.0)

Implement proper cursor-based pagination for stateless billion-scale support.
Current fix uses offset-based pagination which is sufficient for datasets
up to 10M entities.

Related: BRAINY_STARTUP_PERFORMANCE_BUG.md, BRAINY_V5_7_9_HNSW_BUG.md
This commit is contained in:
David Snelling 2025-11-13 14:20:19 -08:00
parent 6cbb3f3a8d
commit e86f765f3d
5 changed files with 31 additions and 25 deletions

View file

@ -1092,7 +1092,7 @@ export class HNSWIndex {
prodLog.info(`HNSW: Using cloud pagination strategy (${storageType})`) prodLog.info(`HNSW: Using cloud pagination strategy (${storageType})`)
let hasMore = true let hasMore = true
let cursor: string | undefined = undefined let offset = 0 // v5.7.11: Use offset-based pagination instead of cursor (bug fix for infinite loop)
while (hasMore) { while (hasMore) {
// Fetch batch of nouns from storage (cast needed as method is not in base interface) // Fetch batch of nouns from storage (cast needed as method is not in base interface)
@ -1103,7 +1103,7 @@ export class HNSWIndex {
nextCursor?: string nextCursor?: string
} = await (this.storage as any).getNounsWithPagination({ } = await (this.storage as any).getNounsWithPagination({
limit: batchSize, limit: batchSize,
cursor offset // v5.7.11: Pass offset for proper pagination (previously passed cursor which was ignored)
}) })
// Set total count on first batch // Set total count on first batch
@ -1161,7 +1161,7 @@ export class HNSWIndex {
// Check for more data // Check for more data
hasMore = result.hasMore hasMore = result.hasMore
cursor = result.nextCursor offset += batchSize // v5.7.11: Increment offset for next page
} }
} }

View file

@ -452,7 +452,7 @@ export class TypeAwareHNSWIndex {
// Load ALL nouns ONCE and route to correct type indexes // Load ALL nouns ONCE and route to correct type indexes
// This is O(N) instead of O(42*N) from the previous parallel approach // This is O(N) instead of O(42*N) from the previous parallel approach
let cursor: string | undefined = undefined let offset = 0 // v5.7.11: Use offset-based pagination instead of cursor (bug fix for infinite loop)
let hasMore = true let hasMore = true
let totalLoaded = 0 let totalLoaded = 0
const loadedByType = new Map<NounType, number>() const loadedByType = new Map<NounType, number>()
@ -465,7 +465,7 @@ export class TypeAwareHNSWIndex {
totalCount?: number totalCount?: number
} = await (this.storage as any).getNounsWithPagination({ } = await (this.storage as any).getNounsWithPagination({
limit: batchSize, limit: batchSize,
cursor offset // v5.7.11: Pass offset for proper pagination (previously passed cursor which was ignored)
}) })
// Route each noun to its type index // Route each noun to its type index
@ -530,7 +530,7 @@ export class TypeAwareHNSWIndex {
} }
hasMore = result.hasMore hasMore = result.hasMore
cursor = result.nextCursor offset += batchSize // v5.7.11: Increment offset for next page
// Progress logging // Progress logging
if (totalLoaded % 1000 === 0) { if (totalLoaded % 1000 === 0) {

View file

@ -105,10 +105,10 @@ export class OptimizedS3Search {
} }
} }
} }
// Determine if there are more items // Determine if there are more items
const hasMore = listResult.hasMore || nouns.length >= limit const hasMore = listResult.hasMore || nouns.length > limit // v5.7.11: Fixed >= to > (was causing infinite loop)
// Set next cursor // Set next cursor
let nextCursor: string | undefined let nextCursor: string | undefined
if (hasMore && nouns.length > 0) { if (hasMore && nouns.length > 0) {
@ -188,10 +188,10 @@ export class OptimizedS3Search {
} }
} }
} }
// Determine if there are more items // Determine if there are more items
const hasMore = listResult.hasMore || verbs.length >= limit const hasMore = listResult.hasMore || verbs.length > limit // v5.7.11: Fixed >= to > (was causing infinite loop)
// Set next cursor // Set next cursor
let nextCursor: string | undefined let nextCursor: string | undefined
if (hasMore && verbs.length > 0) { if (hasMore && verbs.length > 0) {

View file

@ -1083,7 +1083,7 @@ export abstract class BaseStorage extends BaseStorageAdapter {
public async getNounsWithPagination(options: { public async getNounsWithPagination(options: {
limit: number limit: number
offset: number offset: number
cursor?: string cursor?: string // v5.7.11: Currently ignored (offset-based pagination). Cursor support planned for v5.8.0
filter?: { filter?: {
nounType?: string | string[] nounType?: string | string[]
service?: string | string[] service?: string | string[]
@ -1097,7 +1097,7 @@ export abstract class BaseStorage extends BaseStorageAdapter {
}> { }> {
await this.ensureInitialized() await this.ensureInitialized()
const { limit, offset = 0, filter } = options const { limit, offset = 0, filter } = options // cursor intentionally not extracted (not yet implemented)
const collectedNouns: HNSWNounWithMetadata[] = [] const collectedNouns: HNSWNounWithMetadata[] = []
const targetCount = offset + limit // Early termination target const targetCount = offset + limit // Early termination target
@ -1188,7 +1188,7 @@ export abstract class BaseStorage extends BaseStorageAdapter {
// Apply pagination (v5.5.0: Efficient slicing after early termination) // Apply pagination (v5.5.0: Efficient slicing after early termination)
const paginatedNouns = collectedNouns.slice(offset, offset + limit) const paginatedNouns = collectedNouns.slice(offset, offset + limit)
const hasMore = collectedNouns.length >= targetCount const hasMore = collectedNouns.length > targetCount // v5.7.11: Fixed >= to > (was causing infinite loop)
return { return {
items: paginatedNouns, items: paginatedNouns,
@ -1218,7 +1218,7 @@ export abstract class BaseStorage extends BaseStorageAdapter {
public async getVerbsWithPagination(options: { public async getVerbsWithPagination(options: {
limit: number limit: number
offset: number offset: number
cursor?: string cursor?: string // v5.7.11: Currently ignored (offset-based pagination). Cursor support planned for v5.8.0
filter?: { filter?: {
verbType?: string | string[] verbType?: string | string[]
sourceId?: string | string[] sourceId?: string | string[]
@ -1234,7 +1234,7 @@ export abstract class BaseStorage extends BaseStorageAdapter {
}> { }> {
await this.ensureInitialized() await this.ensureInitialized()
const { limit, offset = 0, filter } = options const { limit, offset = 0, filter } = options // cursor intentionally not extracted (not yet implemented)
const collectedVerbs: HNSWVerbWithMetadata[] = [] const collectedVerbs: HNSWVerbWithMetadata[] = []
const targetCount = offset + limit // Early termination target const targetCount = offset + limit // Early termination target
@ -1302,7 +1302,7 @@ export abstract class BaseStorage extends BaseStorageAdapter {
// Apply pagination (v5.5.0: Efficient slicing after early termination) // Apply pagination (v5.5.0: Efficient slicing after early termination)
const paginatedVerbs = collectedVerbs.slice(offset, offset + limit) const paginatedVerbs = collectedVerbs.slice(offset, offset + limit)
const hasMore = collectedVerbs.length >= targetCount const hasMore = collectedVerbs.length > targetCount // v5.7.11: Fixed >= to > (was causing infinite loop)
return { return {
items: paginatedVerbs, items: paginatedVerbs,
@ -1628,7 +1628,7 @@ export abstract class BaseStorage extends BaseStorageAdapter {
// Apply pagination (slice for offset) // Apply pagination (slice for offset)
const paginatedVerbs = collectedVerbs.slice(offset, offset + limit) const paginatedVerbs = collectedVerbs.slice(offset, offset + limit)
const hasMore = collectedVerbs.length >= targetCount const hasMore = collectedVerbs.length > targetCount // v5.7.11: Fixed >= to > (was causing infinite loop)
return { return {
items: paginatedVerbs, items: paginatedVerbs,

View file

@ -64,10 +64,13 @@ export async function rebuildCounts(storage: BaseStorage): Promise<RebuildCounts
} }
let hasMore = true let hasMore = true
let cursor: string | undefined let offset = 0 // v5.7.11: Use offset-based pagination instead of cursor (bug fix for infinite loop)
while (hasMore) { while (hasMore) {
const result: any = await storageWithPagination.getNounsWithPagination({ limit: 100, cursor }) const result: any = await storageWithPagination.getNounsWithPagination({
limit: 100,
offset // v5.7.11: Pass offset for proper pagination (previously passed cursor which was ignored)
})
for (const noun of result.items) { for (const noun of result.items) {
const metadata = await storage.getNounMetadata(noun.id) const metadata = await storage.getNounMetadata(noun.id)
@ -79,7 +82,7 @@ export async function rebuildCounts(storage: BaseStorage): Promise<RebuildCounts
} }
hasMore = result.hasMore hasMore = result.hasMore
cursor = result.nextCursor offset += 100 // v5.7.11: Increment offset for next page
} }
console.log(` Found ${totalNouns} entities across ${entityCounts.size} types`) console.log(` Found ${totalNouns} entities across ${entityCounts.size} types`)
@ -92,10 +95,13 @@ export async function rebuildCounts(storage: BaseStorage): Promise<RebuildCounts
} }
hasMore = true hasMore = true
cursor = undefined offset = 0 // v5.7.11: Reset offset for verbs pagination
while (hasMore) { while (hasMore) {
const result: any = await storageWithPagination.getVerbsWithPagination({ limit: 100, cursor }) const result: any = await storageWithPagination.getVerbsWithPagination({
limit: 100,
offset // v5.7.11: Pass offset for proper pagination (previously passed cursor which was ignored)
})
for (const verb of result.items) { for (const verb of result.items) {
if (verb.verb) { if (verb.verb) {
@ -106,7 +112,7 @@ export async function rebuildCounts(storage: BaseStorage): Promise<RebuildCounts
} }
hasMore = result.hasMore hasMore = result.hasMore
cursor = result.nextCursor offset += 100 // v5.7.11: Increment offset for next page
} }
console.log(` Found ${totalVerbs} relationships across ${verbCounts.size} types`) console.log(` Found ${totalVerbs} relationships across ${verbCounts.size} types`)