diff --git a/.github/actions/run-gradle/action.yml b/.github/actions/run-gradle/action.yml index 00ed95801d..b9f8db6d00 100644 --- a/.github/actions/run-gradle/action.yml +++ b/.github/actions/run-gradle/action.yml @@ -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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a0618b4141..064f3b5b80 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -46,7 +46,7 @@ updates: patterns: - "*" - package-ecosystem: gradle - directory: examples/write-behind-rxjava + directory: examples/hibernate schedule: interval: monthly groups: @@ -55,7 +55,7 @@ updates: patterns: - "*" - package-ecosystem: gradle - directory: examples/hibernate + directory: examples/indexable schedule: interval: monthly groups: @@ -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: + - "*" diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index f9c630ddc6..2e361beb9c 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -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 diff --git a/.java-version b/.java-version deleted file mode 100644 index 03b6389f32..0000000000 --- a/.java-version +++ /dev/null @@ -1 +0,0 @@ -17.0 diff --git a/.tool-versions b/.tool-versions deleted file mode 100644 index 91abe9545c..0000000000 --- a/.tool-versions +++ /dev/null @@ -1 +0,0 @@ -java 17 diff --git a/examples/coalescing-bulkloader-reactor/build.gradle.kts b/examples/coalescing-bulkloader-reactor/build.gradle.kts index a816509fc0..3430c9c8d6 100644 --- a/examples/coalescing-bulkloader-reactor/build.gradle.kts +++ b/examples/coalescing-bulkloader-reactor/build.gradle.kts @@ -17,11 +17,4 @@ testing.suites { } } -java.toolchain.languageVersion = JavaLanguageVersion.of( - System.getenv("JAVA_VERSION")?.toIntOrNull() ?: 17) - -tasks.withType().configureEach { - javaCompiler = javaToolchains.compilerFor { - languageVersion = java.toolchain.languageVersion - } -} +java.toolchain.languageVersion = JavaLanguageVersion.of(21) diff --git a/examples/coalescing-bulkloader-reactor/gradle/gradle-daemon-jvm.properties b/examples/coalescing-bulkloader-reactor/gradle/gradle-daemon-jvm.properties index 858feb7e38..63e5bbdf48 100644 --- a/examples/coalescing-bulkloader-reactor/gradle/gradle-daemon-jvm.properties +++ b/examples/coalescing-bulkloader-reactor/gradle/gradle-daemon-jvm.properties @@ -1,2 +1,2 @@ #This file is generated by updateDaemonJvm -toolchainVersion=17 +toolchainVersion=21 diff --git a/examples/coalescing-bulkloader-reactor/gradle/libs.versions.toml b/examples/coalescing-bulkloader-reactor/gradle/libs.versions.toml index 850f3750e9..cb45473fce 100644 --- a/examples/coalescing-bulkloader-reactor/gradle/libs.versions.toml +++ b/examples/coalescing-bulkloader-reactor/gradle/libs.versions.toml @@ -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" diff --git a/examples/hibernate/build.gradle.kts b/examples/hibernate/build.gradle.kts index 7708b7a02f..85b2448d36 100644 --- a/examples/hibernate/build.gradle.kts +++ b/examples/hibernate/build.gradle.kts @@ -25,15 +25,6 @@ testing.suites { } } -java.toolchain.languageVersion = JavaLanguageVersion.of( - System.getenv("JAVA_VERSION")?.toIntOrNull() ?: 17) - -tasks.withType().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") diff --git a/examples/hibernate/gradle/gradle-daemon-jvm.properties b/examples/hibernate/gradle/gradle-daemon-jvm.properties index 858feb7e38..63e5bbdf48 100644 --- a/examples/hibernate/gradle/gradle-daemon-jvm.properties +++ b/examples/hibernate/gradle/gradle-daemon-jvm.properties @@ -1,2 +1,2 @@ #This file is generated by updateDaemonJvm -toolchainVersion=17 +toolchainVersion=21 diff --git a/examples/indexable/README.md b/examples/indexable/README.md new file mode 100644 index 0000000000..f5815d2f21 --- /dev/null +++ b/examples/indexable/README.md @@ -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() + .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("john.doe@example.com")); +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 diff --git a/examples/indexable/build.gradle.kts b/examples/indexable/build.gradle.kts new file mode 100644 index 0000000000..4e67b3e05a --- /dev/null +++ b/examples/indexable/build.gradle.kts @@ -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() + } +} diff --git a/examples/indexable/gradle/gradle-daemon-jvm.properties b/examples/indexable/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000000..63e5bbdf48 --- /dev/null +++ b/examples/indexable/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,2 @@ +#This file is generated by updateDaemonJvm +toolchainVersion=21 diff --git a/examples/indexable/gradle/libs.versions.toml b/examples/indexable/gradle/libs.versions.toml new file mode 100644 index 0000000000..dac0a28fac --- /dev/null +++ b/examples/indexable/gradle/libs.versions.toml @@ -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" } diff --git a/examples/indexable/gradle/wrapper/gradle-wrapper.jar b/examples/indexable/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..e6441136f3 Binary files /dev/null and b/examples/indexable/gradle/wrapper/gradle-wrapper.jar differ diff --git a/examples/indexable/gradle/wrapper/gradle-wrapper.properties b/examples/indexable/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..c9e6332d32 --- /dev/null +++ b/examples/indexable/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/examples/indexable/gradlew b/examples/indexable/gradlew new file mode 100755 index 0000000000..b740cf1339 --- /dev/null +++ b/examples/indexable/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/indexable/gradlew.bat b/examples/indexable/gradlew.bat new file mode 100644 index 0000000000..25da30dbde --- /dev/null +++ b/examples/indexable/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/indexable/settings.gradle.kts b/examples/indexable/settings.gradle.kts new file mode 100644 index 0000000000..50df97547a --- /dev/null +++ b/examples/indexable/settings.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id("com.gradle.develocity") version "3.17.4" + id("com.gradle.common-custom-user-data-gradle-plugin") version "2.0.1" + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} + +apply(from = "../../gradle/develocity.gradle") + +rootProject.name = "indexable" diff --git a/examples/indexable/src/main/java/com/github/benmanes/caffeine/examples/indexable/IndexedCache.java b/examples/indexable/src/main/java/com/github/benmanes/caffeine/examples/indexable/IndexedCache.java new file mode 100644 index 0000000000..0494f8d238 --- /dev/null +++ b/examples/indexable/src/main/java/com/github/benmanes/caffeine/examples/indexable/IndexedCache.java @@ -0,0 +1,180 @@ +/* + * Copyright 2024 Ben Manes. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.benmanes.caffeine.examples.indexable; + +import static com.google.common.collect.ImmutableSet.toImmutableSet; +import static java.util.Objects.requireNonNull; + +import java.time.Duration; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.Lock; +import java.util.function.Function; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Ticker; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Sets; +import com.google.common.util.concurrent.Striped; + +/** + * A cache abstraction that allows the entry to looked up by alternative keys. This approach mirrors + * a database table where a row is stored by its primary key, it contains all of the columns that + * identify it, and the unique indexes are additional mappings defined by the column mappings. This + * class similarly stores the in the value once in the cache by its primary key and maintains a + * secondary mapping for lookups by using indexing functions to derive the keys. + * + * @author ben.manes@gmail.com (Ben Manes) + */ +public final class IndexedCache { + final ConcurrentMap> indexes; + final Function mappingFunction; + final Function> indexer; + final Striped locks; + final Cache store; + + private IndexedCache(Caffeine cacheBuilder, Function mappingFunction, + Function primary, Set> secondaries) { + this.locks = Striped.lock(1_000); + this.mappingFunction = mappingFunction; + this.indexes = new ConcurrentHashMap<>(); + this.store = cacheBuilder + .evictionListener((key, value, cause) -> + indexes.keySet().removeAll(indexes.get(key).allKeys())) + .build(); + this.indexer = value -> new Index<>(primary.apply(value), + secondaries.stream().map(indexer -> indexer.apply(value)).collect(toImmutableSet())); + } + + /** Returns the value associated with the key or {@code null} if not found. */ + public V getIfPresent(K key) { + var index = indexes.get(key); + return (index == null) ? null : store.getIfPresent(index.primaryKey()); + } + + /** + * Returns the value associated with the key, obtaining that value from the + * {@code mappingFunction} if necessary. The entire method invocation is performed atomically, so + * the function is applied at most once per key. As the value may be looked up by alternative + * keys, those function invocations may be executed in parallel and will replace any existing + * mappings when completed. + */ + public V get(K key) { + var value = getIfPresent(key); + if (value != null) { + return value; + } + + var lock = locks.get(key); + lock.lock(); + try { + value = getIfPresent(key); + if (value != null) { + return value; + } + + value = mappingFunction.apply(key); + if (value == null) { + return null; + } + + put(value); + return value; + } finally { + lock.unlock(); + } + } + + /** Associates the {@code value} with its keys, replacing the old value and keys if present. */ + public V put(V value) { + requireNonNull(value); + var index = indexer.apply(value); + return store.asMap().compute(index.primaryKey(), (key, oldValue) -> { + if (oldValue != null) { + indexes.keySet().removeAll(Sets.difference( + indexes.get(index.primaryKey()).allKeys(), index.allKeys())); + } + for (var indexKey : index.allKeys()) { + indexes.put(indexKey, index); + } + return value; + }); + } + + /** Discards any cached value and its keys. */ + public void invalidate(K key) { + var index = indexes.get(key); + if (index == null) { + return; + } + + store.asMap().computeIfPresent(index.primaryKey(), (k, v) -> { + indexes.keySet().removeAll(index.allKeys()); + return null; + }); + } + + private record Index(K primaryKey, Set secondaryKeys) { + public Set allKeys() { + return Sets.union(Set.of(primaryKey), secondaryKeys); + } + } + + /** This builder could be extended to support most cache options, e.g. excluding for weak keys. */ + public static final class Builder { + final Caffeine cacheBuilder; + final ImmutableSet.Builder> secondaries; + + Function primary; + + public Builder() { + cacheBuilder = Caffeine.newBuilder(); + secondaries = ImmutableSet.builder(); + } + + /** See {@link Caffeine#expireAfterWrite(Duration)}. */ + public Builder expireAfterWrite(Duration duration) { + cacheBuilder.expireAfterWrite(duration); + return this; + } + + /** See {@link Caffeine#ticker(Duration)}. */ + public Builder ticker(Ticker ticker) { + cacheBuilder.ticker(ticker); + return this; + } + + /** Adds the functions to extract the indexable keys. */ + public Builder primaryKey(Function primary) { + this.primary = requireNonNull(primary); + return this; + } + + /** Adds the functions to extract the indexable keys. */ + public Builder addSecondaryKey(Function secondary) { + secondaries.add(requireNonNull(secondary)); + return this; + } + + public IndexedCache build(Function mappingFunction) { + requireNonNull(primary); + requireNonNull(mappingFunction); + return new IndexedCache(cacheBuilder, mappingFunction, primary, secondaries.build()); + } + } +} diff --git a/examples/indexable/src/test/java/com/github/benmanes/caffeine/examples/indexable/IndexedCacheTest.java b/examples/indexable/src/test/java/com/github/benmanes/caffeine/examples/indexable/IndexedCacheTest.java new file mode 100644 index 0000000000..2a26b3e08a --- /dev/null +++ b/examples/indexable/src/test/java/com/github/benmanes/caffeine/examples/indexable/IndexedCacheTest.java @@ -0,0 +1,91 @@ +/* + * Copyright 2024 Ben Manes. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.benmanes.caffeine.examples.indexable; + +import static com.google.common.truth.Truth.assertThat; + +import java.time.Duration; +import java.util.Set; +import java.util.function.Predicate; + +import org.junit.jupiter.api.Test; + +import com.github.benmanes.caffeine.examples.indexable.IndexedCacheTest.UserKey.UserById; +import com.github.benmanes.caffeine.examples.indexable.IndexedCacheTest.UserKey.UserByLogin; +import com.github.benmanes.caffeine.examples.indexable.IndexedCacheTest.UserKey.UserByPhone; +import com.google.common.testing.FakeTicker; + +/** + * @author ben.manes@gmail.com (Ben Manes) + */ +public final class IndexedCacheTest { + private final Set users = Set.of( + new User(1, "john.doe", "+1 (555) 555-5555"), + new User(2, "jane.doe", "+1 (777) 777-7777")); + + @Test + public void basicUsage() { + var ticker = new FakeTicker(); + var cache = new IndexedCache.Builder() + .addSecondaryKey(user -> new UserByLogin(user.login())) + .addSecondaryKey(user -> new UserByPhone(user.phone())) + .primaryKey(user -> new UserById(user.id())) + .expireAfterWrite(Duration.ofMinutes(1)) + .ticker(ticker::read) + .build(this::findUser); + + var johnById = cache.get(new UserById(1)); + assertThat(johnById).isNotNull(); + + var johnByLogin = cache.get(new UserByLogin("john.doe")); + assertThat(johnByLogin).isSameInstanceAs(johnById); + + var janeByLogin = cache.get(new UserByLogin("jane.doe")); + assertThat(janeByLogin).isNotSameInstanceAs(johnById); + + assertThat(cache.store.asMap()).hasSize(2); + assertThat(cache.indexes).hasSize(6); + + cache.invalidate(new UserByPhone("+1 (555) 555-5555")); + assertThat(cache.getIfPresent(new UserByLogin("john.doe"))).isNull(); + + assertThat(cache.store.asMap()).hasSize(1); + assertThat(cache.indexes).hasSize(3); + + ticker.advance(Duration.ofHours(1)); + cache.store.cleanUp(); + + assertThat(cache.store.asMap()).isEmpty(); + assertThat(cache.indexes).isEmpty(); + } + + /** Returns the user found in the system of record. */ + private User findUser(UserKey key) { + Predicate predicate = switch (key) { + case UserById(int id) -> user -> user.id() == id; + case UserByLogin(var login) -> user -> user.login().equals(login); + case UserByPhone(var phone) -> user -> user.phone().equals(phone); + }; + return users.stream().filter(predicate).findAny().orElse(null); + } + + sealed interface UserKey permits UserById, UserByLogin, UserByPhone { + record UserByLogin(String login) implements UserKey {} + record UserByPhone(String phone) implements UserKey {} + record UserById(int id) implements UserKey {} + } + record User(int id, String login, String phone) {} +} diff --git a/examples/resilience-failsafe/build.gradle.kts b/examples/resilience-failsafe/build.gradle.kts index ddf8b54df4..d73d73b2b3 100644 --- a/examples/resilience-failsafe/build.gradle.kts +++ b/examples/resilience-failsafe/build.gradle.kts @@ -16,12 +16,3 @@ testing.suites { useJUnitJupiter() } } - -java.toolchain.languageVersion = JavaLanguageVersion.of( - System.getenv("JAVA_VERSION")?.toIntOrNull() ?: 17) - -tasks.withType().configureEach { - javaCompiler = javaToolchains.compilerFor { - languageVersion = java.toolchain.languageVersion - } -} diff --git a/examples/resilience-failsafe/gradle/gradle-daemon-jvm.properties b/examples/resilience-failsafe/gradle/gradle-daemon-jvm.properties index 858feb7e38..63e5bbdf48 100644 --- a/examples/resilience-failsafe/gradle/gradle-daemon-jvm.properties +++ b/examples/resilience-failsafe/gradle/gradle-daemon-jvm.properties @@ -1,2 +1,2 @@ #This file is generated by updateDaemonJvm -toolchainVersion=17 +toolchainVersion=21 diff --git a/examples/resilience-failsafe/settings.gradle.kts b/examples/resilience-failsafe/settings.gradle.kts index 5fa0e06224..5e04180ef8 100644 --- a/examples/resilience-failsafe/settings.gradle.kts +++ b/examples/resilience-failsafe/settings.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("com.gradle.develocity") version "3.17.4" + id("com.gradle.develocity") version "3.17.5" id("com.gradle.common-custom-user-data-gradle-plugin") version "2.0.1" id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" } diff --git a/examples/write-behind-rxjava/build.gradle.kts b/examples/write-behind-rxjava/build.gradle.kts index facadf7abb..4d63ae2538 100644 --- a/examples/write-behind-rxjava/build.gradle.kts +++ b/examples/write-behind-rxjava/build.gradle.kts @@ -16,12 +16,3 @@ testing.suites { useJUnitJupiter() } } - -java.toolchain.languageVersion = JavaLanguageVersion.of( - System.getenv("JAVA_VERSION")?.toIntOrNull() ?: 17) - -tasks.withType().configureEach { - javaCompiler = javaToolchains.compilerFor { - languageVersion = java.toolchain.languageVersion - } -} diff --git a/examples/write-behind-rxjava/gradle/gradle-daemon-jvm.properties b/examples/write-behind-rxjava/gradle/gradle-daemon-jvm.properties index 858feb7e38..63e5bbdf48 100644 --- a/examples/write-behind-rxjava/gradle/gradle-daemon-jvm.properties +++ b/examples/write-behind-rxjava/gradle/gradle-daemon-jvm.properties @@ -1,2 +1,2 @@ #This file is generated by updateDaemonJvm -toolchainVersion=17 +toolchainVersion=21 diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties index 858feb7e38..63e5bbdf48 100644 --- a/gradle/gradle-daemon-jvm.properties +++ b/gradle/gradle-daemon-jvm.properties @@ -1,2 +1,2 @@ #This file is generated by updateDaemonJvm -toolchainVersion=17 +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6d19ace364..44fb3b5b94 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,7 +29,7 @@ expiring-map = "0.5.11" fast-filter = "1.0.2" fastutil = "8.5.13" felix-framework = "7.0.5" -felix-scr = "2.2.10" +felix-scr = "2.2.12" findsecbugs = "1.13.0" flip-tables = "1.1.1" forbidden-apis = "3.7" @@ -53,7 +53,7 @@ jcache = "1.1.1" jcommander = "1.82" jctools = "4.0.5" jfreechart = "1.5.4" -jgit = "6.9.0.202403050737-r" +jgit = "6.10.0.202406032230-r" jmh-core = "1.37" jmh-plugin = "0.7.2" jmh-report = "0.9.6" @@ -86,7 +86,7 @@ snakeyaml = "2.2" sonarqube = "5.0.0.4638" spotbugs-contrib = "7.6.4" spotbugs-core = "4.8.5" -spotbugs-plugin = "6.0.15" +spotbugs-plugin = "6.0.16" stream = "2.9.8" tcache = "2.0.1" testng = "7.10.2" diff --git a/gradle/plugins/gradle/gradle-daemon-jvm.properties b/gradle/plugins/gradle/gradle-daemon-jvm.properties index 858feb7e38..63e5bbdf48 100644 --- a/gradle/plugins/gradle/gradle-daemon-jvm.properties +++ b/gradle/plugins/gradle/gradle-daemon-jvm.properties @@ -1,2 +1,2 @@ #This file is generated by updateDaemonJvm -toolchainVersion=17 +toolchainVersion=21 diff --git a/jitpack.yml b/jitpack.yml index f57d7fe8e2..b4da4048f1 100644 --- a/jitpack.yml +++ b/jitpack.yml @@ -1,5 +1,5 @@ before_install: - source "$HOME/.sdkman/bin/sdkman-init.sh" - sdk update - - sdk install java 17.0.10-zulu - - sdk use java 17.0.10-zulu + - sdk install java 21.0.3-zulu + - sdk use java 21.0.3-zulu