Skip to content

Commit 9f80fa0

Browse files
committed
feat(scheduler): add ShedLock to prevent concurrent job execution
Without distributed locking, scheduled jobs run simultaneously on all instances when the application is horizontally scaled, causing duplicate deletes and potential race conditions. - Add ShedLock dependencies (shedlock-spring + shedlock-provider-jdbc-template) - Add V007 Flyway migration to create the shedlock table - Add ShedLockConfig with JdbcTemplateLockProvider using DB clock (usingDbTime) to avoid clock skew issues between instances - Annotate RefreshTokenCleanupJob with @SchedulerLock (lockAtLeastFor=30m, lockAtMostFor=1h)
1 parent c759fcb commit 9f80fa0

File tree

8 files changed

+136
-73
lines changed

8 files changed

+136
-73
lines changed

build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ ext {
6161
jjwtVersion = "0.13.0"
6262
testcontainersVersion = "2.0.3"
6363
awsS3Version = "2.42.2"
64+
shedlockVersion = "6.9.1"
6465
}
6566

6667
dependencies {
@@ -123,6 +124,9 @@ dependencies {
123124
// AWS SDK (para integração com S3)
124125
implementation "software.amazon.awssdk:s3:${awsS3Version}"
125126

127+
// ShedLock — distributed lock para scheduled jobs
128+
implementation "net.javacrumbs.shedlock:shedlock-spring:${shedlockVersion}"
129+
implementation "net.javacrumbs.shedlock:shedlock-provider-jdbc-template:${shedlockVersion}"
126130
}
127131

128132
// ─────────────────────────────────────────────

src/integrationTest/java/com/example/library/refresh_token/RefreshTokenCleanupJobIT.java

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@
2020

2121
@SpringBootTest
2222
@Testcontainers
23-
@ActiveProfiles("it")
2423
@Transactional
24+
@ActiveProfiles("it")
2525
@DisplayName("RefreshTokenCleanupJob - Integration Tests")
2626
class RefreshTokenCleanupJobIT {
2727

2828
@Autowired
29-
private RefreshTokenCleanupJob cleanupJob;
29+
private RefreshTokenCleanupService cleanupJob;
3030

3131
@Autowired
3232
private RefreshTokenRepository refreshTokenRepository;
@@ -45,7 +45,7 @@ void setUp() {
4545
testUser.setRoles(Set.of("ROLE_USER"));
4646
testUser = userRepository.save(testUser);
4747
}
48-
48+
4949
@Test
5050
@DisplayName("Deve deletar apenas tokens expirados, mantendo os válidos")
5151
void shouldDeleteOnlyExpiredTokensKeepingValidOnes() {
@@ -64,7 +64,7 @@ void shouldDeleteOnlyExpiredTokensKeepingValidOnes() {
6464
refreshTokenRepository.save(valid2);
6565

6666
// Act
67-
cleanupJob.cleanupExpiredTokens();
67+
cleanupJob.deleteExpiredTokens();
6868

6969
// Assert
7070
assertThat(refreshTokenRepository.count()).isEqualTo(2); // Apenas os 2 válidos
@@ -86,7 +86,7 @@ void shouldNotDeleteWhenAllTokensAreValid() {
8686
refreshTokenRepository.save(valid2);
8787

8888
// Act
89-
cleanupJob.cleanupExpiredTokens();
89+
cleanupJob.deleteExpiredTokens();
9090

9191
// Assert
9292
assertThat(refreshTokenRepository.count()).isEqualTo(2);
@@ -97,15 +97,10 @@ void shouldNotDeleteWhenAllTokensAreValid() {
9797
void shouldDeleteAllWhenAllTokensAreExpired() {
9898
// Arrange
9999
RefreshToken expired1 = createToken("expired-1", Instant.now().minus(Duration.ofDays(1)));
100-
RefreshToken expired2 = createToken("expired-2", Instant.now().minus(Duration.ofDays(2)));
101-
RefreshToken expired3 = createToken("expired-3", Instant.now().minus(Duration.ofDays(3)));
102-
103100
refreshTokenRepository.save(expired1);
104-
refreshTokenRepository.save(expired2);
105-
refreshTokenRepository.save(expired3);
106101

107102
// Act
108-
cleanupJob.cleanupExpiredTokens();
103+
cleanupJob.deleteExpiredTokens();
109104

110105
// Assert
111106
assertThat(refreshTokenRepository.count()).isZero();
@@ -115,7 +110,7 @@ void shouldDeleteAllWhenAllTokensAreExpired() {
115110
@DisplayName("Não deve fazer nada quando não há tokens no banco")
116111
void shouldDoNothingWhenNoTokensExist() {
117112
// Act
118-
cleanupJob.cleanupExpiredTokens();
113+
cleanupJob.deleteExpiredTokens();
119114

120115
// Assert
121116
assertThat(refreshTokenRepository.count()).isZero();
@@ -129,7 +124,7 @@ void shouldKeepTokenExpiringRightNow() {
129124
refreshTokenRepository.save(almostExpired);
130125

131126
// Act
132-
cleanupJob.cleanupExpiredTokens();
127+
cleanupJob.deleteExpiredTokens();
133128

134129
// Assert
135130
assertThat(refreshTokenRepository.findByToken("almost-expired")).isPresent();
@@ -143,7 +138,7 @@ void shouldDeleteTokenExpiredOneSecondAgo() {
143138
refreshTokenRepository.save(justExpired);
144139

145140
// Act
146-
cleanupJob.cleanupExpiredTokens();
141+
cleanupJob.deleteExpiredTokens();
147142

148143
// Assert
149144
assertThat(refreshTokenRepository.findByToken("just-expired")).isEmpty();
@@ -164,7 +159,7 @@ void shouldHandleLargeVolumeOfTokens() {
164159
}
165160

166161
// Act
167-
cleanupJob.cleanupExpiredTokens();
162+
cleanupJob.deleteExpiredTokens();
168163

169164
// Assert
170165
assertThat(refreshTokenRepository.count()).isEqualTo(50); // Apenas os válidos
@@ -181,9 +176,9 @@ void shouldHandleMultipleExecutionsSafely() {
181176
refreshTokenRepository.save(valid);
182177

183178
// Act - executar 3 vezes
184-
cleanupJob.cleanupExpiredTokens();
185-
cleanupJob.cleanupExpiredTokens();
186-
cleanupJob.cleanupExpiredTokens();
179+
cleanupJob.deleteExpiredTokens();
180+
cleanupJob.deleteExpiredTokens();
181+
cleanupJob.deleteExpiredTokens();
187182

188183
// Assert - resultado deve ser idempotente
189184
assertThat(refreshTokenRepository.count()).isEqualTo(1);

src/integrationTest/java/com/example/library/repository/RefreshTokenRepositoryIT.java

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
import org.junit.jupiter.api.Test;
1212
import org.springframework.beans.factory.annotation.Autowired;
1313
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
14-
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
1514
import org.springframework.test.context.ActiveProfiles;
15+
import org.springframework.transaction.annotation.Transactional;
1616
import org.testcontainers.junit.jupiter.Testcontainers;
1717

1818
import com.example.library.refresh_token.RefreshToken;
@@ -23,10 +23,10 @@
2323
import static org.assertj.core.api.Assertions.assertThat;
2424

2525
@DataJpaTest
26-
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
2726
@Testcontainers
27+
@Transactional
2828
@ActiveProfiles("it")
29-
@DisplayName("RefreshTokenRepository - Integration Tests")
29+
@DisplayName("RefreshTokenRepositoryIT - Integration Tests")
3030
class RefreshTokenRepositoryIT {
3131

3232
@Autowired
@@ -40,9 +40,6 @@ class RefreshTokenRepositoryIT {
4040

4141
@BeforeEach
4242
void setUp() {
43-
refreshTokenRepository.deleteAll();
44-
userRepository.deleteAll();
45-
4643
testUser = new User();
4744
testUser.setName("John Doe");
4845
testUser.setEmail("john@example.com");
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.example.library.config;
2+
3+
import javax.sql.DataSource;
4+
5+
import org.springframework.context.annotation.Bean;
6+
import org.springframework.context.annotation.Configuration;
7+
import org.springframework.jdbc.core.JdbcTemplate;
8+
9+
import net.javacrumbs.shedlock.core.LockProvider;
10+
import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider;
11+
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
12+
13+
@Configuration
14+
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
15+
public class ShedLockConfig {
16+
17+
/**
18+
* Usa o próprio PostgreSQL como backend de lock distribuído.
19+
* Não requer infraestrutura extra — apenas a tabela `shedlock` (V007).
20+
*
21+
* defaultLockAtMostFor = "10m": tempo máximo que o lock pode ficar ativo.
22+
* Garante liberação automática se a instância travar ou morrer sem liberar o lock.
23+
*/
24+
@Bean
25+
LockProvider lockProvider(DataSource dataSource) {
26+
return new JdbcTemplateLockProvider(
27+
JdbcTemplateLockProvider.Configuration.builder()
28+
.withJdbcTemplate(new JdbcTemplate(dataSource))
29+
.usingDbTime() // usa o clock do banco — evita problemas com clock skew entre instâncias
30+
.build()
31+
);
32+
}
33+
}
Lines changed: 16 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,38 @@
11
package com.example.library.refresh_token;
22

3-
import java.time.Instant;
4-
53
import org.springframework.scheduling.annotation.Scheduled;
64
import org.springframework.stereotype.Component;
7-
import org.springframework.transaction.annotation.Transactional;
85

96
import lombok.RequiredArgsConstructor;
107
import lombok.extern.slf4j.Slf4j;
8+
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
119

1210
/**
1311
* Job agendado para limpar refresh tokens expirados do banco de dados.
14-
*
15-
* Tokens expirados são deletados quando alguém tenta usá-los (via validate()),
16-
* mas tokens que nunca são usados ficam acumulando no banco.
17-
*
18-
* Este job roda diariamente às 2h da manhã para fazer limpeza preventiva.
12+
*
13+
* Separação intencional entre lock e transação:
14+
* - @SchedulerLock NÃO pode estar num método @Transactional
15+
* porque o lock precisa ser adquirido e liberado FORA da transação.
16+
* Se estivessem juntos, o lock só seria liberado no commit,
17+
* anulando a proteção distribuída do ShedLock.
18+
* - A transação fica em RefreshTokenCleanupService, que é chamado
19+
* após o lock ser adquirido.
1920
*/
2021
@Component
2122
@RequiredArgsConstructor
2223
@Slf4j
2324
public class RefreshTokenCleanupJob {
2425

25-
private final RefreshTokenRepository repository;
26+
private final RefreshTokenCleanupService cleanupService;
2627

27-
/**
28-
* Remove todos os refresh tokens expirados do banco.
29-
*
30-
* Cron: "0 0 2 * * *" = todos dia às 02:00 AM
31-
* Formato: segundo minuto hora dia mês dia-da-semana
32-
*
33-
* Configurável via property:
34-
* @Scheduled(cron = "${refresh-token.cleanup.cron:0 0 2 * * *}")
35-
*/
3628
@Scheduled(cron = "0 0 2 * * *")
37-
@Transactional
29+
@SchedulerLock(
30+
name = "refreshTokenCleanupJob",
31+
lockAtLeastFor = "30m",
32+
lockAtMostFor = "1h"
33+
)
3834
public void cleanupExpiredTokens() {
3935
log.info("Starting cleanup of expired refresh tokens...");
40-
41-
try {
42-
int deletedCount = repository.deleteByExpiryDateBefore(Instant.now());
43-
44-
if (deletedCount > 0) {
45-
log.info("Deleted {} expired refresh tokens", deletedCount);
46-
} else {
47-
log.debug("No expired refresh tokens found");
48-
}
49-
} catch (Exception e) {
50-
log.error("Error cleaning up expired refresh tokens", e);
51-
}
36+
cleanupService.deleteExpiredTokens();
5237
}
5338
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.example.library.refresh_token;
2+
3+
import java.time.Instant;
4+
5+
import org.springframework.stereotype.Service;
6+
import org.springframework.transaction.annotation.Transactional;
7+
8+
import lombok.RequiredArgsConstructor;
9+
import lombok.extern.slf4j.Slf4j;
10+
11+
/**
12+
* Serviço responsável pela limpeza transacional de refresh tokens expirados.
13+
*
14+
* Separado do RefreshTokenCleanupJob intencionalmente:
15+
* - O Job gerencia o lock distribuído (ShedLock) sem transação
16+
* - Este Service gerencia a transação sem lock
17+
*
18+
* Esta separação garante que o ShedLock adquira e libere o lock
19+
* FORA da transação — comportamento correto e esperado pelo ShedLock.
20+
*/
21+
@Service
22+
@RequiredArgsConstructor
23+
@Slf4j
24+
public class RefreshTokenCleanupService {
25+
26+
private final RefreshTokenRepository repository;
27+
28+
@Transactional
29+
public void deleteExpiredTokens() {
30+
try {
31+
int deletedCount = repository.deleteByExpiryDateBefore(Instant.now());
32+
33+
if (deletedCount > 0) {
34+
log.info("Deleted {} expired refresh tokens", deletedCount);
35+
} else {
36+
log.debug("No expired refresh tokens found");
37+
}
38+
} catch (Exception e) {
39+
log.error("Error cleaning up expired refresh tokens", e);
40+
}
41+
}
42+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- =============================================
2+
-- ShedLock — tabela de lock distribuído para scheduled jobs
3+
-- Garante que apenas uma instância execute cada job por vez
4+
-- =============================================
5+
6+
CREATE TABLE shedlock (
7+
name VARCHAR(64) NOT NULL,
8+
lock_until TIMESTAMP NOT NULL,
9+
locked_at TIMESTAMP NOT NULL,
10+
locked_by VARCHAR(255) NOT NULL,
11+
CONSTRAINT pk_shedlock PRIMARY KEY (name)
12+
);
13+
14+
COMMENT ON TABLE shedlock
15+
IS 'Distributed lock table used by ShedLock to prevent concurrent scheduled job execution across multiple instances';

0 commit comments

Comments
 (0)