feat(cache): Redis backend (single-node + integration tests) - #22

Merged
moonming merged 2 commits into
mainfrom
feat/redis-cache
Apr 20, 2026
Merged

feat(cache): Redis backend (single-node + integration tests)#22
moonming merged 2 commits into
mainfrom
feat/redis-cache

Conversation

@moonming

Copy link
Copy Markdown
Collaborator

Summary

New `aisix-cache::RedisCache` implements the existing `Cache` trait
against a single-node Redis via `redis::aio::ConnectionManager`.

  • JSON-encoded `ChatResponse` stored under `:`
    (default prefix `aisix:cache`).
  • TTL applied per-entry via `SET EX`, defaults to 5m to match the
    in-memory backend.
  • Backend errors → `CacheError::Backend` → the proxy treats as cache
    miss and proceeds to upstream (no failure leaks to client).

Bootstrap wiring

```yaml
cache:
backend: redis
redis:
url: "redis://127.0.0.1:6379"
```

The server bootstrap now matches on `cfg.cache.backend`:

  • `memory` → `MemoryCache` (unchanged default)
  • `redis` → `RedisCache::connect(url)`
  • `qdrant` → falls back to `MemoryCache` with a `WARN` until that PR lands

Tests

  • 4 hermetic unit tests (TTL flooring, prefix join, invalid-URL
    error path)
  • 3 integration tests in `tests/redis_integration.rs` that round-trip
    against a real Redis. Tests no-op silently when `AISIX_REDIS_URL`
    is unset (so local `cargo test` stays hermetic). CI now sets it to
    `redis://127.0.0.1:6379` against a `redis:7-alpine` service container
    in the `rust-unit` job.

CI changes

  • `rust-unit` job: added `redis:7-alpine` service container + `AISIX_REDIS_URL` env.
  • `cargo llvm-cov --all-features` so the redis-feature code paths land
    in the coverage gate.

Test plan

  • `cargo test --workspace --all-features` — 411 tests pass (+16
    from baseline)
  • `cargo clippy --workspace --all-targets --all-features -D warnings` clean
  • `cargo fmt --check` clean
  • CI all-green (including the new Redis service in rust-unit)

Follow-ups

  • Cluster + Sentinel modes (config field already exists; the redis
    crate already supports both via the same ConnectionManager pattern).
  • Qdrant semantic cache (separate PR — needs an embedder abstraction
    first).

🤖 Generated with Claude Code

Per spec §3.5 / plan §4.14. New aisix-cache::RedisCache that
implements the existing Cache trait against a single-node Redis
instance via redis::aio::ConnectionManager (transparent reconnect, no
per-request handshake).
- JSON-serialise ChatResponse for storage, namespaced under
configurable prefix (default 'aisix:cache:<fingerprint>').
- TTL applied per-entry via SET EX, defaults to 5m to match
MemoryCache.
- All errors map to CacheError::Backend; the proxy treats backend
failures as cache misses and proceeds to the upstream.
Bootstrap (aisix-server) now switches on cfg.cache.backend:
- memory → MemoryCache (default)
- redis → RedisCache::connect(cfg.cache.redis.url)
- qdrant → falls back to MemoryCache with a WARN until that PR lands
Tests:
- 4 hermetic unit tests (TTL flooring, prefix join, invalid-URL
error)
- 3 integration tests in tests/redis_integration.rs that round-trip
against a real Redis instance. Skipped silently when AISIX_REDIS_URL
is unset; CI sets it to redis://127.0.0.1:6379 against a redis:7
service container.
CI:
- rust-unit job now spins a redis:7-alpine service and exports
AISIX_REDIS_URL. cargo llvm-cov runs with --all-features so the
redis backend code paths land in the coverage gate.
Cluster + Sentinel modes drop in under the same ConnectionManager
pattern in a follow-up — the redis crate already has them on its
workspace feature list.
CopilotAI review requested due to automatic review settings April 20, 2026 00:54

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a Redis-backed implementation of the aisix-cache::Cache trait and wires the server/CI to support running Redis-backed caching (including live integration tests in CI).

Changes:

  • Implement RedisCache (feature-gated) with JSON ChatResponse storage and per-entry TTL via SET EX.
  • Add Redis backend selection in aisix-server bootstrap config wiring.
  • Add live Redis integration tests and update CI to run them with a Redis service + --all-features coverage run.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
FileDescription
crates/aisix-server/src/main.rsSelects cache backend (memory/redis/qdrant fallback) based on config and connects to Redis when configured.
crates/aisix-server/Cargo.tomlEnables aisix-cache’s redis feature for the server binary.
crates/aisix-cache/src/redis.rsNew RedisCache backend implementation + unit tests.
crates/aisix-cache/src/lib.rsFeature-gated module/export wiring for RedisCache.
.github/workflows/ci.ymlAdds Redis service + env var for integration tests and runs coverage with --all-features.
crates/aisix-cache/tests/redis_integration.rsNew opt-in (env var) integration tests against a live Redis instance.

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

tracing::info!(target: "aisix::cache", backend = "redis", "connecting cache backend");
let redis = RedisCache::connect(&url)
.await
.map_err(|e| anyhow::anyhow!("redis cache connect failed (url={url}): {e}"))?;

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The error context includes the full Redis URL (url={url}), which may contain credentials (e.g. redis://:password@host:6379). Consider redacting userinfo before formatting/logging (or omit the URL entirely) to avoid leaking secrets into logs and error reports.

Suggested change
.map_err(|e| anyhow::anyhow!("redis cache connect failed (url={url}): {e}"))?;
.map_err(|e| anyhow::anyhow!("redis cache connect failed: {e}"))?;

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +66
/// Override the per-entry TTL. Caps at `u64::MAX / 1000` to stay
/// inside Redis's `EX` second range.
pub fn with_ttl(mut self, ttl: Duration) -> Self {
self.ttl_secs = ttl.as_secs().max(1);

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

with_ttl's doc comment says the TTL is capped to stay within Redis EX range, but the implementation only floors to 1s (ttl.as_secs().max(1)) and does not cap. Either implement the described cap (and document the actual limit) or remove/adjust the comment so it matches behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +116 to +127
#[test]
fn full_key_concatenates_prefix() {
// Construct a RedisCache without actually opening a connection by
// using a dummy ConnectionManager via mem::forget? No — we'd leak.
// Easier: test the prefix logic via a free function.
assert_eq!(prefix_join("aisix:cache", "ab12"), "aisix:cache:ab12");
assert_eq!(prefix_join("", "x"), ":x");
}

fn prefix_join(prefix: &str, key: &str) -> String {
format!("{prefix}:{key}")
}

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The full_key_concatenates_prefix test doesn't exercise RedisCache::full_key; it reimplements the formatting in a local prefix_join, so a future change to full_key could break behavior without failing this test. Consider extracting the key-join logic into a shared helper used by both full_key and the tests (or expose a small pure function) so the unit test actually covers production code.

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +32
#[cfg(feature = "redis")]
mod redis;

pub use cache::{Cache, CacheError, CacheOutcome};
pub use key::CacheKey;
pub use memory::{MemoryCache, DEFAULT_CAPACITY, DEFAULT_TTL};
#[cfg(feature = "redis")]
pub use redis::{
RedisCache, DEFAULT_PREFIX as REDIS_DEFAULT_PREFIX, DEFAULT_TTL as REDIS_DEFAULT_TTL,
};

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The crate-level docs still say the Redis backend "lands in a follow-up PR" even though this PR adds RedisCache and exports it behind the redis feature. Please update the module/docs to reflect the current set of backends so users aren't misled about Redis availability.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +6
//! End-to-end Redis tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file proves the
//! request → upstream → cache round-trip actually round-trips.

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The module docs claim these tests prove a "request → upstream → cache" round-trip, but the tests interact with RedisCache directly (no proxy request / upstream call involved). Suggest rewording the comment to describe what is actually validated here (RedisCache put/get/TTL behavior against a live Redis), or add a higher-level integration test if end-to-end proxy behavior is intended.

Suggested change
//! End-to-end Redis tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file proves the
//! request → upstream → cache round-trip actually round-trips.
//! Redis integration tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file validates
//! `RedisCache` put/get/miss/TTL behavior against a real Redis server.

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +54
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
env:
# Picked up by crates/aisix-cache/tests/redis_integration.rs.
# The tests no-op when this is unset (local dev), so absence is safe.
AISIX_REDIS_URL: redis://127.0.0.1:6379

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The Redis service is started without a health check or explicit wait. GitHub Actions will start the container, but tests can still race Redis readiness and become flaky. Consider adding options with a --health-cmd (e.g. redis-cli ping) + retries, or add a small step before tests that polls redis-cli ping until it succeeds.

Copilot uses AI. Check for mistakes.
@moonming
moonming merged commit cf7d15b into mainApr 20, 2026
6 checks passed
@moonming
moonming deleted the feat/redis-cache branch April 20, 2026 01:09
jarvis9443 added a commit that referenced this pull request Jun 2, 2026
The /v1/messages path dispatched to the upstream without any guardrail
check and without the budget pre-check, so prompts bypassed content/DLP
policy (rate limits were already enforced). Translate the Anthropic body
into the internal ChatFormat and run the resolved input guardrail chain
before dispatch (a Block short-circuits), and run the cp-api budget
pre-check — mirroring /v1/chat/completions. Applies to both the
Anthropic passthrough and cross-provider dispatch paths.
Output guardrails on the verbatim Anthropic byte-passthrough stream are
not yet enforced (the passthrough forwards raw bytes without building a
ChatResponse); that remains as follow-up.
Part of #448 (finding #22, input + budget)
jarvis9443 added a commit that referenced this pull request Jun 2, 2026
…eaming)
Thread the resolved guardrail chain (as Arc) through the /v1/messages
dispatch paths and run output guardrails on the response:
- Non-streaming: cross-provider checks the bridge ChatResponse;
passthrough extracts response text (content blocks + raw content array
for tool_use) into a synthetic ChatResponse.
- Streaming: both the cross-provider SSE encoder path and the verbatim
Anthropic byte-passthrough accumulate assistant text and run the
guardrail at end-of-stream. Bytes are forwarded live (matching
/v1/chat/completions and LiteLLM's streaming guardrail), so a block is
signalled with a terminal Anthropic `error` (content_filter) event.
Completes the output side of #448#22; with this and the earlier input +
budget work, /v1/messages no longer bypasses the guardrail/quota
pipeline. The remaining findings (#6 count_tokens, #2/#13
reasoning_content, #24 guardrail-vs-rate-limit ordering) are accepted as
standard behavior (LiteLLM has the same gap).
Fixes#448
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(cache): Redis backend (single-node + integration tests) - #22

Merged
moonming merged 2 commits into
mainfrom
feat/redis-cache
Apr 20, 2026
Merged

feat(cache): Redis backend (single-node + integration tests)#22
moonming merged 2 commits into
mainfrom
feat/redis-cache

Conversation

@moonming

Copy link
Copy Markdown
Collaborator

Summary

New `aisix-cache::RedisCache` implements the existing `Cache` trait
against a single-node Redis via `redis::aio::ConnectionManager`.

  • JSON-encoded `ChatResponse` stored under `:`
    (default prefix `aisix:cache`).
  • TTL applied per-entry via `SET EX`, defaults to 5m to match the
    in-memory backend.
  • Backend errors → `CacheError::Backend` → the proxy treats as cache
    miss and proceeds to upstream (no failure leaks to client).

Bootstrap wiring

```yaml
cache:
backend: redis
redis:
url: "redis://127.0.0.1:6379"
```

The server bootstrap now matches on `cfg.cache.backend`:

  • `memory` → `MemoryCache` (unchanged default)
  • `redis` → `RedisCache::connect(url)`
  • `qdrant` → falls back to `MemoryCache` with a `WARN` until that PR lands

Tests

  • 4 hermetic unit tests (TTL flooring, prefix join, invalid-URL
    error path)
  • 3 integration tests in `tests/redis_integration.rs` that round-trip
    against a real Redis. Tests no-op silently when `AISIX_REDIS_URL`
    is unset (so local `cargo test` stays hermetic). CI now sets it to
    `redis://127.0.0.1:6379` against a `redis:7-alpine` service container
    in the `rust-unit` job.

CI changes

  • `rust-unit` job: added `redis:7-alpine` service container + `AISIX_REDIS_URL` env.
  • `cargo llvm-cov --all-features` so the redis-feature code paths land
    in the coverage gate.

Test plan

  • `cargo test --workspace --all-features` — 411 tests pass (+16
    from baseline)
  • `cargo clippy --workspace --all-targets --all-features -D warnings` clean
  • `cargo fmt --check` clean
  • CI all-green (including the new Redis service in rust-unit)

Follow-ups

  • Cluster + Sentinel modes (config field already exists; the redis
    crate already supports both via the same ConnectionManager pattern).
  • Qdrant semantic cache (separate PR — needs an embedder abstraction
    first).

🤖 Generated with Claude Code

Per spec §3.5 / plan §4.14. New aisix-cache::RedisCache that
implements the existing Cache trait against a single-node Redis
instance via redis::aio::ConnectionManager (transparent reconnect, no
per-request handshake).
- JSON-serialise ChatResponse for storage, namespaced under
configurable prefix (default 'aisix:cache:<fingerprint>').
- TTL applied per-entry via SET EX, defaults to 5m to match
MemoryCache.
- All errors map to CacheError::Backend; the proxy treats backend
failures as cache misses and proceeds to the upstream.
Bootstrap (aisix-server) now switches on cfg.cache.backend:
- memory → MemoryCache (default)
- redis → RedisCache::connect(cfg.cache.redis.url)
- qdrant → falls back to MemoryCache with a WARN until that PR lands
Tests:
- 4 hermetic unit tests (TTL flooring, prefix join, invalid-URL
error)
- 3 integration tests in tests/redis_integration.rs that round-trip
against a real Redis instance. Skipped silently when AISIX_REDIS_URL
is unset; CI sets it to redis://127.0.0.1:6379 against a redis:7
service container.
CI:
- rust-unit job now spins a redis:7-alpine service and exports
AISIX_REDIS_URL. cargo llvm-cov runs with --all-features so the
redis backend code paths land in the coverage gate.
Cluster + Sentinel modes drop in under the same ConnectionManager
pattern in a follow-up — the redis crate already has them on its
workspace feature list.
CopilotAI review requested due to automatic review settings April 20, 2026 00:54

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a Redis-backed implementation of the aisix-cache::Cache trait and wires the server/CI to support running Redis-backed caching (including live integration tests in CI).

Changes:

  • Implement RedisCache (feature-gated) with JSON ChatResponse storage and per-entry TTL via SET EX.
  • Add Redis backend selection in aisix-server bootstrap config wiring.
  • Add live Redis integration tests and update CI to run them with a Redis service + --all-features coverage run.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
FileDescription
crates/aisix-server/src/main.rsSelects cache backend (memory/redis/qdrant fallback) based on config and connects to Redis when configured.
crates/aisix-server/Cargo.tomlEnables aisix-cache’s redis feature for the server binary.
crates/aisix-cache/src/redis.rsNew RedisCache backend implementation + unit tests.
crates/aisix-cache/src/lib.rsFeature-gated module/export wiring for RedisCache.
.github/workflows/ci.ymlAdds Redis service + env var for integration tests and runs coverage with --all-features.
crates/aisix-cache/tests/redis_integration.rsNew opt-in (env var) integration tests against a live Redis instance.

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

tracing::info!(target: "aisix::cache", backend = "redis", "connecting cache backend");
let redis = RedisCache::connect(&url)
.await
.map_err(|e| anyhow::anyhow!("redis cache connect failed (url={url}): {e}"))?;

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The error context includes the full Redis URL (url={url}), which may contain credentials (e.g. redis://:password@host:6379). Consider redacting userinfo before formatting/logging (or omit the URL entirely) to avoid leaking secrets into logs and error reports.

Suggested change
.map_err(|e| anyhow::anyhow!("redis cache connect failed (url={url}): {e}"))?;
.map_err(|e| anyhow::anyhow!("redis cache connect failed: {e}"))?;

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +66
/// Override the per-entry TTL. Caps at `u64::MAX / 1000` to stay
/// inside Redis's `EX` second range.
pub fn with_ttl(mut self, ttl: Duration) -> Self {
self.ttl_secs = ttl.as_secs().max(1);

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

with_ttl's doc comment says the TTL is capped to stay within Redis EX range, but the implementation only floors to 1s (ttl.as_secs().max(1)) and does not cap. Either implement the described cap (and document the actual limit) or remove/adjust the comment so it matches behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +116 to +127
#[test]
fn full_key_concatenates_prefix() {
// Construct a RedisCache without actually opening a connection by
// using a dummy ConnectionManager via mem::forget? No — we'd leak.
// Easier: test the prefix logic via a free function.
assert_eq!(prefix_join("aisix:cache", "ab12"), "aisix:cache:ab12");
assert_eq!(prefix_join("", "x"), ":x");
}

fn prefix_join(prefix: &str, key: &str) -> String {
format!("{prefix}:{key}")
}

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The full_key_concatenates_prefix test doesn't exercise RedisCache::full_key; it reimplements the formatting in a local prefix_join, so a future change to full_key could break behavior without failing this test. Consider extracting the key-join logic into a shared helper used by both full_key and the tests (or expose a small pure function) so the unit test actually covers production code.

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +32
#[cfg(feature = "redis")]
mod redis;

pub use cache::{Cache, CacheError, CacheOutcome};
pub use key::CacheKey;
pub use memory::{MemoryCache, DEFAULT_CAPACITY, DEFAULT_TTL};
#[cfg(feature = "redis")]
pub use redis::{
RedisCache, DEFAULT_PREFIX as REDIS_DEFAULT_PREFIX, DEFAULT_TTL as REDIS_DEFAULT_TTL,
};

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The crate-level docs still say the Redis backend "lands in a follow-up PR" even though this PR adds RedisCache and exports it behind the redis feature. Please update the module/docs to reflect the current set of backends so users aren't misled about Redis availability.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +6
//! End-to-end Redis tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file proves the
//! request → upstream → cache round-trip actually round-trips.

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The module docs claim these tests prove a "request → upstream → cache" round-trip, but the tests interact with RedisCache directly (no proxy request / upstream call involved). Suggest rewording the comment to describe what is actually validated here (RedisCache put/get/TTL behavior against a live Redis), or add a higher-level integration test if end-to-end proxy behavior is intended.

Suggested change
//! End-to-end Redis tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file proves the
//! request → upstream → cache round-trip actually round-trips.
//! Redis integration tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file validates
//! `RedisCache` put/get/miss/TTL behavior against a real Redis server.

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +54
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
env:
# Picked up by crates/aisix-cache/tests/redis_integration.rs.
# The tests no-op when this is unset (local dev), so absence is safe.
AISIX_REDIS_URL: redis://127.0.0.1:6379

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The Redis service is started without a health check or explicit wait. GitHub Actions will start the container, but tests can still race Redis readiness and become flaky. Consider adding options with a --health-cmd (e.g. redis-cli ping) + retries, or add a small step before tests that polls redis-cli ping until it succeeds.

Copilot uses AI. Check for mistakes.
@moonming
moonming merged commit cf7d15b into mainApr 20, 2026
6 checks passed
@moonming
moonming deleted the feat/redis-cache branch April 20, 2026 01:09
jarvis9443 added a commit that referenced this pull request Jun 2, 2026
The /v1/messages path dispatched to the upstream without any guardrail
check and without the budget pre-check, so prompts bypassed content/DLP
policy (rate limits were already enforced). Translate the Anthropic body
into the internal ChatFormat and run the resolved input guardrail chain
before dispatch (a Block short-circuits), and run the cp-api budget
pre-check — mirroring /v1/chat/completions. Applies to both the
Anthropic passthrough and cross-provider dispatch paths.
Output guardrails on the verbatim Anthropic byte-passthrough stream are
not yet enforced (the passthrough forwards raw bytes without building a
ChatResponse); that remains as follow-up.
Part of #448 (finding #22, input + budget)
jarvis9443 added a commit that referenced this pull request Jun 2, 2026
…eaming)
Thread the resolved guardrail chain (as Arc) through the /v1/messages
dispatch paths and run output guardrails on the response:
- Non-streaming: cross-provider checks the bridge ChatResponse;
passthrough extracts response text (content blocks + raw content array
for tool_use) into a synthetic ChatResponse.
- Streaming: both the cross-provider SSE encoder path and the verbatim
Anthropic byte-passthrough accumulate assistant text and run the
guardrail at end-of-stream. Bytes are forwarded live (matching
/v1/chat/completions and LiteLLM's streaming guardrail), so a block is
signalled with a terminal Anthropic `error` (content_filter) event.
Completes the output side of #448#22; with this and the earlier input +
budget work, /v1/messages no longer bypasses the guardrail/quota
pipeline. The remaining findings (#6 count_tokens, #2/#13
reasoning_content, #24 guardrail-vs-rate-limit ordering) are accepted as
standard behavior (LiteLLM has the same gap).
Fixes#448
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(cache): Redis backend (single-node + integration tests) - #22

Merged
moonming merged 2 commits into
mainfrom
feat/redis-cache
Apr 20, 2026
Merged

feat(cache): Redis backend (single-node + integration tests)#22
moonming merged 2 commits into
mainfrom
feat/redis-cache

Conversation

@moonming

Copy link
Copy Markdown
Collaborator

Summary

New `aisix-cache::RedisCache` implements the existing `Cache` trait
against a single-node Redis via `redis::aio::ConnectionManager`.

  • JSON-encoded `ChatResponse` stored under `:`
    (default prefix `aisix:cache`).
  • TTL applied per-entry via `SET EX`, defaults to 5m to match the
    in-memory backend.
  • Backend errors → `CacheError::Backend` → the proxy treats as cache
    miss and proceeds to upstream (no failure leaks to client).

Bootstrap wiring

```yaml
cache:
backend: redis
redis:
url: "redis://127.0.0.1:6379"
```

The server bootstrap now matches on `cfg.cache.backend`:

  • `memory` → `MemoryCache` (unchanged default)
  • `redis` → `RedisCache::connect(url)`
  • `qdrant` → falls back to `MemoryCache` with a `WARN` until that PR lands

Tests

  • 4 hermetic unit tests (TTL flooring, prefix join, invalid-URL
    error path)
  • 3 integration tests in `tests/redis_integration.rs` that round-trip
    against a real Redis. Tests no-op silently when `AISIX_REDIS_URL`
    is unset (so local `cargo test` stays hermetic). CI now sets it to
    `redis://127.0.0.1:6379` against a `redis:7-alpine` service container
    in the `rust-unit` job.

CI changes

  • `rust-unit` job: added `redis:7-alpine` service container + `AISIX_REDIS_URL` env.
  • `cargo llvm-cov --all-features` so the redis-feature code paths land
    in the coverage gate.

Test plan

  • `cargo test --workspace --all-features` — 411 tests pass (+16
    from baseline)
  • `cargo clippy --workspace --all-targets --all-features -D warnings` clean
  • `cargo fmt --check` clean
  • CI all-green (including the new Redis service in rust-unit)

Follow-ups

  • Cluster + Sentinel modes (config field already exists; the redis
    crate already supports both via the same ConnectionManager pattern).
  • Qdrant semantic cache (separate PR — needs an embedder abstraction
    first).

🤖 Generated with Claude Code

Per spec §3.5 / plan §4.14. New aisix-cache::RedisCache that
implements the existing Cache trait against a single-node Redis
instance via redis::aio::ConnectionManager (transparent reconnect, no
per-request handshake).
- JSON-serialise ChatResponse for storage, namespaced under
configurable prefix (default 'aisix:cache:<fingerprint>').
- TTL applied per-entry via SET EX, defaults to 5m to match
MemoryCache.
- All errors map to CacheError::Backend; the proxy treats backend
failures as cache misses and proceeds to the upstream.
Bootstrap (aisix-server) now switches on cfg.cache.backend:
- memory → MemoryCache (default)
- redis → RedisCache::connect(cfg.cache.redis.url)
- qdrant → falls back to MemoryCache with a WARN until that PR lands
Tests:
- 4 hermetic unit tests (TTL flooring, prefix join, invalid-URL
error)
- 3 integration tests in tests/redis_integration.rs that round-trip
against a real Redis instance. Skipped silently when AISIX_REDIS_URL
is unset; CI sets it to redis://127.0.0.1:6379 against a redis:7
service container.
CI:
- rust-unit job now spins a redis:7-alpine service and exports
AISIX_REDIS_URL. cargo llvm-cov runs with --all-features so the
redis backend code paths land in the coverage gate.
Cluster + Sentinel modes drop in under the same ConnectionManager
pattern in a follow-up — the redis crate already has them on its
workspace feature list.
CopilotAI review requested due to automatic review settings April 20, 2026 00:54

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a Redis-backed implementation of the aisix-cache::Cache trait and wires the server/CI to support running Redis-backed caching (including live integration tests in CI).

Changes:

  • Implement RedisCache (feature-gated) with JSON ChatResponse storage and per-entry TTL via SET EX.
  • Add Redis backend selection in aisix-server bootstrap config wiring.
  • Add live Redis integration tests and update CI to run them with a Redis service + --all-features coverage run.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
FileDescription
crates/aisix-server/src/main.rsSelects cache backend (memory/redis/qdrant fallback) based on config and connects to Redis when configured.
crates/aisix-server/Cargo.tomlEnables aisix-cache’s redis feature for the server binary.
crates/aisix-cache/src/redis.rsNew RedisCache backend implementation + unit tests.
crates/aisix-cache/src/lib.rsFeature-gated module/export wiring for RedisCache.
.github/workflows/ci.ymlAdds Redis service + env var for integration tests and runs coverage with --all-features.
crates/aisix-cache/tests/redis_integration.rsNew opt-in (env var) integration tests against a live Redis instance.

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

tracing::info!(target: "aisix::cache", backend = "redis", "connecting cache backend");
let redis = RedisCache::connect(&url)
.await
.map_err(|e| anyhow::anyhow!("redis cache connect failed (url={url}): {e}"))?;

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The error context includes the full Redis URL (url={url}), which may contain credentials (e.g. redis://:password@host:6379). Consider redacting userinfo before formatting/logging (or omit the URL entirely) to avoid leaking secrets into logs and error reports.

Suggested change
.map_err(|e| anyhow::anyhow!("redis cache connect failed (url={url}): {e}"))?;
.map_err(|e| anyhow::anyhow!("redis cache connect failed: {e}"))?;

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +66
/// Override the per-entry TTL. Caps at `u64::MAX / 1000` to stay
/// inside Redis's `EX` second range.
pub fn with_ttl(mut self, ttl: Duration) -> Self {
self.ttl_secs = ttl.as_secs().max(1);

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

with_ttl's doc comment says the TTL is capped to stay within Redis EX range, but the implementation only floors to 1s (ttl.as_secs().max(1)) and does not cap. Either implement the described cap (and document the actual limit) or remove/adjust the comment so it matches behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +116 to +127
#[test]
fn full_key_concatenates_prefix() {
// Construct a RedisCache without actually opening a connection by
// using a dummy ConnectionManager via mem::forget? No — we'd leak.
// Easier: test the prefix logic via a free function.
assert_eq!(prefix_join("aisix:cache", "ab12"), "aisix:cache:ab12");
assert_eq!(prefix_join("", "x"), ":x");
}

fn prefix_join(prefix: &str, key: &str) -> String {
format!("{prefix}:{key}")
}

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The full_key_concatenates_prefix test doesn't exercise RedisCache::full_key; it reimplements the formatting in a local prefix_join, so a future change to full_key could break behavior without failing this test. Consider extracting the key-join logic into a shared helper used by both full_key and the tests (or expose a small pure function) so the unit test actually covers production code.

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +32
#[cfg(feature = "redis")]
mod redis;

pub use cache::{Cache, CacheError, CacheOutcome};
pub use key::CacheKey;
pub use memory::{MemoryCache, DEFAULT_CAPACITY, DEFAULT_TTL};
#[cfg(feature = "redis")]
pub use redis::{
RedisCache, DEFAULT_PREFIX as REDIS_DEFAULT_PREFIX, DEFAULT_TTL as REDIS_DEFAULT_TTL,
};

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The crate-level docs still say the Redis backend "lands in a follow-up PR" even though this PR adds RedisCache and exports it behind the redis feature. Please update the module/docs to reflect the current set of backends so users aren't misled about Redis availability.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +6
//! End-to-end Redis tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file proves the
//! request → upstream → cache round-trip actually round-trips.

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The module docs claim these tests prove a "request → upstream → cache" round-trip, but the tests interact with RedisCache directly (no proxy request / upstream call involved). Suggest rewording the comment to describe what is actually validated here (RedisCache put/get/TTL behavior against a live Redis), or add a higher-level integration test if end-to-end proxy behavior is intended.

Suggested change
//! End-to-end Redis tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file proves the
//! request → upstream → cache round-trip actually round-trips.
//! Redis integration tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file validates
//! `RedisCache` put/get/miss/TTL behavior against a real Redis server.

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +54
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
env:
# Picked up by crates/aisix-cache/tests/redis_integration.rs.
# The tests no-op when this is unset (local dev), so absence is safe.
AISIX_REDIS_URL: redis://127.0.0.1:6379

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The Redis service is started without a health check or explicit wait. GitHub Actions will start the container, but tests can still race Redis readiness and become flaky. Consider adding options with a --health-cmd (e.g. redis-cli ping) + retries, or add a small step before tests that polls redis-cli ping until it succeeds.

Copilot uses AI. Check for mistakes.
@moonming
moonming merged commit cf7d15b into mainApr 20, 2026
6 checks passed
@moonming
moonming deleted the feat/redis-cache branch April 20, 2026 01:09
jarvis9443 added a commit that referenced this pull request Jun 2, 2026
The /v1/messages path dispatched to the upstream without any guardrail
check and without the budget pre-check, so prompts bypassed content/DLP
policy (rate limits were already enforced). Translate the Anthropic body
into the internal ChatFormat and run the resolved input guardrail chain
before dispatch (a Block short-circuits), and run the cp-api budget
pre-check — mirroring /v1/chat/completions. Applies to both the
Anthropic passthrough and cross-provider dispatch paths.
Output guardrails on the verbatim Anthropic byte-passthrough stream are
not yet enforced (the passthrough forwards raw bytes without building a
ChatResponse); that remains as follow-up.
Part of #448 (finding #22, input + budget)
jarvis9443 added a commit that referenced this pull request Jun 2, 2026
…eaming)
Thread the resolved guardrail chain (as Arc) through the /v1/messages
dispatch paths and run output guardrails on the response:
- Non-streaming: cross-provider checks the bridge ChatResponse;
passthrough extracts response text (content blocks + raw content array
for tool_use) into a synthetic ChatResponse.
- Streaming: both the cross-provider SSE encoder path and the verbatim
Anthropic byte-passthrough accumulate assistant text and run the
guardrail at end-of-stream. Bytes are forwarded live (matching
/v1/chat/completions and LiteLLM's streaming guardrail), so a block is
signalled with a terminal Anthropic `error` (content_filter) event.
Completes the output side of #448#22; with this and the earlier input +
budget work, /v1/messages no longer bypasses the guardrail/quota
pipeline. The remaining findings (#6 count_tokens, #2/#13
reasoning_content, #24 guardrail-vs-rate-limit ordering) are accepted as
standard behavior (LiteLLM has the same gap).
Fixes#448
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(cache): Redis backend (single-node + integration tests) - #22

Merged
moonming merged 2 commits into
mainfrom
feat/redis-cache
Apr 20, 2026
Merged

feat(cache): Redis backend (single-node + integration tests)#22
moonming merged 2 commits into
mainfrom
feat/redis-cache

Conversation

@moonming

Copy link
Copy Markdown
Collaborator

Summary

New `aisix-cache::RedisCache` implements the existing `Cache` trait
against a single-node Redis via `redis::aio::ConnectionManager`.

  • JSON-encoded `ChatResponse` stored under `:`
    (default prefix `aisix:cache`).
  • TTL applied per-entry via `SET EX`, defaults to 5m to match the
    in-memory backend.
  • Backend errors → `CacheError::Backend` → the proxy treats as cache
    miss and proceeds to upstream (no failure leaks to client).

Bootstrap wiring

```yaml
cache:
backend: redis
redis:
url: "redis://127.0.0.1:6379"
```

The server bootstrap now matches on `cfg.cache.backend`:

  • `memory` → `MemoryCache` (unchanged default)
  • `redis` → `RedisCache::connect(url)`
  • `qdrant` → falls back to `MemoryCache` with a `WARN` until that PR lands

Tests

  • 4 hermetic unit tests (TTL flooring, prefix join, invalid-URL
    error path)
  • 3 integration tests in `tests/redis_integration.rs` that round-trip
    against a real Redis. Tests no-op silently when `AISIX_REDIS_URL`
    is unset (so local `cargo test` stays hermetic). CI now sets it to
    `redis://127.0.0.1:6379` against a `redis:7-alpine` service container
    in the `rust-unit` job.

CI changes

  • `rust-unit` job: added `redis:7-alpine` service container + `AISIX_REDIS_URL` env.
  • `cargo llvm-cov --all-features` so the redis-feature code paths land
    in the coverage gate.

Test plan

  • `cargo test --workspace --all-features` — 411 tests pass (+16
    from baseline)
  • `cargo clippy --workspace --all-targets --all-features -D warnings` clean
  • `cargo fmt --check` clean
  • CI all-green (including the new Redis service in rust-unit)

Follow-ups

  • Cluster + Sentinel modes (config field already exists; the redis
    crate already supports both via the same ConnectionManager pattern).
  • Qdrant semantic cache (separate PR — needs an embedder abstraction
    first).

🤖 Generated with Claude Code

Per spec §3.5 / plan §4.14. New aisix-cache::RedisCache that
implements the existing Cache trait against a single-node Redis
instance via redis::aio::ConnectionManager (transparent reconnect, no
per-request handshake).
- JSON-serialise ChatResponse for storage, namespaced under
configurable prefix (default 'aisix:cache:<fingerprint>').
- TTL applied per-entry via SET EX, defaults to 5m to match
MemoryCache.
- All errors map to CacheError::Backend; the proxy treats backend
failures as cache misses and proceeds to the upstream.
Bootstrap (aisix-server) now switches on cfg.cache.backend:
- memory → MemoryCache (default)
- redis → RedisCache::connect(cfg.cache.redis.url)
- qdrant → falls back to MemoryCache with a WARN until that PR lands
Tests:
- 4 hermetic unit tests (TTL flooring, prefix join, invalid-URL
error)
- 3 integration tests in tests/redis_integration.rs that round-trip
against a real Redis instance. Skipped silently when AISIX_REDIS_URL
is unset; CI sets it to redis://127.0.0.1:6379 against a redis:7
service container.
CI:
- rust-unit job now spins a redis:7-alpine service and exports
AISIX_REDIS_URL. cargo llvm-cov runs with --all-features so the
redis backend code paths land in the coverage gate.
Cluster + Sentinel modes drop in under the same ConnectionManager
pattern in a follow-up — the redis crate already has them on its
workspace feature list.
CopilotAI review requested due to automatic review settings April 20, 2026 00:54

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a Redis-backed implementation of the aisix-cache::Cache trait and wires the server/CI to support running Redis-backed caching (including live integration tests in CI).

Changes:

  • Implement RedisCache (feature-gated) with JSON ChatResponse storage and per-entry TTL via SET EX.
  • Add Redis backend selection in aisix-server bootstrap config wiring.
  • Add live Redis integration tests and update CI to run them with a Redis service + --all-features coverage run.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
FileDescription
crates/aisix-server/src/main.rsSelects cache backend (memory/redis/qdrant fallback) based on config and connects to Redis when configured.
crates/aisix-server/Cargo.tomlEnables aisix-cache’s redis feature for the server binary.
crates/aisix-cache/src/redis.rsNew RedisCache backend implementation + unit tests.
crates/aisix-cache/src/lib.rsFeature-gated module/export wiring for RedisCache.
.github/workflows/ci.ymlAdds Redis service + env var for integration tests and runs coverage with --all-features.
crates/aisix-cache/tests/redis_integration.rsNew opt-in (env var) integration tests against a live Redis instance.

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

tracing::info!(target: "aisix::cache", backend = "redis", "connecting cache backend");
let redis = RedisCache::connect(&url)
.await
.map_err(|e| anyhow::anyhow!("redis cache connect failed (url={url}): {e}"))?;

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The error context includes the full Redis URL (url={url}), which may contain credentials (e.g. redis://:password@host:6379). Consider redacting userinfo before formatting/logging (or omit the URL entirely) to avoid leaking secrets into logs and error reports.

Suggested change
.map_err(|e| anyhow::anyhow!("redis cache connect failed (url={url}): {e}"))?;
.map_err(|e| anyhow::anyhow!("redis cache connect failed: {e}"))?;

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +66
/// Override the per-entry TTL. Caps at `u64::MAX / 1000` to stay
/// inside Redis's `EX` second range.
pub fn with_ttl(mut self, ttl: Duration) -> Self {
self.ttl_secs = ttl.as_secs().max(1);

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

with_ttl's doc comment says the TTL is capped to stay within Redis EX range, but the implementation only floors to 1s (ttl.as_secs().max(1)) and does not cap. Either implement the described cap (and document the actual limit) or remove/adjust the comment so it matches behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +116 to +127
#[test]
fn full_key_concatenates_prefix() {
// Construct a RedisCache without actually opening a connection by
// using a dummy ConnectionManager via mem::forget? No — we'd leak.
// Easier: test the prefix logic via a free function.
assert_eq!(prefix_join("aisix:cache", "ab12"), "aisix:cache:ab12");
assert_eq!(prefix_join("", "x"), ":x");
}

fn prefix_join(prefix: &str, key: &str) -> String {
format!("{prefix}:{key}")
}

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The full_key_concatenates_prefix test doesn't exercise RedisCache::full_key; it reimplements the formatting in a local prefix_join, so a future change to full_key could break behavior without failing this test. Consider extracting the key-join logic into a shared helper used by both full_key and the tests (or expose a small pure function) so the unit test actually covers production code.

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +32
#[cfg(feature = "redis")]
mod redis;

pub use cache::{Cache, CacheError, CacheOutcome};
pub use key::CacheKey;
pub use memory::{MemoryCache, DEFAULT_CAPACITY, DEFAULT_TTL};
#[cfg(feature = "redis")]
pub use redis::{
RedisCache, DEFAULT_PREFIX as REDIS_DEFAULT_PREFIX, DEFAULT_TTL as REDIS_DEFAULT_TTL,
};

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The crate-level docs still say the Redis backend "lands in a follow-up PR" even though this PR adds RedisCache and exports it behind the redis feature. Please update the module/docs to reflect the current set of backends so users aren't misled about Redis availability.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +6
//! End-to-end Redis tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file proves the
//! request → upstream → cache round-trip actually round-trips.

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The module docs claim these tests prove a "request → upstream → cache" round-trip, but the tests interact with RedisCache directly (no proxy request / upstream call involved). Suggest rewording the comment to describe what is actually validated here (RedisCache put/get/TTL behavior against a live Redis), or add a higher-level integration test if end-to-end proxy behavior is intended.

Suggested change
//! End-to-end Redis tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file proves the
//! request → upstream → cache round-trip actually round-trips.
//! Redis integration tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file validates
//! `RedisCache` put/get/miss/TTL behavior against a real Redis server.

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +54
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
env:
# Picked up by crates/aisix-cache/tests/redis_integration.rs.
# The tests no-op when this is unset (local dev), so absence is safe.
AISIX_REDIS_URL: redis://127.0.0.1:6379

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The Redis service is started without a health check or explicit wait. GitHub Actions will start the container, but tests can still race Redis readiness and become flaky. Consider adding options with a --health-cmd (e.g. redis-cli ping) + retries, or add a small step before tests that polls redis-cli ping until it succeeds.

Copilot uses AI. Check for mistakes.
@moonming
moonming merged commit cf7d15b into mainApr 20, 2026
6 checks passed
@moonming
moonming deleted the feat/redis-cache branch April 20, 2026 01:09
jarvis9443 added a commit that referenced this pull request Jun 2, 2026
The /v1/messages path dispatched to the upstream without any guardrail
check and without the budget pre-check, so prompts bypassed content/DLP
policy (rate limits were already enforced). Translate the Anthropic body
into the internal ChatFormat and run the resolved input guardrail chain
before dispatch (a Block short-circuits), and run the cp-api budget
pre-check — mirroring /v1/chat/completions. Applies to both the
Anthropic passthrough and cross-provider dispatch paths.
Output guardrails on the verbatim Anthropic byte-passthrough stream are
not yet enforced (the passthrough forwards raw bytes without building a
ChatResponse); that remains as follow-up.
Part of #448 (finding #22, input + budget)
jarvis9443 added a commit that referenced this pull request Jun 2, 2026
…eaming)
Thread the resolved guardrail chain (as Arc) through the /v1/messages
dispatch paths and run output guardrails on the response:
- Non-streaming: cross-provider checks the bridge ChatResponse;
passthrough extracts response text (content blocks + raw content array
for tool_use) into a synthetic ChatResponse.
- Streaming: both the cross-provider SSE encoder path and the verbatim
Anthropic byte-passthrough accumulate assistant text and run the
guardrail at end-of-stream. Bytes are forwarded live (matching
/v1/chat/completions and LiteLLM's streaming guardrail), so a block is
signalled with a terminal Anthropic `error` (content_filter) event.
Completes the output side of #448#22; with this and the earlier input +
budget work, /v1/messages no longer bypasses the guardrail/quota
pipeline. The remaining findings (#6 count_tokens, #2/#13
reasoning_content, #24 guardrail-vs-rate-limit ordering) are accepted as
standard behavior (LiteLLM has the same gap).
Fixes#448
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(cache): Redis backend (single-node + integration tests) - #22

Merged
moonming merged 2 commits into
mainfrom
feat/redis-cache
Apr 20, 2026
Merged

feat(cache): Redis backend (single-node + integration tests)#22
moonming merged 2 commits into
mainfrom
feat/redis-cache

Conversation

@moonming

Copy link
Copy Markdown
Collaborator

Summary

New `aisix-cache::RedisCache` implements the existing `Cache` trait
against a single-node Redis via `redis::aio::ConnectionManager`.

  • JSON-encoded `ChatResponse` stored under `:`
    (default prefix `aisix:cache`).
  • TTL applied per-entry via `SET EX`, defaults to 5m to match the
    in-memory backend.
  • Backend errors → `CacheError::Backend` → the proxy treats as cache
    miss and proceeds to upstream (no failure leaks to client).

Bootstrap wiring

```yaml
cache:
backend: redis
redis:
url: "redis://127.0.0.1:6379"
```

The server bootstrap now matches on `cfg.cache.backend`:

  • `memory` → `MemoryCache` (unchanged default)
  • `redis` → `RedisCache::connect(url)`
  • `qdrant` → falls back to `MemoryCache` with a `WARN` until that PR lands

Tests

  • 4 hermetic unit tests (TTL flooring, prefix join, invalid-URL
    error path)
  • 3 integration tests in `tests/redis_integration.rs` that round-trip
    against a real Redis. Tests no-op silently when `AISIX_REDIS_URL`
    is unset (so local `cargo test` stays hermetic). CI now sets it to
    `redis://127.0.0.1:6379` against a `redis:7-alpine` service container
    in the `rust-unit` job.

CI changes

  • `rust-unit` job: added `redis:7-alpine` service container + `AISIX_REDIS_URL` env.
  • `cargo llvm-cov --all-features` so the redis-feature code paths land
    in the coverage gate.

Test plan

  • `cargo test --workspace --all-features` — 411 tests pass (+16
    from baseline)
  • `cargo clippy --workspace --all-targets --all-features -D warnings` clean
  • `cargo fmt --check` clean
  • CI all-green (including the new Redis service in rust-unit)

Follow-ups

  • Cluster + Sentinel modes (config field already exists; the redis
    crate already supports both via the same ConnectionManager pattern).
  • Qdrant semantic cache (separate PR — needs an embedder abstraction
    first).

🤖 Generated with Claude Code

Per spec §3.5 / plan §4.14. New aisix-cache::RedisCache that
implements the existing Cache trait against a single-node Redis
instance via redis::aio::ConnectionManager (transparent reconnect, no
per-request handshake).
- JSON-serialise ChatResponse for storage, namespaced under
configurable prefix (default 'aisix:cache:<fingerprint>').
- TTL applied per-entry via SET EX, defaults to 5m to match
MemoryCache.
- All errors map to CacheError::Backend; the proxy treats backend
failures as cache misses and proceeds to the upstream.
Bootstrap (aisix-server) now switches on cfg.cache.backend:
- memory → MemoryCache (default)
- redis → RedisCache::connect(cfg.cache.redis.url)
- qdrant → falls back to MemoryCache with a WARN until that PR lands
Tests:
- 4 hermetic unit tests (TTL flooring, prefix join, invalid-URL
error)
- 3 integration tests in tests/redis_integration.rs that round-trip
against a real Redis instance. Skipped silently when AISIX_REDIS_URL
is unset; CI sets it to redis://127.0.0.1:6379 against a redis:7
service container.
CI:
- rust-unit job now spins a redis:7-alpine service and exports
AISIX_REDIS_URL. cargo llvm-cov runs with --all-features so the
redis backend code paths land in the coverage gate.
Cluster + Sentinel modes drop in under the same ConnectionManager
pattern in a follow-up — the redis crate already has them on its
workspace feature list.
CopilotAI review requested due to automatic review settings April 20, 2026 00:54

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a Redis-backed implementation of the aisix-cache::Cache trait and wires the server/CI to support running Redis-backed caching (including live integration tests in CI).

Changes:

  • Implement RedisCache (feature-gated) with JSON ChatResponse storage and per-entry TTL via SET EX.
  • Add Redis backend selection in aisix-server bootstrap config wiring.
  • Add live Redis integration tests and update CI to run them with a Redis service + --all-features coverage run.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
FileDescription
crates/aisix-server/src/main.rsSelects cache backend (memory/redis/qdrant fallback) based on config and connects to Redis when configured.
crates/aisix-server/Cargo.tomlEnables aisix-cache’s redis feature for the server binary.
crates/aisix-cache/src/redis.rsNew RedisCache backend implementation + unit tests.
crates/aisix-cache/src/lib.rsFeature-gated module/export wiring for RedisCache.
.github/workflows/ci.ymlAdds Redis service + env var for integration tests and runs coverage with --all-features.
crates/aisix-cache/tests/redis_integration.rsNew opt-in (env var) integration tests against a live Redis instance.

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

tracing::info!(target: "aisix::cache", backend = "redis", "connecting cache backend");
let redis = RedisCache::connect(&url)
.await
.map_err(|e| anyhow::anyhow!("redis cache connect failed (url={url}): {e}"))?;

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The error context includes the full Redis URL (url={url}), which may contain credentials (e.g. redis://:password@host:6379). Consider redacting userinfo before formatting/logging (or omit the URL entirely) to avoid leaking secrets into logs and error reports.

Suggested change
.map_err(|e| anyhow::anyhow!("redis cache connect failed (url={url}): {e}"))?;
.map_err(|e| anyhow::anyhow!("redis cache connect failed: {e}"))?;

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +66
/// Override the per-entry TTL. Caps at `u64::MAX / 1000` to stay
/// inside Redis's `EX` second range.
pub fn with_ttl(mut self, ttl: Duration) -> Self {
self.ttl_secs = ttl.as_secs().max(1);

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

with_ttl's doc comment says the TTL is capped to stay within Redis EX range, but the implementation only floors to 1s (ttl.as_secs().max(1)) and does not cap. Either implement the described cap (and document the actual limit) or remove/adjust the comment so it matches behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +116 to +127
#[test]
fn full_key_concatenates_prefix() {
// Construct a RedisCache without actually opening a connection by
// using a dummy ConnectionManager via mem::forget? No — we'd leak.
// Easier: test the prefix logic via a free function.
assert_eq!(prefix_join("aisix:cache", "ab12"), "aisix:cache:ab12");
assert_eq!(prefix_join("", "x"), ":x");
}

fn prefix_join(prefix: &str, key: &str) -> String {
format!("{prefix}:{key}")
}

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The full_key_concatenates_prefix test doesn't exercise RedisCache::full_key; it reimplements the formatting in a local prefix_join, so a future change to full_key could break behavior without failing this test. Consider extracting the key-join logic into a shared helper used by both full_key and the tests (or expose a small pure function) so the unit test actually covers production code.

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +32
#[cfg(feature = "redis")]
mod redis;

pub use cache::{Cache, CacheError, CacheOutcome};
pub use key::CacheKey;
pub use memory::{MemoryCache, DEFAULT_CAPACITY, DEFAULT_TTL};
#[cfg(feature = "redis")]
pub use redis::{
RedisCache, DEFAULT_PREFIX as REDIS_DEFAULT_PREFIX, DEFAULT_TTL as REDIS_DEFAULT_TTL,
};

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The crate-level docs still say the Redis backend "lands in a follow-up PR" even though this PR adds RedisCache and exports it behind the redis feature. Please update the module/docs to reflect the current set of backends so users aren't misled about Redis availability.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +6
//! End-to-end Redis tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file proves the
//! request → upstream → cache round-trip actually round-trips.

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The module docs claim these tests prove a "request → upstream → cache" round-trip, but the tests interact with RedisCache directly (no proxy request / upstream call involved). Suggest rewording the comment to describe what is actually validated here (RedisCache put/get/TTL behavior against a live Redis), or add a higher-level integration test if end-to-end proxy behavior is intended.

Suggested change
//! End-to-end Redis tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file proves the
//! request → upstream → cache round-trip actually round-trips.
//! Redis integration tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file validates
//! `RedisCache` put/get/miss/TTL behavior against a real Redis server.

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +54
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
env:
# Picked up by crates/aisix-cache/tests/redis_integration.rs.
# The tests no-op when this is unset (local dev), so absence is safe.
AISIX_REDIS_URL: redis://127.0.0.1:6379

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The Redis service is started without a health check or explicit wait. GitHub Actions will start the container, but tests can still race Redis readiness and become flaky. Consider adding options with a --health-cmd (e.g. redis-cli ping) + retries, or add a small step before tests that polls redis-cli ping until it succeeds.

Copilot uses AI. Check for mistakes.
@moonming
moonming merged commit cf7d15b into mainApr 20, 2026
6 checks passed
@moonming
moonming deleted the feat/redis-cache branch April 20, 2026 01:09
jarvis9443 added a commit that referenced this pull request Jun 2, 2026
The /v1/messages path dispatched to the upstream without any guardrail
check and without the budget pre-check, so prompts bypassed content/DLP
policy (rate limits were already enforced). Translate the Anthropic body
into the internal ChatFormat and run the resolved input guardrail chain
before dispatch (a Block short-circuits), and run the cp-api budget
pre-check — mirroring /v1/chat/completions. Applies to both the
Anthropic passthrough and cross-provider dispatch paths.
Output guardrails on the verbatim Anthropic byte-passthrough stream are
not yet enforced (the passthrough forwards raw bytes without building a
ChatResponse); that remains as follow-up.
Part of #448 (finding #22, input + budget)
jarvis9443 added a commit that referenced this pull request Jun 2, 2026
…eaming)
Thread the resolved guardrail chain (as Arc) through the /v1/messages
dispatch paths and run output guardrails on the response:
- Non-streaming: cross-provider checks the bridge ChatResponse;
passthrough extracts response text (content blocks + raw content array
for tool_use) into a synthetic ChatResponse.
- Streaming: both the cross-provider SSE encoder path and the verbatim
Anthropic byte-passthrough accumulate assistant text and run the
guardrail at end-of-stream. Bytes are forwarded live (matching
/v1/chat/completions and LiteLLM's streaming guardrail), so a block is
signalled with a terminal Anthropic `error` (content_filter) event.
Completes the output side of #448#22; with this and the earlier input +
budget work, /v1/messages no longer bypasses the guardrail/quota
pipeline. The remaining findings (#6 count_tokens, #2/#13
reasoning_content, #24 guardrail-vs-rate-limit ordering) are accepted as
standard behavior (LiteLLM has the same gap).
Fixes#448
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(cache): Redis backend (single-node + integration tests) - #22

Merged
moonming merged 2 commits into
mainfrom
feat/redis-cache
Apr 20, 2026
Merged

feat(cache): Redis backend (single-node + integration tests)#22
moonming merged 2 commits into
mainfrom
feat/redis-cache

Conversation

@moonming

Copy link
Copy Markdown
Collaborator

Summary

New `aisix-cache::RedisCache` implements the existing `Cache` trait
against a single-node Redis via `redis::aio::ConnectionManager`.

  • JSON-encoded `ChatResponse` stored under `:`
    (default prefix `aisix:cache`).
  • TTL applied per-entry via `SET EX`, defaults to 5m to match the
    in-memory backend.
  • Backend errors → `CacheError::Backend` → the proxy treats as cache
    miss and proceeds to upstream (no failure leaks to client).

Bootstrap wiring

```yaml
cache:
backend: redis
redis:
url: "redis://127.0.0.1:6379"
```

The server bootstrap now matches on `cfg.cache.backend`:

  • `memory` → `MemoryCache` (unchanged default)
  • `redis` → `RedisCache::connect(url)`
  • `qdrant` → falls back to `MemoryCache` with a `WARN` until that PR lands

Tests

  • 4 hermetic unit tests (TTL flooring, prefix join, invalid-URL
    error path)
  • 3 integration tests in `tests/redis_integration.rs` that round-trip
    against a real Redis. Tests no-op silently when `AISIX_REDIS_URL`
    is unset (so local `cargo test` stays hermetic). CI now sets it to
    `redis://127.0.0.1:6379` against a `redis:7-alpine` service container
    in the `rust-unit` job.

CI changes

  • `rust-unit` job: added `redis:7-alpine` service container + `AISIX_REDIS_URL` env.
  • `cargo llvm-cov --all-features` so the redis-feature code paths land
    in the coverage gate.

Test plan

  • `cargo test --workspace --all-features` — 411 tests pass (+16
    from baseline)
  • `cargo clippy --workspace --all-targets --all-features -D warnings` clean
  • `cargo fmt --check` clean
  • CI all-green (including the new Redis service in rust-unit)

Follow-ups

  • Cluster + Sentinel modes (config field already exists; the redis
    crate already supports both via the same ConnectionManager pattern).
  • Qdrant semantic cache (separate PR — needs an embedder abstraction
    first).

🤖 Generated with Claude Code

Per spec §3.5 / plan §4.14. New aisix-cache::RedisCache that
implements the existing Cache trait against a single-node Redis
instance via redis::aio::ConnectionManager (transparent reconnect, no
per-request handshake).
- JSON-serialise ChatResponse for storage, namespaced under
configurable prefix (default 'aisix:cache:<fingerprint>').
- TTL applied per-entry via SET EX, defaults to 5m to match
MemoryCache.
- All errors map to CacheError::Backend; the proxy treats backend
failures as cache misses and proceeds to the upstream.
Bootstrap (aisix-server) now switches on cfg.cache.backend:
- memory → MemoryCache (default)
- redis → RedisCache::connect(cfg.cache.redis.url)
- qdrant → falls back to MemoryCache with a WARN until that PR lands
Tests:
- 4 hermetic unit tests (TTL flooring, prefix join, invalid-URL
error)
- 3 integration tests in tests/redis_integration.rs that round-trip
against a real Redis instance. Skipped silently when AISIX_REDIS_URL
is unset; CI sets it to redis://127.0.0.1:6379 against a redis:7
service container.
CI:
- rust-unit job now spins a redis:7-alpine service and exports
AISIX_REDIS_URL. cargo llvm-cov runs with --all-features so the
redis backend code paths land in the coverage gate.
Cluster + Sentinel modes drop in under the same ConnectionManager
pattern in a follow-up — the redis crate already has them on its
workspace feature list.
CopilotAI review requested due to automatic review settings April 20, 2026 00:54

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a Redis-backed implementation of the aisix-cache::Cache trait and wires the server/CI to support running Redis-backed caching (including live integration tests in CI).

Changes:

  • Implement RedisCache (feature-gated) with JSON ChatResponse storage and per-entry TTL via SET EX.
  • Add Redis backend selection in aisix-server bootstrap config wiring.
  • Add live Redis integration tests and update CI to run them with a Redis service + --all-features coverage run.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
FileDescription
crates/aisix-server/src/main.rsSelects cache backend (memory/redis/qdrant fallback) based on config and connects to Redis when configured.
crates/aisix-server/Cargo.tomlEnables aisix-cache’s redis feature for the server binary.
crates/aisix-cache/src/redis.rsNew RedisCache backend implementation + unit tests.
crates/aisix-cache/src/lib.rsFeature-gated module/export wiring for RedisCache.
.github/workflows/ci.ymlAdds Redis service + env var for integration tests and runs coverage with --all-features.
crates/aisix-cache/tests/redis_integration.rsNew opt-in (env var) integration tests against a live Redis instance.

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

tracing::info!(target: "aisix::cache", backend = "redis", "connecting cache backend");
let redis = RedisCache::connect(&url)
.await
.map_err(|e| anyhow::anyhow!("redis cache connect failed (url={url}): {e}"))?;

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The error context includes the full Redis URL (url={url}), which may contain credentials (e.g. redis://:password@host:6379). Consider redacting userinfo before formatting/logging (or omit the URL entirely) to avoid leaking secrets into logs and error reports.

Suggested change
.map_err(|e| anyhow::anyhow!("redis cache connect failed (url={url}): {e}"))?;
.map_err(|e| anyhow::anyhow!("redis cache connect failed: {e}"))?;

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +66
/// Override the per-entry TTL. Caps at `u64::MAX / 1000` to stay
/// inside Redis's `EX` second range.
pub fn with_ttl(mut self, ttl: Duration) -> Self {
self.ttl_secs = ttl.as_secs().max(1);

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

with_ttl's doc comment says the TTL is capped to stay within Redis EX range, but the implementation only floors to 1s (ttl.as_secs().max(1)) and does not cap. Either implement the described cap (and document the actual limit) or remove/adjust the comment so it matches behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +116 to +127
#[test]
fn full_key_concatenates_prefix() {
// Construct a RedisCache without actually opening a connection by
// using a dummy ConnectionManager via mem::forget? No — we'd leak.
// Easier: test the prefix logic via a free function.
assert_eq!(prefix_join("aisix:cache", "ab12"), "aisix:cache:ab12");
assert_eq!(prefix_join("", "x"), ":x");
}

fn prefix_join(prefix: &str, key: &str) -> String {
format!("{prefix}:{key}")
}

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The full_key_concatenates_prefix test doesn't exercise RedisCache::full_key; it reimplements the formatting in a local prefix_join, so a future change to full_key could break behavior without failing this test. Consider extracting the key-join logic into a shared helper used by both full_key and the tests (or expose a small pure function) so the unit test actually covers production code.

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +32
#[cfg(feature = "redis")]
mod redis;

pub use cache::{Cache, CacheError, CacheOutcome};
pub use key::CacheKey;
pub use memory::{MemoryCache, DEFAULT_CAPACITY, DEFAULT_TTL};
#[cfg(feature = "redis")]
pub use redis::{
RedisCache, DEFAULT_PREFIX as REDIS_DEFAULT_PREFIX, DEFAULT_TTL as REDIS_DEFAULT_TTL,
};

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The crate-level docs still say the Redis backend "lands in a follow-up PR" even though this PR adds RedisCache and exports it behind the redis feature. Please update the module/docs to reflect the current set of backends so users aren't misled about Redis availability.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +6
//! End-to-end Redis tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file proves the
//! request → upstream → cache round-trip actually round-trips.

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The module docs claim these tests prove a "request → upstream → cache" round-trip, but the tests interact with RedisCache directly (no proxy request / upstream call involved). Suggest rewording the comment to describe what is actually validated here (RedisCache put/get/TTL behavior against a live Redis), or add a higher-level integration test if end-to-end proxy behavior is intended.

Suggested change
//! End-to-end Redis tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file proves the
//! request → upstream → cache round-trip actually round-trips.
//! Redis integration tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file validates
//! `RedisCache` put/get/miss/TTL behavior against a real Redis server.

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +54
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
env:
# Picked up by crates/aisix-cache/tests/redis_integration.rs.
# The tests no-op when this is unset (local dev), so absence is safe.
AISIX_REDIS_URL: redis://127.0.0.1:6379

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The Redis service is started without a health check or explicit wait. GitHub Actions will start the container, but tests can still race Redis readiness and become flaky. Consider adding options with a --health-cmd (e.g. redis-cli ping) + retries, or add a small step before tests that polls redis-cli ping until it succeeds.

Copilot uses AI. Check for mistakes.
@moonming
moonming merged commit cf7d15b into mainApr 20, 2026
6 checks passed
@moonming
moonming deleted the feat/redis-cache branch April 20, 2026 01:09
jarvis9443 added a commit that referenced this pull request Jun 2, 2026
The /v1/messages path dispatched to the upstream without any guardrail
check and without the budget pre-check, so prompts bypassed content/DLP
policy (rate limits were already enforced). Translate the Anthropic body
into the internal ChatFormat and run the resolved input guardrail chain
before dispatch (a Block short-circuits), and run the cp-api budget
pre-check — mirroring /v1/chat/completions. Applies to both the
Anthropic passthrough and cross-provider dispatch paths.
Output guardrails on the verbatim Anthropic byte-passthrough stream are
not yet enforced (the passthrough forwards raw bytes without building a
ChatResponse); that remains as follow-up.
Part of #448 (finding #22, input + budget)
jarvis9443 added a commit that referenced this pull request Jun 2, 2026
…eaming)
Thread the resolved guardrail chain (as Arc) through the /v1/messages
dispatch paths and run output guardrails on the response:
- Non-streaming: cross-provider checks the bridge ChatResponse;
passthrough extracts response text (content blocks + raw content array
for tool_use) into a synthetic ChatResponse.
- Streaming: both the cross-provider SSE encoder path and the verbatim
Anthropic byte-passthrough accumulate assistant text and run the
guardrail at end-of-stream. Bytes are forwarded live (matching
/v1/chat/completions and LiteLLM's streaming guardrail), so a block is
signalled with a terminal Anthropic `error` (content_filter) event.
Completes the output side of #448#22; with this and the earlier input +
budget work, /v1/messages no longer bypasses the guardrail/quota
pipeline. The remaining findings (#6 count_tokens, #2/#13
reasoning_content, #24 guardrail-vs-rate-limit ordering) are accepted as
standard behavior (LiteLLM has the same gap).
Fixes#448
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(cache): Redis backend (single-node + integration tests) - #22

Merged
moonming merged 2 commits into
mainfrom
feat/redis-cache
Apr 20, 2026
Merged

feat(cache): Redis backend (single-node + integration tests)#22
moonming merged 2 commits into
mainfrom
feat/redis-cache

Conversation

@moonming

Copy link
Copy Markdown
Collaborator

Summary

New `aisix-cache::RedisCache` implements the existing `Cache` trait
against a single-node Redis via `redis::aio::ConnectionManager`.

  • JSON-encoded `ChatResponse` stored under `:`
    (default prefix `aisix:cache`).
  • TTL applied per-entry via `SET EX`, defaults to 5m to match the
    in-memory backend.
  • Backend errors → `CacheError::Backend` → the proxy treats as cache
    miss and proceeds to upstream (no failure leaks to client).

Bootstrap wiring

```yaml
cache:
backend: redis
redis:
url: "redis://127.0.0.1:6379"
```

The server bootstrap now matches on `cfg.cache.backend`:

  • `memory` → `MemoryCache` (unchanged default)
  • `redis` → `RedisCache::connect(url)`
  • `qdrant` → falls back to `MemoryCache` with a `WARN` until that PR lands

Tests

  • 4 hermetic unit tests (TTL flooring, prefix join, invalid-URL
    error path)
  • 3 integration tests in `tests/redis_integration.rs` that round-trip
    against a real Redis. Tests no-op silently when `AISIX_REDIS_URL`
    is unset (so local `cargo test` stays hermetic). CI now sets it to
    `redis://127.0.0.1:6379` against a `redis:7-alpine` service container
    in the `rust-unit` job.

CI changes

  • `rust-unit` job: added `redis:7-alpine` service container + `AISIX_REDIS_URL` env.
  • `cargo llvm-cov --all-features` so the redis-feature code paths land
    in the coverage gate.

Test plan

  • `cargo test --workspace --all-features` — 411 tests pass (+16
    from baseline)
  • `cargo clippy --workspace --all-targets --all-features -D warnings` clean
  • `cargo fmt --check` clean
  • CI all-green (including the new Redis service in rust-unit)

Follow-ups

  • Cluster + Sentinel modes (config field already exists; the redis
    crate already supports both via the same ConnectionManager pattern).
  • Qdrant semantic cache (separate PR — needs an embedder abstraction
    first).

🤖 Generated with Claude Code

Per spec §3.5 / plan §4.14. New aisix-cache::RedisCache that
implements the existing Cache trait against a single-node Redis
instance via redis::aio::ConnectionManager (transparent reconnect, no
per-request handshake).
- JSON-serialise ChatResponse for storage, namespaced under
configurable prefix (default 'aisix:cache:<fingerprint>').
- TTL applied per-entry via SET EX, defaults to 5m to match
MemoryCache.
- All errors map to CacheError::Backend; the proxy treats backend
failures as cache misses and proceeds to the upstream.
Bootstrap (aisix-server) now switches on cfg.cache.backend:
- memory → MemoryCache (default)
- redis → RedisCache::connect(cfg.cache.redis.url)
- qdrant → falls back to MemoryCache with a WARN until that PR lands
Tests:
- 4 hermetic unit tests (TTL flooring, prefix join, invalid-URL
error)
- 3 integration tests in tests/redis_integration.rs that round-trip
against a real Redis instance. Skipped silently when AISIX_REDIS_URL
is unset; CI sets it to redis://127.0.0.1:6379 against a redis:7
service container.
CI:
- rust-unit job now spins a redis:7-alpine service and exports
AISIX_REDIS_URL. cargo llvm-cov runs with --all-features so the
redis backend code paths land in the coverage gate.
Cluster + Sentinel modes drop in under the same ConnectionManager
pattern in a follow-up — the redis crate already has them on its
workspace feature list.
CopilotAI review requested due to automatic review settings April 20, 2026 00:54

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a Redis-backed implementation of the aisix-cache::Cache trait and wires the server/CI to support running Redis-backed caching (including live integration tests in CI).

Changes:

  • Implement RedisCache (feature-gated) with JSON ChatResponse storage and per-entry TTL via SET EX.
  • Add Redis backend selection in aisix-server bootstrap config wiring.
  • Add live Redis integration tests and update CI to run them with a Redis service + --all-features coverage run.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
FileDescription
crates/aisix-server/src/main.rsSelects cache backend (memory/redis/qdrant fallback) based on config and connects to Redis when configured.
crates/aisix-server/Cargo.tomlEnables aisix-cache’s redis feature for the server binary.
crates/aisix-cache/src/redis.rsNew RedisCache backend implementation + unit tests.
crates/aisix-cache/src/lib.rsFeature-gated module/export wiring for RedisCache.
.github/workflows/ci.ymlAdds Redis service + env var for integration tests and runs coverage with --all-features.
crates/aisix-cache/tests/redis_integration.rsNew opt-in (env var) integration tests against a live Redis instance.

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

tracing::info!(target: "aisix::cache", backend = "redis", "connecting cache backend");
let redis = RedisCache::connect(&url)
.await
.map_err(|e| anyhow::anyhow!("redis cache connect failed (url={url}): {e}"))?;

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The error context includes the full Redis URL (url={url}), which may contain credentials (e.g. redis://:password@host:6379). Consider redacting userinfo before formatting/logging (or omit the URL entirely) to avoid leaking secrets into logs and error reports.

Suggested change
.map_err(|e| anyhow::anyhow!("redis cache connect failed (url={url}): {e}"))?;
.map_err(|e| anyhow::anyhow!("redis cache connect failed: {e}"))?;

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +66
/// Override the per-entry TTL. Caps at `u64::MAX / 1000` to stay
/// inside Redis's `EX` second range.
pub fn with_ttl(mut self, ttl: Duration) -> Self {
self.ttl_secs = ttl.as_secs().max(1);

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

with_ttl's doc comment says the TTL is capped to stay within Redis EX range, but the implementation only floors to 1s (ttl.as_secs().max(1)) and does not cap. Either implement the described cap (and document the actual limit) or remove/adjust the comment so it matches behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +116 to +127
#[test]
fn full_key_concatenates_prefix() {
// Construct a RedisCache without actually opening a connection by
// using a dummy ConnectionManager via mem::forget? No — we'd leak.
// Easier: test the prefix logic via a free function.
assert_eq!(prefix_join("aisix:cache", "ab12"), "aisix:cache:ab12");
assert_eq!(prefix_join("", "x"), ":x");
}

fn prefix_join(prefix: &str, key: &str) -> String {
format!("{prefix}:{key}")
}

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The full_key_concatenates_prefix test doesn't exercise RedisCache::full_key; it reimplements the formatting in a local prefix_join, so a future change to full_key could break behavior without failing this test. Consider extracting the key-join logic into a shared helper used by both full_key and the tests (or expose a small pure function) so the unit test actually covers production code.

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +32
#[cfg(feature = "redis")]
mod redis;

pub use cache::{Cache, CacheError, CacheOutcome};
pub use key::CacheKey;
pub use memory::{MemoryCache, DEFAULT_CAPACITY, DEFAULT_TTL};
#[cfg(feature = "redis")]
pub use redis::{
RedisCache, DEFAULT_PREFIX as REDIS_DEFAULT_PREFIX, DEFAULT_TTL as REDIS_DEFAULT_TTL,
};

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The crate-level docs still say the Redis backend "lands in a follow-up PR" even though this PR adds RedisCache and exports it behind the redis feature. Please update the module/docs to reflect the current set of backends so users aren't misled about Redis availability.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +6
//! End-to-end Redis tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file proves the
//! request → upstream → cache round-trip actually round-trips.

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The module docs claim these tests prove a "request → upstream → cache" round-trip, but the tests interact with RedisCache directly (no proxy request / upstream call involved). Suggest rewording the comment to describe what is actually validated here (RedisCache put/get/TTL behavior against a live Redis), or add a higher-level integration test if end-to-end proxy behavior is intended.

Suggested change
//! End-to-end Redis tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file proves the
//! request → upstream → cache round-trip actually round-trips.
//! Redis integration tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file validates
//! `RedisCache` put/get/miss/TTL behavior against a real Redis server.

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +54
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
env:
# Picked up by crates/aisix-cache/tests/redis_integration.rs.
# The tests no-op when this is unset (local dev), so absence is safe.
AISIX_REDIS_URL: redis://127.0.0.1:6379

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The Redis service is started without a health check or explicit wait. GitHub Actions will start the container, but tests can still race Redis readiness and become flaky. Consider adding options with a --health-cmd (e.g. redis-cli ping) + retries, or add a small step before tests that polls redis-cli ping until it succeeds.

Copilot uses AI. Check for mistakes.
@moonming
moonming merged commit cf7d15b into mainApr 20, 2026
6 checks passed
@moonming
moonming deleted the feat/redis-cache branch April 20, 2026 01:09
jarvis9443 added a commit that referenced this pull request Jun 2, 2026
The /v1/messages path dispatched to the upstream without any guardrail
check and without the budget pre-check, so prompts bypassed content/DLP
policy (rate limits were already enforced). Translate the Anthropic body
into the internal ChatFormat and run the resolved input guardrail chain
before dispatch (a Block short-circuits), and run the cp-api budget
pre-check — mirroring /v1/chat/completions. Applies to both the
Anthropic passthrough and cross-provider dispatch paths.
Output guardrails on the verbatim Anthropic byte-passthrough stream are
not yet enforced (the passthrough forwards raw bytes without building a
ChatResponse); that remains as follow-up.
Part of #448 (finding #22, input + budget)
jarvis9443 added a commit that referenced this pull request Jun 2, 2026
…eaming)
Thread the resolved guardrail chain (as Arc) through the /v1/messages
dispatch paths and run output guardrails on the response:
- Non-streaming: cross-provider checks the bridge ChatResponse;
passthrough extracts response text (content blocks + raw content array
for tool_use) into a synthetic ChatResponse.
- Streaming: both the cross-provider SSE encoder path and the verbatim
Anthropic byte-passthrough accumulate assistant text and run the
guardrail at end-of-stream. Bytes are forwarded live (matching
/v1/chat/completions and LiteLLM's streaming guardrail), so a block is
signalled with a terminal Anthropic `error` (content_filter) event.
Completes the output side of #448#22; with this and the earlier input +
budget work, /v1/messages no longer bypasses the guardrail/quota
pipeline. The remaining findings (#6 count_tokens, #2/#13
reasoning_content, #24 guardrail-vs-rate-limit ordering) are accepted as
standard behavior (LiteLLM has the same gap).
Fixes#448
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(cache): Redis backend (single-node + integration tests) - #22

Merged
moonming merged 2 commits into
mainfrom
feat/redis-cache
Apr 20, 2026
Merged

feat(cache): Redis backend (single-node + integration tests)#22
moonming merged 2 commits into
mainfrom
feat/redis-cache

Conversation

@moonming

Copy link
Copy Markdown
Collaborator

Summary

New `aisix-cache::RedisCache` implements the existing `Cache` trait
against a single-node Redis via `redis::aio::ConnectionManager`.

  • JSON-encoded `ChatResponse` stored under `:`
    (default prefix `aisix:cache`).
  • TTL applied per-entry via `SET EX`, defaults to 5m to match the
    in-memory backend.
  • Backend errors → `CacheError::Backend` → the proxy treats as cache
    miss and proceeds to upstream (no failure leaks to client).

Bootstrap wiring

```yaml
cache:
backend: redis
redis:
url: "redis://127.0.0.1:6379"
```

The server bootstrap now matches on `cfg.cache.backend`:

  • `memory` → `MemoryCache` (unchanged default)
  • `redis` → `RedisCache::connect(url)`
  • `qdrant` → falls back to `MemoryCache` with a `WARN` until that PR lands

Tests

  • 4 hermetic unit tests (TTL flooring, prefix join, invalid-URL
    error path)
  • 3 integration tests in `tests/redis_integration.rs` that round-trip
    against a real Redis. Tests no-op silently when `AISIX_REDIS_URL`
    is unset (so local `cargo test` stays hermetic). CI now sets it to
    `redis://127.0.0.1:6379` against a `redis:7-alpine` service container
    in the `rust-unit` job.

CI changes

  • `rust-unit` job: added `redis:7-alpine` service container + `AISIX_REDIS_URL` env.
  • `cargo llvm-cov --all-features` so the redis-feature code paths land
    in the coverage gate.

Test plan

  • `cargo test --workspace --all-features` — 411 tests pass (+16
    from baseline)
  • `cargo clippy --workspace --all-targets --all-features -D warnings` clean
  • `cargo fmt --check` clean
  • CI all-green (including the new Redis service in rust-unit)

Follow-ups

  • Cluster + Sentinel modes (config field already exists; the redis
    crate already supports both via the same ConnectionManager pattern).
  • Qdrant semantic cache (separate PR — needs an embedder abstraction
    first).

🤖 Generated with Claude Code

Per spec §3.5 / plan §4.14. New aisix-cache::RedisCache that
implements the existing Cache trait against a single-node Redis
instance via redis::aio::ConnectionManager (transparent reconnect, no
per-request handshake).
- JSON-serialise ChatResponse for storage, namespaced under
configurable prefix (default 'aisix:cache:<fingerprint>').
- TTL applied per-entry via SET EX, defaults to 5m to match
MemoryCache.
- All errors map to CacheError::Backend; the proxy treats backend
failures as cache misses and proceeds to the upstream.
Bootstrap (aisix-server) now switches on cfg.cache.backend:
- memory → MemoryCache (default)
- redis → RedisCache::connect(cfg.cache.redis.url)
- qdrant → falls back to MemoryCache with a WARN until that PR lands
Tests:
- 4 hermetic unit tests (TTL flooring, prefix join, invalid-URL
error)
- 3 integration tests in tests/redis_integration.rs that round-trip
against a real Redis instance. Skipped silently when AISIX_REDIS_URL
is unset; CI sets it to redis://127.0.0.1:6379 against a redis:7
service container.
CI:
- rust-unit job now spins a redis:7-alpine service and exports
AISIX_REDIS_URL. cargo llvm-cov runs with --all-features so the
redis backend code paths land in the coverage gate.
Cluster + Sentinel modes drop in under the same ConnectionManager
pattern in a follow-up — the redis crate already has them on its
workspace feature list.
CopilotAI review requested due to automatic review settings April 20, 2026 00:54

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a Redis-backed implementation of the aisix-cache::Cache trait and wires the server/CI to support running Redis-backed caching (including live integration tests in CI).

Changes:

  • Implement RedisCache (feature-gated) with JSON ChatResponse storage and per-entry TTL via SET EX.
  • Add Redis backend selection in aisix-server bootstrap config wiring.
  • Add live Redis integration tests and update CI to run them with a Redis service + --all-features coverage run.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
FileDescription
crates/aisix-server/src/main.rsSelects cache backend (memory/redis/qdrant fallback) based on config and connects to Redis when configured.
crates/aisix-server/Cargo.tomlEnables aisix-cache’s redis feature for the server binary.
crates/aisix-cache/src/redis.rsNew RedisCache backend implementation + unit tests.
crates/aisix-cache/src/lib.rsFeature-gated module/export wiring for RedisCache.
.github/workflows/ci.ymlAdds Redis service + env var for integration tests and runs coverage with --all-features.
crates/aisix-cache/tests/redis_integration.rsNew opt-in (env var) integration tests against a live Redis instance.

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

tracing::info!(target: "aisix::cache", backend = "redis", "connecting cache backend");
let redis = RedisCache::connect(&url)
.await
.map_err(|e| anyhow::anyhow!("redis cache connect failed (url={url}): {e}"))?;

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The error context includes the full Redis URL (url={url}), which may contain credentials (e.g. redis://:password@host:6379). Consider redacting userinfo before formatting/logging (or omit the URL entirely) to avoid leaking secrets into logs and error reports.

Suggested change
.map_err(|e| anyhow::anyhow!("redis cache connect failed (url={url}): {e}"))?;
.map_err(|e| anyhow::anyhow!("redis cache connect failed: {e}"))?;

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +66
/// Override the per-entry TTL. Caps at `u64::MAX / 1000` to stay
/// inside Redis's `EX` second range.
pub fn with_ttl(mut self, ttl: Duration) -> Self {
self.ttl_secs = ttl.as_secs().max(1);

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

with_ttl's doc comment says the TTL is capped to stay within Redis EX range, but the implementation only floors to 1s (ttl.as_secs().max(1)) and does not cap. Either implement the described cap (and document the actual limit) or remove/adjust the comment so it matches behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +116 to +127
#[test]
fn full_key_concatenates_prefix() {
// Construct a RedisCache without actually opening a connection by
// using a dummy ConnectionManager via mem::forget? No — we'd leak.
// Easier: test the prefix logic via a free function.
assert_eq!(prefix_join("aisix:cache", "ab12"), "aisix:cache:ab12");
assert_eq!(prefix_join("", "x"), ":x");
}

fn prefix_join(prefix: &str, key: &str) -> String {
format!("{prefix}:{key}")
}

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The full_key_concatenates_prefix test doesn't exercise RedisCache::full_key; it reimplements the formatting in a local prefix_join, so a future change to full_key could break behavior without failing this test. Consider extracting the key-join logic into a shared helper used by both full_key and the tests (or expose a small pure function) so the unit test actually covers production code.

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +32
#[cfg(feature = "redis")]
mod redis;

pub use cache::{Cache, CacheError, CacheOutcome};
pub use key::CacheKey;
pub use memory::{MemoryCache, DEFAULT_CAPACITY, DEFAULT_TTL};
#[cfg(feature = "redis")]
pub use redis::{
RedisCache, DEFAULT_PREFIX as REDIS_DEFAULT_PREFIX, DEFAULT_TTL as REDIS_DEFAULT_TTL,
};

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The crate-level docs still say the Redis backend "lands in a follow-up PR" even though this PR adds RedisCache and exports it behind the redis feature. Please update the module/docs to reflect the current set of backends so users aren't misled about Redis availability.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +6
//! End-to-end Redis tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file proves the
//! request → upstream → cache round-trip actually round-trips.

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The module docs claim these tests prove a "request → upstream → cache" round-trip, but the tests interact with RedisCache directly (no proxy request / upstream call involved). Suggest rewording the comment to describe what is actually validated here (RedisCache put/get/TTL behavior against a live Redis), or add a higher-level integration test if end-to-end proxy behavior is intended.

Suggested change
//! End-to-end Redis tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file proves the
//! request → upstream → cache round-trip actually round-trips.
//! Redis integration tests against a live Redis instance.
//!
//! Runs only when `AISIX_REDIS_URL` is set (e.g. on CI which spins
//! `redis:7-alpine` as a service). The unit test module in
//! `src/redis.rs` handles hermetic checks; this file validates
//! `RedisCache` put/get/miss/TTL behavior against a real Redis server.

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +54
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
env:
# Picked up by crates/aisix-cache/tests/redis_integration.rs.
# The tests no-op when this is unset (local dev), so absence is safe.
AISIX_REDIS_URL: redis://127.0.0.1:6379

CopilotAIApr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The Redis service is started without a health check or explicit wait. GitHub Actions will start the container, but tests can still race Redis readiness and become flaky. Consider adding options with a --health-cmd (e.g. redis-cli ping) + retries, or add a small step before tests that polls redis-cli ping until it succeeds.

Copilot uses AI. Check for mistakes.
@moonming
moonming merged commit cf7d15b into mainApr 20, 2026
6 checks passed
@moonming
moonming deleted the feat/redis-cache branch April 20, 2026 01:09
jarvis9443 added a commit that referenced this pull request Jun 2, 2026
The /v1/messages path dispatched to the upstream without any guardrail
check and without the budget pre-check, so prompts bypassed content/DLP
policy (rate limits were already enforced). Translate the Anthropic body
into the internal ChatFormat and run the resolved input guardrail chain
before dispatch (a Block short-circuits), and run the cp-api budget
pre-check — mirroring /v1/chat/completions. Applies to both the
Anthropic passthrough and cross-provider dispatch paths.
Output guardrails on the verbatim Anthropic byte-passthrough stream are
not yet enforced (the passthrough forwards raw bytes without building a
ChatResponse); that remains as follow-up.
Part of #448 (finding #22, input + budget)
jarvis9443 added a commit that referenced this pull request Jun 2, 2026
…eaming)
Thread the resolved guardrail chain (as Arc) through the /v1/messages
dispatch paths and run output guardrails on the response:
- Non-streaming: cross-provider checks the bridge ChatResponse;
passthrough extracts response text (content blocks + raw content array
for tool_use) into a synthetic ChatResponse.
- Streaming: both the cross-provider SSE encoder path and the verbatim
Anthropic byte-passthrough accumulate assistant text and run the
guardrail at end-of-stream. Bytes are forwarded live (matching
/v1/chat/completions and LiteLLM's streaming guardrail), so a block is
signalled with a terminal Anthropic `error` (content_filter) event.
Completes the output side of #448#22; with this and the earlier input +
budget work, /v1/messages no longer bypasses the guardrail/quota
pipeline. The remaining findings (#6 count_tokens, #2/#13
reasoning_content, #24 guardrail-vs-rate-limit ordering) are accepted as
standard behavior (LiteLLM has the same gap).
Fixes#448
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@moonming