Skip to content

Add in-memory cache infrastructure#98

Merged
graphite-app[bot] merged 1 commit into
mainfrom
in-memory-cache-infra
May 6, 2026
Merged

Add in-memory cache infrastructure#98
graphite-app[bot] merged 1 commit into
mainfrom
in-memory-cache-infra

Conversation

@findolor
Copy link
Copy Markdown
Collaborator

@findolor findolor commented May 4, 2026

Motivation

PR #54 mixes reusable cache infrastructure with endpoint behavior changes. This PR keeps the first stack item narrow by adding only the in-memory caching primitives on top of main.

Solution

  • Add moka with the async future feature.
  • Add AppCache<K, V> as a crate-local wrapper for TTL/capacity bounded in-memory caches.
  • Add get_or_try_insert for async fallible cache population with request coalescing.
  • Add CacheGroup for grouped invalidation across registered caches.
  • Cover hit, miss, invalidation, error, and concurrent miss behavior in unit tests.

Checks

  • nix develop -c cargo fmt
  • nix develop -c cargo test cache
  • nix develop -c rainix-rs-static

Summary by CodeRabbit

  • Tests

    • Added comprehensive async unit tests for cache behavior: insert/get, TTL invalidation, concurrent miss coalescing, error handling (non-caching), and group-wide invalidation.
  • Chores

    • Introduced an internal async TTL cache with configurable capacity, automatic expiration, request coalescing to avoid duplicate fetches, and multi-cache invalidation support to improve performance and consistency.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 4, 2026

📝 Walkthrough

Walkthrough

Adds an async, TTL-capable caching layer using moka with an AppCache<K, V> wrapper, a CacheGroup for collective invalidation, integrates the module into the crate, and provides Rocket async tests covering behavior and concurrent miss coalescing. (49 words)

Changes

Caching Layer

Layer / File(s) Summary
Dependency Addition
Cargo.toml
Adds moka = { version = "0.12", features = ["future"] } to enable async caching.
Cache Implementation
src/cache.rs
Adds AppCache<K, V> wrapper around moka::future::Cache with new(max_capacity, ttl), async get(&K) -> Option<V), async insert(K, V), get_or_try_insert(key, fetch) -> Result<V, Arc<E>> (uses try_get_with for concurrent miss coalescing), and invalidate_all().
Cache Grouping
src/cache.rs
Adds Invalidatable trait and CacheGroup to register heterogeneous caches and call invalidate_all() across them.
Module Integration
src/main.rs
Adds mod cache; to include the new module in the crate.
Tests
src/cache.rs
Adds Rocket async unit tests validating insert/get, missing-key behavior, invalidation, fetch-on-miss, no-fetch-on-hit, error non-caching, concurrent miss coalescing, and group-wide invalidation.

Sequence Diagram

sequenceDiagram
    participant Client as Client Code
    participant AppCache as AppCache<K, V>
    participant Moka as moka::future::Cache
    participant FetchFn as Fetch Function

    Note over Client,FetchFn: Cache get_or_try_insert flow (concurrent miss coalescing)

    par Request 1
        Client->>AppCache: get_or_try_insert(key, fetch_fn)
    and Request 2
        Client->>AppCache: get_or_try_insert(key, fetch_fn)
    end

    Note over AppCache: Both requests observe cache miss

    AppCache->>Moka: try_get_with(key, fetch_fn)
    Note over AppCache,Moka: try_get_with coalesces concurrent fetches for same key

    Moka->>FetchFn: invoke fetch_fn() once
    FetchFn-->>Moka: Result<V, E>

    Moka->>Moka: insert value if Ok
    Moka-->>AppCache: Result<V, Arc<E>>
    AppCache-->>Client: Result<V, Arc<E>>
    AppCache-->>Client: Result<V, Arc<E>>
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A little cache hops into my crate,
Coalescing misses—oh how great!
One fetch for many, stored with care,
TTL ticks softly in the air.
Cached and quick, the rabbit's share.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.06% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: introducing in-memory cache infrastructure (AppCache, CacheGroup, and moka dependency) across three files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch in-memory-cache-infra

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Collaborator Author

findolor commented May 4, 2026


How to use the Graphite Merge Queue

Add the label add-to-gt-merge-queue to this PR to add it to the merge queue.

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has enabled the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

This stack of pull requests is managed by Graphite. Learn more about stacking.

@findolor findolor marked this pull request as ready for review May 4, 2026 11:19
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
src/cache.rs (2)

66-68: ⚡ Quick win

Consider implementing Default for CacheGroup.

Since new() takes no arguments and returns Self with a trivially constructible state, Clippy's new_without_default lint will fire here. The fix is a one-liner.

♻️ Proposed fix
+impl Default for CacheGroup {
+    fn default() -> Self {
+        Self::new()
+    }
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cache.rs` around lines 66 - 68, Implement Default for CacheGroup and have
new() delegate to Default::default(); specifically, add an impl Default for
CacheGroup that returns Self { caches: Vec::new() } and change or keep
pub(crate) fn new() -> Self to call Self::default() (or replace it entirely).
This addresses the clippy new_without_default lint by providing a canonical
Default implementation for CacheGroup and ensures new() remains available and
trivial.

107-110: ⚡ Quick win

Prefer run_pending_tasks() over yield_now() after invalidate_all() in tests.

tokio::task::yield_now() cedes the scheduler for one tick, but does not guarantee that moka's internal write-op queue has been drained. Maintenance tasks are executed lazily when specific bounded-channel conditions are met, so a single yield may not be sufficient under different scheduler configurations or future moka internals. run_pending_tasks().await is moka's explicit API for flushing pending maintenance in tests, making the assertions unconditionally reliable. Because the tests are in the same module, cache.0 is accessible.

♻️ Proposed fix (applies to both affected tests)
-        tokio::task::yield_now().await;
+        cache.0.run_pending_tasks().await;

and for the group test:

-        tokio::task::yield_now().await;
+        cache_a.0.run_pending_tasks().await;
+        cache_b.0.run_pending_tasks().await;

Also applies to: 183-185

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cache.rs` around lines 107 - 110, The test uses
tokio::task::yield_now().await after calling cache.invalidate_all(), which does
not guarantee moka's internal maintenance/write-op queue is flushed; replace the
yield with calling the crate's test helper to flush pending maintenance by
awaiting run_pending_tasks() via cache.0 (e.g.,
cache.0.run_pending_tasks().await) so the subsequent assertions on
cache.get(&"a").await and cache.get(&"b").await are reliably valid; apply the
same replacement in the other affected test where invalidate_all() is followed
by yield_now().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/cache.rs`:
- Around line 66-68: Implement Default for CacheGroup and have new() delegate to
Default::default(); specifically, add an impl Default for CacheGroup that
returns Self { caches: Vec::new() } and change or keep pub(crate) fn new() ->
Self to call Self::default() (or replace it entirely). This addresses the clippy
new_without_default lint by providing a canonical Default implementation for
CacheGroup and ensures new() remains available and trivial.
- Around line 107-110: The test uses tokio::task::yield_now().await after
calling cache.invalidate_all(), which does not guarantee moka's internal
maintenance/write-op queue is flushed; replace the yield with calling the
crate's test helper to flush pending maintenance by awaiting run_pending_tasks()
via cache.0 (e.g., cache.0.run_pending_tasks().await) so the subsequent
assertions on cache.get(&"a").await and cache.get(&"b").await are reliably
valid; apply the same replacement in the other affected test where
invalidate_all() is followed by yield_now().

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ba9c0eae-e355-4723-b2c5-6d67d433b529

📥 Commits

Reviewing files that changed from the base of the PR and between 2ca2a6b and 753da6a.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (3)
  • Cargo.toml
  • src/cache.rs
  • src/main.rs

@findolor findolor self-assigned this May 4, 2026
@findolor findolor requested review from JuaniRios and hardyjosh May 4, 2026 12:12
@findolor findolor requested a review from 0xgleb May 6, 2026 07:47
@graphite-app
Copy link
Copy Markdown

graphite-app Bot commented May 6, 2026

Merge activity

## Motivation
PR #54 mixes reusable cache infrastructure with endpoint behavior changes. This PR keeps the first stack item narrow by adding only the in-memory caching primitives on top of `main`.

## Solution
- Add `moka` with the async `future` feature.
- Add `AppCache<K, V>` as a crate-local wrapper for TTL/capacity bounded in-memory caches.
- Add `get_or_try_insert` for async fallible cache population with request coalescing.
- Add `CacheGroup` for grouped invalidation across registered caches.
- Cover hit, miss, invalidation, error, and concurrent miss behavior in unit tests.

## Checks
- [x] `nix develop -c cargo fmt`
- [x] `nix develop -c cargo test cache`
- [x] `nix develop -c rainix-rs-static`

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **Tests**
  * Included comprehensive unit tests for cache functionality covering insert/get operations, TTL-based invalidation, concurrent miss coalescing, error handling, and group-wide cache invalidation.

* **Chores**
  * Introduced an internal caching layer with configurable capacity, automatic time-based entry expiration, concurrent request optimization, and multi-cache management capabilities.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
@graphite-app graphite-app Bot force-pushed the in-memory-cache-infra branch from 753da6a to 27c67e9 Compare May 6, 2026 09:51
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
src/cache.rs (3)

51-59: ⚖️ Poor tradeoff

Invalidatable is implemented on the inner Cache, not on AppCache — bypasses the abstraction.

CacheGroup stores clones of the inner moka::future::Cache directly, so any logic that might be added to AppCache::invalidate_all in the future (e.g. tracing events, metrics) would be silently bypassed. Implementing Invalidatable on AppCache<K,V> itself and adjusting register to accept something like Arc<AppCache<K,V>> would keep the group operating through the intended abstraction boundary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cache.rs` around lines 51 - 59, The current impl of Invalidatable is on
Cache<K,V> which lets CacheGroup operate on the inner moka::future::Cache and
bypass AppCache logic; change the impl to target AppCache<K,V> (implement
Invalidatable for AppCache<K,V> with its invalidate_all calling
AppCache::invalidate_all) and update CacheGroup::register (and any call sites)
to accept Arc<AppCache<K,V>> instead of clones of the inner cache so CacheGroup
stores and calls invalidate_all via the AppCache abstraction, preserving future
metrics/tracing hooks.

65-68: 💤 Low value

CacheGroup::new() should implement Default.

new() on a plain empty struct with no constructor arguments is idiomatic Rust only when Default is also implemented (or derived). Consider adding #[derive(Default)] or a manual Default impl.

✏️ Proposed change
+#[derive(Default)]
 pub(crate) struct CacheGroup {
     caches: Vec<Arc<dyn Invalidatable>>,
 }

 impl CacheGroup {
-    pub(crate) fn new() -> Self {
-        Self { caches: Vec::new() }
-    }
+    pub(crate) fn new() -> Self {
+        Self::default()
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cache.rs` around lines 65 - 68, CacheGroup::new() constructs an empty
CacheGroup but CacheGroup lacks a Default implementation; add Default for
CacheGroup so callers can use CacheGroup::default() and derive/impl it
consistently. Modify the CacheGroup definition to either add #[derive(Default)]
or implement impl Default for CacheGroup { fn default() -> Self { Self { caches:
Vec::new() } } }, and keep or forward CacheGroup::new() to call Self::default()
(or remove new() if you prefer the derived default).

42-44: ⚡ Quick win

Document the eventual-consistency of invalidate_all.

moka::future::Cache::invalidate_all schedules removal rather than evicting entries synchronously. Entries can still be read immediately after this call until the runtime is given a chance to run pending tasks. The tests already account for this with tokio::task::yield_now().await, but callsites in production code may not know they need to yield before expecting a clean state. A doc comment on the method would make this contract explicit.

✏️ Suggested doc comment
+    /// Schedules invalidation of all entries. Entries may still be visible
+    /// immediately after this call; yield to the async runtime
+    /// (e.g. `tokio::task::yield_now().await`) before expecting an empty cache.
     pub(crate) fn invalidate_all(&self) {
         self.0.invalidate_all()
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cache.rs` around lines 42 - 44, Add a doc comment to the pub(crate) fn
invalidate_all(&self) method noting that it delegates to
moka::future::Cache::invalidate_all which schedules removals (eventual
consistency) rather than synchronously evicting entries, so entries may still be
readable immediately after the call until the runtime runs pending tasks; advise
callers that require a clean state to await the runtime (e.g.,
tokio::task::yield_now().await) or use a synchronous/blocking invalidate
alternative if available.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/cache.rs`:
- Around line 51-59: The current impl of Invalidatable is on Cache<K,V> which
lets CacheGroup operate on the inner moka::future::Cache and bypass AppCache
logic; change the impl to target AppCache<K,V> (implement Invalidatable for
AppCache<K,V> with its invalidate_all calling AppCache::invalidate_all) and
update CacheGroup::register (and any call sites) to accept Arc<AppCache<K,V>>
instead of clones of the inner cache so CacheGroup stores and calls
invalidate_all via the AppCache abstraction, preserving future metrics/tracing
hooks.
- Around line 65-68: CacheGroup::new() constructs an empty CacheGroup but
CacheGroup lacks a Default implementation; add Default for CacheGroup so callers
can use CacheGroup::default() and derive/impl it consistently. Modify the
CacheGroup definition to either add #[derive(Default)] or implement impl Default
for CacheGroup { fn default() -> Self { Self { caches: Vec::new() } } }, and
keep or forward CacheGroup::new() to call Self::default() (or remove new() if
you prefer the derived default).
- Around line 42-44: Add a doc comment to the pub(crate) fn
invalidate_all(&self) method noting that it delegates to
moka::future::Cache::invalidate_all which schedules removals (eventual
consistency) rather than synchronously evicting entries, so entries may still be
readable immediately after the call until the runtime runs pending tasks; advise
callers that require a clean state to await the runtime (e.g.,
tokio::task::yield_now().await) or use a synchronous/blocking invalidate
alternative if available.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6185e05f-9d58-4b75-89aa-351696bf9460

📥 Commits

Reviewing files that changed from the base of the PR and between 753da6a and 27c67e9.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (3)
  • Cargo.toml
  • src/cache.rs
  • src/main.rs
✅ Files skipped from review due to trivial changes (1)
  • src/main.rs

@graphite-app graphite-app Bot merged commit 27c67e9 into main May 6, 2026
5 checks passed
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.

3 participants