@@ -5,6 +5,7 @@ import io.tolgee.model.batch.BatchJobChunkExecution
55import io.tolgee.util.Logging
66import io.tolgee.util.logger
77import org.redisson.api.RAtomicLong
8+ import org.redisson.api.RBatch
89import org.redisson.api.RMap
910import org.redisson.api.RedissonClient
1011import java.util.concurrent.ConcurrentHashMap
@@ -45,6 +46,8 @@ open class RedisBatchJobStateStorage(
4546 private const val REDIS_CANCELLED_COUNT_KEY_PREFIX = " batch_job_cancelled:"
4647 private const val REDIS_COMMITTED_COUNT_KEY_PREFIX = " batch_job_committed:"
4748 private const val REDIS_STARTED_KEY_PREFIX = " batch_job_started:"
49+
50+ private const val CLEANUP_BATCH_SIZE = 100
4851 }
4952
5053 // Local cache for initialization status - avoids Redis calls for already-initialized jobs
@@ -175,7 +178,7 @@ open class RedisBatchJobStateStorage(
175178 }
176179
177180 override fun tryMarkJobStarted (jobId : Long ): Boolean {
178- val bucket = redissonClient.getBucket<Boolean >(" $REDIS_STARTED_KEY_PREFIX$ jobId" )
181+ val bucket = redissonClient.getBucket<Boolean >(startedKey( jobId) )
179182 return bucket.setIfAbsent(true )
180183 }
181184
@@ -192,13 +195,12 @@ open class RedisBatchJobStateStorage(
192195
193196 override fun removeJobState (jobId : Long ) {
194197 logger.debug(" Removing job state for job $jobId " )
195- removeAllCounters(jobId)
196- val redisHash = getRedisHashForJob(jobId)
197- redisHash.delete()
198- // Also remove initialization and started markers
199- redissonClient.getBucket<Boolean >(" $REDIS_STATE_INITIALIZED_KEY_PREFIX$jobId " ).delete()
200- redissonClient.getBucket<Boolean >(" $REDIS_STARTED_KEY_PREFIX$jobId " ).delete()
201- // Clear local initialization cache to allow re-initialization if jobId is reused
198+ val batch = redissonClient.createBatch()
199+ addCounterDeletesToBatch(batch, jobId)
200+ batch.getMap<Long , ExecutionState >(stateKey(jobId)).deleteAsync()
201+ batch.getBucket<Boolean >(initializedKey(jobId)).deleteAsync()
202+ batch.getBucket<Boolean >(startedKey(jobId)).deleteAsync()
203+ batch.execute()
202204 localInitializedJobs.remove(jobId)
203205 }
204206
@@ -213,25 +215,52 @@ open class RedisBatchJobStateStorage(
213215
214216 /* *
215217 * Cleans up batch job state hashes where all executions have a completed status.
216- *
217- * This uses HVALS to read all values from each hash. Because [ExecutionState] only
218- * stores lightweight metadata (no successTargets list), each value is ~50 bytes,
219- * making this operation fast even for jobs with thousands of chunks.
220218 */
221219 override fun clearUnusedStates () {
222- val keys = redissonClient.keys.getKeysByPattern(" $REDIS_STATE_KEY_PREFIX *" )
223- keys.forEach { key ->
224- val jobId = key.removePrefix(REDIS_STATE_KEY_PREFIX ).toLongOrNull() ? : return @forEach
225- val redisHash = getRedisHashForJob(jobId)
226- val allCompleted = redisHash.readAllValues().all { state -> state.status.completed }
227- if (allCompleted) {
228- redisHash.delete()
229- redissonClient.getBucket<Boolean >(" $REDIS_STATE_INITIALIZED_KEY_PREFIX$jobId " ).delete()
230- // Clear local initialization cache to allow re-initialization if jobId is reused
231- localInitializedJobs.remove(jobId)
232- // Do NOT remove counters here - they're needed until job status is properly updated
233- // Counters will be removed in removeJobState when job is finalized
220+ getCachedJobIds().chunked(CLEANUP_BATCH_SIZE ).forEach { clearCompletedStates(it) }
221+ }
222+
223+ private fun clearCompletedStates (jobIds : List <Long >) {
224+ val completedJobIds =
225+ readStates(jobIds).mapNotNull { (jobId, values) ->
226+ val allCompleted = values != null && values.all { it.status.completed }
227+ jobId.takeIf { allCompleted }
234228 }
229+ if (completedJobIds.isEmpty()) {
230+ return
231+ }
232+
233+ // Counters stay live until removeJobState finalizes the job; deleting them here corrupts status updates.
234+ val deleteBatch = redissonClient.createBatch()
235+ completedJobIds.forEach { jobId ->
236+ deleteBatch.getMap<Long , ExecutionState >(stateKey(jobId)).deleteAsync()
237+ deleteBatch.getBucket<Boolean >(initializedKey(jobId)).deleteAsync()
238+ }
239+ deleteBatch.execute()
240+ completedJobIds.forEach { localInitializedJobs.remove(it) }
241+ }
242+
243+ private fun readStates (jobIds : List <Long >): Map <Long , Collection <ExecutionState >? > {
244+ return try {
245+ val readBatch = redissonClient.createBatch()
246+ val futures =
247+ jobIds.associateWith { jobId ->
248+ readBatch.getMap<Long , ExecutionState >(stateKey(jobId)).readAllValuesAsync()
249+ }
250+ readBatch.execute()
251+ futures.mapValues { it.value.get() }
252+ } catch (e: Exception ) {
253+ logger.warn(" Pipelined batch-job state read failed; falling back to per-job reads" , e)
254+ jobIds.associateWith { readStateOrNull(it) }
255+ }
256+ }
257+
258+ private fun readStateOrNull (jobId : Long ): Collection <ExecutionState >? {
259+ return try {
260+ getRedisHashForJob(jobId).readAllValues()
261+ } catch (e: Exception ) {
262+ logger.warn(" Failed to read batch job state for job $jobId during cleanup" , e)
263+ null
235264 }
236265 }
237266
@@ -251,9 +280,15 @@ open class RedisBatchJobStateStorage(
251280 }
252281
253282 private fun getRedisHashForJob (jobId : Long ): RMap <Long , ExecutionState > {
254- return redissonClient.getMap(" $REDIS_STATE_KEY_PREFIX$ jobId" )
283+ return redissonClient.getMap(stateKey( jobId) )
255284 }
256285
286+ private fun stateKey (jobId : Long ) = " $REDIS_STATE_KEY_PREFIX$jobId "
287+
288+ private fun initializedKey (jobId : Long ) = " $REDIS_STATE_INITIALIZED_KEY_PREFIX$jobId "
289+
290+ private fun startedKey (jobId : Long ) = " $REDIS_STARTED_KEY_PREFIX$jobId "
291+
257292 /* *
258293 * Ensures Redis hash is initialized from DB. Uses local in-memory cache first for O(1) check,
259294 * then falls back to Redis marker for cross-instance coordination.
@@ -270,7 +305,7 @@ open class RedisBatchJobStateStorage(
270305 return
271306 }
272307 // Check Redis marker for cross-instance coordination
273- val initKey = " $REDIS_STATE_INITIALIZED_KEY_PREFIX$ jobId"
308+ val initKey = initializedKey( jobId)
274309 if (redissonClient.getBucket<Boolean >(initKey).get() == true ) {
275310 localInitializedJobs.add(jobId)
276311 return
@@ -295,14 +330,17 @@ open class RedisBatchJobStateStorage(
295330 }
296331 }
297332
298- private fun removeAllCounters (jobId : Long ) {
299- redissonClient.getAtomicLong(" $REDIS_RUNNING_COUNT_KEY_PREFIX$jobId " ).delete()
300- redissonClient.getAtomicLong(" $REDIS_COMPLETED_CHUNKS_COUNT_KEY_PREFIX$jobId " ).delete()
301- redissonClient.getAtomicLong(" $REDIS_PROGRESS_COUNT_KEY_PREFIX$jobId " ).delete()
302- redissonClient.getAtomicLong(" $REDIS_SINGLE_CHUNK_PROGRESS_COUNT_KEY_PREFIX$jobId " ).delete()
303- redissonClient.getAtomicLong(" $REDIS_FAILED_COUNT_KEY_PREFIX$jobId " ).delete()
304- redissonClient.getAtomicLong(" $REDIS_CANCELLED_COUNT_KEY_PREFIX$jobId " ).delete()
305- redissonClient.getAtomicLong(" $REDIS_COMMITTED_COUNT_KEY_PREFIX$jobId " ).delete()
333+ private fun addCounterDeletesToBatch (
334+ batch : RBatch ,
335+ jobId : Long ,
336+ ) {
337+ batch.getAtomicLong(" $REDIS_RUNNING_COUNT_KEY_PREFIX$jobId " ).deleteAsync()
338+ batch.getAtomicLong(" $REDIS_COMPLETED_CHUNKS_COUNT_KEY_PREFIX$jobId " ).deleteAsync()
339+ batch.getAtomicLong(" $REDIS_PROGRESS_COUNT_KEY_PREFIX$jobId " ).deleteAsync()
340+ batch.getAtomicLong(" $REDIS_SINGLE_CHUNK_PROGRESS_COUNT_KEY_PREFIX$jobId " ).deleteAsync()
341+ batch.getAtomicLong(" $REDIS_FAILED_COUNT_KEY_PREFIX$jobId " ).deleteAsync()
342+ batch.getAtomicLong(" $REDIS_CANCELLED_COUNT_KEY_PREFIX$jobId " ).deleteAsync()
343+ batch.getAtomicLong(" $REDIS_COMMITTED_COUNT_KEY_PREFIX$jobId " ).deleteAsync()
306344 }
307345
308346 private fun initializeCountersFromState (
0 commit comments