rust-py-cache 0.1.3__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rust_py_cache-0.2.1/CHANGELOG.md +36 -0
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/Cargo.lock +1 -1
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/Cargo.toml +1 -1
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/PKG-INFO +37 -7
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/README.md +36 -6
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/ROADMAP.md +15 -13
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/pyproject.toml +1 -1
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/python/rust_py_cache/__init__.py +1 -1
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/python/rust_py_cache/decorators.py +44 -16
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/src/cache.rs +108 -12
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/src/lib.rs +61 -11
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/src/stats.rs +8 -0
- rust_py_cache-0.2.1/src/sweeper.rs +77 -0
- rust_py_cache-0.2.1/tests/test_eviction.py +79 -0
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/tests/test_features.py +70 -1
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/.github/workflows/ci.yml +0 -0
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/.github/workflows/release.yml +0 -0
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/.gitignore +0 -0
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/PUBLISHING.md +0 -0
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/examples/django_example.py +0 -0
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/examples/fastapi_example.py +0 -0
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/src/entry.rs +0 -0
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/src/ttl.rs +0 -0
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/tests/test_cache_basic.py +0 -0
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/tests/test_delete.py +0 -0
- {rust_py_cache-0.1.3 → rust_py_cache-0.2.1}/tests/test_set_get.py +0 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Todas as mudanças relevantes deste projeto são registradas aqui.
|
|
4
|
+
|
|
5
|
+
O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
|
|
6
|
+
e o versionamento segue [SemVer](https://semver.org/lang/pt-BR/).
|
|
7
|
+
|
|
8
|
+
## [0.2.1] - 2026-08-11
|
|
9
|
+
|
|
10
|
+
### Corrigido
|
|
11
|
+
|
|
12
|
+
- `@cache.cached` sobre função `async def` guardava no cache o objeto coroutine
|
|
13
|
+
em vez do resultado do `await`, quebrando o decorator já na primeira chamada
|
|
14
|
+
com `TypeError: cannot pickle 'coroutine' object`. O decorator agora detecta
|
|
15
|
+
`inspect.iscoroutinefunction` e gera um wrapper `async` que faz o `await`
|
|
16
|
+
antes do `set`. (BACKLOG2 P0-2)
|
|
17
|
+
|
|
18
|
+
## [0.2.0]
|
|
19
|
+
|
|
20
|
+
### Adicionado
|
|
21
|
+
|
|
22
|
+
- Expiração em background via `Sweeper` (thread dedicada com `Weak<T>`,
|
|
23
|
+
encerramento limpo no `Drop`).
|
|
24
|
+
- Evicção por `max_size` com política LRU.
|
|
25
|
+
- Exemplos para FastAPI e Django.
|
|
26
|
+
|
|
27
|
+
## [0.1.x]
|
|
28
|
+
|
|
29
|
+
### Adicionado
|
|
30
|
+
|
|
31
|
+
- `Cache` com `set` / `get(default=...)` / `delete` / `exists` / `keys` /
|
|
32
|
+
`len` / `clear` / `cleanup_expired` / `stats`.
|
|
33
|
+
- TTL por entrada com expiração preguiçosa.
|
|
34
|
+
- `stats()` cumulativo baseado em `AtomicU64`.
|
|
35
|
+
- Decorator de memoização `@cache.cached(ttl=..., key=...)`.
|
|
36
|
+
- Core em Rust sobre `DashMap`, valores serializados com `pickle`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rust-py-cache
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Classifier: Programming Language :: Rust
|
|
5
5
|
Classifier: Programming Language :: Python :: 3
|
|
6
6
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
@@ -63,7 +63,28 @@ cache.len() # approximate size
|
|
|
63
63
|
cache.keys() # list of keys
|
|
64
64
|
cache.cleanup_expired() # remove expired entries; returns the count
|
|
65
65
|
cache.clear() # remove everything (keeps counters)
|
|
66
|
-
cache.stats() # {'hits',
|
|
66
|
+
cache.stats() # {'hits','misses','sets','deletes','expired','evicted','size'}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Bounded cache with LRU eviction
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
# Cap the number of keys. When full and a new key arrives, evict the
|
|
73
|
+
# least-recently-used entry instead of rejecting the write.
|
|
74
|
+
cache = Cache(max_size=1000, eviction_policy="lru")
|
|
75
|
+
cache.eviction_policy # "lru"
|
|
76
|
+
|
|
77
|
+
# Default policy is "reject": set() returns False when full (and the key is new).
|
|
78
|
+
cache = Cache(max_size=1000) # eviction_policy="reject"
|
|
79
|
+
cache.set("a", 1) # True / False
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Background expiration
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
# A background thread reclaims expired entries every N seconds, so you don't
|
|
86
|
+
# have to call cleanup_expired() yourself. It stops when the cache is collected.
|
|
87
|
+
cache = Cache(cleanup_interval=30) # seconds (int/float)
|
|
67
88
|
```
|
|
68
89
|
|
|
69
90
|
### Memoization decorator
|
|
@@ -86,9 +107,13 @@ See full examples under [`examples/`](./examples) (FastAPI and Django).
|
|
|
86
107
|
|
|
87
108
|
## API
|
|
88
109
|
|
|
110
|
+
Constructor: `Cache(max_size=None, eviction_policy="reject", cleanup_interval=None)`.
|
|
111
|
+
`eviction_policy` must be `"reject"` or `"lru"` (any other value raises
|
|
112
|
+
`ValueError`). `cleanup_interval` (seconds, `> 0`) enables the background sweeper.
|
|
113
|
+
|
|
89
114
|
| Method | Description |
|
|
90
115
|
|---|---|
|
|
91
|
-
| `set(key, value, ttl=None)` | Store a value. `ttl` in seconds (`int`/`float`); `None` = no expiration; `ttl <= 0` → `ValueError`. Overwrites. |
|
|
116
|
+
| `set(key, value, ttl=None)` | Store a value. `ttl` in seconds (`int`/`float`); `None` = no expiration; `ttl <= 0` → `ValueError`. Overwrites. Returns `True`, or `False` when full under `eviction_policy="reject"` and the key is new. |
|
|
92
117
|
| `get(key, default=None)` | The value, or `default` if missing/expired (expired entries are removed). |
|
|
93
118
|
| `delete(key)` | `True` if removed, `False` if it didn't exist. |
|
|
94
119
|
| `exists(key)` | `True`/`False`, honoring TTL. |
|
|
@@ -96,7 +121,8 @@ See full examples under [`examples/`](./examples) (FastAPI and Django).
|
|
|
96
121
|
| `len()` / `len(cache)` | Approximate size. |
|
|
97
122
|
| `clear()` | Remove everything (does not reset counters). |
|
|
98
123
|
| `cleanup_expired()` | Remove expired entries; returns how many. |
|
|
99
|
-
| `
|
|
124
|
+
| `eviction_policy` (property) | The active policy: `"reject"` or `"lru"`. |
|
|
125
|
+
| `stats()` | `dict` with `hits, misses, sets, deletes, expired, evicted, size`. |
|
|
100
126
|
| `@cache.cached(ttl=None, key=None)` | Memoization decorator. |
|
|
101
127
|
|
|
102
128
|
## How it works
|
|
@@ -105,8 +131,11 @@ See full examples under [`examples/`](./examples) (FastAPI and Django).
|
|
|
105
131
|
side, via PyO3) and stored as opaque bytes (`Vec<u8>`) in the Rust core.
|
|
106
132
|
- **Concurrency:** `DashMap` (a HashMap with per-shard locks) plus `AtomicU64`
|
|
107
133
|
counters, with no global lock on the hot path. Thread-safe, no busy loop.
|
|
108
|
-
- **TTL:** expiration is **lazy** — an expired key is removed when
|
|
109
|
-
(`get`/`exists`) or via `cleanup_expired()`.
|
|
134
|
+
- **TTL:** expiration is **lazy** by default — an expired key is removed when
|
|
135
|
+
accessed (`get`/`exists`) or via `cleanup_expired()`. Pass `cleanup_interval` to
|
|
136
|
+
also run a **background** sweeper thread that reclaims expired keys on its own.
|
|
137
|
+
- **Eviction:** with `max_size` + `eviction_policy="lru"`, a full cache evicts the
|
|
138
|
+
least-recently-used entry (recency updated on every `get` hit) to admit a new key.
|
|
110
139
|
|
|
111
140
|
## Limitations
|
|
112
141
|
|
|
@@ -114,7 +143,8 @@ See full examples under [`examples/`](./examples) (FastAPI and Django).
|
|
|
114
143
|
- It does **not** replace Redis for distributed caching.
|
|
115
144
|
- Data is **lost** when the process restarts.
|
|
116
145
|
- `pickle` must **not** be used to deserialize untrusted data.
|
|
117
|
-
- Lazy TTL: expired items may linger until
|
|
146
|
+
- Lazy TTL by default: without `cleanup_interval`, expired items may linger until
|
|
147
|
+
accessed or until `cleanup_expired()` runs.
|
|
118
148
|
|
|
119
149
|
## Development
|
|
120
150
|
|
|
@@ -45,7 +45,28 @@ cache.len() # approximate size
|
|
|
45
45
|
cache.keys() # list of keys
|
|
46
46
|
cache.cleanup_expired() # remove expired entries; returns the count
|
|
47
47
|
cache.clear() # remove everything (keeps counters)
|
|
48
|
-
cache.stats() # {'hits',
|
|
48
|
+
cache.stats() # {'hits','misses','sets','deletes','expired','evicted','size'}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Bounded cache with LRU eviction
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
# Cap the number of keys. When full and a new key arrives, evict the
|
|
55
|
+
# least-recently-used entry instead of rejecting the write.
|
|
56
|
+
cache = Cache(max_size=1000, eviction_policy="lru")
|
|
57
|
+
cache.eviction_policy # "lru"
|
|
58
|
+
|
|
59
|
+
# Default policy is "reject": set() returns False when full (and the key is new).
|
|
60
|
+
cache = Cache(max_size=1000) # eviction_policy="reject"
|
|
61
|
+
cache.set("a", 1) # True / False
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Background expiration
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
# A background thread reclaims expired entries every N seconds, so you don't
|
|
68
|
+
# have to call cleanup_expired() yourself. It stops when the cache is collected.
|
|
69
|
+
cache = Cache(cleanup_interval=30) # seconds (int/float)
|
|
49
70
|
```
|
|
50
71
|
|
|
51
72
|
### Memoization decorator
|
|
@@ -68,9 +89,13 @@ See full examples under [`examples/`](./examples) (FastAPI and Django).
|
|
|
68
89
|
|
|
69
90
|
## API
|
|
70
91
|
|
|
92
|
+
Constructor: `Cache(max_size=None, eviction_policy="reject", cleanup_interval=None)`.
|
|
93
|
+
`eviction_policy` must be `"reject"` or `"lru"` (any other value raises
|
|
94
|
+
`ValueError`). `cleanup_interval` (seconds, `> 0`) enables the background sweeper.
|
|
95
|
+
|
|
71
96
|
| Method | Description |
|
|
72
97
|
|---|---|
|
|
73
|
-
| `set(key, value, ttl=None)` | Store a value. `ttl` in seconds (`int`/`float`); `None` = no expiration; `ttl <= 0` → `ValueError`. Overwrites. |
|
|
98
|
+
| `set(key, value, ttl=None)` | Store a value. `ttl` in seconds (`int`/`float`); `None` = no expiration; `ttl <= 0` → `ValueError`. Overwrites. Returns `True`, or `False` when full under `eviction_policy="reject"` and the key is new. |
|
|
74
99
|
| `get(key, default=None)` | The value, or `default` if missing/expired (expired entries are removed). |
|
|
75
100
|
| `delete(key)` | `True` if removed, `False` if it didn't exist. |
|
|
76
101
|
| `exists(key)` | `True`/`False`, honoring TTL. |
|
|
@@ -78,7 +103,8 @@ See full examples under [`examples/`](./examples) (FastAPI and Django).
|
|
|
78
103
|
| `len()` / `len(cache)` | Approximate size. |
|
|
79
104
|
| `clear()` | Remove everything (does not reset counters). |
|
|
80
105
|
| `cleanup_expired()` | Remove expired entries; returns how many. |
|
|
81
|
-
| `
|
|
106
|
+
| `eviction_policy` (property) | The active policy: `"reject"` or `"lru"`. |
|
|
107
|
+
| `stats()` | `dict` with `hits, misses, sets, deletes, expired, evicted, size`. |
|
|
82
108
|
| `@cache.cached(ttl=None, key=None)` | Memoization decorator. |
|
|
83
109
|
|
|
84
110
|
## How it works
|
|
@@ -87,8 +113,11 @@ See full examples under [`examples/`](./examples) (FastAPI and Django).
|
|
|
87
113
|
side, via PyO3) and stored as opaque bytes (`Vec<u8>`) in the Rust core.
|
|
88
114
|
- **Concurrency:** `DashMap` (a HashMap with per-shard locks) plus `AtomicU64`
|
|
89
115
|
counters, with no global lock on the hot path. Thread-safe, no busy loop.
|
|
90
|
-
- **TTL:** expiration is **lazy** — an expired key is removed when
|
|
91
|
-
(`get`/`exists`) or via `cleanup_expired()`.
|
|
116
|
+
- **TTL:** expiration is **lazy** by default — an expired key is removed when
|
|
117
|
+
accessed (`get`/`exists`) or via `cleanup_expired()`. Pass `cleanup_interval` to
|
|
118
|
+
also run a **background** sweeper thread that reclaims expired keys on its own.
|
|
119
|
+
- **Eviction:** with `max_size` + `eviction_policy="lru"`, a full cache evicts the
|
|
120
|
+
least-recently-used entry (recency updated on every `get` hit) to admit a new key.
|
|
92
121
|
|
|
93
122
|
## Limitations
|
|
94
123
|
|
|
@@ -96,7 +125,8 @@ See full examples under [`examples/`](./examples) (FastAPI and Django).
|
|
|
96
125
|
- It does **not** replace Redis for distributed caching.
|
|
97
126
|
- Data is **lost** when the process restarts.
|
|
98
127
|
- `pickle` must **not** be used to deserialize untrusted data.
|
|
99
|
-
- Lazy TTL: expired items may linger until
|
|
128
|
+
- Lazy TTL by default: without `cleanup_interval`, expired items may linger until
|
|
129
|
+
accessed or until `cleanup_expired()` runs.
|
|
100
130
|
|
|
101
131
|
## Development
|
|
102
132
|
|
|
@@ -8,7 +8,7 @@ the stages already shipped and what comes next.
|
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
-
## Shipped —
|
|
11
|
+
## Shipped — current: 0.2.0
|
|
12
12
|
|
|
13
13
|
- ✅ `Cache` with `set` / `get(default=...)` / `delete` / `exists` / `keys` /
|
|
14
14
|
`len` / `clear` / `cleanup_expired` / `stats`.
|
|
@@ -24,23 +24,25 @@ the stages already shipped and what comes next.
|
|
|
24
24
|
|
|
25
25
|
---
|
|
26
26
|
|
|
27
|
-
##
|
|
27
|
+
## Shipped — v0.2.0
|
|
28
|
+
|
|
29
|
+
### Background expiration
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
- ✅ Opt-in background sweeper thread via `Cache(cleanup_interval=...)` (seconds),
|
|
32
|
+
reclaiming expired entries proactively instead of relying only on lazy
|
|
33
|
+
expiration / manual `cleanup_expired()`. The thread is tied to the cache's
|
|
34
|
+
lifetime (stops on `Drop`) and sleeps in short slices for responsive shutdown.
|
|
30
35
|
|
|
31
|
-
|
|
32
|
-
configurable via the constructor (e.g. `Cache(cleanup_interval=...)`), instead
|
|
33
|
-
of relying only on lazy expiration / manual `cleanup_expired()`.
|
|
34
|
-
- 🔜 Keep the sweeper opt-in and lock-friendly (sweep shard by shard) so it never
|
|
35
|
-
stalls the hot path.
|
|
36
|
+
### LRU eviction
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
- ✅ `Cache(max_size=..., eviction_policy="lru")` evicts the **least-recently-used**
|
|
39
|
+
entry once the cap is hit (recency updated on every `get` hit). Default policy
|
|
40
|
+
is `"reject"` (backward compatible). Evictions are surfaced in `stats()` as an
|
|
41
|
+
`evicted` counter.
|
|
38
42
|
|
|
39
|
-
|
|
40
|
-
- 🔜 Track recency cheaply (avoid a global lock); surface evictions in `stats()`
|
|
41
|
-
(e.g. an `evicted` counter).
|
|
43
|
+
## Planned
|
|
42
44
|
|
|
43
|
-
### v0.
|
|
45
|
+
### v0.3 — LFU eviction
|
|
44
46
|
|
|
45
47
|
- 🔜 **Least-frequently-used** eviction policy as an alternative to LRU, selectable
|
|
46
48
|
at construction time (e.g. `Cache(max_size=..., policy="lfu")`).
|
|
@@ -6,6 +6,7 @@ chave e `functools.wraps` — tudo muito mais natural aqui.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import functools
|
|
9
|
+
import inspect
|
|
9
10
|
|
|
10
11
|
# Sentinela para distinguir "ausente no cache" de "valor None legítimo".
|
|
11
12
|
# `cache.get(key, _MISSING)` devolve `_MISSING` só quando a chave não existe.
|
|
@@ -37,25 +38,52 @@ def cached(self, ttl=None, key=None):
|
|
|
37
38
|
@cache.cached(ttl=60)
|
|
38
39
|
def soma(a, b):
|
|
39
40
|
return a + b
|
|
41
|
+
|
|
42
|
+
@cache.cached(ttl=60)
|
|
43
|
+
async def busca(id):
|
|
44
|
+
return await api.get(id)
|
|
45
|
+
|
|
46
|
+
Funções `async def` são detectadas e recebem um wrapper `async`, que guarda
|
|
47
|
+
no cache o resultado do `await` — não o objeto coroutine. Cachear a coroutine
|
|
48
|
+
faria a segunda chamada levantar `RuntimeError: cannot reuse already awaited
|
|
49
|
+
coroutine`.
|
|
40
50
|
"""
|
|
41
51
|
|
|
52
|
+
def _resolve_key(func, args, kwargs):
|
|
53
|
+
if key is None:
|
|
54
|
+
return _default_key(func, args, kwargs)
|
|
55
|
+
if callable(key):
|
|
56
|
+
return key(*args, **kwargs)
|
|
57
|
+
return key
|
|
58
|
+
|
|
42
59
|
def decorator(func):
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
if inspect.iscoroutinefunction(func):
|
|
61
|
+
|
|
62
|
+
@functools.wraps(func)
|
|
63
|
+
async def wrapper(*args, **kwargs):
|
|
64
|
+
cache_key = _resolve_key(func, args, kwargs)
|
|
65
|
+
|
|
66
|
+
hit = self.get(cache_key, _MISSING)
|
|
67
|
+
if hit is not _MISSING:
|
|
68
|
+
return hit
|
|
69
|
+
|
|
70
|
+
result = await func(*args, **kwargs)
|
|
71
|
+
self.set(cache_key, result, ttl=ttl)
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
else:
|
|
75
|
+
|
|
76
|
+
@functools.wraps(func)
|
|
77
|
+
def wrapper(*args, **kwargs):
|
|
78
|
+
cache_key = _resolve_key(func, args, kwargs)
|
|
79
|
+
|
|
80
|
+
hit = self.get(cache_key, _MISSING)
|
|
81
|
+
if hit is not _MISSING:
|
|
82
|
+
return hit
|
|
83
|
+
|
|
84
|
+
result = func(*args, **kwargs)
|
|
85
|
+
self.set(cache_key, result, ttl=ttl)
|
|
86
|
+
return result
|
|
59
87
|
|
|
60
88
|
# Expõe a chave-base para inspeção/invalidação manual em testes.
|
|
61
89
|
wrapper.__wrapped__ = func
|
|
@@ -9,6 +9,16 @@ use dashmap::DashMap;
|
|
|
9
9
|
use crate::entry::CacheEntry;
|
|
10
10
|
use crate::stats::{CacheStats, StatsSnapshot};
|
|
11
11
|
|
|
12
|
+
/// O que fazer quando `max_size` é atingido e chega uma chave **nova**.
|
|
13
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
14
|
+
pub enum EvictionPolicy {
|
|
15
|
+
/// Rejeita a escrita (`set` devolve `false`). Comportamento padrão.
|
|
16
|
+
#[default]
|
|
17
|
+
Reject,
|
|
18
|
+
/// Remove a entrada menos recentemente usada (LRU) para abrir espaço.
|
|
19
|
+
Lru,
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
/// Núcleo concorrente do cache.
|
|
13
23
|
///
|
|
14
24
|
/// `DashMap` é um `HashMap` thread-safe com *sharding*: ele divide o mapa em
|
|
@@ -19,18 +29,45 @@ use crate::stats::{CacheStats, StatsSnapshot};
|
|
|
19
29
|
/// faz *interior mutability* — ele cuida da sincronização internamente, então
|
|
20
30
|
/// não precisamos de `&mut self` nem de um `Mutex` por fora. As `stats` seguem
|
|
21
31
|
/// a mesma ideia com `AtomicU64`.
|
|
22
|
-
#[derive(Debug
|
|
32
|
+
#[derive(Debug)]
|
|
23
33
|
pub struct RustCache {
|
|
24
34
|
store: DashMap<String, CacheEntry>,
|
|
25
35
|
stats: CacheStats,
|
|
36
|
+
/// Limite opcional de número de chaves.
|
|
37
|
+
max_size: Option<usize>,
|
|
38
|
+
/// Política aplicada quando o cache enche e chega uma chave nova.
|
|
39
|
+
eviction_policy: EvictionPolicy,
|
|
26
40
|
}
|
|
27
41
|
|
|
28
42
|
impl RustCache {
|
|
29
|
-
/// Cria um cache vazio.
|
|
30
|
-
pub fn new() -> Self {
|
|
43
|
+
/// Cria um cache vazio. `max_size = None` => sem limite de chaves.
|
|
44
|
+
pub fn new(max_size: Option<usize>, eviction_policy: EvictionPolicy) -> Self {
|
|
31
45
|
Self {
|
|
32
46
|
store: DashMap::new(),
|
|
33
47
|
stats: CacheStats::new(),
|
|
48
|
+
max_size,
|
|
49
|
+
eviction_policy,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// A política de evicção configurada.
|
|
54
|
+
pub fn eviction_policy(&self) -> EvictionPolicy {
|
|
55
|
+
self.eviction_policy
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Remove a entrada com o menor `last_accessed_at` (a "menos recentemente
|
|
59
|
+
/// usada"). Varre o mapa uma vez — O(n) só na evicção (caminho raro: cache
|
|
60
|
+
/// cheio + chave nova). Conta em `evicted`.
|
|
61
|
+
fn evict_lru(&self) {
|
|
62
|
+
let oldest = self
|
|
63
|
+
.store
|
|
64
|
+
.iter()
|
|
65
|
+
.min_by_key(|e| e.value().last_accessed_at)
|
|
66
|
+
.map(|e| e.key().clone());
|
|
67
|
+
if let Some(key) = oldest {
|
|
68
|
+
if self.store.remove(&key).is_some() {
|
|
69
|
+
self.stats.record_evicted();
|
|
70
|
+
}
|
|
34
71
|
}
|
|
35
72
|
}
|
|
36
73
|
|
|
@@ -39,10 +76,25 @@ impl RustCache {
|
|
|
39
76
|
/// O core não sabe o que há nos bytes — quem serializa (pickle) é a camada
|
|
40
77
|
/// `lib.rs`, do lado do Python. Aqui só montamos a `CacheEntry` e inserimos.
|
|
41
78
|
/// `insert` no `DashMap` pega o lock só do shard daquela chave.
|
|
42
|
-
|
|
79
|
+
///
|
|
80
|
+
/// Sobrescrever uma chave existente sempre é permitido. Para uma chave
|
|
81
|
+
/// **nova** com o cache cheio (`max_size`): com `Reject` devolve `false` sem
|
|
82
|
+
/// inserir; com `Lru` remove a entrada menos recentemente usada e prossegue.
|
|
83
|
+
/// Devolve `true` quando a entrada foi (ou seria) gravada.
|
|
84
|
+
pub fn set(&self, key: String, value: Vec<u8>, expires_at: Option<u64>, now: u64) -> bool {
|
|
85
|
+
if let Some(max) = self.max_size {
|
|
86
|
+
if self.store.len() >= max && !self.store.contains_key(&key) {
|
|
87
|
+
match self.eviction_policy {
|
|
88
|
+
EvictionPolicy::Reject => return false,
|
|
89
|
+
EvictionPolicy::Lru => self.evict_lru(),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
43
94
|
self.store
|
|
44
95
|
.insert(key, CacheEntry::new(value, expires_at, now));
|
|
45
96
|
self.stats.record_set();
|
|
97
|
+
true
|
|
46
98
|
}
|
|
47
99
|
|
|
48
100
|
/// Lê os bytes de `key`, aplicando **expiração lazy**.
|
|
@@ -57,7 +109,9 @@ impl RustCache {
|
|
|
57
109
|
/// vivo, travaríamos o mesmo shard contra nós mesmos (deadlock). Por isso
|
|
58
110
|
/// `drop(entry)` **antes** do `remove`.
|
|
59
111
|
pub fn get(&self, key: &str, now: u64) -> Option<Vec<u8>> {
|
|
60
|
-
|
|
112
|
+
// `get_mut` segura o lock de ESCRITA do shard: além de ler o valor,
|
|
113
|
+
// atualizamos `last_accessed_at`/`hits` (base do LRU) no mesmo acesso.
|
|
114
|
+
let Some(mut entry) = self.store.get_mut(key) else {
|
|
61
115
|
self.stats.record_miss();
|
|
62
116
|
return None;
|
|
63
117
|
};
|
|
@@ -68,8 +122,11 @@ impl RustCache {
|
|
|
68
122
|
self.stats.record_miss();
|
|
69
123
|
return None;
|
|
70
124
|
}
|
|
125
|
+
entry.last_accessed_at = now;
|
|
126
|
+
entry.hits += 1;
|
|
127
|
+
let value = entry.value.clone();
|
|
71
128
|
self.stats.record_hit();
|
|
72
|
-
Some(
|
|
129
|
+
Some(value)
|
|
73
130
|
}
|
|
74
131
|
|
|
75
132
|
/// `true` se `key` existe e **não** está expirada. Coleta a chave se expirada.
|
|
@@ -166,7 +223,7 @@ mod tests {
|
|
|
166
223
|
|
|
167
224
|
#[test]
|
|
168
225
|
fn set_then_get_returns_value() {
|
|
169
|
-
let c = RustCache::new();
|
|
226
|
+
let c = RustCache::new(None, EvictionPolicy::Reject);
|
|
170
227
|
c.set("a".into(), b"v".to_vec(), None, NOW);
|
|
171
228
|
assert_eq!(c.get("a", NOW), Some(b"v".to_vec()));
|
|
172
229
|
assert_eq!(c.len(), 1);
|
|
@@ -174,14 +231,14 @@ mod tests {
|
|
|
174
231
|
|
|
175
232
|
#[test]
|
|
176
233
|
fn get_missing_is_none_and_counts_miss() {
|
|
177
|
-
let c = RustCache::new();
|
|
234
|
+
let c = RustCache::new(None, EvictionPolicy::Reject);
|
|
178
235
|
assert_eq!(c.get("x", NOW), None);
|
|
179
236
|
assert_eq!(c.stats().misses, 1);
|
|
180
237
|
}
|
|
181
238
|
|
|
182
239
|
#[test]
|
|
183
240
|
fn expired_get_collects_and_counts() {
|
|
184
|
-
let c = RustCache::new();
|
|
241
|
+
let c = RustCache::new(None, EvictionPolicy::Reject);
|
|
185
242
|
c.set("a".into(), b"v".to_vec(), Some(NOW + 10), NOW);
|
|
186
243
|
// ainda válido
|
|
187
244
|
assert!(c.get("a", NOW + 5).is_some());
|
|
@@ -196,7 +253,7 @@ mod tests {
|
|
|
196
253
|
|
|
197
254
|
#[test]
|
|
198
255
|
fn exists_respects_ttl() {
|
|
199
|
-
let c = RustCache::new();
|
|
256
|
+
let c = RustCache::new(None, EvictionPolicy::Reject);
|
|
200
257
|
c.set("a".into(), b"v".to_vec(), Some(NOW + 10), NOW);
|
|
201
258
|
assert!(c.exists("a", NOW + 5));
|
|
202
259
|
assert!(!c.exists("a", NOW + 20));
|
|
@@ -205,7 +262,7 @@ mod tests {
|
|
|
205
262
|
|
|
206
263
|
#[test]
|
|
207
264
|
fn delete_counts_only_real_removals() {
|
|
208
|
-
let c = RustCache::new();
|
|
265
|
+
let c = RustCache::new(None, EvictionPolicy::Reject);
|
|
209
266
|
c.set("a".into(), b"v".to_vec(), None, NOW);
|
|
210
267
|
assert!(c.delete("a"));
|
|
211
268
|
assert!(!c.delete("a"));
|
|
@@ -214,7 +271,7 @@ mod tests {
|
|
|
214
271
|
|
|
215
272
|
#[test]
|
|
216
273
|
fn cleanup_removes_only_expired() {
|
|
217
|
-
let c = RustCache::new();
|
|
274
|
+
let c = RustCache::new(None, EvictionPolicy::Reject);
|
|
218
275
|
c.set("keep".into(), b"v".to_vec(), None, NOW);
|
|
219
276
|
c.set("gone".into(), b"v".to_vec(), Some(NOW + 10), NOW);
|
|
220
277
|
let removed = c.cleanup_expired(NOW + 20);
|
|
@@ -223,4 +280,43 @@ mod tests {
|
|
|
223
280
|
assert!(!c.exists("gone", NOW + 20));
|
|
224
281
|
assert_eq!(c.stats().expired, 1);
|
|
225
282
|
}
|
|
283
|
+
|
|
284
|
+
#[test]
|
|
285
|
+
fn reject_policy_blocks_new_keys_when_full() {
|
|
286
|
+
let c = RustCache::new(Some(2), EvictionPolicy::Reject);
|
|
287
|
+
assert!(c.set("a".into(), b"1".to_vec(), None, NOW));
|
|
288
|
+
assert!(c.set("b".into(), b"2".to_vec(), None, NOW));
|
|
289
|
+
// cheio + chave nova => rejeita, sem evicção
|
|
290
|
+
assert!(!c.set("c".into(), b"3".to_vec(), None, NOW));
|
|
291
|
+
assert_eq!(c.len(), 2);
|
|
292
|
+
assert_eq!(c.stats().evicted, 0);
|
|
293
|
+
// sobrescrever chave existente é sempre permitido
|
|
294
|
+
assert!(c.set("a".into(), b"10".to_vec(), None, NOW));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#[test]
|
|
298
|
+
fn lru_policy_evicts_least_recently_used() {
|
|
299
|
+
let c = RustCache::new(Some(2), EvictionPolicy::Lru);
|
|
300
|
+
c.set("a".into(), b"1".to_vec(), None, NOW);
|
|
301
|
+
c.set("b".into(), b"2".to_vec(), None, NOW);
|
|
302
|
+
// Acessa "a" mais tarde: "b" vira a menos recentemente usada.
|
|
303
|
+
assert!(c.get("a", NOW + 5).is_some());
|
|
304
|
+
// Insere "c": remove "b" (LRU), mantém "a" e "c".
|
|
305
|
+
assert!(c.set("c".into(), b"3".to_vec(), None, NOW + 10));
|
|
306
|
+
assert_eq!(c.len(), 2);
|
|
307
|
+
assert_eq!(c.stats().evicted, 1);
|
|
308
|
+
assert!(c.get("b", NOW + 11).is_none());
|
|
309
|
+
assert!(c.get("a", NOW + 11).is_some());
|
|
310
|
+
assert!(c.get("c", NOW + 11).is_some());
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
#[test]
|
|
314
|
+
fn no_max_size_never_evicts() {
|
|
315
|
+
let c = RustCache::new(None, EvictionPolicy::Lru);
|
|
316
|
+
for i in 0..50 {
|
|
317
|
+
c.set(format!("k{i}"), b"v".to_vec(), None, NOW);
|
|
318
|
+
}
|
|
319
|
+
assert_eq!(c.len(), 50);
|
|
320
|
+
assert_eq!(c.stats().evicted, 0);
|
|
321
|
+
}
|
|
226
322
|
}
|
|
@@ -6,13 +6,18 @@
|
|
|
6
6
|
mod cache;
|
|
7
7
|
mod entry;
|
|
8
8
|
mod stats;
|
|
9
|
+
mod sweeper;
|
|
9
10
|
mod ttl;
|
|
10
11
|
|
|
12
|
+
use std::sync::Arc;
|
|
13
|
+
use std::time::Duration;
|
|
14
|
+
|
|
11
15
|
use pyo3::exceptions::PyValueError;
|
|
12
16
|
use pyo3::prelude::*;
|
|
13
17
|
use pyo3::types::{PyBytes, PyDict};
|
|
14
18
|
|
|
15
|
-
use crate::cache::RustCache;
|
|
19
|
+
use crate::cache::{EvictionPolicy, RustCache};
|
|
20
|
+
use crate::sweeper::Sweeper;
|
|
16
21
|
use crate::ttl::{expires_at_from_ttl, now_ms};
|
|
17
22
|
|
|
18
23
|
/// Serializa um objeto Python em bytes via `pickle.dumps`.
|
|
@@ -57,19 +62,61 @@ fn hello() -> PyResult<String> {
|
|
|
57
62
|
/// adiciona o decorator `@cache.cached` (ver `python/.../decorators.py`).
|
|
58
63
|
#[pyclass(subclass)]
|
|
59
64
|
pub struct Cache {
|
|
60
|
-
inner: RustCache
|
|
65
|
+
inner: Arc<RustCache>,
|
|
66
|
+
/// Thread de expiração em background (quando `cleanup_interval` é dado).
|
|
67
|
+
/// Mantida viva junto com o `Cache`; encerrada no `Drop`. O `_` evita o aviso
|
|
68
|
+
/// de campo não lido — seu efeito é o ciclo de vida, não a leitura.
|
|
69
|
+
_sweeper: Option<Sweeper>,
|
|
61
70
|
}
|
|
62
71
|
|
|
63
72
|
#[pymethods]
|
|
64
73
|
impl Cache {
|
|
65
|
-
/// `Cache(
|
|
74
|
+
/// `Cache(max_size=None, eviction_policy="reject", cleanup_interval=None)`.
|
|
66
75
|
///
|
|
67
|
-
///
|
|
68
|
-
///
|
|
76
|
+
/// - `max_size`: limite de chaves (`None` = ilimitado).
|
|
77
|
+
/// - `eviction_policy`: `"reject"` (padrão) ou `"lru"` (remove a menos
|
|
78
|
+
/// recentemente usada ao encher). Outro valor levanta `ValueError`.
|
|
79
|
+
/// - `cleanup_interval`: se `> 0`, liga uma thread que varre expirados a cada
|
|
80
|
+
/// N segundos (expiração em background, além da preguiçosa por acesso).
|
|
69
81
|
#[new]
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
82
|
+
#[pyo3(signature = (max_size=None, eviction_policy="reject", cleanup_interval=None))]
|
|
83
|
+
fn new(
|
|
84
|
+
max_size: Option<usize>,
|
|
85
|
+
eviction_policy: &str,
|
|
86
|
+
cleanup_interval: Option<f64>,
|
|
87
|
+
) -> PyResult<Self> {
|
|
88
|
+
let policy = match eviction_policy {
|
|
89
|
+
"reject" => EvictionPolicy::Reject,
|
|
90
|
+
"lru" => EvictionPolicy::Lru,
|
|
91
|
+
other => {
|
|
92
|
+
return Err(PyValueError::new_err(format!(
|
|
93
|
+
"eviction_policy deve ser \"reject\" ou \"lru\", recebido {other:?}"
|
|
94
|
+
)));
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
let inner = Arc::new(RustCache::new(max_size, policy));
|
|
99
|
+
|
|
100
|
+
let sweeper = match cleanup_interval {
|
|
101
|
+
Some(secs) if secs > 0.0 => Some(Sweeper::start(
|
|
102
|
+
&inner,
|
|
103
|
+
Duration::from_millis((secs * 1000.0) as u64),
|
|
104
|
+
)),
|
|
105
|
+
_ => None,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
Ok(Cache {
|
|
109
|
+
inner,
|
|
110
|
+
_sweeper: sweeper,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// A política de evicção ativa: `"reject"` ou `"lru"`.
|
|
115
|
+
#[getter]
|
|
116
|
+
fn eviction_policy(&self) -> &'static str {
|
|
117
|
+
match self.inner.eviction_policy() {
|
|
118
|
+
EvictionPolicy::Reject => "reject",
|
|
119
|
+
EvictionPolicy::Lru => "lru",
|
|
73
120
|
}
|
|
74
121
|
}
|
|
75
122
|
|
|
@@ -84,6 +131,9 @@ impl Cache {
|
|
|
84
131
|
/// seguramos o GIL — necessário para chamar `pickle`.
|
|
85
132
|
/// - `value: &Bound<'_, PyAny>` é qualquer objeto Python, emprestado.
|
|
86
133
|
/// - `#[pyo3(signature = ...)]` define o default `ttl=None` visível no Python.
|
|
134
|
+
/// Devolve `True` quando a entrada foi gravada; `False` apenas quando há
|
|
135
|
+
/// `max_size` com `eviction_policy="reject"`, a chave é nova e o cache está
|
|
136
|
+
/// cheio. Sobrescrever uma chave existente sempre devolve `True`.
|
|
87
137
|
#[pyo3(signature = (key, value, ttl=None))]
|
|
88
138
|
fn set(
|
|
89
139
|
&self,
|
|
@@ -91,13 +141,12 @@ impl Cache {
|
|
|
91
141
|
key: String,
|
|
92
142
|
value: &Bound<'_, PyAny>,
|
|
93
143
|
ttl: Option<f64>,
|
|
94
|
-
) -> PyResult<
|
|
144
|
+
) -> PyResult<bool> {
|
|
95
145
|
let now = now_ms();
|
|
96
146
|
let expires_at = expires_at_from_ttl(ttl, now)
|
|
97
147
|
.map_err(|_| PyValueError::new_err("ttl deve ser > 0 (ou None para não expirar)"))?;
|
|
98
148
|
let bytes = dumps(py, value)?;
|
|
99
|
-
self.inner.set(key, bytes, expires_at, now)
|
|
100
|
-
Ok(())
|
|
149
|
+
Ok(self.inner.set(key, bytes, expires_at, now))
|
|
101
150
|
}
|
|
102
151
|
|
|
103
152
|
/// `get(key, default=None)` — devolve o valor de `key`.
|
|
@@ -147,6 +196,7 @@ impl Cache {
|
|
|
147
196
|
d.set_item("sets", s.sets)?;
|
|
148
197
|
d.set_item("deletes", s.deletes)?;
|
|
149
198
|
d.set_item("expired", s.expired)?;
|
|
199
|
+
d.set_item("evicted", s.evicted)?;
|
|
150
200
|
d.set_item("size", self.inner.len())?;
|
|
151
201
|
Ok(d)
|
|
152
202
|
}
|
|
@@ -18,6 +18,7 @@ pub struct CacheStats {
|
|
|
18
18
|
pub sets: AtomicU64,
|
|
19
19
|
pub deletes: AtomicU64,
|
|
20
20
|
pub expired: AtomicU64,
|
|
21
|
+
pub evicted: AtomicU64,
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
/// Cópia "congelada" dos contadores num instante — fácil de mandar pro Python.
|
|
@@ -28,6 +29,7 @@ pub struct StatsSnapshot {
|
|
|
28
29
|
pub sets: u64,
|
|
29
30
|
pub deletes: u64,
|
|
30
31
|
pub expired: u64,
|
|
32
|
+
pub evicted: u64,
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
impl CacheStats {
|
|
@@ -57,6 +59,11 @@ impl CacheStats {
|
|
|
57
59
|
self.expired.fetch_add(n, Ordering::Relaxed);
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
/// Conta uma entrada removida por evicção (política LRU ao atingir `max_size`).
|
|
63
|
+
pub fn record_evicted(&self) {
|
|
64
|
+
self.evicted.fetch_add(1, Ordering::Relaxed);
|
|
65
|
+
}
|
|
66
|
+
|
|
60
67
|
pub fn snapshot(&self) -> StatsSnapshot {
|
|
61
68
|
StatsSnapshot {
|
|
62
69
|
hits: self.hits.load(Ordering::Relaxed),
|
|
@@ -64,6 +71,7 @@ impl CacheStats {
|
|
|
64
71
|
sets: self.sets.load(Ordering::Relaxed),
|
|
65
72
|
deletes: self.deletes.load(Ordering::Relaxed),
|
|
66
73
|
expired: self.expired.load(Ordering::Relaxed),
|
|
74
|
+
evicted: self.evicted.load(Ordering::Relaxed),
|
|
67
75
|
}
|
|
68
76
|
}
|
|
69
77
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
//! Expiração em background: uma thread que varre as entradas expiradas em
|
|
2
|
+
//! intervalos regulares, em vez de depender só da expiração preguiçosa (lazy)
|
|
3
|
+
//! ou de chamadas manuais a `cleanup_expired()`.
|
|
4
|
+
//!
|
|
5
|
+
//! ## Ciclo de vida
|
|
6
|
+
//!
|
|
7
|
+
//! A thread recebe um [`Weak<RustCache>`], não um `Arc` forte: assim ela **não**
|
|
8
|
+
//! mantém o cache vivo. Se o objeto `Cache` do Python for coletado, o `Arc` some,
|
|
9
|
+
//! o `upgrade()` falha e a thread encerra sozinha. Além disso o `Cache` guarda
|
|
10
|
+
//! este [`Sweeper`]; quando ele é destruído (`Drop`), sinalizamos a parada e
|
|
11
|
+
//! fazemos `join` — sem threads órfãs.
|
|
12
|
+
//!
|
|
13
|
+
//! O sono é em fatias curtas (não um único `sleep(interval)`) para o encerramento
|
|
14
|
+
//! responder rápido ao sinal de parada, sem esperar o intervalo inteiro.
|
|
15
|
+
|
|
16
|
+
use std::sync::atomic::{AtomicBool, Ordering};
|
|
17
|
+
use std::sync::{Arc, Weak};
|
|
18
|
+
use std::thread::{self, JoinHandle};
|
|
19
|
+
use std::time::Duration;
|
|
20
|
+
|
|
21
|
+
use crate::cache::RustCache;
|
|
22
|
+
use crate::ttl::now_ms;
|
|
23
|
+
|
|
24
|
+
/// Handle da thread de varredura. Pára e faz `join` automaticamente no `Drop`.
|
|
25
|
+
pub struct Sweeper {
|
|
26
|
+
stop: Arc<AtomicBool>,
|
|
27
|
+
handle: Option<JoinHandle<()>>,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
impl Sweeper {
|
|
31
|
+
/// Inicia a thread que varre `cache` a cada `interval`.
|
|
32
|
+
pub fn start(cache: &Arc<RustCache>, interval: Duration) -> Self {
|
|
33
|
+
let stop = Arc::new(AtomicBool::new(false));
|
|
34
|
+
let stop_thread = Arc::clone(&stop);
|
|
35
|
+
let weak = Arc::downgrade(cache);
|
|
36
|
+
let handle = thread::spawn(move || run(weak, interval, stop_thread));
|
|
37
|
+
Sweeper {
|
|
38
|
+
stop,
|
|
39
|
+
handle: Some(handle),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Laço da thread: dorme `interval` (em fatias) e então varre os expirados,
|
|
45
|
+
/// repetindo até receber o sinal de parada ou o cache deixar de existir.
|
|
46
|
+
fn run(cache: Weak<RustCache>, interval: Duration, stop: Arc<AtomicBool>) {
|
|
47
|
+
let tick = Duration::from_millis(200);
|
|
48
|
+
loop {
|
|
49
|
+
let mut waited = Duration::ZERO;
|
|
50
|
+
while waited < interval {
|
|
51
|
+
if stop.load(Ordering::Relaxed) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
let chunk = tick.min(interval - waited);
|
|
55
|
+
thread::sleep(chunk);
|
|
56
|
+
waited += chunk;
|
|
57
|
+
}
|
|
58
|
+
if stop.load(Ordering::Relaxed) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
match cache.upgrade() {
|
|
62
|
+
Some(c) => {
|
|
63
|
+
c.cleanup_expired(now_ms());
|
|
64
|
+
}
|
|
65
|
+
None => return, // o cache foi coletado; nada mais a varrer
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
impl Drop for Sweeper {
|
|
71
|
+
fn drop(&mut self) {
|
|
72
|
+
self.stop.store(true, Ordering::Relaxed);
|
|
73
|
+
if let Some(handle) = self.handle.take() {
|
|
74
|
+
let _ = handle.join();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Eviction (LRU) + expiração em background.
|
|
2
|
+
|
|
3
|
+
A correção da LRU "qual chave sai" é verificada de forma determinística nos
|
|
4
|
+
testes Rust (`src/cache.rs`), onde o relógio é injetado. Aqui cobrimos a API
|
|
5
|
+
Python: seleção de política, o getter `eviction_policy`, validação, retorno de
|
|
6
|
+
`set` e a thread de expiração em background.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from rust_py_cache import Cache
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_eviction_policy_defaults_to_reject():
|
|
17
|
+
cache = Cache(max_size=2)
|
|
18
|
+
assert cache.eviction_policy == "reject"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_eviction_policy_getter_reports_lru():
|
|
22
|
+
cache = Cache(max_size=2, eviction_policy="lru")
|
|
23
|
+
assert cache.eviction_policy == "lru"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_invalid_eviction_policy_raises():
|
|
27
|
+
with pytest.raises(ValueError):
|
|
28
|
+
Cache(max_size=2, eviction_policy="lfu")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_reject_blocks_new_keys_when_full():
|
|
32
|
+
cache = Cache(max_size=2, eviction_policy="reject")
|
|
33
|
+
assert cache.set("a", 1) is True
|
|
34
|
+
assert cache.set("b", 2) is True
|
|
35
|
+
assert cache.set("c", 3) is False # cheio
|
|
36
|
+
assert len(cache) == 2
|
|
37
|
+
assert cache.stats()["evicted"] == 0
|
|
38
|
+
# sobrescrever chave existente continua permitido
|
|
39
|
+
assert cache.set("a", 10) is True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_lru_evicts_to_make_room():
|
|
43
|
+
cache = Cache(max_size=2, eviction_policy="lru")
|
|
44
|
+
cache.set("a", 1)
|
|
45
|
+
cache.set("b", 2)
|
|
46
|
+
cache.set("c", 3) # cheio + chave nova => remove a LRU
|
|
47
|
+
assert len(cache) == 2
|
|
48
|
+
assert cache.stats()["evicted"] == 1
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_lru_keeps_recently_used():
|
|
52
|
+
cache = Cache(max_size=2, eviction_policy="lru")
|
|
53
|
+
cache.set("a", 1)
|
|
54
|
+
cache.set("b", 2)
|
|
55
|
+
time.sleep(0.01)
|
|
56
|
+
assert cache.get("a") == 1 # "b" passa a ser a menos recentemente usada
|
|
57
|
+
cache.set("c", 3)
|
|
58
|
+
assert cache.get("b") is None # evicted
|
|
59
|
+
assert cache.get("a") == 1
|
|
60
|
+
assert cache.get("c") == 3
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_no_max_size_never_evicts():
|
|
64
|
+
cache = Cache()
|
|
65
|
+
for i in range(100):
|
|
66
|
+
cache.set(f"k{i}", i)
|
|
67
|
+
assert len(cache) == 100
|
|
68
|
+
assert cache.stats()["evicted"] == 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_background_expiration_sweeps_automatically():
|
|
72
|
+
cache = Cache(cleanup_interval=0.5)
|
|
73
|
+
cache.set("a", 1, ttl=0.5)
|
|
74
|
+
assert len(cache) == 1
|
|
75
|
+
# Sem chamar cleanup_expired(): a thread de fundo deve coletar depois que o
|
|
76
|
+
# TTL (0.5s) vence e o sweeper (0.5s) roda.
|
|
77
|
+
time.sleep(1.6)
|
|
78
|
+
assert len(cache) == 0
|
|
79
|
+
assert cache.stats()["expired"] >= 1
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Testes de exists/keys/stats/cleanup_expired/clear e decorator — Etapas 8-17."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import time
|
|
4
5
|
|
|
5
6
|
from rust_py_cache import Cache
|
|
@@ -56,7 +57,15 @@ def test_stats_tracks_operations():
|
|
|
56
57
|
assert s["misses"] == 1
|
|
57
58
|
assert s["deletes"] == 1
|
|
58
59
|
assert s["size"] == 0
|
|
59
|
-
assert set(s) == {
|
|
60
|
+
assert set(s) == {
|
|
61
|
+
"hits",
|
|
62
|
+
"misses",
|
|
63
|
+
"sets",
|
|
64
|
+
"deletes",
|
|
65
|
+
"expired",
|
|
66
|
+
"evicted",
|
|
67
|
+
"size",
|
|
68
|
+
}
|
|
60
69
|
|
|
61
70
|
|
|
62
71
|
def test_decorator_caches_calls():
|
|
@@ -102,3 +111,63 @@ def test_decorator_caches_none_result():
|
|
|
102
111
|
assert returns_none() is None
|
|
103
112
|
assert returns_none() is None
|
|
104
113
|
assert calls["n"] == 1 # None foi cacheado, não recomputou
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_decorator_caches_async_function():
|
|
117
|
+
"""P0-2: `@cached` sobre `async def` deve cachear o RESULTADO do await.
|
|
118
|
+
|
|
119
|
+
Antes da correção, o wrapper síncrono guardava o objeto coroutine: a
|
|
120
|
+
primeira chamada devolvia o valor certo e a segunda levantava
|
|
121
|
+
`RuntimeError: cannot reuse already awaited coroutine`.
|
|
122
|
+
"""
|
|
123
|
+
cache = Cache()
|
|
124
|
+
calls = {"n": 0}
|
|
125
|
+
|
|
126
|
+
@cache.cached(ttl=60)
|
|
127
|
+
async def busca(x):
|
|
128
|
+
calls["n"] += 1
|
|
129
|
+
return x * 2
|
|
130
|
+
|
|
131
|
+
async def cenario():
|
|
132
|
+
# Duas chamadas no mesmo event loop — é aqui que o bug aparecia.
|
|
133
|
+
primeira = await busca(21)
|
|
134
|
+
segunda = await busca(21)
|
|
135
|
+
terceira = await busca(10) # args diferentes -> recomputa
|
|
136
|
+
return primeira, segunda, terceira
|
|
137
|
+
|
|
138
|
+
primeira, segunda, terceira = asyncio.run(cenario())
|
|
139
|
+
|
|
140
|
+
assert primeira == 42
|
|
141
|
+
assert segunda == 42
|
|
142
|
+
assert terceira == 20
|
|
143
|
+
assert calls["n"] == 2 # busca(21) rodou uma vez só
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_decorator_async_keeps_coroutine_function():
|
|
147
|
+
"""O wrapper de uma `async def` continua sendo uma coroutine function."""
|
|
148
|
+
cache = Cache()
|
|
149
|
+
|
|
150
|
+
@cache.cached()
|
|
151
|
+
async def f():
|
|
152
|
+
return 1
|
|
153
|
+
|
|
154
|
+
assert asyncio.iscoroutinefunction(f)
|
|
155
|
+
assert f.__name__ == "f"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_decorator_async_caches_none_result():
|
|
159
|
+
cache = Cache()
|
|
160
|
+
calls = {"n": 0}
|
|
161
|
+
|
|
162
|
+
@cache.cached()
|
|
163
|
+
async def returns_none():
|
|
164
|
+
calls["n"] += 1
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
async def cenario():
|
|
168
|
+
return await returns_none(), await returns_none()
|
|
169
|
+
|
|
170
|
+
a, b = asyncio.run(cenario())
|
|
171
|
+
assert a is None
|
|
172
|
+
assert b is None
|
|
173
|
+
assert calls["n"] == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|