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.
Files changed (97) hide show
  1. {cachekit-0.11.1 → cachekit-0.14.0}/Cargo.lock +5 -5
  2. {cachekit-0.11.1 → cachekit-0.14.0}/PKG-INFO +5 -4
  3. {cachekit-0.11.1 → cachekit-0.14.0}/README.md +4 -3
  4. {cachekit-0.11.1 → cachekit-0.14.0}/pyproject.toml +2 -1
  5. {cachekit-0.11.1 → cachekit-0.14.0}/rust/Cargo.toml +2 -2
  6. {cachekit-0.11.1 → cachekit-0.14.0}/rust/README.md +4 -3
  7. {cachekit-0.11.1 → cachekit-0.14.0}/rust/src/lib.rs +7 -2
  8. {cachekit-0.11.1 → cachekit-0.14.0}/rust/src/python_bindings.rs +56 -11
  9. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/__init__.py +17 -3
  10. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/base.py +4 -2
  11. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/cachekitio/backend.py +64 -11
  12. cachekit-0.14.0/src/cachekit/backends/cachekitio/session.py +59 -0
  13. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/file/backend.py +131 -0
  14. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/memcached/backend.py +43 -0
  15. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/provider.py +49 -0
  16. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/redis/backend.py +40 -15
  17. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/redis/client.py +64 -14
  18. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/redis/config.py +13 -0
  19. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/redis/provider.py +17 -6
  20. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/cache_handler.py +552 -48
  21. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/config/decorator.py +44 -8
  22. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/config/nested.py +26 -6
  23. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/config/settings.py +15 -29
  24. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/config/singleton.py +2 -2
  25. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/decorators/intent.py +13 -3
  26. cachekit-0.14.0/src/cachekit/decorators/session.py +133 -0
  27. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/decorators/wrapper.py +755 -84
  28. cachekit-0.14.0/src/cachekit/interop.py +496 -0
  29. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/l1_cache.py +37 -12
  30. cachekit-0.14.0/src/cachekit/object_cache.py +471 -0
  31. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/serializers/__init__.py +5 -1
  32. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/serializers/auto_serializer.py +29 -8
  33. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/serializers/base.py +16 -0
  34. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/serializers/encryption_wrapper.py +71 -20
  35. cachekit-0.14.0/src/cachekit/serializers/interop_serializer.py +64 -0
  36. cachekit-0.11.1/src/cachekit/backends/cachekitio/session.py +0 -164
  37. cachekit-0.11.1/src/cachekit/decorators/session.py +0 -42
  38. cachekit-0.11.1/src/cachekit/object_cache.py +0 -199
  39. {cachekit-0.11.1 → cachekit-0.14.0}/Cargo.toml +0 -0
  40. {cachekit-0.11.1 → cachekit-0.14.0}/LICENSE +0 -0
  41. {cachekit-0.11.1 → cachekit-0.14.0}/rust/Makefile +0 -0
  42. {cachekit-0.11.1 → cachekit-0.14.0}/rust/TEST_EXPANSION_SUMMARY.md +0 -0
  43. {cachekit-0.11.1 → cachekit-0.14.0}/rust/supply-chain/audits.toml +0 -0
  44. {cachekit-0.11.1 → cachekit-0.14.0}/rust/supply-chain/config.toml +0 -0
  45. {cachekit-0.11.1 → cachekit-0.14.0}/rust/supply-chain/imports.lock +0 -0
  46. {cachekit-0.11.1 → cachekit-0.14.0}/rust/tsan_suppressions.txt +0 -0
  47. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/__init__.py +0 -0
  48. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/base_config.py +0 -0
  49. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/cachekitio/__init__.py +0 -0
  50. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/cachekitio/client.py +0 -0
  51. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/cachekitio/config.py +0 -0
  52. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/cachekitio/error_handler.py +0 -0
  53. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/errors.py +0 -0
  54. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/file/__init__.py +0 -0
  55. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/file/config.py +0 -0
  56. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/memcached/__init__.py +0 -0
  57. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/memcached/config.py +0 -0
  58. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/memcached/error_handler.py +0 -0
  59. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/redis/__init__.py +0 -0
  60. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/backends/redis/error_handler.py +0 -0
  61. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/config/__init__.py +0 -0
  62. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/config/validation.py +0 -0
  63. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/decorators/__init__.py +0 -0
  64. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/decorators/local_wrapper.py +0 -0
  65. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/decorators/main.py +0 -0
  66. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/decorators/orchestrator.py +0 -0
  67. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/decorators/stats_context.py +0 -0
  68. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/decorators/tenant_context.py +0 -0
  69. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/decorators/utils/__init__.py +0 -0
  70. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/di.py +0 -0
  71. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/hash_utils.py +0 -0
  72. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/health.py +0 -0
  73. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/hiredis_compat.py +0 -0
  74. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/imports.py +0 -0
  75. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/invalidation/__init__.py +0 -0
  76. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/invalidation/channel.py +0 -0
  77. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/invalidation/event.py +0 -0
  78. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/invalidation/redis_channel.py +0 -0
  79. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/key_generator.py +0 -0
  80. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/logging.py +0 -0
  81. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/monitoring/__init__.py +0 -0
  82. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/monitoring/correlation_tracking.py +0 -0
  83. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/monitoring/pool_monitor.py +0 -0
  84. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/monitoring/protocols.py +0 -0
  85. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/py.typed +0 -0
  86. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/reliability/__init__.py +0 -0
  87. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/reliability/adaptive_timeout.py +0 -0
  88. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/reliability/async_metrics.py +0 -0
  89. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/reliability/circuit_breaker.py +0 -0
  90. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/reliability/error_classification.py +0 -0
  91. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/reliability/load_control.py +0 -0
  92. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/reliability/metrics_collection.py +0 -0
  93. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/reliability/profiles.py +0 -0
  94. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/serializers/arrow_serializer.py +0 -0
  95. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/serializers/orjson_serializer.py +0 -0
  96. {cachekit-0.11.1 → cachekit-0.14.0}/src/cachekit/serializers/standard_serializer.py +0 -0
  97. {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.2.1"
248
+ version = "0.3.0"
249
249
  source = "registry+https://github.com/rust-lang/crates.io-index"
250
- checksum = "01870e86fa79ca9ee521b5b9c2bcff8428eab95fb343f75d35a6fce159bb1577"
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.11.1"
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.18"
471
+ version = "0.9.20"
472
472
  source = "registry+https://github.com/rust-lang/crates.io-index"
473
- checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
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.11.1
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 decorated function (shared across all calls)
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
- CACHEKIT_MAX_CHUNK_SIZE_MB=100
484
- CACHEKIT_ENABLE_COMPRESSION=true
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 decorated function (shared across all calls)
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
- CACHEKIT_MAX_CHUNK_SIZE_MB=100
430
- CACHEKIT_ENABLE_COMPRESSION=true
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.11.1"
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.11.1"
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.2.0", features = ["compression", "checksum", "messagepack", "encryption"] }
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 decorated function (shared across all calls)
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
- CACHEKIT_MAX_CHUNK_SIZE_MB=100
430
- CACHEKIT_ENABLE_COMPRESSION=true
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, Blake3 checksums, and zero-knowledge encryption",
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 Blake3 checksums",
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
- let envelope_bytes = self
42
- .inner
43
- .store(data, format)
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
- self.inner
58
- .retrieve(envelope_bytes)
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
- self.inner
65
- .estimate_compression(data)
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
- Ok(self.inner.validate(envelope_bytes))
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 chunked data handling, multi-serialization
5
- support, distributed locking, and automatic corruption detection.
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.11.1"
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, FileSystem
123
- - Not supported: HTTP (stateless), Memcached (limited), S3 (limited)
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
- def set(self, key: str, value: bytes, ttl: int | None = None) -> None:
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
- headers = {}
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
- self._request_sync("PUT", key, content=value, headers=headers)
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
- headers = {}
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: