Skip to content

Conversation

@binaryfire
Copy link
Contributor

@binaryfire binaryfire commented Dec 13, 2025

This PR is a comprehensive architectural overhaul of Hypervel's Redis cache driver. It transitions from a monolithic RedisStore to a modular, operation-based architecture while fixing long-standing issues and introducing an optional union-based any tagging mode for Redis 8.0+.

Note
This is a driver refactor, not just adding new features. Existing users using the default all tag mode will benefit from improvements to the existing implementation, regardless of whether they opt into the new any tagging mode.


Background: Problems Being Solved

Problem 1: Memory leak in tag Implementation

The current tag implementation uses sorted sets with TTL timestamps as scores:

{prefix}:tag:users:entries  →  ZSET { "cache_key_1": 1699900000, "cache_key_2": 1699900500 }

Issues:

  • Entries remain in sorted sets forever after cache keys expire via Redis TTL
  • Hypervel has no cleanup mechanism - the flushStale() method exists but nothing invoked it
  • Memory grows unbounded over time in any application using cache tags

Problem 2: Intersection-only flush semantics

Current behavior requires all tags to match for flush:

// Current behavior (intersection/AND logic)
Cache::tags(['posts', 'user:123'])->put('key', 'value', 60);

Cache::tags(['posts'])->flush();             // ❌ Won't flush - missing 'user:123'
Cache::tags(['user:123'])->flush();          // ❌ Won't flush - missing 'posts'
Cache::tags(['posts', 'user:123'])->flush(); // ✅ Only this works

Most tagged cache systems use union/OR semantics: tag with many categories, flush by any matching tag.

Problem 3: Chatty (sub-optimal) network operations

The original tagged cache made multiple separate calls for a single logical operation:

// Original flow for Cache::tags(['a', 'b'])->put('key', 'value', 60)
$this->tags->addEntry($key, $ttl);  // Network call 1: ZADD to tag sorted sets
parent::put($key, $value, $ttl);    // Network call 2: SETEX for cache value

Each call potentially checked out a separate connection from the pool.

Problem 4: Inconsistent connection handling

The existing code mixed approaches:

  • Some operations used $store->connection() (proxy via RedisFactory)
  • Serialization decisions happened without checking phpredis configuration
  • No unified pattern for holding connections during multi-command operations
  • Potential for double-serialization when phpredis OPT_SERIALIZER was enabled

What's New

For all users (no config changes required)

Improvement Benefit
Pipelined tagged writes ZADD + SETEX combined in single pipeline (was 2 separate calls)
Unified withConnection() pattern Single connection held for operation duration
Smart serialization Checks $conn->serialized() to avoid double-serialization
Explicit cluster handling Detects cluster mode and switches to sequential execution
Operations extracted to classes Better testability, cleaner code, no inheritance magic
evalSha caching for Lua scripts Faster repeated operations
Optimized putMany() with Lua Single round-trip vs multiple
cache:prune-redis-stale-tags command Fixes the memory leak
cache:redis-doctor command Environment diagnostics
cache:redis-benchmark command Performance testing
Consistent event firing KeyWritten, CacheHit, etc. fired explicitly after each operation

For users opting into "any" tag mode (Redis 8.0+ Required)

Feature Benefit
Union-based flush semantics Flush by any matching tag
Self-expiring tag references Hash fields auto-expire via HSETEX
Atomic multi-structure updates Lua scripts ensure consistency
O(1) tag discovery Registry sorted set vs O(N) SCAN
Direct key access Keys not namespaced by tags

Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│                         RedisStore                              │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  tags($names)                                               ││
│  │    └─→ tagMode === TagMode::Any                             ││
│  │          ? new AnyTaggedCache(...)                          ││
│  │          : new AllTaggedCache(...)                          ││
│  └─────────────────────────────────────────────────────────────┘│
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  Non-tagged operations (all users benefit):                 ││
│  │    get(), put(), forget(), add(), putMany(), etc.           ││
│  │    └─→ Redis/Operations/Get, Put, etc.                      ││
│  └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────┐  ┌─────────────────────────────────┐
│   AllTaggedCache                │  │   AnyTaggedCache                │
│   (default - intersection)      │  │   (opt-in - union)              │
│                                 │  │                                 │
│  - Sorted sets with TTL scores  │  │  - Hashes with HSETEX           │
│  - ZREMRANGEBYSCORE cleanup     │  │  - Tag registry for discovery   │
│  - Works on any Redis version   │  │  - Reverse index for flush      │
│  - Keys namespaced by tags      │  │  - Requires Redis 8.0+          │
└─────────────────────────────────┘  └─────────────────────────────────┘

Key architectural changes

From monolith to operations:
The original RedisStore contained all logic inline. The new version delegates to dedicated operation classes, making each operation isolated, testable, and self-contained.

No inheritance magic:
The original RedisTaggedCache relied on parent::put() and parent::add() from the TaggedCacheRepository inheritance chain. The new AllTaggedCache calls $store->allTagOps()->put()->execute() directly, making data flow explicit.

Cluster-aware operations:
Tagged operations detect cluster mode via $context->isCluster() and switch from pipeline to sequential execution to handle cross-slot tags safely:

// From AllTag/Put.php
if ($this->context->isCluster()) {
    return $this->executeCluster($key, $value, $seconds, $tagIds);
}
return $this->executePipeline($key, $value, $seconds, $tagIds);

Key structure

Both tagging modes use distinct key prefixes to avoid collisions and enable mode-specific cleanup operations.

All mode (default)

Key Type Pattern Data Structure
Cache value {prefix}{sha1(tags)}:{key} STRING
Tag entries {prefix}_all:tag:{tagName}:entries ZSET (score = TTL timestamp)

Example:

myapp:cache:a1b2c3d4...:{key}           →  STRING "cached value"
myapp:cache:_all:tag:users:entries      →  ZSET { "a1b2c3...:{key}": 1699900000 }

Any mode (opt-in, Redis 8.0+)

Key Type Pattern Data Structure
Cache value {prefix}{key} STRING
Tag entries {prefix}_any:tag:{tagName}:entries HASH (fields auto-expire via HSETEX)
Reverse index {prefix}{key}:_any:tags SET (tracks which tags a key has)
Tag registry {prefix}_any:tag:registry ZSET (tracks active tags for O(1) discovery)

Example:

myapp:cache:user:123                    →  STRING "cached value"
myapp:cache:_any:tag:users:entries      →  HASH { "user:123": "1" }  (field expires with key)
myapp:cache:user:123:_any:tags          →  SET  { "users", "posts" }
myapp:cache:_any:tag:registry           →  ZSET { "users": 1699900500 }

Key Structure Change from Previous Versions

Important
The tag key structure changed in this release for consistency between modes.

Version Tag Key Pattern
Before {prefix}tag:{tagName}:entries
After (all mode) {prefix}_all:tag:{tagName}:entries
After (any mode) {prefix}_any:tag:{tagName}:entries

This change enables clean separation between modes and consistent cleanup patterns. Users with existing tagged cache data must run cache:clear after upgrading - see Migration Guide.


Detailed Changes

Redis package enhancements

Note
These changes improve the hypervel/redis package itself. While needed by the cache driver, they're available for general use throughout your application.

RedisConnection new methods

File: redis/src/RedisConnection.php

Method Purpose
client() Raw \Redis/RedisCluster access for advanced operations
serialized() Check if phpredis OPT_SERIALIZER is enabled
compressed() Check if phpredis OPT_COMPRESSION is enabled
pack(array $values) Serialize values for Lua script ARGV parameters
safeScan(string $pattern) Memory-efficient SCAN with correct OPT_PREFIX handling
flushByPattern(string $pattern) Delete all keys matching a pattern (batched)

Coroutine-safe pipelines & transactions

File: redis/src/Traits/MultiExec.php (NEW)

Added MultiExec trait (ported from Hyperf) providing callback-based pipelines and transactions:

// Pipeline - batch commands for efficiency
Redis::pipeline(function ($redis) {
    $redis->set('key1', 'value1');
    $redis->set('key2', 'value2');
    $redis->incr('counter');
});

// Transaction - atomic multi/exec
Redis::transaction(function ($redis) {
    $redis->incr('balance');
    $redis->decr('pending');
});

Properly handles coroutine context - releases connections only if it acquired them, preventing connection leaks.

SafeScan: Fixing the OPT_PREFIX double-prefixing bug

File: redis/src/Operations/SafeScan.php (NEW)

phpredis OPT_PREFIX creates a subtle bug when scanning and deleting keys:

// OPT_PREFIX = "myapp:"
// Key stored in Redis = "myapp:cache:user:1"

// WRONG (what naive code does):
$keys = $redis->scan($iter, "myapp:cache:*");  // Returns ["myapp:cache:user:1"]
$redis->del($keys[0]);  // Tries to delete "myapp:myapp:cache:user:1" - FAILS!

// CORRECT (what SafeScan does):
foreach ($conn->safeScan("cache:*") as $key) {  // Yields "cache:user:1" (stripped)
    $conn->del($key);  // phpredis adds prefix → deletes "myapp:cache:user:1" - SUCCESS
}

Why this happens:

  • SCAN does not auto-add OPT_PREFIX to the pattern
  • SCAN returns keys with the full prefix as stored
  • DEL does auto-add OPT_PREFIX to key names

SafeScan handles this correctly and also supports RedisCluster by iterating all master nodes.

FlushByPattern: Batched key deletion

File: redis/src/Operations/FlushByPattern.php (NEW)

Efficient pattern-based key deletion using SafeScan:

// Delete all keys matching pattern
$deleted = Redis::flushByPattern('cache:users:*');

// Or on a specific connection
$deleted = $connection->flushByPattern('temp:*');
  • Uses SafeScan for memory-efficient iteration
  • Batched deletion (1000 keys at a time)
  • Uses UNLINK for async deletion (non-blocking)

Updated docblocks

Added ~40 new @method annotations for modern Redis commands:

Redis 8.0+ Hash Field Expiration (used by "any" mode):

  • hsetex, hexpire, hpexpire, hexpireat, hpexpireat
  • httl, hpttl, hexpiretime, hpexpiretime, hpersist
  • hgetex, hgetdel

Other Modern Commands:

  • _pack, _unpack, _digest (phpredis internals)
  • serverName, serverVersion
  • msetex, delex, xdelex
  • Vector search: vadd, vsim, vrange, vcard, vdim, etc.

Cache driver: Support classes

Directory: cache/src/Redis/Support/

Class Purpose
StoreContext Mode-aware context providing withConnection() for scoped connection use
Serialization Connection-aware serialization (checks $conn->serialized() to prevent double-serialization)

File: cache/src/Redis/TagMode.php

Single source of truth enum for all mode-specific logic:

  • Key patterns (tagSegment(), tagKey(), reverseIndexKey())
  • Feature flags (hasReverseIndex(), hasRegistry())
  • Easy to understand all differences between modes in one place

Extracted operations

Directory: cache/src/Redis/Operations/

All Redis cache operations extracted to dedicated classes:

Class Description
Get.php Single key retrieval
Many.php Multiple key retrieval (MGET)
Put.php Store with TTL
PutMany.php Bulk store with Lua + cluster handling
Add.php Store if not exists (evalSha optimized)
Forever.php Store indefinitely
Forget.php Remove item
Increment.php / Decrement.php Atomic counters
Flush.php Remove all items
Remember.php / RememberForever.php Single-connection get+set (fixes check-then-checkout-again pattern)

All-mode tag operations

Directory: cache/src/Redis/Operations/AllTag/

Pipelined operations combining tag tracking + cache storage:

Class Description
Put.php ZADD to all tags + SETEX in single pipeline
PutMany.php Batch ZADD + SETEX (optimized from O(keys×tags) to O(tags+keys))
Add.php ZADD + Lua atomic add
Forever.php ZADD (score MAX_EXPIRY) + SET in one pipeline
Increment.php / Decrement.php ZADD NX + INCRBY/DECRBY in one pipeline
Remember.php / RememberForever.php Single-connection tagged remember
AddEntry.php ZADD to tag sorted sets
GetEntries.php ZSCAN to get all keys from sorted sets
FlushStale.php ZREMRANGEBYSCORE cleanup
Flush.php DEL cache keys + DEL sorted set keys
Prune.php Full stale entry cleanup with stats

Any-mode tag operations

Directory: cache/src/Redis/Operations/AnyTag/

Operations using Redis 8.0+ hash field expiration:

Class Description
Put.php Atomic Lua: SETEX + HSETEX to tags + reverse index + registry
PutMany.php Batch tagged put
Add.php Atomic tagged add
Forever.php Tagged forever (MAX_EXPIRY score in registry)
Increment.php / Decrement.php Tagged increment/decrement
Remember.php / RememberForever.php Single-connection Lua tagged remember
GetTaggedKeys.php HSCAN/HKEYS from tag hashes
GetTagItems.php Key-value pairs for tags
Flush.php Delete all items with any matching tag
Prune.php Orphan cleanup via registry + HSCAN

Renamed/moved classes

Original New Location
RedisTaggedCache Redis/AllTaggedCache
RedisTagSet Redis/AllTagSet

New classes:

  • Redis/AnyTaggedCache
  • Redis/AnyTagSet

New commands

cache:prune-redis-stale-tags

Auto-detects tag mode and runs appropriate cleanup.

php artisan cache:prune-redis-stale-tags [store]

All mode output:

┌─────────────────────────────────────┬─────────┐
│ Metric                              │ Value   │
├─────────────────────────────────────┼─────────┤
│ Tags scanned                        │ 1,234   │
│ Stale entries removed (TTL expired) │ 567     │
│ Entries checked for orphans         │ 890     │
│ Orphaned entries removed            │ 12      │
│ Empty tag sets deleted              │ 3       │
└─────────────────────────────────────┴─────────┘

Any mode output:

┌─────────────────────────────────────────┬─────────┐
│ Metric                                  │ Value   │
├─────────────────────────────────────────┼─────────┤
│ Tag hashes scanned                      │ 456     │
│ Fields checked                          │ 2,345   │
│ Orphaned fields removed                 │ 23      │
│ Empty hashes deleted                    │ 5       │
│ Expired tags removed from registry      │ 8       │
└─────────────────────────────────────────┴─────────┘

Critical
This command MUST be scheduled to fix the existing memory leak:

$schedule->command('cache:prune-redis-stale-tags')->hourly();

cache:redis-doctor

Comprehensive environment diagnostics:

php artisan cache:redis-doctor [--store=redis]
  • Checks PHP, phpredis, and Redis server versions
  • Verifies HSETEX/HEXPIRE support (required for any mode)
  • Runs functional checks for all cache operations
  • Mode-aware (tests tagged operations appropriate to configured mode)

cache:redis-benchmark

Performance testing:

php artisan cache:redis-benchmark [--store=redis] [--scale=medium] [--compare-tag-modes]

Scales: small (1K ops), medium (10K ops), large (100K ops), extreme (1M ops)


Configuration

New Config Option

// config/cache.php
'stores' => [
    'redis' => [
        'driver' => 'redis',
        'connection' => 'default',
        'lock_connection' => 'default',
        'tag_mode' => env('REDIS_CACHE_TAG_MODE', 'all'), // 'all' or 'any'
    ],
],
Value Behavior Requirements
'all' (default) Current behavior - intersection flush semantics Any Redis version
'any' Union flush semantics, self-expiring tag refs Redis 8.0+ or Valkey 9.0+

Performance

Both tagging modes deliver similar performance, so developers can choose based on semantics rather than speed concerns.

Benchmark Results (Large Scale: 100K operations)

Metric All Mode Any Mode Diff
Non-Tagged Operations
put() 12,048 ops/s 11,756 ops/s -2.4%
get() 12,992 ops/s 11,505 ops/s -11.4%
forget() 12,779 ops/s 12,432 ops/s -2.7%
add() 12,745 ops/s 12,711 ops/s -0.3%
remember() 6,482 ops/s 6,502 ops/s +0.3%
putMany() 203,705 ops/s 205,034 ops/s +0.7%
Tagged Operations
put() 7,796 ops/s 7,224 ops/s -7.3%
get() 10,633 ops/s 12,492 ops/s +17.5%
add() 5,277 ops/s 7,495 ops/s +42.0%
remember() 5,055 ops/s 4,506 ops/s -10.9%
putMany() 133,756 ops/s 48,907 ops/s -63.4%
flush() (1 tag) 3.1ms 2.0ms -36.1%
Maintenance
Prune stale tags 24.2ms 21.9ms -9.6%

Key observations:

  • Non-tagged operations are nearly identical between modes (expected - same code path)
  • Any mode's putMany() is slower because each item requires atomic Lua execution with multiple data structures
  • Any mode's get() and add() are faster due to simpler key lookup (no namespace computation)
  • Both modes handle 100K+ operations efficiently

Note
Run your own benchmarks with php artisan cache:redis-benchmark --scale=large --compare-tag-modes


Migration guide

Users not using tags

No changes required. You automatically benefit from:

  • Optimized add(), putMany(), remember(), rememberForever()
  • Efficient connection handling
  • Smart serialization

Recommended: Add prune command to scheduler for future-proofing:

$schedule->command('cache:prune-redis-stale-tags')->hourly();

Users using tags (staying with the default all Mode)

One-time action required: Clear the cache after upgrading.

The internal tag key structure changed from tag:{name}:entries to _all:tag:{name}:entries for consistency between modes. Your application code doesn't change, but existing tag data won't be found by the new code.

php artisan cache:clear

Critical: Add prune to scheduler to fix the existing memory leak:

$schedule->command('cache:prune-redis-stale-tags')->hourly();

Users wanting any mode

  1. Verify environment:

    php artisan cache:redis-doctor

    Ensure Redis 8.0+ or Valkey 9.0+ with HSETEX support.

  2. Update config:

    'tag_mode' => 'any',
  3. Clear existing cache (any mode uses different key structure):

    php artisan cache:clear
  4. Update code if using Cache::tags(['a'])->get('key'):

    // Any mode: get() via tags throws BadMethodCallException
    // Tags are for writing and flushing only
    // Use direct access instead:
    Cache::get('key');
  5. Schedule cleanup (handles orphans from edge cases):

    $schedule->command('cache:prune-redis-stale-tags')->hourly();

Breaking Changes

Internal only (won't developers unless they're overriding internal classes / methods)

Change Impact
RedisTaggedCacheRedis/AllTaggedCache Internal class - users use Cache::tags() facade
RedisTagSetRedis/AllTagSet Internal class
RedisStore::serialize() / unserialize() removed Internal methods - now handled by Serialization class

If you extended internal classes

If you extended RedisTaggedCache or RedisTagSet, update your imports:

// Before
use Hypervel\Cache\RedisTaggedCache;
use Hypervel\Cache\RedisTagSet;

// After
use Hypervel\Cache\Redis\AllTaggedCache;
use Hypervel\Cache\Redis\AllTagSet;

File Structure

Click to expand full file structure
components/src/redis/src/
├── Redis.php                                # +use MultiExec trait, +flushByPattern()
├── RedisConnection.php                      # +client(), +serialized(), +compressed(), +pack(),
│                                            #   +safeScan(), +flushByPattern(), +40 docblocks
├── Traits/
│   └── MultiExec.php                        # NEW - coroutine-safe pipeline/transaction
└── Operations/
    ├── SafeScan.php                         # NEW - memory-efficient SCAN iterator
    └── FlushByPattern.php                   # NEW - batched key deletion

components/src/cache/src/
├── CacheManager.php                         # Passes tag_mode config to store
├── ConfigProvider.php                       # Registers new commands
├── RedisStore.php                           # Complete refactor - delegates to Operations
│
├── Redis/
│   ├── AllTaggedCache.php                   # Refactored (was RedisTaggedCache)
│   ├── AllTagSet.php                        # Refactored (was RedisTagSet)
│   ├── AnyTaggedCache.php                   # NEW - union mode tagged cache
│   ├── AnyTagSet.php                        # NEW - union mode tag set
│   ├── TagMode.php                          # NEW - mode enum (single source of truth)
│   │
│   ├── Support/
│   │   ├── StoreContext.php                 # NEW - withConnection() pattern
│   │   └── Serialization.php                # NEW - connection-aware serialization
│   │
│   ├── Operations/
│   │   ├── Get.php                          # NEW
│   │   ├── Many.php                         # NEW
│   │   ├── Put.php                          # NEW
│   │   ├── PutMany.php                      # NEW
│   │   ├── Add.php                          # NEW
│   │   ├── Forever.php                      # NEW
│   │   ├── Forget.php                       # NEW
│   │   ├── Increment.php                    # NEW
│   │   ├── Decrement.php                    # NEW
│   │   ├── Flush.php                        # NEW
│   │   ├── Remember.php                     # NEW
│   │   ├── RememberForever.php              # NEW
│   │   │
│   │   ├── AllTagOperations.php             # NEW - container for all-mode ops
│   │   ├── AllTag/
│   │   │   ├── Put.php                      # NEW
│   │   │   ├── PutMany.php                  # NEW
│   │   │   ├── Add.php                      # NEW
│   │   │   ├── Forever.php                  # NEW
│   │   │   ├── Increment.php                # NEW
│   │   │   ├── Decrement.php                # NEW
│   │   │   ├── Remember.php                 # NEW
│   │   │   ├── RememberForever.php          # NEW
│   │   │   ├── AddEntry.php                 # NEW
│   │   │   ├── GetEntries.php               # NEW
│   │   │   ├── FlushStale.php               # NEW
│   │   │   ├── Flush.php                    # NEW
│   │   │   └── Prune.php                    # NEW
│   │   │
│   │   ├── AnyTagOperations.php             # NEW - container for any-mode ops
│   │   └── AnyTag/
│   │       ├── Put.php                      # NEW
│   │       ├── PutMany.php                  # NEW
│   │       ├── Add.php                      # NEW
│   │       ├── Forever.php                  # NEW
│   │       ├── Increment.php                # NEW
│   │       ├── Decrement.php                # NEW
│   │       ├── Remember.php                 # NEW
│   │       ├── RememberForever.php          # NEW
│   │       ├── GetTaggedKeys.php            # NEW
│   │       ├── GetTagItems.php              # NEW
│   │       ├── Flush.php                    # NEW
│   │       └── Prune.php                    # NEW
│   │
│   └── Console/
│       ├── PruneStaleTagsCommand.php        # NEW
│       ├── DoctorCommand.php                # NEW
│       ├── BenchmarkCommand.php             # NEW
│       ├── Doctor/                          # NEW - diagnostic check classes
│       └── Benchmark/                       # NEW - benchmark scenario classes
│
└── publish/
    └── cache.php                            # Updated with tag_mode option

Key design decisions

Single driver with config toggle (vs separate drivers)

  • Non-tagged improvements apply to everyone without requiring driver change
  • Cleaner upgrade path (change config value, not driver name)
  • Shared infrastructure (commands, support classes)

Operations extracted to classes

  • Consistency: Same pattern for tagged and non-tagged operations
  • Testability: Each operation unit-tested in isolation
  • Maintainability: Complex Lua scripts isolated per-operation
  • Explicit flow: No inheritance magic - data flow is traceable

withConnection() Pattern

  • Avoids double serialization: Operations check $conn->serialized() before PHP serializing
  • Efficient connection use: Hold ONE connection for operation duration
  • Consistency: One code path for all operations
  • Cluster compatibility: Cluster mode needs same connection for related commands

TagMode enum as single source of truth

All mode-specific logic centralized:

  • Key patterns (tagSegment(), tagKey(), reverseIndexKey())
  • Feature flags (hasReverseIndex(), hasRegistry())
  • Mode queries (isAnyMode(), isAllMode())

Makes it easy to understand all behavioral differences between modes in one place.

Lazy flush for any mode

  • Hypervel targets high-performance dedicated infrastructure
  • Built-in scheduler makes cleanup trivial to automate
  • Hash field auto-expiration (HSETEX) handles most cleanup automatically
  • Eager deletion during flush would add I/O contradicting performance goals

Testing

This PR introduces comprehensive test coverage and establishes integration testing infrastructure for the Hypervel components repo.

Test coverage overview

Category Test Files Test Methods
Cache Redis unit tests 49 392
Cache Redis integration tests 16 326
Redis package unit tests 8 94
Total new tests 73 812

Integration testing infrastructure

This PR introduces integration testing infrastructure that didn't previously exist in the repo:

New base classes:

Class Purpose
tests/Support/RedisIntegrationTestCase.php Generic Redis integration test base
tests/Cache/Redis/Integration/RedisCacheIntegrationTestCase.php Cache-specific Redis test base

Key features:

  • Parallel-safe: Uses TEST_TOKEN to create unique prefixes per worker (e.g., int_test_1:, int_test_2:). This makes the tests parallel-safe in case we want to use paratest in the future
  • Environment-driven: Configure via .env for local testing
  • Self-cleaning: Automatic cleanup via flushByPattern('*') with OPT_PREFIX (only deletes test keys)
  • Skip by default: Tests skip unless RUN_REDIS_INTEGRATION_TESTS=true

CI: Multi-vendor Redis testing

The GitHub Actions workflow now runs integration tests against both Redis 8.0 and Valkey 9.0:

redis_integration_tests:
  strategy:
    fail-fast: false
    matrix:
      include:
        - redis: "redis:8"
          name: "Redis 8"
        - redis: "valkey/valkey:9"
          name: "Valkey 9"

  services:
    redis:
      image: ${{ matrix.redis }}
      ports:
        - 6379:6379

This ensures the cache driver works correctly on both major Redis-compatible platforms, catching any implementation differences between Redis and Valkey.

Integration test classes

Test Class Coverage
BasicOperationsIntegrationTest get/put/forget/forever/add
BlockedOperationsIntegrationTest Operations that throw in any-mode
ClusterFallbackIntegrationTest Cluster detection and sequential fallback
ConcurrencyIntegrationTest Race conditions, atomic operations
EdgeCasesIntegrationTest Empty values, special characters, large data
FlushOperationsIntegrationTest Tag flushing in both modes
HashExpirationIntegrationTest HSETEX/HEXPIRE behavior (any-mode)
HashLifecycleIntegrationTest Hash field creation/deletion
KeyNamingIntegrationTest Key patterns, namespacing
PrefixHandlingIntegrationTest OPT_PREFIX interactions
PruneIntegrationTest Stale entry cleanup
RememberIntegrationTest remember/rememberForever
TagConsistencyIntegrationTest Tag structure integrity
TaggedOperationsIntegrationTest All tagged operations in both modes
TagQueryIntegrationTest Tag queries and lookups
TtlHandlingIntegrationTest TTL accuracy, expiration

Running tests locally

# Unit tests only (default)
vendor/bin/phpunit

# Enable integration tests
cp .env.example .env
# Edit .env: RUN_REDIS_INTEGRATION_TESTS=true

# Run all tests including integration
vendor/bin/phpunit

# Run only integration tests
vendor/bin/phpunit --group redis-integration

# Run only unit tests (excludes integration)
vendor/bin/phpunit --exclude-group redis-integration

Benefits for future development

The integration test infrastructure is designed to be reusable:

  1. Other packages can extend RedisIntegrationTestCase for their Redis-dependent tests
  2. Parallel execution is supported in case we want to use paratest in the future
  3. CI template demonstrates how to add service containers for integration tests
  4. Pattern established for testing against multiple backend implementations

@binaryfire
Copy link
Contributor Author

@albertcht Sorry about the size of the code review 😅 It's a complete overhaul of the driver so it's a big PR.

@albertcht albertcht requested a review from Copilot December 18, 2025 12:01
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR implements a comprehensive architectural overhaul of Hypervel's Redis cache driver, transitioning from a monolithic structure to a modular, operation-based architecture while introducing an optional union-based "any" tagging mode for Redis 8.0+. The existing "all" mode benefits from pipelined operations, unified connection handling, smart serialization, and a critical memory leak fix via the new prune command.

Key changes:

  • Extracted all Redis operations into dedicated, testable operation classes
  • Introduced optional "any" tag mode with union flush semantics using Redis 8.0+ hash field expiration
  • Fixed memory leak in tag implementation by adding cache:prune-redis-stale-tags command
  • Added integration testing infrastructure with multi-vendor Redis testing (Redis 8.0 and Valkey 9.0)

Reviewed changes

Copilot reviewed 131 out of 197 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/redis/src/Traits/MultiExec.php Coroutine-safe pipeline/transaction trait ported from Hyperf
src/redis/src/Operations/SafeScan.php Memory-efficient SCAN iterator fixing OPT_PREFIX double-prefixing bug
src/redis/src/Operations/FlushByPattern.php Batched pattern-based key deletion using SafeScan
src/redis/src/RedisConnection.php Added helper methods: client(), serialized(), compressed(), pack(), safeScan(), flushByPattern()
src/redis/src/Redis.php Added MultiExec trait and flushByPattern() facade method
src/cache/src/Redis/Support/StoreContext.php Unified connection handling with withConnection() pattern
src/cache/src/Redis/Support/Serialization.php Connection-aware serialization preventing double-serialization
src/cache/src/Redis/TagMode.php Enum centralizing all mode-specific logic and key patterns
src/cache/src/Redis/Operations/ Extracted operations for Get, Put, Many, Add, Forever, Forget, Increment, Decrement, Flush, Remember
src/cache/src/Redis/Operations/AllTag/ All-mode tagged operations with pipelined ZADD + SETEX
src/cache/src/Redis/Operations/AnyTag/ Any-mode tagged operations using Redis 8.0+ HSETEX
src/cache/src/Redis/Console/PruneStaleTagsCommand.php Fixes memory leak by cleaning stale tag entries
src/cache/src/Redis/Console/DoctorCommand.php Environment diagnostics for Redis cache setup
src/cache/src/Redis/Console/BenchmarkCommand.php Performance testing across both tag modes
tests/Cache/Redis/Integration/ 16 new integration test classes covering both modes
tests/Cache/Redis/ 49 unit test files with comprehensive mock-based testing
tests/Support/RedisIntegrationTestCase.php Reusable integration test infrastructure

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

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