rust-py-cache 0.1.2__tar.gz → 0.2.0__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.1.2 → rust_py_cache-0.2.0}/Cargo.lock +1 -1
- {rust_py_cache-0.1.2 → rust_py_cache-0.2.0}/Cargo.toml +1 -1
- {rust_py_cache-0.1.2 → rust_py_cache-0.2.0}/PKG-INFO +37 -7
- {rust_py_cache-0.1.2 → rust_py_cache-0.2.0}/README.md +36 -6
- rust_py_cache-0.2.0/ROADMAP.md +92 -0
- {rust_py_cache-0.1.2 → rust_py_cache-0.2.0}/pyproject.toml +1 -1
- {rust_py_cache-0.1.2 → rust_py_cache-0.2.0}/python/rust_py_cache/__init__.py +1 -1
- {rust_py_cache-0.1.2 → rust_py_cache-0.2.0}/src/cache.rs +108 -12
- {rust_py_cache-0.1.2 → rust_py_cache-0.2.0}/src/lib.rs +61 -11
- {rust_py_cache-0.1.2 → rust_py_cache-0.2.0}/src/stats.rs +8 -0
- rust_py_cache-0.2.0/src/sweeper.rs +77 -0
- rust_py_cache-0.2.0/tests/test_eviction.py +79 -0
- {rust_py_cache-0.1.2 → rust_py_cache-0.2.0}/tests/test_features.py +9 -1
- {rust_py_cache-0.1.2 → rust_py_cache-0.2.0}/.github/workflows/ci.yml +0 -0
- {rust_py_cache-0.1.2 → rust_py_cache-0.2.0}/.github/workflows/release.yml +0 -0
- {rust_py_cache-0.1.2 → rust_py_cache-0.2.0}/.gitignore +0 -0
- {rust_py_cache-0.1.2 → rust_py_cache-0.2.0}/PUBLISHING.md +0 -0
- {rust_py_cache-0.1.2 → rust_py_cache-0.2.0}/examples/django_example.py +0 -0
- {rust_py_cache-0.1.2 → rust_py_cache-0.2.0}/examples/fastapi_example.py +0 -0
- {rust_py_cache-0.1.2 → rust_py_cache-0.2.0}/python/rust_py_cache/decorators.py +0 -0
- {rust_py_cache-0.1.2 → rust_py_cache-0.2.0}/src/entry.rs +0 -0
- {rust_py_cache-0.1.2 → rust_py_cache-0.2.0}/src/ttl.rs +0 -0
- {rust_py_cache-0.1.2 → rust_py_cache-0.2.0}/tests/test_cache_basic.py +0 -0
- {rust_py_cache-0.1.2 → rust_py_cache-0.2.0}/tests/test_delete.py +0 -0
- {rust_py_cache-0.1.2 → rust_py_cache-0.2.0}/tests/test_set_get.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rust-py-cache
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
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
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Roadmap — rust-py-cache
|
|
2
|
+
|
|
3
|
+
Direction for `rust-py-cache`: an ultra-fast, process-local cache for Python with
|
|
4
|
+
a Rust core (PyO3 + maturin). The MVP is intentionally small; this document tracks
|
|
5
|
+
the stages already shipped and what comes next.
|
|
6
|
+
|
|
7
|
+
> Status legend: ✅ shipped · 🔜 planned (next) · 💡 idea (no version yet) · ⚠️ note
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Shipped — current: 0.2.0
|
|
12
|
+
|
|
13
|
+
- ✅ `Cache` with `set` / `get(default=...)` / `delete` / `exists` / `keys` /
|
|
14
|
+
`len` / `clear` / `cleanup_expired` / `stats`.
|
|
15
|
+
- ✅ TTL per entry with **lazy expiration** (expired keys are dropped on access or
|
|
16
|
+
via `cleanup_expired()`).
|
|
17
|
+
- ✅ Cumulative `stats()` (`hits, misses, sets, deletes, expired, size`) backed by
|
|
18
|
+
`AtomicU64` — no global lock on the hot path.
|
|
19
|
+
- ✅ Memoization decorator `@cache.cached(ttl=..., key=...)` (fixed string or
|
|
20
|
+
callable key).
|
|
21
|
+
- ✅ Rust core on `DashMap` (per-shard locks); values serialized with `pickle` and
|
|
22
|
+
stored as opaque bytes (`Vec<u8>`).
|
|
23
|
+
- ✅ Examples for FastAPI and Django.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Shipped — v0.2.0
|
|
28
|
+
|
|
29
|
+
### Background expiration
|
|
30
|
+
|
|
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.
|
|
35
|
+
|
|
36
|
+
### LRU eviction
|
|
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.
|
|
42
|
+
|
|
43
|
+
## Planned
|
|
44
|
+
|
|
45
|
+
### v0.3 — LFU eviction
|
|
46
|
+
|
|
47
|
+
- 🔜 **Least-frequently-used** eviction policy as an alternative to LRU, selectable
|
|
48
|
+
at construction time (e.g. `Cache(max_size=..., policy="lfu")`).
|
|
49
|
+
|
|
50
|
+
### v0.5 — Configurable serializer
|
|
51
|
+
|
|
52
|
+
- 🔜 Pluggable serialization instead of hard-coded `pickle`: e.g.
|
|
53
|
+
`Cache(serializer="json" | "pickle" | "msgpack")` or a custom
|
|
54
|
+
encode/decode pair.
|
|
55
|
+
- ⚠️ Security: `pickle` must not deserialize untrusted data — a JSON/MessagePack
|
|
56
|
+
option makes the cache safe to share across trust boundaries.
|
|
57
|
+
|
|
58
|
+
### v0.6 — Namespaces
|
|
59
|
+
|
|
60
|
+
- 🔜 Logical key namespaces (e.g. `cache.namespace("users")`) so independent
|
|
61
|
+
features can share one `Cache` instance without key collisions, with per-namespace
|
|
62
|
+
`clear()` and stats.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Ideas / future (no version assigned)
|
|
67
|
+
|
|
68
|
+
- 💡 Prometheus metrics exporter (align with `rust-py-monitor`).
|
|
69
|
+
- 💡 Redis synchronization / a distributed tier (today the cache is process-local).
|
|
70
|
+
- 💡 ImmutableLog integration for eviction events
|
|
71
|
+
(`{"event_type": "cache_evicted", "key": ..., "reason": ...}`).
|
|
72
|
+
- 💡 Binary value formats end-to-end (MessagePack / CBOR / Bincode) for smaller,
|
|
73
|
+
faster payloads.
|
|
74
|
+
- 💡 Benchmarks suite (throughput vs. `dict`, `cachetools`, in-process Redis).
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Known limitations (by design, for now)
|
|
79
|
+
|
|
80
|
+
- Process-local: multiple workers = multiple independent caches.
|
|
81
|
+
- Not a replacement for Redis in distributed setups.
|
|
82
|
+
- Data is lost on process restart.
|
|
83
|
+
- Lazy TTL: expired items may linger until accessed or `cleanup_expired()` runs
|
|
84
|
+
(addressed by v0.2).
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Contributing to the roadmap
|
|
89
|
+
|
|
90
|
+
Versions and ordering are indicative and may shift. Bump the version in **both**
|
|
91
|
+
`Cargo.toml` and `pyproject.toml` (kept in sync) plus `__version__`, ship tests
|
|
92
|
+
(`cargo test` + `pytest`), then tag `vX.Y.Z` to trigger the release workflow.
|
|
@@ -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
|
|
@@ -56,7 +56,15 @@ def test_stats_tracks_operations():
|
|
|
56
56
|
assert s["misses"] == 1
|
|
57
57
|
assert s["deletes"] == 1
|
|
58
58
|
assert s["size"] == 0
|
|
59
|
-
assert set(s) == {
|
|
59
|
+
assert set(s) == {
|
|
60
|
+
"hits",
|
|
61
|
+
"misses",
|
|
62
|
+
"sets",
|
|
63
|
+
"deletes",
|
|
64
|
+
"expired",
|
|
65
|
+
"evicted",
|
|
66
|
+
"size",
|
|
67
|
+
}
|
|
60
68
|
|
|
61
69
|
|
|
62
70
|
def test_decorator_caches_calls():
|
|
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
|
|
File without changes
|