Skip to content

refactor(cache): extract write-through get-or-load helper (#33) - #49

Open
1Git2Clone wants to merge 4 commits into
mainfrom
refactor/cache-write-through
Open

refactor(cache): extract write-through get-or-load helper (#33)#49
1Git2Clone wants to merge 4 commits into
mainfrom
refactor/cache-write-through

Conversation

@1Git2Clone

Copy link
Copy Markdown
Owner

Closes part of #33 (Pattern 1 only). Pattern 2 — the add/list/remove command scaffold dedup — is intentionally left for a separate pass; per the issue's own suggestion, /ai note (#32) can now be built on top of this helper instead of becoming a fourth copy.

What

Adds one shared helper, cache::write_through::get_or_load, that owns the get-or-load / write-back / log-on-error scaffolding. Three call sites repeat the same flow today (DB-authoritative, write-through, optional negative-cache sentinel); they differ only in cache shape (set / string / hash), key naming, TTL, and the negative-sentinel convention — and those stay at the call site, exactly as the issue's "don't paper over the differences" rule requires.

Migrated call sites

  • data::ai::channels::is_ai_channel — set membership, no TTL.
  • data::ai::guild_prompt::get_guild_prompt — string with 1800s TTL, empty-string as the no-prompt sentinel.
  • data::custom_reactions::matching — per-guild hash, with the cr:guilds SISMEMBER short-circuit kept above the helper. The MULTI/EXEC hash rebuild on register/remove (reseed_guild_cache) stays as-is — it's a write-through-with-rebuild, not a get-or-load.

Helper shape

pub async fn get_or_load<T, LoadFut>(
    read_cache: impl for<'a> FnOnce(&'a mut ConnectionManager) -> Pin<Box<dyn Future<Output = Result<Option<T>, RedisError>> + Send + 'a>>,
    load_from_db: impl FnOnce() -> LoadFut + Send,
    write_cache: impl for<'a> FnOnce(&'a mut ConnectionManager, &'a T) -> Pin<Box<dyn Future<Output = Result<(), RedisError>> + Send + 'a>>,
) -> Result<T, Error>

Cache hit → return; miss → load DB → write back (best-effort, logged) → return. Cache read error → logged, falls through to DB. DB error → propagates. No-Redis → loads DB directly. Negative sentinels (empty string for no prompt) are encoded by the caller as Some("") and translated back to None at the boundary, keeping the helper shape-agnostic.

The lifetime bound is the lightest form that compiles when closures both borrow the connection and capture locals — three call sites and the unit tests fit cleanly, so redesign isn't warranted yet. If a fourth call site ever lands, that's the signal to revisit.

Verification

  • cargo fmt --check
  • cargo clippy --all-targets -- -D warnings × {no features, --features "opentelemetry ai-openrouter" (deployed set per scripts/deploy-features.sh), --all-features}
  • cargo test --all-features --lib → 75 passed, 0 failed
  • New helper unit tests: cache_hit_skips_db, cache_miss_loads_from_db_and_writes_back, db_load_failure_propagates_and_cache_is_not_written — all green against real Redis

Out of scope

Three call sites (channels, guild_prompt, custom_reactions) repeat the
same get-or-load flow: Redis read, DB on miss, write-back best-effort.
The cache shape (set/string/hash, TTL, negative sentinel) varies per
feature and stays at the call site; the helper owns only the scaffolding.

Future commits migrate each call site to it.
is_ai_channel now uses the shared get-or-load helper. A confirmed DB
hit (channel is registered) is written back to the Redis set, so
subsequent reads stay off the DB — a minor behaviour upgrade from the
prior read-only path.
get_guild_prompt now uses the shared get-or-load helper. The empty-string
negative sentinel and 1800s TTL live in the call site as before; the
helper handles cache read, DB fallback, write-back, and log-on-error.
…_through

The cr:guilds short-circuit stays above the helper (the SISMEMBER is a
different access shape than the HGETALL it gates). The HGETALL-on-miss
+ write-back is now in the helper. The hash rebuild on register/remove
stays in place — that's a write-through-with-rebuild, not a simple
get-or-load, and is exactly the carve-out the plan calls out.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 65.80311% with 66 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/data/cache.rs 79.66% 24 Missing ⚠️
src/data/custom_reactions.rs 29.41% 24 Missing ⚠️
src/data/ai/channels.rs 34.78% 15 Missing ⚠️
src/data/ai/guild_prompt.rs 83.33% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jul 6, 2026

Copy link
Copy Markdown

Greptile Summary

Extracts a shared cache::write_through::get_or_load helper that owns the cache-read → DB-fallback → write-back → log-on-error flow, then migrates the three existing call sites (is_ai_channel, get_guild_prompt, matching) onto it. Shape differences (set vs. string vs. hash, TTL, negative-sentinel encoding) remain at each call site.

  • The helper logic and all three migrations are semantically correct; the guild_prompt empty-string sentinel and channels positive-only write-back both round-trip cleanly through the new shape.
  • The custom_reactions write-back on cache-error recovery uses field-by-field HSET instead of the atomic MULTI/EXEC used by reseed_guild_cache, creating a narrow race with concurrent mutations on that exceptional path.
  • The channels.rs doc comment incorrectly claims the DB is queried on a clean set-miss; the DB is only reached on Redis errors.

Confidence Score: 4/5

Safe to merge; all three call sites are behaviourally equivalent to the pre-refactor code on the hot path, with write-back as a bounded improvement on error recovery.

The helper and its migrations are correct. The one structural concern is the field-by-field HSET in the custom-reactions write-back, which can race with a concurrent MULTI/EXEC reseed during Redis-error recovery and briefly leave a mixed-state hash — a narrow window that self-corrects on the next mutation. The channels doc comment misstates when the DB is consulted. Neither issue affects the normal hot path.

src/data/custom_reactions.rs — the write-back atomicity on the cache-error path; src/data/ai/channels.rs — the doc comment correction.

Important Files Changed

Filename Overview
src/data/cache.rs Adds write_through::get_or_load helper with three unit tests; helper logic is correct but generic warning logs lose call-site structured fields.
src/data/custom_reactions.rs Migrates HGETALL/DB-fallback path to get_or_load; write-back on error recovery is non-atomic (field-by-field HSET) vs. the MULTI/EXEC reseed used elsewhere — bounded race on an exceptional path.
src/data/ai/channels.rs Migrates is_ai_channel to get_or_load; doc comment incorrectly states DB is queried when channel isn't in the set (it isn't — clean negatives are returned directly).
src/data/ai/guild_prompt.rs Migrates get_guild_prompt to get_or_load; sentinel handling (""None) and TTL preserved correctly; one unnecessary key clone.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant get_or_load
    participant Redis
    participant DB

    Caller->>get_or_load: T, read_cache, load_from_db, write_cache
    alt Redis unavailable
        get_or_load->>DB: load_from_db()
        DB-->>get_or_load: Ok(T) or Err
        get_or_load-->>Caller: "Result<T>"
    else Redis available
        get_or_load->>Redis: "read_cache(&mut conn)"
        alt Cache hit (Ok(Some(T)))
            Redis-->>get_or_load: Ok(Some(value))
            get_or_load-->>Caller: Ok(value)
        else Cache miss (Ok(None))
            Redis-->>get_or_load: Ok(None)
            get_or_load->>DB: load_from_db()
            DB-->>get_or_load: Ok(T)
            get_or_load->>Redis: "write_cache(&mut conn, &value) [best-effort]"
            get_or_load-->>Caller: Ok(value)
        else Cache read error (Err)
            Redis-->>get_or_load: Err(e)
            Note over get_or_load: tracing::warn logged
            get_or_load->>DB: load_from_db()
            DB-->>get_or_load: Ok(T)
            get_or_load->>Redis: "write_cache(&mut conn, &value) [best-effort]"
            get_or_load-->>Caller: Ok(value)
        end
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant get_or_load
    participant Redis
    participant DB

    Caller->>get_or_load: T, read_cache, load_from_db, write_cache
    alt Redis unavailable
        get_or_load->>DB: load_from_db()
        DB-->>get_or_load: Ok(T) or Err
        get_or_load-->>Caller: "Result<T>"
    else Redis available
        get_or_load->>Redis: "read_cache(&mut conn)"
        alt Cache hit (Ok(Some(T)))
            Redis-->>get_or_load: Ok(Some(value))
            get_or_load-->>Caller: Ok(value)
        else Cache miss (Ok(None))
            Redis-->>get_or_load: Ok(None)
            get_or_load->>DB: load_from_db()
            DB-->>get_or_load: Ok(T)
            get_or_load->>Redis: "write_cache(&mut conn, &value) [best-effort]"
            get_or_load-->>Caller: Ok(value)
        else Cache read error (Err)
            Redis-->>get_or_load: Err(e)
            Note over get_or_load: tracing::warn logged
            get_or_load->>DB: load_from_db()
            DB-->>get_or_load: Ok(T)
            get_or_load->>Redis: "write_cache(&mut conn, &value) [best-effort]"
            get_or_load-->>Caller: Ok(value)
        end
    end
Loading

Fix All in Claude Code

Reviews (1): Last reviewed commit: "refactor(custom_reactions): route matchi..." | Re-trigger Greptile

Comment thread src/data/ai/channels.rs
Comment on lines 21 to +25
/// Check whether a channel has AI auto-replies enabled. This runs per
/// message, so a Redis answer (hit or miss) is trusted; the DB is only
/// queried when Redis is unavailable or the call errors.
/// queried when Redis is unavailable, the call errors, or the channel isn't
/// in the set. A confirmed DB hit is written back to Redis so subsequent
/// reads stay off the DB.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 The updated doc comment says "the DB is only queried when Redis is unavailable, the call errors, or the channel isn't in the set" — but the third clause is wrong. When set_contains returns Ok(false) (channel not a member), read_cache returns Ok(Some(false)), which get_or_load treats as a cache hit and returns immediately. The DB is only consulted on Redis errors or unavailability, not on a clean negative result. This was also true of the pre-refactor code.

Suggested change
/// Check whether a channel has AI auto-replies enabled. This runs per
/// message, so a Redis answer (hit or miss) is trusted; the DB is only
/// queried when Redis is unavailable or the call errors.
/// queried when Redis is unavailable, the call errors, or the channel isn't
/// in the set. A confirmed DB hit is written back to Redis so subsequent
/// reads stay off the DB.
/// Check whether a channel has AI auto-replies enabled. This runs per
/// message, so a Redis answer (hit or miss) is trusted; the DB is only
/// queried when Redis is unavailable or the call errors. A confirmed DB hit
/// is written back to Redis so subsequent reads stay off the DB.

Fix in Claude Code

Comment on lines +495 to +503
move |c, pairs| {
let pairs = pairs.clone();
Box::pin(async move {
for (field, value) in &pairs {
cache::hash_set(c, &meta_for_write, field, value).await?;
}
Ok(())
})
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Non-atomic write-back vs. MULTI/EXEC reseed

The write_cache closure calls hash_set field-by-field, while the authoritative reseed_guild_cache uses pipe().atomic() (MULTI/EXEC with a leading DEL). If a concurrent register or remove triggers reseed_guild_cache between two of the individual HSET calls here, the reseed's DEL will clear the hash and rebuild it from the post-mutation DB state; then the remaining HSET calls from this write-back will silently overwrite some fields with stale pre-mutation data. The hash stays inconsistent until the next mutation re-runs reseed_guild_cache. This race only opens on the cache-error recovery path, so the window is narrow, but the fix is to either pipeline the HSETs atomically or skip the write-back on error recovery and let the next register/remove reseed.

Fix in Claude Code

Comment on lines 18 to +20
let key = prompt_key(guild_id);
if let Ok(Some(cached)) = cache::get_string(&mut conn, &key).await {
// An empty string is the negative-cache sentinel for "no prompt set".
return (!cached.is_empty()).then_some(cached);
}

// Cache miss (or read error): consult the DB and write the result back. Only
// a value the DB actually returned is cached, so a transient DB error isn't.
// A reader that races a concurrent mutation can repopulate a stale value for
// up to PROMPT_TTL_SECS; mod prompt changes are rare and the staleness is
// bounded, so that window is accepted rather than guarded with versioning.
let prompt = match GuildAiSettingsTable::fetch(pool, guild_id).await {
Ok(prompt) => prompt,
Err(e) => {
tracing::warn!(error = %e, guild_id, "Failed to fetch guild AI prompt");
return None;
}
};
if let Err(e) = cache::set_string_ex(
&mut conn,
&key,
prompt.as_deref().unwrap_or(""),
PROMPT_TTL_SECS,
let key_for_read = key.clone();
let key_for_write = key.clone();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 One of the three key bindings is redundant — key itself can serve as key_for_read (or vice versa), saving one clone call.

Suggested change
let key = prompt_key(guild_id);
if let Ok(Some(cached)) = cache::get_string(&mut conn, &key).await {
// An empty string is the negative-cache sentinel for "no prompt set".
return (!cached.is_empty()).then_some(cached);
}
// Cache miss (or read error): consult the DB and write the result back. Only
// a value the DB actually returned is cached, so a transient DB error isn't.
// A reader that races a concurrent mutation can repopulate a stale value for
// up to PROMPT_TTL_SECS; mod prompt changes are rare and the staleness is
// bounded, so that window is accepted rather than guarded with versioning.
let prompt = match GuildAiSettingsTable::fetch(pool, guild_id).await {
Ok(prompt) => prompt,
Err(e) => {
tracing::warn!(error = %e, guild_id, "Failed to fetch guild AI prompt");
return None;
}
};
if let Err(e) = cache::set_string_ex(
&mut conn,
&key,
prompt.as_deref().unwrap_or(""),
PROMPT_TTL_SECS,
let key_for_read = key.clone();
let key_for_write = key.clone();
let key_for_read = prompt_key(guild_id);
let key_for_write = key_for_read.clone();

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Comment thread src/data/cache.rs
Comment on lines +306 to +313
if let Err(e) = write_cache(&mut conn, &value).await {
tracing::warn!(error = %e, "write_through: cache write-back failed");
}

Ok(value)
}

#[cfg(test)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Generic warnings lose call-site context

Both warning messages emitted by the helper — "write_through: cache read failed, falling back to DB" and "write_through: cache write-back failed" — carry only the Redis error. The call sites that used to log these paths included structured fields like guild_id (e.g. the old "Failed to cache guild AI prompt" warning). With the refactor those fields are no longer available to the helper, so a cache write-back failure in production gives no indication of which guild or feature triggered it. Consider documenting that callers relying on field-level log correlation should add a surrounding span or tracing instrument to preserve that context.

Fix in Claude Code

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant