diff --git a/asciidoc/src/main/docs/asciidoc/index.adoc b/asciidoc/src/main/docs/asciidoc/index.adoc index ff6b5197..fbf1cab4 100644 --- a/asciidoc/src/main/docs/asciidoc/index.adoc +++ b/asciidoc/src/main/docs/asciidoc/index.adoc @@ -9,6 +9,10 @@ Release date: `21 Apr 2024` * http://bucket4j.com/{revnumber}/toc.html[{revnumber} Reference] * http://bucket4j.com/commercial/java8.html[Instructions to get the builds for Java 8] +== How to support +https://app.lava.top/ru/2716741203?donate=open[Make donate] to increase motivation of project maintainer to develop new features, +write documentation and answer user questions. + == Documentation archive If you are looking for documentation related to a previous version then http://bucket4j.com/previos-releases.html[ see there] @@ -17,7 +21,7 @@ Vladimir Bukhtoyarov:: Lead Java developer at vk.com + Saint-Petersburg, Russia + jsecoder@mail.ru + -Role: maintainer, future vision, architect + +Role: maintainer, future vision, code owner + image:images/photo.jpg[80,80] + Maxim Bartkov:: diff --git a/bucket4j-core/src/main/java/io/github/bucket4j/distributed/proxy/ExpiredEntriesCleaner.java b/bucket4j-core/src/main/java/io/github/bucket4j/distributed/proxy/ExpiredEntriesCleaner.java index 04ce3a48..d9f687e9 100644 --- a/bucket4j-core/src/main/java/io/github/bucket4j/distributed/proxy/ExpiredEntriesCleaner.java +++ b/bucket4j-core/src/main/java/io/github/bucket4j/distributed/proxy/ExpiredEntriesCleaner.java @@ -19,8 +19,6 @@ */ package io.github.bucket4j.distributed.proxy; -import java.util.Collection; - /** * Interface used by particular {@link ProxyManager} implementations to indicate * that they support the manual(triggered by user) removing of expired bucket entries. @@ -41,21 +39,21 @@ public interface ExpiredEntriesCleaner { *

Example of usage: *

      * {@code
-     *    private static final int CLEANUP_INTERVAL_MILLIS = 10_000;
-     *    private static final int MAX_KEYS_TO_REMOVE_IN_ONE_TRANSACTION = 100;
-     *    private static final int THRESHOLD_TO_CONTINUE_REMOVING = 20;
+     *    private static final int MAX_TO_REMOVE_IN_ONE_TRANSACTION = 1_000;
+     *    private static final int THRESHOLD_TO_CONTINUE_REMOVING = 50;
      *
-     *    @Scheduled(fixedDelay = CLEANUP_INTERVAL_MILLIS)
+     *    // once per day at 4:30 morning
+     *    @Scheduled(cron = "0 30 4 * * *")
      *    public void scheduleFixedDelayTask() {
      *       int removedKeysCount;
      *       do {
-     *            removedKeysCount = proxyManager.removedKeys(MAX_KEYS_TO_REMOVE_IN_ONE_TRANSACTION);
+     *            removedCount = proxyManager.removeExpired(MAX_TO_REMOVE_IN_ONE_TRANSACTION);
      *            if (removedKeysCount > 0) {
-     *                logger.info("Removed {} expired buckets", removedKeysCount);
+     *                logger.info("Removed {} expired buckets", removedCount);
      *            } else {
      *                logger.info("There are no expired buckets to remove");
      *            }
-     *       } while (removedKeysCount > THRESHOLD_TO_CONTINUE_REMOVING)
+     *       } while (removedCount > THRESHOLD_TO_CONTINUE_REMOVING)
      *    }
      * }
      * 
diff --git a/bucket4j-mysql/pom.xml b/bucket4j-mysql/pom.xml index b8535df8..802c5d1d 100644 --- a/bucket4j-mysql/pom.xml +++ b/bucket4j-mysql/pom.xml @@ -41,19 +41,19 @@ org.testcontainers mysql - 1.16.3 + 1.19.7 test mysql mysql-connector-java - 8.0.28 + 8.0.33 provided com.zaxxer - HikariCP-java6 - 2.3.8 + HikariCP + 5.1.0 test diff --git a/bucket4j-mysql/src/main/java/io/github/bucket4j/mysql/Bucket4jMySQL.java b/bucket4j-mysql/src/main/java/io/github/bucket4j/mysql/Bucket4jMySQL.java index 4cc92d97..22b0ac89 100644 --- a/bucket4j-mysql/src/main/java/io/github/bucket4j/mysql/Bucket4jMySQL.java +++ b/bucket4j-mysql/src/main/java/io/github/bucket4j/mysql/Bucket4jMySQL.java @@ -64,6 +64,11 @@ public MySQLSelectForUpdateBasedProxyManagerBuilder primaryKeyMapper(Pr super.primaryKeyMapper = (PrimaryKeyMapper) Objects.requireNonNull(primaryKeyMapper); return (MySQLSelectForUpdateBasedProxyManagerBuilder) this; } + + @Override + public boolean isExpireAfterWriteSupported() { + return true; + } } } diff --git a/bucket4j-mysql/src/main/java/io/github/bucket4j/mysql/MySQLSelectForUpdateBasedProxyManager.java b/bucket4j-mysql/src/main/java/io/github/bucket4j/mysql/MySQLSelectForUpdateBasedProxyManager.java index f4f4a4b4..9a617b44 100644 --- a/bucket4j-mysql/src/main/java/io/github/bucket4j/mysql/MySQLSelectForUpdateBasedProxyManager.java +++ b/bucket4j-mysql/src/main/java/io/github/bucket4j/mysql/MySQLSelectForUpdateBasedProxyManager.java @@ -21,8 +21,10 @@ import com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException; import io.github.bucket4j.BucketExceptions; +import io.github.bucket4j.distributed.jdbc.CustomColumnProvider; import io.github.bucket4j.distributed.jdbc.PrimaryKeyMapper; import io.github.bucket4j.distributed.jdbc.SQLProxyConfiguration; +import io.github.bucket4j.distributed.proxy.ExpiredEntriesCleaner; import io.github.bucket4j.distributed.proxy.generic.select_for_update.AbstractSelectForUpdateBasedProxyManager; import io.github.bucket4j.distributed.proxy.generic.select_for_update.LockAndGetResult; import io.github.bucket4j.distributed.proxy.generic.select_for_update.SelectForUpdateBasedTransaction; @@ -35,6 +37,8 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; import java.util.Optional; @@ -45,7 +49,7 @@ * * @param type of primary key */ -public class MySQLSelectForUpdateBasedProxyManager extends AbstractSelectForUpdateBasedProxyManager { +public class MySQLSelectForUpdateBasedProxyManager extends AbstractSelectForUpdateBasedProxyManager implements ExpiredEntriesCleaner { private final DataSource dataSource; private final PrimaryKeyMapper primaryKeyMapper; @@ -53,16 +57,35 @@ public class MySQLSelectForUpdateBasedProxyManager extends AbstractSelectForU private final String updateSqlQuery; private final String insertSqlQuery; private final String selectSqlQuery; + private final String clearExpiredSqlQuery; + private final List> customColumns = new ArrayList<>(); MySQLSelectForUpdateBasedProxyManager(MySQLSelectForUpdateBasedProxyManagerBuilder builder) { super(builder.getClientSideConfig()); this.dataSource = builder.getDataSource(); this.primaryKeyMapper = builder.getPrimaryKeyMapper(); this.removeSqlQuery = MessageFormat.format("DELETE FROM {0} WHERE {1} = ?", builder.getTableName(), builder.getIdColumnName()); - updateSqlQuery = MessageFormat.format("UPDATE {0} SET {1}=? WHERE {2}=?", builder.getTableName(), builder.getStateColumnName(), builder.getIdColumnName()); insertSqlQuery = MessageFormat.format("INSERT IGNORE INTO {0}({1}, {2}) VALUES(?, null)", builder.getTableName(), builder.getIdColumnName(), builder.getStateColumnName()); selectSqlQuery = MessageFormat.format("SELECT {0} as state FROM {1} WHERE {2} = ? FOR UPDATE", builder.getStateColumnName(), builder.getTableName(), builder.getIdColumnName()); + this.customColumns.addAll(builder.getCustomColumns()); + getClientSideConfig().getExpirationAfterWriteStrategy().ifPresent(expiration -> { + this.customColumns.add(CustomColumnProvider.createExpiresInColumnProvider(builder.getExpiresAtColumnName(), expiration)); + }); + if (customColumns.isEmpty()) { + this.updateSqlQuery = MessageFormat.format("UPDATE {0} SET {1}=? WHERE {2}=?", builder.getTableName(), builder.getStateColumnName(), builder.getIdColumnName()); + } else { + String customPartInUpdate = String.join(",", customColumns.stream().map(column -> column.getCustomFieldName() + "=?").toList()); + this.updateSqlQuery = MessageFormat.format("UPDATE {0} SET {1}=?,{2} WHERE {3}=?", builder.getTableName(), builder.getStateColumnName(), customPartInUpdate, builder.getIdColumnName()); + } + // https://stackoverflow.com/questions/12810346/alternative-to-using-limit-keyword-in-a-subquery-in-mysql + this.clearExpiredSqlQuery = MessageFormat.format( + """ + DELETE FROM {0} WHERE + {2} < ? AND + {1} IN(SELECT * FROM (SELECT {1} FROM {0} WHERE {2} < ? LIMIT ? FOR UPDATE SKIP LOCKED) as subquery) + """, builder.getTableName(), builder.getIdColumnName(), builder.getExpiresAtColumnName() + ); } /** @@ -71,6 +94,7 @@ public class MySQLSelectForUpdateBasedProxyManager extends AbstractSelectForU @Deprecated public MySQLSelectForUpdateBasedProxyManager(SQLProxyConfiguration configuration) { super(configuration.getClientSideConfig()); + this.clearExpiredSqlQuery = null; this.dataSource = Objects.requireNonNull(configuration.getDataSource()); this.primaryKeyMapper = configuration.getPrimaryKeyMapper(); this.removeSqlQuery = MessageFormat.format("DELETE FROM {0} WHERE {1} = ?", configuration.getTableName(), configuration.getIdName()); @@ -78,6 +102,9 @@ public MySQLSelectForUpdateBasedProxyManager(SQLProxyConfiguration configurat insertSqlQuery = MessageFormat.format("INSERT IGNORE INTO {0}({1}, {2}) VALUES(?, null)", configuration.getTableName(), configuration.getIdName(), configuration.getStateName()); selectSqlQuery = MessageFormat.format("SELECT {0} as state FROM {1} WHERE {2} = ? FOR UPDATE", configuration.getStateName(), configuration.getTableName(), configuration.getIdName()); + if (getClientSideConfig().getExpirationAfterWriteStrategy().isPresent()) { + throw new IllegalArgumentException(); + } } @Override @@ -104,8 +131,12 @@ public void update(byte[] data, RemoteBucketState newState, Optional reque try { try (PreparedStatement updateStatement = connection.prepareStatement(updateSqlQuery)) { applyTimeout(updateStatement, requestTimeoutNanos); - updateStatement.setBytes(1, data); - primaryKeyMapper.set(updateStatement, 2, key); + int i = 0; + updateStatement.setBytes(++i, data); + for (CustomColumnProvider column : customColumns) { + column.setCustomField(key, ++i, updateStatement, newState, currentTimeNanos()); + } + primaryKeyMapper.set(updateStatement, ++i, key); updateStatement.executeUpdate(); } } catch (SQLException e) { @@ -187,4 +218,25 @@ public void removeProxy(K key) { } } + @Override + public boolean isExpireAfterWriteSupported() { + return true; + } + + + @Override + public int removeExpired(int batchSize) { + try (Connection connection = dataSource.getConnection()) { + long currentTimeMillis = System.currentTimeMillis(); + try(PreparedStatement clearStatement = connection.prepareStatement(clearExpiredSqlQuery)) { + clearStatement.setLong(1, currentTimeMillis); + clearStatement.setLong(2, currentTimeMillis); + clearStatement.setInt(3, batchSize); + return clearStatement.executeUpdate(); + } + } catch (SQLException e) { + throw new BucketExceptions.BucketExecutionException(e); + } + } + } diff --git a/bucket4j-mysql/src/test/java/io/github/bucket4j/mysql/MySQLSelectForUpdateLockBasedTransactionTest.java b/bucket4j-mysql/src/test/java/io/github/bucket4j/mysql/MySQLSelectForUpdateLockBasedTransactionTest.java index 562b63b3..93f32559 100644 --- a/bucket4j-mysql/src/test/java/io/github/bucket4j/mysql/MySQLSelectForUpdateLockBasedTransactionTest.java +++ b/bucket4j-mysql/src/test/java/io/github/bucket4j/mysql/MySQLSelectForUpdateLockBasedTransactionTest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.utility.DockerImageName; import javax.sql.DataSource; import java.sql.Connection; @@ -28,7 +29,7 @@ public static void initializeInstance() throws SQLException { container = startMySQLContainer(); dataSource = createJdbcDataSource(container); BucketTableSettings tableSettings = BucketTableSettings.customSettings("test.bucket", "id", "state"); - final String INIT_TABLE_SCRIPT = "CREATE TABLE IF NOT EXISTS {0}({1} BIGINT PRIMARY KEY, {2} BLOB)"; + final String INIT_TABLE_SCRIPT = "CREATE TABLE IF NOT EXISTS {0}({1} BIGINT PRIMARY KEY, {2} BLOB, expires_at BIGINT)"; try (Connection connection = dataSource.getConnection()) { try (Statement statement = connection.createStatement()) { String query = MessageFormat.format(INIT_TABLE_SCRIPT, tableSettings.getTableName(), tableSettings.getIdName(), tableSettings.getStateName()); @@ -44,7 +45,7 @@ public static void initializeInstance() throws SQLException { .table("test.bucket") .idColumn("id") .stateColumn("state") - ) + ).checkExpiration() ); } @@ -56,17 +57,16 @@ public static void shutdown() { } private static DataSource createJdbcDataSource(MySQLContainer container) { - HikariConfig hikariConfig = new HikariConfig(); - hikariConfig.setJdbcUrl(container.getJdbcUrl()); - hikariConfig.setUsername(container.getUsername()); - hikariConfig.setPassword(container.getPassword()); - hikariConfig.setDriverClassName(container.getDriverClassName()); - hikariConfig.setMaximumPoolSize(100); - return new HikariDataSource(hikariConfig); + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(container.getJdbcUrl()); + config.setUsername(container.getUsername()); + config.setPassword(container.getPassword()); + config.setMaximumPoolSize(10); + return new HikariDataSource(config); } private static MySQLContainer startMySQLContainer() { - MySQLContainer container = new MySQLContainer(); + MySQLContainer container = new MySQLContainer(DockerImageName.parse("mysql:8.0.36")); container.start(); return container; }