Skip to content

Commit c1c6a2b

Browse files
authored
Merge pull request #23 from splitwise/ar/docs
Add gem badge and architecture document
2 parents ae64cf7 + 68c5589 commit c1c6a2b

File tree

2 files changed

+181
-1
lines changed

2 files changed

+181
-1
lines changed

ARCHITECTURE.md

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# Architecture
2+
3+
This document explains how Cacheable works internally. After reading it you should be able to trace any method call through the caching layer and know which file to open when something needs to change.
4+
5+
## File Map
6+
7+
```
8+
lib/
9+
├── cacheable.rb # Entry point. Defines the Cacheable module and its `included` hook.
10+
├── cacheable/
11+
│ ├── version.rb # VERSION constant (MAJOR.MINOR.TINY.PRE)
12+
│ ├── method_generator.rb # Generates the five interceptor methods per cached method
13+
│ ├── cache_adapter.rb # Adapter protocol: resolution, assignment, and fallback logic
14+
│ ├── cache_adapters.rb # Registry that maps adapter names (symbols) to classes
15+
│ └── cache_adapters/
16+
│ └── memory_adapter.rb # Built-in thread-safe hash-backed cache
17+
spec/
18+
├── spec_helper.rb
19+
├── cacheable/
20+
│ ├── cacheable_spec.rb # Integration tests for the full caching lifecycle
21+
│ ├── cache_adapter_spec.rb # Tests for adapter resolution and assignment
22+
│ └── cache_adapters/
23+
│ └── memory_adapter_spec.rb # Tests for the in-memory cache backend
24+
examples/
25+
├── simple_example.rb # Minimal usage
26+
├── custom_key_example.rb # Custom key_format proc
27+
├── conditional_example.rb # unless: option
28+
├── class_method_example.rb # Caching class methods via eigenclass
29+
└── memoize_example.rb # memoize: true with a logging adapter wrapper
30+
```
31+
32+
## The Big Idea
33+
34+
Cacheable uses **module prepending** to insert a caching layer in front of your methods. When you write:
35+
36+
```ruby
37+
class Foo
38+
include Cacheable
39+
cacheable :bar
40+
def bar = "hello"
41+
end
42+
```
43+
44+
Ruby's method lookup for `Foo#bar` becomes:
45+
46+
```
47+
Foo instance
48+
→ FooCacher (prepended interceptor module) # calls cache or original
49+
→ Foo (the class itself) # your actual def bar
50+
→ Object / BasicObject
51+
```
52+
53+
The interceptor module sits in front of the class in the ancestor chain, so calling `bar` hits the interceptor first. The interceptor decides whether to serve a cached value or call `super` (your original method).
54+
55+
## What Happens When You `include Cacheable`
56+
57+
**File: `lib/cacheable.rb``Cacheable.included(base)`**
58+
59+
Three things happen when a class includes Cacheable:
60+
61+
1. **Extend with `CacheAdapter`** — gives the class `.cache_adapter` and `.cache_adapter=` so it can optionally override the global adapter.
62+
2. **Extend with `MethodGenerator`** — gives the class the `.cacheable` class method used to declare which methods to cache.
63+
3. **Create and prepend an interceptor module** — an anonymous `Module.new` is created, stored as `@_cacheable_interceptor`, and prepended to the class. All generated methods are defined on this module, not on the class itself. The module gets a readable name like `"FooCacher"` via custom `to_s`/`inspect`.
64+
65+
The interceptor is unique per class. If `Foo` and `Bar` both include Cacheable, they each get their own interceptor module with their own generated methods.
66+
67+
## What Happens When You Call `cacheable :method_name`
68+
69+
**File: `lib/cacheable/method_generator.rb``MethodGenerator#cacheable`**
70+
71+
This is a class-level method (added via `extend`). It accepts one or more method names and an options hash. For each method name, it calls `create_cacheable_methods`, which defines **five methods** on the interceptor module:
72+
73+
### Generated Methods
74+
75+
Given `cacheable :star_count`, the following are defined on the interceptor:
76+
77+
| Method | Purpose |
78+
|---|---|
79+
| `star_count` | **Dispatcher.** Checks the `unless:` condition. Routes to `_with_cache` or `_without_cache`. |
80+
| `star_count_with_cache` | **Cache path.** Checks memoization, then calls `adapter.fetch(key) { original }`. |
81+
| `star_count_without_cache` | **Bypass path.** Calls `method(:star_count).super_method.call(...)` to skip straight to the original. |
82+
| `star_count_key_format` | **Key generator.** Calls the `key_format:` proc (or the default) to produce a cache key. |
83+
| `clear_star_count_cache` | **Invalidation.** Deletes the key from the adapter (and from the memoization hash if applicable). |
84+
85+
Methods ending in `?`, `!`, or `=` are handled correctly — the punctuation is moved to the end of each generated name (e.g., `valid?` produces `valid_with_cache?`, `clear_valid_cache?`).
86+
87+
### How `_with_cache` Works (the hot path)
88+
89+
```
90+
star_count called
91+
→ dispatcher checks unless: proc
92+
→ star_count_with_cache
93+
1. Compute cache key via star_count_key_format
94+
2. If memoize: true, check @_cacheable_memoized[method][key]
95+
→ hit: return immediately (adapter is never touched)
96+
3. Call adapter.fetch(key, cache_options) { star_count_without_cache(...) }
97+
→ adapter hit: return deserialized value
98+
→ adapter miss: execute the block (original method), adapter stores result
99+
4. If memoize: true, store result in @_cacheable_memoized[method][key]
100+
5. Return result
101+
```
102+
103+
### How the Original Method Is Reached
104+
105+
`_without_cache` calls `method(:star_count).super_method.call(...)`. Because the interceptor is prepended, `method(:star_count)` resolves to the interceptor's version, and `.super_method` walks up to the class's own definition. This is how `super` works through the prepend chain.
106+
107+
### The `unless:` Option
108+
109+
The `unless:` option accepts a proc or a symbol (converted to a proc via `.to_proc`). It receives `(target, method_name, args, **kwargs)`. When it returns truthy, the dispatcher routes to `_without_cache`, skipping the cache entirely.
110+
111+
### Default Key Format
112+
113+
When no `key_format:` is provided, the default proc builds `[cache_key || class_name, method_name]`. If the object responds to `cache_key` (as ActiveRecord models do), that value is used; otherwise the class name string is used. Arguments are **not** included — if the method is called with arguments, a one-time warning is printed to stderr.
114+
115+
### The `memoize:` Option
116+
117+
When enabled, a per-instance hash (`@_cacheable_memoized`) stores deserialized values keyed by `[method_name][cache_key]`. This avoids repeated adapter `fetch` calls (and any deserialization cost) for the lifetime of the object. A sentinel value (`Cacheable::MEMOIZE_NOT_SET`) distinguishes "not yet cached" from a memoized `nil` or `false`. Clearing the cache (`clear_*_cache`) also removes the memoized entry.
118+
119+
## Cache Adapter System
120+
121+
### Protocol
122+
123+
**File: `lib/cacheable/cache_adapter.rb``CacheAdapter`**
124+
125+
Any object that responds to `fetch(key, options, &block)` and `delete(key)` can be a cache adapter. `Rails.cache` satisfies this out of the box.
126+
127+
### Resolution Order
128+
129+
```
130+
class-level @_cache_adapter → Cacheable (global) @_cache_adapter
131+
```
132+
133+
Each class that includes Cacheable can set its own adapter via `self.cache_adapter = ...`. If none is set, it falls back to `Cacheable.cache_adapter` (the global default). The global default is `:memory` unless overridden (e.g., `Cacheable.cache_adapter = Rails.cache`).
134+
135+
### Setting an Adapter
136+
137+
`cache_adapter=` accepts either:
138+
139+
- **A symbol/string** (e.g., `:memory`) — looked up via `CacheAdapters.lookup`, which converts the name to a class (`memory``MemoryAdapter`) and calls `.new`.
140+
- **An object instance** — used directly if it responds to `fetch` and `delete`.
141+
142+
### Adapter Registry
143+
144+
**File: `lib/cacheable/cache_adapters.rb``CacheAdapters.lookup`**
145+
146+
Converts a snake_case name to a PascalCase class name, appends `"Adapter"`, and does a `const_get` inside the `Cacheable::CacheAdapters` namespace. To add a new built-in adapter, define a class like `Cacheable::CacheAdapters::RedisAdapter` and set it with `Cacheable.cache_adapter = :redis`.
147+
148+
### Memory Adapter
149+
150+
**File: `lib/cacheable/cache_adapters/memory_adapter.rb`**
151+
152+
A `Hash` wrapped in a `Monitor` for thread safety. `fetch` yields inside the lock to prevent thundering herd on cache miss (two threads racing to compute the same value). Intended for testing and simple use cases.
153+
154+
## Class Method Caching
155+
156+
Cacheable works on class methods by including it in the eigenclass:
157+
158+
```ruby
159+
class Foo
160+
class << self
161+
include Cacheable
162+
cacheable :bar
163+
end
164+
end
165+
```
166+
167+
This prepends an interceptor onto `Foo`'s singleton class. Inside generated methods, `is_a?(Module)` checks distinguish class-level calls (where the "instance" is the class itself) from regular instance calls, so the correct adapter is resolved via `singleton_class` rather than `self.class`.
168+
169+
## Adding a New Feature — Where to Look
170+
171+
| I want to... | File |
172+
|---|---|
173+
| Change what methods are generated | `lib/cacheable/method_generator.rb``create_cacheable_methods` |
174+
| Change the default cache key | `lib/cacheable/method_generator.rb``default_key_format` |
175+
| Add a new option to `cacheable` | `lib/cacheable/method_generator.rb``opts` hash in `create_cacheable_methods` |
176+
| Change how adapters are resolved | `lib/cacheable/cache_adapter.rb` |
177+
| Add a built-in adapter | `lib/cacheable/cache_adapters/` — new file, require it from `cache_adapters.rb` |
178+
| Change the module prepend behavior | `lib/cacheable.rb``included` hook |
179+
| Write tests | `spec/cacheable/cacheable_spec.rb` for integration, adapter-specific specs in `spec/cacheable/cache_adapters/` |

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Cacheable
22

33
[![CI](https://github.com/splitwise/cacheable/actions/workflows/ci.yml/badge.svg)](https://github.com/splitwise/cacheable/actions/workflows/ci.yml)
4+
[![Gem Version](https://badge.fury.io/rb/cacheable.svg)](https://badge.fury.io/rb/cacheable)
45

56
By [Splitwise](https://www.splitwise.com)
67

@@ -14,7 +15,7 @@ Cacheable is a gem which adds method caching in Ruby following an [aspect-orient
1415

1516
While using Ruby on Rails is not a requirement, Cacheable was built inside a mature Rails app and later extracted. The current release is designed for drop-in support in Rails, and includes an adapter for an in-memory cache backed by a simple hash. This may be enough for your needs, but it's more likely that additional cache adapters will need to be written for other projects.
1617

17-
See more about [Cache Adapters](cache-adapters.md).
18+
See more about [Cache Adapters](cache-adapters.md). For a deep dive into how the code is structured, see [Architecture](ARCHITECTURE.md).
1819

1920
## Getting Started
2021

0 commit comments

Comments
 (0)