Skip to content

Commit 9f9c83b

Browse files
FreekHeijtingclaude
andcommitted
feat: V2.5 new skills P1+P2 batch (8 skills)
P1 skills: - frappe-core-notifications: email, realtime, Assignment Rules, Auto Repeat - frappe-core-files: File DocType, uploads, S3, permissions - frappe-core-cache: Redis, @redis_cache, distributed locking - frappe-impl-website: portal pages, Web Forms, SEO, themes - frappe-impl-ui-components: Dialog, List View, Kanban, realtime, Scanner P2 skills: - frappe-impl-integrations: OAuth, webhooks, Data Import/Export - frappe-ops-bench: 100+ commands, multi-tenancy, site management - frappe-ops-app-lifecycle: (already committed) 16/25 new skills complete. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ec51f9e commit 9f9c83b

30 files changed

Lines changed: 6542 additions & 43 deletions

File tree

skills/source/core/frappe-core-cache/SKILL.md

Lines changed: 350 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,361 @@ metadata:
1414

1515
# Frappe Cache & Locking
1616

17-
> **STATUS**: Stub - content pending V2.5 development phase.
18-
1917
## Quick Reference
2018

21-
_Content to be developed._
19+
| Action | Method | Notes |
20+
|--------|--------|-------|
21+
| Set value | `frappe.cache.set_value(key, val)` | With optional TTL |
22+
| Get value | `frappe.cache.get_value(key)` | Returns `None` if missing |
23+
| Get or generate | `frappe.cache.get_value(key, generator=fn)` | Calls `fn()` on cache miss |
24+
| Delete value | `frappe.cache.delete_value(key)` | Single key or list of keys |
25+
| Delete by pattern | `frappe.cache.delete_keys(pattern)` | Wildcard `*` matching |
26+
| Hash set | `frappe.cache.hset(name, key, val)` | Redis hash field |
27+
| Hash get | `frappe.cache.hget(name, key)` | Single hash field |
28+
| Hash get all | `frappe.cache.hgetall(name)` | Full hash as dict |
29+
| Hash delete | `frappe.cache.hdel(name, key)` | Remove hash field |
30+
| Hash exists | `frappe.cache.hexists(name, key)` | Returns bool |
31+
| Cached document | `frappe.get_cached_doc(dt, dn)` | Full doc from cache |
32+
| Clear doc cache | `frappe.clear_document_cache(dt, dn)` | Invalidate cached doc |
33+
| Decorator cache | `@redis_cache` | Auto-cache function result |
34+
| Request cache | `frappe.local.cache` | Per-request dict (not Redis) |
35+
36+
---
2237

2338
## Decision Tree
2439

25-
_Content to be developed._
40+
```
41+
What caching pattern do you need?
42+
43+
├─ Cache a function result automatically?
44+
│ ├─ Pure function (same args → same result) → @redis_cache
45+
│ └─ Need custom key/TTL → manual get_value/set_value
46+
47+
├─ Cache a document?
48+
│ ├─ Read-only access → frappe.get_cached_doc()
49+
│ └─ Need to invalidate → frappe.clear_document_cache()
50+
51+
├─ Cache structured data (multiple fields)?
52+
│ └─ Redis hash → hset/hget/hgetall
53+
54+
├─ Per-request cache (avoid repeated DB calls in one request)?
55+
│ └─ frappe.local.cache dict
56+
57+
├─ Prevent concurrent execution?
58+
│ └─ Distributed lock → frappe.lock("resource_name")
59+
60+
└─ Invalidate cache?
61+
├─ Single key → delete_value(key)
62+
├─ Pattern → delete_keys("prefix*")
63+
└─ All site cache → frappe.clear_cache()
64+
```
65+
66+
---
67+
68+
## String Operations
69+
70+
### Set and Get
71+
72+
```python
73+
# Set a value (persists until evicted or deleted)
74+
frappe.cache.set_value("exchange_rate_USD", 1.08)
75+
76+
# Set with TTL (expires after N seconds)
77+
frappe.cache.set_value("exchange_rate_USD", 1.08, expires_in_sec=3600)
78+
79+
# Get value (returns None if missing)
80+
rate = frappe.cache.get_value("exchange_rate_USD")
81+
82+
# Get with generator (calls function on cache miss, stores result)
83+
rate = frappe.cache.get_value(
84+
"exchange_rate_USD",
85+
generator=lambda: fetch_exchange_rate("USD"),
86+
)
87+
```
88+
89+
### User-Scoped Values
90+
91+
```python
92+
# Store per-user preference
93+
frappe.cache.set_value("dashboard_layout", "compact", user="user@example.com")
94+
95+
# Retrieve for specific user
96+
layout = frappe.cache.get_value("dashboard_layout", user="user@example.com")
97+
```
98+
99+
### Delete
100+
101+
```python
102+
# Single key
103+
frappe.cache.delete_value("exchange_rate_USD")
104+
105+
# Multiple keys
106+
frappe.cache.delete_value(["exchange_rate_USD", "exchange_rate_EUR"])
107+
108+
# Pattern-based deletion (wildcard)
109+
frappe.cache.delete_keys("exchange_rate*")
110+
```
111+
112+
---
113+
114+
## Hash Operations
115+
116+
Use hashes to group related fields under a single key.
117+
118+
```python
119+
# Set hash fields
120+
frappe.cache.hset("config|notifications", "email_enabled", True)
121+
frappe.cache.hset("config|notifications", "sms_enabled", False)
122+
frappe.cache.hset("config|notifications", "max_retries", 3)
123+
124+
# Get single field
125+
email_on = frappe.cache.hget("config|notifications", "email_enabled")
126+
127+
# Get all fields as dict
128+
config = frappe.cache.hgetall("config|notifications")
129+
# {"email_enabled": True, "sms_enabled": False, "max_retries": 3}
130+
131+
# Delete field
132+
frappe.cache.hdel("config|notifications", "sms_enabled")
133+
134+
# Check existence
135+
exists = frappe.cache.hexists("config|notifications", "email_enabled")
136+
```
137+
138+
### Hash with Generator
139+
140+
```python
141+
# hget with generator — calls function on miss
142+
value = frappe.cache.hget(
143+
"user|permissions",
144+
"user@example.com",
145+
generator=lambda: compute_permissions("user@example.com"),
146+
)
147+
```
148+
149+
---
150+
151+
## @redis_cache Decorator
152+
153+
Automatically cache function return values based on arguments.
154+
155+
```python
156+
from frappe.utils.caching import redis_cache
157+
158+
@redis_cache
159+
def get_item_price(item_code, price_list):
160+
"""Expensive query — cached automatically."""
161+
return frappe.db.get_value("Item Price",
162+
{"item_code": item_code, "price_list": price_list},
163+
"price_list_rate",
164+
)
165+
166+
# First call — hits database, stores in Redis
167+
price = get_item_price("ITEM-001", "Standard Selling")
168+
169+
# Second call — returns from cache
170+
price = get_item_price("ITEM-001", "Standard Selling")
171+
172+
# Clear all cached results for this function
173+
get_item_price.clear_cache()
174+
```
175+
176+
### With TTL
177+
178+
```python
179+
@redis_cache(ttl=300) # expires after 5 minutes
180+
def get_exchange_rate(from_currency, to_currency):
181+
return fetch_rate_from_api(from_currency, to_currency)
182+
```
183+
184+
**Rules for @redis_cache:**
185+
- ALWAYS ensure arguments are hashable (strings, numbers, tuples). NEVER pass dicts or lists as arguments.
186+
- ALWAYS call `.clear_cache()` when underlying data changes.
187+
- NEVER use on functions with side effects — the function will NOT execute on cache hits.
188+
189+
---
190+
191+
## frappe.local.cache — Request-Scoped Cache
192+
193+
`frappe.local.cache` is a plain Python dict that lives for the duration of a single HTTP request. It is NOT stored in Redis.
194+
195+
```python
196+
def get_user_settings():
197+
"""Avoid repeated DB calls within a single request."""
198+
if "user_settings" not in frappe.local.cache:
199+
frappe.local.cache["user_settings"] = frappe.get_doc(
200+
"User Settings", frappe.session.user
201+
)
202+
return frappe.local.cache["user_settings"]
203+
```
204+
205+
Use `frappe.local.cache` when:
206+
- The same data is needed multiple times in one request
207+
- The data does NOT need to persist across requests
208+
- You want zero Redis overhead
209+
210+
---
211+
212+
## Document Caching
213+
214+
```python
215+
# Get cached document (read-only, no permission check)
216+
settings = frappe.get_cached_doc("System Settings")
217+
item = frappe.get_cached_doc("Item", "ITEM-001")
218+
219+
# Invalidate when document changes
220+
frappe.clear_document_cache("Item", "ITEM-001")
221+
222+
# Cached single value
223+
val = frappe.db.get_value("Item", "ITEM-001", "item_name", cache=True)
224+
```
225+
226+
NEVER modify a document returned by `frappe.get_cached_doc()` — it returns a shared reference. Modifications corrupt the cache for all subsequent reads.
227+
228+
---
229+
230+
## Distributed Locking
231+
232+
Prevent concurrent execution of critical sections using Redis-based locks.
233+
234+
```python
235+
# Context manager (recommended)
236+
with frappe.lock("process_payroll"):
237+
# Only one worker executes this block at a time
238+
process_all_salary_slips()
239+
# Lock auto-released on exit
240+
241+
# Manual lock/unlock
242+
frappe.lock("inventory_sync")
243+
try:
244+
sync_inventory()
245+
finally:
246+
frappe.unlock("inventory_sync") # ALWAYS unlock in finally
247+
```
248+
249+
**Rules:**
250+
- ALWAYS use `with frappe.lock()` (context manager) to guarantee release.
251+
- NEVER hold locks for more than a few seconds — long locks cause worker starvation.
252+
- ALWAYS use descriptive lock names to avoid collisions.
253+
254+
---
255+
256+
## Cache Invalidation Patterns
257+
258+
### Pattern 1: TTL-Based (Time-to-Live)
259+
260+
```python
261+
frappe.cache.set_value("dashboard_stats", compute_stats(), expires_in_sec=300)
262+
```
263+
264+
Best for: Data that can be slightly stale (exchange rates, dashboard aggregates).
265+
266+
### Pattern 2: Event-Based Invalidation
267+
268+
```python
269+
# In hooks.py
270+
doc_events = {
271+
"Item Price": {
272+
"on_update": "my_app.cache.invalidate_price_cache",
273+
"on_trash": "my_app.cache.invalidate_price_cache",
274+
}
275+
}
276+
277+
# In my_app/cache.py
278+
def invalidate_price_cache(doc, method):
279+
frappe.cache.delete_keys("item_price*")
280+
# Or clear specific function cache:
281+
# get_item_price.clear_cache()
282+
```
283+
284+
Best for: Data that MUST be fresh immediately after changes.
285+
286+
### Pattern 3: Hybrid (TTL + Event)
287+
288+
```python
289+
@redis_cache(ttl=600)
290+
def get_pricing_rules():
291+
return frappe.get_all("Pricing Rule", fields=["*"])
292+
293+
# Event hook clears cache immediately on change
294+
def on_pricing_rule_update(doc, method):
295+
get_pricing_rules.clear_cache()
296+
```
297+
298+
Best for: Frequently read data with occasional updates.
299+
300+
---
301+
302+
## Common Cache Keys (Internal)
303+
304+
| Key Pattern | Content |
305+
|-------------|---------|
306+
| `doctype::meta::{dt}` | DocType metadata |
307+
| `user_permissions::{user}` | User permission cache |
308+
| `bootinfo::{user}` | User boot info |
309+
| `notifications::{user}` | Notification counts |
310+
| `document_cache::{dt}::{dn}` | Cached document |
311+
312+
NEVER write to internal cache keys directly. ALWAYS use the documented API methods (`get_cached_doc`, `clear_document_cache`, etc.).
313+
314+
---
315+
316+
## Performance Guidelines
317+
318+
1. **ALWAYS set TTL** on cached values that derive from external data — without TTL, stale data persists until manual invalidation or Redis eviction.
319+
2. **NEVER cache large objects** (>1 MB) — Redis uses pickle serialization, and large values increase serialization overhead and memory usage.
320+
3. **ALWAYS use `frappe.local.cache`** for data needed multiple times within a single request — it avoids Redis round-trips entirely.
321+
4. **NEVER use `frappe.clear_cache()`** as a routine invalidation strategy — it clears ALL cache keys for the site, causing a cold-cache performance hit.
322+
5. **ALWAYS prefix custom cache keys** with your app name (e.g., `myapp|exchange_rate`) to avoid collisions with Frappe internals.
323+
324+
---
325+
326+
## Redis Configuration
327+
328+
Default config: `{bench}/config/redis_cache.conf`
329+
330+
| Setting | Default | Description |
331+
|---------|---------|-------------|
332+
| Port | 13000 | Redis cache port |
333+
| Bind | 127.0.0.1 | Listen address |
334+
| maxmemory-policy | allkeys-lru | Eviction policy |
335+
| maxmemory | 256mb | Max memory (adjustable) |
336+
337+
---
338+
339+
## Key Namespacing
340+
341+
All cache keys are automatically prefixed by Frappe with the site name:
342+
343+
```python
344+
# You write:
345+
frappe.cache.set_value("my_key", "value")
346+
347+
# Redis stores:
348+
# "mysite.localhost|my_key"
349+
```
350+
351+
`frappe.cache.make_key(key, user, shared)` handles prefixing. The `shared=True` parameter removes the site prefix for cross-site keys (rare use case).
352+
353+
---
354+
355+
## Version Differences
356+
357+
| Feature | v14 | v15 | v16 |
358+
|---------|-----|-----|-----|
359+
| `frappe.cache.set_value` | Available | Available | Available |
360+
| `@redis_cache` | Not available | Available | Available |
361+
| `@redis_cache(ttl=)` | Not available | Available | Available |
362+
| `frappe.lock` context mgr | Available | Available | Available |
363+
| `frappe.local.cache` | Available | Available | Available |
364+
| `hget` with generator | Available | Available | Available |
365+
366+
---
26367

27368
## See Also
28369

29-
_Cross-references to be added._
370+
- [references/examples.md](references/examples.md) — Cache implementation patterns
371+
- [references/anti-patterns.md](references/anti-patterns.md) — Common cache mistakes
372+
- [references/api-reference.md](references/api-reference.md) — Complete API signatures
373+
- `frappe-core-database` — Database queries that benefit from caching
374+
- `frappe-core-permissions` — User permission caching

0 commit comments

Comments
 (0)