Clustering: Add distributed lock (#2333)

To support sync of activities in cluster setups

Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com>
This commit is contained in:
Avgustin Marinov
2025-04-02 12:25:08 +03:00
committed by GitHub
parent 32990ab2ea
commit 2af5439b39
12 changed files with 484 additions and 11 deletions

View File

@@ -0,0 +1,231 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.cluster;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.Lock;
import javax.sql.DataSource;
import io.qameta.allure.Description;
import io.qameta.allure.Feature;
import io.qameta.allure.Story;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.repository.jpa.AbstractJpaIntegrationTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.integration.jdbc.lock.DefaultLockRepository;
import org.springframework.integration.jdbc.lock.JdbcLockRegistry;
import org.springframework.integration.jdbc.lock.LockRepository;
import org.springframework.integration.support.locks.LockRegistry;
import org.springframework.integration.util.UUIDConverter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@Feature("Component Tests - Repository")
@Story("Distributed Lock")
@SpringBootTest(classes = { DistributedLockTest.Config.class }, webEnvironment = SpringBootTest.WebEnvironment.NONE)
@Slf4j
class DistributedLockTest extends AbstractJpaIntegrationTest {
@Autowired
private LockProperties lockProperties;
@Autowired
@Qualifier("lockRepository0")
private LockRepository lockRepository0;
@Autowired
@Qualifier("lockRepository1")
private LockRepository lockRepository1;
@EnableTransactionManagement
@Configuration
@EnableConfigurationProperties({ LockProperties.class })
@PropertySource("classpath:/jpa-test.properties")
static class Config {
@Bean
@ConditionalOnMissingBean
public UserDetailsService userDetailsService() {
return new InMemoryUserDetailsManager();
}
@Bean
LockProperties lockProperties() {
return new LockProperties();
}
@Bean
LockRepository lockRepository0(final DataSource dataSource, final LockProperties lockProperties, final PlatformTransactionManager txManager) {
return lockRepository(dataSource, lockProperties, txManager);
}
@Bean
LockRepository lockRepository1(final DataSource dataSource, final LockProperties lockProperties, final PlatformTransactionManager txManager) {
return lockRepository(dataSource, lockProperties, txManager);
}
private LockRepository lockRepository(final DataSource dataSource, final LockProperties lockProperties, final PlatformTransactionManager txManager) {
final DefaultLockRepository repository = new DistributedLockRepository(dataSource, lockProperties, txManager);
repository.setPrefix("SP_");
return repository;
}
}
@SuppressWarnings({"java:S2925"})
@Test
@Description("Test to verify that lock is kept while ping runs")
void keepLockAlive() {
final LockRegistry lockRegistry0 = new JdbcLockRegistry(lockRepository0);
final LockRegistry lockRegistry1 = new JdbcLockRegistry(lockRepository1);
final String lockKey0 = "test-lock0";
final String lockKey1 = "test-lock1";
// JDBCLockRegistry#pathFor
final String path0 = UUIDConverter.getUUID(lockKey0).toString();
final String path1 = UUIDConverter.getUUID(lockKey1).toString();
// lock{i}{j} -> lockKey{i} obtained by lockRegistry{j}
final Lock lock00 = lockRegistry0.obtain(lockKey0);
final Lock lock01 = lockRegistry1.obtain(lockKey0);
final Lock lock10 = lockRegistry0.obtain(lockKey1);
final Lock lock11 = lockRegistry1.obtain(lockKey1);
final AtomicBoolean lock01Obtained = new AtomicBoolean();
final AtomicBoolean lock11Obtained = new AtomicBoolean();
final AtomicBoolean lock11Locked = new AtomicBoolean(); // state of the lock11
log.info("Starting test");
// service 0 must be able to lock lockKey0
assertThat(lock00.tryLock()).isTrue();
try {
assertThat(lockRepository0.isAcquired(path0)).isTrue(); // check db state
final Thread lockThread1 = new Thread(() -> {
// asserts lockKey1 is free and could be locked
assertThat(lock11.tryLock()).isTrue();
assertThat(lockRepository1.isAcquired(path1)).isTrue(); // check db state
try {
lock11Obtained.set(true);
lock11Locked.set(true);
// asserts lockKey0 is kept by lock00 and could not be locked via lockRepository1
try {
final Instant timeout = Instant.now().plus(4 * lockProperties.getTtl(), ChronoUnit.MILLIS);
while (Instant.now().isBefore(timeout)) {
log.info("lockThread1: loop, timeout: {}", new Date(timeout.toEpochMilli()));
assertThat(lock01.tryLock()).isFalse();
assertThat(lockRepository1.isAcquired(path0)).isFalse(); // check db state
try {
Thread.sleep(Math.min(1, lockProperties.getTtl() / 4));
} catch (final InterruptedException e) {
if (Thread.interrupted()) {
Thread.currentThread().interrupt();
}
}
}
} catch (final AssertionError e) {
log.error("lockRepository1 has locked lockKey0 which has to be in lockRepository0 possession!", e);
lock01Obtained.set(true);
lock01.unlock();
}
assertThat(lockRepository0.isAcquired(path1)).isFalse(); // check db state
assertThat(lockRepository1.isAcquired(path1)).isTrue(); // check db state
} finally {
lock11Locked.set(false);
lock11.unlock();
assertThat(lockRepository1.isAcquired(path1)).isFalse(); // check db state
}
});
lockThread1.start();
// asserts lockKey1 is kept by lock11 and could not be locked via lockRepository0
final Instant timeout = Instant.now().plus(4 * lockProperties.getTtl(), ChronoUnit.MILLIS);
while (Instant.now().isBefore(timeout)) {
log.info("main thread: loop, timeout: {}", new Date(timeout.toEpochMilli()));
if (lock11Locked.get()) {
try {
assertThat(lock10.tryLock()).isFalse();
assertThat(lockRepository0.isAcquired(path1)).isFalse(); // check db state
} catch (final AssertionError e) {
log.error("lockRepository0 has locked lockKey1 which has to be in lockRepository1 possession!");
lock10.unlock();
if (lock11Locked.get()) {
throw e;
} else {
// otherwise the lock has been released
break;
}
}
try {
Thread.sleep(Math.min(1, lockProperties.getTtl() / 4));
} catch (final InterruptedException e) {
if (Thread.interrupted()) {
Thread.currentThread().interrupt();
}
}
}
}
try {
lockThread1.join();
} catch (final InterruptedException e) {
if (Thread.interrupted()) {
Thread.currentThread().interrupt();
}
}
// assert that service 1 hasn't been able to acquire the lock 0
assertThat(lock01Obtained).isFalse();
// assert that service 1 has been able to acquire the lock 1
assertThat(lock11Obtained).isTrue();
assertThat(lockRepository0.isAcquired(path0)).isTrue(); // check db state
assertThat(lockRepository1.isAcquired(path0)).isFalse(); // check db state
} finally {
lock00.unlock();
assertThat(lockRepository0.isAcquired(path0)).isFalse(); // check db state
}
try {
// assert that lockKey1 has been released by lock11 and could be got again
// and in different thread
assertThat(lock10.tryLock()).isTrue();
// and can't be locked by it while locked by lock01
assertThat(lock11.tryLock()).isFalse();
} finally {
lock10.unlock();
}
// assert that db is clean
assertThat(lockRepository0.isAcquired(path0)).isFalse();
assertThat(lockRepository1.isAcquired(path0)).isFalse();
assertThat(lockRepository0.isAcquired(path1)).isFalse();
assertThat(lockRepository1.isAcquired(path1)).isFalse();
}
}

View File

@@ -50,3 +50,8 @@ logging.level.org.eclipse.persistence=ERROR
# enable / disable case sensitiveness of the DB when playing around
#hawkbit.rsql.caseInsensitiveDB=true
hawkbit.repository.cluster.lock.ttl=1000
hawkbit.repository.cluster.lock.refreshOnRemainMS=200
hawkbit.repository.cluster.lock.refreshOnRemainPercent=10
hawkbit.repository.cluster.lock.ticPeriodMS=10