| 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 |
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.
Current implementation has several performance issues:
checkAndTriggerNextStage loads all tasks on every completion (~1,200 queries per batch)Impact:
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.
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)
}
}
}
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() }
}
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.
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)
}
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()
}
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
}
}
)