Bucket4j: Backends

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

A distributed Bucket4j bucket needs a store, and Bucket4j 8.21.0 ships a module for almost every store a Java service is likely to have: Redis through five clients, six relational databases, nine data grids and caches, MongoDB, Couchbase, Memcached and Cassandra. All of them produce the same ProxyManager<K> described on the distributed page; they differ in artifact, builder, how atomicity is achieved, whether async and expiry work, and what you must do yourself (create a table, put jars on grid nodes, register a serializer).

It was written against Bucket4j 8.21.0, Spring Boot 4.1.x, Spring Framework 7.0.x and Java 21 and verified on 4 October 2026. The Redis and PostgreSQL examples, a single-node Redis Cluster, the JCache provider of Caffeine and the Caffeine module were run against 8.21.0 (Testcontainers for Redis 7 and PostgreSQL 17). The other backends are described from the 8.21.0 sources and the reference and were not run, because each needs its own server; their builder signatures were read from the 8.21.0 tag rather than copied from the reference. Add the bucket4j_jdk17-bom import so the module versions stay aligned (see the overview).

Four columns group the backends by how the read-modify-write is made atomic. Compare-and-swap: Redis with Lettuce, Redisson, Jedis, Vert.x and GLIDE, MongoDB, Couchbase, Memcached, Cassandra, Geode with a cache transaction, Hazelcast casBasedBuilder and Ignite thin casBasedBuilder. Entry processor: JCache, Hazelcast entryProcessor, Ignite 2 thick client, Infinispan and Coherence. Server-side job: Ignite 3, Ignite thin clientCompute, Infinispan Hot Rod and Geode functionBasedBuilder. Row or key locks: PostgreSQL select for update and advisory lock, MySQL, MariaDB, Oracle, SQL Server, DB2 and Hazelcast lockBasedBuilder. Per-key compute, in-process: Caffeine, through the cache map’s atomic compute, with no compare-and-swap retries and not distributed. DynamoDB SDK v1 is legacy, stopping at 8.6.0

The backend table

Every artifact id below is bucket4j_jdk17-<name>, version 8.21.0. "Async" is the value of isAsyncModeSupported() and "Expiry" the value of isExpireAfterWriteSupported(), read from the 8.21.0 sources; where a backend has several builders the values differ per builder.

Table 1. Backends of Bucket4j 8.21.0
Backend Artifact Builder entry point Atomicity Async Expiry Reference

Redis, Lettuce

redis-common + lettuce

Bucket4jLettuce.casBasedBuilder(…​)

CAS (Lua EVAL)

yes

yes

Lettuce

Redis, Redisson

redis-common + redisson

Bucket4jRedisson.casBasedBuilder(CommandAsyncExecutor)

CAS

yes

yes

Redisson

Redis, Jedis

redis-common + jedis

Bucket4jJedis.casBasedBuilder(Pool<Jedis> | UnifiedJedis | JedisCluster)

CAS

no

yes

Jedis

Redis, Vert.x

redis-common + vertx

Bucket4jVertx.casBasedBuilder(Redis)

CAS

yes

yes

Vert.x

Redis or Valkey, GLIDE

redis-common + glide

Bucket4jGlide.casBasedBuilder(BaseClient)

CAS

yes

yes

GLIDE

PostgreSQL

postgresql

Bucket4jPostgreSQL.selectForUpdateBasedBuilder(DataSource), advisoryLockBasedBuilder(DataSource)

row lock, advisory lock

no

yes (manual cleanup)

PostgreSQL

MySQL, MariaDB

mysql, mariadb

Bucket4jMySQL and Bucket4jMariaDB .selectForUpdateBasedBuilder(DataSource)

row lock

no

yes (manual cleanup)

MySQL, MariaDB

Oracle, SQL Server, DB2

oracle, mssql, db2

Bucket4jOracle, Bucket4jMSSQL, Bucket4jDb2 .selectForUpdateBasedBuilder(DataSource)

row lock

no

yes (manual cleanup)

Oracle, SQL Server, DB2

JCache (JSR 107)

jcache

Bucket4jJCache.entryProcessorBasedBuilder(Cache<K, byte[]>)

entry processor

no

no

JCache

Hazelcast

hazelcast (hazelcast-4 for 4.x)

Bucket4jHazelcast.entryProcessorBasedBuilder(IMap), lockBasedBuilder(IMap), casBasedBuilder(IMap)

entry processor, lock, CAS

yes, no, no

yes, yes, no

Hazelcast

Apache Ignite 2

ignite

Bucket4jIgnite.thickClient().entryProcessorBasedBuilder(IgniteCache); thinClient().clientComputeBasedBuilder(ClientCache, ClientCompute); thinClient().casBasedBuilder(ClientCache<K, ByteBuffer>)

entry processor, server job, CAS

yes

no

Ignite

Apache Ignite 3

ignite3

Bucket4jIgnite3.builder()

server job in a SERIALIZABLE transaction

yes

no

Ignite 3

Infinispan

infinispan (infinispan-15 for 15.x)

Bucket4jInfinispan.entryProcessorBasedBuilder(ReadWriteMap); hotrodClientBasedBuilder(RemoteCache)

entry processor; server task

yes; no

yes; yes

Infinispan

Oracle Coherence

coherence (coherence-21 for 21.x)

Bucket4jCoherence.entryProcessorBasedBuilder(NamedCache<K, byte[]>)

entry processor

yes

yes

Coherence

Apache Geode

geode

Bucket4jGeode.compareAndSwapBasedBuilder(Region), functionBasedBuilder(Region)

CAS with a cache transaction; function

no

no

Geode

MongoDB

mongo-common + mongodb-sync or mongodb-async

Bucket4jMongoDBSync and Bucket4jMongoDBAsync .compareAndSwapBasedBuilder(MongoCollection<Document>)

CAS

async module only

yes

sync, async

Couchbase

couchbase

Bucket4jCouchbase.compareAndSwapBasedBuilder(Collection | AsyncCollection)

CAS (document CAS)

yes

yes

Couchbase

Memcached

memcached

Bucket4jMemcached.casBasedBuilder(MemcacheClient<byte[]>)

CAS (gets and cas)

yes

yes

Memcached

Apache Cassandra

cassandra

Bucket4jCassandra.compareAndSwapBasedBuilder(CqlSession)

CAS (lightweight transactions)

yes

yes

Cassandra

Caffeine (in-process)

caffeine

Bucket4jCaffeine.builderFor(Caffeine)

Per-key atomic compute on a local map

yes

yes

none (README only)

Four facts about the artifacts. The Redis modules always come in pairs: bucket4j_jdk17-redis-common plus one client module; the aggregator bucket4j_jdk17-redis stopped at 8.14.0 and is not a dependency to use. The bucket4j_jdk17-bom manages every module in the table except infinispan-15, infinispan-all, hazelcast-all and the legacy redis aggregator, so declare an explicit version for infinispan-15. Modules that arrived recently: Vert.x in 8.17.0, Couchbase in 8.18.0, Geode in 8.20.0, and Cassandra, Memcached, Ignite 3, Infinispan 15, Coherence 21 and the BOM in 8.21.0 (first versions on Maven Central). And several older constructors are deprecated in favour of these builders (for example those of CaffeineProxyManager and InfinispanProxyManager).

Which atomicity model applies determines what you must configure. Entry-processor and server-job backends execute Bucket4j code on the grid members, so the jars (and, for Hazelcast, a serializer; for Infinispan, the ProtoStream context initializer) must be there. Compare-and-swap backends run entirely in the client and need nothing on the server, at the price of retries on hot keys. Row-lock backends need a table. Caffeine is none of these: it runs each command inside cache.asMap().compute(key, …​), which is atomic per key within the JVM, so there is no read-compare-swap, no conflict and no retry, and maxRetries and retryStrategy have no effect on it.

Redis

Redis is the default choice when the service already has one. Add bucket4j_jdk17-redis-common and exactly one client module. Concurrent access is handled by compare-and-swap: the client reads the state, computes the new one and replaces it with a Lua script run by EVAL that checks the stored value is unchanged (details and a sequence diagram on the distributed page). It is a conditional write, not a Lua implementation of the token bucket, so a hot key loses races and retries.

Dependencies for Redis with Lettuce
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.bucket4j</groupId>
            <artifactId>bucket4j_jdk17-bom</artifactId>
            <version>8.21.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>com.bucket4j</groupId>
        <artifactId>bucket4j_jdk17-redis-common</artifactId>
    </dependency>
    <dependency>
        <groupId>com.bucket4j</groupId>
        <artifactId>bucket4j_jdk17-lettuce</artifactId>
    </dependency>
</dependencies>

Building the manager

Bucket4jLettuce.casBasedBuilder has an overload for a RedisClient or a RedisClusterClient, and others for connections and async command objects. The RedisClient and RedisClusterClient overloads return ProxyManager<byte[]>: the key is raw bytes, because Redis keys are binary. Applications work with strings, so withMapper converts. Spring Boot does not create a RedisClient bean (it builds a LettuceConnectionFactory), so define one, reading the address from spring.data.redis.* (see configuration and profiles and Spring Boot Redis as a cache).

A String-keyed ProxyManager over Redis
RedisClient redisClient = RedisClient.create(RedisURI.create(host, port));

ProxyManager<String> proxyManager = Bucket4jLettuce.casBasedBuilder(redisClient)
        .expirationAfterWrite(ExpirationAfterWriteStrategy
                .basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(5)))
        .build()
        .withMapper(key -> key.getBytes(StandardCharsets.UTF_8));

BucketProxy bucket = proxyManager.getProxy("api:alice", () -> freePlan);

Run against a Redis 7 container, a shared quota of 10 consumed from two such managers admitted exactly 10 calls. Redis expiry works through the TTL written with every state (psetex), so expirationAfterWrite needs no cleanup job. The reference shows a ClientSideConfig variant with withClientSideConfig; use the builder methods instead.

Redis Cluster and the other clients

For Redis Cluster pass a RedisClusterClient; the same chain works and the key is mapped the same way. Against a single-node cluster in a container the builder connected and enforced a 3-token limit. Jedis accepts a JedisCluster, Redisson a CommandAsyncExecutor of a cluster configuration, Vert.x and GLIDE their cluster-capable clients. All five Redis modules support cluster. Choose the client the application already uses, so the service keeps one connection pool and one set of timeouts; Jedis is the only one without an asynchronous API in Bucket4j.

Redis Cluster with Lettuce
RedisClusterClient clusterClient = RedisClusterClient.create("redis://redis-cluster:7000");
ProxyManager<String> proxyManager = Bucket4jLettuce.casBasedBuilder(clusterClient)
        .build()
        .withMapper(key -> key.getBytes(StandardCharsets.UTF_8));

Source: Bucket4j reference: Lettuce; Bucket4jLettuce.java at 8.21.0 for the overloads. The replication, Sentinel and Cluster behaviour of Redis itself is in replication, Sentinel and Cluster; with replicas and asynchronous replication, a failover can lose the most recent state, which RECONSTRUCT turns into a full bucket.

JDBC

The six JDBC modules share one design: the bucket state is a binary column in a table that you create, one SELECT …​ FOR UPDATE (or an advisory lock) in a transaction makes the update atomic, and there is no asynchronous mode, so asAsync() throws UnsupportedOperationException. They are the right backend when a database is the only shared infrastructure, at the price of one transaction per consumption and of cleanup being your job.

Table 2. The DDL per database (state only, no expiry)
Database DDL

PostgreSQL

CREATE TABLE IF NOT EXISTS bucket(id BIGINT PRIMARY KEY, state BYTEA);

MySQL, MariaDB

CREATE TABLE IF NOT EXISTS bucket(id BIGINT PRIMARY KEY, state BLOB);

Oracle

CREATE TABLE bucket(id NUMBER NOT NULL PRIMARY KEY, state RAW(255));

SQL Server

CREATE TABLE bucket(id BIGINT NOT NULL PRIMARY KEY, state BINARY(256))

DB2

CREATE TABLE bucket(id BIGINT NOT NULL PRIMARY KEY, state VARCHAR(512))

When an expiration strategy is configured the manager also writes an expires_at column (a BIGINT, or NUMBER on Oracle), so the table needs it: …​ state BYTEA, expires_at BIGINT. For the PostgreSQL advisory-lock manager with expiry the reference adds a fourth column, explicit_lock BIGINT. Source: Bucket4j reference: JDBC integrations and the page of each database.

Flyway migration

Bucket4j never creates the table. With Spring Boot the natural place is a Flyway migration (Boot 4 uses spring-boot-starter-flyway plus the database module, for example flyway-database-postgresql).

src/main/resources/db/migration/V1__bucket4j_bucket.sql
CREATE TABLE IF NOT EXISTS bucket (
    id         BIGINT PRIMARY KEY,
    state      BYTEA,
    expires_at BIGINT
);

Building the manager, names and key types

PostgreSQL manager over Boot’s DataSource
@Bean   // the concrete type also exposes removeExpired(...) for the cleanup job below
PostgreSQLSelectForUpdateBasedProxyManager<Long> postgresProxyManager(DataSource dataSource) {
    return Bucket4jPostgreSQL.selectForUpdateBasedBuilder(dataSource)
            .expirationAfterWrite(ExpirationAfterWriteStrategy
                    .basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(10)))
            .build();
}

The defaults are table bucket, columns id, state and expires_at, and a Long key. The builder methods table(…​), idColumn(…​), stateColumn(…​) and expiresAtColumn(…​) rename them, addCustomColumn(…​) adds a computed column, and primaryKeyMapper(…​) changes the key type through PrimaryKeyMapper (LONG, INT, STRING, or your own implementation). Changing the key type changes the builder’s type parameter, so the chain must keep the returned builder.

Custom names with a String key
// CREATE TABLE IF NOT EXISTS user_buckets(user_id VARCHAR PRIMARY KEY, state_bytes BYTEA);
ProxyManager<String> proxyManager = Bucket4jPostgreSQL.selectForUpdateBasedBuilder(dataSource)
        .table("user_buckets")
        .idColumn("user_id")
        .stateColumn("state_bytes")
        .primaryKeyMapper(PrimaryKeyMapper.STRING)
        .build();

BucketProxy bucket = proxyManager.getProxy("alice@example.org", () -> threeADay);

An advisory-lock variant (advisoryLockBasedBuilder(dataSource), backed by pg_advisory_xact_lock, which locks an application-defined 64-bit key for the transaction) is available when locking the row is not wanted. Both PostgreSQL managers were run: three managers on one data source shared a quota of 1000 across 12 worker threads trying 100 times each, and exactly 1000 calls were admitted.

Source: Bucket4j reference: PostgreSQL; Bucket4jPostgreSQL.java and PrimaryKeyMapper.java at 8.21.0.

Cleaning up expired rows

A database has no TTL. When an expiration strategy is set the manager fills expires_at but never deletes anything, so a scheduled job must. The select-for-update managers implement ExpiredEntriesCleaner: removeExpired(int batchSize) deletes up to that many expired rows in one transaction and returns the count. removeExpired is not on the ProxyManager interface, so the job needs the bean typed as PostgreSQLSelectForUpdateBasedProxyManager<Long> (or ExpiredEntriesCleaner), as declared above.

A cleanup job
private static final int MAX_TO_REMOVE_IN_ONE_TRANSACTION = 1_000;
private static final int THRESHOLD_TO_CONTINUE_REMOVING = 50;

@Scheduled(cron = "0 30 4 * * *")      // needs @EnableScheduling
void removeExpiredBuckets() {
    int removed;
    do {
        removed = postgresProxyManager.removeExpired(MAX_TO_REMOVE_IN_ONE_TRANSACTION);
    } while (removed > THRESHOLD_TO_CONTINUE_REMOVING);
}

With a TTL of 200 ms and five buckets created, removeExpired(1000) returned 5 after the TTL had passed. In a deployment with several replicas, run the job on one of them (a scheduler lock) or tolerate concurrent cleaners, which only contend on the deletes. Source: Bucket4j reference: JDBC integrations and ExpiredEntriesCleaner.java at 8.21.0.

Data grids and caches

JCache (bucket4j_jdk17-jcache) works with any provider that implements entry processors by the JSR 107 rules. The reference is explicit that checking this is your job: many implementations are not certified, and one that loses concurrent writes silently breaks the limit; it ships a compatibility test to run against your provider. The manager is Bucket4jJCache.entryProcessorBasedBuilder(Cache<K, byte[]>), async is not supported (JCache has no async API) and neither is Bucket4j expiry (configure it on the cache). Run against the JCache provider of Caffeine, a bucket of 3 admitted 3 and refused the fourth. The Caffeine JCache provider is a local cache, so this proves the API, not distribution.

JCache manager
Cache<String, byte[]> cache = cacheManager.createCache("buckets",
        new MutableConfiguration<String, byte[]>().setTypes(String.class, byte[].class));
ProxyManager<String> proxyManager = Bucket4jJCache.entryProcessorBasedBuilder(cache).build();

Hazelcast (hazelcast, or hazelcast-4 for the 4.x line) has three builders: entry processor (async, expiry), lockBasedBuilder (expiry, no async) and casBasedBuilder (neither). Bucket4j classes are serialized with Java serialization unless you register its custom serializers (HazelcastProxyManager.addCustomSerializers, or for a standalone cluster the Bucket4j jars on every node plus the bucket4j.hazelcast.serializer.type_id_base setting).

Apache Ignite 2 (ignite) exists because JCache has no async API. Bucket4jIgnite.thickClient() and thinClient() .clientComputeBasedBuilder(…​) need the Bucket4j jars on every cluster node; thinClient().casBasedBuilder(…​) does not, but has higher latency. Expiry is configured on the cache, not through Bucket4j. Ignite 3 (ignite3) is a separate module for an incompatible product: it needs a table with specific upper-case columns that you create, runs each command as a compute job colocated with the key inside a SERIALIZABLE transaction, and batches concurrent requests for the same key so a hot key does not turn into a storm of rollbacks.

Infinispan (infinispan, or infinispan-15): entryProcessorBasedBuilder(ReadWriteMap) for embedded caches and hotrodClientBasedBuilder(RemoteCache) for a remote server. The Hot Rod builder returns HotrodInfinispanProxyManager<K>, which the reference types as InfinispanProxyManager<K>. Since Infinispan 10 the default ProtoStream marshaller needs the Bucket4j context initializer registered (Bucket4jProtobufContextInitializer). Oracle Coherence (coherence, coherence-21) uses entryProcessorBasedBuilder(NamedCache<K, byte[]>) and offers POF serialization for the Bucket4j classes.

Apache Geode (geode) is compare-and-swap, not entry-processor based, because a Geode function does not serialize concurrent invocations for one key; the client guards its write with a cache transaction and retries on a commit conflict. functionBasedBuilder(Region) moves the loop onto the member that owns the key to save round trips under contention. Neither supports async or Bucket4j expiry.

Source: JCache, Hazelcast, Ignite, Ignite 3, Infinispan, Coherence and Geode. The reference’s Hot Rod, async and JCache servlet snippets use InfinispanProxyManager, a deprecated constructor and javax.servlet; use the builders and jakarta.servlet on Spring Boot 4.

MongoDB, Couchbase, Memcached, Cassandra

These four are compare-and-swap managers built on the store’s own conditional write, and all four support expiry through the store’s TTL.

  • MongoDB: two modules sharing bucket4j_jdk17-mongo-common, mongodb-sync for the synchronous driver (no async) and mongodb-async for the reactive streams driver (async). The builders take a MongoCollection<Document> and key String by default, with an overload taking a Mapper<K> for other key types.

  • Couchbase: compareAndSwapBasedBuilder over a Collection or an AsyncCollection, using the document CAS value; the state is binary document content and expiry is the document TTL.

  • Memcached: through the Folsom client; Memcached cannot run scripts, so Bucket4j uses gets and cas. Its API is CompletionStage based, so async works.

  • Cassandra: through the 4.x driver, using lightweight transactions. The reference warns they are expensive: each successful conditional write needs four round trips between Paxos participants, and contention on one partition key makes it worse. A reasonable choice for a team that already runs Cassandra, not a replacement for Redis on a hot key; combine with request optimizations.

Vert.x and GLIDE

Both are Redis clients, covered by the Redis section: Bucket4jVertx.casBasedBuilder(Redis) for Vert.x applications and Bucket4jGlide.casBasedBuilder(BaseClient) for Valkey GLIDE, with ProxyManager<byte[]> keys, async and expiry.

Caffeine

bucket4j_jdk17-caffeine has no section in the 8.21.0 reference (it is described only in the README). It keeps the "remote" state in a Caffeine cache inside the JVM, which makes it useful for tests and for single-instance services that want the ProxyManager API (including expiry and async) without a store. It is not distributed. Use Bucket4jCaffeine.builderFor(…​); the CaffeineProxyManager constructors, which the Spring Cloud Gateway documentation still shows, are deprecated.

Caffeine as a ProxyManager
ProxyManager<String> proxyManager = Bucket4jCaffeine.<String>builderFor(Caffeine.newBuilder().maximumSize(10_000))
        .expirationAfterWrite(ExpirationAfterWriteStrategy
                .basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(1)))
        .build();

Against it isAsyncModeSupported() and isExpireAfterWriteSupported() are both true. Source: Bucket4jCaffeine.java at 8.21.0.

DynamoDB

There is no DynamoDB module in the 8.21.0 repository and no section in the reference. Maven Central holds com.bucket4j:bucket4j-dynamodb-sdk-v1 and bucket4j_jdk8-dynamodb-sdk-v1, both ending at 8.6.0 (October 2023) and built for version 1 of the AWS SDK, with no jdk17 build. Treat them as legacy: they are not in the BOM and are not maintained alongside the current releases. On AWS use ElastiCache for Redis (the Redis section) or an RDS database (the JDBC section).

Choosing a backend

Start from what the service already runs, because a new stateful dependency for rate limiting is the most expensive option.

Table 3. Choosing a backend
Situation Choice

A Redis or Valkey is already in the stack

Redis through the client the app uses; Lettuce is the Spring Boot default. Needs no table and no job; expiry is automatic.

Only a relational database is shared

PostgreSQL, MySQL, MariaDB, Oracle, SQL Server or DB2. Create the table with Flyway, schedule removeExpired; no async, so not for WebFlux; one transaction per request.

A data grid already holds state (Hazelcast, Infinispan, Coherence, Ignite)

The grid’s entry-processor builder; mind the classpath and serialization of the members

Reactive stack

A backend with async support: Lettuce, Redisson, Vert.x, GLIDE, MongoDB async, Couchbase, Memcached, Cassandra, Ignite, Infinispan entry processor, Coherence, Hazelcast entry processor

One instance, or tests

Caffeine; the same API, nothing to run

A single hot key under heavy load

An entry-processor or server-job backend (one round trip) or the batching optimization; compare-and-swap loses races

Lowest latency to the limit check

Server-side backends and Redis in the same availability zone; a database over a network costs a transaction

Durability matters less than for most data: a lost bucket under RECONSTRUCT becomes a full bucket, which for most limits is an acceptable failure; for limits where it is not, choose a store with replication and THROW_BUCKET_NOT_FOUND_EXCEPTION. Whatever the choice, plan the failure mode (fail open or closed) as described in the production checklist, and test the backend with Testcontainers as in testing Bucket4j.

Testing Redis and PostgreSQL with Testcontainers

The examples above were run with Testcontainers 2.x. Two managers built on the same container stand in for two replicas.

Two replicas sharing a quota through Redis
@Container
static final GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
        .withExposedPorts(6379);

@Test
void redisSharedQuotaAcrossTwoInstances() {
    RedisClient client = RedisClient.create(RedisURI.create(redis.getHost(), redis.getMappedPort(6379)));
    ProxyManager<byte[]> a = Bucket4jLettuce.casBasedBuilder(client).build();
    ProxyManager<byte[]> b = Bucket4jLettuce.casBasedBuilder(client).build();
    byte[] key = "quota:isbn".getBytes(StandardCharsets.UTF_8);
    long consumed = 0;
    for (int i = 0; i < 6; i++) {
        consumed += a.builder().build(key, () -> quota10).tryConsume(1) ? 1 : 0;
        consumed += b.builder().build(key, () -> quota10).tryConsume(1) ? 1 : 0;
    }
    assertThat(consumed).isEqualTo(10);        // 12 attempts, 10 admitted
    client.shutdown();
}
Two replicas sharing a quota through PostgreSQL
@Container
static final PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17-alpine");

@Test
void postgresSharedQuotaAcrossTwoInstances() throws Exception {
    PGSimpleDataSource ds = new PGSimpleDataSource();
    ds.setUrl(postgres.getJdbcUrl());
    ds.setUser(postgres.getUsername());
    ds.setPassword(postgres.getPassword());
    Flyway.configure().dataSource(ds).locations("classpath:db/migration").load().migrate();

    ProxyManager<Long> a = Bucket4jPostgreSQL.selectForUpdateBasedBuilder(ds).build();
    ProxyManager<Long> b = Bucket4jPostgreSQL.selectForUpdateBasedBuilder(ds).build();
    long consumed = 0;
    for (int i = 0; i < 6; i++) {
        consumed += a.builder().build(1L, () -> quota10).tryConsume(1) ? 1 : 0;
        consumed += b.builder().build(1L, () -> quota10).tryConsume(1) ? 1 : 0;
    }
    assertThat(consumed).isEqualTo(10);
}

Both pass against 8.21.0. PostgreSQLContainer is in org.testcontainers.postgresql in Testcontainers 2.x, and on Spring Boot 4 the Flyway auto-configuration lives in org.springframework.boot.flyway.autoconfigure. The Spring Boot Testcontainers setup is in integration tests with Testcontainers. Source: Bucket4j reference: JDBC integrations.

Pitfalls

Pitfall What happens Fix

Adding bucket4j_jdk17-redis

The aggregator is stale (last 8.14.0) and brings no client

Use redis-common plus one client module

Mixing module versions

Serialization or API mismatches between core and backend

Import bucket4j_jdk17-bom (and pin infinispan-15 yourself)

Forgetting to create the JDBC table

The first request fails

Flyway migration with the DDL of your database, including expires_at when you set an expiry

Expiry configured, no cleanup job on JDBC

Rows accumulate; the table grows without bound

Schedule removeExpired(n)

asAsync() on JDBC, Jedis, JCache, Geode or Hot Rod

UnsupportedOperationException

Pick a backend with async for WebFlux, or run blocking calls on a bounded pool

Expiry on JCache, Geode, Ignite or Hazelcast CAS

The builder refuses the strategy

Configure expiry in the store, or choose a backend that supports it

Grid members without the Bucket4j jars

Entry processors fail to deserialize

Put bucket4j-core and the module on every member (Ignite thick and compute, Coherence, Hazelcast standalone, Ignite 3)

JCache provider that is not entry-processor atomic

Writes are lost and the limit is exceeded

Run the reference’s compatibility test against the provider

Hot key on Cassandra or compare-and-swap in general

Retries and latency grow with contention

Use batching, or an entry-processor backend

Using the reference’s withClientSideConfig or CaffeineProxyManager constructors

The first does not exist on 8.21.0 builders, the second is deprecated

Builder methods and Bucket4jCaffeine.builderFor(…​)

What changed

  • 8.11.1 renamed the artifacts to bucket4j_jdk17-*; JDK 11 builds stopped at 8.15.0.

  • 8.17.0 to 8.21.0 added Vert.x (8.17.0), Couchbase (8.18.0), Geode (8.20.0), and Cassandra, Memcached, Ignite 3, Infinispan 15, Coherence 21 and the BOM (8.21.0), according to Maven Central.

  • The 8.21.0 release notes list the compare-and-swap retry mechanism, Redis Sentinel through Lettuce and Geode support, and fixes for the exception stack trace and MySQL gap-lock deadlocks.

  • The redis, hazelcast-all and infinispan-all aggregators did not get 8.21.0 builds.

  • The full discrepancy table (Hot Rod type, async example, Caffeine, JDK 11 matrix) is on migration and what’s changed.