cachekit 0.11.1__tar.gz → 0.14.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.
- {cachekit-0.11.1 → cachekit-0.14.0}/Cargo.lock +5 -5
- {cachekit-0.11.1 → cachekit-0.14.0}/PKG-INFO +5 -4
- {cachekit-0.11.1 → cachekit-0.14.0}/README.md +4 -3
- {cachekit-0.11.1 → cachekit-0.14.0}/pyproject.toml +2 -1
- {cachekit-0.11.1 → cachekit-0.14.0}/rust/Cargo.toml +2 -2
- {cachekit-0.11.1 → cachekit-0.14.0}/rust/README.md +4 -3
- {cachekit-0.11.1 → cachekit-0.14.0}/rust/src/lib.rs +7 -2
- {cachekit-0.11.1 → cachekit-0.14.0}/rust/src/python_bindings.rs +56 -11
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/__init__.py +17 -3
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/base.py +4 -2
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/cachekitio/backend.py +64 -11
- cachekit-0.14.0/src/cachekit/backends/cachekitio/session.py +59 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/file/backend.py +131 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/memcached/backend.py +43 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/provider.py +49 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/redis/backend.py +40 -15
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/redis/client.py +64 -14
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/redis/config.py +13 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/redis/provider.py +17 -6
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/cache_handler.py +552 -48
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/config/decorator.py +44 -8
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/config/nested.py +26 -6
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/config/settings.py +15 -29
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/config/singleton.py +2 -2
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/decorators/intent.py +13 -3
- cachekit-0.14.0/src/cachekit/decorators/session.py +133 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/decorators/wrapper.py +755 -84
- cachekit-0.14.0/src/cachekit/interop.py +496 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/l1_cache.py +37 -12
- cachekit-0.14.0/src/cachekit/object_cache.py +471 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/serializers/__init__.py +5 -1
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/serializers/auto_serializer.py +29 -8
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/serializers/base.py +16 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/serializers/encryption_wrapper.py +71 -20
- cachekit-0.14.0/src/cachekit/serializers/interop_serializer.py +64 -0
- cachekit-0.11.1/src/cachekit/backends/cachekitio/session.py +0 -164
- cachekit-0.11.1/src/cachekit/decorators/session.py +0 -42
- cachekit-0.11.1/src/cachekit/object_cache.py +0 -199
- {cachekit-0.11.1 → cachekit-0.14.0}/Cargo.toml +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/LICENSE +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/rust/Makefile +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/rust/TEST_EXPANSION_SUMMARY.md +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/rust/supply-chain/audits.toml +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/rust/supply-chain/config.toml +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/rust/supply-chain/imports.lock +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/rust/tsan_suppressions.txt +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/__init__.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/base_config.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/cachekitio/__init__.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/cachekitio/client.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/cachekitio/config.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/cachekitio/error_handler.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/errors.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/file/__init__.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/file/config.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/memcached/__init__.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/memcached/config.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/memcached/error_handler.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/redis/__init__.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/redis/error_handler.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/config/__init__.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/config/validation.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/decorators/__init__.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/decorators/local_wrapper.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/decorators/main.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/decorators/orchestrator.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/decorators/stats_context.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/decorators/tenant_context.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/decorators/utils/__init__.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/di.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/hash_utils.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/health.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/hiredis_compat.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/imports.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/invalidation/__init__.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/invalidation/channel.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/invalidation/event.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/invalidation/redis_channel.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/key_generator.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/logging.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/monitoring/__init__.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/monitoring/correlation_tracking.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/monitoring/pool_monitor.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/monitoring/protocols.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/py.typed +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/reliability/__init__.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/reliability/adaptive_timeout.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/reliability/async_metrics.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/reliability/circuit_breaker.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/reliability/error_classification.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/reliability/load_control.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/reliability/metrics_collection.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/reliability/profiles.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/serializers/arrow_serializer.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/serializers/orjson_serializer.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/serializers/standard_serializer.py +0 -0
- {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/serializers/wrapper.py +0 -0
|
@@ -245,9 +245,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
|
|
245
245
|
|
|
246
246
|
[[package]]
|
|
247
247
|
name = "cachekit-core"
|
|
248
|
-
version = "0.
|
|
248
|
+
version = "0.3.0"
|
|
249
249
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
250
|
-
checksum = "
|
|
250
|
+
checksum = "9ee6235f73aefb0dc66b9cd0d333b9da928c8192e4234357a739fe756c2f8f23"
|
|
251
251
|
dependencies = [
|
|
252
252
|
"aes",
|
|
253
253
|
"aes-gcm",
|
|
@@ -271,7 +271,7 @@ dependencies = [
|
|
|
271
271
|
|
|
272
272
|
[[package]]
|
|
273
273
|
name = "cachekit-rs"
|
|
274
|
-
version = "0.
|
|
274
|
+
version = "0.14.0"
|
|
275
275
|
dependencies = [
|
|
276
276
|
"cachekit-core",
|
|
277
277
|
"criterion",
|
|
@@ -468,9 +468,9 @@ dependencies = [
|
|
|
468
468
|
|
|
469
469
|
[[package]]
|
|
470
470
|
name = "crossbeam-epoch"
|
|
471
|
-
version = "0.9.
|
|
471
|
+
version = "0.9.20"
|
|
472
472
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
473
|
-
checksum = "
|
|
473
|
+
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
|
474
474
|
dependencies = [
|
|
475
475
|
"crossbeam-utils",
|
|
476
476
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cachekit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.0
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Intended Audience :: Developers
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -412,8 +412,9 @@ exposition setup.
|
|
|
412
412
|
<summary><strong>Thread Safety Details</strong></summary>
|
|
413
413
|
|
|
414
414
|
**Per-Function Statistics:**
|
|
415
|
-
- Statistics tracked per
|
|
415
|
+
- Statistics tracked per function identity (`module.qualname`), shared across all calls and across re-decorations of the same function
|
|
416
416
|
- Thread-safe via RLock (all methods safe for concurrent access)
|
|
417
|
+
- Fork-safe: a forked child starts with zeroed counters and its own session ID
|
|
417
418
|
|
|
418
419
|
```python
|
|
419
420
|
from concurrent.futures import ThreadPoolExecutor
|
|
@@ -480,8 +481,8 @@ CACHEKIT_MEMCACHED_KEY_PREFIX="myapp:" # Default: "" (none)
|
|
|
480
481
|
|
|
481
482
|
# Optional Configuration
|
|
482
483
|
CACHEKIT_DEFAULT_TTL=3600
|
|
483
|
-
|
|
484
|
-
|
|
484
|
+
CACHEKIT_MAX_VALUE_SIZE=104857600
|
|
485
|
+
CACHEKIT_ARROW_COMPRESSION=zstd
|
|
485
486
|
```
|
|
486
487
|
|
|
487
488
|
> [!NOTE]
|
|
@@ -358,8 +358,9 @@ exposition setup.
|
|
|
358
358
|
<summary><strong>Thread Safety Details</strong></summary>
|
|
359
359
|
|
|
360
360
|
**Per-Function Statistics:**
|
|
361
|
-
- Statistics tracked per
|
|
361
|
+
- Statistics tracked per function identity (`module.qualname`), shared across all calls and across re-decorations of the same function
|
|
362
362
|
- Thread-safe via RLock (all methods safe for concurrent access)
|
|
363
|
+
- Fork-safe: a forked child starts with zeroed counters and its own session ID
|
|
363
364
|
|
|
364
365
|
```python
|
|
365
366
|
from concurrent.futures import ThreadPoolExecutor
|
|
@@ -426,8 +427,8 @@ CACHEKIT_MEMCACHED_KEY_PREFIX="myapp:" # Default: "" (none)
|
|
|
426
427
|
|
|
427
428
|
# Optional Configuration
|
|
428
429
|
CACHEKIT_DEFAULT_TTL=3600
|
|
429
|
-
|
|
430
|
-
|
|
430
|
+
CACHEKIT_MAX_VALUE_SIZE=104857600
|
|
431
|
+
CACHEKIT_ARROW_COMPRESSION=zstd
|
|
431
432
|
```
|
|
432
433
|
|
|
433
434
|
> [!NOTE]
|
|
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cachekit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.14.0"
|
|
8
8
|
description = "Production-ready Redis caching for Python with intelligent reliability features and Rust-powered performance"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -156,6 +156,7 @@ addopts = [
|
|
|
156
156
|
"--doctest-modules", # Validate docstring examples
|
|
157
157
|
"--doctest-continue-on-failure", # Report all doctest failures, not just first
|
|
158
158
|
"--markdown-docs", # Validate markdown documentation examples
|
|
159
|
+
"--benchmark-skip", # pytest-benchmark tests run only via --benchmark-only (make benchmark)
|
|
159
160
|
]
|
|
160
161
|
markers = [
|
|
161
162
|
"asyncio: Async tests using pytest-asyncio",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "cachekit-rs"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.14.0"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
authors = ["cachekit Contributors"]
|
|
6
6
|
description = "High-performance storage engine for caching with compression and encryption"
|
|
@@ -20,7 +20,7 @@ crate-type = ["cdylib", "rlib"]
|
|
|
20
20
|
|
|
21
21
|
[dependencies]
|
|
22
22
|
# Compression, checksums, encryption (https://crates.io/crates/cachekit-core)
|
|
23
|
-
cachekit-core = { version = "0.
|
|
23
|
+
cachekit-core = { version = "0.3.0", features = ["compression", "checksum", "messagepack", "encryption"] }
|
|
24
24
|
|
|
25
25
|
# Python integration - optional for Rust-only builds
|
|
26
26
|
pyo3 = { workspace = true, optional = true }
|
|
@@ -358,8 +358,9 @@ exposition setup.
|
|
|
358
358
|
<summary><strong>Thread Safety Details</strong></summary>
|
|
359
359
|
|
|
360
360
|
**Per-Function Statistics:**
|
|
361
|
-
- Statistics tracked per
|
|
361
|
+
- Statistics tracked per function identity (`module.qualname`), shared across all calls and across re-decorations of the same function
|
|
362
362
|
- Thread-safe via RLock (all methods safe for concurrent access)
|
|
363
|
+
- Fork-safe: a forked child starts with zeroed counters and its own session ID
|
|
363
364
|
|
|
364
365
|
```python
|
|
365
366
|
from concurrent.futures import ThreadPoolExecutor
|
|
@@ -426,8 +427,8 @@ CACHEKIT_MEMCACHED_KEY_PREFIX="myapp:" # Default: "" (none)
|
|
|
426
427
|
|
|
427
428
|
# Optional Configuration
|
|
428
429
|
CACHEKIT_DEFAULT_TTL=3600
|
|
429
|
-
|
|
430
|
-
|
|
430
|
+
CACHEKIT_MAX_VALUE_SIZE=104857600
|
|
431
|
+
CACHEKIT_ARROW_COMPRESSION=zstd
|
|
431
432
|
```
|
|
432
433
|
|
|
433
434
|
> [!NOTE]
|
|
@@ -30,6 +30,11 @@ fn _rust_serializer(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
30
30
|
// Add byte storage class
|
|
31
31
|
m.add_class::<python_bindings::PyByteStorage>()?;
|
|
32
32
|
|
|
33
|
+
// Standalone integrity primitive — registered unconditionally (usable with
|
|
34
|
+
// the checksum feature alone; must not vanish when encryption is off)
|
|
35
|
+
m.add_function(wrap_pyfunction!(python_bindings::checksum_py, m)?)?;
|
|
36
|
+
m.add_function(wrap_pyfunction!(python_bindings::verify_checksum_py, m)?)?;
|
|
37
|
+
|
|
33
38
|
// Add encryption functionality if feature is enabled
|
|
34
39
|
#[cfg(feature = "encryption")]
|
|
35
40
|
{
|
|
@@ -43,13 +48,13 @@ fn _rust_serializer(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
43
48
|
#[cfg(feature = "encryption")]
|
|
44
49
|
m.add(
|
|
45
50
|
"__description__",
|
|
46
|
-
"Raw byte storage with LZ4 compression,
|
|
51
|
+
"Raw byte storage with LZ4 compression, xxHash3-64 checksums, and zero-knowledge encryption",
|
|
47
52
|
)?;
|
|
48
53
|
|
|
49
54
|
#[cfg(not(feature = "encryption"))]
|
|
50
55
|
m.add(
|
|
51
56
|
"__description__",
|
|
52
|
-
"Raw byte storage layer with LZ4 compression and
|
|
57
|
+
"Raw byte storage layer with LZ4 compression and xxHash3-64 checksums",
|
|
53
58
|
)?;
|
|
54
59
|
|
|
55
60
|
Ok(())
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
//! All business logic is delegated to cachekit-core.
|
|
5
5
|
|
|
6
6
|
use cachekit_core::ByteStorage;
|
|
7
|
+
use pyo3::buffer::PyBuffer;
|
|
7
8
|
use pyo3::exceptions::PyValueError;
|
|
8
9
|
use pyo3::prelude::*;
|
|
9
10
|
use pyo3::types::PyBytes;
|
|
@@ -38,9 +39,11 @@ impl PyByteStorage {
|
|
|
38
39
|
/// Returns:
|
|
39
40
|
/// Bytes: Serialized StorageEnvelope
|
|
40
41
|
pub fn store(&self, py: Python, data: &[u8], format: Option<String>) -> PyResult<Py<PyBytes>> {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
// Detach from the GIL: LZ4 + xxh3 on a large payload otherwise blocks every
|
|
43
|
+
// Python thread for the full compression duration (cachekit-core#45).
|
|
44
|
+
// Sound: `data` borrows an immutable `bytes` buffer kept alive by this call.
|
|
45
|
+
let envelope_bytes = py
|
|
46
|
+
.detach(|| self.inner.store(data, format))
|
|
44
47
|
.map_err(|e| PyValueError::new_err(format!("Storage failed: {}", e)))?;
|
|
45
48
|
|
|
46
49
|
Ok(PyBytes::new(py, &envelope_bytes).into())
|
|
@@ -53,22 +56,23 @@ impl PyByteStorage {
|
|
|
53
56
|
///
|
|
54
57
|
/// Returns:
|
|
55
58
|
/// Tuple[bytes, str]: (original_data, format_identifier)
|
|
56
|
-
pub fn retrieve(&self, envelope_bytes: &[u8]) -> PyResult<(Vec<u8>, String)> {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
pub fn retrieve(&self, py: Python, envelope_bytes: &[u8]) -> PyResult<(Vec<u8>, String)> {
|
|
60
|
+
// Detach from the GIL for decompression + checksum (see store()).
|
|
61
|
+
py.detach(|| self.inner.retrieve(envelope_bytes))
|
|
59
62
|
.map_err(|e| PyValueError::new_err(format!("Retrieval failed: {}", e)))
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
/// Get compression ratio for given data
|
|
63
|
-
pub fn estimate_compression(&self, data: &[u8]) -> PyResult<f64> {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
pub fn estimate_compression(&self, py: Python, data: &[u8]) -> PyResult<f64> {
|
|
67
|
+
// Full-payload LZ4 pass — same GIL-blocking profile as store().
|
|
68
|
+
py.detach(|| self.inner.estimate_compression(data))
|
|
66
69
|
.map_err(|e| PyValueError::new_err(format!("Compression estimation failed: {}", e)))
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
/// Validate envelope without extracting data
|
|
70
|
-
pub fn validate(&self, envelope_bytes: &[u8]) -> PyResult<bool> {
|
|
71
|
-
|
|
73
|
+
pub fn validate(&self, py: Python, envelope_bytes: &[u8]) -> PyResult<bool> {
|
|
74
|
+
// Full decompression + checksum under the hood — same GIL-blocking profile.
|
|
75
|
+
Ok(py.detach(|| self.inner.validate(envelope_bytes)))
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
/// Get security limits for clients
|
|
@@ -340,6 +344,47 @@ pub fn key_fingerprint_py(key: &[u8]) -> Vec<u8> {
|
|
|
340
344
|
key_fingerprint(key).to_vec()
|
|
341
345
|
}
|
|
342
346
|
|
|
347
|
+
/// Compute the standalone xxHash3-64 checksum of `data` (8 bytes, big-endian).
|
|
348
|
+
///
|
|
349
|
+
/// Accepts any buffer-protocol object — `bytes`, `bytearray`, `memoryview`,
|
|
350
|
+
/// Arrow buffers — so a serializer holding its payload as a `memoryview`
|
|
351
|
+
/// (e.g. Arrow IPC) can hash it directly, without forcing a `bytes` copy.
|
|
352
|
+
///
|
|
353
|
+
/// NON-cryptographic: detects corruption, not tampering. For tamper-resistance
|
|
354
|
+
/// use @cache.secure (AES-256-GCM), never this checksum. Produces the exact
|
|
355
|
+
/// bytes embedded in every StorageEnvelope, without the LZ4 compression
|
|
356
|
+
/// overhead — for serializers where compression is ineffective (Arrow IPC, JSON).
|
|
357
|
+
#[pyfunction]
|
|
358
|
+
#[pyo3(name = "checksum")]
|
|
359
|
+
pub fn checksum_py(py: Python, data: PyBuffer<u8>) -> PyResult<Py<PyBytes>> {
|
|
360
|
+
let data = data.to_vec(py)?;
|
|
361
|
+
Ok(PyBytes::new(py, &cachekit_core::checksum(&data)).into())
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/// Verify `data` against an expected 8-byte xxHash3-64 checksum.
|
|
365
|
+
///
|
|
366
|
+
/// Both arguments accept any buffer-protocol object (`bytes`, `bytearray`,
|
|
367
|
+
/// `memoryview`, …) — the Arrow verify path slices a `memoryview` (`mv[8:]`),
|
|
368
|
+
/// so a bytes-only signature would break the moment a serializer moves onto
|
|
369
|
+
/// this FFI.
|
|
370
|
+
///
|
|
371
|
+
/// NON-cryptographic: detects corruption, not tampering (see `checksum`).
|
|
372
|
+
/// Raises ValueError if `expected` is not exactly 8 bytes — a truncated
|
|
373
|
+
/// checksum must fail loudly, never return a wrong verdict.
|
|
374
|
+
#[pyfunction]
|
|
375
|
+
#[pyo3(name = "verify_checksum")]
|
|
376
|
+
pub fn verify_checksum_py(
|
|
377
|
+
py: Python,
|
|
378
|
+
data: PyBuffer<u8>,
|
|
379
|
+
expected: PyBuffer<u8>,
|
|
380
|
+
) -> PyResult<bool> {
|
|
381
|
+
let expected: [u8; 8] = expected.to_vec(py)?.try_into().map_err(|v: Vec<u8>| {
|
|
382
|
+
PyValueError::new_err(format!("expected must be exactly 8 bytes, got {}", v.len()))
|
|
383
|
+
})?;
|
|
384
|
+
let data = data.to_vec(py)?;
|
|
385
|
+
Ok(cachekit_core::verify_checksum(&data, &expected))
|
|
386
|
+
}
|
|
387
|
+
|
|
343
388
|
/// Register encryption module with Python
|
|
344
389
|
#[cfg(feature = "encryption")]
|
|
345
390
|
pub fn register_encryption_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
r"""cachekit - Caching decorator for Python applications.
|
|
2
2
|
|
|
3
3
|
A robust, production-ready Python library that provides intelligent caching
|
|
4
|
-
capabilities with advanced features like
|
|
5
|
-
|
|
4
|
+
capabilities with advanced features like multi-serialization support,
|
|
5
|
+
distributed locking, and automatic corruption detection.
|
|
6
6
|
|
|
7
7
|
Supports pluggable backends: Redis (default), CachekitIO SaaS, File, and custom.
|
|
8
8
|
|
|
@@ -68,7 +68,7 @@ Example Usage:
|
|
|
68
68
|
```
|
|
69
69
|
"""
|
|
70
70
|
|
|
71
|
-
__version__ = "0.
|
|
71
|
+
__version__ = "0.14.0"
|
|
72
72
|
|
|
73
73
|
from collections.abc import Callable
|
|
74
74
|
from typing import Any, TypeVar
|
|
@@ -98,6 +98,16 @@ from .health import (
|
|
|
98
98
|
health_check_handler,
|
|
99
99
|
)
|
|
100
100
|
|
|
101
|
+
# Interop mode (interop/v1): cross-SDK key generation and value codec.
|
|
102
|
+
# The decorator API is @cache(interop="op", namespace="ns"); these helpers are
|
|
103
|
+
# for manual key/value handling (debugging, out-of-band writers, migrations).
|
|
104
|
+
from .interop import (
|
|
105
|
+
InteropError,
|
|
106
|
+
decode_interop_value,
|
|
107
|
+
encode_interop_value,
|
|
108
|
+
generate_interop_key,
|
|
109
|
+
)
|
|
110
|
+
|
|
101
111
|
# L1/L2 architecture integrated into standard cache interface
|
|
102
112
|
# No separate imports needed - cache.minimal/.production/.secure handle L1+L2 transparently
|
|
103
113
|
# Import reliability configuration
|
|
@@ -113,9 +123,13 @@ __all__ = [
|
|
|
113
123
|
"CacheInfo",
|
|
114
124
|
"CircuitBreakerConfig",
|
|
115
125
|
"DecoratorConfig",
|
|
126
|
+
"decode_interop_value",
|
|
127
|
+
"encode_interop_value",
|
|
128
|
+
"generate_interop_key",
|
|
116
129
|
"get_health_checker",
|
|
117
130
|
"health_check_handler",
|
|
118
131
|
"HealthCheckResult",
|
|
119
132
|
"HealthLevel",
|
|
120
133
|
"HealthStatus",
|
|
134
|
+
"InteropError",
|
|
121
135
|
]
|
|
@@ -119,8 +119,10 @@ class TTLInspectableBackend(Protocol):
|
|
|
119
119
|
TTL refresh for frequently-accessed keys.
|
|
120
120
|
|
|
121
121
|
Not all backends support this capability:
|
|
122
|
-
- Supported: Redis, PostgreSQL, DynamoDB, SQLite
|
|
123
|
-
- Not supported: HTTP (stateless),
|
|
122
|
+
- Supported: Redis, CachekitIO, File, PostgreSQL, DynamoDB, SQLite
|
|
123
|
+
- Not supported: HTTP (stateless), S3 (limited)
|
|
124
|
+
- Memcached: partial — implements ``refresh_ttl`` (via ``touch``) but NOT ``get_ttl``
|
|
125
|
+
(no protocol command to read remaining TTL), so it is not a full TTLInspectableBackend.
|
|
124
126
|
|
|
125
127
|
Example:
|
|
126
128
|
>>> # TTL inspection pattern (async context):
|
|
@@ -34,6 +34,20 @@ _logger = get_structured_logger(__name__)
|
|
|
34
34
|
# preferring the header. See protocol spec/saas-api.md (DELETE .../lock).
|
|
35
35
|
LOCK_ID_HEADER = "X-CacheKit-Lock-Id"
|
|
36
36
|
|
|
37
|
+
# Protocol-canonical TTL header (spec/saas-api.md). The legacy X-TTL is sent
|
|
38
|
+
# alongside it until the dual-reading server (saas#245) is deployed everywhere;
|
|
39
|
+
# sending both is value-identical and safe against either server generation.
|
|
40
|
+
# TODO(LAB-381 follow-up): drop X-TTL once saas#245 is live in prod.
|
|
41
|
+
TTL_HEADER = "X-CacheKit-TTL"
|
|
42
|
+
LEGACY_TTL_HEADER = "X-TTL"
|
|
43
|
+
|
|
44
|
+
# Stale-while-revalidate (LAB-381, spec/saas-api.md#stale-while-revalidate).
|
|
45
|
+
# STALE_TTL_HEADER rides PUTs to open a stale-grace window past the fresh TTL;
|
|
46
|
+
# FRESHNESS_HEADER labels every GET/HEAD 200 as fresh|stale. Pre-SWR servers
|
|
47
|
+
# ignore the former and never emit the latter.
|
|
48
|
+
STALE_TTL_HEADER = "X-CacheKit-Stale-TTL"
|
|
49
|
+
FRESHNESS_HEADER = "X-CacheKit-Freshness"
|
|
50
|
+
|
|
37
51
|
|
|
38
52
|
def _inject_metrics_headers(stats: _FunctionStats | None) -> dict[str, str]:
|
|
39
53
|
"""Extract cache metrics and format as HTTP headers.
|
|
@@ -335,22 +349,62 @@ class CachekitIOBackend:
|
|
|
335
349
|
return None
|
|
336
350
|
raise
|
|
337
351
|
|
|
338
|
-
|
|
352
|
+
@staticmethod
|
|
353
|
+
def _is_stale(response: httpx.Response) -> bool:
|
|
354
|
+
"""Map the X-CacheKit-Freshness header to staleness (spec/saas-api.md).
|
|
355
|
+
|
|
356
|
+
Absent header = fresh (pre-SWR server); unrecognized value = stale
|
|
357
|
+
(revalidation is the conservative action). Tokens are lowercase and
|
|
358
|
+
case-sensitive per spec.
|
|
359
|
+
"""
|
|
360
|
+
value = response.headers.get(FRESHNESS_HEADER)
|
|
361
|
+
return value is not None and value != "fresh"
|
|
362
|
+
|
|
363
|
+
def get_with_freshness(self, key: str) -> tuple[bytes, bool] | None:
|
|
364
|
+
"""Retrieve value plus its SWR freshness (sync).
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
``(value, is_stale)`` on a hit — ``is_stale`` is True only for an
|
|
368
|
+
entry in its stale-grace window (LAB-381) — or None on a miss.
|
|
369
|
+
|
|
370
|
+
Raises:
|
|
371
|
+
BackendError: If operation fails (network, auth, etc.)
|
|
372
|
+
"""
|
|
373
|
+
try:
|
|
374
|
+
response = self._request_sync("GET", key)
|
|
375
|
+
return response.content, self._is_stale(response)
|
|
376
|
+
except BackendError as exc:
|
|
377
|
+
if exc.original_exception and isinstance(exc.original_exception, httpx.HTTPStatusError):
|
|
378
|
+
if exc.original_exception.response.status_code == 404:
|
|
379
|
+
return None
|
|
380
|
+
raise
|
|
381
|
+
|
|
382
|
+
def set(self, key: str, value: bytes, ttl: int | None = None, stale_ttl: int | None = None) -> None:
|
|
339
383
|
"""Store value in cache (sync).
|
|
340
384
|
|
|
341
385
|
Args:
|
|
342
386
|
key: Cache key
|
|
343
387
|
value: Bytes to cache
|
|
344
388
|
ttl: Time-to-live in seconds (optional)
|
|
389
|
+
stale_ttl: Stale-grace window in seconds past the fresh TTL
|
|
390
|
+
(LAB-381 SWR). Only honoured alongside an explicit ``ttl``;
|
|
391
|
+
pre-SWR servers ignore it.
|
|
345
392
|
|
|
346
393
|
Raises:
|
|
347
394
|
BackendError: If operation fails
|
|
348
395
|
"""
|
|
349
|
-
|
|
350
|
-
if ttl is not None:
|
|
351
|
-
headers["X-TTL"] = str(ttl)
|
|
396
|
+
self._request_sync("PUT", key, content=value, headers=self._set_headers(ttl, stale_ttl))
|
|
352
397
|
|
|
353
|
-
|
|
398
|
+
@staticmethod
|
|
399
|
+
def _set_headers(ttl: int | None, stale_ttl: int | None) -> dict[str, str]:
|
|
400
|
+
"""PUT timing headers: canonical + legacy TTL (dual-send until saas#245 deploys), stale window."""
|
|
401
|
+
headers: dict[str, str] = {}
|
|
402
|
+
if ttl is not None:
|
|
403
|
+
headers[TTL_HEADER] = str(ttl)
|
|
404
|
+
headers[LEGACY_TTL_HEADER] = str(ttl)
|
|
405
|
+
if stale_ttl is not None and stale_ttl > 0 and ttl is not None:
|
|
406
|
+
headers[STALE_TTL_HEADER] = str(stale_ttl)
|
|
407
|
+
return headers
|
|
354
408
|
|
|
355
409
|
def delete(self, key: str) -> bool:
|
|
356
410
|
"""Delete key from cache (sync).
|
|
@@ -457,22 +511,21 @@ class CachekitIOBackend:
|
|
|
457
511
|
return None
|
|
458
512
|
raise
|
|
459
513
|
|
|
460
|
-
async def set_async(self, key: str, value: bytes, ttl: int | None = None) -> None:
|
|
514
|
+
async def set_async(self, key: str, value: bytes, ttl: int | None = None, stale_ttl: int | None = None) -> None:
|
|
461
515
|
"""Store value in cache (async).
|
|
462
516
|
|
|
463
517
|
Args:
|
|
464
518
|
key: Cache key
|
|
465
519
|
value: Bytes to cache
|
|
466
520
|
ttl: Time-to-live in seconds (optional)
|
|
521
|
+
stale_ttl: Stale-grace window in seconds past the fresh TTL
|
|
522
|
+
(LAB-381 SWR). Only honoured alongside an explicit ``ttl``;
|
|
523
|
+
pre-SWR servers ignore it.
|
|
467
524
|
|
|
468
525
|
Raises:
|
|
469
526
|
BackendError: If operation fails
|
|
470
527
|
"""
|
|
471
|
-
|
|
472
|
-
if ttl is not None:
|
|
473
|
-
headers["X-TTL"] = str(ttl)
|
|
474
|
-
|
|
475
|
-
await self._request_async("PUT", key, content=value, headers=headers)
|
|
528
|
+
await self._request_async("PUT", key, content=value, headers=self._set_headers(ttl, stale_ttl))
|
|
476
529
|
|
|
477
530
|
async def delete_async(self, key: str) -> bool:
|
|
478
531
|
"""Delete key from cache (async).
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Session headers for cachekit.io requests.
|
|
2
|
+
|
|
3
|
+
Process session identity (UUID + start timestamp, PID-aware) lives in
|
|
4
|
+
:mod:`cachekit.decorators.session` — the single source of truth shared
|
|
5
|
+
with the decorator stats tracker. This module only assembles the SaaS
|
|
6
|
+
HTTP headers from it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
|
|
13
|
+
from cachekit.decorators.session import get_session_id, get_session_start_ms
|
|
14
|
+
|
|
15
|
+
# Thread-local storage for header dict caching
|
|
16
|
+
_thread_local = threading.local()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_session_headers() -> dict[str, str]:
|
|
20
|
+
"""Get session headers for cachekit.io requests.
|
|
21
|
+
|
|
22
|
+
Returns a dictionary containing X-CacheKit-Session-ID and
|
|
23
|
+
X-CacheKit-Session-Start headers needed for cachekit.io API requests.
|
|
24
|
+
|
|
25
|
+
The returned dict is cached in thread-local storage to avoid repeated
|
|
26
|
+
dictionary allocations and revalidated against the current session ID,
|
|
27
|
+
so a process restart or fork (PID change) transparently regenerates it.
|
|
28
|
+
A fresh copy is returned on each call to prevent accidental mutation of
|
|
29
|
+
cached data while maintaining efficiency.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
dict[str, str]: Headers dict with keys:
|
|
33
|
+
- X-CacheKit-Session-ID: Process session UUID
|
|
34
|
+
- X-CacheKit-Session-Start: Session start milliseconds
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
>>> headers = get_session_headers()
|
|
38
|
+
>>> "X-CacheKit-Session-ID" in headers
|
|
39
|
+
True
|
|
40
|
+
>>> "X-CacheKit-Session-Start" in headers
|
|
41
|
+
True
|
|
42
|
+
>>> headers["X-CacheKit-Session-Start"].isdigit()
|
|
43
|
+
True
|
|
44
|
+
"""
|
|
45
|
+
session_id = get_session_id()
|
|
46
|
+
cached = getattr(_thread_local, "headers", None)
|
|
47
|
+
if cached is None or cached["X-CacheKit-Session-ID"] != session_id:
|
|
48
|
+
_thread_local.headers = {
|
|
49
|
+
"X-CacheKit-Session-ID": session_id,
|
|
50
|
+
"X-CacheKit-Session-Start": str(get_session_start_ms()),
|
|
51
|
+
}
|
|
52
|
+
return dict(_thread_local.headers)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"get_session_id",
|
|
57
|
+
"get_session_start_ms",
|
|
58
|
+
"get_session_headers",
|
|
59
|
+
]
|
|
@@ -5,6 +5,7 @@ This module implements BaseBackend protocol for filesystem-based caching with:
|
|
|
5
5
|
- Atomic writes via write-then-rename pattern
|
|
6
6
|
- LRU eviction triggered at 90% capacity, evicting to 70%
|
|
7
7
|
- TTL-based expiration with secure 14-byte header format
|
|
8
|
+
- TTL inspection & refresh (TTLInspectableBackend): get_ttl / refresh_ttl off the header
|
|
8
9
|
- Security features: O_NOFOLLOW, realpath resolution, permission enforcement
|
|
9
10
|
- Blake2b key hashing (16 bytes hex = 32 chars) for filename safety
|
|
10
11
|
"""
|
|
@@ -571,6 +572,136 @@ class FileBackend:
|
|
|
571
572
|
"error": str(exc),
|
|
572
573
|
}
|
|
573
574
|
|
|
575
|
+
# ==================== TTLInspectableBackend Protocol ====================
|
|
576
|
+
|
|
577
|
+
async def get_ttl(self, key: str) -> int | None:
|
|
578
|
+
"""Remaining TTL in seconds, read from the on-disk expiry header (bytes [6:14]).
|
|
579
|
+
|
|
580
|
+
Returns None when the key is missing, permanent (expiry field == 0), or already
|
|
581
|
+
expired. Expired/corrupt entries are unlinked on read, mirroring ``get``/``exists``.
|
|
582
|
+
|
|
583
|
+
The sync file I/O inside an ``async`` signature intentionally matches the Redis
|
|
584
|
+
provider (async method wrapping a blocking client call); local disk reads are fast
|
|
585
|
+
and this keeps every backend's TTLInspectableBackend surface uniform.
|
|
586
|
+
"""
|
|
587
|
+
file_path = self._key_to_path(key)
|
|
588
|
+
|
|
589
|
+
with self._lock:
|
|
590
|
+
try:
|
|
591
|
+
fd = os.open(file_path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
592
|
+
fd_closed = False
|
|
593
|
+
try:
|
|
594
|
+
self._acquire_file_lock(fd, exclusive=False)
|
|
595
|
+
try:
|
|
596
|
+
header = os.read(fd, HEADER_SIZE)
|
|
597
|
+
if len(header) < HEADER_SIZE or header[0:2] != MAGIC or header[2] != FORMAT_VERSION:
|
|
598
|
+
os.close(fd)
|
|
599
|
+
fd_closed = True
|
|
600
|
+
self._safe_unlink(file_path)
|
|
601
|
+
return None
|
|
602
|
+
|
|
603
|
+
expiry_timestamp = struct.unpack(">Q", header[6:14])[0] # uint64 BE
|
|
604
|
+
if expiry_timestamp == 0:
|
|
605
|
+
return None # permanent, no TTL to report
|
|
606
|
+
|
|
607
|
+
remaining = expiry_timestamp - time.time()
|
|
608
|
+
if remaining <= 0:
|
|
609
|
+
# Expired: unlink and report absent (same as get/exists).
|
|
610
|
+
os.close(fd)
|
|
611
|
+
fd_closed = True
|
|
612
|
+
self._safe_unlink(file_path)
|
|
613
|
+
return None
|
|
614
|
+
return int(remaining) # whole-second granularity, matching Redis TTL
|
|
615
|
+
finally:
|
|
616
|
+
self._release_file_lock(fd)
|
|
617
|
+
finally:
|
|
618
|
+
if not fd_closed:
|
|
619
|
+
os.close(fd)
|
|
620
|
+
|
|
621
|
+
except FileNotFoundError:
|
|
622
|
+
return None
|
|
623
|
+
except OSError as exc:
|
|
624
|
+
if exc.errno in (errno.ENOENT, errno.ELOOP):
|
|
625
|
+
return None # missing, or symlink rejected by O_NOFOLLOW
|
|
626
|
+
raise BackendError(
|
|
627
|
+
f"Failed to read cache file TTL: {exc}",
|
|
628
|
+
error_type=self._classify_os_error(exc, is_directory=False),
|
|
629
|
+
original_exception=exc,
|
|
630
|
+
operation="get_ttl",
|
|
631
|
+
key=key,
|
|
632
|
+
) from exc
|
|
633
|
+
|
|
634
|
+
async def refresh_ttl(self, key: str, ttl: int) -> bool:
|
|
635
|
+
"""Slide a key's expiry by rewriting the 8-byte timestamp field in place.
|
|
636
|
+
|
|
637
|
+
Returns True if the key existed and was refreshed, False if it is missing or already
|
|
638
|
+
expired (an expired entry is treated as absent and unlinked, mirroring ``get``). A
|
|
639
|
+
``ttl`` of 0 makes the entry permanent, matching ``set``. No on-disk format change:
|
|
640
|
+
only bytes [6:14] are rewritten, so the payload and all other header fields are
|
|
641
|
+
untouched (and cross-SDK File readers stay compatible).
|
|
642
|
+
"""
|
|
643
|
+
# Same TTL bounds as set() (security: prevent integer overflow/underflow).
|
|
644
|
+
if ttl == 0:
|
|
645
|
+
new_expiry = 0
|
|
646
|
+
elif ttl < 0 or ttl > MAX_TTL_SECONDS:
|
|
647
|
+
raise BackendError(
|
|
648
|
+
f"TTL {ttl} out of range [0, {MAX_TTL_SECONDS}] (max 10 years)",
|
|
649
|
+
BackendErrorType.PERMANENT,
|
|
650
|
+
)
|
|
651
|
+
else:
|
|
652
|
+
new_expiry = int(time.time() + ttl)
|
|
653
|
+
|
|
654
|
+
file_path = self._key_to_path(key)
|
|
655
|
+
|
|
656
|
+
with self._lock:
|
|
657
|
+
try:
|
|
658
|
+
fd = os.open(file_path, os.O_RDWR | os.O_NOFOLLOW)
|
|
659
|
+
fd_closed = False
|
|
660
|
+
try:
|
|
661
|
+
self._acquire_file_lock(fd, exclusive=True)
|
|
662
|
+
try:
|
|
663
|
+
header = os.read(fd, HEADER_SIZE)
|
|
664
|
+
if len(header) < HEADER_SIZE or header[0:2] != MAGIC or header[2] != FORMAT_VERSION:
|
|
665
|
+
os.close(fd)
|
|
666
|
+
fd_closed = True
|
|
667
|
+
self._safe_unlink(file_path)
|
|
668
|
+
return False
|
|
669
|
+
|
|
670
|
+
current_expiry = struct.unpack(">Q", header[6:14])[0]
|
|
671
|
+
if current_expiry > 0 and time.time() > current_expiry:
|
|
672
|
+
# Already expired: treat as absent (mirror get/exists) and unlink.
|
|
673
|
+
os.close(fd)
|
|
674
|
+
fd_closed = True
|
|
675
|
+
self._safe_unlink(file_path)
|
|
676
|
+
return False
|
|
677
|
+
|
|
678
|
+
# Overwrite ONLY the expiry field. ponytail: an 8-byte in-place write to
|
|
679
|
+
# a fixed offset is atomic enough for a cache timestamp — a torn write on
|
|
680
|
+
# power loss yields a wrong expiry, never a corrupt payload (magic/version
|
|
681
|
+
# are untouched), so the entry just expires early/late. No rewrite-rename.
|
|
682
|
+
os.lseek(fd, 6, os.SEEK_SET)
|
|
683
|
+
os.write(fd, struct.pack(">Q", new_expiry))
|
|
684
|
+
os.fsync(fd)
|
|
685
|
+
return True
|
|
686
|
+
finally:
|
|
687
|
+
self._release_file_lock(fd)
|
|
688
|
+
finally:
|
|
689
|
+
if not fd_closed:
|
|
690
|
+
os.close(fd)
|
|
691
|
+
|
|
692
|
+
except FileNotFoundError:
|
|
693
|
+
return False
|
|
694
|
+
except OSError as exc:
|
|
695
|
+
if exc.errno in (errno.ENOENT, errno.ELOOP):
|
|
696
|
+
return False # missing, or symlink rejected by O_NOFOLLOW
|
|
697
|
+
raise BackendError(
|
|
698
|
+
f"Failed to refresh cache file TTL: {exc}",
|
|
699
|
+
error_type=self._classify_os_error(exc, is_directory=False),
|
|
700
|
+
original_exception=exc,
|
|
701
|
+
operation="refresh_ttl",
|
|
702
|
+
key=key,
|
|
703
|
+
) from exc
|
|
704
|
+
|
|
574
705
|
# Private helper methods
|
|
575
706
|
|
|
576
707
|
def _key_to_path(self, key: str) -> str:
|