Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 26 additions & 0 deletions hawkbit-autoconfigure/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,31 @@
<artifactId>protostuff-runtime</artifactId>
<optional>true</optional>
</dependency>

<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* 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.autoconfigure.throttle;

import javax.sql.DataSource;

import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.throttle.TenantThrottle;
import org.eclipse.hawkbit.throttle.ThrottleProperties;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;

/**
* Wires the per-tenant DB connection throttle . Active only when
* {@code hawkbit.throttle.enabled=true}; otherwise this auto-configuration is skipped entirely, the
* DataSource is left un-wrapped and behavior is byte-for-byte the current one.
* <p/>
* A {@link BeanPostProcessor} wraps the application {@link DataSource} in a
* {@link ThrottlingDataSourceDecorator}, mirroring the proven {@code QueryCountConfiguration} pattern.
* Pool capacity is auto-detected from Hikari's {@code maximumPoolSize} (so granted permits never
* exceed the real pool), or taken from {@code hawkbit.throttle.capacity} when set.
*/
@Slf4j
@AutoConfiguration
@ConditionalOnProperty(prefix = "hawkbit.throttle", name = "enabled", havingValue = "true")
@EnableConfigurationProperties(ThrottleProperties.class)
public class ThrottleAutoConfiguration {

private static final int DEFAULT_CAPACITY = 10; // Hikari default; used only if auto-detect fails

@Bean
static BeanPostProcessor throttlingDataSourcePostProcessor(final ObjectProvider<ThrottleProperties> properties) {
return new BeanPostProcessor() {

@Override
public Object postProcessAfterInitialization(final Object bean, final String beanName) {
if (!(bean instanceof DataSource dataSource) || bean instanceof ThrottlingDataSourceDecorator) {
return bean;
}
final ThrottleProperties props = properties.getObject();
validateVirtualThreadsWhenWaitMode(props);
final int capacity = resolveCapacity(props, dataSource);
final int threshold = props.getThreshold();
final TenantThrottle throttle = new TenantThrottle(capacity, threshold, props::limit);
log.info("hawkBit per-tenant connection throttle enabled (bean '{}'): capacity={}, limit={}, timeout={}, threshold={}",
beanName, capacity, props.getLimit(), props.getTimeout(),
threshold == -1 ? " -1 (always enforce limit)" : " " + threshold + " slots");
return new ThrottlingDataSourceDecorator(dataSource, throttle, props.getTimeout());
}
};
}

private static void validateVirtualThreadsWhenWaitMode(final ThrottleProperties props) {
if (props.getTimeout().isZero() || props.getTimeout().isNegative()) {
return; // fast-reject mode (timeout=0) is safe on platform threads
}
// timeout > 0 requires virtual threads to avoid platform thread exhaustion
try {
final Thread vt = Thread.ofVirtual().unstarted(() -> {});
if (!vt.isVirtual()) {
throw new IllegalStateException(
"hawkbit.throttle.timeout > 0 requires virtual threads (spring.threads.virtual.enabled=true), " + "otherwise platform thread pool will be exhausted by blocked waiters. " + "Either set timeout=0 (fast-reject) or enable virtual threads.");
}
} catch (final UnsupportedOperationException e) {
throw new IllegalStateException(
"hawkbit.throttle.timeout > 0 requires virtual threads (spring.threads.virtual.enabled=true), " + "but this JVM does not support virtual threads. Either set timeout=0 or upgrade to JDK 21+.",
e);
}
}

private static int resolveCapacity(final ThrottleProperties props, final DataSource dataSource) {
final int configured = props.getCapacity();
if (configured > 0) {
return configured;
}
// -1 or 0 → auto-detect
final Integer poolSize = hikariMaximumPoolSize(dataSource);
if (poolSize != null && poolSize > 0) {
return poolSize;
}
log.warn("Could not auto-detect DB pool size for throttling; set hawkbit.throttle.capacity. Falling back to {}",
DEFAULT_CAPACITY);
return DEFAULT_CAPACITY;
}

// reflective read so hawkbit-autoconfigure need not compile-depend on HikariCP
private static Integer hikariMaximumPoolSize(final DataSource dataSource) {
try {
final Object value = dataSource.getClass().getMethod("getMaximumPoolSize").invoke(dataSource);
return value instanceof Integer size ? size : null;
} catch (final ReflectiveOperationException | RuntimeException e) {
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* 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.autoconfigure.throttle;

import java.sql.Connection;
import java.sql.SQLException;
import java.time.Duration;

import javax.sql.DataSource;

import org.eclipse.hawkbit.throttle.ConnectionThrottleGate;
import org.eclipse.hawkbit.throttle.TenantThrottle;
import org.springframework.jdbc.datasource.DelegatingDataSource;

/**
* Layer-2 enforcement point (): thin {@link DelegatingDataSource} that routes
* every connection acquisition through {@link ConnectionThrottleGate}. All logic (tenant exemption,
* reentrancy bypass, permit acquire/release tied to {@link Connection#close()}) lives in the gate and
* is unit-tested in hawkbit-core; this class is only the Spring/JDBC glue.
*/
public class ThrottlingDataSourceDecorator extends DelegatingDataSource {

private final TenantThrottle throttle;
private final Duration timeout;

public ThrottlingDataSourceDecorator(final DataSource targetDataSource, final TenantThrottle throttle,
final Duration timeout) {
super(targetDataSource);
this.throttle = throttle;
this.timeout = timeout;
}

@Override
public Connection getConnection() throws SQLException {
return ConnectionThrottleGate.acquire(throttle, timeout, super::getConnection);
}

@Override
public Connection getConnection(final String username, final String password) throws SQLException {
return ConnectionThrottleGate.acquire(throttle, timeout, () -> super.getConnection(username, password));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ org.eclipse.hawkbit.autoconfigure.scheduling.AsyncConfigurerAutoConfiguration
org.eclipse.hawkbit.autoconfigure.scheduling.ExecutorAutoConfiguration
org.eclipse.hawkbit.autoconfigure.security.SecurityAutoConfiguration
org.eclipse.hawkbit.autoconfigure.security.StaticUserManagementAutoConfiguration
org.eclipse.hawkbit.autoconfigure.throttle.ThrottleAutoConfiguration
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* 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.autoconfigure.throttle;

import static org.assertj.core.api.Assertions.assertThat;

import javax.sql.DataSource;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;

/**
* Verifies the master switch actually gates the DataSource wrapping: enabled ⇒ the application
* DataSource is replaced by a {@link ThrottlingDataSourceDecorator}; disabled ⇒ the DataSource is
* left untouched (byte-for-byte current behavior).
*/
class ThrottleAutoConfigurationTest {

private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(ThrottleAutoConfiguration.class))
.withBean("dataSource", DataSource.class, ThrottleAutoConfigurationTest::h2DataSource);

@Test
void wrapsDataSourceWhenEnabled() {
runner.withPropertyValues("hawkbit.throttle.enabled=true")
.run(context -> assertThat(context.getBean(DataSource.class)).isInstanceOf(ThrottlingDataSourceDecorator.class));
}

@Test
void leavesDataSourceUntouchedWhenDisabled() {
// enabled defaults to false → auto-configuration skipped entirely
runner.run(context -> assertThat(context.getBean(DataSource.class)).isNotInstanceOf(ThrottlingDataSourceDecorator.class));
}

private static DataSource h2DataSource() {
final HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:h2:mem:throttleac_" + System.nanoTime() + ";DB_CLOSE_DELAY=-1");
config.setMaximumPoolSize(4);
return new HikariDataSource(config);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* 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.autoconfigure.throttle;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.eclipse.hawkbit.context.AccessContext.asTenant;

import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import java.time.Duration;
import java.util.function.ToIntFunction;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.eclipse.hawkbit.throttle.TenantThrottle;
import org.eclipse.hawkbit.throttle.ThrottledException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

/**
* End-to-end Layer-2 verification: the decorator + {@code ConnectionThrottleGate} + engine against a
* real HikariCP pool over H2. Proves the whole DataSource path, not just the unit-tested logic.
*/
class ThrottlingDataSourceDecoratorTest {

private static final ToIntFunction<String> UNLIMITED = tenant -> -1;

private HikariDataSource target;

@BeforeEach
void setUp() {
final HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:h2:mem:throttle_" + System.nanoTime() + ";DB_CLOSE_DELAY=-1");
config.setMaximumPoolSize(5);
target = new HikariDataSource(config);
}

@AfterEach
void tearDown() {
target.close();
}

@Test
void tenantRequestExecutesRealQueryAndReleasesPermitOnClose() {
final TenantThrottle throttle = new TenantThrottle(5, 0, UNLIMITED);
final ThrottlingDataSourceDecorator dataSource = new ThrottlingDataSourceDecorator(target, throttle, Duration.ofSeconds(1));

asTenant("acme", () -> {
try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery("SELECT 1")) {
assertThat(resultSet.next()).isTrue();
assertThat(resultSet.getInt(1)).isEqualTo(1); // real query ran through the proxied connection
assertThat(throttle.inUse("acme")).isEqualTo(1);
} catch (final Exception e) {
throw new RuntimeException(e);
}

assertThat(throttle.inUse("acme")).isZero(); // permit released when the connection returned to the pool
});
}

@Test
void backgroundThreadWithoutMarkerBypassesThrottle() throws Exception {
final TenantThrottle throttle = new TenantThrottle(1, 0, UNLIMITED);
throttle.acquire("filler", Duration.ZERO); // engine capacity consumed by another holder
final ThrottlingDataSourceDecorator dataSource = new ThrottlingDataSourceDecorator(target, throttle, Duration.ZERO);

// no marker on this thread → exempt, must still get a real connection despite the full engine
try (Connection connection = dataSource.getConnection()) {
assertThat(connection.isValid(1)).isTrue();
}
assertThat(throttle.inUse()).isEqualTo(1); // unchanged: only the filler holds a permit
}

@Test
void tenantOverCapacityIsThrottled() {
final TenantThrottle throttle = new TenantThrottle(1, 0, UNLIMITED);
throttle.acquire("filler", Duration.ZERO); // capacity full
final ThrottlingDataSourceDecorator dataSource = new ThrottlingDataSourceDecorator(target, throttle, Duration.ZERO);

asTenant("acme", () -> assertThatExceptionOfType(ThrottledException.class).isThrownBy(dataSource::getConnection));
}
}
Loading
Loading