Skip to content

Commit

Permalink
add an example for an indexable cache to support alternative key lookups
Browse files Browse the repository at this point in the history
  • Loading branch information
ben-manes committed Jun 14, 2024
1 parent 76b62f4 commit cba35bd
Show file tree
Hide file tree
Showing 29 changed files with 790 additions and 53 deletions.
10 changes: 8 additions & 2 deletions .github/actions/run-gradle/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,18 @@ runs:
run: |
echo "JDK_CI=$JAVA_HOME" >> $GITHUB_ENV
echo "JDK_EA=${{ inputs.early-access == inputs.java }}" >> $GITHUB_ENV
- name: Set up JDK 17
- name: Read Gradle JDK toolchain version
id: read-jdk-version
shell: bash
run: |
toolchainVersion=$(grep -oP '(?<=^toolchainVersion=).*' gradle/gradle-daemon-jvm.properties)
echo "toolchainVersion=${toolchainVersion}" >> $GITHUB_ENV
- name: Set up JDK ${{ env.toolchainVersion }}
id: setup-gradle-jdk
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
if: inputs.java != 'GraalVM'
with:
java-version: 17
java-version: ${{ env.toolchainVersion }}
distribution: temurin
- name: Setup Gradle
id: setup-gradle
Expand Down
13 changes: 11 additions & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ updates:
patterns:
- "*"
- package-ecosystem: gradle
directory: examples/write-behind-rxjava
directory: examples/hibernate
schedule:
interval: monthly
groups:
Expand All @@ -55,7 +55,7 @@ updates:
patterns:
- "*"
- package-ecosystem: gradle
directory: examples/hibernate
directory: examples/indexable
schedule:
interval: monthly
groups:
Expand All @@ -81,3 +81,12 @@ updates:
applies-to: version-updates
patterns:
- "*"
- package-ecosystem: gradle
directory: examples/write-behind-rxjava
schedule:
interval: monthly
groups:
gradle-dependencies:
applies-to: version-updates
patterns:
- "*"
3 changes: 3 additions & 0 deletions .github/workflows/examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ jobs:
- name: Hibernate (jcache)
working-directory: examples/hibernate
run: ./gradlew build
- name: Indexable
working-directory: examples/indexable
run: ./gradlew build
- name: Resilience (failsafe)
working-directory: examples/resilience-failsafe
run: ./gradlew build
Expand Down
1 change: 0 additions & 1 deletion .java-version

This file was deleted.

1 change: 0 additions & 1 deletion .tool-versions

This file was deleted.

9 changes: 1 addition & 8 deletions examples/coalescing-bulkloader-reactor/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,4 @@ testing.suites {
}
}

java.toolchain.languageVersion = JavaLanguageVersion.of(
System.getenv("JAVA_VERSION")?.toIntOrNull() ?: 17)

tasks.withType<JavaCompile>().configureEach {
javaCompiler = javaToolchains.compilerFor {
languageVersion = java.toolchain.languageVersion
}
}
java.toolchain.languageVersion = JavaLanguageVersion.of(21)
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
#This file is generated by updateDaemonJvm
toolchainVersion=17
toolchainVersion=21
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[versions]
caffeine = "3.1.8"
junit = "5.11.0-M2"
reactor = "3.6.6"
reactor = "3.6.7"
truth = "1.4.2"
versions = "0.51.0"

Expand Down
9 changes: 0 additions & 9 deletions examples/hibernate/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,6 @@ testing.suites {
}
}

java.toolchain.languageVersion = JavaLanguageVersion.of(
System.getenv("JAVA_VERSION")?.toIntOrNull() ?: 17)

tasks.withType<JavaCompile>().configureEach {
javaCompiler = javaToolchains.compilerFor {
languageVersion = java.toolchain.languageVersion
}
}

eclipse.classpath.file.beforeMerged {
if (this is Classpath) {
val absolutePath = layout.buildDirectory.dir("generated/sources/annotationProcessor/java/main")
Expand Down
2 changes: 1 addition & 1 deletion examples/hibernate/gradle/gradle-daemon-jvm.properties
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
#This file is generated by updateDaemonJvm
toolchainVersion=17
toolchainVersion=21
82 changes: 82 additions & 0 deletions examples/indexable/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
In some scenarios, it can be useful to associate a single cache value with alternative keys. Similar
to a relational database, the cache acts as a table with a primary key, where the value is a row of
data, and unique hash indexes allow for fast retrieval using secondary keys. When the value is
updated or deleted, either explicitly or by eviction, the changes should be reflected in the key
associations. An _Indexable Cache_ provides a straightforward solution for achieving multiple unique
key lookups to a single value.

### A simple example
In the schema below, the application needs to find a user by the row id for direct queries, by the
username during login, and by the email during a password recovery flow.

```sql
CREATE TABLE user_info (
id bigserial primary key,
first_name varchar(255) NOT NULL,
last_name varchar(255) NOT NULL,
email varchar(255) NOT NULL,
username varchar(255) NOT NULL,
password_hash varchar(255) NOT NULL,
created_on timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
modified_on timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
);
CREATE UNIQUE INDEX user_info_email_idx ON user_info (email);
CREATE UNIQUE INDEX user_info_username_idx ON user_info (username);
```

Java's [Data-Oriented Programming][] approach represents each lookup key as a distinct type and uses
pattern matching when loading an entry on a cache miss.

```java
sealed interface UserKey permits UserById, UserByLogin, UserByEmail {
record UserByLogin(String login) implements UserKey {}
record UserByEmail(String email) implements UserKey {}
record UserById(long id) implements UserKey {}
}

private User findUser(UserKey key) {
Condition condition = switch (key) {
case UserById(var id) -> USER_INFO.ID.eq(id);
case UserByLogin(var login) -> USER_INFO.USERNAME.eq(login);
case UserByEmail(var email) -> USER_INFO.EMAIL.eq(email.toLowerCase());
};
return db.selectFrom(USER_INFO).where(condition).fetchOneInto(User.class);
}
```

The cache is constructed with functions to build the indexes, the data loader, and the bounding
constraints. The value can then be queried using the typed key.

```java
var cache = new IndexedCache.Builder<UserKey, User>()
.primaryKey(user -> new UserById(user.id()))
.addSecondaryKey(user -> new UserByLogin(user.login()))
.addSecondaryKey(user -> new UserByEmail(user.email()))
.expireAfterWrite(Duration.ofMinutes(5))
.maximumSize(10_000)
.build(this::findUser);

var userByEmail = cache.get(new UserByEmail("[email protected]"));
var userByLogin = cache.get(new UserByLogin("john.doe"));
assertThat(userByEmail).isSameInstanceAs(userByLogin);
```

### How it works
The sample [IndexedCache][] combines a key-value cache with an associated mapping from the
individual keys to the entry's complete set. Consistency is maintained by acquiring the write lock
through the cache using the primary key before updating the index. This prevents race conditions
when the entry is concurrently updated and evicted, which could otherwise lead to missing or
non-resident key associations in the index. On eviction, a listener discards the keys while holding
the cache's entry lock.

When a value is not found and must be loaded, an important performance optimization is to avoid a
cache stampede of redundant queries by performing that work once for all callers. This is
challenging when there are alternative lookup keys, as the cache is unaware of a canonical key to
lock against until the value is loaded. While using a single shared lock across all keys could solve
this, it would also penalize distinct entries by the slow loading time of preceding calls. A
[StripedLock][] provides a balanced solution by memoizing per key and using a last-write-wins policy
if the same value is loaded by concurrent calls using different keys.

[Data-Oriented Programming]: https://inside.java/2024/05/23/dop-v1-1-introduction
[IndexedCache]: src/main/java/com/github/benmanes/caffeine/examples/indexable/IndexedCache.java
[StripedLock]: https://guava.dev/releases/snapshot-jre/api/docs/com/google/common/util/concurrent/Striped.html
21 changes: 21 additions & 0 deletions examples/indexable/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
plugins {
`java-library`
alias(libs.plugins.versions)
}

dependencies {
implementation(libs.caffeine)
implementation(libs.guava)

testImplementation(libs.junit.jupiter)
testImplementation(libs.guava.testlib)
testImplementation(libs.truth)
}

java.toolchain.languageVersion = JavaLanguageVersion.of(21)

testing.suites {
val test by getting(JvmTestSuite::class) {
useJUnitJupiter()
}
}
2 changes: 2 additions & 0 deletions examples/indexable/gradle/gradle-daemon-jvm.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#This file is generated by updateDaemonJvm
toolchainVersion=21
16 changes: 16 additions & 0 deletions examples/indexable/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[versions]
caffeine = "3.1.8"
guava = "33.2.1-jre"
junit-jupiter = "5.11.0-M2"
truth = "1.4.2"
versions = "0.51.0"

[libraries]
caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine" }
guava = { module = "com.google.guava:guava", version.ref = "guava" }
guava-testlib = { module = "com.google.guava:guava-testlib", version.ref = "guava" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" }
truth = { module = "com.google.truth:truth", version.ref = "truth" }

[plugins]
versions = { id = "com.github.ben-manes.versions", version.ref = "versions" }
Binary file not shown.
7 changes: 7 additions & 0 deletions examples/indexable/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
validateDistributionUrl=true
zipStorePath=wrapper/dists
networkTimeout=10000
Loading

0 comments on commit cba35bd

Please sign in to comment.