Skip to content

Repository files navigation

PgCache

Java Maven Central License

Caching for Java apps that uses your existing PostgreSQL as the backend — no Redis needed.

Good fit: small-to-medium apps where PostgreSQL is already the primary database and Redis would be one more thing to run. Not a fit: millions of ops/sec, sub-millisecond latency, or multi-million-entry caches.

Features

  • Zero extra infrastructure — UNLOGGED tables + JSONB in the database you already have
  • Spring Boot@Cacheable/@CacheEvict via auto-configuration, Actuator health, Micrometer metrics
  • Quarkus — CDI injection and Mutiny API (programmatic only; it is a library jar, not a Quarkus extension)
  • Cache-stampede protectiongetOrCompute coordinates cold loads across threads and JVMs with PostgreSQL advisory locks
  • Redis-parity operationsincrement/decrement, getAndDelete, getAndPut, persist, expireAt, getTtlInfo
  • Type-faithful values — entries record their Java type, so @Cacheable returns your class back, not a Map
  • TTL policies — ABSOLUTE (fixed) or SLIDING (reset on access), per cache or per entry
  • And the rest — async API, batch ops, typed generic reads, scanKeys streaming, namespaces, event listeners, statistics, pattern eviction, background cleanup

Choose the module

PgCache deliberately reuses the application's DataSource; it does not create a second connection pool. Framework dependencies and the PostgreSQL JDBC driver are therefore provided, so choose the module that matches an application which already owns those resources.

Spring Boot

Use pgcache-spring in an application that already has a JDBC/JPA starter and the PostgreSQL driver. For example, the application normally contains spring-boot-starter-jdbc (or spring-boot-starter-data-jpa) and org.postgresql:postgresql in addition to:

<dependency>
  <groupId>io.github.hunghhdev</groupId>
  <artifactId>pgcache-spring</artifactId>
  <version>1.11.1</version>
</dependency>

Auto-configuration backs off when any other CacheManager bean exists; in an application combining cache providers, define the desired primary/composite manager explicitly. @EnablePgCache below also enables Spring's cache interception.

@SpringBootApplication
@EnablePgCache
public class Application { ... }

@Service
public class UserService {
    @Cacheable("users")
    public User getUser(Long id) { return userRepository.findById(id); }

    @CacheEvict("users")
    public void deleteUser(Long id) { userRepository.deleteById(id); }
}
# application.yml
pgcache:
  default-ttl: PT1H
  auto-create-table: true
  single-flight-concurrency: 4
  background-cleanup:
    enabled: true
    interval: PT30M
  caches:
    users:
      ttl: PT2H
      ttl-policy: SLIDING

Quarkus

Use pgcache-quarkus with io.quarkus:quarkus-cache and io.quarkus:quarkus-jdbc-postgresql. PgCache injects the default Quarkus DataSource; an application with only named data sources must provide its own PgCacheStore/PgQuarkusCacheManager beans.

<dependency>
  <groupId>io.github.hunghhdev</groupId>
  <artifactId>pgcache-quarkus</artifactId>
  <version>1.11.1</version>
</dependency>

PgQuarkusHealthCheck is an injectable data provider, not an automatically registered MicroProfile Health endpoint; expose a small @Readiness HealthCheck delegate when that endpoint is required.

@ApplicationScoped
public class UserService {
    @Inject
    PgQuarkusCacheManager cacheManager;

    public Uni<User> getUser(Long id) {
        PgQuarkusCache cache = (PgQuarkusCache) cacheManager.getCache("users").get();
        return cache.getAsync("user:" + id, key -> userRepository.findById(id));
    }
}
# application.properties
pgcache.default-ttl=PT1H
pgcache.auto-create-table=true
pgcache.single-flight-concurrency=4
pgcache.caches.users.ttl=PT2H
pgcache.caches.users.ttl-policy=SLIDING

Standalone (core)

Use pgcache-core, provide the PostgreSQL JDBC driver and an SLF4J binding at runtime, and pass an existing pooled DataSource. The SLF4J API is included transitively; the application still chooses the logging backend. PgCache borrows and closes individual Connections; it never closes the DataSource.

<dependency>
  <groupId>io.github.hunghhdev</groupId>
  <artifactId>pgcache-core</artifactId>
  <version>1.11.1</version>
</dependency>
PgCacheStore cache = PgCacheStore.builder()
    .dataSource(dataSource)
    .build();

cache.put("user:123", user, Duration.ofHours(1));
Optional<User> user = cache.get("user:123", User.class);

API Tour

// Read-through with stampede protection: normally one caller (across threads
// and JVMs) runs the loader; fail-open/admission-limit paths may load directly.
// Spring's @Cacheable(sync = true) and Quarkus cache.get(...) route through this.
User user = cache.getOrCompute("user:42", User.class, Duration.ofMinutes(10),
    () -> userRepository.findById(42));

// Atomic operations (Redis parity)
long views = cache.increment("views:page:1", 1);                     // INCR
Optional<Token> old = cache.getAndDelete("token:abc", Token.class);  // GETDEL
cache.persist("session:1");                                          // PERSIST
cache.expireAt("report:daily", tomorrowMidnight);                    // EXPIREAT

// Typed generic reads
Optional<List<User>> users = cache.get("team:all", new TypeReference<List<User>>() {});

// Key scanning — batched, constant memory
for (String key : cache.scanKeys("user:%", 500)) { process(key); }

// Batch + pattern operations
cache.putAll(entries, Duration.ofHours(1));
cache.getAll(keys, User.class);
cache.evictByPattern("user:%");

// Async
cache.getAsync("user:123", User.class).thenAccept(opt -> ...);

// Statistics
cache.getStatistics().getHitRate();

Namespaces

Isolated logical caches sharing one table — clear(), size(), getKeys() only see their own namespace:

PgCacheStore tenantA = PgCacheStore.builder().dataSource(ds).namespace("tenant_a").build();
PgCacheStore tenantB = PgCacheStore.builder().dataSource(ds).namespace("tenant_b").build();
tenantA.clear(); // tenant_b untouched

Event Listeners

PgCacheStore.builder()
    .dataSource(dataSource)
    .addEventListener(new CacheEventListener() {
        @Override public void onPut(String key, Object value) { log.info("Cached: {}", key); }
        @Override public void onEvict(String key) { log.info("Evicted: {}", key); }
    })
    .build();

In Spring Boot, just define a CacheEventListener bean — it's auto-detected.

Operational Notes

Performance. PgCache is a database-backed cache, so latency and throughput depend on the application's pool, network, and PostgreSQL load. Benchmark it in the target environment; it is intended for practical moderate-load reuse of an existing database, not Redis-class sub-millisecond traffic.

UNLOGGED semantics. After a PostgreSQL crash (not a clean restart) the table is truncated — treat every read as a potential miss, as with any cache. UNLOGGED data is not replicated: point PgCache at the primary, never a read replica.

Database ownership. PgCache does not contain or manage a connection pool. Spring and Quarkus reuse the framework's default DataSource; standalone callers provide one. Do not create a pool per cache or per request. PgCache closes every borrowed connection but never closes the application-owned DataSource.

Schema permissions. autoCreateTable=true is convenient for development and simple deployments, but the database role needs CREATE on the target schema and ownership-like rights to ALTER the table, create indexes, and delete rows during an old-schema migration. It does not create a missing schema. With a least-privilege runtime role, provision the current table with a migration/owner role using the packaged default DDL, then set auto-create-table=false; normal operation needs USAGE on the schema plus SELECT, INSERT, UPDATE, and DELETE on the table. For a custom table name, replace every pgcache_store occurrence in that DDL, including the index-name prefixes. Spring's default pg_cache table is one such replacement.

Initialization. With autoCreateTable=true, core and the Quarkus producer initialize/migrate the table when the store is built. Spring creates stores lazily when a cache is first requested. Concurrent stores and application instances targeting the same table serialize this DDL in PostgreSQL; no separate coordinator is required. A green application startup still does not prove that its cache table permissions are correct. The Spring Actuator health indicator performs a real SELECT 1 through the configured DataSource even before a cache exists. After traffic has initialized a store, health also verifies that store's table still exists. It does not eagerly create dynamic caches/tables, write probe rows, or run COUNT queries; creation/migration and table DML permissions are therefore validated by real cache operations rather than a side-effecting health probe.

Error handling. Direct core reads (get, getAll, size, and similar operations) throw PgCacheException on database failure. Coordinated read-through operations are fail-open: core getOrCompute, Spring Cache.get(key, loader) / @Cacheable(sync=true), Quarkus get(...), and Quarkus getAsync(...) run the loader and return its result uncached when PostgreSQL is unavailable. A plain Spring cache read degrades to a miss, but a later put still throws. Direct writes and invalidations throw; silently accepting them could leave stale data live.

Micrometer metrics (auto-configured with pgcache-spring): pgcache.gets, pgcache.puts, pgcache.evictions, pgcache.size, pgcache.hit.rate.

Requirements. PostgreSQL 9.6 or newer. PostgreSQL 11+ uses a 64-bit advisory-lock hash; 9.6/10 use a compatible 32-bit hash, where the rare collision only serializes two unrelated cold keys. The minimum-compatible releases are upstream-EOL, so use a currently supported PostgreSQL release for production.

Generic depth. Values record their type so framework adapters can hand your class back rather than a Map. Generic nesting is recorded three levels deep: Map<String, List<User>> round-trips, a fourth level degrades to a raw type. When you need a deeper generic, ask for it explicitly — cache.get(key, new TypeReference<Map<String, List<Map<String, User>>>>() {}) — which always wins over the recorded type.

Single-flight and your pool. getOrCompute holds one connection while the loader runs. Same-key callers are collapsed in-process first, and singleFlightConcurrency (default 4) caps how many callers may hold a connection inside the cross-JVM lock at once; the rest load directly. Keep that value at or below a quarter of your pool size. Spring and Quarkus expose it as pgcache.single-flight-concurrency. Quarkus synchronous get(...) uses this path; getAsync(...) stays non-blocking and does not hold an advisory lock while waiting for its Uni, so it does not promise cross-JVM single-flight.

Serialization. Spring reuses a unique application ObjectMapper; Quarkus reuses an injectable mapper when one exists (for example from quarkus-jackson). Core users can pass .objectMapper(mapper). Otherwise PgCache uses a plain ObjectMapper, so Java time, optional, and application-specific modules are not registered automatically.

Key encoding. Core keys are strings. The Spring and Quarkus adapters scope an object key by cache name and persist its toString() value. Distinct objects with the same string form therefore collide, and mutable/default identity-based string forms are not safe across restarts. Prefer stable unique strings; for Spring annotations, use key = ... or a custom KeyGenerator that returns one. Quarkus invalidateIf receives the persisted string representation because original Java key objects are not stored.

Framework compatibility. CI verifies the Java 11 baseline on JDK 11/17/21, Spring Boot 2.6.13 and 3.2.12, and Quarkus 3.6.4. These are explicit compatibility anchors, not a promise for every framework release. Spring Boot 4 is not currently supported.

Defaults

Setting Core Spring Quarkus
Table pgcache_store pg_cache pgcache_store
Default TTL permanent 1 hour permanent
Allow null false true true
Auto-create/migrate true true true
Background cleanup off off on, every 30 minutes
Single-flight concurrency 4 4 4

These differences are retained for compatibility. Set the values explicitly when the same behavior across modules matters. Expired rows never count as hits; background cleanup only controls when their physical rows are deleted.

Security note on value_type. The recorded type decides which class is instantiated on read, and Jackson may call that class's constructor and setters. PgCache does not enable Jackson default typing; if a supplied framework/application mapper enables it, that mapper's security policy applies. Anyone who can write to the cache table can choose the recorded target class, so protect it with the same trust boundary as application data.

Migration

From 1.9.x to 1.10.0

The cache is emptied once on upgrade. Entries written before 1.10.0 carry no type information, so no read path can deserialize them into the caller's type; they are deleted when the schema is initialized. Expect a cold cache on first start.

Schema changes are applied automatically (value_type, plus ttl_policy/last_accessed on tables older than 1.2.0). Requires PostgreSQL 9.6+. Rolling back to 1.9.2 is safe — the added columns are nullable and the JSONB format is unchanged.

New APIs are additive: getAsStored, getOrComputeAsStored, singleFlightConcurrency. NullValueMarker.isMarker is deprecated.

Older upgrades and manual migration SQL are documented in CHANGELOG.md.

License

MIT License - see LICENSE for details.

About

Simple and reliable PostgreSQL-backed caching solution for Spring-based applications

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages