Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3133ef9
feat: async branch snapshot creation
dkrizan Feb 24, 2026
2eac317
chore: schema updated && fix type in BranchMergeServiceTest.kt
dkrizan Feb 24, 2026
ae7df49
fix: prevent serialization conflict when updating branch in REPEATABL…
dkrizan Feb 24, 2026
24630cb
fix: add snapshotStatus to defaultBranchObject in useBranchesService
dkrizan Feb 24, 2026
e57b0af
fix: async branch cleanup - fix test races, deadlock, and SQL bug
dkrizan Feb 25, 2026
5ea2295
refactor: move afterCommit scheduling into snapshot/cleanup workers
dkrizan Feb 25, 2026
a854441
fix: ktlint format and CDN config branching test race
dkrizan Feb 26, 2026
a2624ae
chore: ktlint format
dkrizan Feb 26, 2026
60369ff
fix: track async branch snapshots in test cleanup
dkrizan Feb 26, 2026
7d1e0b7
fix: prevent redirect to main when navigating to newly created branch
dkrizan Mar 3, 2026
089cfd8
fix: address CodeRabbit review findings in branch snapshot/cleanup
dkrizan Mar 3, 2026
3d5b87a
chore: ktlint format BranchCleanupService
dkrizan Mar 3, 2026
f15402d
test: fix flaky BranchMergeServiceTest labels test
dkrizan Mar 5, 2026
fa91978
chore: ktlint format
dkrizan Mar 5, 2026
5872673
refactor: remove async branch creation, keep async deletion
dkrizan Mar 5, 2026
4e81ed2
perf: hybrid branch cleanup — bulk SQL for volume, services for logic
dkrizan Mar 5, 2026
9e27070
refactor: remove test-only code from services, use waitForNotThrowing
dkrizan Mar 5, 2026
f538e44
chore: BranchCopyIntegrationTest test changed
dkrizan Mar 5, 2026
f6160ce
fix: delete task_key and translation_suggestion rows before keys in b…
dkrizan Mar 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,21 @@ class BranchSnapshotTestData : BaseTestData("branch_snapshot_user", "branch_snap
addTag("ghi")
}.self

// matching target key on feature branch
// matching target key on feature branch (mirrors main's state, as BranchCopyService would produce)
addKey {
name = "snapshot-key"
branch = featureBranch
}.build {
addTranslation {
language = englishLanguage
text = "Snapshot text"
}
val featureTranslationEn =
addTranslation {
language = englishLanguage
text = "Snapshot text"
}.self
featureTranslationEn.addLabel(label)
addScreenshot { }.self
addTag("abc")
addTag("def")
addTag("ghi")
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.tolgee.events

class OnBranchSoftDeleted(
val projectId: Long,
val branchId: Long,
)
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import io.tolgee.model.key.screenshotReference.KeyScreenshotReference
import org.springframework.context.annotation.Lazy
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository

@Repository
Expand Down Expand Up @@ -75,4 +76,29 @@ interface ScreenshotRepository : JpaRepository<Screenshot, Long> {
""",
)
fun getAllKeyScreenshotReferences(key: Key): List<KeyScreenshotReference>

/**
* Returns screenshots that will become orphans after all key_screenshot_reference
* rows for [branchId]'s keys are deleted — i.e., screenshots referenced ONLY by branch keys
* and by no key on any other branch.
*
* Uses JPQL (not nativeQuery) because Screenshot.filename is a Kotlin computed property,
* not a DB column — so it cannot be selected in native SQL.
*/
@Query(
"""
SELECT s FROM Screenshot s
WHERE s.id IN (
SELECT ksr.screenshot.id FROM KeyScreenshotReference ksr
WHERE ksr.key.id IN (SELECT k.id FROM Key k WHERE k.branch.id = :branchId)
)
AND s.id NOT IN (
SELECT ksr.screenshot.id FROM KeyScreenshotReference ksr
WHERE ksr.key.id NOT IN (SELECT k.id FROM Key k WHERE k.branch.id = :branchId)
)
""",
)
fun findOrphansByBranchId(
@Param("branchId") branchId: Long,
): List<Screenshot>
}
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,17 @@ class ScreenshotService(
removeScreenshotReferences(all)
}

/**
* Deletes storage files for screenshots that will become orphans after all
* key_screenshot_reference rows for [branchId]'s keys are removed.
* The actual DB rows are deleted by the caller via bulk SQL.
*/
fun deleteFilesByBranch(branchId: Long) {
screenshotRepository.findOrphansByBranchId(branchId).forEach { screenshot ->
deleteFile(screenshot)
}
}

private fun deleteFile(screenshot: Screenshot) {
fileStorage.deleteFile(screenshot.getFilePath())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ class CleanDbTestListener : TestExecutionListener {
}
}

else -> throw e
else -> {
throw e
}
}
logger.info(
"Failed to clean DB, retrying in 1s. Attempt ${i + 1}, " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,115 +4,187 @@ import io.tolgee.Metrics
import io.tolgee.ee.repository.branching.BranchMergeRepository
import io.tolgee.ee.repository.branching.BranchRepository
import io.tolgee.ee.service.TaskService
import io.tolgee.repository.KeyRepository
import io.tolgee.events.OnBranchSoftDeleted
import io.tolgee.repository.LanguageStatsRepository
import io.tolgee.service.contentDelivery.ContentDeliveryConfigService
import io.tolgee.service.key.KeyService
import io.tolgee.service.key.NamespaceService
import io.tolgee.service.key.ScreenshotService
import jakarta.persistence.EntityManager
import jakarta.transaction.Transactional
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.context.annotation.Lazy
import org.springframework.data.domain.PageRequest
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Service
import org.springframework.transaction.event.TransactionPhase
import org.springframework.transaction.event.TransactionalEventListener

@Suppress("SelfReferenceConstructorParameter")
@Service
class BranchCleanupService(
private val keyRepository: KeyRepository,
private val keyService: KeyService,
private val branchRepository: BranchRepository,
private val branchMergeRepository: BranchMergeRepository,
private val taskService: TaskService,
private val branchSnapshotService: BranchSnapshotService,
private val screenshotService: ScreenshotService,
private val namespaceService: NamespaceService,
private val languageStatsRepository: LanguageStatsRepository,
private val entityManager: EntityManager,
private val metrics: Metrics,
@Lazy
private val contentDeliveryConfigService: ContentDeliveryConfigService,
@Lazy
private val self: BranchCleanupService,
) {
companion object {
private const val BATCH_SIZE = 1000
}

val logger: Logger by lazy {
LoggerFactory.getLogger(javaClass)
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async
fun onBranchSoftDeleted(event: OnBranchSoftDeleted) {
self.cleanupBranch(event.projectId, event.branchId)
}

/**
* Synchronously deletes all branch-related data and the branch entity itself.
* Removes tasks, keys, merges, snapshots, and then hard-deletes the branch.
* Deletes all branch-related data and hard-deletes the branch row.
*
* Uses bulk SQL for high-volume tables (translations, key metadata, keys)
* and delegates to services for business logic (screenshots, namespaces, tasks).
*/
@Transactional
fun cleanupBranch(
projectId: Long,
branchId: Long,
) {
logger.info("Starting cleanup for branch $branchId")

cleanupBranchContentDeliveryConfigs(projectId, branchId)
cleanupBranchTasks(projectId, branchId)
cleanupBranchKeys(projectId, branchId)
contentDeliveryConfigService.deleteAllByBranchId(projectId, branchId)
taskService.deleteTasksForBranch(projectId, branchId)
cleanupBranchMerges(branchId)
cleanupLanguageStats(branchId)
cleanupBranchSnapshots(branchId)
cleanupBranchKeys(projectId, branchId)
languageStatsRepository.deleteAllByBranchId(branchId)
branchSnapshotService.deleteSnapshots(branchId)
deleteBranch(branchId)

logger.info("Completed cleanup for branch $branchId")
}

private fun cleanupBranchContentDeliveryConfigs(
projectId: Long,
branchId: Long,
) {
contentDeliveryConfigService.deleteAllByBranchId(projectId, branchId)
metrics.branchCleanupBatchesCounter.increment()
}

/**
* Deletes all tasks associated with the branch.
*/
private fun cleanupBranchTasks(
projectId: Long,
branchId: Long,
) {
taskService.deleteTasksForBranch(projectId, branchId)
}

/**
* Deletes all keys associated with the branch in batches.
* KeyService.hardDeleteMultiple handles cascading deletion of translations, metadata, etc.
* Always queries page 0 since deletion shifts remaining keys.
* Deletes all keys and their children for the given branch.
*
* Uses bulk SQL for high-volume simple cascades (translations, key metadata, keys)
* to avoid ORM overhead and the 65K parameter limit.
* Delegates to services for screenshots (file storage + orphan detection)
* and namespaces (soft-delete aware cleanup).
*/
private fun cleanupBranchKeys(
projectId: Long,
branchId: Long,
) {
var totalDeleted = 0
var batchCount = 0

while (true) {
val idsPage =
keyRepository.findIdsByProjectAndBranch(
projectId,
branchId,
PageRequest.of(0, BATCH_SIZE),
)

if (idsPage.isEmpty) break

val ids = idsPage.content
if (ids.isEmpty()) break

keyService.hardDeleteMultiple(ids)
totalDeleted += ids.size
batchCount++
metrics.branchCleanupBatchesCounter.increment()
val keySub = "SELECT id FROM key WHERE branch_id = :branchId"

// --- Translation children + translations (bulk SQL) ---
execByBranch(
"""
DELETE FROM translation_comment
WHERE translation_id IN (SELECT id FROM translation WHERE key_id IN ($keySub))
""",
branchId,
)
execByBranch(
"""
DELETE FROM translation_label
WHERE translation_id IN (SELECT id FROM translation WHERE key_id IN ($keySub))
""",
branchId,
)
execByBranch(
"""
UPDATE import_translation SET conflict_id = NULL
WHERE conflict_id IN (SELECT id FROM translation WHERE key_id IN ($keySub))
""",
branchId,
)
execByBranch(
"DELETE FROM translation WHERE key_id IN ($keySub)",
branchId,
)

// --- Key-meta children + key_meta (bulk SQL) ---
execByBranch(
"DELETE FROM key_comment WHERE key_meta_id IN (SELECT id FROM key_meta WHERE key_id IN ($keySub))",
branchId,
)
execByBranch(
"DELETE FROM key_code_reference WHERE key_meta_id IN (SELECT id FROM key_meta WHERE key_id IN ($keySub))",
branchId,
)
execByBranch(
"DELETE FROM key_meta_tags WHERE key_metas_id IN (SELECT id FROM key_meta WHERE key_id IN ($keySub))",
branchId,
)
execByBranch(
"DELETE FROM key_meta WHERE key_id IN ($keySub)",
branchId,
)

// --- Screenshots: service deletes files from storage before we remove DB rows ---
screenshotService.deleteFilesByBranch(branchId)
// Collect orphan screenshot IDs (only referenced by this branch's keys) before deleting refs.
@Suppress("UNCHECKED_CAST")
val orphanScreenshotIds =
entityManager
.createNativeQuery(
"""
SELECT DISTINCT ksr.screenshot_id FROM key_screenshot_reference ksr
JOIN key k ON k.id = ksr.key_id
WHERE k.branch_id = :branchId
AND NOT EXISTS (
SELECT 1 FROM key_screenshot_reference other
JOIN key ok ON ok.id = other.key_id
WHERE other.screenshot_id = ksr.screenshot_id AND ok.branch_id != :branchId
)
""".trimIndent(),
).setParameter("branchId", branchId)
.resultList as List<Number>

execByBranch("DELETE FROM key_screenshot_reference WHERE key_id IN ($keySub)", branchId)
if (orphanScreenshotIds.isNotEmpty()) {
entityManager
.createNativeQuery("DELETE FROM screenshot WHERE id IN (:ids)")
.setParameter("ids", orphanScreenshotIds.map { it.toLong() })
.executeUpdate()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (totalDeleted > 0) {
logger.debug("Deleted $totalDeleted keys in $batchCount batches for branch $branchId")
// --- Other key children (bulk SQL) ---
execByBranch("DELETE FROM task_key WHERE key_id IN ($keySub)", branchId)
execByBranch("DELETE FROM translation_suggestion WHERE key_id IN ($keySub)", branchId)
execByBranch("DELETE FROM ai_playground_result WHERE key_id IN ($keySub)", branchId)

// --- Collect namespaces before deleting keys ---
@Suppress("UNCHECKED_CAST")
val namespaceIds =
entityManager
.createNativeQuery(
"SELECT DISTINCT namespace_id FROM key WHERE branch_id = :branchId AND namespace_id IS NOT NULL",
).setParameter("branchId", branchId)
.resultList as List<Number>

// --- Keys (bulk SQL) ---
execByBranch("DELETE FROM key WHERE branch_id = :branchId", branchId)

// --- Namespaces: service handles soft-delete aware cleanup ---
if (namespaceIds.isNotEmpty()) {
val namespaces =
namespaceIds.mapNotNull { id ->
entityManager.find(io.tolgee.model.key.Namespace::class.java, id.toLong())
}
namespaceService.deleteUnusedNamespaces(namespaces)
}
}

/**
* Deletes all merge records where this branch is either source or target.
* This includes merge changes and conflict resolutions.
*/
private fun cleanupBranchMerges(branchId: Long) {
val merges =
branchMergeRepository
Expand All @@ -124,25 +196,15 @@ class BranchCleanupService(
}
}

/**
* Deletes all language stats associated with the branch.
*/
private fun cleanupLanguageStats(branchId: Long) {
languageStatsRepository.deleteAllByBranchId(branchId)
}

/**
* Deletes all snapshots created for the branch.
*/
private fun cleanupBranchSnapshots(branchId: Long) {
branchSnapshotService.deleteSnapshots(branchId)
}

/**
* Hard-deletes the branch entity.
*/
private fun deleteBranch(branchId: Long) {
val branch = branchRepository.findById(branchId).orElse(null) ?: return
branchRepository.delete(branch)
}

private fun execByBranch(
sql: String,
branchId: Long,
) {
entityManager.createNativeQuery(sql.trimIndent()).setParameter("branchId", branchId).executeUpdate()
}
}
Loading
Loading