Skip to content

Conversation

@yhayase
Copy link

@yhayase yhayase commented Nov 3, 2025

Description

This PR fixes Cache::flexible() to work with AWS ElastiCache Serverless (Valkey) and similar environments that have cluster_enabled: 1 but provide a single endpoint.

Problem

AWS ElastiCache Serverless (Valkey) has a unique configuration:

  • Server-side: cluster_enabled: 1 (cluster mode enabled)
  • Connection: Single endpoint

This creates a problem where neither connection mode works:

phpredis driver

Single-node connection mode (default):

TypeError: array_map(): Argument #2 ($array) must be of type array, bool given
File: vendor/illuminate/redis/Connections/PhpRedisConnection.php:67
Redis last error: CROSSSLOT Keys in request don't hash to the same slot

The flexible() method stores two keys:

  • Value key: illuminate:cache:{key}
  • Timestamp key: illuminate:cache:flexible:created:{key}

These keys hash to different slots in Redis Cluster. Since the server has cluster mode enabled, MGET returns a CROSSSLOT error.

Cluster connection mode:

RedisClusterException: Error processing response from Redis node!

phpredis RedisCluster fails to process responses from Valkey Serverless (Valkey Serverless-specific issue).

predis driver

Single-node connection mode (default):

Same CROSSSLOT error as phpredis.

Cluster connection mode:

Predis\NotSupportedException: Cannot use 'MGET' with redis-cluster.

predis checks hash tags before executing MGET. Without consistent hash tags, it rejects the operation.

Solution

Add Redis hash tags to ensure all related keys are stored in the same slot:

$hashKey = substr(md5($key), 0, 4);  // 16-bit hash
$valueKey = "{{$hashKey}}:{$key}";
$createdKey = "{{$hashKey}}:illuminate:cache:flexible:created:{$key}";
$lockKey = "{{$hashKey}}:illuminate:cache:flexible:lock:{$key}";

How it works

  • Redis Cluster uses the content within {...} to determine the slot
  • All three keys share the same hash tag {$hashKey}, ensuring they're in the same slot
  • The 4-character MD5 hash (16 bits) provides sufficient uniqueness for slot distribution
  • The original $key is preserved outside the hash tag for uniqueness
  • Works with standalone Redis (hash tags are ignored)

Why this fixes the issue

For phpredis + default (single-node mode):

  • Keys now hash to the same slot
  • CROSSSLOT error no longer occurs
  • Works with Valkey Serverless ✅

For predis + default (single-node mode):

  • Keys now hash to the same slot
  • CROSSSLOT error no longer occurs ✅

For predis + clusters (cluster mode):

  • predis checks that all keys have the same hash tag {$hashKey}
  • Allows MGET to proceed since keys are in the same slot
  • Works with Valkey Serverless ✅

Testing

Verified on AWS ElastiCache Serverless (Valkey) with cluster_enabled: 1:

  • ✅ phpredis + default (single-node connection): SUCCESS (0.145s)
  • ✅ predis + default (single-node connection): SUCCESS (0.008s)
  • ✅ predis + clusters (cluster mode connection): SUCCESS (0.095s)

Complete reproduction case: https://github.com/yhayase/laravel-cache-flexible-valkey-serverless-issue

Compatibility

  • ✅ Backward compatible with standalone Redis (hash tags are ignored)
  • ✅ Works with phpredis and predis drivers
  • ✅ Works with all cache backends (hash tags are added to all drivers, but only meaningful for Redis)
  • ⚠️ Breaking change: Cache keys will change after upgrading
    • Affects all cache drivers (Redis, Memcached, Database, File, DynamoDB, etc.)
    • Existing flexible() cache entries will be missed on first access after upgrade
    • Callbacks will be executed to regenerate values
    • Old cache entries will be automatically deleted when their TTL expires
    • No manual cache clearing required, but temporary storage increase during TTL period

Related Issue

Fixes CROSSSLOT errors on AWS ElastiCache Serverless (Valkey) and similar environments where the server has cluster mode enabled but provides a single endpoint.

hayase_yasuhiro added 2 commits November 3, 2025 22:24
The flexible() method was generating keys that could hash to different
slots in Redis Cluster, causing CROSSSLOT errors. This fix adds Redis
hash tags to ensure all related keys (value, created timestamp, and lock)
are stored in the same slot.

Changes:
- Add a 4-character MD5 hash prefix as a hash tag for slot allocation
- Wrap hash in {} to ensure consistent slot placement in Redis Cluster
- Maintain original key structure for non-cluster Redis instances
- Works with both phpredis and predis drivers

This resolves issues with AWS ElastiCache Serverless (Valkey) and other
Redis Cluster environments while remaining backward compatible with
standalone Redis instances.
@yhayase yhayase changed the title Fix Cache::flexible() to work with Redis Cluster Fix Cache::flexible() to work with AWS ElastiCache Serverless (Valkey) Nov 3, 2025
@yhayase yhayase marked this pull request as draft November 3, 2025 14:00
@vadimonus
Copy link
Contributor

vadimonus commented Nov 3, 2025

@yhayase , your PR is backward incompatible, as will change all logic of setting and retreiving values from cache. It's very dangerous.

Also you do not need to use md5 hash for key, as redis already calculates slot with more quick crc16 hash (see https://redis.io/docs/latest/operate/oss_and_stack/management/scaling/#redis-cluster-data-sharding).

So, all you need are only changes in \Illuminate\Cache\Repository::flexible method.

"illuminate:cache:flexible:created:{$key}" -> "illuminate:cache:flexible:created:\{$key\}"
"illuminate:cache:flexible:lock:{$key}" -> "illuminate:cache:flexible:lock:\{$key\}"

Maybe it was just a mistake not to escape curly braces.

This will lead that slot for key 'xxx' and 'illuminate:cache:flexible:created:{xxx}' would be the same (see how redis calculate slots).

There are integration tests with Redis Cluster in .github/workflows/queues.yml. You may add run of tests/Integration/Cache/RepositoryTest.php there to show if it fails before fix and not fails after it.
You will need CACHE_STORE=redis and REDIS_CACHE_CONNECTION=default for tests with redis cluster

This adds a new configuration option 'flexible_cluster_mode' that enables
sequential operations in Cache::flexible() for Redis Cluster compatibility.

Implementation:
- When 'flexible_cluster_mode' is enabled, flexible() uses sequential get/put
  operations instead of bulk many()/putMany() operations
- This avoids CROSSSLOT errors in Redis Cluster environments where keys hash
  to different slots
- Default is false to maintain existing behavior

Configuration (config/cache.php):
```php
'stores' => [
    'redis' => [
        'driver' => 'redis',
        'flexible_cluster_mode' => env('CACHE_FLEXIBLE_CLUSTER_MODE', false),
    ],
],
```

Trade-offs:
- Sequential mode: 2 network round-trips instead of 1
- Performance impact is minimal since flexible() callback execution dominates
- This approach already exists for putMany() in cluster connections

All tests pass (75 tests, 302 assertions).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@yhayase yhayase force-pushed the fix-cache-flexible-redis-cluster branch from 9fb8741 to 1eadeb1 Compare November 5, 2025 02:48
Repository::flexible() references the flexible_cluster_mode config,
but CacheManager::repository() was using Arr::only($config, ['store']),
which prevented flexible_cluster_mode from being passed to Repository.

Changes:
- Modified Arr::only($config, ['store', 'flexible_cluster_mode'])
- This ensures flexible_cluster_mode config is properly passed to Repository

Test results:
- Local: 75 tests, 302 assertions all passed
- Valkey Serverless: 3/3 patterns all succeeded
  - phpredis + default: SUCCESS
  - predis + default: SUCCESS
  - predis + clusters: SUCCESS

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@yhayase yhayase force-pushed the fix-cache-flexible-redis-cluster branch from 60baaa4 to c798cde Compare November 5, 2025 04:15
@yhayase
Copy link
Author

yhayase commented Nov 5, 2025

@vadimonus Thank you so much for your detailed review and valuable suggestions! I really appreciate you taking the time to point out the backward compatibility issue and propose a simpler solution.

You're absolutely right that my original approach (adding hash tags to all keys) was backward incompatible and overly complex. I've been reconsidering the implementation based on your feedback.

Issue with the hash tag escape approach

I initially considered your suggestion to use escaped braces:

"illuminate:cache:flexible:created:{{$key}}"

This would produce a string like illuminate:cache:flexible:created:{mykey} when $key = "mykey", which looks correct for Redis hash tags.

However, I found a critical issue with this approach. When a user provides a key like "a}keyname", the result would be:

illuminate:cache:flexible:created:{a}keyname}

Redis Cluster would only use the content between the first { and } (i.e., a) for slot calculation. This means:

  • User key a}keyname → slot based on full string a}keyname
  • Created key illuminate:cache:flexible:created:{a}keyname} → slot based on a only

These would hash to different slots, causing the same CROSSSLOT error we're trying to fix.

Alternative approach: Configuration-based sequential operations

I've explored a different approach that maintains backward compatibility while fixing the Redis Cluster issue:

Add a flexible_cluster_mode configuration option that switches flexible() to use sequential operations instead of bulk operations (MGET/MSET):

Configuration (config/cache.php):

'stores' => [
    'redis' => [
        'driver' => 'redis',
        'flexible_cluster_mode' => true,
    ],
],

Implementation:

  • When flexible_cluster_mode is enabled, flexible() uses individual get()/put() calls instead of many()/putMany()
  • This avoids CROSSSLOT errors entirely since each operation targets a single key
  • Default is false, maintaining existing behavior
  • No key name changes, so fully backward compatible
  • Works consistently with Cache::get(), Cache::forget(), and Cache::tags()

Trade-offs:

  • 2 network round-trips instead of 1 (minimal performance impact since callback execution typically dominates)
  • Only needed for environments like AWS ElastiCache Serverless where the server has cluster mode enabled but provides a single endpoint
  • For proper Redis Cluster connections (predis clusters mode), this configuration is not needed as putMany() already handles it correctly

I've tested this approach successfully on AWS ElastiCache Serverless (Valkey) with all patterns passing.

I've already updated the branch to implement this new approach. I'll update the PR description once we reach a consensus on the direction.

What do you think about this approach? I'm open to any suggestions or alternative solutions you might have.

Thanks again for your guidance!

@vadimonus
Copy link
Contributor

@yhayase You give such a polite and detailed answer that I don't always understand whether I'm communicating with you or with a gpt bot.

Cache keys like a}keyname can appear only in rare specific cases. In such cases you should think about some sort of escaping of keys. They can containt { as well, leading to unpredictable behaviour in many cases. Such cases rare cases can be eliminated on application and not on framework side. Please note, Redis Cluster itself does not provide any way to escape curly braces, or other way to specify, what part of key to use as hash key is. So with any other framework or without it you will experience some difficulties if working with Redis Cluster and keys with curly braces.
In this situation, moving solution of such rare cases from framework to application level is good tradeoff between framework simplicity, backward compatibility and making it workable in most of cases.

If you choose use tho puts instead of one putMany, you do not need special config settings. It's enough to check something like $this->store->connection instanceof PhpRedisCluster. See similar branches in RedisBroadcaster class.

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.

2 participants