diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..72f03d6 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,7 @@ +# Auto-generated from team-config.yml +# Team: core +# +# To apply: scripts/apply-codeowners.sh legion-cache + +* @LegionIO/maintainers +* @LegionIO/core diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..79ea87c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: bundler + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + labels: + - "type:dependencies" + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + labels: + - "type:dependencies" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5ed4d2f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '0 9 * * 1' + +jobs: + ci: + uses: LegionIO/.github/.github/workflows/ci.yml@main + with: + needs-redis: true + needs-memcached: true + + lint: + uses: LegionIO/.github/.github/workflows/lint-patterns.yml@main + + security: + uses: LegionIO/.github/.github/workflows/security-scan.yml@main + + version-changelog: + uses: LegionIO/.github/.github/workflows/version-changelog.yml@main + + dependency-review: + uses: LegionIO/.github/.github/workflows/dependency-review.yml@main + + stale: + if: github.event_name == 'schedule' + uses: LegionIO/.github/.github/workflows/stale.yml@main + + release: + needs: [ci, lint] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: LegionIO/.github/.github/workflows/release.yml@main + secrets: + rubygems-api-key: ${{ secrets.RUBYGEMS_API_KEY }} \ No newline at end of file diff --git a/.github/workflows/rubocop-analysis.yml b/.github/workflows/rubocop-analysis.yml deleted file mode 100644 index 0a07e18..0000000 --- a/.github/workflows/rubocop-analysis.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Rubocop -on: [push, pull_request] -jobs: - rubocop: - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest] - ruby: [2.7] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v2 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby }} - bundler-cache: true - - name: Install Rubocop - run: gem install rubocop code-scanning-rubocop - - name: Rubocop run --no-doc - run: | - bash -c " - rubocop --require code_scanning --format CodeScanning::SarifFormatter -o rubocop.sarif - [[ $? -ne 2 ]] - " - - name: Upload Sarif output - uses: github/codeql-action/upload-sarif@v1 - with: - sarif_file: rubocop.sarif \ No newline at end of file diff --git a/.github/workflows/sourcehawk-scan.yml b/.github/workflows/sourcehawk-scan.yml deleted file mode 100644 index 72a2af8..0000000 --- a/.github/workflows/sourcehawk-scan.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Sourcehawk Scan -on: - push: - branches: - - main - - master - pull_request: - branches: - - main - - master -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Sourcehawk Scan - uses: optum/sourcehawk-scan-github-action@main - - - diff --git a/.gitignore b/.gitignore index 54781f1..1d17157 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ # rspec failure tracking .rspec_status legionio.key +# logs and OS artifacts +legion.log +.DS_Store +.worktrees/ diff --git a/.rubocop.yml b/.rubocop.yml index 5d9277e..80fdf88 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,26 +1,53 @@ +AllCops: + TargetRubyVersion: 3.4 + NewCops: enable + SuggestExtensions: false + Layout/LineLength: - Max: 120 - Exclude: - - 'legion-cache.gemspec' + Max: 160 + +Layout/SpaceAroundEqualsInParameterDefault: + EnforcedStyle: space + +Layout/HashAlignment: + EnforcedHashRocketStyle: table + EnforcedColonStyle: table + Metrics/MethodLength: - Max: 30 + Max: 50 + Metrics/ClassLength: Max: 1500 + +Metrics/ModuleLength: + Max: 1500 + Metrics/BlockLength: - Max: 50 + Max: 40 Exclude: - - 'spec/*/**.rb' + - 'spec/**/*' + Metrics/AbcSize: - Max: 18 + Max: 60 + +Metrics/CyclomaticComplexity: + Max: 15 + +Metrics/PerceivedComplexity: + Max: 17 + Style/Documentation: Enabled: false -Style/ModuleFunction: - Enabled: false -AllCops: - TargetRubyVersion: 2.6 - NewCops: enable - SuggestExtensions: false + +Style/SymbolArray: + Enabled: true + Style/FrozenStringLiteralComment: + Enabled: true + EnforcedStyle: always + +Naming/FileName: + Enabled: false + +Naming/PredicateMethod: Enabled: false -Gemspec/RequiredRubyVersion: - Enabled: false \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 98668eb..f11d830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,247 @@ -# Legion::Cache +# Changelog -## v1.2.0 -Moving from BitBucket to GitHub inside the Optum org. All git history is reset from this point on +## [Unreleased] + +## [1.4.1] - 2026-04-06 + +### Fixed +- AsyncWriter TOCTOU race condition in enqueue (capture local executor reference) +- Reconnector deadlock on stop (release mutex before thread.join) +- Reconnector NoMethodError on successful reconnect (AtomicFixnum reset) +- Missing `require 'concurrent'` in reconnector.rb +- Redis cluster flush now passes auth/TLS credentials to per-node connections +- Async writer drains before pool close on shutdown +- Serialization applied to mget/mset_sync (was only on set_sync/get) +- Binary encoding forced before serialization prefix checks + +### Added +- Automatic failback to Local tier when shared cache is disabled or disconnected (configurable via `failback_to_local: true`) +- `mget`/`mset` methods on Memory adapter for interface consistency +- Public `pool` accessor on `Legion::Cache` (replaces direct `@client` access) +- `failed_count` counter in AsyncWriter stats (`async_failed` in stats hash) +- `reconnect_shared!` raising method for reconnector connect_block +- End-to-end lifecycle integration test (shared fail -> local failback -> reconnect) + +### Changed +- Helper and Cacheable use `async: false` for read-after-write consistency +- AsyncWriter and Reconnector are tier-aware (`settings_key:` parameter, `:cache_local` for local tier) +- Redis driver `pool_size` resolved from settings (was hardcoded to 20) +- Pool checkout timeout separated from operation timeout (new `pool_checkout_timeout` setting) +- Reconnector starts on any shared failure (even when local fallback succeeds) +- `setup` guarded by `enabled?` check +- State flags (`@connected`, `@using_local`, `@using_memory`) refactored to `Concurrent::AtomicBoolean` +- Reconnector `@stop_signal` refactored to `Concurrent::AtomicBoolean` +- `RedisHash` uses public `pool` accessor instead of `instance_variable_get(:@client)` + +## [1.4.0] - 2026-04-06 + +### Added +- Async write support via `Legion::Cache::AsyncWriter` backed by `concurrent-ruby` ThreadPoolExecutor +- `set`, `delete`, and `mset` now accept `async:` keyword (default `true`) for non-blocking writes +- `Legion::Cache::Reconnector` with exponential backoff (1s to 60s) for background reconnection +- Reconnector auto-starts when both shared and local cache are unavailable at setup +- `enabled?` guard on both shared and local tiers +- `stats` method returning frozen Hash with driver, connection, pool, async, and reconnect metrics +- `set_sync`, `delete_sync`, `mset_sync` explicit synchronous write methods on all tiers +- Async and reconnect default settings in `Legion::Cache::Settings` +- Transparent JSON serialization for Redis driver (prefix-byte protocol, backward-compatible with legacy data) + +### Changed +- All cache drivers now use keyword TTL (`ttl:`) instead of positional arguments +- `flush` takes no arguments across all drivers (was `flush(delay = 0)`) +- `Helper` module updated: `FALLBACK_TTL` changed from 60 to 3600, all delegations use keyword signatures, `cache_set`/`cache_delete`/`cache_mset` accept `async:` keyword +- `Cacheable` module updated: `cache_write` and `local_cache_write` use keyword TTL +- Default TTL changed from 60 to 3600 (shared) and 21600 (local) +- Version bump from 1.3.22 to 1.4.0 + +### Fixed +- Unified exception handling model: reads return nil (handled), sync writes re-raise, lifecycle handles internally + +## [1.3.22] - 2026-04-03 + +### Fixed +- Default `servers` no longer pre-computed at require time with stale driver port; resolves Redis connecting to `127.0.0.1:11211` (memcached default) instead of user-configured host + +## [1.3.21] - 2026-04-02 + +### Fixed +- Preserve `fetch` block behavior across shared, local, memory, and Redis cache paths; local-cache failures now fall back to the in-process cache and cached `false` values are retained correctly +- Move shared adapter selection to runtime setup/client calls, register cache defaults through `Legion::Cache::Settings`, and normalize IPv4/hostname/IPv6 server addresses consistently +- Restrict Redis hash/sorted-set helpers to the actual Redis backend and enforce documented TTL behavior for helper batch writes +- Make the default `bundle exec rspec` suite hermetic by excluding service-backed integration specs unless `RUN_INTEGRATION_SPECS=1` + +### Changed +- Uplift cache logging internals to `Legion::Logging::Helper`, replacing direct logger calls with helper-provided `log` usage across cache runtime modules +- Route rescued cache adapter/helper/setup failures through `handle_exception` and expand runtime `info`/`debug`/`error` coverage for shared, local, memory, pool, and RedisHash flows +- Require `legion-logging >= 1.5.0` at runtime so helper exception handling is always available + +## [1.3.20] - 2026-03-31 + +### Fixed +- Forward `timeout` setting to `::Redis.new` — was silently using redis gem's 1.0s default instead of configured 5s, causing spurious timeouts on service mesh connections +- Forward `timeout` to `::Redis.new` in cluster mode path as well + +### Changed +- Increase `reconnect_attempts` from `1` to `[0, 0.5, 1]` (shared) / `[0, 0.25, 0.5]` (local) — 3 retries with escalating backoff instead of 1 instant retry, improving resilience for service mesh and remote Redis connections + +## [1.3.19] - 2026-03-31 + +### Added +- `cache_mget` / `cache_mset` (and `local_cache_mget` / `local_cache_mset`) on `Helper` mixin — delegates to Redis batch ops, falls back to sequential get/set on Memcached (closes #3) +- `cache_hset`, `cache_hgetall`, `cache_hdel`, `cache_zadd`, `cache_zrangebyscore`, `cache_zrem`, `cache_expire` on `Helper` mixin — delegates to `RedisHash` with namespace prefixing; hash ops fall back to JSON-serialized Memcached values, sorted-set ops raise `NotImplementedError`, expire is a no-op on Memcached (closes #4) + +## [1.3.18] - 2026-03-29 + +### Added +- Layered TTL resolution in Helper (per-call → LEX override → Settings → FALLBACK_TTL) +- `cache_default_ttl` / `local_cache_default_ttl` — LEX-overridable default TTL methods +- `cache_exist?` / `local_cache_exist?` — key existence checks +- `cache_connected?` / `local_cache_connected?` — connection status helpers +- `cache_pool_size` / `cache_pool_available` — pool info (shared tier) +- `local_cache_pool_size` / `local_cache_pool_available` — pool info (local tier) +- `phi:` keyword argument on `cache_set` / `local_cache_set` for PHI TTL enforcement +- `default_ttl` key in Settings.default and Settings.local (defaults to 60) + +## [1.3.17] - 2026-03-25 + +### Added +- `Legion::Cache::RedisHash` module: Redis hash and sorted-set operations (`hset`, `hgetall`, `hdel`, `zadd`, `zrangebyscore`, `zrem`, `expire`) with `redis_available?` guard and safe defaults when Redis is not connected +- Auto-required from `legion/cache.rb` alongside the existing Redis adapter + +## [1.3.16] - 2026-03-25 + +### Fixed +- Accept ttl as positional or keyword argument in Cache.set for caller flexibility +- Align Redis.set signature to positional ttl arg matching parent module convention + +## [1.3.15] - 2026-03-24 + +### Added +- PHI-aware TTL enforcement: `Cache.set` accepts `phi: true` keyword option; TTL is capped at `cache.compliance.phi_max_ttl` (default 3600s) when set +- `Legion::Cache.phi_max_ttl` — reads `cache.compliance.phi_max_ttl` from settings with 3600s default +- `Legion::Cache.enforce_phi_ttl(ttl, phi:)` — public helper for PHI TTL cap logic + +## [1.3.14] - 2026-03-24 + +### Added +- `username`, `password`, `db`, and `reconnect_attempts` options to Redis `client` and `build_redis_client` +- Corresponding nil/default entries in `Settings.default` and `Settings.local` + +## [1.3.13] - 2026-03-24 + +### Changed +- Reindex docs: update CLAUDE.md and README with Memory adapter and Helper mixin docs + +## [1.3.12] - 2026-03-24 + +### Added +- `Legion::Cache::Memory` adapter module for lite mode: pure in-memory cache with TTL expiry and thread-safe Mutex synchronization +- Cache `setup` auto-detects `LEGION_MODE=lite` and activates Memory adapter, skipping Redis/Memcached +- `@using_memory` flag routes `get`/`set`/`fetch`/`delete`/`flush` through Memory adapter +- `shutdown` cleanly tears down Memory adapter when active + +## [1.3.11] - 2026-03-22 + +### Added +- `Legion::Cache::Helper` module: injectable cache mixin for LEX extensions +- Namespaced `cache_set`, `cache_get`, `cache_delete`, `cache_fetch` for shared cache +- Namespaced `local_cache_set`, `local_cache_get`, `local_cache_delete`, `local_cache_fetch` for per-node local cache + +## [1.3.10] - 2026-03-22 + +### Changed +- Updated gemspec dependency version constraints: legion-logging >= 1.2.8, legion-settings >= 1.3.12 + +## [1.3.9] - 2026-03-22 + +### Changed +- Added `Legion::Logging` calls (guarded with `defined?`) to all previously silent rescue blocks +- `memcached.rb`: debug log on `memcached_tls_settings` failure +- `redis.rb`: warn log on `cluster_flush` fallback; debug log on `resolved_redis_address` and `cache_tls_settings` failures +- `settings.rb`: stdlib `warn` on `legion/settings` require failure (Logging not yet available at that point) + +## [1.3.8] - 2026-03-22 + +### Changed +- Memcached driver now logs server addresses on connect +- Local cache now logs server addresses on connect +- Shared cache setup log now shows full server list instead of just first server + +## [1.3.7] - 2026-03-22 + +### Added +- Redis driver: `.debug` logging on get (hit/miss), set (ttl/success), delete, flush, mget (key count), mset (key count) +- Redis driver: `.info` on successful client creation with host/port address +- Redis driver: private `resolved_redis_address` helper for extracting address at connect time +- Pool: `.info` on close and restart +- Cacheable: `.debug` on cache hit/miss in wrapper; `.warn` on swallowed errors in `local_cache_read`/`local_cache_write` +- Local: `.debug` on get/set/fetch/delete/flush operations +- Cache: `.info` on successful shared cache setup (driver + server) +- All new logging calls guarded with `if defined?(Legion::Logging)` for standalone use + +## [1.3.6] - 2026-03-21 + +### Added +- Redis Cluster mode: `cluster:`, `replica:`, `fixed_hostname:` options in `build_redis_client` +- `cluster_mode?` predicate for runtime cluster detection +- `mget(*keys)` and `mset(hash)` with automatic slot-aware grouping for cross-slot operations +- Cluster-aware `flush` that iterates all primary nodes via `CLUSTER NODES` +- Failover logging: `Redis::BaseError` rescues log via `Legion::Logging.warn` before re-raising +- Settings defaults: `cluster: nil`, `replica: false`, `fixed_hostname: nil` + +### Fixed +- `get`, `set`, `delete`, `flush` visibility changed from private to public (were inaccessible on the module directly) + +## [1.3.5] - 2026-03-21 + +### Added +- TLS support for Redis driver: `ssl: true` + `ssl_params` when TLS enabled via `Legion::Crypt::TLS.resolve` +- TLS support for Memcached driver: `ssl_context` option when TLS enabled via `Legion::Crypt::TLS.resolve` +- Port-based auto-detection: Redis TLS port 6380, Memcached TLS port 11207 + +## [1.3.3] - 2026-03-20 + +### Fixed +- Serializer option (`Legion::JSON`) now correctly flows through to `Dalli::Client.new`, preventing Dalli from falling back to Marshal and emitting a security warning + +## [1.3.2] - 2026-03-20 + +### Added +- `Legion::Cache::Cacheable` module for transparent method-level caching +- `cache_method` DSL: declare cached methods with TTL, scope, and key exclusions +- `build_cache_key`: deterministic MD5-based cache keys from module path + method + filtered args +- `bypass_local_method_cache:` kwarg for force-refresh on cached methods +- In-memory fallback store with TTL expiry when no cache backend is available +- `memory_clear!` class method for test isolation + +## [1.3.1] - 2026-03-20 + +### Added +- `Settings.normalize_driver` — maps `:memcached`, `:dalli`, `:redis` to internal gem names +- `Settings.resolve_servers` — merges `server:` (string) and `servers:` (array), injects default port per driver (memcached: 11211, redis: 6379), deduplicates +- `Settings::DEFAULT_PORTS` constant for driver default ports + +### Fixed +- Redis driver now uses configured `server:`/`servers:` instead of hardcoded localhost +- Memcached driver accepts `server:` (singular) in addition to `servers:` (plural) + +### Changed +- `Settings.default` and `Settings.local` use `resolve_servers` for driver-aware server defaults +- Driver selection in `cache.rb` and `local.rb` uses `normalize_driver` for consistent name handling + +## [1.3.0] - 2026-03-16 + +### Added +- `Legion::Cache::Local` module for local Redis/Memcached caching +- `Settings.local` with independent defaults (namespace: `legion_local`, pool_size: 5, timeout: 3) +- Transparent fallback: shared cache failure at setup redirects all operations to Local +- `Legion::Cache.local` accessor, `Legion::Cache.using_local?` query + +## [1.2.1] - 2026-03-16 + +### Fixed +- Set dalli `value_max_bytes` to 8MB by default — dalli enforces a 1MB client-side limit that prevented large cache values from being stored even when memcached server allows larger items + +## [1.2.0] + +Moving from BitBucket to GitHub. All git history is reset from this point on diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..58900a6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,163 @@ +# legion-cache: Caching Layer for LegionIO + +**Repository Level 3 Documentation** +- **Parent**: `/Users/miverso2/rubymine/legion/CLAUDE.md` + +## Purpose + +Caching wrapper for the LegionIO framework. Provides a consistent interface for Memcached (via `dalli`) and Redis (via `redis` gem) with connection pooling. Driver selection is config-driven. + +**GitHub**: https://github.com/LegionIO/legion-cache +**Version**: 1.3.17 +**License**: Apache-2.0 + +## Architecture + +``` +Legion::Cache (singleton module) +├── .setup(**opts) # Connect to cache backend (auto-detects LEGION_MODE=lite -> Memory adapter) +├── .get(key) # Retrieve cached value +├── .fetch(key, ttl) # Get with block/TTL support (Memcached only; alias for get on Redis) +├── .set(key, value, ttl) # Store value with optional TTL (positional on Memcached, keyword on Redis) +├── .delete(key) # Remove a key +├── .flush # Flush all keys (flush(delay) on Memcached, flushdb on Redis) +├── .connected? # Connection status +├── .size # Total pool connections +├── .available # Idle pool connections +├── .restart(**opts) # Close and reconnect pool with optional new opts +├── .shutdown # Close connections, mark disconnected (handles Memory adapter) +├── .local # Accessor for Legion::Cache::Local +├── .using_local? # Whether fallback to local is active +├── .using_memory? # Whether Memory adapter (lite mode) is active +│ +├── Memcached # Dalli-based Memcached driver (default) +│ └── Uses connection_pool for thread safety +│ └── value_max_bytes defaults to 8MB (overrides dalli's 1MB client-side limit) +├── Redis # Redis driver +│ └── Uses connection_pool for thread safety +│ └── Default pool_size is 20 (Memcached default is 10) +├── Memory # Lite mode adapter: pure in-memory cache, TTL expiry, Mutex thread-safety +│ └── Activated by LEGION_MODE=lite env var; no Redis/Memcached required +├── Helper # Injectable cache mixin for LEX extensions (namespaced cache_*/local_cache_*) +├── RedisHash # Redis-specific sorted set + hash operations (hset/hgetall/hdel/zadd/zrangebyscore/zrem/expire); Redis-only, module_function pattern +├── Local # Local cache tier (localhost Redis/Memcached, fallback target) +│ ├── .setup # Connect to local cache server (auto-detect driver) +│ ├── .shutdown # Close local connection +│ ├── .connected? # Whether local cache is active +│ ├── .get/set/delete/fetch/flush # Cache operations on local tier +│ ├── .restart(**opts) # Close and reconnect with new opts +│ └── .reset! # Clear all state (testing) +├── Pool # Connection pool management (connected?, size, available, close, restart) +├── Settings # Default cache config + driver auto-detection +└── Version +``` + +### Key Design Patterns + +- **Driver Selection at Load Time**: `Legion::Settings[:cache][:driver]` determines which module gets `extend`ed into `Legion::Cache` (`'redis'` or `'dalli'`) +- **Connection Pooling**: Both drivers use `connection_pool` gem for thread-safe access +- **Unified Interface**: Same `get`/`set`/`delete`/`flush`/`connected?`/`shutdown` methods regardless of backend +- **Uniform TTL Signature**: All backends use `set(key, value, ttl)` with a positional TTL argument (Memcached default 180s, Redis/Memory/Local default nil) + +### Two-Tier Cache Architecture + +- **Shared** (`Legion::Cache`) — remote Redis/Memcached cluster for cross-node caching +- **Local** (`Legion::Cache::Local`) — localhost Redis/Memcached for per-machine caching +- **Fallback**: If shared cluster is unreachable at setup, all operations transparently delegate to Local +- Both tiers use the same driver modules (`Memcached`/`Redis`) with independent connection pools +- Local uses `.dup` on the driver module to get isolated `@client`/`@connected` state + +## Default Settings + +```json +{ + "driver": "dalli", + "servers": ["127.0.0.1:11211"], + "connected": false, + "enabled": true, + "namespace": "legion", + "compress": false, + "failover": true, + "threadsafe": true, + "cache_nils": false, + "pool_size": 10, + "timeout": 5, + "expires_in": 0, + "serializer": "Legion::JSON" +} +``` + +The `driver` is auto-detected at load time: prefers `dalli`, falls back to `redis` if dalli is unavailable. Both gems are required dependencies so auto-detection is a fallback for unusual environments. + +### Local Default Settings + +`Legion::Cache::Settings.local` provides independent defaults for the local tier: + +```json +{ + "driver": "dalli", + "servers": ["127.0.0.1:11211"], + "connected": false, + "enabled": true, + "namespace": "legion_local", + "compress": false, + "failover": false, + "threadsafe": true, + "cache_nils": false, + "pool_size": 5, + "timeout": 3, + "expires_in": 0, + "serializer": "Legion::JSON" +} +``` + +### Memcached value_max_bytes + +Dalli enforces a 1MB client-side limit by default (`value_max_bytes: 1_048_576`). The Memcached driver overrides this to **8MB** (`8 * 1024 * 1024`) unless explicitly set. This prevents silent rejection of large cached values. The Memcached server must also be started with `-I 8m` to accept values up to 8MB server-side. + +## Dependencies + +| Gem | Purpose | +|-----|---------| +| `dalli` (>= 3.0) | Memcached client | +| `redis` (>= 5.0) | Redis client | +| `connection_pool` (>= 2.4) | Thread-safe connection pooling | +| `legion-logging` | Logging | +| `legion-settings` | Configuration | + +## File Map + +| Path | Purpose | +|------|---------| +| `lib/legion/cache.rb` | Module entry, driver selection, setup/shutdown, fallback wiring, Memory adapter activation | +| `lib/legion/cache/memcached.rb` | Dalli/Memcached driver implementation | +| `lib/legion/cache/redis.rb` | Redis driver implementation | +| `lib/legion/cache/memory.rb` | Lite mode Memory adapter: in-memory store with TTL + Mutex thread-safety | +| `lib/legion/cache/helper.rb` | Injectable cache mixin for LEX extensions | +| `lib/legion/cache/local.rb` | Local cache tier (localhost, fallback target) | +| `lib/legion/cache/redis_hash.rb` | Redis sorted set + hash operations (hset/hgetall/hdel/zadd/zrangebyscore/zrem/expire) | +| `lib/legion/cache/pool.rb` | Connection pool management | +| `lib/legion/cache/settings.rb` | Default configuration + local defaults | +| `lib/legion/cache/version.rb` | VERSION constant | + +## PHI TTL Cap + +When `phi: true` is passed to `set`, the TTL is capped at `cache.compliance.phi_max_ttl` (default 3600s). This enforces the HIPAA PHI TTL policy in legion-logging. The `enforce_phi_ttl(ttl, phi: false)` method applies the cap; without `phi: true` the TTL is passed through unchanged. + +```json +{ + "cache": { + "compliance": { + "phi_max_ttl": 3600 + } + } +} +``` + +## Role in LegionIO + +Optional caching layer initialized during `Legion::Service` startup. Used by `legion-data` for model caching (Sequel caching plugin) and by extensions for general-purpose caching. + +--- + +**Maintained By**: Matthew Iverson (@Esity) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 52c7f95..0000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,75 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, gender identity and expression, level of experience, -nationality, personal appearance, race, religion, or sexual identity and -orientation. - -## Our Standards - -Examples of behavior that contributes to creating a positive environment -include: - -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members - -Examples of unacceptable behavior by participants include: - -* The use of sexualized language or imagery and unwelcome sexual attention or -advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic - address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Our Responsibilities - -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. - -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. - -## Scope - -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project email -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at [opensource@optum.com][email]. All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. - -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at [http://contributor-covenant.org/version/1/4][version] - -[homepage]: http://contributor-covenant.org -[version]: http://contributor-covenant.org/version/1/4/ -[email]: mailto:opensource@optum.com \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index b0c397d..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,55 +0,0 @@ -# Contribution Guidelines - -Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. Please also review our [Contributor License Agreement ("CLA")](INDIVIDUAL_CONTRIBUTOR_LICENSE.md) prior to submitting changes to the project. You will need to attest to this agreement following the instructions in the [Paperwork for Pull Requests](#paperwork-for-pull-requests) section below. - ---- - -# How to Contribute - -Now that we have the disclaimer out of the way, let's get into how you can be a part of our project. There are many different ways to contribute. - -## Issues - -We track our work using Issues in GitHub. Feel free to open up your own issue to point out areas for improvement or to suggest your own new experiment. If you are comfortable with signing the waiver linked above and contributing code or documentation, grab your own issue and start working. - -## Coding Standards - -We have some general guidelines towards contributing to this project. -Please run RSpec and Rubocop while developing code for LegionIO - -### Languages - -*Ruby* - -## Pull Requests - -If you've gotten as far as reading this section, then thank you for your suggestions. - -## Paperwork for Pull Requests - -* Please read this guide and make sure you agree with our [Contributor License Agreement ("CLA")](INDIVIDUAL_CONTRIBUTOR_LICENSE.md). -* Make sure git knows your name and email address: - ``` - $ git config user.name "J. Random User" - $ git config user.email "j.random.user@example.com" - ``` ->The name and email address must be valid as we cannot accept anonymous contributions. -* Write good commit messages. -> Concise commit messages that describe your changes help us better understand your contributions. -* The first time you open a pull request in this repository, you will see a comment on your PR with a link that will allow you to sign our Contributor License Agreement (CLA) if necessary. -> The link will take you to a page that allows you to view our CLA. You will need to click the `Sign in with GitHub to agree button` and authorize the cla-assistant application to access the email addresses associated with your GitHub account. Agreeing to the CLA is also considered to be an attestation that you either wrote or have the rights to contribute the code. All committers to the PR branch will be required to sign the CLA, but you will only need to sign once. This CLA applies to all repositories in the Optum org. - -## General Guidelines - -Ensure your pull request (PR) adheres to the following guidelines: - -* Try to make the name concise and descriptive. -* Give a good description of the change being made. Since this is very subjective, see the [Updating Your Pull Request (PR)](#updating-your-pull-request-pr) section below for further details. -* Every pull request should be associated with one or more issues. If no issue exists yet, please create your own. -* Make sure that all applicable issues are mentioned somewhere in the PR description. This can be done by typing # to bring up a list of issues. - -### Updating Your Pull Request (PR) - -A lot of times, making a PR adhere to the standards above can be difficult. If the maintainers notice anything that we'd like changed, we'll ask you to edit your PR before we merge it. This applies to both the content documented in the PR and the changed contained within the branch being merged. There's no need to open a new PR. Just edit the existing one. - -[email]: mailto:opensource@optum.com \ No newline at end of file diff --git a/Gemfile b/Gemfile index edaf657..4c18b66 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,5 @@ +# frozen_string_literal: true + source 'https://rubygems.org' gemspec @@ -6,5 +8,6 @@ group :test do gem 'rspec' gem 'rspec_junit_formatter' gem 'rubocop' + gem 'rubocop-legion' gem 'simplecov' end diff --git a/INDIVIDUAL_CONTRIBUTOR_LICENSE.md b/INDIVIDUAL_CONTRIBUTOR_LICENSE.md deleted file mode 100644 index 79460dc..0000000 --- a/INDIVIDUAL_CONTRIBUTOR_LICENSE.md +++ /dev/null @@ -1,30 +0,0 @@ -# Individual Contributor License Agreement ("Agreement") V2.0 - -Thank you for your interest in this Optum project (the "PROJECT"). In order to clarify the intellectual property license granted with Contributions from any person or entity, the PROJECT must have a Contributor License Agreement ("CLA") on file that has been signed by each Contributor, indicating agreement to the license terms below. This license is for your protection as a Contributor as well as the protection of the PROJECT and its users; it does not change your rights to use your own Contributions for any other purpose. - -You accept and agree to the following terms and conditions for Your present and future Contributions submitted to the PROJECT. In return, the PROJECT shall not use Your Contributions in a way that is inconsistent with stated project goals in effect at the time of the Contribution. Except for the license granted herein to the PROJECT and recipients of software distributed by the PROJECT, You reserve all right, title, and interest in and to Your Contributions. -1. Definitions. - -"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with the PROJECT. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. - -"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to the PROJECT for inclusion in, or documentation of, any of the products owned or managed by the PROJECT (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the PROJECT or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the PROJECT for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." - -2. Grant of Copyright License. - -Subject to the terms and conditions of this Agreement, You hereby grant to the PROJECT and to recipients of software distributed by the PROJECT a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. - -3. Grant of Patent License. - -Subject to the terms and conditions of this Agreement, You hereby grant to the PROJECT and to recipients of software distributed by the PROJECT a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed. - -4. Representations. - - (a) You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to the PROJECT, or that your employer has executed a separate Corporate CLA with the PROJECT. - - (b) You represent that each of Your Contributions is Your original creation (see section 6 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions. - -5. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. - -6. Should You wish to submit work that is not Your original creation, You may submit it to the PROJECT separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]". - -7. You agree to notify the PROJECT of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect. \ No newline at end of file diff --git a/LICENSE b/LICENSE index 93234d8..20cba51 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2021 Optum + Copyright 2021 Esity Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/NOTICE.txt b/NOTICE.txt deleted file mode 100644 index 2dd3a0c..0000000 --- a/NOTICE.txt +++ /dev/null @@ -1,9 +0,0 @@ -Legion::Cache(legion-cache) -Copyright 2021 Optum - -Project Description: -==================== -A Wrapper class for the LegionIO framework to interface with both Memcached and Redis for caching purposes - -Author(s): -Esity \ No newline at end of file diff --git a/README.md b/README.md index 306eef4..0b875bc 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,178 @@ -Legion::Cache -===== +# legion-cache -Legion::Cache is a wrapper class to handle requests to the caching tier. It supports both memcached and redis +Caching wrapper for the [LegionIO](https://github.com/LegionIO/LegionIO) framework. Provides a consistent interface for Memcached (via `dalli`) and Redis (via `redis` gem) with connection pooling. Driver selection is config-driven. -Supported Ruby versions and implementations ------------------------------------------------- +**Version**: 1.3.17 -Legion::Json should work identically on: - -* JRuby 9.2+ -* Ruby 2.4+ - - -Installation and Usage ------------------------- - -You can verify your installation using this piece of code: +## Installation ```bash gem install legion-cache ``` +Or add to your Gemfile: + +```ruby +gem 'legion-cache' +``` + +## Usage + ```ruby require 'legion/cache' Legion::Cache.setup Legion::Cache.connected? # => true -Legion::Cache.set('foobar', 'testing', ttl: 10) -Legion::Cache.get('foobar') # => 'testing' -sleep(11) -Legion::Cache.get('foobar') # => nil +# Memcached driver (default) — TTL is a positional argument, default 180s +Legion::Cache.set('foobar', 'testing', 10) +Legion::Cache.get('foobar') # => 'testing' +Legion::Cache.fetch('foobar') # => 'testing' (get with block support) +Legion::Cache.delete('foobar') # => true +Legion::Cache.flush # flush all keys + +# Redis driver — TTL is the third positional argument +Legion::Cache.set('foobar', 'testing', 10) +Legion::Cache.get('foobar') # => 'testing' +Legion::Cache.delete('foobar') # => true +Legion::Cache.flush # flushdb + +Legion::Cache.shutdown +``` + +## Lite Mode (No Infrastructure) + +When `LEGION_MODE=lite` is set, `Legion::Cache` activates the pure in-memory `Memory` adapter, bypassing Redis and Memcached entirely: + +```ruby +ENV['LEGION_MODE'] = 'lite' +Legion::Cache.setup +Legion::Cache.using_memory? # => true +Legion::Cache.set('key', 'value', 60) +Legion::Cache.get('key') # => 'value' +``` + +The Memory adapter is thread-safe (Mutex), supports TTL expiry, and exposes the same `get`/`set`/`fetch`/`delete`/`flush` interface. Shutdown cleanly tears it down. + +## Two-Tier Cache + +Legion::Cache supports a two-tier architecture: a shared remote cluster and a local per-machine cache. If the shared cluster is unreachable at setup, all operations transparently fall back to local. + +```ruby +# Shared cache connects to remote cluster; Local connects to localhost +Legion::Cache.setup # starts Local first, then tries shared +Legion::Cache.using_local? # => true if shared was unreachable +Legion::Cache.local # => Legion::Cache::Local + +# Use Local directly if needed +Legion::Cache::Local.setup +Legion::Cache::Local.set('key', 'value', 60) +Legion::Cache::Local.get('key') # => 'value' +Legion::Cache::Local.shutdown ``` -Settings ----------- +Local uses a separate namespace (`legion_local`) and independent connection pool (pool_size: 5, timeout: 3) so it never collides with the shared tier. + +## Configuration ```json { "driver": "dalli", - "servers": [ - "127.0.0.1:11211" - ], + "servers": ["127.0.0.1:11211"], "connected": false, "enabled": true, "namespace": "legion", "compress": false, + "failover": true, + "threadsafe": true, "cache_nils": false, "pool_size": 10, - "timeout": 10, + "timeout": 5, "expires_in": 0 } ``` -Authors ----------- +The driver is auto-detected at load time: prefers `dalli` (Memcached) if available, falls back to `redis`. Override with `"driver": "redis"` and update `servers` to point at your Redis instance. + +### Driver Names + +Supported driver names: `memcached` (or `dalli`), `redis`. All names are normalized internally — `"memcached"` and `"dalli"` are equivalent. + +### Server Resolution + +Both `server` (singular string) and `servers` (array) are accepted and merged. Default ports are injected per driver when omitted: 11211 for memcached, 6379 for redis. Duplicates are removed. + +```json +{ + "cache": { + "driver": "memcached", + "server": "10.0.0.5", + "servers": ["10.0.0.6", "10.0.0.7:22122"] + } +} +``` + +### Memcached notes + +- `value_max_bytes` defaults to **8MB**. Dalli enforces a 1MB client-side limit by default, which silently rejects large values. This default overrides that. Your Memcached server should also be started with `-I 8m` to match. +- Redis default pool size is 20; Memcached default pool size is 10. + +### Local Cache Settings + +```json +{ + "driver": "dalli", + "servers": ["127.0.0.1:11211"], + "namespace": "legion_local", + "pool_size": 5, + "timeout": 3 +} +``` + +Override via `Legion::Settings[:cache_local]`. + +## Method Caching + +Runner modules can use `cache_method` to transparently cache method results with TTL: + +```ruby +module Runners::Presence + extend Legion::Cache::Cacheable + + cache_method :get_presence, ttl: 300, exclude_from_key: [:token] + + def get_presence(user_id: 'me', **) + conn = graph_connection(**) + response = conn.get("#{user_path(user_id)}/presence") + { availability: response.body['availability'], activity: response.body['activity'] } + end +end +``` + +Every caller of `get_presence` gets cached results for 5 minutes. Use `bypass_local_method_cache: true` to force-refresh: + +```ruby +runner.get_presence(user_id: 'me') # cached +runner.get_presence(user_id: 'me', bypass_local_method_cache: true) # fresh +``` + +Options: `ttl:` (seconds), `scope:` (`:local` or `:global`), `exclude_from_key:` (args to ignore in cache key). Falls back to in-memory store when no cache backend is available. + +## Pool API + +```ruby +Legion::Cache.connected? # => true/false +Legion::Cache.size # total pool connections +Legion::Cache.available # idle pool connections +Legion::Cache.restart # close and reconnect pool +Legion::Cache.shutdown # close pool and mark disconnected +``` + +## Requirements + +- Ruby >= 3.4 +- Memcached or Redis server + +## License -* [Matthew Iverson](https://github.com/Esity) - current maintainer +Apache-2.0 diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index acc4d53..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,9 +0,0 @@ -# Security Policy - -## Supported Versions -| Version | Supported | -| ------- | ------------------ | -| 1.x.x | :white_check_mark: | - -## Reporting a Vulnerability -To be added diff --git a/attribution.txt b/attribution.txt deleted file mode 100644 index e4c875c..0000000 --- a/attribution.txt +++ /dev/null @@ -1 +0,0 @@ -Add attributions here. \ No newline at end of file diff --git a/legion-cache.gemspec b/legion-cache.gemspec index c831bed..a33af92 100644 --- a/legion-cache.gemspec +++ b/legion-cache.gemspec @@ -6,29 +6,30 @@ Gem::Specification.new do |spec| spec.name = 'legion-cache' spec.version = Legion::Cache::VERSION spec.authors = ['Esity'] - spec.email = %w[matthewdiverson@gmail.com ruby@optum.com] + spec.email = ['matthewdiverson@gmail.com'] spec.summary = 'Wraps both the redis and dalli gems to make a consistent interface for accessing cached objects' spec.description = 'A Wrapper class for the LegionIO framework to interface with both Memcached and Redis for caching purposes' - spec.homepage = 'https://github.com/Optum/legion-cache' + spec.homepage = 'https://github.com/LegionIO/legion-cache' spec.license = 'Apache-2.0' spec.require_paths = ['lib'] - spec.required_ruby_version = '>= 2.4' + spec.required_ruby_version = '>= 3.4' spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } - spec.test_files = spec.files.select { |p| p =~ %r{^test/.*_test.rb} } - spec.extra_rdoc_files = %w[README.md LICENSE CHANGELOG.md] + spec.extra_rdoc_files = %w[README.md LICENSE CHANGELOG.md] spec.metadata = { - 'bug_tracker_uri' => 'https://github.com/Optum/legion-cache/issues', - 'changelog_uri' => 'https://github.com/Optum/legion-cache/src/main/CHANGELOG.md', - 'documentation_uri' => 'https://github.com/Optum/legion-cache', - 'homepage_uri' => 'https://github.com/Optum/LegionIO', - 'source_code_uri' => 'https://github.com/Optum/legion-cache', - 'wiki_uri' => 'https://github.com/Optum/legion-cache/wiki' + 'bug_tracker_uri' => 'https://github.com/LegionIO/legion-cache/issues', + 'changelog_uri' => 'https://github.com/LegionIO/legion-cache/blob/main/CHANGELOG.md', + 'documentation_uri' => 'https://github.com/LegionIO/legion-cache', + 'homepage_uri' => 'https://github.com/LegionIO/LegionIO', + 'source_code_uri' => 'https://github.com/LegionIO/legion-cache', + 'wiki_uri' => 'https://github.com/LegionIO/legion-cache/wiki', + 'rubygems_mfa_required' => 'true' } - spec.add_dependency 'connection_pool', '>= 2.2.3' - spec.add_dependency 'dalli', '>= 2.7' - spec.add_dependency 'legion-logging' - spec.add_dependency 'legion-settings' - spec.add_dependency 'redis', '>= 4.2' + spec.add_dependency 'concurrent-ruby', '>= 1.2' + spec.add_dependency 'connection_pool', '>= 2.4' + spec.add_dependency 'dalli', '>= 3.0' + spec.add_dependency 'legion-logging', '>= 1.5.0' + spec.add_dependency 'legion-settings', '>= 1.3.12' + spec.add_dependency 'redis', '>= 5.0' end diff --git a/lib/legion/cache.rb b/lib/legion/cache.rb index 723ff45..e1f486e 100644 --- a/lib/legion/cache.rb +++ b/lib/legion/cache.rb @@ -1,32 +1,586 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' require 'legion/cache/version' require 'legion/cache/settings' +require 'legion/cache/cacheable' require 'legion/cache/memcached' require 'legion/cache/redis' +require 'legion/cache/redis_hash' +require 'legion/cache/memory' +require 'legion/cache/local' +require 'legion/cache/async_writer' +require 'legion/cache/reconnector' +require 'concurrent' +require 'legion/cache/helper' module Legion module Cache + extend Legion::Logging::Helper + + @async_writer = Legion::Cache::AsyncWriter.new(settings_key: :cache) + @connected = Concurrent::AtomicBoolean.new(false) + @using_local = Concurrent::AtomicBoolean.new(false) + @using_memory = Concurrent::AtomicBoolean.new(false) + class << self - if Legion::Settings[:cache][:driver] == 'redis' - include Legion::Cache::Redis - else - include Legion::Cache::Memcached + include Legion::Logging::Helper + + def enabled? + return true unless defined?(Legion::Settings) + + Legion::Settings.dig(:cache, :enabled) != false + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :cache_enabled) + true + end + + def connected? + @connected&.true? || false + end + + def driver_name + return 'memory' if using_memory? + return 'local' if using_local? + + @active_shared_driver || configured_shared_driver end - def setup(**opts) + def stats + { + driver: driver_name, + servers: resolved_servers, + enabled: enabled?, + connected: connected?, + using_local: using_local?, + using_memory: using_memory?, + pool_size: safe_pool_size, + pool_available: safe_pool_available, + async_pool_size: async_writer_pool_size, + async_queue_depth: async_writer_queue_depth, + async_processed: async_writer_processed_count, + async_failed: async_writer_failed_count, + reconnect_attempts: reconnector_attempts, + uptime: uptime_seconds + }.freeze + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :cache_stats) + { error: e.message }.freeze + end + + def setup(**) + return unless enabled? return Legion::Settings[:cache][:connected] = true if connected? - return unless client(**Legion::Settings[:cache], **opts) + @setup_at = Time.now - @connected = true - Legion::Settings[:cache][:connected] = true + async_writer.start + + if ENV['LEGION_MODE'] == 'lite' + Legion::Cache::Memory.setup + @using_memory.make_true + @connected.make_true + Legion::Settings[:cache][:connected] = true + log.info 'Legion::Cache using in-memory adapter (lite mode)' + return + end + + log.debug { "Legion::Cache setup driver=#{Legion::Settings[:cache][:driver]} servers=#{Array(Legion::Settings[:cache][:servers]).size}" } + setup_local + setup_shared(**) end def shutdown - Legion::Logging.info 'Shutting down Legion::Cache' - close + log.info 'Shutting down Legion::Cache' + # 1. Drain async writer FIRST (while pool is still alive) + async_writer.stop(timeout: configured_shutdown_timeout) + # 2. Stop reconnector + stop_reconnector + # 3. Now close pools + if using_memory? + Legion::Cache::Memory.shutdown + else + close unless using_local? + Legion::Cache::Local.shutdown if Legion::Cache::Local.connected? + end + @using_local.make_false + @using_memory.make_false + @connected.make_false Legion::Settings[:cache][:connected] = false end + + def local + Legion::Cache::Local + end + + def pool + @client + end + + def using_local? + @using_local&.true? || false + end + + def using_memory? + @using_memory&.true? || false + end + + def client(**opts) + if ENV['LEGION_MODE'] == 'lite' + Legion::Cache::Memory.setup unless Legion::Cache::Memory.connected? + @using_memory.make_true + @using_local.make_false + @connected.make_true + @active_shared_driver = nil + Legion::Settings[:cache][:connected] = true if defined?(Legion::Settings) + return Legion::Cache::Memory.client + end + + configure_shared_adapter!(opts[:driver]) + @using_memory.make_false + @using_local.make_false + result = super + # super (Pool) sets @connected to a plain boolean; restore AtomicBoolean + @connected = Concurrent::AtomicBoolean.new(true) + Legion::Settings[:cache][:connected] = true if defined?(Legion::Settings) + result + rescue StandardError + @connected = Concurrent::AtomicBoolean.new(false) + Legion::Settings[:cache][:connected] = false if defined?(Legion::Settings) + raise + end + + def get(key) + return Legion::Cache::Memory.get(key) if using_memory? + return Legion::Cache::Local.get(key) if using_local? + return Legion::Cache::Local.get(key) if failback_to_local? + + configure_shared_adapter! + super + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :cache_get, key: key) + nil + end + + def phi_max_ttl + return 3600 unless defined?(Legion::Settings) + + Legion::Settings.dig(:cache, :compliance, :phi_max_ttl) || 3600 + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :cache_phi_max_ttl) + 3600 + end + + def enforce_phi_ttl(ttl, phi: false, **) + return ttl unless phi == true + + max = phi_max_ttl + [ttl, max].min + end + + def set(key, value, ttl: nil, async: true, phi: false) + effective_ttl = resolve_ttl(ttl, phi: phi) + + if async && async_writer.running? + async_writer.enqueue { set_internal(key, value, ttl: effective_ttl) } + true + else + set_internal(key, value, ttl: effective_ttl) + end + end + + def set_nx(key, value, ttl: nil) + effective_ttl = resolve_ttl(ttl) + return Legion::Cache::Memory.set_nx(key, value, ttl: effective_ttl) if using_memory? + return Legion::Cache::Local.set_nx(key, value, ttl: effective_ttl) if using_local? + return Legion::Cache::Local.set_nx(key, value, ttl: effective_ttl) if failback_to_local? + + configure_shared_adapter! + super + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :cache_set_nx, key: key) + false + end + + def set_sync(key, value, ttl: nil, **) + return Legion::Cache::Memory.set_sync(key, value, ttl: ttl) if using_memory? + return Legion::Cache::Local.set_sync(key, value, ttl: ttl) if using_local? + return Legion::Cache::Local.set_sync(key, value, ttl: ttl) if failback_to_local? + + configure_shared_adapter! + super + end + + def fetch(key, ttl: nil, &) + return Legion::Cache::Memory.fetch(key, ttl: ttl, &) if using_memory? + return Legion::Cache::Local.fetch(key, ttl: ttl, &) if using_local? + return Legion::Cache::Local.fetch(key, ttl: ttl, &) if failback_to_local? + + configure_shared_adapter! + super + end + + def delete(key, async: true) + if async && async_writer.running? + async_writer.enqueue { delete_internal(key) } + true + else + delete_internal(key) + end + end + + def delete_sync(key) + return Legion::Cache::Memory.delete_sync(key) if using_memory? + return Legion::Cache::Local.delete_sync(key) if using_local? + return Legion::Cache::Local.delete_sync(key) if failback_to_local? + + configure_shared_adapter! + super + end + + def flush + return Legion::Cache::Memory.flush if using_memory? + return Legion::Cache::Local.flush if using_local? + return Legion::Cache::Local.flush if failback_to_local? + + configure_shared_adapter! + super + end + + def mget(*keys) + keys = keys.flatten + return {} if keys.empty? + return keys.to_h { |key| [key, Legion::Cache::Memory.get(key)] } if using_memory? + return Legion::Cache::Local.mget(*keys) if using_local? + return Legion::Cache::Local.mget(*keys) if failback_to_local? + + configure_shared_adapter! + super + end + + def mset(hash, ttl: nil, async: true) + return true if hash.empty? + + if async && async_writer.running? + async_writer.enqueue { mset_internal(hash, ttl: ttl) } + true + else + mset_internal(hash, ttl: ttl) + end + end + + def mset_sync(hash, ttl: nil, **) + return true if hash.empty? + return hash.each { |key, value| Legion::Cache::Memory.set_sync(key, value, ttl: ttl) } && true if using_memory? + return Legion::Cache::Local.mset(hash, ttl: ttl) if using_local? + return Legion::Cache::Local.mset(hash, ttl: ttl) if failback_to_local? + + configure_shared_adapter! + super + end + + def close + if using_memory? + Legion::Cache::Memory.shutdown + @using_memory.make_false + @connected.make_false + Legion::Settings[:cache][:connected] = false if defined?(Legion::Settings) + return false + end + + if using_local? + Legion::Cache::Local.close + @using_local.make_false + @connected.make_false + Legion::Settings[:cache][:connected] = false if defined?(Legion::Settings) + return false + end + + return false unless instance_variable_defined?(:@client) && @client + + configure_shared_adapter! + result = super + @connected = Concurrent::AtomicBoolean.new(false) + Legion::Settings[:cache][:connected] = false if defined?(Legion::Settings) + result + end + + def restart(**opts) + configure_shared_adapter!(opts[:driver]) + @using_memory.make_false + @using_local.make_false + result = super + @connected = Concurrent::AtomicBoolean.new(true) + Legion::Settings[:cache][:connected] = true if defined?(Legion::Settings) + result + end + + def size + return Legion::Cache::Memory.size if using_memory? + return Legion::Cache::Local.size if using_local? + + configure_shared_adapter! + super + end + + def available + return Legion::Cache::Memory.available if using_memory? + return Legion::Cache::Local.available if using_local? + + configure_shared_adapter! + super + end + + def pool_size + return Legion::Cache::Memory.size if using_memory? + return Legion::Cache::Local.pool_size if using_local? + + configure_shared_adapter! + super + end + + def timeout + return 0 if using_memory? + return Legion::Cache::Local.timeout if using_local? + + configure_shared_adapter! + super + end + + private + + def async_writer + Legion::Cache.instance_variable_get(:@async_writer) + end + + def set_internal(key, value, ttl: nil) + return Legion::Cache::Memory.set(key, value, ttl: ttl) if using_memory? + return Legion::Cache::Local.set(key, value, ttl: ttl) if using_local? + return Legion::Cache::Local.set(key, value, ttl: ttl) if failback_to_local? + + configure_shared_adapter! + set_sync(key, value, ttl: ttl) + end + + def delete_internal(key) + return Legion::Cache::Memory.delete(key) if using_memory? + return Legion::Cache::Local.delete(key) if using_local? + return Legion::Cache::Local.delete(key) if failback_to_local? + + configure_shared_adapter! + delete_sync(key) + end + + def mset_internal(hash, ttl: nil) + return hash.each { |key, value| Legion::Cache::Memory.set(key, value, ttl: ttl) } && true if using_memory? + return Legion::Cache::Local.mset(hash, ttl: ttl) if using_local? + return Legion::Cache::Local.mset(hash, ttl: ttl) if failback_to_local? + + configure_shared_adapter! + mset_sync(hash, ttl: ttl) + end + + def failback_to_local? + return false unless Legion::Cache::Local.connected? + + setting = if defined?(Legion::Settings) + Legion::Settings.dig(:cache, :failback_to_local) != false + else + true + end + setting && (!enabled? || !connected?) + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :cache_failback_check) + false + end + + def resolve_ttl(ttl, phi: false) + effective = ttl || default_ttl + enforce_phi_ttl(effective, phi: phi) + end + + def default_ttl + return 3600 unless defined?(Legion::Settings) + + Legion::Settings.dig(:cache, :default_ttl) || 3600 + rescue StandardError + 3600 + end + + def setup_local + return if Legion::Cache::Local.connected? + + Legion::Cache::Local.setup + rescue StandardError => e + report_exception(e, level: :warn, handled: true, operation: :setup_local) + end + + def setup_shared(**) + client(**Legion::Settings[:cache], logger: log, **) + @connected.make_true + @using_local.make_false + Legion::Settings[:cache][:connected] = true + driver = Legion::Settings[:cache][:driver] || 'dalli' + servers = Array(Legion::Settings[:cache][:servers]).join(', ') + log.info "Legion::Cache connected (driver=#{driver}) to #{servers}" + rescue StandardError => e + report_exception(e, level: :warn, handled: true, operation: :setup_shared, fallback: :local) + if Legion::Cache::Local.connected? + @using_local.make_true + @connected.make_true + Legion::Settings[:cache][:connected] = true + log.info 'Legion::Cache fell back to Local cache' + else + @connected.make_false + Legion::Settings[:cache][:connected] = false + log.error 'Legion::Cache shared and local adapters are unavailable' + end + start_reconnector if enabled? + end + + def reconnect_shared! + client(**Legion::Settings[:cache], logger: log) + @connected.make_true + @using_local.make_false + Legion::Settings[:cache][:connected] = true + log.info 'Legion::Cache shared reconnected' + end + + def report_exception(exception, level:, handled:, **) + handle_exception(exception, level: level, handled: handled, **) + end + + def configure_shared_adapter!(requested_driver = nil) + driver = Legion::Cache::Settings.normalize_driver(requested_driver || configured_shared_driver) + return if @active_shared_driver == driver + + close_existing_shared_client + extend build_shared_adapter(driver) + + @active_shared_driver = driver + log.info "Legion::Cache selected shared adapter=#{driver}" + end + + def configured_shared_driver + if defined?(Legion::Settings) + Legion::Cache::Settings.normalize_driver(Legion::Settings.dig(:cache, :driver) || Legion::Cache::Settings.driver) + else + Legion::Cache::Settings.driver + end + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :cache_configured_shared_driver) + 'dalli' + end + + def build_shared_adapter(driver) + case Legion::Cache::Settings.normalize_driver(driver) + when 'redis' + Legion::Cache::Redis.dup + else + Legion::Cache::Memcached.dup + end + end + + def close_existing_shared_client + return unless instance_variable_defined?(:@client) && @client + + @client.shutdown(&:close) + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :cache_close_existing_shared_client) + ensure + @client = nil + @connected = Concurrent::AtomicBoolean.new(false) + end + + def resolved_servers + return [] if using_memory? + + Array(Legion::Settings.dig(:cache, :servers)) + rescue StandardError + [] + end + + def safe_pool_size + return 1 if using_memory? + return 0 unless connected? + + pool_size + rescue StandardError + 0 + end + + def safe_pool_available + return 1 if using_memory? + return 0 unless connected? + + available + rescue StandardError + 0 + end + + def async_writer_pool_size + async_writer.pool_size + rescue StandardError + 0 + end + + def async_writer_queue_depth + async_writer.queue_depth + rescue StandardError + 0 + end + + def async_writer_processed_count + async_writer.processed_count + rescue StandardError + 0 + end + + def configured_shutdown_timeout + return 5 unless defined?(Legion::Settings) + + Legion::Settings.dig(:cache, :async, :shutdown_timeout) || 5 + rescue StandardError + 5 + end + + def async_writer_failed_count + async_writer.failed_count + rescue StandardError + 0 + end + + def reconnector_attempts + @reconnector&.attempts || 0 + end + + def start_reconnector + return unless enabled? + + stop_reconnector + @reconnector = Legion::Cache::Reconnector.new( + tier: :shared, + connect_block: -> { reconnect_shared! }, + enabled_block: -> { enabled? }, + settings_key: :cache + ) + @reconnector.start + log.info 'Legion::Cache started background reconnector for shared tier' + end + + def stop_reconnector + @reconnector&.stop + @reconnector = nil + end + + def uptime_seconds + return 0 unless @setup_at + + (Time.now - @setup_at).to_i + rescue StandardError + 0 + end end end end diff --git a/lib/legion/cache/async_writer.rb b/lib/legion/cache/async_writer.rb new file mode 100644 index 0000000..3df0ebd --- /dev/null +++ b/lib/legion/cache/async_writer.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'concurrent-ruby' +require 'legion/logging/helper' + +module Legion + module Cache + class AsyncWriter + include Legion::Logging::Helper + + DEFAULT_POOL_SIZE = 4 + DEFAULT_QUEUE_SIZE = 1000 + DEFAULT_SHUTDOWN_TIMEOUT = 5 + + def initialize(pool_size: nil, queue_size: nil, shutdown_timeout: nil, settings_key: :cache) + @settings_key = settings_key + @config_pool_size = pool_size + @config_queue_size = queue_size + @config_shutdown_timeout = shutdown_timeout + @processed = Concurrent::AtomicFixnum.new(0) + @failed = Concurrent::AtomicFixnum.new(0) + @executor = nil + @mutex = Mutex.new + end + + def start(pool_size: nil, queue_size: nil, **) + @mutex.synchronize do + return if running? + + ps = pool_size || @config_pool_size || configured_pool_size + qs = queue_size || @config_queue_size || configured_queue_size + + @executor = Concurrent::ThreadPoolExecutor.new( + min_threads: 1, + max_threads: ps, + max_queue: qs, + fallback_policy: :caller_runs + ) + log.info "Legion::Cache::AsyncWriter started pool_size=#{ps} queue_size=#{qs}" + end + end + + def stop(timeout: nil) + @mutex.synchronize do + return unless @executor + + to = timeout || @config_shutdown_timeout || configured_shutdown_timeout + @executor.shutdown + unless @executor.wait_for_termination(to) + @executor.kill + log.warn "Legion::Cache::AsyncWriter force-killed after #{to}s timeout" + end + log.info "Legion::Cache::AsyncWriter stopped processed=#{@processed.value}" + @executor = nil + end + end + + def enqueue(&block) + executor = @executor + if executor&.running? + executor.post do + block.call + @processed.increment + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :async_writer_job) + @failed.increment + end + else + begin + block.call + @processed.increment + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :async_writer_sync_fallback) + @failed.increment + end + end + end + + def running? + @executor&.running? == true + end + + def pool_size + @executor&.max_length || 0 + end + + def queue_depth + @executor&.queue_length || 0 + end + + def processed_count + @processed.value + end + + def failed_count + @failed.value + end + + private + + def configured_pool_size + return DEFAULT_POOL_SIZE unless defined?(Legion::Settings) + + Legion::Settings.dig(@settings_key, :async, :pool_size) || DEFAULT_POOL_SIZE + rescue StandardError + DEFAULT_POOL_SIZE + end + + def configured_queue_size + return DEFAULT_QUEUE_SIZE unless defined?(Legion::Settings) + + Legion::Settings.dig(@settings_key, :async, :queue_size) || DEFAULT_QUEUE_SIZE + rescue StandardError + DEFAULT_QUEUE_SIZE + end + + def configured_shutdown_timeout + return DEFAULT_SHUTDOWN_TIMEOUT unless defined?(Legion::Settings) + + Legion::Settings.dig(@settings_key, :async, :shutdown_timeout) || DEFAULT_SHUTDOWN_TIMEOUT + rescue StandardError + DEFAULT_SHUTDOWN_TIMEOUT + end + end + end +end diff --git a/lib/legion/cache/cacheable.rb b/lib/legion/cache/cacheable.rb new file mode 100644 index 0000000..ce9eaf3 --- /dev/null +++ b/lib/legion/cache/cacheable.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require 'digest' +require 'legion/logging/helper' + +module Legion + module Cache + module Cacheable + extend Legion::Logging::Helper + + LOCAL_CACHE_MISS = Object.new + + def self.extended(base) + base.instance_variable_set(:@cached_methods, {}) + end + + def cached_methods + @cached_methods ||= {} + end + + def cache_method(method_name, ttl:, scope: :local, exclude_from_key: []) + exclude_from_key |= %i[token bypass_local_method_cache] + cached_methods[method_name] = { ttl: ttl, scope: scope, exclude_from_key: exclude_from_key } + + mod_name = name || 'Anonymous' + config = cached_methods[method_name] + + wrapper = Module.new do + define_method(method_name) do |bypass_local_method_cache: false, **kwargs| + key = Legion::Cache::Cacheable.build_cache_key( + mod_name, method_name, exclude: config[:exclude_from_key], **kwargs + ) + + unless bypass_local_method_cache + cached = Legion::Cache::Cacheable.cache_read(key, scope: config[:scope]) + if cached.nil? + Legion::Cache::Cacheable.log.debug { "[cacheable] miss key=#{key}" } + else + Legion::Cache::Cacheable.log.debug { "[cacheable] hit key=#{key}" } + return cached + end + end + + result = super(**kwargs) + Legion::Cache::Cacheable.cache_write(key, result, ttl: config[:ttl], scope: config[:scope]) + result + end + end + + prepend wrapper + end + + def self.build_cache_key(mod_name, method_name, exclude:, **kwargs) + filtered = kwargs.except(*exclude) + args_hash = Digest::MD5.hexdigest(filtered.sort.to_s) + "#{mod_name}.#{method_name}.#{args_hash}" + end + + def self.cache_read(key, scope:) + case scope + when :global + return Legion::Cache.get(key) if global_cache_available? + + memory_read(key) + else + result = local_cache_read(key) + result.equal?(LOCAL_CACHE_MISS) ? memory_read(key) : result + end + end + + def self.cache_write(key, value, ttl:, scope:) + case scope + when :global + if global_cache_available? + Legion::Cache.set(key, value, ttl: ttl, async: false) + else + memory_write(key, value, ttl) + end + else + if local_cache_available? + result = local_cache_write(key, value, ttl: ttl) + memory_write(key, value, ttl) unless result + else + memory_write(key, value, ttl) + end + end + end + + def self.global_cache_available? + defined?(Legion::Cache) && Legion::Cache.respond_to?(:connected?) && Legion::Cache.connected? + end + + def self.local_cache_available? + defined?(Legion::Cache::Local) && Legion::Cache::Local.respond_to?(:connected?) && Legion::Cache::Local.connected? + end + + def self.local_cache_read(key) + return LOCAL_CACHE_MISS unless local_cache_available? + + result = Legion::Cache::Local.get(key) + result.nil? ? LOCAL_CACHE_MISS : result + rescue StandardError => e + handle_exception(e, level: :warn, operation: :local_cache_read, key: key) + LOCAL_CACHE_MISS + end + + def self.local_cache_write(key, value, ttl:) + return unless local_cache_available? + + Legion::Cache::Local.set(key, value, ttl: ttl, async: false) + rescue StandardError => e + handle_exception(e, level: :warn, operation: :local_cache_write, key: key, ttl: ttl) + nil + end + + # In-memory fallback store (class-level, process-wide) + def self.memory_store + @memory_store ||= {} + end + + def self.memory_read(key) + entry = memory_store[key] + return nil unless entry + return nil if Time.now.utc > entry[:expires_at] + + entry[:value] + end + + def self.memory_write(key, value, ttl) + memory_store[key] = { value: value, expires_at: Time.now.utc + ttl } + end + + def self.memory_clear! + @memory_store = {} + end + end + end +end diff --git a/lib/legion/cache/helper.rb b/lib/legion/cache/helper.rb new file mode 100644 index 0000000..967f832 --- /dev/null +++ b/lib/legion/cache/helper.rb @@ -0,0 +1,352 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' + +module Legion + module Cache + module Helper + include Legion::Logging::Helper + + FALLBACK_TTL = 3600 + + # --- TTL Resolution --- + # Override in your LEX to set a custom default TTL for the extension. + # Resolution chain: per-call ttl: kwarg -> LEX override -> Settings -> FALLBACK_TTL + def cache_default_ttl + return FALLBACK_TTL unless defined?(Legion::Settings) + + Legion::Settings.dig(:cache, :default_ttl) || FALLBACK_TTL + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :cache_default_ttl) + FALLBACK_TTL + end + + def local_cache_default_ttl + return cache_default_ttl unless defined?(Legion::Settings) + + Legion::Settings.dig(:cache_local, :default_ttl) || cache_default_ttl + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :local_cache_default_ttl) + cache_default_ttl + end + + # --- Namespace --- + + def cache_namespace + @cache_namespace ||= derive_cache_namespace + end + + # --- Core Operations (shared tier) --- + + def cache_set(key, value, ttl: nil, phi: false) + effective_ttl = ttl || cache_default_ttl + Legion::Cache.set(cache_namespace + key, value, ttl: effective_ttl, async: false, phi: phi) + end + + def cache_get(key) + Legion::Cache.get(cache_namespace + key) + end + + def cache_delete(key) + Legion::Cache.delete(cache_namespace + key, async: false) + end + + def cache_fetch(key, ttl: nil, &) + effective_ttl = ttl || cache_default_ttl + Legion::Cache.fetch(cache_namespace + key, ttl: effective_ttl, &) + end + + def cache_exist?(key) + !Legion::Cache.get(cache_namespace + key).nil? + end + + # --- Batch Operations (shared tier) --- + # Issue #3: mget/mset with Memcached safety + + # Returns a Hash of { key => value } pairs. Prefixes all keys with cache_namespace. + # Delegates to Legion::Cache.mget on Redis; falls back to sequential gets on Memcached. + def cache_mget(*keys) + keys = keys.flatten + return {} if keys.empty? + + namespaced = keys.map { |k| cache_namespace + k } + + if cache_redis? + raw = Legion::Cache.mget(*namespaced) + keys.to_h { |k| [k, raw[cache_namespace + k]] } + else + keys.to_h { |k| [k, Legion::Cache.get(cache_namespace + k)] } + end + rescue StandardError => e + log_cache_error('cache_mget', e) + {} + end + + # Stores multiple key-value pairs. Accepts a Hash of { key => value }. + # TTL follows the same resolution chain as cache_set. + # Delegates to Legion::Cache.mset on Redis; falls back to sequential sets on Memcached. + def cache_mset(hash, ttl: nil) + return true if hash.empty? + + effective_ttl = ttl || cache_default_ttl + + hash.each { |k, v| Legion::Cache.set(cache_namespace + k, v, ttl: effective_ttl, async: false) } + true + rescue StandardError => e + log_cache_error('cache_mset', e) + false + end + + # --- Batch Operations (local tier) --- + + def local_cache_mget(*keys) + keys = keys.flatten + return {} if keys.empty? + + keys.to_h { |k| [k, Legion::Cache::Local.get(cache_namespace + k)] } + rescue StandardError => e + log_cache_error('local_cache_mget', e) + {} + end + + def local_cache_mset(hash, ttl: nil) + return true if hash.empty? + + effective_ttl = ttl || local_cache_default_ttl + + hash.each { |k, v| Legion::Cache::Local.set(cache_namespace + k, v, ttl: effective_ttl, async: false) } + true + rescue StandardError => e + log_cache_error('local_cache_mset', e) + false + end + + # --- RedisHash Helpers (shared tier) --- + # Issue #4: namespaced wrappers for RedisHash operations with Memcached fallback + + def cache_hset(key, hash) + if cache_redis? + Legion::Cache::RedisHash.hset(cache_namespace + key, hash) + else + memcached_hash_merge(cache_namespace + key, hash) + end + rescue StandardError => e + log_cache_error('cache_hset', e) + false + end + + def cache_hgetall(key) + if cache_redis? + Legion::Cache::RedisHash.hgetall(cache_namespace + key) + else + memcached_hash_load(cache_namespace + key) + end + rescue StandardError => e + log_cache_error('cache_hgetall', e) + nil + end + + def cache_hdel(key, *fields) + if cache_redis? + Legion::Cache::RedisHash.hdel(cache_namespace + key, *fields) + else + memcached_hash_delete_fields(cache_namespace + key, fields) + end + rescue StandardError => e + log_cache_error('cache_hdel', e) + 0 + end + + def cache_zadd(key, score, member) + raise_sorted_set_unsupported('cache_zadd') unless cache_redis? + + Legion::Cache::RedisHash.zadd(cache_namespace + key, score, member) + rescue NotImplementedError + raise + rescue StandardError => e + log_cache_error('cache_zadd', e) + false + end + + def cache_zrangebyscore(key, min, max, limit: nil) + raise_sorted_set_unsupported('cache_zrangebyscore') unless cache_redis? + + Legion::Cache::RedisHash.zrangebyscore(cache_namespace + key, min, max, limit: limit) + rescue NotImplementedError + raise + rescue StandardError => e + log_cache_error('cache_zrangebyscore', e) + [] + end + + def cache_zrem(key, member) + raise_sorted_set_unsupported('cache_zrem') unless cache_redis? + + Legion::Cache::RedisHash.zrem(cache_namespace + key, member) + rescue NotImplementedError + raise + rescue StandardError => e + log_cache_error('cache_zrem', e) + false + end + + # Sets TTL on a key. No-op on Memcached (TTL is set at write time). + def cache_expire(key, seconds) + return false unless cache_redis? + + Legion::Cache::RedisHash.expire(cache_namespace + key, seconds) + rescue StandardError => e + log_cache_error('cache_expire', e) + false + end + + # --- Core Operations (local tier) --- + + def local_cache_set(key, value, ttl: nil, phi: false) + effective_ttl = ttl || local_cache_default_ttl + effective_ttl = Legion::Cache.enforce_phi_ttl(effective_ttl, phi: phi) + Legion::Cache::Local.set(cache_namespace + key, value, ttl: effective_ttl, async: false) + end + + def local_cache_get(key) + Legion::Cache::Local.get(cache_namespace + key) + end + + def local_cache_delete(key) + Legion::Cache::Local.delete(cache_namespace + key, async: false) + end + + def local_cache_fetch(key, ttl: nil, &) + effective_ttl = ttl || local_cache_default_ttl + Legion::Cache::Local.fetch(cache_namespace + key, ttl: effective_ttl, &) + end + + def local_cache_exist?(key) + !Legion::Cache::Local.get(cache_namespace + key).nil? + end + + # --- Status --- + + def cache_connected? + Legion::Cache.connected? + end + + def local_cache_connected? + Legion::Cache::Local.connected? + end + + # --- Pool Info --- + + def cache_pool_size + return 0 unless cache_connected? + + Legion::Cache.pool_size + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :cache_pool_size) + 0 + end + + def cache_pool_available + return 0 unless cache_connected? + + Legion::Cache.available + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :cache_pool_available) + 0 + end + + def local_cache_pool_size + return 0 unless local_cache_connected? + + Legion::Cache::Local.pool_size + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :local_cache_pool_size) + 0 + end + + def local_cache_pool_available + return 0 unless local_cache_connected? + + Legion::Cache::Local.available + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :local_cache_pool_available) + 0 + end + + private + + def derive_cache_namespace + if respond_to?(:lex_filename) + fname = lex_filename + fname.is_a?(Array) ? fname.first : fname + else + derive_cache_namespace_from_class + end + end + + def derive_cache_namespace_from_class + name = respond_to?(:ancestors) ? ancestors.first.to_s : self.class.to_s + parts = name.split('::') + ext_idx = parts.index('Extensions') + target = if ext_idx && parts[ext_idx + 1] + parts[ext_idx + 1] + else + parts.last + end + target.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z\d])([A-Z])/, '\1_\2') + .downcase + end + + def cache_redis? + Legion::Cache::RedisHash.redis_available? + end + + def local_cache_redis? + defined?(Legion::Cache::Local) && + Legion::Cache::Local.connected? && + Legion::Cache::Local.respond_to?(:driver_name) && + Legion::Cache::Local.driver_name == 'redis' + end + + def memcached_hash_merge(full_key, new_fields) + current = memcached_hash_load(full_key) || {} + merged = current.merge(new_fields.transform_keys(&:to_s)) + Legion::Cache.set(full_key, Legion::JSON.dump(merged), ttl: cache_default_ttl, async: false) + true + end + + def memcached_hash_load(full_key) + raw = Legion::Cache.get(full_key) + return nil if raw.nil? + + parsed = Legion::JSON.load(raw) + # Legion::JSON.load returns symbol keys; convert to string keys to mirror Redis hgetall + parsed.transform_keys(&:to_s) + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :memcached_hash_load, key: full_key) + nil + end + + def memcached_hash_delete_fields(full_key, fields) + current = memcached_hash_load(full_key) + return 0 if current.nil? + + str_fields = fields.map(&:to_s) + removed = str_fields.count { |f| current.key?(f) } + str_fields.each { |f| current.delete(f) } + Legion::Cache.set(full_key, Legion::JSON.dump(current), ttl: cache_default_ttl, async: false) + removed + end + + def raise_sorted_set_unsupported(method) + raise NotImplementedError, + "#{method} requires a Redis backend — sorted sets are not supported on Memcached" + end + + def log_cache_error(method, error) + handle_exception(error, level: :warn, operation: method) + end + end + end +end diff --git a/lib/legion/cache/local.rb b/lib/legion/cache/local.rb new file mode 100644 index 0000000..9a18b11 --- /dev/null +++ b/lib/legion/cache/local.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +require 'concurrent' +require 'legion/logging/helper' +require 'legion/cache/settings' + +module Legion + module Cache + module Local + @connected = Concurrent::AtomicBoolean.new(false) + + class << self + include Legion::Logging::Helper + + def setup(**) + return unless enabled? + return if connected? + + settings = local_settings + return unless settings[:enabled] + + driver_name = settings[:driver] || Legion::Cache::Settings.driver + @driver_name = Legion::Cache::Settings.normalize_driver(driver_name) + @driver = build_driver(driver_name) + @driver.client(**settings, logger: log, **) + @connected.make_true + servers = Array(settings[:servers]).join(', ') + log.info "Legion::Cache::Local connected (#{driver_name}) to #{servers}" + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :cache_local_setup, driver: driver_name) + @connected.make_false + end + + def shutdown + return unless @connected + + log.info 'Shutting down Legion::Cache::Local' + @driver&.close + @driver = nil + @driver_name = nil + @connected.make_false + end + + def enabled? + return true unless defined?(Legion::Settings) + + Legion::Settings.dig(:cache_local, :enabled) != false + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :cache_local_enabled) + true + end + + def connected? + @connected&.true? || false + end + + def driver_name + @driver_name || Legion::Cache::Settings.normalize_driver(local_settings[:driver] || Legion::Cache::Settings.driver) + end + + def get(key) + result = @driver.get(key) + log.debug { "[cache:local] GET #{key} hit=#{!result.nil?}" } + result + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :cache_local_get, key: key) + nil + end + + def set(key, value, ttl: nil, **) + set_sync(key, value, ttl: ttl, **) + end + + def set_sync(key, value, ttl: nil, **) + effective_ttl = ttl || local_default_ttl + result = @driver.set_sync(key, value, ttl: effective_ttl) + log.debug { "[cache:local] SET #{key} ttl=#{effective_ttl} success=#{result}" } + result + rescue StandardError => e + handle_exception(e, level: :error, handled: false, operation: :cache_local_set_sync, key: key, ttl: effective_ttl) + raise + end + + def fetch(key, ttl: nil, &) + result = @driver.fetch(key, ttl: ttl, &) + log.debug { "[cache:local] FETCH #{key} hit=#{!result.nil?}" } + result + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :cache_local_fetch, key: key, ttl: ttl) + nil + end + + def delete(key, **) + delete_sync(key) + end + + def delete_sync(key) + result = @driver.delete_sync(key) + log.debug { "[cache:local] DELETE #{key} success=#{result}" } + result + rescue StandardError => e + handle_exception(e, level: :error, handled: false, operation: :cache_local_delete_sync, key: key) + raise + end + + def flush + result = @driver.flush + log.debug { '[cache:local] FLUSH completed' } + result + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :cache_local_flush) + nil + end + + def mget(*keys) + keys = keys.flatten + return {} if keys.empty? + + keys.to_h { |key| [key, get(key)] } + end + + def mset(hash, ttl: nil, **) + return true if hash.empty? + + hash.each { |key, value| set(key, value, ttl: ttl) } + true + end + + def stats + { + driver: driver_name, + servers: local_servers, + enabled: enabled?, + connected: connected? + }.freeze + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :cache_local_stats) + { error: e.message }.freeze + end + + def client + @driver&.client + end + + def close + @driver&.close + @driver = nil + @driver_name = nil + @connected.make_false + log.info 'Legion::Cache::Local pool closed' + @connected + end + + def restart(**opts) + settings = local_settings + @driver&.restart(**settings.merge(opts, logger: log)) + @connected.make_true + log.info 'Legion::Cache::Local pool restarted' + @connected + end + + def size + @driver.size + end + + def available + @driver.available + end + + def pool_size + @driver.pool_size + end + + def timeout + @driver.timeout + end + + def reset! + @driver = nil + @driver_name = nil + @connected.make_false + log.debug 'Legion::Cache::Local state reset' + @connected + end + + private + + def local_servers + Array(local_settings[:servers]) + rescue StandardError + [] + end + + def local_default_ttl + return 21_600 unless defined?(Legion::Settings) + + Legion::Settings.dig(:cache_local, :default_ttl) || 21_600 + rescue StandardError + 21_600 + end + + def build_driver(driver_name) + case Legion::Cache::Settings.normalize_driver(driver_name) + when 'redis' + require 'legion/cache/redis' + Legion::Cache::Redis.dup + else + require 'legion/cache/memcached' + Legion::Cache::Memcached.dup + end + end + + def local_settings + return Legion::Cache::Settings.local unless defined?(Legion::Settings) + + Legion::Settings[:cache_local] || Legion::Cache::Settings.local + end + end + end + end +end diff --git a/lib/legion/cache/memcached.rb b/lib/legion/cache/memcached.rb index 2d4c41e..ad339b4 100644 --- a/lib/legion/cache/memcached.rb +++ b/lib/legion/cache/memcached.rb @@ -1,4 +1,8 @@ +# frozen_string_literal: true + +require 'openssl' require 'dalli' +require 'legion/logging/helper' require 'legion/cache/pool' module Legion @@ -6,40 +10,174 @@ module Cache module Memcached include Legion::Cache::Pool extend self + extend Legion::Logging::Helper - def client(servers: Legion::Settings[:cache][:servers], **opts) + def client(server: nil, servers: nil, pool_size: nil, timeout: nil, # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/ParameterLists + username: nil, password: nil, logger: nil, **opts) return @client unless @client.nil? - @pool_size = opts.key?(:pool_size) ? opts[:pool_size] : Legion::Settings[:cache][:pool_size] || 10 - @timeout = opts.key?(:timeout) ? opts[:timeout] : Legion::Settings[:cache][:timeout] || 5 + settings = defined?(Legion::Settings) ? Legion::Settings[:cache] : {} + servers ||= settings[:servers] || [] + @component_logger = logger || log + + @pool_size = pool_size || settings[:pool_size] || 10 + @timeout = timeout || settings[:timeout] || 5 + + resolved = Legion::Cache::Settings.resolve_servers( + driver: 'memcached', server: server, servers: Array(servers) + ) + + Dalli.logger = log + cache_opts = settings.merge(opts) + cache_opts[:value_max_bytes] ||= 8 * 1024 * 1024 + cache_opts[:serializer] ||= Legion::JSON + cache_opts[:username] = username unless username.nil? + cache_opts[:password] = password unless password.nil? - Dalli.logger = Legion::Logging - @client = ConnectionPool.new(size: pool_size, timeout: timeout) do - Dalli::Client.new(servers, Legion::Settings[:cache].merge(opts)) + tls_ctx = memcached_tls_context(port: resolved.first.split(':').last.to_i) + cache_opts[:ssl_context] = tls_ctx if tls_ctx + + checkout_timeout = opts[:pool_checkout_timeout] || settings[:pool_checkout_timeout] || @timeout + @client = ConnectionPool.new(size: @pool_size, timeout: checkout_timeout) do + Dalli::Client.new(resolved, cache_opts) end @connected = true + log.info "Memcached connected to #{resolved.join(', ')}" @client + rescue StandardError => e + handle_exception(e, level: :error, handled: false, operation: :memcached_client, + server: server, servers: Array(servers)) + @connected = false + raise end def get(key) - client.with { |conn| conn.get(key) } + result = client.with { |conn| conn.get(key) } + log.debug { "[cache] GET #{key} hit=#{!result.nil?}" } + result + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :memcached_get, key: key) + nil + end + + def fetch(key, ttl: nil, &) + result = client.with do |conn| + if block_given? + conn.fetch(key, ttl, &) + else + conn.fetch(key, ttl) + end + end + log.debug { "[cache] FETCH #{key} hit=#{!result.nil?}" } + result + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :memcached_fetch, key: key, ttl: ttl) + nil end - def fetch(key, ttl = nil) - client.with { |conn| conn.fetch(key, ttl) } + def set_nx(key, value, ttl: nil) + effective_ttl = ttl || default_ttl + result = client.with { |conn| conn.add(key, value, effective_ttl) == true } + log.debug { "[cache] SET_NX #{key} ttl=#{effective_ttl.inspect} result=#{result}" } + result + rescue StandardError => e + handle_exception(e, level: :error, handled: false, operation: :memcached_set_nx, key: key, ttl: effective_ttl) + raise end - def set(key, value, ttl = 180) - client.with { |conn| conn.set(key, value, ttl).positive? } + def set(key, value, ttl: nil, **) + set_sync(key, value, ttl: ttl, **) end - def delete(key) - client.with { |conn| conn.delete(key) == true } + def set_sync(key, value, ttl: nil, **) + effective_ttl = ttl || default_ttl + result = client.with { |conn| conn.set(key, value, effective_ttl).positive? } + log.debug { "[cache] SET #{key} ttl=#{effective_ttl} success=#{result} value_class=#{value.class}" } + result + rescue StandardError => e + handle_exception(e, level: :error, handled: false, operation: :memcached_set_sync, key: key, ttl: effective_ttl) + raise end - def flush(delay = 0) - client.with { |conn| conn.flush(delay).first } + def delete(key, **) + delete_sync(key) + end + + def delete_sync(key) + result = client.with { |conn| conn.delete(key) == true } + log.debug { "[cache] DELETE #{key} success=#{result}" } + result + rescue StandardError => e + handle_exception(e, level: :error, handled: false, operation: :memcached_delete_sync, key: key) + raise + end + + def flush + result = client.with { |conn| conn.flush.first } + log.debug { '[cache] FLUSH completed' } + result + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :memcached_flush) + nil + end + + def mget(*keys) + keys = keys.flatten + return {} if keys.empty? + + result = client.with { |conn| conn.get_multi(*keys) } + log.debug { "[cache] MGET keys=#{keys.size} hits=#{result.size}" } + result + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :memcached_mget, key_count: keys.size) + {} + end + + def mset(hash, ttl: nil, **) + mset_sync(hash, ttl: ttl) + end + + def mset_sync(hash, ttl: nil, **) # rubocop:disable Lint/UnusedMethodArgument + return true if hash.empty? + + client.with { |conn| conn.set_multi(hash) } + log.debug { "[cache] MSET keys=#{hash.size}" } + true + rescue StandardError => e + handle_exception(e, level: :error, handled: false, operation: :memcached_mset_sync, key_count: hash.size) + raise + end + + private + + def default_ttl + return 3600 unless defined?(Legion::Settings) + + Legion::Settings.dig(:cache, :default_ttl) || 3600 + rescue StandardError + 3600 + end + + def memcached_tls_context(port:) + return nil unless defined?(Legion::Crypt::TLS) + + tls = Legion::Crypt::TLS.resolve(memcached_tls_settings, port: port) + return nil unless tls[:enabled] + + ctx = OpenSSL::SSL::SSLContext.new + ctx.verify_mode = tls[:verify] == :none ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER + ctx.ca_file = tls[:ca] if tls[:ca] + ctx + end + + def memcached_tls_settings + return {} unless defined?(Legion::Settings) + + Legion::Settings[:cache][:tls] || {} + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :memcached_tls_settings) + {} end end end diff --git a/lib/legion/cache/memory.rb b/lib/legion/cache/memory.rb new file mode 100644 index 0000000..891b80e --- /dev/null +++ b/lib/legion/cache/memory.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +require 'concurrent' +require 'legion/logging/helper' + +module Legion + module Cache + module Memory + extend self + extend Legion::Logging::Helper + + @store = {} + @expiry = {} + @mutex = Mutex.new + @connected = Concurrent::AtomicBoolean.new(false) + + def setup(**) + @connected.make_true + log.info 'Legion::Cache::Memory connected' + true + rescue StandardError => e + @connected.make_false + handle_exception(e, level: :warn, handled: true, operation: :memory_setup) + false + end + + def client(**) = self + + def connected? + @connected.true? + end + + def restart(**) + shutdown + setup + rescue StandardError => e + @connected.make_false + handle_exception(e, level: :warn, handled: true, operation: :memory_restart) + false + end + + def get(key) + @mutex.synchronize do + expire_if_needed(key) + result = @store[key] + log.debug { "[cache:memory] GET #{key} hit=#{!result.nil?}" } + result + end + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :memory_get) + nil + end + + def set(key, value, ttl: nil, async: true, phi: false) # rubocop:disable Lint/UnusedMethodArgument + set_sync(key, value, ttl: ttl, phi: phi) + end + + def set_nx(key, value, ttl: nil) + @mutex.synchronize do + expire_if_needed(key) + return false if @store.key?(key) + + @store[key] = value + if ttl&.positive? + @expiry[key] = Time.now + ttl + else + @expiry.delete(key) + end + log.debug { "[cache:memory] SET_NX #{key} ttl=#{ttl.inspect} result=true" } + true + end + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :memory_set_nx) + false + end + + def set_sync(key, value, ttl: nil, phi: false) + ttl = enforce_phi_ttl(ttl, phi: phi) if phi + @mutex.synchronize do + @store[key] = value + if ttl&.positive? + @expiry[key] = Time.now + ttl + else + @expiry.delete(key) + end + log.debug { "[cache:memory] SET #{key} ttl=#{ttl.inspect}" } + true + end + end + + def fetch(key, ttl: nil, &block) + val = get(key) + return val unless val.nil? + + log.debug { "[cache:memory] FETCH #{key} miss=true" } + val = block&.call + set(key, val, ttl: ttl) + val + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :memory_fetch) + nil + end + + def delete(key, async: true) # rubocop:disable Lint/UnusedMethodArgument + delete_sync(key) + end + + def delete_sync(key) + @mutex.synchronize do + removed = @store.delete(key) + @expiry.delete(key) + log.debug { "[cache:memory] DELETE #{key} success=#{!removed.nil?}" } + !removed.nil? + end + end + + def mget(*keys) + keys = keys.flatten + return {} if keys.empty? + + @mutex.synchronize do + keys.each { |k| expire_if_needed(k) } + keys.to_h { |k| [k, @store[k]] } + end + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :memory_mget) + {} + end + + def mset(hash, ttl: nil, async: true) + return true if hash.empty? + + hash.each { |k, v| set(k, v, ttl: ttl, async: async) } + true + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :memory_mset) + true + end + + def mset_sync(hash, ttl: nil, phi: false) + return true if hash.empty? + + @mutex.synchronize do + hash.each do |key, value| + effective_ttl = phi ? enforce_phi_ttl(ttl, phi: true) : ttl + @store[key] = value + if effective_ttl&.positive? + @expiry[key] = Time.now + effective_ttl + else + @expiry.delete(key) + end + end + true + end + end + + def flush + @mutex.synchronize do + @store.clear + @expiry.clear + end + log.info 'Legion::Cache::Memory flushed' + true + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :memory_flush) + false + end + + def close = nil + + def shutdown + flush + @connected.make_false + log.info 'Legion::Cache::Memory shut down' + false + rescue StandardError => e + @connected.make_false + handle_exception(e, level: :warn, handled: true, operation: :memory_shutdown) + false + end + + def reset! + @mutex.synchronize do + @connected.make_false + @store.clear + @expiry.clear + end + log.info 'Legion::Cache::Memory state reset' + false + end + + def size = 1 + def available = 1 + + private + + def expire_if_needed(key) + return unless @expiry.key?(key) && Time.now > @expiry[key] + + @store.delete(key) + @expiry.delete(key) + log.debug { "[cache:memory] EXPIRE #{key}" } + end + + def enforce_phi_ttl(ttl, phi: false) + return ttl unless phi + + max = if defined?(Legion::Settings) + Legion::Settings.dig(:cache, :compliance, :phi_max_ttl) || 3600 + else + 3600 + end + result = ttl.nil? ? max : [ttl, max].min + [result, 1].max + end + end + end +end diff --git a/lib/legion/cache/pool.rb b/lib/legion/cache/pool.rb index 6fb7b95..428421f 100644 --- a/lib/legion/cache/pool.rb +++ b/lib/legion/cache/pool.rb @@ -1,10 +1,19 @@ +# frozen_string_literal: true + require 'connection_pool' +require 'legion/logging/helper' module Legion module Cache module Pool + extend self + extend Legion::Logging::Helper + def connected? - @connected ||= false + return false unless defined?(@connected) + return @connected.true? if @connected.respond_to?(:true?) + + @connected == true end def size @@ -24,9 +33,12 @@ def available end def close - client.shutdown(&:close) + return unless @client + + @client.shutdown(&:close) @client = nil @connected = false + log.info "#{pool_log_name} pool closed" end def restart(**opts) @@ -37,6 +49,26 @@ def restart(**opts) client_hash[:timeout] = opts[:timeout] if opts.key? :timeout client(**client_hash) @connected = true + log.info "#{pool_log_name} pool restarted" + end + + private + + def pool_log_name + if respond_to?(:name) + label = name.to_s + return label unless label.empty? || label.start_with?('#<') + end + + segments = if instance_variable_defined?(:@component_logger) && @component_logger.respond_to?(:segments) + Array(@component_logger.segments) + elsif log.respond_to?(:segments) + Array(log.segments) + else + [] + end + + segments.empty? ? 'cache.pool' : segments.join('.') end end end diff --git a/lib/legion/cache/reconnector.rb b/lib/legion/cache/reconnector.rb new file mode 100644 index 0000000..790659b --- /dev/null +++ b/lib/legion/cache/reconnector.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'concurrent' +require 'legion/logging/helper' + +module Legion + module Cache + class Reconnector + include Legion::Logging::Helper + + DEFAULT_INITIAL_DELAY = 1 + DEFAULT_MAX_DELAY = 60 + + def initialize(tier:, connect_block:, enabled_block:, settings_key: :cache) + @tier = tier + @connect_block = connect_block + @enabled_block = enabled_block + @settings_key = settings_key + @attempts = Concurrent::AtomicFixnum.new(0) + @thread = nil + @mutex = Mutex.new + @stop_signal = Concurrent::AtomicBoolean.new(false) + @next_retry_at = nil + end + + def start + @mutex.synchronize do + return if running? + + @stop_signal.make_false + @thread = Thread.new { reconnect_loop } + log.info "Legion::Cache::Reconnector[#{@tier}] started" + end + end + + def stop + thread_to_join = nil + @mutex.synchronize do + @stop_signal.make_true + thread_to_join = @thread + @thread = nil + end + thread_to_join&.join(5) + log.info "Legion::Cache::Reconnector[#{@tier}] stopped" + end + + def running? + @thread&.alive? == true + end + + def attempts + @attempts.value + end + + attr_reader :next_retry_at + + private + + def reconnect_loop + delay = configured_initial_delay + + until @stop_signal.true? + unless @enabled_block.call + sleep 1 + next + end + + begin + @next_retry_at = Time.now + delay + sleep delay + return if @stop_signal.true? + + @connect_block.call + count = @attempts.value + @attempts = Concurrent::AtomicFixnum.new(0) + @next_retry_at = nil + log.info "Legion::Cache::Reconnector[#{@tier}] reconnected after #{count} attempts" + return + rescue StandardError => e + @attempts.increment + handle_exception(e, level: :warn, handled: true, + operation: :"reconnector_#{@tier}", + attempt: @attempts.value, next_delay: delay) + delay = [delay * 2, configured_max_delay].min + end + end + rescue StandardError => e + handle_exception(e, level: :error, handled: true, operation: :"reconnector_#{@tier}_loop") + end + + def configured_initial_delay + return DEFAULT_INITIAL_DELAY unless defined?(Legion::Settings) + + Legion::Settings.dig(@settings_key, :reconnect, :initial_delay) || DEFAULT_INITIAL_DELAY + rescue StandardError + DEFAULT_INITIAL_DELAY + end + + def configured_max_delay + return DEFAULT_MAX_DELAY unless defined?(Legion::Settings) + + Legion::Settings.dig(@settings_key, :reconnect, :max_delay) || DEFAULT_MAX_DELAY + rescue StandardError + DEFAULT_MAX_DELAY + end + end + end +end diff --git a/lib/legion/cache/redis.rb b/lib/legion/cache/redis.rb index 8d2a99b..153237f 100644 --- a/lib/legion/cache/redis.rb +++ b/lib/legion/cache/redis.rb @@ -1,42 +1,317 @@ +# frozen_string_literal: true + +require 'openssl' require 'redis' +require 'legion/logging/helper' require 'legion/cache/pool' +require 'legion/cache/settings' module Legion module Cache module Redis include Legion::Cache::Pool extend self + extend Legion::Logging::Helper - def client(pool_size: 20, timeout: 5, **) + def client(server: nil, servers: [], pool_size: nil, timeout: nil, # rubocop:disable Metrics/ParameterLists + username: nil, password: nil, logger: nil, **opts) return @client unless @client.nil? + settings = defined?(Legion::Settings) ? Legion::Settings[:cache] : {} + pool_size ||= settings[:pool_size] || 10 + timeout ||= settings[:timeout] || 5 + + cluster = opts.delete(:cluster) + replica = opts.delete(:replica) || false + fixed_hostname = opts.delete(:fixed_hostname) + db = opts.delete(:db) + reconnect_attempts = opts.delete(:reconnect_attempts) || [0, 0.5, 1] + @pool_size = pool_size - @timeout = timeout + @timeout = timeout + @cluster_mode = Array(cluster).compact.any? + @component_logger = logger || log - @client = ConnectionPool.new(size: pool_size, timeout: timeout) do - ::Redis.new + @connection_opts = { username: username, password: password, timeout: @timeout }.compact + @connection_opts.merge!(redis_tls_options(port: resolve_primary_port(server: server, servers: servers, cluster: cluster))) + + checkout_timeout = opts[:pool_checkout_timeout] || settings[:pool_checkout_timeout] || @timeout + @client = ConnectionPool.new(size: pool_size, timeout: checkout_timeout) do + build_redis_client(server: server, servers: servers, cluster: cluster, + replica: replica, fixed_hostname: fixed_hostname, + username: username, password: password, db: db, + reconnect_attempts: reconnect_attempts) end @connected = true + log.info "Redis connected to #{resolved_redis_address(server: server, servers: servers, cluster: cluster)}" @client + rescue StandardError => e + handle_exception(e, level: :error, handled: false, operation: :redis_client, + server: server, servers: Array(servers), cluster_nodes: Array(cluster)) + @connected = false + raise + end + + def build_redis_client(server: nil, servers: [], cluster: nil, replica: false, fixed_hostname: nil, # rubocop:disable Metrics/ParameterLists + username: nil, password: nil, db: nil, reconnect_attempts: [0, 0.5, 1]) + nodes = Array(cluster).compact + if nodes.any? + opts = { cluster: nodes, reconnect_attempts: reconnect_attempts, timeout: @timeout } + opts[:replica] = true if replica + opts[:fixed_hostname] = fixed_hostname unless fixed_hostname.nil? + opts[:username] = username unless username.nil? + opts[:password] = password unless password.nil? + ::Redis.new(**opts) + else + resolved = Legion::Cache::Settings.resolve_servers( + driver: 'redis', server: server, servers: servers + ) + host, port = Legion::Cache::Settings.parse_server_address(resolved.first, default_port: 6379) + redis_opts = { host: host, port: port.to_i, reconnect_attempts: reconnect_attempts, + timeout: @timeout } + redis_opts[:username] = username unless username.nil? + redis_opts[:password] = password unless password.nil? + redis_opts[:db] = db unless db.nil? + redis_opts.merge!(redis_tls_options(port: port.to_i)) + ::Redis.new(**redis_opts) + end + end + + def cluster_mode? + @cluster_mode == true end def get(key) - client.with { |conn| conn.get(key) } + raw = client.with { |conn| conn.get(key) } + result = deserialize_value(raw) + log.debug { "[cache] GET #{key} hit=#{!result.nil?}" } + result + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :redis_get, key: key) + nil end - alias fetch get - def set(key, value, ttl: nil) + def fetch(key, ttl: nil) + result = get(key) + return result unless result.nil? && block_given? + + result = yield + set(key, result, ttl: ttl) + result + end + + def set_nx(key, value, ttl: nil) + effective_ttl = ttl || default_ttl + serialized = serialize_value(value) + result = client.with { |conn| conn.set(key, serialized, nx: true, ex: effective_ttl) == 'OK' } + log.debug { "[cache] SET_NX #{key} ttl=#{effective_ttl.inspect} result=#{result}" } + result + rescue StandardError => e + handle_exception(e, level: :error, handled: false, operation: :redis_set_nx, key: key, ttl: effective_ttl) + raise + end + + def set(key, value, ttl: nil, **) + set_sync(key, value, ttl: ttl, **) + end + + def set_sync(key, value, ttl: nil, **) + effective_ttl = ttl || default_ttl args = {} - args[:ex] = ttl unless ttl.nil? - client.with { |conn| conn.set(key, value, **args) == 'OK' } + args[:ex] = effective_ttl unless effective_ttl.nil? + serialized = serialize_value(value) + result = client.with { |conn| conn.set(key, serialized, **args) == 'OK' } + log.debug { "[cache] SET #{key} ttl=#{effective_ttl.inspect} success=#{result}" } + result + rescue StandardError => e + handle_exception(e, level: :error, handled: false, operation: :redis_set_sync, key: key, ttl: effective_ttl) + raise end - def delete(key) - client.with { |conn| conn.del(key) == 1 } + def delete(key, **) + delete_sync(key) + end + + def delete_sync(key) + result = client.with { |conn| conn.del(key) == 1 } + log.debug { "[cache] DELETE #{key} success=#{result}" } + result + rescue StandardError => e + handle_exception(e, level: :error, handled: false, operation: :redis_delete_sync, key: key) + raise end def flush - client.with { |conn| conn.flushdb == 'OK' } + result = client.with do |conn| + if cluster_mode? + cluster_flush(conn) + else + conn.flushdb == 'OK' + end + end + log.debug { '[cache] FLUSH completed' } + result + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :redis_flush) + nil + end + + def mget(*keys) + keys = keys.flatten + return {} if keys.empty? + + result = client.with do |conn| + if cluster_mode? + cluster_mget(conn, keys) + else + values = conn.mget(*keys) + keys.zip(values).to_h + end + end + result = result.transform_values { |v| deserialize_value(v) } + log.debug { "[cache] MGET keys=#{keys.size}" } + result + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :redis_mget, key_count: keys.size) + {} + end + + def mset(hash, ttl: nil, **) + mset_sync(hash, ttl: ttl) + end + + def mset_sync(hash, ttl: nil, **) + return true if hash.empty? + + hash.each { |key, value| set_sync(key, value, ttl: ttl) } + true + rescue StandardError => e + handle_exception(e, level: :error, handled: false, operation: :redis_mset_sync, key_count: hash.size) + raise + end + + SERIALIZE_STRING = "S\x00".b.freeze + SERIALIZE_JSON = "J\x00".b.freeze + + private + + def serialize_value(value) + case value + when String + "#{SERIALIZE_STRING}#{value}" + else + "#{SERIALIZE_JSON}#{Legion::JSON.dump(value)}" + end + end + + def deserialize_value(raw) + return nil if raw.nil? + + raw = raw.b if raw.respond_to?(:b) + if raw.start_with?(SERIALIZE_JSON) + Legion::JSON.load(raw.byteslice(2..)) + elsif raw.start_with?(SERIALIZE_STRING) + raw.byteslice(2..) + else + raw # legacy data, no prefix + end + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :redis_deserialize) + raw + end + + def default_ttl + return 3600 unless defined?(Legion::Settings) + + Legion::Settings.dig(:cache, :default_ttl) || 3600 + rescue StandardError + 3600 + end + + def cluster_mget(conn, keys) + groups = group_keys_by_slot(keys) + result = {} + groups.each_value do |group_keys| + values = conn.mget(*group_keys) + group_keys.zip(values).each { |k, v| result[k] = v } + end + result + end + + def cluster_mset(conn, hash) + groups = group_keys_by_slot(hash.keys) + groups.each_value do |group_keys| + pairs = group_keys.flat_map { |k| [k, hash[k]] } + conn.mset(*pairs) + end + true + end + + def cluster_flush(conn) + node_info = conn.cluster('nodes') + primaries = node_info.lines.select { |l| l.include?('master') }.map { |l| l.split[1].split('@').first } + primaries.each do |addr| + host, port = Legion::Cache::Settings.parse_server_address(addr, default_port: 6379) + node = ::Redis.new(host: host, port: port.to_i, **(@connection_opts || {})) + node.flushdb + node.close + end + true + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :cluster_flush, fallback: :single_flushdb) + conn.flushdb == 'OK' + end + + def group_keys_by_slot(keys) + if defined?(::Redis::Cluster::KeySlotConverter) + keys.group_by { |k| ::Redis::Cluster::KeySlotConverter.convert(k) } + else + { 0 => keys } + end + end + + def resolved_redis_address(server:, servers:, cluster:) + nodes = Array(cluster).compact + return nodes.join(', ') if nodes.any? + + Legion::Cache::Settings.resolve_servers(driver: 'redis', server: server, servers: Array(servers)).first + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :resolved_redis_address) + 'unknown' + end + + def resolve_primary_port(server: nil, servers: [], cluster: nil) + nodes = Array(cluster).compact + return 6379 if nodes.any? + + resolved = Legion::Cache::Settings.resolve_servers(driver: 'redis', server: server, servers: Array(servers)) + _, port = Legion::Cache::Settings.parse_server_address(resolved.first, default_port: 6379) + port.to_i + rescue StandardError + 6379 + end + + def redis_tls_options(port:) + return {} unless defined?(Legion::Crypt::TLS) + + tls = Legion::Crypt::TLS.resolve(cache_tls_settings, port: port) + return {} unless tls[:enabled] + + ssl_params = { + verify_mode: tls[:verify] == :none ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER + } + ssl_params[:ca_file] = tls[:ca] if tls[:ca] + + { ssl: true, ssl_params: ssl_params } + end + + def cache_tls_settings + return {} unless defined?(Legion::Settings) + + Legion::Settings[:cache][:tls] || {} + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :cache_tls_settings) + {} end end end diff --git a/lib/legion/cache/redis_hash.rb b/lib/legion/cache/redis_hash.rb new file mode 100644 index 0000000..186ce35 --- /dev/null +++ b/lib/legion/cache/redis_hash.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' + +module Legion + module Cache + module RedisHash + extend Legion::Logging::Helper + + module_function + + # Returns true when the Redis driver is loaded and the connection pool is live. + def redis_available? + pool = Legion::Cache.pool + return false if pool.nil? + return false unless Legion::Cache.respond_to?(:driver_name) && Legion::Cache.driver_name == 'redis' + + Legion::Cache.connected? + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :redis_hash_available) + false + end + + # Set hash fields from a Ruby Hash. + # Uses Redis HSET key field value [field value ...] + def hset(key, hash) + return false unless redis_available? + + Legion::Cache.pool.with do |conn| + flat = hash.flat_map { |k, v| [k.to_s, v.to_s] } + conn.hset(key, *flat) + end + log.debug "[cache:redis_hash] HSET #{key} fields=#{hash.size}" + true + rescue StandardError => e + log_redis_error('hset', e) + false + end + + # Returns a Ruby Hash (string keys) of all field-value pairs for the key. + def hgetall(key) + return nil unless redis_available? + + result = Legion::Cache.pool.with do |conn| + conn.hgetall(key) + end + log.debug "[cache:redis_hash] HGETALL #{key} fields=#{result.size}" + result + rescue StandardError => e + log_redis_error('hgetall', e) + nil + end + + # Delete one or more hash fields. + def hdel(key, *fields) + return 0 unless redis_available? + + result = Legion::Cache.pool.with do |conn| + conn.hdel(key, *fields) + end + log.debug "[cache:redis_hash] HDEL #{key} fields=#{fields.size} removed=#{result}" + result + rescue StandardError => e + log_redis_error('hdel', e) + 0 + end + + # Add a member to a sorted set with the given score. + def zadd(key, score, member) + return false unless redis_available? + + Legion::Cache.pool.with do |conn| + conn.zadd(key, score.to_f, member.to_s) + end + log.debug "[cache:redis_hash] ZADD #{key} member=#{member}" + true + rescue StandardError => e + log_redis_error('zadd', e) + false + end + + # Range query on a sorted set by score. Returns an array of members. + # limit: accepts [offset, count] array matching Redis LIMIT semantics. + def zrangebyscore(key, min, max, limit: nil) + return [] unless redis_available? + + opts = {} + opts[:limit] = limit if limit + + result = Legion::Cache.pool.with do |conn| + conn.zrangebyscore(key, min, max, **opts) + end + log.debug "[cache:redis_hash] ZRANGEBYSCORE #{key} results=#{result.size}" + result + rescue StandardError => e + log_redis_error('zrangebyscore', e) + [] + end + + # Remove a member from a sorted set. + def zrem(key, member) + return false unless redis_available? + + Legion::Cache.pool.with do |conn| + conn.zrem(key, member.to_s) + end + log.debug "[cache:redis_hash] ZREM #{key} member=#{member}" + true + rescue StandardError => e + log_redis_error('zrem', e) + false + end + + # Set a TTL (in seconds) on a key. + def expire(key, seconds) + return false unless redis_available? + + result = Legion::Cache.pool.with do |conn| + conn.expire(key, seconds.to_i) == 1 + end + log.debug "[cache:redis_hash] EXPIRE #{key} seconds=#{seconds} success=#{result}" + result + rescue StandardError => e + log_redis_error('expire', e) + false + end + + def log_redis_error(method, error) + handle_exception(error, level: :warn, handled: true, operation: method) + end + end + end +end diff --git a/lib/legion/cache/settings.rb b/lib/legion/cache/settings.rb index d8668dd..dec4c68 100644 --- a/lib/legion/cache/settings.rb +++ b/lib/legion/cache/settings.rb @@ -1,41 +1,164 @@ -begin - require 'legion/settings' -rescue StandardError - # empty block -end +# frozen_string_literal: true + +require 'ipaddr' +require 'legion/logging/helper' module Legion module Cache module Settings - Legion::Settings.merge_settings(:cache, default) if Legion::Settings.method_defined? :merge_settings + extend Legion::Logging::Helper + + begin + require 'legion/settings' + rescue StandardError => e + handle_exception(e, + level: :error, + handled: true, + operation: :cache_settings_require_legion_settings) + end + def self.default { - driver: driver, - servers: ['127.0.0.1:11211'], - connected: false, - enabled: true, - namespace: 'legion', - compress: false, - failover: true, - threadsafe: true, - expires_in: 0, - cache_nils: false, - pool_size: 10, - timeout: 5, - serializer: Legion::JSON + driver: driver, + servers: [], + connected: false, + enabled: true, + namespace: 'legion', + compress: false, + failover: true, + threadsafe: true, + expires_in: 0, + cache_nils: false, + pool_size: 10, + timeout: 5, + pool_checkout_timeout: 5, + default_ttl: 3600, + failback_to_local: true, + serializer: Legion::JSON, + cluster: nil, + replica: false, + fixed_hostname: nil, + username: nil, + password: nil, + db: nil, + reconnect_attempts: [0, 0.5, 1].freeze, + async: { + pool_size: 4, + queue_size: 1000, + shutdown_timeout: 5 + }.freeze, + reconnect: { + initial_delay: 1, + max_delay: 60, + enabled: true + }.freeze + } + end + + def self.local + { + driver: driver, + servers: [], + connected: false, + enabled: true, + namespace: 'legion_local', + compress: false, + failover: true, + threadsafe: true, + expires_in: 0, + cache_nils: false, + pool_size: 5, + timeout: 3, + pool_checkout_timeout: 5, + default_ttl: 21_600, + serializer: Legion::JSON, + username: nil, + password: nil, + db: nil, + reconnect_attempts: [0, 0.25, 0.5].freeze } end + DEFAULT_PORTS = { 'dalli' => 11_211, 'redis' => 6379 }.freeze + + def self.resolve_servers(driver:, server: nil, servers: [], port: nil) + gem_driver = normalize_driver(driver) + port ||= DEFAULT_PORTS.fetch(gem_driver, 11_211) + + all = Array(servers) + Array(server) + all = ["127.0.0.1:#{port}"] if all.empty? + + all.map! { |s| normalize_server(s, port: port) } + resolved = all.uniq + log.debug "Legion::Cache::Settings resolved driver=#{gem_driver} servers=#{resolved.join(', ')}" + resolved + end + + def self.parse_server_address(server, default_port:) + raw = server.to_s.strip + return ['127.0.0.1', default_port] if raw.empty? + + bracketed = raw.match(/\A\[(?[^\]]+)\](?::(?\d+))?\z/) + return [bracketed[:host], (bracketed[:port] || default_port).to_i] if bracketed + + return [raw, default_port] if ipv6_literal?(raw) + + host, explicit_port = raw.split(':', 2) + if explicit_port&.match?(/\A\d+\z/) + [host, explicit_port.to_i] + else + [raw, default_port] + end + end + + def self.register_defaults! + return unless defined?(Legion::Settings) && Legion::Settings.respond_to?(:merge_settings) + + Legion::Settings.merge_settings(:cache, default) + Legion::Settings.merge_settings(:cache_local, local) + end + + def self.normalize_driver(name) + case name.to_s + when 'redis' then 'redis' + when 'memcached', 'dalli' then 'dalli' + else name.to_s + end + end + def self.driver(prefer = 'dalli') secondary = prefer == 'dalli' ? 'redis' : 'dalli' - if Gem::Specification.find_all_by_name(prefer).count.positive? + if Gem::Specification.find_all_by_name(prefer).any? + log.debug "Legion::Cache::Settings selected driver=#{prefer}" prefer - elsif Gem::Specification.find_all_by_name(secondary).count.positive? + elsif Gem::Specification.find_all_by_name(secondary).any? + log.info "Legion::Cache::Settings falling back driver=#{secondary} preferred=#{prefer}" secondary else - raise NameError('Legion::Cache.driver is nil') + error = NameError.new('Legion::Cache.driver is nil') + handle_exception(error, level: :error, handled: false, operation: :cache_settings_driver, preferred: prefer) + raise error end end + + def self.normalize_server(server, port:) + host, resolved_port = parse_server_address(server, default_port: port) + format_server(host, resolved_port) + end + + def self.format_server(host, port) + return "[#{host}]:#{port}" if ipv6_literal?(host) + + "#{host}:#{port}" + end + + def self.ipv6_literal?(value) + IPAddr.new(value).ipv6? + rescue IPAddr::InvalidAddressError + false + end + + register_defaults! end end end diff --git a/lib/legion/cache/version.rb b/lib/legion/cache/version.rb index 83bcdd1..910f71a 100644 --- a/lib/legion/cache/version.rb +++ b/lib/legion/cache/version.rb @@ -2,6 +2,6 @@ module Legion module Cache - VERSION = '1.2.0' + VERSION = '1.4.2' end end diff --git a/sourcehawk.yml b/sourcehawk.yml deleted file mode 100644 index a228e9b..0000000 --- a/sourcehawk.yml +++ /dev/null @@ -1,4 +0,0 @@ - -config-locations: - - https://raw.githubusercontent.com/optum/.github/main/sourcehawk.yml - diff --git a/spec/legion/cache/async_integration_spec.rb b/spec/legion/cache/async_integration_spec.rb new file mode 100644 index 0000000..4404c8d --- /dev/null +++ b/spec/legion/cache/async_integration_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache' + +RSpec.describe 'async write integration' do + before do + ENV['LEGION_MODE'] = 'lite' + Legion::Cache.setup + end + + after do + Legion::Cache.shutdown + ENV.delete('LEGION_MODE') + end + + it 'set with async: true returns true immediately' do + expect(Legion::Cache.set('async_key', 'val', async: true)).to be(true) + end + + it 'set with async: false writes synchronously' do + Legion::Cache.set('sync_key', 'val', async: false) + expect(Legion::Cache.get('sync_key')).to eq('val') + end + + it 'set with async: true eventually writes the value' do + Legion::Cache.set('eventual', 'val', async: true) + sleep 0.2 + expect(Legion::Cache.get('eventual')).to eq('val') + end + + it 'drains async writer before closing pool on shutdown' do + # Write async, then immediately shutdown — drain should complete the write + Legion::Cache.set('drain_test', 'value', async: true) + # Small sleep to let async writer pick up the job + sleep 0.1 + # Verify value was written (drain ensures this before pool close) + expect(Legion::Cache.get('drain_test')).to eq('value') + end + + it 'stats reports async pool size' do + stats = Legion::Cache.stats + expect(stats[:async_pool_size]).to be_a(Integer) + expect(stats[:async_pool_size]).to be > 0 + end +end diff --git a/spec/legion/cache/async_writer_spec.rb b/spec/legion/cache/async_writer_spec.rb new file mode 100644 index 0000000..4619449 --- /dev/null +++ b/spec/legion/cache/async_writer_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'concurrent-ruby' +require 'legion/cache/async_writer' + +RSpec.describe Legion::Cache::AsyncWriter do + subject(:writer) { described_class.new } + + after { writer.stop(timeout: 2) if writer.running? } + + describe '#start' do + it 'starts the thread pool' do + writer.start + expect(writer.running?).to be(true) + end + + it 'is idempotent' do + writer.start + writer.start + expect(writer.running?).to be(true) + end + end + + describe '#stop' do + it 'drains pending work within timeout' do + writer.start + completed = Concurrent::AtomicBoolean.new(false) + writer.enqueue { completed.make_true } + writer.stop(timeout: 5) + expect(completed.value).to be(true) + expect(writer.running?).to be(false) + end + end + + describe '#enqueue' do + it 'executes the block asynchronously' do + writer.start + result = Concurrent::AtomicReference.new(nil) + writer.enqueue { result.set('done') } + sleep 0.1 + expect(result.get).to eq('done') + end + + it 'increments processed_count' do + writer.start + 3.times { writer.enqueue { nil } } + sleep 0.2 + expect(writer.processed_count).to eq(3) + end + + it 'falls back to synchronous when pool is not running' do + result = nil + writer.enqueue { result = 'sync_fallback' } + expect(result).to eq('sync_fallback') + end + end + + describe 'thread safety' do + it 'handles concurrent stop and enqueue without error' do + writer.start + errors = Concurrent::AtomicFixnum.new(0) + threads = 10.times.map do + Thread.new do + 50.times { writer.enqueue { nil } } + rescue StandardError + errors.increment + end + end + sleep 0.05 + writer.stop(timeout: 2) + threads.each(&:join) + expect(errors.value).to eq(0) + end + end + + describe '#failed_count' do + it 'tracks failed jobs separately from processed' do + writer.start + writer.enqueue { raise 'boom' } + sleep 0.2 + expect(writer.failed_count).to eq(1) + expect(writer.processed_count).to eq(0) + end + end + + describe '#pool_size' do + it 'returns configured pool size' do + writer.start(pool_size: 2) + expect(writer.pool_size).to eq(2) + end + end + + describe '#queue_depth' do + it 'returns 0 when idle' do + writer.start + sleep 0.05 + expect(writer.queue_depth).to eq(0) + end + end +end diff --git a/spec/legion/cache/enabled_spec.rb b/spec/legion/cache/enabled_spec.rb new file mode 100644 index 0000000..312787f --- /dev/null +++ b/spec/legion/cache/enabled_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache' + +RSpec.describe 'enabled? and connected?' do + before do + Legion::Cache.instance_variable_set(:@connected, Concurrent::AtomicBoolean.new(false)) + Legion::Cache.instance_variable_set(:@using_memory, Concurrent::AtomicBoolean.new(false)) + Legion::Cache.instance_variable_set(:@using_local, Concurrent::AtomicBoolean.new(false)) + Legion::Cache::Local.reset! + end + + after do + Legion::Settings[:cache][:enabled] = true + end + + describe 'Legion::Cache.enabled?' do + it 'returns true when settings enabled is true' do + Legion::Settings[:cache][:enabled] = true + expect(Legion::Cache.enabled?).to be(true) + end + + it 'returns false when settings enabled is false' do + Legion::Settings[:cache][:enabled] = false + expect(Legion::Cache.enabled?).to be(false) + end + end + + describe 'Legion::Cache::Local.enabled?' do + it 'reads from cache_local settings' do + Legion::Settings[:cache_local] ||= {} + Legion::Settings[:cache_local][:enabled] = false + expect(Legion::Cache::Local.enabled?).to be(false) + Legion::Settings[:cache_local][:enabled] = true + end + end + + describe 'setup respects enabled?' do + it 'does not connect when disabled' do + Legion::Settings[:cache][:enabled] = false + expect(Legion::Cache::Local).not_to receive(:setup) + Legion::Cache.setup + expect(Legion::Cache.connected?).to be(false) + Legion::Settings[:cache][:enabled] = true + end + end + + describe 'Legion::Cache::Memory enabled?' do + it 'always returns true' do + expect(Legion::Cache::Memory).to respond_to(:connected?) + end + end +end diff --git a/spec/legion/cache/exception_handling_spec.rb b/spec/legion/cache/exception_handling_spec.rb new file mode 100644 index 0000000..1eb2c22 --- /dev/null +++ b/spec/legion/cache/exception_handling_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache' +require 'legion/cache/memory' +require 'legion/cache/memcached' +require 'legion/cache/redis' + +RSpec.describe 'exception handling' do + describe Legion::Cache::Memory do + before { described_class.setup } + after { described_class.reset! } + + describe 'reads return nil on error' do + it 'get returns nil when store raises' do + allow(described_class).to receive(:expire_if_needed).and_raise(RuntimeError, 'boom') + expect(described_class.get('key')).to be_nil + end + end + + describe 'sync writes re-raise' do + it 'set_sync raises on error' do + allow(described_class.instance_variable_get(:@store)).to receive(:[]=).and_raise(RuntimeError, 'boom') + expect { described_class.set_sync('k', 'v', ttl: 60) }.to raise_error(RuntimeError, 'boom') + end + end + + describe 'flush handles errors internally' do + it 'flush returns false on error' do + store = described_class.instance_variable_get(:@store) + allow(store).to receive(:clear).and_raise(RuntimeError, 'boom') + expect(described_class.flush).to be(false) + allow(store).to receive(:clear).and_call_original + end + end + end + + describe Legion::Cache::Memcached do + let(:cache) { described_class.dup } + let(:pool) { instance_double(ConnectionPool) } + let(:dalli) { instance_double(Dalli::Client) } + + before do + cache.instance_variable_set(:@client, pool) + cache.instance_variable_set(:@connected, true) + allow(pool).to receive(:with).and_yield(dalli) + end + + it 'get returns nil on error' do + allow(dalli).to receive(:get).and_raise(StandardError, 'timeout') + expect(cache.get('k')).to be_nil + end + + it 'set_sync re-raises on error' do + allow(dalli).to receive(:set).and_raise(StandardError, 'timeout') + expect { cache.set_sync('k', 'v', ttl: 60) }.to raise_error(StandardError, 'timeout') + end + + it 'delete_sync re-raises on error' do + allow(dalli).to receive(:delete).and_raise(StandardError, 'timeout') + expect { cache.delete_sync('k') }.to raise_error(StandardError, 'timeout') + end + end + + describe Legion::Cache::Redis do + let(:cache) { described_class.dup } + let(:pool) { instance_double(ConnectionPool) } + let(:redis) { instance_double(Redis) } + + before do + cache.instance_variable_set(:@client, pool) + cache.instance_variable_set(:@connected, true) + allow(pool).to receive(:with).and_yield(redis) + end + + it 'get returns nil on error' do + allow(redis).to receive(:get).and_raise(StandardError, 'timeout') + expect(cache.get('k')).to be_nil + end + + it 'set_sync re-raises on error' do + allow(redis).to receive(:set).and_raise(StandardError, 'timeout') + expect { cache.set_sync('k', 'v', ttl: 60) }.to raise_error(StandardError, 'timeout') + end + + it 'delete_sync re-raises on error' do + allow(redis).to receive(:del).and_raise(StandardError, 'timeout') + expect { cache.delete_sync('k') }.to raise_error(StandardError, 'timeout') + end + end +end + +RSpec.describe 'Legion::Cache top-level exception handling' do + before do + ENV['LEGION_MODE'] = 'lite' + Legion::Cache::Memory.setup + Legion::Cache.instance_variable_set(:@using_memory, Concurrent::AtomicBoolean.new(true)) + Legion::Cache.instance_variable_set(:@connected, Concurrent::AtomicBoolean.new(true)) + end + + after do + ENV.delete('LEGION_MODE') + Legion::Cache::Memory.reset! + Legion::Cache.instance_variable_set(:@using_memory, Concurrent::AtomicBoolean.new(false)) + Legion::Cache.instance_variable_set(:@connected, Concurrent::AtomicBoolean.new(false)) + end + + it 'get returns nil on internal error' do + allow(Legion::Cache::Memory).to receive(:get).and_raise(RuntimeError, 'boom') + expect(Legion::Cache.get('key')).to be_nil + end + + it 'set with async: false re-raises on error from Memory' do + allow(Legion::Cache::Memory).to receive(:set).and_raise(RuntimeError, 'boom') + expect { Legion::Cache.set('k', 'v', ttl: 60, async: false) }.to raise_error(RuntimeError, 'boom') + end +end diff --git a/spec/legion/cache/failback_spec.rb b/spec/legion/cache/failback_spec.rb new file mode 100644 index 0000000..274132f --- /dev/null +++ b/spec/legion/cache/failback_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache' + +RSpec.describe 'failback to local' do + let(:local_store) { {} } + + before do + Legion::Cache.instance_variable_set(:@client, nil) + Legion::Cache.instance_variable_set(:@connected, Concurrent::AtomicBoolean.new(false)) + Legion::Cache.instance_variable_set(:@using_local, Concurrent::AtomicBoolean.new(false)) + Legion::Cache.instance_variable_set(:@using_memory, Concurrent::AtomicBoolean.new(false)) + Legion::Cache.instance_variable_set(:@active_shared_driver, nil) + Legion::Settings[:cache][:failback_to_local] = true + + allow(Legion::Cache::Local).to receive(:connected?).and_return(true) + allow(Legion::Cache::Local).to receive(:enabled?).and_return(true) + allow(Legion::Cache::Local).to receive(:get) { |key| local_store[key] } + allow(Legion::Cache::Local).to receive(:set) do |key, value, **| + local_store[key] = value + true + end + allow(Legion::Cache::Local).to receive(:set_sync) do |key, value, **| + local_store[key] = value + true + end + allow(Legion::Cache::Local).to receive(:delete) do |key, **| + !local_store.delete(key).nil? + end + allow(Legion::Cache::Local).to receive(:delete_sync) do |key| + !local_store.delete(key).nil? + end + allow(Legion::Cache::Local).to receive(:fetch) do |key, **_opts, &block| + next local_store[key] if local_store.key?(key) + + value = block&.call + local_store[key] = value + value + end + allow(Legion::Cache::Local).to receive(:flush) do + local_store.clear + true + end + end + + after do + Legion::Settings[:cache][:enabled] = true + Legion::Settings[:cache][:failback_to_local] = true + end + + describe 'when shared is disabled' do + before { Legion::Settings[:cache][:enabled] = false } + + it 'get delegates to Local' do + local_store['key'] = 'value' + expect(Legion::Cache.get('key')).to eq('value') + end + + it 'set delegates to Local' do + Legion::Cache.set('key', 'value', async: false) + expect(local_store['key']).to eq('value') + end + + it 'mget delegates to Local' do + local_store['a'] = 1 + local_store['b'] = 2 + allow(Legion::Cache::Local).to receive(:mget).with('a', 'b').and_return({ 'a' => 1, 'b' => 2 }) + expect(Legion::Cache.mget('a', 'b')).to eq({ 'a' => 1, 'b' => 2 }) + end + + it 'fetch delegates to Local' do + result = Legion::Cache.fetch('miss', ttl: 60) { 'computed' } + expect(result).to eq('computed') + expect(local_store['miss']).to eq('computed') + end + + it 'delete delegates to Local' do + local_store['del'] = 'gone' + Legion::Cache.delete('del', async: false) + expect(local_store['del']).to be_nil + end + + it 'flush delegates to Local' do + local_store['a'] = 1 + Legion::Cache.flush + expect(local_store).to be_empty + end + end + + describe 'when shared is disconnected (failure)' do + before do + Legion::Settings[:cache][:enabled] = true + Legion::Cache.instance_variable_set(:@connected, Concurrent::AtomicBoolean.new(false)) + end + + it 'get delegates to Local' do + local_store['key'] = 'value' + expect(Legion::Cache.get('key')).to eq('value') + end + + it 'set delegates to Local' do + Legion::Cache.set('key', 'value', async: false) + expect(local_store['key']).to eq('value') + end + end + + describe 'when failback_to_local is false' do + before do + Legion::Settings[:cache][:enabled] = false + Legion::Settings[:cache][:failback_to_local] = false + end + + it 'get returns nil instead of delegating' do + local_store['key'] = 'value' + expect(Legion::Cache.get('key')).to be_nil + end + end + + describe 'when Local is also not connected' do + before do + Legion::Settings[:cache][:enabled] = false + allow(Legion::Cache::Local).to receive(:connected?).and_return(false) + end + + it 'get returns nil' do + expect(Legion::Cache.get('key')).to be_nil + end + end +end diff --git a/spec/legion/cache/helper_spec.rb b/spec/legion/cache/helper_spec.rb new file mode 100644 index 0000000..c2a993a --- /dev/null +++ b/spec/legion/cache/helper_spec.rb @@ -0,0 +1,636 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Cache::Helper do + let(:helper_class) do + Class.new do + include Legion::Cache::Helper + + def lex_filename + 'microsoft_teams' + end + end + end + + let(:bare_class) do + stub_const('Legion::Extensions::MyExtension::Runners::Foo', Class.new do + include Legion::Cache::Helper + end) + end + + let(:custom_ttl_class) do + Class.new do + include Legion::Cache::Helper + + def lex_filename + 'custom_lex' + end + + def cache_default_ttl + 600 + end + end + end + + subject { helper_class.new } + + describe 'FALLBACK_TTL' do + it 'is 3600' do + expect(Legion::Cache::Helper::FALLBACK_TTL).to eq(3600) + end + end + + describe '#cache_default_ttl' do + it 'returns the settings value' do + expect(subject.cache_default_ttl).to eq(3600) + end + + it 'falls back to FALLBACK_TTL when settings key is nil' do + allow(Legion::Settings).to receive(:dig).with(:cache, :default_ttl).and_return(nil) + expect(subject.cache_default_ttl).to eq(3600) + end + + it 'can be overridden by a LEX' do + obj = custom_ttl_class.new + expect(obj.cache_default_ttl).to eq(600) + end + + it 'reports exceptions and falls back when settings lookup fails' do + allow(Legion::Settings).to receive(:dig).with(:cache, :default_ttl).and_raise(StandardError, 'boom') + allow(subject).to receive(:handle_exception) + + expect(subject.cache_default_ttl).to eq(3600) + expect(subject).to have_received(:handle_exception).with( + an_instance_of(StandardError), + level: :warn, + handled: true, + operation: :cache_default_ttl + ) + end + end + + describe '#local_cache_default_ttl' do + it 'returns the local settings value' do + expect(subject.local_cache_default_ttl).to eq(21_600) + end + + it 'falls back to cache_default_ttl when local key is nil' do + allow(Legion::Settings).to receive(:dig).with(:cache_local, :default_ttl).and_return(nil) + allow(Legion::Settings).to receive(:dig).with(:cache, :default_ttl).and_return(120) + expect(subject.local_cache_default_ttl).to eq(120) + end + + it 'reports exceptions and falls back to cache_default_ttl when local lookup fails' do + allow(Legion::Settings).to receive(:dig).with(:cache_local, :default_ttl).and_raise(StandardError, 'boom') + allow(subject).to receive(:handle_exception) + allow(subject).to receive(:cache_default_ttl).and_return(90) + + expect(subject.local_cache_default_ttl).to eq(90) + expect(subject).to have_received(:handle_exception).with( + an_instance_of(StandardError), + level: :warn, + handled: true, + operation: :local_cache_default_ttl + ) + end + end + + describe '#cache_namespace' do + it 'derives from lex_filename' do + expect(subject.cache_namespace).to eq('microsoft_teams') + end + + it 'derives from class name when lex_filename is not defined' do + obj = bare_class.new + expect(obj.cache_namespace).to eq('my_extension') + end + end + + describe '#cache_set' do + it 'delegates to Legion::Cache with namespaced key and explicit TTL' do + expect(Legion::Cache).to receive(:set).with('microsoft_teams:messages', 'data', ttl: 120, async: false, phi: false) + subject.cache_set(':messages', 'data', ttl: 120) + end + + it 'uses cache_default_ttl when ttl is not provided' do + expect(Legion::Cache).to receive(:set).with('microsoft_teams:messages', 'data', ttl: 3600, async: false, phi: false) + subject.cache_set(':messages', 'data') + end + + it 'uses LEX override TTL when defined' do + obj = custom_ttl_class.new + expect(Legion::Cache).to receive(:set).with('custom_lex:key', 'val', ttl: 600, async: false, phi: false) + obj.cache_set(':key', 'val') + end + + it 'forwards phi: true to Legion::Cache.set' do + expect(Legion::Cache).to receive(:set).with('microsoft_teams:phi_data', 'secret', ttl: 7200, async: false, phi: true) + subject.cache_set(':phi_data', 'secret', ttl: 7200, phi: true) + end + end + + describe '#cache_get' do + it 'delegates to Legion::Cache with namespaced key' do + expect(Legion::Cache).to receive(:get).with('microsoft_teams:messages').and_return('data') + expect(subject.cache_get(':messages')).to eq('data') + end + end + + describe '#cache_delete' do + it 'delegates to Legion::Cache with namespaced key' do + expect(Legion::Cache).to receive(:delete).with('microsoft_teams:messages', async: false) + subject.cache_delete(':messages') + end + end + + describe '#cache_fetch' do + it 'delegates to Legion::Cache with namespaced key and explicit TTL' do + expect(Legion::Cache).to receive(:fetch).with('microsoft_teams:key', ttl: 120) + subject.cache_fetch(':key', ttl: 120) + end + + it 'uses cache_default_ttl when ttl is not provided' do + expect(Legion::Cache).to receive(:fetch).with('microsoft_teams:key', ttl: 3600) + subject.cache_fetch(':key') + end + end + + describe '#cache_exist?' do + it 'returns true when key has a value' do + expect(Legion::Cache).to receive(:get).with('microsoft_teams:key').and_return('val') + expect(subject.cache_exist?(':key')).to be true + end + + it 'returns false when key is absent' do + expect(Legion::Cache).to receive(:get).with('microsoft_teams:key').and_return(nil) + expect(subject.cache_exist?(':key')).to be false + end + end + + describe '#local_cache_set' do + it 'delegates to Legion::Cache::Local with namespaced key' do + allow(Legion::Cache).to receive(:enforce_phi_ttl).with(21_600, phi: false).and_return(21_600) + expect(Legion::Cache::Local).to receive(:set).with('microsoft_teams:hwm', 'ts', ttl: 21_600, async: false) + subject.local_cache_set(':hwm', 'ts') + end + + it 'uses explicit TTL when provided' do + allow(Legion::Cache).to receive(:enforce_phi_ttl).with(300, phi: false).and_return(300) + expect(Legion::Cache::Local).to receive(:set).with('microsoft_teams:hwm', 'ts', ttl: 300, async: false) + subject.local_cache_set(':hwm', 'ts', ttl: 300) + end + + it 'enforces PHI TTL cap' do + allow(Legion::Cache).to receive(:enforce_phi_ttl).with(7200, phi: true).and_return(3600) + expect(Legion::Cache::Local).to receive(:set).with('microsoft_teams:phi', 'data', ttl: 3600, async: false) + subject.local_cache_set(':phi', 'data', ttl: 7200, phi: true) + end + end + + describe '#local_cache_get' do + it 'delegates to Legion::Cache::Local with namespaced key' do + expect(Legion::Cache::Local).to receive(:get).with('microsoft_teams:hwm').and_return('ts') + expect(subject.local_cache_get(':hwm')).to eq('ts') + end + end + + describe '#local_cache_delete' do + it 'delegates to Legion::Cache::Local with namespaced key' do + expect(Legion::Cache::Local).to receive(:delete).with('microsoft_teams:hwm', async: false) + subject.local_cache_delete(':hwm') + end + end + + describe '#local_cache_fetch' do + it 'uses local_cache_default_ttl when ttl is not provided' do + expect(Legion::Cache::Local).to receive(:fetch).with('microsoft_teams:key', ttl: 21_600) + subject.local_cache_fetch(':key') + end + + it 'uses explicit TTL when provided' do + expect(Legion::Cache::Local).to receive(:fetch).with('microsoft_teams:key', ttl: 300) + subject.local_cache_fetch(':key', ttl: 300) + end + end + + describe '#local_cache_exist?' do + it 'returns true when key has a value' do + expect(Legion::Cache::Local).to receive(:get).with('microsoft_teams:key').and_return('val') + expect(subject.local_cache_exist?(':key')).to be true + end + + it 'returns false when key is absent' do + expect(Legion::Cache::Local).to receive(:get).with('microsoft_teams:key').and_return(nil) + expect(subject.local_cache_exist?(':key')).to be false + end + end + + describe '#cache_connected?' do + it 'delegates to Legion::Cache.connected?' do + allow(Legion::Cache).to receive(:connected?).and_return(true) + expect(subject.cache_connected?).to be true + end + + it 'returns false when not connected' do + allow(Legion::Cache).to receive(:connected?).and_return(false) + expect(subject.cache_connected?).to be false + end + end + + describe '#local_cache_connected?' do + it 'delegates to Legion::Cache::Local.connected?' do + allow(Legion::Cache::Local).to receive(:connected?).and_return(true) + expect(subject.local_cache_connected?).to be true + end + end + + describe '#cache_pool_size' do + it 'returns pool size when connected' do + allow(Legion::Cache).to receive(:connected?).and_return(true) + allow(Legion::Cache).to receive(:pool_size).and_return(10) + expect(subject.cache_pool_size).to eq(10) + end + + it 'returns 0 when not connected' do + allow(Legion::Cache).to receive(:connected?).and_return(false) + expect(subject.cache_pool_size).to eq(0) + end + end + + describe '#cache_pool_available' do + it 'returns available connections when connected' do + allow(Legion::Cache).to receive(:connected?).and_return(true) + allow(Legion::Cache).to receive(:available).and_return(8) + expect(subject.cache_pool_available).to eq(8) + end + + it 'returns 0 when not connected' do + allow(Legion::Cache).to receive(:connected?).and_return(false) + expect(subject.cache_pool_available).to eq(0) + end + end + + describe '#local_cache_pool_size' do + it 'returns pool size when connected' do + allow(Legion::Cache::Local).to receive(:connected?).and_return(true) + allow(Legion::Cache::Local).to receive(:pool_size).and_return(5) + expect(subject.local_cache_pool_size).to eq(5) + end + + it 'returns 0 when not connected' do + allow(Legion::Cache::Local).to receive(:connected?).and_return(false) + expect(subject.local_cache_pool_size).to eq(0) + end + end + + describe '#local_cache_pool_available' do + it 'returns available connections when connected' do + allow(Legion::Cache::Local).to receive(:connected?).and_return(true) + allow(Legion::Cache::Local).to receive(:available).and_return(4) + expect(subject.local_cache_pool_available).to eq(4) + end + + it 'returns 0 when not connected' do + allow(Legion::Cache::Local).to receive(:connected?).and_return(false) + expect(subject.local_cache_pool_available).to eq(0) + end + end + + # --- Issue #3: cache_mget / cache_mset --- + + describe '#cache_mget' do + context 'with Redis backend' do + before { allow(subject).to receive(:cache_redis?).and_return(true) } + + it 'delegates to Legion::Cache.mget with namespaced keys and un-namespaces the result' do + allow(Legion::Cache).to receive(:mget).with('microsoft_teams:a', 'microsoft_teams:b') + .and_return({ 'microsoft_teams:a' => 'v1', 'microsoft_teams:b' => 'v2' }) + expect(subject.cache_mget(':a', ':b')).to eq({ ':a' => 'v1', ':b' => 'v2' }) + end + + it 'returns empty hash for empty key list' do + expect(subject.cache_mget).to eq({}) + end + + it 'returns empty hash on error' do + allow(Legion::Cache).to receive(:mget).and_raise(StandardError, 'fail') + expect(subject.cache_mget(':x')).to eq({}) + end + end + + context 'with Memcached backend' do + before { allow(subject).to receive(:cache_redis?).and_return(false) } + + it 'falls back to sequential gets and un-namespaces keys' do + allow(Legion::Cache).to receive(:get).with('microsoft_teams:a').and_return('v1') + allow(Legion::Cache).to receive(:get).with('microsoft_teams:b').and_return('v2') + expect(subject.cache_mget(':a', ':b')).to eq({ ':a' => 'v1', ':b' => 'v2' }) + end + + it 'accepts an array argument' do + allow(Legion::Cache).to receive(:get).with('microsoft_teams:x').and_return('vx') + expect(subject.cache_mget([':x'])).to eq({ ':x' => 'vx' }) + end + end + end + + describe '#cache_mset' do + context 'with Redis backend' do + before { allow(subject).to receive(:cache_redis?).and_return(true) } + + it 'preserves TTL semantics via sequential set calls' do + expect(Legion::Cache).to receive(:set).with('microsoft_teams:a', 'v1', ttl: 3600, async: false) + expect(Legion::Cache).to receive(:set).with('microsoft_teams:b', 'v2', ttl: 3600, async: false) + subject.cache_mset({ ':a' => 'v1', ':b' => 'v2' }) + end + + it 'uses explicit TTL when provided' do + expect(Legion::Cache).to receive(:set).with('microsoft_teams:k', 'val', ttl: 300, async: false) + subject.cache_mset({ ':k' => 'val' }, ttl: 300) + end + + it 'returns true for empty hash without calling set' do + expect(Legion::Cache).not_to receive(:set) + expect(subject.cache_mset({})).to be true + end + + it 'returns false on error' do + allow(Legion::Cache).to receive(:set).and_raise(StandardError, 'fail') + expect(subject.cache_mset({ ':x' => 'v' })).to be false + end + end + + context 'with Memcached backend' do + before { allow(subject).to receive(:cache_redis?).and_return(false) } + + it 'falls back to sequential sets using default TTL' do + expect(Legion::Cache).to receive(:set).with('microsoft_teams:a', 'v1', ttl: 3600, async: false) + expect(Legion::Cache).to receive(:set).with('microsoft_teams:b', 'v2', ttl: 3600, async: false) + subject.cache_mset({ ':a' => 'v1', ':b' => 'v2' }) + end + + it 'uses explicit TTL when provided' do + expect(Legion::Cache).to receive(:set).with('microsoft_teams:k', 'val', ttl: 300, async: false) + subject.cache_mset({ ':k' => 'val' }, ttl: 300) + end + + it 'returns true on success' do + allow(Legion::Cache).to receive(:set) + expect(subject.cache_mset({ ':k' => 'v' })).to be true + end + end + end + + describe '#local_cache_mget' do + context 'with Redis local backend' do + before do + allow(subject).to receive(:local_cache_redis?).and_return(true) + allow(Legion::Cache::Local).to receive(:get).with('microsoft_teams:a').and_return('lv1') + end + + it 'uses sequential local gets and un-namespaces keys' do + expect(subject.local_cache_mget(':a')).to eq({ ':a' => 'lv1' }) + end + end + + context 'with Memcached local backend' do + before { allow(subject).to receive(:local_cache_redis?).and_return(false) } + + it 'falls back to sequential local gets' do + allow(Legion::Cache::Local).to receive(:get).with('microsoft_teams:a').and_return('lv1') + expect(subject.local_cache_mget(':a')).to eq({ ':a' => 'lv1' }) + end + end + + it 'returns empty hash for empty key list' do + expect(subject.local_cache_mget).to eq({}) + end + end + + describe '#local_cache_mset' do + context 'with Redis local backend' do + before { allow(subject).to receive(:local_cache_redis?).and_return(true) } + + it 'preserves TTL semantics via sequential local set calls' do + expect(Legion::Cache::Local).to receive(:set).with('microsoft_teams:k', 'v', ttl: 21_600, async: false) + subject.local_cache_mset({ ':k' => 'v' }) + end + end + + context 'with Memcached local backend' do + before { allow(subject).to receive(:local_cache_redis?).and_return(false) } + + it 'falls back to sequential local sets' do + expect(Legion::Cache::Local).to receive(:set).with('microsoft_teams:k', 'v', ttl: 21_600, async: false) + subject.local_cache_mset({ ':k' => 'v' }) + end + + it 'uses explicit TTL when provided' do + expect(Legion::Cache::Local).to receive(:set).with('microsoft_teams:k', 'v', ttl: 120, async: false) + subject.local_cache_mset({ ':k' => 'v' }, ttl: 120) + end + end + + it 'returns true for empty hash' do + expect(subject.local_cache_mset({})).to be true + end + end + + # --- Issue #4: RedisHash helper methods --- + + describe '#cache_hset' do + context 'with Redis backend' do + before { allow(subject).to receive(:cache_redis?).and_return(true) } + + it 'delegates to RedisHash.hset with namespaced key' do + expect(Legion::Cache::RedisHash).to receive(:hset).with('microsoft_teams:h', { 'f' => 'v' }).and_return(true) + expect(subject.cache_hset(':h', { 'f' => 'v' })).to be true + end + + it 'returns false on error' do + allow(Legion::Cache::RedisHash).to receive(:hset).and_raise(StandardError, 'fail') + expect(subject.cache_hset(':h', {})).to be false + end + end + + context 'with Memcached backend' do + before { allow(subject).to receive(:cache_redis?).and_return(false) } + + it 'serializes hash as JSON via cache set (merge)' do + allow(Legion::Cache).to receive(:get).with('microsoft_teams:h').and_return(nil) + expect(Legion::Cache).to receive(:set) do |key, json, **opts| + expect(key).to eq('microsoft_teams:h') + expect(opts).to eq(ttl: 3600, async: false) + expect(Legion::JSON.load(json)).to eq(f: 'v') + end + subject.cache_hset(':h', { 'f' => 'v' }) + end + + it 'merges new fields into existing JSON hash' do + allow(Legion::Cache).to receive(:get).with('microsoft_teams:h').and_return('{"existing":"val"}') + expect(Legion::Cache).to receive(:set) do |_key, json, **_opts| + parsed = Legion::JSON.load(json) + expect(parsed).to include(existing: 'val', f: 'v') + end + subject.cache_hset(':h', { 'f' => 'v' }) + end + end + end + + describe '#cache_hgetall' do + context 'with Redis backend' do + before { allow(subject).to receive(:cache_redis?).and_return(true) } + + it 'delegates to RedisHash.hgetall with namespaced key' do + expect(Legion::Cache::RedisHash).to receive(:hgetall).with('microsoft_teams:h').and_return({ 'f' => 'v' }) + expect(subject.cache_hgetall(':h')).to eq({ 'f' => 'v' }) + end + + it 'returns nil on error' do + allow(Legion::Cache::RedisHash).to receive(:hgetall).and_raise(StandardError, 'fail') + expect(subject.cache_hgetall(':h')).to be_nil + end + end + + context 'with Memcached backend' do + before { allow(subject).to receive(:cache_redis?).and_return(false) } + + it 'deserializes JSON from cache and returns string-key hash' do + allow(Legion::Cache).to receive(:get).with('microsoft_teams:h').and_return('{"f":"v"}') + result = subject.cache_hgetall(':h') + expect(result).to eq({ 'f' => 'v' }) + end + + it 'returns nil when key is absent' do + allow(Legion::Cache).to receive(:get).with('microsoft_teams:h').and_return(nil) + expect(subject.cache_hgetall(':h')).to be_nil + end + end + end + + describe '#cache_hdel' do + context 'with Redis backend' do + before { allow(subject).to receive(:cache_redis?).and_return(true) } + + it 'delegates to RedisHash.hdel with namespaced key' do + expect(Legion::Cache::RedisHash).to receive(:hdel).with('microsoft_teams:h', 'f1').and_return(1) + expect(subject.cache_hdel(':h', 'f1')).to eq(1) + end + + it 'returns 0 on error' do + allow(Legion::Cache::RedisHash).to receive(:hdel).and_raise(StandardError, 'fail') + expect(subject.cache_hdel(':h', 'f')).to eq(0) + end + end + + context 'with Memcached backend' do + before { allow(subject).to receive(:cache_redis?).and_return(false) } + + it 'removes specified fields from JSON hash and returns count' do + allow(Legion::Cache).to receive(:get).with('microsoft_teams:h').and_return('{"a":"1","b":"2"}') + expect(Legion::Cache).to receive(:set).with('microsoft_teams:h', anything, ttl: 3600, async: false) do |_k, json, **_opts| + parsed = Legion::JSON.load(json) + expect(parsed.keys.map(&:to_s)).not_to include('a') + end + expect(subject.cache_hdel(':h', 'a')).to eq(1) + end + + it 'returns 0 when key is absent' do + allow(Legion::Cache).to receive(:get).with('microsoft_teams:h').and_return(nil) + expect(subject.cache_hdel(':h', 'f')).to eq(0) + end + end + end + + describe '#cache_zadd' do + context 'with Redis backend' do + before { allow(subject).to receive(:cache_redis?).and_return(true) } + + it 'delegates to RedisHash.zadd with namespaced key' do + expect(Legion::Cache::RedisHash).to receive(:zadd).with('microsoft_teams:z', 1.5, 'member').and_return(true) + expect(subject.cache_zadd(':z', 1.5, 'member')).to be true + end + end + + context 'with Memcached backend' do + before { allow(subject).to receive(:cache_redis?).and_return(false) } + + it 'raises NotImplementedError' do + expect { subject.cache_zadd(':z', 1.0, 'm') }.to raise_error(NotImplementedError, /cache_zadd/) + end + end + end + + describe '#cache_zrangebyscore' do + context 'with Redis backend' do + before { allow(subject).to receive(:cache_redis?).and_return(true) } + + it 'delegates to RedisHash.zrangebyscore with namespaced key' do + expect(Legion::Cache::RedisHash).to receive(:zrangebyscore) + .with('microsoft_teams:z', 0, 100, limit: nil) + .and_return(%w[a b]) + expect(subject.cache_zrangebyscore(':z', 0, 100)).to eq(%w[a b]) + end + + it 'passes limit option' do + expect(Legion::Cache::RedisHash).to receive(:zrangebyscore) + .with('microsoft_teams:z', 0, 100, limit: [0, 5]) + .and_return(['a']) + expect(subject.cache_zrangebyscore(':z', 0, 100, limit: [0, 5])).to eq(['a']) + end + end + + context 'with Memcached backend' do + before { allow(subject).to receive(:cache_redis?).and_return(false) } + + it 'raises NotImplementedError' do + expect { subject.cache_zrangebyscore(':z', 0, 100) }.to raise_error(NotImplementedError, /cache_zrangebyscore/) + end + end + end + + describe '#cache_zrem' do + context 'with Redis backend' do + before { allow(subject).to receive(:cache_redis?).and_return(true) } + + it 'delegates to RedisHash.zrem with namespaced key' do + expect(Legion::Cache::RedisHash).to receive(:zrem).with('microsoft_teams:z', 'm').and_return(true) + expect(subject.cache_zrem(':z', 'm')).to be true + end + end + + context 'with Memcached backend' do + before { allow(subject).to receive(:cache_redis?).and_return(false) } + + it 'raises NotImplementedError' do + expect { subject.cache_zrem(':z', 'm') }.to raise_error(NotImplementedError, /cache_zrem/) + end + end + end + + describe '#cache_expire' do + context 'with Redis backend' do + before { allow(subject).to receive(:cache_redis?).and_return(true) } + + it 'delegates to RedisHash.expire with namespaced key' do + expect(Legion::Cache::RedisHash).to receive(:expire).with('microsoft_teams:k', 300).and_return(true) + expect(subject.cache_expire(':k', 300)).to be true + end + + it 'returns false on error' do + allow(Legion::Cache::RedisHash).to receive(:expire).and_raise(StandardError, 'fail') + expect(subject.cache_expire(':k', 60)).to be false + end + end + + context 'with Memcached backend (no-op)' do + before { allow(subject).to receive(:cache_redis?).and_return(false) } + + it 'returns false without calling RedisHash' do + expect(Legion::Cache::RedisHash).not_to receive(:expire) + expect(subject.cache_expire(':k', 300)).to be false + end + end + end +end diff --git a/spec/legion/cache/lifecycle_spec.rb b/spec/legion/cache/lifecycle_spec.rb new file mode 100644 index 0000000..e776f5e --- /dev/null +++ b/spec/legion/cache/lifecycle_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache' + +RSpec.describe 'full cache lifecycle' do + let(:local_store) { {} } + let(:shared_available) { Concurrent::AtomicBoolean.new(false) } + + before do + Legion::Cache.instance_variable_set(:@client, nil) + Legion::Cache.instance_variable_set(:@connected, Concurrent::AtomicBoolean.new(false)) + Legion::Cache.instance_variable_set(:@using_local, Concurrent::AtomicBoolean.new(false)) + Legion::Cache.instance_variable_set(:@using_memory, Concurrent::AtomicBoolean.new(false)) + Legion::Cache.instance_variable_set(:@active_shared_driver, nil) + Legion::Cache::Local.reset! + + Legion::Settings[:cache][:enabled] = true + + # Stub Local + allow(Legion::Cache::Local).to receive(:connected?).and_return(true) + allow(Legion::Cache::Local).to receive(:enabled?).and_return(true) + allow(Legion::Cache::Local).to receive(:setup) + allow(Legion::Cache::Local).to receive(:shutdown) + allow(Legion::Cache::Local).to receive(:get) { |key| local_store[key] } + allow(Legion::Cache::Local).to receive(:set) do |key, value, **| + local_store[key] = value + true + end + + # Stub shared to fail initially + allow(Legion::Cache).to receive(:client).and_invoke( + lambda { |**| + raise 'connection refused' if shared_available.false? + + nil + } + ) + end + + after do + reconnector = Legion::Cache.instance_variable_get(:@reconnector) + reconnector&.stop + Legion::Settings[:cache][:enabled] = true + end + + it 'fails back to local, then recovers when shared comes back' do + # Phase 1: shared fails, falls back to local + Legion::Cache.setup + expect(Legion::Cache.using_local?).to be(true) + + # Phase 2: operations work via local + Legion::Cache.set('lifecycle', 'local_value', async: false) + expect(Legion::Cache.get('lifecycle')).to eq('local_value') + expect(local_store['lifecycle']).to eq('local_value') + + # Phase 3: shared comes back + shared_available.make_true + + # Phase 4: verify reconnector was started + reconnector = Legion::Cache.instance_variable_get(:@reconnector) + expect(reconnector).not_to be_nil + + # Cleanup + reconnector.stop + end + + it 'returns nil everywhere when both shared and local are down' do + allow(Legion::Cache::Local).to receive(:connected?).and_return(false) + + Legion::Cache.setup + expect(Legion::Cache.get('anything')).to be_nil + end +end diff --git a/spec/legion/cache/memcached_tls_spec.rb b/spec/legion/cache/memcached_tls_spec.rb new file mode 100644 index 0000000..a33a236 --- /dev/null +++ b/spec/legion/cache/memcached_tls_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache/memcached' + +RSpec.describe 'Legion::Cache::Memcached TLS' do + let(:mc_mod) { Legion::Cache::Memcached.dup } + + before do + stub_const('Legion::Crypt::TLS', Module.new) + allow(Legion::Cache::Settings).to receive(:resolve_servers).and_return(['127.0.0.1:11211']) + allow(Dalli).to receive(:logger=) + end + + after { mc_mod.instance_variable_set(:@client, nil) } + + describe 'TLS options passed to Dalli::Client' do + it 'uses a shared cache logger for Dalli internals' do + allow(Legion::Crypt::TLS).to receive(:resolve).and_return( + { enabled: false, verify: :peer, ca: nil, cert: nil, key: nil, auto_detected: false } + ) + expect(Dalli).to receive(:logger=).with(an_instance_of(Legion::Logging::TaggedLogger)) + expect(Dalli::Client).to receive(:new).and_return(double(alive!: true)) + allow(ConnectionPool).to receive(:new).and_yield + + mc_mod.client + end + + it 'passes ssl_context when TLS is enabled' do + allow(Legion::Crypt::TLS).to receive(:resolve).and_return( + { enabled: true, verify: :peer, ca: '/ca.crt', cert: nil, key: nil, auto_detected: false } + ) + expect(Dalli::Client).to receive(:new) do |_servers, opts| + expect(opts[:ssl_context]).to be_a(OpenSSL::SSL::SSLContext) + end.and_return(double(alive!: true)) + allow(ConnectionPool).to receive(:new).and_yield + + mc_mod.client + end + + it 'skips ssl_context when TLS is disabled' do + allow(Legion::Crypt::TLS).to receive(:resolve).and_return( + { enabled: false, verify: :peer, ca: nil, cert: nil, key: nil, auto_detected: false } + ) + expect(Dalli::Client).to receive(:new) do |_servers, opts| + expect(opts).not_to have_key(:ssl_context) + end.and_return(double(alive!: true)) + allow(ConnectionPool).to receive(:new).and_yield + + mc_mod.client + end + + it 'skips ssl_context when Legion::Crypt::TLS is not defined' do + hide_const('Legion::Crypt::TLS') + expect(Dalli::Client).to receive(:new) do |_servers, opts| + expect(opts).not_to have_key(:ssl_context) + end.and_return(double(alive!: true)) + allow(ConnectionPool).to receive(:new).and_yield + + mc_mod.client + end + end +end diff --git a/spec/legion/cache/memory_spec.rb b/spec/legion/cache/memory_spec.rb new file mode 100644 index 0000000..c37dea8 --- /dev/null +++ b/spec/legion/cache/memory_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache/memory' + +RSpec.describe Legion::Cache::Memory do + before { described_class.reset! } + + describe '.get / .set' do + it 'stores and retrieves a value' do + described_class.set('key1', 'value1') + expect(described_class.get('key1')).to eq('value1') + end + + it 'returns nil for missing key' do + expect(described_class.get('missing')).to be_nil + end + + it 'expires values after TTL' do + described_class.set('expire-me', 'data', ttl: 0.1) + sleep 0.15 + expect(described_class.get('expire-me')).to be_nil + end + + it 'clears stale expiry when a key is rewritten without TTL' do + described_class.set('refresh-me', 'old', ttl: 0.05) + described_class.set('refresh-me', 'new') + sleep 0.06 + + expect(described_class.get('refresh-me')).to eq('new') + end + end + + describe '.delete' do + it 'removes a key' do + described_class.set('del-key', 'val') + described_class.delete('del-key') + expect(described_class.get('del-key')).to be_nil + end + end + + describe '.fetch' do + it 'returns existing value' do + described_class.set('f-key', 'existing') + result = described_class.fetch('f-key') { 'fallback' } # rubocop:disable Style/RedundantFetchBlock + expect(result).to eq('existing') + end + + it 'stores and returns block value on miss' do + result = described_class.fetch('f-miss') { 'computed' } # rubocop:disable Style/RedundantFetchBlock + expect(result).to eq('computed') + expect(described_class.get('f-miss')).to eq('computed') + end + end + + describe '.flush' do + it 'clears all entries' do + described_class.set('a', 1) + described_class.set('b', 2) + described_class.flush + expect(described_class.get('a')).to be_nil + expect(described_class.get('b')).to be_nil + end + end + + describe '.connected?' do + it 'returns true after setup' do + described_class.setup + expect(described_class.connected?).to be true + end + end + + describe '.shutdown' do + it 'marks as disconnected' do + described_class.setup + described_class.shutdown + expect(described_class.connected?).to be false + end + end + + describe 'keyword ttl' do + before { described_class.setup } + + it 'accepts ttl as keyword arg on set' do + described_class.set('kw', 'val', ttl: 300) + expect(described_class.get('kw')).to eq('val') + end + + it 'accepts ttl as keyword arg on fetch' do + result = described_class.fetch('fkw', ttl: 300) { 'fetched' } + expect(result).to eq('fetched') + end + end + + describe 'flush takes no arguments' do + it 'has arity 0' do + expect(described_class.method(:flush).arity).to eq(0) + end + end + + describe 'thread safety' do + it 'handles concurrent reads and writes' do + described_class.setup + threads = 20.times.map do |i| + Thread.new do + described_class.set("k#{i}", i) + described_class.get("k#{i}") + end + end + threads.each(&:join) + expect(described_class.get('k0')).to eq(0) + end + end +end diff --git a/spec/legion/cache/phi_policy_spec.rb b/spec/legion/cache/phi_policy_spec.rb new file mode 100644 index 0000000..bb5f720 --- /dev/null +++ b/spec/legion/cache/phi_policy_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache' + +RSpec.describe 'Legion::Cache PHI TTL policy' do + before do + allow(Legion::Settings).to receive(:dig).with(:cache, :compliance, :phi_max_ttl).and_return(3600) + end + + describe 'Legion::Cache.phi_max_ttl' do + it 'returns the configured phi_max_ttl' do + expect(Legion::Cache.phi_max_ttl).to eq(3600) + end + end + + describe 'Legion::Cache.enforce_phi_ttl' do + it 'caps ttl at phi_max_ttl when phi: true' do + result = Legion::Cache.enforce_phi_ttl(7200, phi: true) + expect(result).to eq(3600) + end + + it 'returns original ttl when phi is false' do + result = Legion::Cache.enforce_phi_ttl(7200, phi: false) + expect(result).to eq(7200) + end + + it 'returns original ttl when phi key is absent' do + result = Legion::Cache.enforce_phi_ttl(7200) + expect(result).to eq(7200) + end + + it 'caps even if ttl is below phi_max_ttl -- passes through lower value' do + result = Legion::Cache.enforce_phi_ttl(60, phi: true) + expect(result).to eq(60) + end + + it 'caps at phi_max_ttl when ttl exceeds it and phi: true' do + result = Legion::Cache.enforce_phi_ttl(86_400, phi: true) + expect(result).to eq(3600) + end + end + + describe 'Legion::Cache.set with phi: true option' do + before do + allow(Legion::Cache::Memory).to receive(:set).with(anything, anything, ttl: anything) + Legion::Cache.instance_variable_set(:@using_memory, Concurrent::AtomicBoolean.new(true)) + end + + after do + Legion::Cache.instance_variable_set(:@using_memory, Concurrent::AtomicBoolean.new(false)) + end + + it 'enforces phi max ttl before delegating to memory adapter' do + Legion::Cache.set('phi:task:99', 'value', ttl: 7200, phi: true, async: false) + expect(Legion::Cache::Memory).to have_received(:set).with('phi:task:99', 'value', ttl: 3600) + end + + it 'passes original ttl when phi is not set' do + Legion::Cache.set('task:99', 'value', ttl: 7200, async: false) + expect(Legion::Cache::Memory).to have_received(:set).with('task:99', 'value', ttl: 7200) + end + end +end diff --git a/spec/legion/cache/reconnector_integration_spec.rb b/spec/legion/cache/reconnector_integration_spec.rb new file mode 100644 index 0000000..39dffd5 --- /dev/null +++ b/spec/legion/cache/reconnector_integration_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache' + +RSpec.describe 'reconnector integration' do + before do + Legion::Cache.instance_variable_set(:@client, nil) + Legion::Cache.instance_variable_set(:@connected, Concurrent::AtomicBoolean.new(false)) + Legion::Cache.instance_variable_set(:@using_local, Concurrent::AtomicBoolean.new(false)) + Legion::Cache.instance_variable_set(:@using_memory, Concurrent::AtomicBoolean.new(false)) + Legion::Cache.instance_variable_set(:@active_shared_driver, nil) + Legion::Cache.instance_variable_set(:@reconnector, nil) + Legion::Cache::Local.reset! + Legion::Settings[:cache][:enabled] = true + end + + after do + reconnector = Legion::Cache.instance_variable_get(:@reconnector) + reconnector&.stop + Legion::Cache.instance_variable_set(:@reconnector, nil) + Legion::Settings[:cache][:enabled] = true + end + + it 'stats reports reconnect_attempts' do + stats = Legion::Cache.stats + expect(stats[:reconnect_attempts]).to be_a(Integer) + end + + it 'setup failure triggers reconnector when enabled' do + allow(Legion::Cache).to receive(:client).and_raise(RuntimeError, 'refused') + allow(Legion::Cache::Local).to receive(:connected?).and_return(false) + allow(Legion::Cache::Local).to receive(:setup) + + Legion::Cache.setup + + reconnector = Legion::Cache.instance_variable_get(:@reconnector) + expect(reconnector).not_to be_nil + expect(reconnector.running?).to be(true) + + reconnector.stop + end + + it 'starts reconnector even when local fallback succeeds' do + allow(Legion::Cache).to receive(:client).and_raise(RuntimeError, 'refused') + allow(Legion::Cache::Local).to receive(:connected?).and_return(true) + allow(Legion::Cache::Local).to receive(:setup) + + Legion::Cache.setup + + expect(Legion::Cache.using_local?).to be(true) + reconnector = Legion::Cache.instance_variable_get(:@reconnector) + expect(reconnector).not_to be_nil + expect(reconnector.running?).to be(true) + reconnector.stop + end + + it 'does not start reconnector when disabled' do + Legion::Settings[:cache][:enabled] = false + allow(Legion::Cache::Local).to receive(:connected?).and_return(false) + allow(Legion::Cache::Local).to receive(:setup) + + Legion::Cache.setup + + reconnector = Legion::Cache.instance_variable_get(:@reconnector) + expect(reconnector).to be_nil + Legion::Settings[:cache][:enabled] = true + end +end diff --git a/spec/legion/cache/reconnector_spec.rb b/spec/legion/cache/reconnector_spec.rb new file mode 100644 index 0000000..d1f8dc3 --- /dev/null +++ b/spec/legion/cache/reconnector_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'concurrent-ruby' +require 'legion/cache/reconnector' + +RSpec.describe Legion::Cache::Reconnector do + let(:connect_called) { Concurrent::AtomicFixnum.new(0) } + let(:connect_block) do + lambda { + connect_called.increment + raise 'nope' + } + end + let(:enabled_block) { -> { true } } + + subject(:reconnector) do + described_class.new( + tier: :shared, + connect_block: connect_block, + enabled_block: enabled_block + ) + end + + after { reconnector.stop } + + describe '#start' do + it 'starts a reconnect loop' do + reconnector.start + expect(reconnector.running?).to be(true) + end + + it 'is idempotent' do + reconnector.start + reconnector.start + expect(reconnector.running?).to be(true) + end + end + + describe '#stop' do + it 'stops the reconnect loop' do + reconnector.start + reconnector.stop + expect(reconnector.running?).to be(false) + end + end + + describe 'exponential backoff' do + it 'attempts reconnection with backoff' do + reconnector.start + sleep 1.5 + reconnector.stop + expect(connect_called.value).to be >= 1 + end + + it 'tracks attempt count' do + reconnector.start + sleep 1.5 + reconnector.stop + expect(reconnector.attempts).to be >= 1 + end + end + + describe 'successful reconnect' do + let(:connect_block) { -> { connect_called.increment } } + + it 'stops after successful reconnect' do + reconnector.start + sleep 1.5 + expect(reconnector.running?).to be(false) + expect(connect_called.value).to eq(1) + end + + it 'resets attempts after success' do + reconnector.start + sleep 1.5 + expect(reconnector.attempts).to eq(0) + end + end + + describe 'can be required independently' do + it 'loads without NameError' do + expect { require 'legion/cache/reconnector' }.not_to raise_error + end + end + + describe 'successful reconnect does not raise on attempt reset' do + let(:connect_block) { -> { connect_called.increment } } + + it 'does not raise NoMethodError on attempt reset' do + reconnector.start + sleep 1.5 + expect { reconnector.stop }.not_to raise_error + end + end + + describe 'respects enabled?' do + let(:enabled_block) { -> { false } } + + it 'does not attempt reconnect when disabled' do + reconnector.start + sleep 1.5 + reconnector.stop + expect(connect_called.value).to eq(0) + end + end +end diff --git a/spec/legion/cache/redis_cluster_spec.rb b/spec/legion/cache/redis_cluster_spec.rb new file mode 100644 index 0000000..99cef01 --- /dev/null +++ b/spec/legion/cache/redis_cluster_spec.rb @@ -0,0 +1,332 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache/redis' + +RSpec.describe Legion::Cache::Redis, 'cluster mode' do + before do + described_class.instance_variable_set(:@client, nil) + described_class.instance_variable_set(:@connected, false) + described_class.instance_variable_set(:@cluster_mode, nil) + end + + after do + described_class.instance_variable_set(:@client, nil) + described_class.instance_variable_set(:@connected, false) + described_class.instance_variable_set(:@cluster_mode, nil) + end + + let(:cluster_nodes) { ['redis://node1:6379', 'redis://node2:6379', 'redis://node3:6379'] } + + describe '#build_redis_client cluster options' do + it 'passes cluster nodes to Redis.new' do + redis_instance = instance_double(Redis) + allow(Redis).to receive(:new).with(hash_including(cluster: cluster_nodes)).and_return(redis_instance) + result = described_class.build_redis_client(cluster: cluster_nodes) + expect(result).to eq redis_instance + end + + it 'passes replica: true when replica is enabled' do + redis_instance = instance_double(Redis) + allow(Redis).to receive(:new).with(hash_including(cluster: cluster_nodes, replica: true)).and_return(redis_instance) + result = described_class.build_redis_client(cluster: cluster_nodes, replica: true) + expect(result).to eq redis_instance + end + + it 'passes fixed_hostname when provided' do + redis_instance = instance_double(Redis) + allow(Redis).to receive(:new).with(hash_including(cluster: cluster_nodes, fixed_hostname: 'redis.internal')).and_return(redis_instance) + result = described_class.build_redis_client(cluster: cluster_nodes, fixed_hostname: 'redis.internal') + expect(result).to eq redis_instance + end + + it 'passes all cluster options together' do + redis_instance = instance_double(Redis) + allow(Redis).to receive(:new).with(hash_including(cluster: cluster_nodes, replica: true, fixed_hostname: 'redis.internal')).and_return(redis_instance) + result = described_class.build_redis_client(cluster: cluster_nodes, replica: true, fixed_hostname: 'redis.internal') + expect(result).to eq redis_instance + end + + it 'omits replica when false' do + redis_instance = instance_double(Redis) + allow(Redis).to receive(:new).with(hash_including(cluster: cluster_nodes)).and_return(redis_instance) + result = described_class.build_redis_client(cluster: cluster_nodes, replica: false) + expect(result).to eq redis_instance + end + + it 'omits fixed_hostname when nil' do + redis_instance = instance_double(Redis) + allow(Redis).to receive(:new).with(hash_including(cluster: cluster_nodes)).and_return(redis_instance) + result = described_class.build_redis_client(cluster: cluster_nodes, fixed_hostname: nil) + expect(result).to eq redis_instance + end + + it 'falls back to single-node when cluster is empty' do + redis_instance = instance_double(Redis) + allow(Redis).to receive(:new).with(hash_including(host: '127.0.0.1', port: 6379)).and_return(redis_instance) + result = described_class.build_redis_client(cluster: []) + expect(result).to eq redis_instance + end + + it 'falls back to single-node when cluster contains only nils' do + redis_instance = instance_double(Redis) + allow(Redis).to receive(:new).with(hash_including(host: '127.0.0.1', port: 6379)).and_return(redis_instance) + result = described_class.build_redis_client(cluster: [nil, nil]) + expect(result).to eq redis_instance + end + + it 'parses bracketed IPv6 hosts for standalone connections' do + redis_instance = instance_double(Redis) + allow(Legion::Cache::Settings).to receive(:resolve_servers).and_return(['[::1]:6379']) + allow(Redis).to receive(:new).with(hash_including(host: '::1', port: 6379)).and_return(redis_instance) + + result = described_class.build_redis_client(cluster: nil) + expect(result).to eq redis_instance + end + end + + describe '#cluster_mode?' do + it 'returns true after connecting with cluster nodes' do + redis_instance = instance_double(Redis) + allow(Redis).to receive(:new).and_return(redis_instance) + described_class.client(cluster: cluster_nodes) + expect(described_class.cluster_mode?).to eq true + end + + it 'returns false after connecting without cluster' do + redis_instance = instance_double(Redis) + allow(Redis).to receive(:new).and_return(redis_instance) + described_class.client(server: '127.0.0.1:6379') + expect(described_class.cluster_mode?).to eq false + end + + it 'returns false before any connection' do + expect(described_class.cluster_mode?).to eq false + end + end + + describe '#mget' do + let(:redis_conn) { instance_double(Redis) } + let(:pool) { instance_double(ConnectionPool) } + + before do + described_class.instance_variable_set(:@client, pool) + described_class.instance_variable_set(:@connected, true) + allow(pool).to receive(:with).and_yield(redis_conn) + end + + context 'standalone mode' do + before { described_class.instance_variable_set(:@cluster_mode, false) } + + it 'returns a hash of key-value pairs' do + allow(redis_conn).to receive(:mget).with('a', 'b', 'c').and_return(%w[1 2 3]) + result = described_class.mget('a', 'b', 'c') + expect(result).to eq({ 'a' => '1', 'b' => '2', 'c' => '3' }) + end + + it 'returns empty hash for empty keys' do + result = described_class.mget + expect(result).to eq({}) + end + + it 'handles nil values in results' do + allow(redis_conn).to receive(:mget).with('a', 'b').and_return(['1', nil]) + result = described_class.mget('a', 'b') + expect(result).to eq({ 'a' => '1', 'b' => nil }) + end + + it 'accepts keys as an array' do + allow(redis_conn).to receive(:mget).with('x', 'y').and_return(%w[10 20]) + result = described_class.mget(%w[x y]) + expect(result).to eq({ 'x' => '10', 'y' => '20' }) + end + end + + context 'cluster mode' do + before { described_class.instance_variable_set(:@cluster_mode, true) } + + it 'groups keys by slot and merges results' do + converter = class_double('Redis::Cluster::KeySlotConverter').as_stubbed_const + allow(converter).to receive(:convert).with('a').and_return(0) + allow(converter).to receive(:convert).with('b').and_return(1) + allow(converter).to receive(:convert).with('c').and_return(0) + + allow(redis_conn).to receive(:mget).with('a', 'c').and_return(%w[1 3]) + allow(redis_conn).to receive(:mget).with('b').and_return(['2']) + + result = described_class.mget('a', 'b', 'c') + expect(result).to eq({ 'a' => '1', 'b' => '2', 'c' => '3' }) + end + + it 'handles single-slot keys normally' do + converter = class_double('Redis::Cluster::KeySlotConverter').as_stubbed_const + allow(converter).to receive(:convert).and_return(5) + + allow(redis_conn).to receive(:mget).with('x', 'y').and_return(%w[10 20]) + + result = described_class.mget('x', 'y') + expect(result).to eq({ 'x' => '10', 'y' => '20' }) + end + end + end + + describe '#fetch' do + it 'returns the existing value without writing' do + allow(described_class).to receive(:get).with('fetch-key').and_return('cached') + expect(described_class).not_to receive(:set) + fetch_block = proc { 'computed' } + + expect(described_class.fetch('fetch-key', ttl: 60, &fetch_block)).to eq('cached') + end + + it 'stores and returns the computed value on miss' do + allow(described_class).to receive(:get).with('fetch-key').and_return(nil) + expect(described_class).to receive(:set).with('fetch-key', 'computed', ttl: 60).and_return(true) + fetch_block = proc { 'computed' } + + expect(described_class.fetch('fetch-key', ttl: 60, &fetch_block)).to eq('computed') + end + end + + describe '#mset' do + let(:redis_conn) { instance_double(Redis) } + let(:pool) { instance_double(ConnectionPool) } + + before do + described_class.instance_variable_set(:@client, pool) + described_class.instance_variable_set(:@connected, true) + allow(pool).to receive(:with).and_yield(redis_conn) + end + + context 'standalone mode' do + before { described_class.instance_variable_set(:@cluster_mode, false) } + + it 'sets all key-value pairs via set_sync' do + allow(redis_conn).to receive(:set).and_return('OK') + result = described_class.mset({ 'a' => '1', 'b' => '2' }) + expect(result).to eq true + expect(redis_conn).to have_received(:set).twice + end + + it 'returns true for empty hash' do + result = described_class.mset({}) + expect(result).to eq true + end + end + end + + describe 'exception handling' do + let(:redis_conn) { instance_double(Redis) } + let(:pool) { instance_double(ConnectionPool) } + + before do + described_class.instance_variable_set(:@client, pool) + described_class.instance_variable_set(:@connected, true) + described_class.instance_variable_set(:@cluster_mode, false) + allow(pool).to receive(:with).and_yield(redis_conn) + end + + it 'get returns nil on failure (handled)' do + allow(redis_conn).to receive(:get).and_raise(Redis::BaseError, 'node down') + expect(described_class.get('key')).to be_nil + end + + it 'set_sync re-raises on failure' do + allow(redis_conn).to receive(:set).and_raise(Redis::BaseError, 'write failed') + expect { described_class.set_sync('key', 'val', ttl: 60) }.to raise_error(Redis::BaseError) + end + + it 'delete_sync re-raises on failure' do + allow(redis_conn).to receive(:del).and_raise(Redis::BaseError, 'conn lost') + expect { described_class.delete_sync('key') }.to raise_error(Redis::BaseError) + end + + it 'mget returns empty hash on failure (handled)' do + allow(redis_conn).to receive(:mget).and_raise(Redis::BaseError, 'cluster fail') + expect(described_class.mget('a')).to eq({}) + end + + it 'mset_sync re-raises on failure' do + allow(redis_conn).to receive(:set).and_raise(Redis::BaseError, 'write fail') + expect { described_class.mset_sync({ 'a' => '1' }) }.to raise_error(Redis::BaseError) + end + + it 'flush returns nil on failure (handled)' do + allow(redis_conn).to receive(:flushdb).and_raise(Redis::BaseError, 'flush fail') + expect(described_class.flush).to be_nil + end + end + + describe '#flush in cluster mode' do + let(:redis_conn) { instance_double(Redis) } + let(:pool) { instance_double(ConnectionPool) } + + before do + described_class.instance_variable_set(:@client, pool) + described_class.instance_variable_set(:@connected, true) + described_class.instance_variable_set(:@cluster_mode, true) + allow(pool).to receive(:with).and_yield(redis_conn) + end + + it 'flushes all primary nodes' do + node_info = "abc123 10.0.0.1:6379@16379 master - 0 0 1 connected 0-5460\ndef456 10.0.0.2:6379@16379 master - 0 0 2 connected 5461-10922\n" + allow(redis_conn).to receive(:cluster).with('nodes').and_return(node_info) + described_class.instance_variable_set(:@connection_opts, {}) + + node1 = instance_double(Redis) + node2 = instance_double(Redis) + allow(Redis).to receive(:new).with(hash_including(host: '10.0.0.1', port: 6379)).and_return(node1) + allow(Redis).to receive(:new).with(hash_including(host: '10.0.0.2', port: 6379)).and_return(node2) + allow(node1).to receive(:flushdb) + allow(node1).to receive(:close) + allow(node2).to receive(:flushdb) + allow(node2).to receive(:close) + + expect(described_class.flush).to eq true + end + + it 'passes credentials to per-node connections' do + cache = described_class.dup + cache.instance_variable_set(:@client, pool) + cache.instance_variable_set(:@connected, true) + cache.instance_variable_set(:@cluster_mode, true) + cache.instance_variable_set(:@connection_opts, { username: 'user', password: 'pass' }) + + node_info = "abc123 10.0.0.1:6379@16379 myself,master - 0 0 1 connected 0-5460\n" + allow(redis_conn).to receive(:cluster).with('nodes').and_return(node_info) + + node_client = instance_double(Redis) + expect(Redis).to receive(:new).with(hash_including(host: '10.0.0.1', port: 6379, username: 'user', password: 'pass')).and_return(node_client) + allow(node_client).to receive(:flushdb) + allow(node_client).to receive(:close) + + cache.flush + end + + it 'falls back to single flushdb on cluster nodes error' do + allow(redis_conn).to receive(:cluster).and_raise(StandardError, 'cluster info failed') + allow(redis_conn).to receive(:flushdb).and_return('OK') + expect(described_class.flush).to eq true + end + end + + describe 'settings defaults' do + it 'includes cluster key defaulting to nil' do + defaults = Legion::Cache::Settings.default + expect(defaults).to have_key(:cluster) + expect(defaults[:cluster]).to be_nil + end + + it 'includes replica key defaulting to false' do + defaults = Legion::Cache::Settings.default + expect(defaults).to have_key(:replica) + expect(defaults[:replica]).to eq false + end + + it 'includes fixed_hostname key defaulting to nil' do + defaults = Legion::Cache::Settings.default + expect(defaults).to have_key(:fixed_hostname) + expect(defaults[:fixed_hostname]).to be_nil + end + end +end diff --git a/spec/legion/cache/redis_hash_spec.rb b/spec/legion/cache/redis_hash_spec.rb new file mode 100644 index 0000000..62dd693 --- /dev/null +++ b/spec/legion/cache/redis_hash_spec.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache/redis_hash' + +RSpec.describe Legion::Cache::RedisHash do + subject(:mod) { Legion::Cache::RedisHash } + + describe '.redis_available?' do + context 'when the cache pool is nil' do + before { allow(Legion::Cache).to receive(:pool).and_return(nil) } + + it 'returns false' do + expect(mod.redis_available?).to be false + end + end + + context 'when the cache is not connected' do + before do + pool = double('ConnectionPool') + allow(Legion::Cache).to receive(:pool).and_return(pool) + allow(Legion::Cache).to receive(:connected?).and_return(false) + allow(Legion::Cache).to receive(:driver_name).and_return('redis') + end + + it 'returns false' do + expect(mod.redis_available?).to be false + end + end + + context 'when the cache is connected on Redis' do + before do + pool = double('ConnectionPool') + allow(Legion::Cache).to receive(:pool).and_return(pool) + allow(Legion::Cache).to receive(:connected?).and_return(true) + allow(Legion::Cache).to receive(:driver_name).and_return('redis') + end + + it 'returns true' do + expect(mod.redis_available?).to be true + end + end + + context 'when the cache is connected on Memcached' do + before do + pool = double('ConnectionPool') + allow(Legion::Cache).to receive(:pool).and_return(pool) + allow(Legion::Cache).to receive(:connected?).and_return(true) + allow(Legion::Cache).to receive(:driver_name).and_return('dalli') + end + + it 'returns false' do + expect(mod.redis_available?).to be false + end + end + + context 'when an exception is raised' do + before { allow(Legion::Cache).to receive(:pool).and_raise(RuntimeError, 'boom') } + + it 'returns false' do + expect(mod.redis_available?).to be false + end + end + end + + describe 'does not access @client directly' do + it 'uses Legion::Cache.pool instead of instance_variable_get' do + source = File.read(File.expand_path('../../../lib/legion/cache/redis_hash.rb', __dir__)) + expect(source).not_to include('instance_variable_get(:@client)') + end + end + + describe 'safe defaults when Redis is unavailable' do + before { allow(mod).to receive(:redis_available?).and_return(false) } + + it '#hset returns false' do + expect(mod.hset('key', { 'a' => '1' })).to be false + end + + it '#hgetall returns nil' do + expect(mod.hgetall('key')).to be_nil + end + + it '#hdel returns 0' do + expect(mod.hdel('key', 'field')).to eq 0 + end + + it '#zadd returns false' do + expect(mod.zadd('key', 1.0, 'member')).to be false + end + + it '#zrangebyscore returns empty array' do + expect(mod.zrangebyscore('key', 0, 100)).to eq [] + end + + it '#zrem returns false' do + expect(mod.zrem('key', 'member')).to be false + end + + it '#expire returns false' do + expect(mod.expire('key', 3600)).to be false + end + end + + describe 'Redis command delegation' do + let(:conn) { double('Redis connection') } + let(:pool) { double('ConnectionPool') } + + before do + allow(mod).to receive(:redis_available?).and_return(true) + allow(Legion::Cache).to receive(:pool).and_return(pool) + allow(pool).to receive(:with).and_yield(conn) + end + + describe '#hset' do + it 'calls conn.hset with flattened key-value pairs and returns true' do + allow(conn).to receive(:hset).with('mykey', 'field1', 'val1', 'field2', 'val2').and_return(2) + expect(mod.hset('mykey', { 'field1' => 'val1', 'field2' => 'val2' })).to be true + end + end + + describe '#hgetall' do + it 'returns the hash from conn.hgetall' do + allow(conn).to receive(:hgetall).with('mykey').and_return({ 'f' => 'v' }) + expect(mod.hgetall('mykey')).to eq({ 'f' => 'v' }) + end + + it 'returns nil when conn.hgetall returns empty hash' do + allow(conn).to receive(:hgetall).with('mykey').and_return({}) + expect(mod.hgetall('mykey')).to eq({}) + end + end + + describe '#hdel' do + it 'calls conn.hdel with fields and returns the result' do + allow(conn).to receive(:hdel).with('mykey', 'field1').and_return(1) + expect(mod.hdel('mykey', 'field1')).to eq 1 + end + end + + describe '#zadd' do + it 'calls conn.zadd with stringified member and returns true' do + allow(conn).to receive(:zadd).with('zkey', 1.5, 'member1').and_return(1) + expect(mod.zadd('zkey', 1.5, 'member1')).to be true + end + end + + describe '#zrangebyscore' do + it 'calls conn.zrangebyscore and returns the array' do + allow(conn).to receive(:zrangebyscore).with('zkey', 0, 100).and_return(%w[a b]) + expect(mod.zrangebyscore('zkey', 0, 100)).to eq %w[a b] + end + + it 'passes limit option when provided' do + allow(conn).to receive(:zrangebyscore).with('zkey', 0, 100, limit: [0, 10]).and_return(['a']) + expect(mod.zrangebyscore('zkey', 0, 100, limit: [0, 10])).to eq ['a'] + end + end + + describe '#zrem' do + it 'calls conn.zrem and returns true' do + allow(conn).to receive(:zrem).with('zkey', 'member1').and_return(1) + expect(mod.zrem('zkey', 'member1')).to be true + end + end + + describe '#expire' do + it 'calls conn.expire and returns true when Redis returns 1' do + allow(conn).to receive(:expire).with('mykey', 3600).and_return(1) + expect(mod.expire('mykey', 3600)).to be true + end + + it 'returns false when Redis returns 0 (key not found)' do + allow(conn).to receive(:expire).with('missing', 60).and_return(0) + expect(mod.expire('missing', 60)).to be false + end + end + end + + describe 'error handling' do + let(:conn) { double('Redis connection') } + let(:pool) { double('ConnectionPool') } + + before do + allow(mod).to receive(:redis_available?).and_return(true) + allow(Legion::Cache).to receive(:pool).and_return(pool) + allow(pool).to receive(:with).and_yield(conn) + end + + it '#hset returns false on StandardError' do + allow(conn).to receive(:hset).and_raise(StandardError, 'fail') + expect(mod.hset('k', { 'a' => '1' })).to be false + end + + it '#hgetall returns nil on StandardError' do + allow(conn).to receive(:hgetall).and_raise(StandardError, 'fail') + expect(mod.hgetall('k')).to be_nil + end + + it '#zadd returns false on StandardError' do + allow(conn).to receive(:zadd).and_raise(StandardError, 'fail') + expect(mod.zadd('k', 1.0, 'm')).to be false + end + + it '#zrangebyscore returns empty array on StandardError' do + allow(conn).to receive(:zrangebyscore).and_raise(StandardError, 'fail') + expect(mod.zrangebyscore('k', 0, 1)).to eq [] + end + + it '#zrem returns false on StandardError' do + allow(conn).to receive(:zrem).and_raise(StandardError, 'fail') + expect(mod.zrem('k', 'm')).to be false + end + + it '#expire returns false on StandardError' do + allow(conn).to receive(:expire).and_raise(StandardError, 'fail') + expect(mod.expire('k', 60)).to be false + end + end +end diff --git a/spec/legion/cache/redis_serialization_spec.rb b/spec/legion/cache/redis_serialization_spec.rb new file mode 100644 index 0000000..73ce0fa --- /dev/null +++ b/spec/legion/cache/redis_serialization_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache/redis' + +RSpec.describe 'Redis transparent serialization' do + let(:cache) { Legion::Cache::Redis.dup } + let(:pool) { instance_double(ConnectionPool) } + let(:redis) { instance_double(Redis) } + + before do + cache.instance_variable_set(:@client, pool) + cache.instance_variable_set(:@connected, true) + allow(pool).to receive(:with).and_yield(redis) + end + + describe 'set_sync serializes complex types' do + it 'prefixes strings with S' do + expect(redis).to receive(:set).with('k', "S\x00hello", any_args).and_return('OK') + cache.set_sync('k', 'hello', ttl: 60) + end + + it 'prefixes hashes with J and JSON-encodes' do + expect(redis).to receive(:set).with('k', /\AJ\x00/, any_args).and_return('OK') + cache.set_sync('k', { foo: 'bar' }, ttl: 60) + end + + it 'prefixes arrays with J and JSON-encodes' do + expect(redis).to receive(:set).with('k', /\AJ\x00/, any_args).and_return('OK') + cache.set_sync('k', [1, 2, 3], ttl: 60) + end + end + + describe 'get deserializes based on prefix' do + it 'returns plain string for S prefix' do + allow(redis).to receive(:get).and_return("S\x00hello") + expect(cache.get('k')).to eq('hello') + end + + it 'returns parsed hash for J prefix' do + json = Legion::JSON.dump({ foo: 'bar' }) + allow(redis).to receive(:get).and_return("J\x00#{json}") + result = cache.get('k') + expect(result).to be_a(Hash) + expect(result[:foo] || result['foo']).to eq('bar') + end + + it 'returns raw string for legacy data without prefix' do + allow(redis).to receive(:get).and_return('legacy_value') + expect(cache.get('k')).to eq('legacy_value') + end + + it 'returns raw string when JSON parse fails' do + allow(redis).to receive(:get).and_return("J\x00not-valid-json{{{") + expect(cache.get('k')).to eq("J\x00not-valid-json{{{") + end + end + + describe 'mget deserializes values' do + it 'deserializes prefixed values from mget' do + allow(redis).to receive(:mget).with('k1', 'k2').and_return(["S\x00hello".b, "J\x00{\"a\":1}".b]) + result = cache.mget('k1', 'k2') + expect(result['k1']).to eq('hello') + expect(result['k2']).to be_a(Hash) + end + + it 'handles nil values in mget' do + allow(redis).to receive(:mget).with('k1').and_return([nil]) + result = cache.mget('k1') + expect(result['k1']).to be_nil + end + end + + describe 'mset_sync serializes values' do + it 'serializes each value through set_sync' do + allow(redis).to receive(:set).and_return('OK') + cache.mset_sync({ 'k1' => 'hello', 'k2' => { a: 1 } }, ttl: 60) + expect(redis).to have_received(:set).with('k1', /\AS\x00/, any_args) + expect(redis).to have_received(:set).with('k2', /\AJ\x00/, any_args) + end + end +end diff --git a/spec/legion/cache/redis_tls_spec.rb b/spec/legion/cache/redis_tls_spec.rb new file mode 100644 index 0000000..7350810 --- /dev/null +++ b/spec/legion/cache/redis_tls_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache/redis' + +RSpec.describe 'Legion::Cache::Redis TLS' do + let(:redis_mod) { Legion::Cache::Redis.dup } + + before do + stub_const('Legion::Crypt::TLS', Module.new) + end + + after { redis_mod.instance_variable_set(:@client, nil) } + + describe 'TLS options passed to Redis.new' do + before do + allow(Legion::Cache::Settings).to receive(:resolve_servers).and_return(['127.0.0.1:6379']) + end + + it 'passes ssl: true when TLS is enabled' do + allow(Legion::Crypt::TLS).to receive(:resolve).and_return( + { enabled: true, verify: :peer, ca: '/ca.crt', cert: nil, key: nil, auto_detected: false } + ) + expect(Redis).to receive(:new).with(hash_including(ssl: true)).and_return(double(connected?: true)) + allow(ConnectionPool).to receive(:new).and_yield + + redis_mod.client + end + + it 'passes ssl_params with verify mode when TLS is enabled' do + allow(Legion::Crypt::TLS).to receive(:resolve).and_return( + { enabled: true, verify: :peer, ca: '/ca.crt', cert: nil, key: nil, auto_detected: false } + ) + expect(Redis).to receive(:new) do |opts| + expect(opts[:ssl]).to be true + expect(opts[:ssl_params][:ca_file]).to eq '/ca.crt' + end.and_return(double(connected?: true)) + allow(ConnectionPool).to receive(:new).and_yield + + redis_mod.client + end + + it 'skips TLS when disabled' do + allow(Legion::Crypt::TLS).to receive(:resolve).and_return( + { enabled: false, verify: :peer, ca: nil, cert: nil, key: nil, auto_detected: false } + ) + expect(Redis).to receive(:new).with(hash_not_including(ssl: true)).and_return(double(connected?: true)) + allow(ConnectionPool).to receive(:new).and_yield + + redis_mod.client + end + + it 'skips TLS when Legion::Crypt::TLS is not defined' do + hide_const('Legion::Crypt::TLS') + expect(Redis).to receive(:new).with(hash_not_including(ssl: true)).and_return(double(connected?: true)) + allow(ConnectionPool).to receive(:new).and_yield + + redis_mod.client + end + end +end diff --git a/spec/legion/cache/set_nx_spec.rb b/spec/legion/cache/set_nx_spec.rb new file mode 100644 index 0000000..ee2a3f6 --- /dev/null +++ b/spec/legion/cache/set_nx_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache/redis' +require 'legion/cache/memcached' +require 'legion/cache/memory' + +RSpec.describe 'Legion::Cache set_nx' do + describe Legion::Cache::Memory do + before { described_class.reset! } + + describe '.set_nx' do + it 'returns true and stores value when key does not exist' do + result = described_class.set_nx('nx-key', 'value', ttl: 60) + expect(result).to be true + expect(described_class.get('nx-key')).to eq('value') + end + + it 'returns false and does not overwrite when key already exists' do + described_class.set('nx-key', 'original', ttl: 60) + result = described_class.set_nx('nx-key', 'overwrite', ttl: 60) + expect(result).to be false + expect(described_class.get('nx-key')).to eq('original') + end + + it 'returns true after an expired key has been purged' do + described_class.set('nx-expire', 'old', ttl: 0.05) + sleep 0.07 + result = described_class.set_nx('nx-expire', 'new', ttl: 60) + expect(result).to be true + expect(described_class.get('nx-expire')).to eq('new') + end + + it 'is atomic under concurrent access' do + winners = [] + mutex = Mutex.new + threads = 10.times.map do |i| + Thread.new do + won = described_class.set_nx('race-key', "value-#{i}", ttl: 60) + mutex.synchronize { winners << i } if won + end + end + threads.each(&:join) + expect(winners.size).to eq(1) + end + end + end + + describe Legion::Cache::Redis do + let(:cache) { described_class.dup } + let(:pool) { instance_double(ConnectionPool) } + let(:redis) { instance_double(Redis) } + + before do + cache.instance_variable_set(:@client, pool) + cache.instance_variable_set(:@connected, true) + allow(pool).to receive(:with).and_yield(redis) + end + + describe '#set_nx' do + it 'returns true when Redis SET NX succeeds (returns "OK")' do + allow(redis).to receive(:set).with('nx-key', anything, nx: true, ex: 60).and_return('OK') + expect(cache.set_nx('nx-key', 'value', ttl: 60)).to be true + end + + it 'returns false when Redis SET NX fails (key exists, returns nil)' do + allow(redis).to receive(:set).with('nx-key', anything, nx: true, ex: 60).and_return(nil) + expect(cache.set_nx('nx-key', 'value', ttl: 60)).to be false + end + + it 'passes nx: true and ex: ttl to Redis SET' do + expect(redis).to receive(:set).with('nx-key', anything, nx: true, ex: 120).and_return('OK') + cache.set_nx('nx-key', 'value', ttl: 120) + end + + it 'serializes the value before storing' do + captured = nil + allow(redis).to receive(:set) do |_key, val, **_opts| + captured = val + 'OK' + end + cache.set_nx('nx-key', { data: 42 }, ttl: 60) + expect(captured).to be_a(String) + end + end + end + + describe Legion::Cache::Memcached do + let(:cache) { described_class.dup } + let(:pool) { instance_double(ConnectionPool) } + let(:dalli) { instance_double(Dalli::Client) } + + before do + cache.instance_variable_set(:@client, pool) + cache.instance_variable_set(:@connected, true) + allow(pool).to receive(:with).and_yield(dalli) + end + + describe '#set_nx' do + it 'returns true when Dalli#add succeeds (key did not exist)' do + allow(dalli).to receive(:add).with('nx-key', 'value', 60).and_return(true) + expect(cache.set_nx('nx-key', 'value', ttl: 60)).to be true + end + + it 'returns false when Dalli#add fails (key already exists, returns nil/false)' do + allow(dalli).to receive(:add).with('nx-key', 'value', 60).and_return(nil) + expect(cache.set_nx('nx-key', 'value', ttl: 60)).to be false + end + + it 'passes the ttl positionally to Dalli#add' do + expect(dalli).to receive(:add).with('nx-key', 'value', 90).and_return(true) + cache.set_nx('nx-key', 'value', ttl: 90) + end + end + end +end diff --git a/spec/legion/cache/stats_spec.rb b/spec/legion/cache/stats_spec.rb new file mode 100644 index 0000000..b15ba90 --- /dev/null +++ b/spec/legion/cache/stats_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache' + +RSpec.describe 'stats' do + describe 'Legion::Cache.stats' do + before do + ENV['LEGION_MODE'] = 'lite' + Legion::Cache.setup + end + + after do + Legion::Cache.shutdown + ENV.delete('LEGION_MODE') + end + + it 'returns a hash with required keys' do + stats = Legion::Cache.stats + expect(stats).to be_a(Hash) + expect(stats).to include( + :driver, :servers, :enabled, :connected, + :using_local, :using_memory, + :pool_size, :pool_available, + :async_pool_size, :async_queue_depth, :async_processed, :async_failed, + :reconnect_attempts, :uptime + ) + end + + it 'returns a frozen hash' do + expect(Legion::Cache.stats).to be_frozen + end + + it 'reports correct driver' do + expect(Legion::Cache.stats[:driver]).to eq('memory') + end + end + + describe 'Legion::Cache::Local.stats' do + before { Legion::Cache::Local.reset! } + + it 'responds to stats' do + expect(Legion::Cache::Local).to respond_to(:stats) + end + + it 'returns a hash with required keys' do + stats = Legion::Cache::Local.stats + expect(stats).to include(:driver, :servers, :enabled, :connected) + end + end +end diff --git a/spec/legion/cache/thread_safety_spec.rb b/spec/legion/cache/thread_safety_spec.rb new file mode 100644 index 0000000..9c4d39d --- /dev/null +++ b/spec/legion/cache/thread_safety_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache' + +RSpec.describe 'thread-safe state flags' do + describe 'Legion::Cache' do + it 'uses AtomicBoolean for connected state' do + flag = Legion::Cache.instance_variable_get(:@connected) + expect(flag).to be_a(Concurrent::AtomicBoolean).or be_nil + end + + it 'uses AtomicBoolean for using_local state' do + flag = Legion::Cache.instance_variable_get(:@using_local) + expect(flag).to be_a(Concurrent::AtomicBoolean).or be_nil + end + + it 'uses AtomicBoolean for using_memory state' do + flag = Legion::Cache.instance_variable_get(:@using_memory) + expect(flag).to be_a(Concurrent::AtomicBoolean).or be_nil + end + end + + describe 'Legion::Cache::Local' do + it 'uses AtomicBoolean for connected state' do + flag = Legion::Cache::Local.instance_variable_get(:@connected) + expect(flag).to be_a(Concurrent::AtomicBoolean).or be_nil + end + end + + describe 'Legion::Cache::Memory' do + it 'uses AtomicBoolean for connected state' do + flag = Legion::Cache::Memory.instance_variable_get(:@connected) + expect(flag).to be_a(Concurrent::AtomicBoolean).or be_nil + end + end +end diff --git a/spec/legion/cache_fallback_spec.rb b/spec/legion/cache_fallback_spec.rb new file mode 100644 index 0000000..dfeff04 --- /dev/null +++ b/spec/legion/cache_fallback_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache' + +RSpec.describe 'Legion::Cache fallback' do + let(:local_store) { {} } + + before do + Legion::Cache.instance_variable_set(:@client, nil) + Legion::Cache.instance_variable_set(:@connected, Concurrent::AtomicBoolean.new(false)) + Legion::Cache.instance_variable_set(:@using_local, Concurrent::AtomicBoolean.new(false)) + Legion::Cache.instance_variable_set(:@using_memory, Concurrent::AtomicBoolean.new(false)) + Legion::Cache.instance_variable_set(:@active_shared_driver, nil) + + allow(Legion::Cache::Local).to receive(:connected?).and_return(true) + allow(Legion::Cache::Local).to receive(:setup).and_return(true) + allow(Legion::Cache::Local).to receive(:shutdown).and_return(false) + allow(Legion::Cache::Local).to receive(:close).and_return(false) + allow(Legion::Cache::Local).to receive(:get) { |key| local_store[key] } + allow(Legion::Cache::Local).to receive(:set) do |key, value, **_opts| + local_store[key] = value + true + end + allow(Legion::Cache::Local).to receive(:set_sync) do |key, value, **_opts| + local_store[key] = value + true + end + allow(Legion::Cache::Local).to receive(:delete) do |key, **| + !local_store.delete(key).nil? + end + allow(Legion::Cache::Local).to receive(:delete_sync) do |key| + !local_store.delete(key).nil? + end + allow(Legion::Cache::Local).to receive(:fetch) do |key, **_opts, &block| + next local_store[key] if local_store.key?(key) + + value = block.call + local_store[key] = value + value + end + allow(Legion::Cache::Local).to receive(:flush) do + local_store.clear + true + end + end + + describe '.local' do + it 'returns Legion::Cache::Local' do + expect(Legion::Cache.local).to eq Legion::Cache::Local + end + end + + describe '.using_local?' do + it 'responds to using_local?' do + expect(Legion::Cache).to respond_to(:using_local?) + end + end + + describe 'fallback on shared failure' do + before do + allow(Legion::Cache).to receive(:client).and_raise(RuntimeError, 'connection refused') + end + + it 'falls back to local when shared raises' do + Legion::Cache.setup + expect(Legion::Cache.connected?).to be(true) + expect(Legion::Cache.using_local?).to be(true) + end + + it 'delegates get/set/delete to local when in fallback mode' do + Legion::Cache.setup + expect(Legion::Cache.set('fallback_test', 'works', async: false)).to be(true) + expect(Legion::Cache.get('fallback_test')).to eq('works') + expect(Legion::Cache.delete('fallback_test', async: false)).to be(true) + end + + it 'delegates fetch blocks to local when in fallback mode' do + Legion::Cache.setup + fetch_block = proc { 'fetchval' } + + expect(Legion::Cache.fetch('fetch_test', ttl: 60, &fetch_block)).to eq('fetchval') + expect(Legion::Cache.fetch('fetch_test')).to eq('fetchval') + end + + it 'delegates flush to local when in fallback mode' do + Legion::Cache.setup + Legion::Cache.set('flush_test', 'bye', async: false) + + expect(Legion::Cache.flush).to be(true) + expect(Legion::Cache.get('flush_test')).to be_nil + end + end + + describe 'shutdown' do + it 'resets using_local? to false after shutdown' do + allow(Legion::Cache).to receive(:client).and_raise(RuntimeError, 'connection refused') + + Legion::Cache.setup + Legion::Cache.shutdown + expect(Legion::Cache.using_local?).to be(false) + end + end +end diff --git a/spec/legion/cache_interface_spec.rb b/spec/legion/cache_interface_spec.rb new file mode 100644 index 0000000..8b0e362 --- /dev/null +++ b/spec/legion/cache_interface_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache' + +RSpec.describe 'Legion::Cache interface' do + it 'has get method' do + expect(Legion::Cache.method(:get)).to be_a(Method) + end + + it 'has set method' do + expect(Legion::Cache.method(:set)).to be_a(Method) + end + + it 'has delete method' do + expect(Legion::Cache.method(:delete)).to be_a(Method) + end + + it 'has flush method' do + expect(Legion::Cache.method(:flush)).to be_a(Method) + end + + it 'responds to connected?' do + expect(Legion::Cache).to respond_to(:connected?) + end + + it 'responds to setup' do + expect(Legion::Cache).to respond_to(:setup) + end + + it 'responds to shutdown' do + expect(Legion::Cache).to respond_to(:shutdown) + end + + it 'responds to close' do + expect(Legion::Cache).to respond_to(:close) + end + + it 'responds to restart' do + expect(Legion::Cache).to respond_to(:restart) + end + + it 'responds to size' do + expect(Legion::Cache).to respond_to(:size) + end + + it 'responds to available' do + expect(Legion::Cache).to respond_to(:available) + end + + it 'has client method' do + expect(Legion::Cache.method(:client)).to be_a(Method) + end + + it 'responds to pool_size' do + expect(Legion::Cache).to respond_to(:pool_size) + end + + it 'responds to timeout' do + expect(Legion::Cache).to respond_to(:timeout) + end + + it 'has fetch method' do + expect(Legion::Cache.method(:fetch)).to be_a(Method) + end + + it 'set accepts keyword ttl and async' do + params = Legion::Cache.method(:set).parameters + names = params.map(&:last) + expect(names).to include(:ttl) + expect(names).to include(:async) + end + + it 'delete accepts keyword async' do + params = Legion::Cache.method(:delete).parameters + names = params.map(&:last) + expect(names).to include(:async) + end + + it 'flush takes no arguments' do + expect(Legion::Cache.method(:flush).arity).to eq(0) + end + + it 'responds to enabled?' do + expect(Legion::Cache).to respond_to(:enabled?) + end + + it 'has set_nx method' do + expect(Legion::Cache.method(:set_nx)).to be_a(Method) + end + + it 'set_nx accepts keyword ttl' do + params = Legion::Cache.method(:set_nx).parameters + names = params.map(&:last) + expect(names).to include(:ttl) + end +end diff --git a/spec/legion/cache_spec.rb b/spec/legion/cache_spec.rb index e4802c5..98ad385 100644 --- a/spec/legion/cache_spec.rb +++ b/spec/legion/cache_spec.rb @@ -1,72 +1,77 @@ # frozen_string_literal: true +require 'spec_helper' require 'legion/cache' RSpec.describe Legion::Cache do - it 'has a version number' do - expect(Legion::Cache::VERSION).not_to be nil + before do + ENV.delete('LEGION_MODE') + Legion::Settings[:cache][:driver] = 'dalli' + Legion::Settings[:cache][:servers] = ['127.0.0.1:11211'] + described_class.instance_variable_set(:@client, nil) + described_class.instance_variable_set(:@connected, Concurrent::AtomicBoolean.new(false)) + described_class.instance_variable_set(:@using_local, Concurrent::AtomicBoolean.new(false)) + described_class.instance_variable_set(:@using_memory, Concurrent::AtomicBoolean.new(false)) + described_class.instance_variable_set(:@active_shared_driver, nil) + Legion::Cache::Local.reset! + Legion::Cache::Memory.reset! end - it 'can setup' do - expect { Legion::Cache.client }.not_to raise_exception - expect(Legion::Cache.connected?).to eq true + after do + ENV.delete('LEGION_MODE') + Legion::Settings[:cache][:driver] = 'dalli' + Legion::Settings[:cache][:servers] = ['127.0.0.1:11211'] + described_class.instance_variable_set(:@client, nil) + described_class.instance_variable_set(:@connected, Concurrent::AtomicBoolean.new(false)) + described_class.instance_variable_set(:@using_local, Concurrent::AtomicBoolean.new(false)) + described_class.instance_variable_set(:@using_memory, Concurrent::AtomicBoolean.new(false)) + described_class.instance_variable_set(:@active_shared_driver, nil) end - it 'can set' do - expect(Legion::Cache).to respond_to :set - expect(Legion::Cache.set('test', 'foobar')).to eq true - expect(Legion::Cache.set('test_ttl', 'ttl_value', 10)).to eq true + it 'has a version number' do + expect(Legion::Cache::VERSION).not_to be_nil end - it 'can get' do - expect(Legion::Cache).to respond_to :get - expect(Legion::Cache.get('test')).to eq 'foobar' - expect(Legion::Cache.get('nil')).to eq nil - end + describe '.setup' do + it 'selects the shared adapter from settings at setup time' do + Legion::Settings[:cache][:driver] = 'redis' + Legion::Settings[:cache][:servers] = ['127.0.0.1:6379'] + allow(Legion::Cache::Local).to receive(:connected?).and_return(true) + allow(Legion::Cache::Local).to receive(:setup) - it 'can delete' do - expect(Legion::Cache).to respond_to :delete - expect(Legion::Cache.delete('test')).to eq true - expect(Legion::Cache.delete('test_nil')).to eq false + expect { described_class.setup }.not_to raise_error + expect(described_class.driver_name).to eq('redis') + expect(described_class.connected?).to be(true) + end end - it 'can flush' do - expect(Legion::Cache).to respond_to :flush - expect(Legion::Cache.flush).to eq true - end + describe '.fetch' do + it 'forwards blocks to the memory adapter' do + described_class.instance_variable_set(:@using_memory, Concurrent::AtomicBoolean.new(true)) + described_class.instance_variable_set(:@connected, Concurrent::AtomicBoolean.new(true)) + fetch_block = proc { 'computed' } - it 'can get size and available counts' do - expect(Legion::Cache.size).to eq 10 - expect(Legion::Cache.available).to eq 10 - end + expect(Legion::Cache::Memory).to receive(:fetch) do |key, ttl: nil, &block| + expect(key).to eq('cache.key') + expect(ttl).to eq(60) + block.call + end.and_return('computed') - it 'can shutdown' do - Legion::Cache.client - expect { Legion::Cache.shutdown }.not_to raise_exception - expect(Legion::Cache.connected?).to eq false - end + expect(described_class.fetch('cache.key', ttl: 60, &fetch_block)).to eq('computed') + end - it 'can restart with new values' do - Legion::Cache.client - expect(Legion::Cache.connected?).to eq true - expect(Legion::Cache.available).to eq 10 - expect(Legion::Cache.timeout).to eq 5 - expect { Legion::Cache.restart(pool_size: 2, timeout: 2) }.not_to raise_exception - expect(Legion::Cache.available).to eq 2 - expect(Legion::Cache.timeout).to eq 2 - expect(Legion::Cache.connected?).to eq true - expect(Legion::Cache.set('test_ttl_restart', 'ttl_value_restart', 10)).to eq true - expect(Legion::Cache.get('test_ttl_restart')).to eq 'ttl_value_restart' - end + it 'forwards blocks to the local adapter' do + described_class.instance_variable_set(:@using_local, Concurrent::AtomicBoolean.new(true)) + described_class.instance_variable_set(:@connected, Concurrent::AtomicBoolean.new(true)) + fetch_block = proc { 'local-computed' } + + expect(Legion::Cache::Local).to receive(:fetch) do |key, ttl: nil, &block| + expect(key).to eq('cache.key') + expect(ttl).to eq(90) + block.call + end.and_return('local-computed') - it 'can setup' do - expect { Legion::Cache.setup }.not_to raise_exception - expect(Legion::Cache.connected?).to eq true - expect { Legion::Cache.setup }.not_to raise_exception - expect(Legion::Cache.connected?).to eq true - expect { Legion::Cache.close }.not_to raise_exception - expect(Legion::Cache.connected?).to eq false - expect { Legion::Cache.setup }.not_to raise_exception - expect(Legion::Cache.connected?).to eq true + expect(described_class.fetch('cache.key', ttl: 90, &fetch_block)).to eq('local-computed') + end end end diff --git a/spec/legion/cacheable_spec.rb b/spec/legion/cacheable_spec.rb new file mode 100644 index 0000000..b58bdd3 --- /dev/null +++ b/spec/legion/cacheable_spec.rb @@ -0,0 +1,296 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache/cacheable' +require 'legion/cache/local' + +RSpec.describe Legion::Cache::Cacheable do + before { described_class.memory_clear! } + + describe '.memory_write and .memory_read' do + it 'stores and retrieves a value' do + described_class.memory_write('test.key', { status: 'ok' }, 60) + expect(described_class.memory_read('test.key')).to eq({ status: 'ok' }) + end + + it 'returns nil for missing keys' do + expect(described_class.memory_read('missing')).to be_nil + end + + it 'returns nil for expired entries' do + described_class.memory_write('expired', 'old', 0) + sleep 0.01 + expect(described_class.memory_read('expired')).to be_nil + end + + it 'overwrites existing entries' do + described_class.memory_write('key', 'first', 60) + described_class.memory_write('key', 'second', 60) + expect(described_class.memory_read('key')).to eq('second') + end + end + + describe '.memory_clear!' do + it 'removes all entries' do + described_class.memory_write('a', 1, 60) + described_class.memory_write('b', 2, 60) + described_class.memory_clear! + expect(described_class.memory_read('a')).to be_nil + expect(described_class.memory_read('b')).to be_nil + end + end +end + +RSpec.describe Legion::Cache::Cacheable, '.build_cache_key' do + it 'produces a key with module path, method name, and args hash' do + key = described_class.build_cache_key('MyModule', :my_method, exclude: [], user_id: 'me') + expect(key).to match(/\AMyModule\.my_method\.[a-f0-9]{32}\z/) + end + + it 'excludes filtered args from the hash' do + key_with = described_class.build_cache_key('M', :m, exclude: [:token], user_id: 'me', token: 'secret') + key_without = described_class.build_cache_key('M', :m, exclude: [:token], user_id: 'me') + expect(key_with).to eq(key_without) + end + + it 'produces different keys for different args' do + key_a = described_class.build_cache_key('M', :m, exclude: [], user_id: 'alice') + key_b = described_class.build_cache_key('M', :m, exclude: [], user_id: 'bob') + expect(key_a).not_to eq(key_b) + end + + it 'produces a deterministic key for the same args regardless of order' do + key_a = described_class.build_cache_key('M', :m, exclude: [], b: 2, a: 1) + key_b = described_class.build_cache_key('M', :m, exclude: [], a: 1, b: 2) + expect(key_a).to eq(key_b) + end + + it 'handles empty kwargs' do + key = described_class.build_cache_key('M', :m, exclude: []) + expect(key).to match(/\AM\.m\.[a-f0-9]{32}\z/) + end +end + +RSpec.describe Legion::Cache::Cacheable, 'cache_read and cache_write' do + before { described_class.memory_clear! } + + describe 'local scope' do + context 'when Legion::Cache::Local is not available' do + before do + allow(described_class).to receive(:local_cache_available?).and_return(false) + end + + it 'falls back to memory store' do + described_class.cache_write('local.key', 'value', ttl: 30, scope: :local) + expect(described_class.cache_read('local.key', scope: :local)).to eq('value') + end + end + + context 'when Legion::Cache::Local is available' do + before do + allow(described_class).to receive(:local_cache_available?).and_return(true) + allow(Legion::Cache::Local).to receive(:get).with('local.hit').and_return('cached') + allow(Legion::Cache::Local).to receive(:get).with('local.miss').and_return(nil) + allow(Legion::Cache::Local).to receive(:set) + end + + it 'reads from Local cache' do + expect(described_class.cache_read('local.hit', scope: :local)).to eq('cached') + end + + it 'falls through to memory on Local miss' do + described_class.memory_write('local.miss', 'fallback', 60) + expect(described_class.cache_read('local.miss', scope: :local)).to eq('fallback') + end + + it 'preserves cached false values from Local' do + allow(Legion::Cache::Local).to receive(:get).with('local.false').and_return(false) + described_class.memory_write('local.false', 'fallback', 60) + + expect(described_class.cache_read('local.false', scope: :local)).to be(false) + end + + it 'falls back to memory when Local reads raise' do + allow(Legion::Cache::Local).to receive(:get).with('local.error').and_raise(StandardError, 'boom') + described_class.memory_write('local.error', 'fallback', 60) + + expect(described_class.cache_read('local.error', scope: :local)).to eq('fallback') + end + + it 'writes to Local cache' do + described_class.cache_write('local.w', 'data', ttl: 60, scope: :local) + expect(Legion::Cache::Local).to have_received(:set).with('local.w', 'data', ttl: 60, async: false) + end + + it 'falls back to memory when Local writes raise' do + allow(Legion::Cache::Local).to receive(:set).with('local.error', 'data', ttl: 60, async: false).and_raise(StandardError, 'boom') + + described_class.cache_write('local.error', 'data', ttl: 60, scope: :local) + expect(described_class.memory_read('local.error')).to eq('data') + end + end + end + + describe 'global scope' do + context 'when global cache is not available' do + before do + allow(described_class).to receive(:global_cache_available?).and_return(false) + end + + it 'falls back to memory store' do + described_class.cache_write('global.key', 'value', ttl: 30, scope: :global) + expect(described_class.cache_read('global.key', scope: :global)).to eq('value') + end + end + + context 'when global cache is available' do + before do + allow(described_class).to receive(:global_cache_available?).and_return(true) + allow(Legion::Cache).to receive(:get).with('global.hit').and_return('remote') + allow(Legion::Cache).to receive(:set) + end + + it 'reads from global cache' do + expect(described_class.cache_read('global.hit', scope: :global)).to eq('remote') + end + + it 'writes to global cache' do + described_class.cache_write('global.w', 'data', ttl: 120, scope: :global) + expect(Legion::Cache).to have_received(:set).with('global.w', 'data', ttl: 120, async: false) + end + end + end +end + +RSpec.describe Legion::Cache::Cacheable, 'cache_method DSL' do + before { Legion::Cache::Cacheable.memory_clear! } + + let(:test_module) do + Module.new do + def self.name + 'TestRunner' + end + + extend Legion::Cache::Cacheable + + def fetch_data(user_id: 'me', **) + { user_id: user_id, fetched_at: Time.now.utc.to_f } + end + + cache_method :fetch_data, ttl: 60 + end + end + + let(:instance) { Object.new.extend(test_module) } + + describe 'caching behavior' do + it 'returns cached result on second call' do + first = instance.fetch_data(user_id: 'alice') + second = instance.fetch_data(user_id: 'alice') + expect(second[:fetched_at]).to eq(first[:fetched_at]) + end + + it 'caches separately for different args' do + alice = instance.fetch_data(user_id: 'alice') + bob = instance.fetch_data(user_id: 'bob') + expect(alice[:user_id]).to eq('alice') + expect(bob[:user_id]).to eq('bob') + expect(alice[:fetched_at]).not_to eq(bob[:fetched_at]) + end + + it 'does not cache across different method calls' do + mod = Module.new do + def self.name + 'MultiMethod' + end + + extend Legion::Cache::Cacheable + + def method_a(**) + { method: :a, t: Time.now.utc.to_f } + end + + def method_b(**) + { method: :b, t: Time.now.utc.to_f } + end + + cache_method :method_a, ttl: 60 + cache_method :method_b, ttl: 60 + end + obj = Object.new.extend(mod) + a = obj.method_a + b = obj.method_b + expect(a[:method]).to eq(:a) + expect(b[:method]).to eq(:b) + end + + it 'falls back to memory when the local backend is connected but failing' do + allow(Legion::Cache::Cacheable).to receive(:local_cache_available?).and_return(true) + allow(Legion::Cache::Local).to receive(:get).and_raise(StandardError, 'read failed') + allow(Legion::Cache::Local).to receive(:set).and_raise(StandardError, 'write failed') + + first = instance.fetch_data(user_id: 'alice') + second = instance.fetch_data(user_id: 'alice') + + expect(second[:fetched_at]).to eq(first[:fetched_at]) + end + end + + describe 'bypass_local_method_cache' do + it 'skips cache read and refreshes on bypass' do + first = instance.fetch_data(user_id: 'me') + bypassed = instance.fetch_data(user_id: 'me', bypass_local_method_cache: true) + expect(bypassed[:fetched_at]).not_to eq(first[:fetched_at]) + end + + it 'writes result back to cache after bypass' do + instance.fetch_data(user_id: 'me') + bypassed = instance.fetch_data(user_id: 'me', bypass_local_method_cache: true) + cached = instance.fetch_data(user_id: 'me') + expect(cached[:fetched_at]).to eq(bypassed[:fetched_at]) + end + end + + describe 'exclude_from_key' do + let(:token_module) do + Module.new do + def self.name + 'TokenRunner' + end + + extend Legion::Cache::Cacheable + + def get_thing(id:, token: nil, **) # rubocop:disable Lint/UnusedMethodArgument + { id: id, t: Time.now.utc.to_f } + end + + cache_method :get_thing, ttl: 60, exclude_from_key: [:token] + end + end + + let(:token_instance) { Object.new.extend(token_module) } + + it 'ignores excluded args when building cache key' do + first = token_instance.get_thing(id: 1, token: 'abc') + second = token_instance.get_thing(id: 1, token: 'xyz') + expect(second[:t]).to eq(first[:t]) + end + end + + describe 'cached_methods registry' do + it 'tracks declared cached methods' do + expect(test_module.cached_methods).to have_key(:fetch_data) + expect(test_module.cached_methods[:fetch_data][:ttl]).to eq(60) + end + end +end + +RSpec.describe 'Cacheable autoload' do + it 'is accessible after requiring legion/cache' do + require 'legion/cache' + expect(Legion::Cache::Cacheable).to be_a(Module) + expect(Legion::Cache::Cacheable).to respond_to(:cache_read) + expect(Legion::Cache::Cacheable).to respond_to(:build_cache_key) + expect(Legion::Cache::Cacheable).to respond_to(:memory_clear!) + end +end diff --git a/spec/legion/local_spec.rb b/spec/legion/local_spec.rb new file mode 100644 index 0000000..5acfdcc --- /dev/null +++ b/spec/legion/local_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache' +require 'legion/cache/local' + +RSpec.describe Legion::Cache::Local do + describe 'module interface' do + it 'responds to setup' do + expect(described_class).to respond_to(:setup) + end + + it 'responds to shutdown' do + expect(described_class).to respond_to(:shutdown) + end + + it 'responds to connected?' do + expect(described_class).to respond_to(:connected?) + end + + it 'responds to get' do + expect(described_class).to respond_to(:get) + end + + it 'responds to set' do + expect(described_class).to respond_to(:set) + end + + it 'responds to delete' do + expect(described_class).to respond_to(:delete) + end + + it 'responds to flush' do + expect(described_class).to respond_to(:flush) + end + + it 'responds to fetch' do + expect(described_class).to respond_to(:fetch) + end + + it 'responds to client' do + expect(described_class).to respond_to(:client) + end + + it 'responds to reset!' do + expect(described_class).to respond_to(:reset!) + end + + it 'responds to close' do + expect(described_class).to respond_to(:close) + end + + it 'responds to restart' do + expect(described_class).to respond_to(:restart) + end + + it 'responds to size' do + expect(described_class).to respond_to(:size) + end + + it 'responds to available' do + expect(described_class).to respond_to(:available) + end + + it 'responds to pool_size' do + expect(described_class).to respond_to(:pool_size) + end + + it 'responds to timeout' do + expect(described_class).to respond_to(:timeout) + end + end + + describe 'method signatures' do + it 'responds to enabled?' do + expect(described_class).to respond_to(:enabled?) + end + + it 'set accepts keyword ttl' do + driver = double('driver') + allow(driver).to receive(:set_sync) + described_class.instance_variable_set(:@driver, driver) + described_class.instance_variable_set(:@connected, Concurrent::AtomicBoolean.new(true)) + expect { described_class.set('k', 'v', ttl: 120) }.not_to raise_error + end + + it 'flush takes no arguments' do + expect(described_class.method(:flush).arity).to eq(0) + end + end + + describe 'not connected' do + before { described_class.reset! } + + it 'reports not connected' do + expect(described_class.connected?).to eq false + end + end +end + +RSpec.describe 'Legion::Cache::Local integration', :integration do + before(:all) do + Legion::Cache::Local.reset! + Legion::Cache::Local.setup + end + + after(:all) do + Legion::Cache::Local.shutdown + end + + it 'can setup and connect' do + expect(Legion::Cache::Local.connected?).to eq true + end + + it 'can set and get' do + expect(Legion::Cache::Local.set('local_test', 'hello')).to eq true + expect(Legion::Cache::Local.get('local_test')).to eq 'hello' + end + + it 'can set with TTL' do + expect(Legion::Cache::Local.set('local_ttl', 'expires', 10)).to eq true + expect(Legion::Cache::Local.get('local_ttl')).to eq 'expires' + end + + it 'can delete' do + Legion::Cache::Local.set('local_del', 'gone') + expect(Legion::Cache::Local.delete('local_del')).to eq true + expect(Legion::Cache::Local.get('local_del')).to be_nil + end + + it 'can flush' do + Legion::Cache::Local.set('local_flush', 'bye') + expect(Legion::Cache::Local.flush).to eq true + end + + it 'can report size and available' do + expect(Legion::Cache::Local.size).to eq 5 + expect(Legion::Cache::Local.available).to be_a(Integer) + end + + it 'can report pool_size and timeout' do + expect(Legion::Cache::Local.pool_size).to eq 5 + expect(Legion::Cache::Local.timeout).to eq 3 + end + + it 'can shutdown and reconnect' do + Legion::Cache::Local.shutdown + expect(Legion::Cache::Local.connected?).to eq false + Legion::Cache::Local.setup + expect(Legion::Cache::Local.connected?).to eq true + end + + it 'can restart with new values' do + Legion::Cache::Local.restart(pool_size: 2, timeout: 1) + expect(Legion::Cache::Local.connected?).to eq true + expect(Legion::Cache::Local.set('restart_test', 'works')).to eq true + expect(Legion::Cache::Local.get('restart_test')).to eq 'works' + end + + it 'uses separate namespace from shared cache' do + Legion::Cache::Local.set('ns_test', 'local_value') + Legion::Cache.setup + Legion::Cache.set('ns_test', 'shared_value') + expect(Legion::Cache::Local.get('ns_test')).to eq 'local_value' + expect(Legion::Cache.get('ns_test')).to eq 'shared_value' + Legion::Cache.shutdown + end +end diff --git a/spec/legion/memcached_spec.rb b/spec/legion/memcached_spec.rb index 78bd9a8..41fd8d1 100644 --- a/spec/legion/memcached_spec.rb +++ b/spec/legion/memcached_spec.rb @@ -1,7 +1,34 @@ +# frozen_string_literal: true + require 'spec_helper' require 'legion/cache/memcached' RSpec.describe Legion::Cache::Memcached do + describe 'method signatures' do + it 'set accepts keyword ttl' do + cache = described_class.dup + pool = instance_double(ConnectionPool) + cache.instance_variable_set(:@client, pool) + cache.instance_variable_set(:@connected, true) + + dalli = instance_double(Dalli::Client) + allow(pool).to receive(:with).and_yield(dalli) + allow(dalli).to receive(:set).and_return(1) + + expect { cache.set('k', 'v', ttl: 120) }.not_to raise_error + end + + it 'flush takes no arguments' do + expect(described_class.method(:flush).arity).to eq(0) + end + + it 'uses log instead of cache_logger' do + expect(described_class.private_method_defined?(:cache_logger)).to be(false) + end + end +end + +RSpec.describe Legion::Cache::Memcached, :integration do before(:all) do @cache = Legion::Cache::Memcached end @@ -53,6 +80,14 @@ expect(@cache.flush).to eq true end + it 'accepts singular server parameter' do + @cache.close if @cache.connected? + @cache.instance_variable_set(:@client, nil) + @cache.instance_variable_set(:@connected, false) + expect { @cache.client(server: '127.0.0.1') }.not_to raise_error + expect(@cache.connected?).to eq true + end + it 'wont use bogus methods' do expect(@cache).not_to respond_to :this_is_fake end diff --git a/spec/legion/pool_spec.rb b/spec/legion/pool_spec.rb index b015b64..db78cac 100644 --- a/spec/legion/pool_spec.rb +++ b/spec/legion/pool_spec.rb @@ -1,8 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache/pool' + RSpec.describe Legion::Cache::Pool do - it { should be_a Module } - it { should respond_to? :connected? } - it { should respond_to? :size } - it { should respond_to? :available } - it { should respond_to? :close } - it { should respond_to? :restart } + it 'is a Module' do + expect(described_class).to be_a(Module) + end + + context 'when included in a test class' do + let(:test_class) do + Class.new do + include Legion::Cache::Pool + end + end + let(:instance) { test_class.new } + + it '#connected? returns false initially' do + expect(instance.connected?).to eq(false) + end + + it '#timeout returns an integer' do + expect(instance.timeout).to be_a(Integer) + end + + it '#pool_size returns an integer' do + expect(instance.pool_size).to be_a(Integer) + end + end + + it 'defines expected instance methods' do + expect(described_class.instance_methods).to include(:connected?, :size, :timeout, :pool_size, :available, :close, :restart) + end end diff --git a/spec/legion/redis_spec.rb b/spec/legion/redis_spec.rb index 235bab3..c062e86 100644 --- a/spec/legion/redis_spec.rb +++ b/spec/legion/redis_spec.rb @@ -1,6 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' require 'legion/cache/redis' RSpec.describe Legion::Cache::Redis do + describe 'method signatures' do + it 'set accepts keyword ttl' do + cache = described_class.dup + pool = instance_double(ConnectionPool) + cache.instance_variable_set(:@client, pool) + cache.instance_variable_set(:@connected, true) + + redis = instance_double(Redis) + allow(pool).to receive(:with).and_yield(redis) + allow(redis).to receive(:set).and_return('OK') + + expect { cache.set('k', 'v', ttl: 120) }.not_to raise_error + end + + it 'fetch accepts keyword ttl' do + cache = described_class.dup + pool = instance_double(ConnectionPool) + cache.instance_variable_set(:@client, pool) + cache.instance_variable_set(:@connected, true) + + redis = instance_double(Redis) + allow(pool).to receive(:with).and_yield(redis) + allow(redis).to receive(:get).and_return('val') + + expect { cache.fetch('k', ttl: 60) }.not_to raise_error + end + + it 'flush takes no arguments' do + expect(described_class.method(:flush).arity).to eq(0) + end + + it 'uses log instead of cache_logger' do + expect(described_class.private_method_defined?(:cache_logger)).to be(false) + end + + it 'uses settings pool_size instead of hardcoded 20' do + cache = described_class.dup + cache.instance_variable_set(:@client, nil) + cache.instance_variable_set(:@connected, false) + + redis_instance = instance_double(Redis) + allow(Redis).to receive(:new).and_return(redis_instance) + + original = Legion::Settings[:cache][:pool_size] + Legion::Settings[:cache][:pool_size] = 8 + cache.client(servers: ['127.0.0.1:6379']) + expect(cache.pool_size).to eq(8) + Legion::Settings[:cache][:pool_size] = original + end + end +end + +RSpec.describe Legion::Cache::Redis, :integration do before(:all) do @cache = Legion::Cache::Redis end @@ -46,7 +102,82 @@ expect(@cache.flush).to eq true end + it 'accepts servers parameter' do + @cache.close if @cache.connected? + @cache.instance_variable_set(:@client, nil) + @cache.instance_variable_set(:@connected, false) + expect { @cache.client(servers: ['127.0.0.1:6379']) }.not_to raise_error + expect(@cache.connected?).to eq true + end + it 'wont use bogus methods' do expect(@cache).not_to respond_to :this_is_fake end + + describe '#build_redis_client' do + before do + @cache.instance_variable_set(:@client, nil) + @cache.instance_variable_set(:@connected, false) + end + + after do + @cache.instance_variable_set(:@client, nil) + @cache.instance_variable_set(:@connected, false) + end + + it 'returns a single-node Redis client when no cluster is given' do + redis_instance = instance_double(Redis) + allow(Redis).to receive(:new).with(hash_including(host: '127.0.0.1', port: 6379)).and_return(redis_instance) + result = @cache.build_redis_client + expect(result).to eq redis_instance + end + + it 'returns a cluster Redis client when cluster nodes are provided' do + nodes = ['redis://node1:6379', 'redis://node2:6379', 'redis://node3:6379'] + redis_instance = instance_double(Redis) + allow(Redis).to receive(:new).with(hash_including(cluster: nodes)).and_return(redis_instance) + result = @cache.build_redis_client(cluster: nodes) + expect(result).to eq redis_instance + end + + it 'falls back to single-node when cluster is an empty array' do + redis_instance = instance_double(Redis) + allow(Redis).to receive(:new).with(hash_including(host: '127.0.0.1', port: 6379)).and_return(redis_instance) + result = @cache.build_redis_client(cluster: []) + expect(result).to eq redis_instance + end + + it 'falls back to single-node when cluster is nil' do + redis_instance = instance_double(Redis) + allow(Redis).to receive(:new).with(hash_including(host: '127.0.0.1', port: 6379)).and_return(redis_instance) + result = @cache.build_redis_client(cluster: nil) + expect(result).to eq redis_instance + end + + it 'passes cluster nodes verbatim to Redis.new' do + nodes = ['redis://10.0.0.1:6379', 'redis://10.0.0.2:6380'] + expect(Redis).to receive(:new).with(hash_including(cluster: nodes)).and_return(instance_double(Redis)) + @cache.build_redis_client(cluster: nodes) + end + end + + describe '#client with cluster:' do + before do + @cache.instance_variable_set(:@client, nil) + @cache.instance_variable_set(:@connected, false) + end + + after do + @cache.instance_variable_set(:@client, nil) + @cache.instance_variable_set(:@connected, false) + end + + it 'creates a ConnectionPool using cluster nodes when cluster: is passed' do + nodes = ['redis://node1:6379', 'redis://node2:6379'] + redis_instance = instance_double(Redis) + allow(Redis).to receive(:new).with(hash_including(cluster: nodes)).and_return(redis_instance) + expect { @cache.client(cluster: nodes) }.not_to raise_error + expect(@cache.connected?).to eq true + end + end end diff --git a/spec/legion/settings_spec.rb b/spec/legion/settings_spec.rb index a70855e..e8bbaf6 100644 --- a/spec/legion/settings_spec.rb +++ b/spec/legion/settings_spec.rb @@ -1,38 +1,267 @@ +# frozen_string_literal: true + require 'spec_helper' require 'legion/cache/settings' RSpec.describe Legion::Cache::Settings do - subject(:default) { Legion::Cache::Settings.default } - it { should respond_to :default } - context 'default attributes' do - before { default.default } - it { should be_a Hash } - it { should include(enabled: true) } - it { should include(servers: ['127.0.0.1:11211']) } - it { should include(connected: false) } - it { should include(namespace: 'legion') } + describe '.default' do + subject(:defaults) { described_class.default } + + it 'returns a hash' do + expect(defaults).to be_a(Hash) + end + + it 'has a driver' do + expect(defaults[:driver]).to be_a(String) + expect(%w[dalli redis]).to include(defaults[:driver]) + end + + it 'defaults servers to empty array (resolved at connection time)' do + expect(defaults[:servers]).to eq([]) + end + + it 'has connected set to false' do + expect(defaults[:connected]).to eq(false) + end + + it 'has enabled set to true' do + expect(defaults[:enabled]).to eq(true) + end + + it 'has namespace of legion' do + expect(defaults[:namespace]).to eq('legion') + end + + it 'has compress set to false' do + expect(defaults[:compress]).to eq(false) + end + + it 'has pool_size of 10' do + expect(defaults[:pool_size]).to eq(10) + end + + it 'has timeout of 5' do + expect(defaults[:timeout]).to eq(5) + end + + it 'has expires_in of 0' do + expect(defaults[:expires_in]).to eq(0) + end + + it 'has cache_nils set to false' do + expect(defaults[:cache_nils]).to eq(false) + end + + it 'has failover set to true' do + expect(defaults[:failover]).to eq(true) + end + + it 'has threadsafe set to true' do + expect(defaults[:threadsafe]).to eq(true) + end + + it 'has serializer set to Legion::JSON' do + expect(defaults[:serializer]).to eq(Legion::JSON) + end + end + + describe 'default TTL values' do + it 'has global default_ttl of 3600' do + expect(Legion::Cache::Settings.default[:default_ttl]).to eq(3600) + end + + it 'has local default_ttl of 21600' do + expect(Legion::Cache::Settings.local[:default_ttl]).to eq(21_600) + end + end + + describe 'pool_checkout_timeout' do + it 'has pool_checkout_timeout in global defaults' do + expect(Legion::Cache::Settings.default[:pool_checkout_timeout]).to eq(5) + end + + it 'has pool_checkout_timeout in local defaults' do + expect(Legion::Cache::Settings.local[:pool_checkout_timeout]).to eq(5) + end end - context 'should have a driver' do - subject(:driver) { Legion::Cache::Settings.driver } - it { should be_a String } + describe 'async settings' do + it 'includes async defaults' do + expect(Legion::Cache::Settings.default[:async]).to include( + pool_size: 4, + queue_size: 1000, + shutdown_timeout: 5 + ) + end end - context 'should be able to override driver' do - subject(:driver) { Legion::Cache::Settings.driver('redis') } - it { should be_a String } - it { should eq 'redis' } + describe 'reconnect settings' do + it 'includes reconnect defaults' do + expect(Legion::Cache::Settings.default[:reconnect]).to include( + initial_delay: 1, + max_delay: 60, + enabled: true + ) + end end - context 'should be able to override driver' do - subject(:driver) { Legion::Cache::Settings.driver('dalli') } - it { should be_a String } - it { should eq 'dalli' } + describe '.local' do + subject(:locals) { described_class.local } + + it 'returns a Hash' do + expect(locals).to be_a(Hash) + end + + it 'defaults enabled to true' do + expect(locals[:enabled]).to eq(true) + end + + it 'defaults servers to empty array (resolved at connection time)' do + expect(locals[:servers]).to eq([]) + end + + it 'defaults namespace to legion_local' do + expect(locals[:namespace]).to eq('legion_local') + end + + it 'defaults pool_size to 5' do + expect(locals[:pool_size]).to eq(5) + end + + it 'defaults timeout to 3' do + expect(locals[:timeout]).to eq(3) + end + + it 'auto-detects driver independently' do + expect(locals[:driver]).to be_a(String) + end end - context 'should be able to default to dalli' do - subject(:driver) { Legion::Cache::Settings.driver('foobar') } - it { should be_a String } - it { should eq 'dalli' } + describe '.normalize_driver' do + it 'maps redis to redis' do + expect(described_class.normalize_driver('redis')).to eq('redis') + expect(described_class.normalize_driver(:redis)).to eq('redis') + end + + it 'maps memcached to dalli' do + expect(described_class.normalize_driver('memcached')).to eq('dalli') + expect(described_class.normalize_driver(:memcached)).to eq('dalli') + end + + it 'maps dalli to dalli for backwards compatibility' do + expect(described_class.normalize_driver('dalli')).to eq('dalli') + expect(described_class.normalize_driver(:dalli)).to eq('dalli') + end + + it 'passes through unknown drivers as strings' do + expect(described_class.normalize_driver('custom')).to eq('custom') + end + end + + describe '.resolve_servers' do + it 'returns default localhost with memcached port when no servers given' do + result = described_class.resolve_servers(driver: 'memcached') + expect(result).to eq(['127.0.0.1:11211']) + end + + it 'returns default localhost with redis port when no servers given' do + result = described_class.resolve_servers(driver: 'redis') + expect(result).to eq(['127.0.0.1:6379']) + end + + it 'accepts a singular server string' do + result = described_class.resolve_servers(driver: 'memcached', server: '10.0.0.5') + expect(result).to eq(['10.0.0.5:11211']) + end + + it 'accepts a servers array' do + result = described_class.resolve_servers(driver: 'redis', servers: ['10.0.0.5', '10.0.0.6']) + expect(result).to eq(['10.0.0.5:6379', '10.0.0.6:6379']) + end + + it 'merges singular and plural together' do + result = described_class.resolve_servers( + driver: 'memcached', server: '10.0.0.5', servers: ['10.0.0.6'] + ) + expect(result).to contain_exactly('10.0.0.6:11211', '10.0.0.5:11211') + end + + it 'preserves explicit ports' do + result = described_class.resolve_servers(driver: 'memcached', servers: ['10.0.0.5:9999']) + expect(result).to eq(['10.0.0.5:9999']) + end + + it 'injects default port only where missing' do + result = described_class.resolve_servers( + driver: 'redis', servers: ['10.0.0.5:9999', '10.0.0.6'] + ) + expect(result).to eq(['10.0.0.5:9999', '10.0.0.6:6379']) + end + + it 'deduplicates entries' do + result = described_class.resolve_servers( + driver: 'memcached', server: '10.0.0.5', servers: ['10.0.0.5'] + ) + expect(result).to eq(['10.0.0.5:11211']) + end + + it 'allows port override' do + result = described_class.resolve_servers(driver: 'memcached', servers: ['10.0.0.5'], port: 22_122) + expect(result).to eq(['10.0.0.5:22122']) + end + + it 'handles dalli as memcached' do + result = described_class.resolve_servers(driver: 'dalli') + expect(result).to eq(['127.0.0.1:11211']) + end + + it 'adds the default port to raw IPv6 hosts' do + result = described_class.resolve_servers(driver: 'redis', servers: ['::1']) + expect(result).to eq(['[::1]:6379']) + end + + it 'adds the default port to bracketed IPv6 hosts without one' do + result = described_class.resolve_servers(driver: 'redis', servers: ['[::1]']) + expect(result).to eq(['[::1]:6379']) + end + + it 'preserves explicit ports for bracketed IPv6 hosts' do + result = described_class.resolve_servers(driver: 'redis', servers: ['[::1]:6380']) + expect(result).to eq(['[::1]:6380']) + end + end + + describe '.register_defaults!' do + it 'merges both shared and local defaults when Legion::Settings can merge settings' do + allow(Legion::Settings).to receive(:merge_settings) + + described_class.register_defaults! + + expect(Legion::Settings).to have_received(:merge_settings).with(:cache, hash_including(namespace: 'legion')) + expect(Legion::Settings).to have_received(:merge_settings).with(:cache_local, hash_including(namespace: 'legion_local')) + end + end + + describe '.driver' do + it 'returns a string' do + expect(described_class.driver).to be_a(String) + end + + it 'defaults to dalli when available' do + expect(described_class.driver).to eq('dalli') + end + + it 'accepts preferred driver' do + expect(described_class.driver('dalli')).to eq('dalli') + end + + it 'returns redis when preferred' do + expect(described_class.driver('redis')).to eq('redis') + end + + it 'falls back to secondary when primary not found' do + expect(described_class.driver('foobar')).to be_a(String) + expect(described_class.driver('foobar')).to eq('dalli') + end end end diff --git a/spec/legion/version_spec.rb b/spec/legion/version_spec.rb new file mode 100644 index 0000000..26c2560 --- /dev/null +++ b/spec/legion/version_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cache/version' + +RSpec.describe 'Legion::Cache::VERSION' do + it 'exists' do + expect(Legion::Cache::VERSION).not_to be_nil + end + + it 'is a string' do + expect(Legion::Cache::VERSION).to be_a(String) + end + + it 'follows semantic versioning format' do + expect(Legion::Cache::VERSION).to match(/\A\d+\.\d+\.\d+/) + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 96b78ef..643f953 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'bundler/setup' require 'legion/logging' require 'legion/settings' @@ -5,15 +7,19 @@ SimpleCov.start Legion::Logging.setup(log_file: './legion.log') -Legion::Settings.merge_settings('cache', Legion::Cache::Settings.default) -Legion::Settings.load -require 'legion/cache/settings' +require 'legion/cache/settings' require 'legion/cache/version' +require 'legion/cache/local' +require 'legion/cache/redis_hash' +require 'legion/cache/helper' + +Legion::Settings.load RSpec.configure do |config| config.example_status_persistence_file_path = '.rspec_status' config.disable_monkey_patching! + config.filter_run_excluding integration: true unless ENV['RUN_INTEGRATION_SPECS'] == '1' config.expect_with :rspec do |c| c.syntax = :expect end