Skip to content

test-lib: reach Redis through a cluster client with optional TLS - #2207

Open
yaroslavmokflmg wants to merge 4 commits into
mainfrom
feat/memorystore-test-client
Open

yaroslavmokflmg wants to merge 4 commits into
mainfrom
feat/memorystore-test-client

Conversation

@yaroslavmokflmg

@yaroslavmokflmg yaroslavmokflmg commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Why

The shared cluster is moving Redis from the in-cluster redis-cluster chart to Memorystore for
Redis Cluster. Two things break for this library:

  • the cluster is reached through one discovery endpoint instead of per-pod addresses, so
    walking a list of seeds and running a node-local SCAN no longer lands on the node that owns
    the slot;
  • the connection is TLS against a private CA, which plain new Jedis(host) cannot do.

How it works now

Redis.getResetToken uses JedisCluster. This is safe precisely because the keys are
hash-tagged (of:{<tenant>}:pwdreset:<token>): Jedis routes a cluster SCAN by the slot of the
MATCH pattern — and rejects a pattern without a tag — so the scan goes straight to the node that
owns every pwdreset key. The batch is then read with a single MGET instead of a GET per key,
which the shared slot makes legal.

TLS is opt-in. The CA arrives from the service config (test.redis.ca) or from
REDIS_SERVER_CA, and is loaded into a trust store of its own. With no CA published the client
connects in plain text exactly as before, so OSS installations on an in-cluster Redis are
untouched.

Contract kept

Failures still return null — key absent, cluster unreachable, wrong tenant prefix. Callers poll
this method, so a failure has to look like a miss. What changed is that the cause is now logged
with its stack; PKIX path building failed is otherwise invisible and reads as "the user never
received a reset mail".

A malformed CA fails fast with a named error rather than an empty trust store and an opaque
handshake failure later.

Files

file change
test/config/RedisConfig.java CA holder, REDIS_SERVER_CA fallback
test/data/redis/Redis.java cluster client, MGET batch read, optional TLS, logged failures

Verified against Jedis 7.2.0 sources

JedisCluster(Set<HostAndPort>, JedisClientConfig) exists, UnifiedJedis is AutoCloseable, and
ClusterCommandObjects.scan enforces the hash-tag rule this change relies on.

@yaroslavmokflmg yaroslavmokflmg self-assigned this Sep 15, 2026
@yaroslavmokflmg
yaroslavmokflmg marked this pull request as draft September 15, 2026 20:51
if (email.equals(jedis.get(key))) {
return key.split(":pwdreset:")[1];
}
try (JedisCluster cluster = new JedisCluster(RedisConfig.getClusterNodes(), clientConfig())) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🦩 🔴 [error/action_required] clientConfig() throws before the JedisCluster try-with-resources can close it, but the SSLSocketFactory/context objects it partially builds are never leaked; however new JedisCluster(...) is constructed with the config as an argument evaluated before entering try-with-resources, so if clientConfig()

In getResetToken, new JedisCluster(RedisConfig.getClusterNodes(), clientConfig()) is evaluated as the resource expression of the try-with-resources statement. If JedisCluster's constructor throws partway through establishing cluster connections (e.g. after opening a socket to node 0 but failing on node 1's handshake), the partially-constructed object is never assigned to the try-with-resources variable and therefore never has close() called on it, leaking any sockets it already opened. This is a real behavior change from the previous per-node Jedis loop, where each Jedis instance was independently opened and closed. Consider constructing the JedisCluster in a preceding statement and only entering the try block once construction succeeds, or accept the leak risk is bounded since JVM/OS will eventually reclaim the sockets on GC/process exit.

Evidence
        try (JedisCluster cluster = new JedisCluster(RedisConfig.getClusterNodes(), clientConfig())) {
🤖 Prompt for AI agents
In openframe-test-service-core/src/main/java/com/openframe/test/data/redis/Redis.java around line 34, address this code-review finding: clientConfig() throws before the JedisCluster try-with-resources can close it, but the SSLSocketFactory/context objects it partially builds are never leaked; however new JedisCluster(...) is constructed with the config as an argument evaluated before entering try-with-resources, so if clientConfig().
In getResetToken, `new JedisCluster(RedisConfig.getClusterNodes(), clientConfig())` is evaluated as the resource expression of the try-with-resources statement. If JedisCluster's constructor throws partway through establishing cluster connections (e.g. after opening a socket to node 0 but failing on node 1's handshake), the partially-constructed object is never assigned to the try-with-resources variable and therefore never has close() called on it, leaking any sockets it already opened. This is a real behavior change from the previous per-node Jedis loop, where each Jedis instance was independently opened and closed. Consider constructing the JedisCluster in a preceding statement and only entering the try block once construction succeeds, or accept the leak risk is bounded since JVM/OS will eventually reclaim the sockets on GC/process exit.
The flagged code:
```
        try (JedisCluster cluster = new JedisCluster(RedisConfig.getClusterNodes(), clientConfig())) {
```
Make the minimal change that resolves the finding; do not refactor unrelated code.

confidence: 35 — react 👍/👎 to teach the reviewer

@yaroslavmokflmg
yaroslavmokflmg force-pushed the feat/memorystore-test-client branch from e547674 to 4c62a4f Compare September 15, 2026 21:08
Memorystore publishes one discovery endpoint instead of per-pod addresses, so probing seed
nodes one at a time no longer reaches the node that owns the slot. Every pwdreset key carries
the same hash tag, and Jedis routes a cluster SCAN by the slot of the MATCH pattern, so a
cluster client lands on that node directly; the batch is then read with one MGET rather than a
GET per key.

TLS is enabled only when a CA is published - from the service config or REDIS_SERVER_CA - and
the CA is loaded into a trust store of its own, because a managed Redis signs with a private CA
the JVM has never seen. With no CA the client connects in plain text exactly as before, so OSS
installations running an in-cluster Redis are untouched.

Failures still return null, which is the contract callers poll against, but the cause is now
logged with its stack: a TLS or routing mistake used to be indistinguishable from a miss.
Auth and TLS are independent, so build the client config incrementally
instead of branching on the CA alone.
Dev runs IAM auth, where the credential is a short-lived token the client
has to fetch itself. An environment variable cannot carry that, so the
TLS support stays and the token wiring goes.
The cluster hands out an access token that expires within the hour, so the
credentials go in through a Supplier that Jedis calls per connection rather
than a string resolved once. Identity comes from Workload Identity, so a pod
needs no key material.

Opt-in, like TLS: with REDIS_IAM_AUTH unset no credentials are sent and an
in-cluster Redis is reached exactly as before.
@yaroslavmokflmg
yaroslavmokflmg force-pushed the feat/memorystore-test-client branch from 8e39059 to c534ba5 Compare September 17, 2026 12:08
@yaroslavmokflmg
yaroslavmokflmg marked this pull request as ready for review September 17, 2026 12:08
@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

🦩 Flamingo Code Review

2 finding(s) — 0 action required · 2 recommended · 0 informational

Mode: advisory · Rules cited: OFJAVA-018 · 1 defect(s) outside any rule

Inline comments: 2 new


Need another pass? Commits pushed after this review are not reviewed automatically.

  • Review the new commits — the commits added since this review
  • Review the whole diff again — ignoring what was already reviewed

Prefer typing? Comment @flamingo-review, or @flamingo-review full. To review every push on this pull request, add the flamingo-review-always label.

React 👍/👎 on inline comments to teach the reviewer.

Started 2026-09-17 12:08 UTC · updated 2026-09-17 12:08 UTC · workflow run

Comment on lines +45 to 66
try (JedisCluster cluster = new JedisCluster(RedisConfig.getClusterNodes(), clientConfig())) {
ScanParams scanParams = new ScanParams().match(pattern).count(100);
String cursor = ScanParams.SCAN_POINTER_START;
do {
ScanResult<String> scanResult = cluster.scan(cursor, scanParams);
List<String> keys = scanResult.getResult();
if (!keys.isEmpty()) {
// One slot for the whole batch, so this is a single round trip rather than a GET per key.
List<String> emails = cluster.mget(keys.toArray(new String[0]));
for (int i = 0; i < keys.size(); i++) {
if (email.equals(emails.get(i))) {
return keys.get(i).split(":pwdreset:")[1];
}
}
cursor = scanResult.getCursor();
} while (!cursor.equals(ScanParams.SCAN_POINTER_START));
} catch (Exception e) {
// Node unreachable or does not own the slot — try the next seed.
}
}
cursor = scanResult.getCursor();
} while (!cursor.equals(ScanParams.SCAN_POINTER_START));
} catch (Exception e) {
log.warn("Reading the password-reset token from Redis failed", e);
}
return null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🦩 🟠 [warn/recommended] OFJAVA-018 Broad catch(Exception e) around getResetToken swallows and reclassifies all failures as a null miss

The rewritten getResetToken wraps the entire cluster connection, SCAN and MGET pipeline in a single catch(Exception e) that logs at WARN and returns null. This means genuine bugs (e.g. IllegalStateException thrown by iamCredentials() or sslSocketFactory() for misconfiguration) are logged identically to a transient 'key not found' condition, and the caller (which polls this method) cannot distinguish 'not yet available' from 'misconfigured/broken'. Per OFJAVA-018/016, catching should be limited to what can be meaningfully handled (or at minimum the config-time exceptions from clientConfig() should be treated differently from scan-time exceptions), otherwise a persistent misconfiguration (e.g. bad CA PEM) manifests only as a poll timeout with a buried WARN log rather than a clear failure.

Evidence
        try (JedisCluster cluster = new JedisCluster(RedisConfig.getClusterNodes(), clientConfig())) {
            ScanParams scanParams = new ScanParams().match(pattern).count(100);
            String cursor = ScanParams.SCAN_POINTER_START;
            do {
                ScanResult<String> scanResult = cluster.scan(cursor, scanParams);
                List<String> keys = scanResult.getResult();
                if (!keys.isEmpty()) {
                    // One slot for the whole batch, so this is a single round trip rather than a GET per key.
                    List<String> emails = cluster.mget(keys.toArray(new String[0]));
                    for (int i = 0; i < keys.size(); i++) {
                        if (email.equals(emails.get(i))) {
                            return keys.get(i).split(":pwdreset:")[1];
🤖 Prompt for AI agents
In openframe-test-service-core/src/main/java/com/openframe/test/data/redis/Redis.java around lines 45-66, address this code-review finding: Broad catch(Exception e) around getResetToken swallows and reclassifies all failures as a null miss.
The rewritten getResetToken wraps the entire cluster connection, SCAN and MGET pipeline in a single catch(Exception e) that logs at WARN and returns null. This means genuine bugs (e.g. IllegalStateException thrown by iamCredentials() or sslSocketFactory() for misconfiguration) are logged identically to a transient 'key not found' condition, and the caller (which polls this method) cannot distinguish 'not yet available' from 'misconfigured/broken'. Per OFJAVA-018/016, catching should be limited to what can be meaningfully handled (or at minimum the config-time exceptions from clientConfig() should be treated differently from scan-time exceptions), otherwise a persistent misconfiguration (e.g. bad CA PEM) manifests only as a poll timeout with a buried WARN log rather than a clear failure.
The flagged code:
```
        try (JedisCluster cluster = new JedisCluster(RedisConfig.getClusterNodes(), clientConfig())) {
            ScanParams scanParams = new ScanParams().match(pattern).count(100);
            String cursor = ScanParams.SCAN_POINTER_START;
            do {
                ScanResult<String> scanResult = cluster.scan(cursor, scanParams);
                List<String> keys = scanResult.getResult();
                if (!keys.isEmpty()) {
                    // One slot for the whole batch, so this is a single round trip rather than a GET per key.
                    List<String> emails = cluster.mget(keys.toArray(new String[0]));
                    for (int i = 0; i < keys.size(); i++) {
                        if (email.equals(emails.get(i))) {
                            return keys.get(i).split(":pwdreset:")[1];
                        }
                    }
                }
                cursor = scanResult.getCursor();
            } while (!cursor.equals(ScanParams.SCAN_POINTER_START));
        } catch (Exception e) {
            log.warn("Reading the password-reset token from Redis failed", e);
        }
        return null;
    }
```
Make the minimal change that resolves the finding; do not refactor unrelated code.

confidence: 45 — react 👍/👎 to teach the reviewer

Comment on lines +96 to +105
private static RedisCredentials iamCredentials() {
try {
GoogleCredentials credentials = GoogleCredentials.getApplicationDefault()
.createScoped("https://www.googleapis.com/auth/cloud-platform");
credentials.refreshIfExpired();
return new DefaultRedisCredentials(null, credentials.getAccessToken().getTokenValue());
} catch (IOException e) {
throw new IllegalStateException("Could not obtain an access token for Redis IAM auth", e);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🦩 🟠 [warn/recommended] IAM access-token refresh discards refreshed credential state on every call, defeating token caching

iamCredentials() calls GoogleCredentials.getApplicationDefault() fresh on every invocation (which happens once per new JedisCluster, i.e. potentially many times when getResetToken is polled in a loop by a caller). GoogleCredentials.getApplicationDefault() performs metadata-server/ADC discovery each time rather than reusing a cached credentials object, and refreshIfExpired() on a brand-new instance will nearly always trigger a full token fetch since a fresh instance has no cached token. This turns every poll iteration into an extra call to the GCE metadata server or STS endpoint, which is wasteful and can hit rate limits under a tight polling loop from a test harness. The credentials object should be created once (e.g. cached in a static field) and only refreshIfExpired() called per connection.

Evidence
    private static RedisCredentials iamCredentials() {
        try {
            GoogleCredentials credentials = GoogleCredentials.getApplicationDefault()
                    .createScoped("https://www.googleapis.com/auth/cloud-platform");
            credentials.refreshIfExpired();
            return new DefaultRedisCredentials(null, credentials.getAccessToken().getTokenValue());
        } catch (IOException e) {
            throw new IllegalStateException("Could not obtain an access token for Redis IAM auth", e);
        }
    }
🤖 Prompt for AI agents
In openframe-test-service-core/src/main/java/com/openframe/test/data/redis/Redis.java around lines 96-105, address this code-review finding: IAM access-token refresh discards refreshed credential state on every call, defeating token caching.
iamCredentials() calls GoogleCredentials.getApplicationDefault() fresh on every invocation (which happens once per new JedisCluster, i.e. potentially many times when getResetToken is polled in a loop by a caller). GoogleCredentials.getApplicationDefault() performs metadata-server/ADC discovery each time rather than reusing a cached credentials object, and refreshIfExpired() on a brand-new instance will nearly always trigger a full token fetch since a fresh instance has no cached token. This turns every poll iteration into an extra call to the GCE metadata server or STS endpoint, which is wasteful and can hit rate limits under a tight polling loop from a test harness. The credentials object should be created once (e.g. cached in a static field) and only refreshIfExpired() called per connection.
The flagged code:
```
    private static RedisCredentials iamCredentials() {
        try {
            GoogleCredentials credentials = GoogleCredentials.getApplicationDefault()
                    .createScoped("https://www.googleapis.com/auth/cloud-platform");
            credentials.refreshIfExpired();
            return new DefaultRedisCredentials(null, credentials.getAccessToken().getTokenValue());
        } catch (IOException e) {
            throw new IllegalStateException("Could not obtain an access token for Redis IAM auth", e);
        }
    }
```
Make the minimal change that resolves the finding; do not refactor unrelated code.

confidence: 40 — react 👍/👎 to teach the reviewer

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant