Sangeetha-Grantha

TRACK-013: Bulk Import Performance & Scalability Improvements

Metadata Value
Status Completed
Owner Backend Team
Priority MEDIUM
Created 2026-01-23
Completed 2026-01-23
Related Tracks TRACK-001 (Bulk Import), TRACK-008 (Entity Resolution)
Implementation Plan bulk-import-fixes-implementation-plan.md

1. Goal

Optimize performance bottlenecks and improve scalability to handle batches of 5,000+ krithis efficiently. Address O(N) queries, memory leaks, and inefficient algorithms identified in code reviews.

2. Problem Statement

Current implementation has several performance issues:

  1. Stage Completion Checks O(N) per Task: checkAndTriggerNextStage loads all tasks on every completion (~1,200 queries per batch)
  2. Entity Resolution Cache: In-memory only, no database persistence, cache invalidation missing
  3. Deduplication Service: O(N^2) performance, missing intra-batch deduplication
  4. Rate Limiting: Too conservative (12/min vs strategy 120/min), 5-10x slower
  5. CSV Validation: Deferred to manifest ingest (should fast-fail at upload)
  6. Normalization Bugs: Honorific regex broken, lookup map collisions
  7. Memory Leak: Rate limiter domain map grows unbounded

Impact:

3. Implementation Plan

3.1 Optimize Stage Completion Checks

Issue: checkAndTriggerNextStage called on every task completion, loads all tasks (O(N) query).

File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/services/BulkImportWorkerService.kt

Solution: Use batch counters instead of loading all tasks.

Changes:

private suspend fun processScrapeTask(task: ImportTaskRunDto, config: WorkerConfig) {
    // ... existing scrape logic ...
    
    // ✅ NEW: Only check completion when counters indicate possible completion
    val batch = dal.bulkImport.findBatchById(batchId)
    if (batch != null && batch.processedTasks >= batch.totalTasks) {
        checkAndTriggerNextStage(job.id)
    }
    // ❌ REMOVE: checkAndTriggerNextStage(job.id) from every completion
}

// Update checkAndTriggerNextStage to use counters
private suspend fun checkAndTriggerNextStage(jobId: Uuid) {
    val job = dal.bulkImport.findJobById(jobId) ?: return
    val batch = dal.bulkImport.findBatchById(job.batchId) ?: return
    
    // ✅ NEW: Use counters instead of loading all tasks
    if (batch.processedTasks < batch.totalTasks) {
        return  // Not complete yet
    }
    
    // Only verify completion if counters suggest it
    val tasks = dal.bulkImport.listTasksByJob(jobId)
    val isComplete = tasks.all {
        val s = TaskStatus.valueOf(it.status.name)
        s == TaskStatus.SUCCEEDED || s == TaskStatus.FAILED ||
        s == TaskStatus.BLOCKED || s == TaskStatus.CANCELLED
    }
    
    if (isComplete) {
        // ... create next stage job ...
    }
}

Impact: Reduces ~1,200 queries per batch to ~2 queries.


3.2 Implement Database-Backed Entity Resolution Cache

Issue: In-memory cache only, no persistence, cache invalidation missing, multi-node divergence.

File: database/migrations/17__entity_resolution_cache.sql

Migration:

CREATE TABLE IF NOT EXISTS entity_resolution_cache (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    entity_type VARCHAR(50) NOT NULL,
    raw_name TEXT NOT NULL,
    normalized_name TEXT NOT NULL,
    resolved_entity_id UUID NOT NULL,
    confidence INTEGER NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT timezone('UTC', now()),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT timezone('UTC', now()),
    UNIQUE(entity_type, normalized_name)
);

CREATE INDEX idx_entity_cache_lookup
    ON entity_resolution_cache(entity_type, normalized_name);

CREATE INDEX idx_entity_cache_entity_id
    ON entity_resolution_cache(resolved_entity_id);

File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/services/EntityResolutionService.kt

Changes:

class EntityResolutionService(
    private val dal: SangitaDal
) {
    private val inMemoryCache = mutableMapOf<String, List<EntityCandidate>>()
    private var cacheExpiry: OffsetDateTime? = null
    private val cacheMutex = Mutex()
    private val CACHE_TTL_MINUTES = 15L
    
    suspend fun resolve(importedKrithi: ImportedKrithiDto): ResolutionResult {
        // ✅ NEW: Check database cache first
        val composerCandidates = resolveWithCache(
            entityType = "composer",
            rawName = importedKrithi.composerName ?: "",
            fallback = { dal.composers.listAll() }
        )
        
        val ragaCandidates = resolveWithCache(
            entityType = "raga",
            rawName = importedKrithi.ragaName ?: "",
            fallback = { dal.ragas.listAll() }
        )
        
        val talaCandidates = resolveWithCache(
            entityType = "tala",
            rawName = importedKrithi.talaName ?: "",
            fallback = { dal.talas.listAll() }
        )
        
        // ... rest of existing logic ...
    }
    
    private suspend fun resolveWithCache(
        entityType: String,
        rawName: String,
        fallback: suspend () -> List<EntityDto>
    ): List<EntityCandidate> {
        val normalized = nameNormalizationService.normalize(rawName)
        
        // ✅ NEW: Check database cache
        val cached = dal.entityResolutionCache.findByNormalizedName(entityType, normalized)
        if (cached != null) {
            return listOf(EntityCandidate(
                entityId = cached.resolvedEntityId,
                name = cached.rawName,
                confidence = cached.confidence / 100.0
            ))
        }
        
        // Fallback to in-memory cache or full resolution
        val allEntities = ensureCache(fallback)
        val candidates = performFuzzyMatching(normalized, allEntities)
        
        // ✅ NEW: Store in database cache
        if (candidates.isNotEmpty()) {
            val topCandidate = candidates.first()
            dal.entityResolutionCache.save(
                entityType = entityType,
                rawName = rawName,
                normalizedName = normalized,
                resolvedEntityId = topCandidate.entityId,
                confidence = (topCandidate.confidence * 100).toInt()
            )
        }
        
        return candidates
    }
    
    // ✅ NEW: Invalidate cache when entities are created/updated
    suspend fun invalidateCache(entityType: String, entityId: Uuid) {
        dal.entityResolutionCache.deleteByEntityId(entityType, entityId)
        // Also clear in-memory cache for this entity type
        cacheMutex.withLock {
            inMemoryCache.remove(entityType)
            cacheExpiry = null
        }
    }
}

Repository:

// EntityResolutionCacheRepository.kt (new)
suspend fun findByNormalizedName(
    entityType: String,
    normalizedName: String
): EntityResolutionCacheDto? = DatabaseFactory.dbQuery {
    EntityResolutionCacheTable
        .selectAll()
        .andWhere { 
            (EntityResolutionCacheTable.entityType eq entityType) and
            (EntityResolutionCacheTable.normalizedName eq normalizedName)
        }
        .singleOrNull()
        ?.toEntityResolutionCacheDto()
}

suspend fun save(
    entityType: String,
    rawName: String,
    normalizedName: String,
    resolvedEntityId: Uuid,
    confidence: Int
) = DatabaseFactory.dbQuery {
    EntityResolutionCacheTable.insert {
        it[this.entityType] = entityType
        it[this.rawName] = rawName
        it[this.normalizedName] = normalizedName
        it[this.resolvedEntityId] = resolvedEntityId.toJavaUuid()
        it[this.confidence] = confidence
        it[updatedAt] = OffsetDateTime.now(ZoneOffset.UTC)
    }.onConflict(EntityResolutionCacheTable.entityType, EntityResolutionCacheTable.normalizedName) {
        update {
            it[this.resolvedEntityId] = resolvedEntityId.toJavaUuid()
            it[this.confidence] = confidence
            it[updatedAt] = OffsetDateTime.now(ZoneOffset.UTC)
        }
    }
}

3.3 Optimize Deduplication Service

Issue: O(N^2) performance, loads all pending imports into memory.

File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/services/DeduplicationService.kt

Changes:

suspend fun findDuplicates(
    imported: ImportedKrithiDto,
    batchContext: List<ImportedKrithiDto> = emptyList()  // ✅ NEW: Intra-batch context
): List<DuplicateMatch> {
    val normalizedTitle = nameNormalizationService.normalize(imported.title)
    val duplicates = mutableListOf<DuplicateMatch>()
    
    // 1. ✅ OPTIMIZED: Check canonical krithis (DB query with normalized title)
    val canonicalMatches = dal.krithis.findByNormalizedTitle(normalizedTitle)
    duplicates.addAll(canonicalMatches.map { krithi ->
        DuplicateMatch(
            id = krithi.id,
            title = krithi.title,
            composer = krithi.composerName,
            confidence = 1.0,  // Exact match
            source = "canonical"
        )
    })
    
    // 2. ✅ OPTIMIZED: Check staging imports (DB query, not load all)
    val stagingMatches = dal.imports.findByNormalizedTitle(
        normalizedTitle = normalizedTitle,
        excludeId = imported.id,
        batchId = imported.batchId  // ✅ NEW: Include batch context
    )
    duplicates.addAll(stagingMatches.map { imp ->
        DuplicateMatch(
            id = imp.id,
            title = imp.title,
            composer = imp.composerName,
            confidence = calculateConfidence(normalizedTitle, imp.title),
            source = "staging"
        )
    })
    
    // 3. ✅ NEW: Intra-batch deduplication
    batchContext.forEach { other ->
        if (other.id != imported.id) {
            val confidence = calculateConfidence(normalizedTitle, other.title)
            if (confidence >= 0.70) {  // Medium confidence threshold
                duplicates.add(DuplicateMatch(
                    id = other.id,
                    title = other.title,
                    composer = other.composerName,
                    confidence = confidence,
                    source = "batch"
                ))
            }
        }
    }
    
    return duplicates.distinctBy { it.id }.sortedByDescending { it.confidence }
}

Repository Optimization:

// ImportRepository.kt
suspend fun findByNormalizedTitle(
    normalizedTitle: String,
    excludeId: Uuid? = null,
    batchId: Uuid? = null
): List<ImportedKrithiDto> = DatabaseFactory.dbQuery {
    var query = ImportedKrithisTable
        .selectAll()
        .andWhere { ImportedKrithisTable.status eq ImportStatus.PENDING }
        .andWhere { 
            // ✅ NEW: Use trigram index for fuzzy matching (if available)
            // For now, use LIKE with normalized title
            ImportedKrithisTable.titleNormalized like "%${normalizedTitle}%"
        }
    
    excludeId?.let { query = query.andWhere { ImportedKrithisTable.id neq it.toJavaUuid() } }
    batchId?.let { query = query.andWhere { ImportedKrithisTable.batchId eq it.toJavaUuid() } }
    
    query.map { it.toImportedKrithiDto() }
}

3.4 Tune Rate Limiting (After Real-World Testing)

Issue: Defaults (12/min per domain, 50/min global) are 5-10x slower than strategy (120/min).

File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/services/BulkImportWorkerService.kt

Changes:

data class WorkerConfig(
    // ... existing fields ...
    val perDomainRateLimitPerMinute: Int = 60,  // ✅ INCREASED: Was 12, now 60 (1 req/sec)
    val globalRateLimitPerMinute: Int = 120,    // ✅ INCREASED: Was 50, now 120 (2 req/sec)
)

Recommendation: Test with 10 URLs first, monitor for 429/503 responses, then increase gradually.


3.5 Add CSV Validation at Upload

Issue: Validation deferred to manifest ingest, invalid CSVs fail minutes later.

File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/routes/BulkImportRoutes.kt

Changes:

post {
    val multipart = call.receiveMultipart()
    var savedFilePath: String? = null
    
    multipart.forEachPart { part ->
        if (part is PartData.FileItem) {
            // ... existing file handling ...
            
            // ✅ NEW: Validate CSV immediately
            val validationResult = validateCsvFile(file)
            if (!validationResult.isValid) {
                file.delete()  // Clean up
                call.respond(
                    HttpStatusCode.BadRequest,
                    mapOf("error" to "Invalid CSV", "details" to validationResult.errors)
                )
                return@post
            }
            
            savedFilePath = file.absolutePath
        }
    }
    
    // ... rest of existing logic ...
}

private fun validateCsvFile(file: File): ValidationResult {
    val errors = mutableListOf<String>()
    
    try {
        file.bufferedReader(Charsets.UTF_8).use { reader ->
            val parser = CSVFormat.DEFAULT.builder()
                .setHeader()
                .setSkipHeaderRecord(true)
                .build()
                .parse(reader)
            
            val headerMap = parser.headerMap
            if (headerMap == null) {
                errors.add("CSV has no header row")
                return ValidationResult(false, errors)
            }
            
            val required = listOf("krithi", "hyperlink")
            val missing = required.filter { !headerMap.keys.any { h -> h.equals(it, ignoreCase = true) } }
            if (missing.isNotEmpty()) {
                errors.add("Missing required columns: ${missing.joinToString()}")
            }
            
            // Validate first few rows
            var rowCount = 0
            parser.forEach { record ->
                rowCount++
                if (rowCount > 10) return@forEach  // Only check first 10 rows
                
                val hyperlink = record.get("hyperlink") ?: ""
                if (hyperlink.isNotBlank() && !isValidUrl(hyperlink)) {
                    errors.add("Invalid URL in row ${rowCount + 1}: $hyperlink")
                }
            }
        }
    } catch (e: Exception) {
        errors.add("CSV parsing error: ${e.message}")
    }
    
    return ValidationResult(errors.isEmpty(), errors)
}

3.6 Fix Normalization Bugs

Issue 1: Honorific removal regex broken ("\b" is backspace, not word boundary).

File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/services/NameNormalizationService.kt

Fix:

// ❌ OLD: "\b" is backspace in Kotlin strings
normalized = normalized.replace(Regex("\\b(Sri|Smt|Dr|Prof)\\b"), "")

// ✅ NEW: Use word boundary correctly
normalized = normalized.replace(Regex("\\b(Sri|Smt|Dr|Prof)\\b", RegexOption.IGNORE_CASE), "")

Issue 2: Normalized lookup maps drop collisions.

File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/services/EntityResolutionService.kt

Fix:

// ❌ OLD: associateBy drops collisions
val normalizedMap = entities.associateBy { normalize(it.name) }

// ✅ NEW: Group by normalized name, handle collisions
val normalizedGroups = entities.groupBy { normalize(it.name) }
val normalizedMap = normalizedGroups.mapValues { (_, group) ->
    // If multiple entities normalize to same key, use the first one
    // Log warning for collisions
    if (group.size > 1) {
        logger.warn("Normalization collision: ${group.map { it.name }}")
    }
    group.first()
}

3.7 Fix Rate Limiter Memory Leak

Issue: perDomainWindows map grows unbounded.

File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/services/BulkImportWorkerService.kt

Fix:

// ❌ OLD: Unbounded map
private val perDomainWindows = mutableMapOf<String, RateWindow>()

// ✅ NEW: LRU cache with TTL
private val perDomainWindows = Collections.synchronizedMap(
    object : LinkedHashMap<String, RateWindow>(100, 0.75f, true) {
        override fun removeEldestEntry(eldest: Map.Entry<String, RateWindow>): Boolean {
            val now = System.currentTimeMillis()
            val age = now - eldest.value.windowStartedAtMs
            return size > 100 || age > 3600_000  // Max 100 entries or 1 hour TTL
        }
    }
)

4. Progress Log

2026-01-23: Track Created & Completed


5. Success Criteria


6. References