cachekit 0.6.1__tar.gz → 0.8.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 (92) hide show
  1. {cachekit-0.6.1 → cachekit-0.8.0}/Cargo.lock +127 -9
  2. {cachekit-0.6.1 → cachekit-0.8.0}/PKG-INFO +1 -1
  3. {cachekit-0.6.1 → cachekit-0.8.0}/pyproject.toml +1 -1
  4. {cachekit-0.6.1 → cachekit-0.8.0}/rust/Cargo.toml +2 -2
  5. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/__init__.py +1 -1
  6. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/base.py +19 -3
  7. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/cachekitio/backend.py +97 -22
  8. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/provider.py +79 -12
  9. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/redis/provider.py +9 -2
  10. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/cache_handler.py +23 -4
  11. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/config/decorator.py +7 -2
  12. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/config/nested.py +7 -2
  13. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/decorators/wrapper.py +74 -77
  14. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/key_generator.py +27 -2
  15. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/reliability/adaptive_timeout.py +10 -8
  16. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/serializers/__init__.py +2 -1
  17. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/serializers/auto_serializer.py +32 -1
  18. {cachekit-0.6.1 → cachekit-0.8.0}/Cargo.toml +0 -0
  19. {cachekit-0.6.1 → cachekit-0.8.0}/LICENSE +0 -0
  20. {cachekit-0.6.1 → cachekit-0.8.0}/README.md +0 -0
  21. {cachekit-0.6.1 → cachekit-0.8.0}/rust/Makefile +0 -0
  22. {cachekit-0.6.1 → cachekit-0.8.0}/rust/README.md +0 -0
  23. {cachekit-0.6.1 → cachekit-0.8.0}/rust/TEST_EXPANSION_SUMMARY.md +0 -0
  24. {cachekit-0.6.1 → cachekit-0.8.0}/rust/src/lib.rs +0 -0
  25. {cachekit-0.6.1 → cachekit-0.8.0}/rust/src/python_bindings.rs +0 -0
  26. {cachekit-0.6.1 → cachekit-0.8.0}/rust/supply-chain/audits.toml +0 -0
  27. {cachekit-0.6.1 → cachekit-0.8.0}/rust/supply-chain/config.toml +0 -0
  28. {cachekit-0.6.1 → cachekit-0.8.0}/rust/supply-chain/imports.lock +0 -0
  29. {cachekit-0.6.1 → cachekit-0.8.0}/rust/tsan_suppressions.txt +0 -0
  30. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/__init__.py +0 -0
  31. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/base_config.py +0 -0
  32. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/cachekitio/__init__.py +0 -0
  33. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/cachekitio/client.py +0 -0
  34. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/cachekitio/config.py +0 -0
  35. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/cachekitio/error_handler.py +0 -0
  36. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/cachekitio/session.py +0 -0
  37. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/errors.py +0 -0
  38. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/file/__init__.py +0 -0
  39. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/file/backend.py +0 -0
  40. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/file/config.py +0 -0
  41. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/memcached/__init__.py +0 -0
  42. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/memcached/backend.py +0 -0
  43. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/memcached/config.py +0 -0
  44. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/memcached/error_handler.py +0 -0
  45. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/redis/__init__.py +0 -0
  46. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/redis/backend.py +0 -0
  47. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/redis/client.py +0 -0
  48. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/redis/config.py +0 -0
  49. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/backends/redis/error_handler.py +0 -0
  50. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/config/__init__.py +0 -0
  51. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/config/settings.py +0 -0
  52. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/config/singleton.py +0 -0
  53. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/config/validation.py +0 -0
  54. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/decorators/__init__.py +0 -0
  55. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/decorators/intent.py +0 -0
  56. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/decorators/local_wrapper.py +0 -0
  57. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/decorators/main.py +0 -0
  58. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/decorators/orchestrator.py +0 -0
  59. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/decorators/session.py +0 -0
  60. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/decorators/stats_context.py +0 -0
  61. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/decorators/tenant_context.py +0 -0
  62. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/decorators/utils/__init__.py +0 -0
  63. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/di.py +0 -0
  64. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/hash_utils.py +0 -0
  65. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/health.py +0 -0
  66. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/hiredis_compat.py +0 -0
  67. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/imports.py +0 -0
  68. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/invalidation/__init__.py +0 -0
  69. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/invalidation/channel.py +0 -0
  70. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/invalidation/event.py +0 -0
  71. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/invalidation/redis_channel.py +0 -0
  72. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/l1_cache.py +0 -0
  73. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/logging.py +0 -0
  74. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/monitoring/__init__.py +0 -0
  75. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/monitoring/correlation_tracking.py +0 -0
  76. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/monitoring/pool_monitor.py +0 -0
  77. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/monitoring/protocols.py +0 -0
  78. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/object_cache.py +0 -0
  79. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/py.typed +0 -0
  80. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/reliability/__init__.py +0 -0
  81. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/reliability/async_metrics.py +0 -0
  82. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/reliability/circuit_breaker.py +0 -0
  83. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/reliability/error_classification.py +0 -0
  84. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/reliability/load_control.py +0 -0
  85. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/reliability/metrics_collection.py +0 -0
  86. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/reliability/profiles.py +0 -0
  87. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/serializers/arrow_serializer.py +0 -0
  88. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/serializers/base.py +0 -0
  89. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/serializers/encryption_wrapper.py +0 -0
  90. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/serializers/orjson_serializer.py +0 -0
  91. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/serializers/standard_serializer.py +0 -0
  92. {cachekit-0.6.1 → cachekit-0.8.0}/src/cachekit/serializers/wrapper.py +0 -0
@@ -17,6 +17,43 @@ version = "2.0.1"
17
17
  source = "registry+https://github.com/rust-lang/crates.io-index"
18
18
  checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
19
19
 
20
+ [[package]]
21
+ name = "aead"
22
+ version = "0.5.2"
23
+ source = "registry+https://github.com/rust-lang/crates.io-index"
24
+ checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
25
+ dependencies = [
26
+ "crypto-common",
27
+ "generic-array",
28
+ ]
29
+
30
+ [[package]]
31
+ name = "aes"
32
+ version = "0.8.4"
33
+ source = "registry+https://github.com/rust-lang/crates.io-index"
34
+ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
35
+ dependencies = [
36
+ "cfg-if",
37
+ "cipher",
38
+ "cpufeatures",
39
+ "zeroize",
40
+ ]
41
+
42
+ [[package]]
43
+ name = "aes-gcm"
44
+ version = "0.10.3"
45
+ source = "registry+https://github.com/rust-lang/crates.io-index"
46
+ checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
47
+ dependencies = [
48
+ "aead",
49
+ "aes",
50
+ "cipher",
51
+ "ctr",
52
+ "ghash",
53
+ "subtle",
54
+ "zeroize",
55
+ ]
56
+
20
57
  [[package]]
21
58
  name = "ahash"
22
59
  version = "0.8.12"
@@ -208,14 +245,17 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
208
245
 
209
246
  [[package]]
210
247
  name = "cachekit-core"
211
- version = "0.1.1"
248
+ version = "0.2.0"
212
249
  source = "registry+https://github.com/rust-lang/crates.io-index"
213
- checksum = "3e264be91367e582a103017104f2fcc6f6f12ca59ded81f53016bc60752a94ae"
250
+ checksum = "9622b3c3e47fcf7abfde287c557e6171674e37842a2dd26fda03f63720542f17"
214
251
  dependencies = [
252
+ "aes",
253
+ "aes-gcm",
215
254
  "byteorder",
216
255
  "bytes",
217
256
  "cbindgen",
218
257
  "generic-array",
258
+ "getrandom 0.2.17",
219
259
  "hkdf",
220
260
  "hmac",
221
261
  "lz4_flex",
@@ -224,14 +264,14 @@ dependencies = [
224
264
  "serde",
225
265
  "serde_bytes",
226
266
  "sha2",
227
- "thiserror 1.0.69",
267
+ "thiserror 2.0.18",
228
268
  "xxhash-rust",
229
269
  "zeroize",
230
270
  ]
231
271
 
232
272
  [[package]]
233
273
  name = "cachekit-rs"
234
- version = "0.6.1"
274
+ version = "0.8.0"
235
275
  dependencies = [
236
276
  "cachekit-core",
237
277
  "criterion",
@@ -312,6 +352,16 @@ dependencies = [
312
352
  "half",
313
353
  ]
314
354
 
355
+ [[package]]
356
+ name = "cipher"
357
+ version = "0.4.4"
358
+ source = "registry+https://github.com/rust-lang/crates.io-index"
359
+ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
360
+ dependencies = [
361
+ "crypto-common",
362
+ "inout",
363
+ ]
364
+
315
365
  [[package]]
316
366
  name = "clap"
317
367
  version = "4.6.0"
@@ -444,6 +494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
444
494
  checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
445
495
  dependencies = [
446
496
  "generic-array",
497
+ "rand_core 0.6.4",
447
498
  "typenum",
448
499
  ]
449
500
 
@@ -463,6 +514,15 @@ version = "0.0.6"
463
514
  source = "registry+https://github.com/rust-lang/crates.io-index"
464
515
  checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
465
516
 
517
+ [[package]]
518
+ name = "ctr"
519
+ version = "0.9.2"
520
+ source = "registry+https://github.com/rust-lang/crates.io-index"
521
+ checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
522
+ dependencies = [
523
+ "cipher",
524
+ ]
525
+
466
526
  [[package]]
467
527
  name = "debugid"
468
528
  version = "0.8.0"
@@ -639,8 +699,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
639
699
  checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
640
700
  dependencies = [
641
701
  "cfg-if",
702
+ "js-sys",
642
703
  "libc",
643
704
  "wasi",
705
+ "wasm-bindgen",
644
706
  ]
645
707
 
646
708
  [[package]]
@@ -668,6 +730,16 @@ dependencies = [
668
730
  "wasip3",
669
731
  ]
670
732
 
733
+ [[package]]
734
+ name = "ghash"
735
+ version = "0.5.1"
736
+ source = "registry+https://github.com/rust-lang/crates.io-index"
737
+ checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
738
+ dependencies = [
739
+ "opaque-debug",
740
+ "polyval",
741
+ ]
742
+
671
743
  [[package]]
672
744
  name = "gimli"
673
745
  version = "0.32.3"
@@ -820,6 +892,15 @@ dependencies = [
820
892
  "str_stack",
821
893
  ]
822
894
 
895
+ [[package]]
896
+ name = "inout"
897
+ version = "0.1.4"
898
+ source = "registry+https://github.com/rust-lang/crates.io-index"
899
+ checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
900
+ dependencies = [
901
+ "generic-array",
902
+ ]
903
+
823
904
  [[package]]
824
905
  name = "is-terminal"
825
906
  version = "0.4.17"
@@ -909,9 +990,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
909
990
 
910
991
  [[package]]
911
992
  name = "lz4_flex"
912
- version = "0.11.6"
993
+ version = "0.12.2"
913
994
  source = "registry+https://github.com/rust-lang/crates.io-index"
914
- checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
995
+ checksum = "90071f8077f8e40adfc4b7fe9cd495ce316263f19e75c2211eeff3fdf475a3d9"
915
996
  dependencies = [
916
997
  "twox-hash",
917
998
  ]
@@ -1006,6 +1087,12 @@ version = "11.1.5"
1006
1087
  source = "registry+https://github.com/rust-lang/crates.io-index"
1007
1088
  checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
1008
1089
 
1090
+ [[package]]
1091
+ name = "opaque-debug"
1092
+ version = "0.3.1"
1093
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1094
+ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
1095
+
1009
1096
  [[package]]
1010
1097
  name = "plotters"
1011
1098
  version = "0.3.7"
@@ -1034,6 +1121,18 @@ dependencies = [
1034
1121
  "plotters-backend",
1035
1122
  ]
1036
1123
 
1124
+ [[package]]
1125
+ name = "polyval"
1126
+ version = "0.6.2"
1127
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1128
+ checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
1129
+ dependencies = [
1130
+ "cfg-if",
1131
+ "cpufeatures",
1132
+ "opaque-debug",
1133
+ "universal-hash",
1134
+ ]
1135
+
1037
1136
  [[package]]
1038
1137
  name = "portable-atomic"
1039
1138
  version = "1.13.1"
@@ -1290,7 +1389,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1290
1389
  checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
1291
1390
  dependencies = [
1292
1391
  "rand_chacha",
1293
- "rand_core",
1392
+ "rand_core 0.9.5",
1294
1393
  ]
1295
1394
 
1296
1395
  [[package]]
@@ -1300,7 +1399,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1300
1399
  checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
1301
1400
  dependencies = [
1302
1401
  "ppv-lite86",
1303
- "rand_core",
1402
+ "rand_core 0.9.5",
1403
+ ]
1404
+
1405
+ [[package]]
1406
+ name = "rand_core"
1407
+ version = "0.6.4"
1408
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1409
+ checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1410
+ dependencies = [
1411
+ "getrandom 0.2.17",
1304
1412
  ]
1305
1413
 
1306
1414
  [[package]]
@@ -1318,7 +1426,7 @@ version = "0.4.0"
1318
1426
  source = "registry+https://github.com/rust-lang/crates.io-index"
1319
1427
  checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
1320
1428
  dependencies = [
1321
- "rand_core",
1429
+ "rand_core 0.9.5",
1322
1430
  ]
1323
1431
 
1324
1432
  [[package]]
@@ -1804,6 +1912,16 @@ version = "0.2.4"
1804
1912
  source = "registry+https://github.com/rust-lang/crates.io-index"
1805
1913
  checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
1806
1914
 
1915
+ [[package]]
1916
+ name = "universal-hash"
1917
+ version = "0.5.1"
1918
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1919
+ checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
1920
+ dependencies = [
1921
+ "crypto-common",
1922
+ "subtle",
1923
+ ]
1924
+
1807
1925
  [[package]]
1808
1926
  name = "untrusted"
1809
1927
  version = "0.9.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cachekit
3
- Version: 0.6.1
3
+ Version: 0.8.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "cachekit"
7
- version = "0.6.1"
7
+ version = "0.8.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"}
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "cachekit-rs"
3
- version = "0.6.1"
3
+ version = "0.8.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.1.1", features = ["compression", "checksum", "messagepack", "encryption"] }
23
+ cachekit-core = { version = "0.2.0", features = ["compression", "checksum", "messagepack", "encryption"] }
24
24
 
25
25
  # Python integration - optional for Rust-only builds
26
26
  pyo3 = { workspace = true, optional = true }
@@ -68,7 +68,7 @@ Example Usage:
68
68
  ```
69
69
  """
70
70
 
71
- __version__ = "0.6.1"
71
+ __version__ = "0.8.0"
72
72
 
73
73
  from typing import Any, Callable, TypeVar
74
74
 
@@ -188,10 +188,23 @@ class LockableBackend(Protocol):
188
188
  - Local-only: SQLite, FileSystem (single-process locking)
189
189
  - Not supported: HTTP (stateless), Memcached, S3
190
190
 
191
+ Contract — bare cache key:
192
+ ``acquire_lock`` receives the **bare cache key**, identical to what
193
+ ``get``/``set``/``delete`` receive. Callers MUST NOT append suffixes
194
+ like ``:lock`` before passing the key. Each backend owns its own
195
+ lock-namespace derivation:
196
+
197
+ - Redis: derives ``<key>:lock`` internally for the on-wire lock name.
198
+ - CachekitIO (SaaS): the lock endpoint is ``POST /v1/cache/{key}/lock`` —
199
+ no key derivation needed.
200
+
201
+ This convergence keeps the Python SDK byte-for-byte compatible with
202
+ the cachekit-rs and cachekit-ts SDKs at the protocol boundary.
203
+
191
204
  Example:
192
205
  >>> # Distributed locking pattern (async context):
193
206
  >>> # if hasattr(backend, 'acquire_lock'):
194
- >>> # async with backend.acquire_lock("lock:compute", timeout=30) as acquired:
207
+ >>> # async with backend.acquire_lock("user:123:profile", timeout=30) as acquired:
195
208
  >>> # if acquired:
196
209
  >>> # result = expensive_computation()
197
210
  """
@@ -205,7 +218,10 @@ class LockableBackend(Protocol):
205
218
  """Acquire a distributed lock on key.
206
219
 
207
220
  Args:
208
- key: Lock key (e.g., "lock:user:123")
221
+ key: Bare cache key (e.g., ``"user:123"``) — the SAME shape passed
222
+ to ``get``/``set``/``delete``. Implementations are responsible
223
+ for any internal lock-namespace derivation; callers MUST NOT
224
+ pre-suffix the key.
209
225
  timeout: How long to hold the lock (seconds) before auto-release
210
226
  blocking_timeout: Max time to wait for lock acquisition (None = non-blocking)
211
227
 
@@ -217,7 +233,7 @@ class LockableBackend(Protocol):
217
233
  BackendError: If backend operation fails
218
234
 
219
235
  Example:
220
- >>> async with backend.acquire_lock("lock:key", timeout=30, blocking_timeout=5) as acquired: # doctest: +SKIP
236
+ >>> async with backend.acquire_lock("user:123:profile", timeout=30, blocking_timeout=5) as acquired: # doctest: +SKIP
221
237
  ... if acquired: # doctest: +SKIP
222
238
  ... # Lock held, safe to proceed
223
239
  ... pass # doctest: +SKIP
@@ -2,16 +2,22 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import json
7
+ import math
8
+ import random
6
9
  import time
7
- from typing import TYPE_CHECKING, Any
10
+ from collections.abc import AsyncIterator
11
+ from contextlib import asynccontextmanager
12
+ from typing import TYPE_CHECKING, Any, Optional
13
+ from urllib.parse import quote
8
14
 
9
15
  import httpx
10
16
 
11
17
  from cachekit.backends.cachekitio.client import get_cached_async_http_client, get_sync_http_client
12
18
  from cachekit.backends.cachekitio.config import CachekitIOBackendConfig
13
19
  from cachekit.backends.cachekitio.error_handler import classify_http_error
14
- from cachekit.backends.errors import BackendError
20
+ from cachekit.backends.errors import BackendError, BackendErrorType
15
21
  from cachekit.decorators.stats_context import get_current_function_stats
16
22
  from cachekit.logging import get_structured_logger
17
23
 
@@ -544,42 +550,111 @@ class CachekitIOBackend:
544
550
 
545
551
  # ==================== LockableBackend Protocol ====================
546
552
 
547
- async def acquire_lock(self, lock_key: str, timeout: int = 5) -> str | None:
548
- """Acquire distributed lock.
553
+ async def _try_acquire_lock(self, lock_key: str, timeout: float) -> str | None:
554
+ """Single attempt at the SaaS lock endpoint. Returns lock_id, or None if held.
549
555
 
550
- Args:
551
- lock_key: Lock identifier
552
- timeout: Lock timeout in seconds
556
+ ``lock_key`` is the bare cache key (LockableBackend contract). The SaaS lock
557
+ endpoint is ``POST /v1/cache/{key}/lock`` — no internal derivation is needed
558
+ because lock semantics live in the URL path, not the key namespace. Appending
559
+ ``:lock`` here would push the SaaS validator past its 7-segment canonical key
560
+ budget and trigger a 400 at the edge.
553
561
 
554
- Returns:
555
- Lock ID if acquired, None if failed
562
+ Non-finite (NaN / ±inf) or non-positive ``timeout`` is clamped to 1ms — without
563
+ the guard, ``int(NaN)`` / ``int(inf)`` would raise outside ``BackendError`` and
564
+ escape the wrapper's degrade-to-no-lock branch.
565
+
566
+ Raises:
567
+ BackendError: For AUTHENTICATION and PERMANENT failures (bad key, bad key
568
+ format rejected by SaaS validator) — polling won't recover and the wrapper
569
+ degrades to no-lock execution. TRANSIENT/TIMEOUT/UNKNOWN are swallowed as
570
+ None so the polling loop can retry.
556
571
  """
572
+ # Clamp non-positive / non-finite timeouts before the int conversion.
573
+ # int(NaN) / int(inf) raise ValueError/OverflowError that aren't BackendError, so
574
+ # they'd escape the wrapper's degrade-to-no-lock branch and crash the @cache.io call.
575
+ timeout_ms = max(1, int(timeout * 1000)) if math.isfinite(timeout) else 1
576
+ encoded_key = quote(lock_key, safe="")
557
577
  try:
558
- payload = json.dumps({"timeout_ms": timeout * 1000})
559
578
  response = await self._request_async(
560
579
  "POST",
561
- f"{lock_key}/lock",
562
- content=payload.encode(),
580
+ f"{encoded_key}/lock",
581
+ content=json.dumps({"timeout_ms": timeout_ms}).encode(),
563
582
  headers={"Content-Type": "application/json"},
564
583
  )
584
+ except BackendError as exc:
585
+ # Re-raise unrecoverable failures so the wrapper can log + degrade once,
586
+ # instead of burning the full blocking_timeout on billable retries.
587
+ if exc.error_type in (BackendErrorType.AUTHENTICATION, BackendErrorType.PERMANENT):
588
+ raise
589
+ return None
590
+
591
+ try:
565
592
  data = response.json()
566
- return data.get("lock_id")
567
- except BackendError:
593
+ lock_id = data.get("lock_id")
594
+ except (ValueError, AttributeError):
595
+ # Malformed body from the SaaS — treat as held (retry) rather than crashing
596
+ # the wrapper. Same failure class as the original issue #129 if we re-raised.
568
597
  return None
569
598
 
570
- async def release_lock(self, lock_key: str, lock_id: str) -> bool:
571
- """Release distributed lock.
599
+ return lock_id if isinstance(lock_id, str) else None
600
+
601
+ @asynccontextmanager
602
+ async def acquire_lock(
603
+ self,
604
+ key: str,
605
+ timeout: float,
606
+ blocking_timeout: Optional[float] = None,
607
+ ) -> AsyncIterator[bool]:
608
+ """Acquire distributed lock (LockableBackend protocol).
609
+
610
+ The SaaS endpoint returns immediately; client-side polling implements
611
+ ``blocking_timeout`` with proportional jitter (0.5×–1× the capped delay) to
612
+ avoid lockstep retries on concurrent waiters. Each retry is a billable SaaS
613
+ request — keep the cap tight.
572
614
 
573
615
  Args:
574
- lock_key: Lock identifier
575
- lock_id: Lock ID from acquire_lock
616
+ key: Lock key
617
+ timeout: Server-side hold duration before auto-release (seconds)
618
+ blocking_timeout: Max client-side wait to acquire (None = single attempt)
576
619
 
577
- Returns:
578
- True if released, False otherwise
620
+ Yields:
621
+ True if acquired, False if ``blocking_timeout`` elapsed without acquisition
622
+ """
623
+ lock_id: str | None = None
624
+ try:
625
+ lock_id = await self._try_acquire_lock(key, timeout)
626
+
627
+ if lock_id is None and blocking_timeout is not None:
628
+ deadline = time.monotonic() + blocking_timeout
629
+ delay = 0.05
630
+ while lock_id is None:
631
+ remaining = deadline - time.monotonic()
632
+ if remaining <= 0:
633
+ break
634
+ # Proportional jitter (not crypto): spread concurrent waiters, 0.5×–1× the capped delay.
635
+ jitter = 0.5 + random.random() * 0.5 # noqa: S311 — backoff jitter, not security
636
+ await asyncio.sleep(min(delay, remaining) * jitter)
637
+ lock_id = await self._try_acquire_lock(key, timeout)
638
+ delay = min(delay * 2, 0.5)
639
+
640
+ yield lock_id is not None
641
+ finally:
642
+ if lock_id is not None:
643
+ await self._release_lock(key, lock_id)
644
+
645
+ async def _release_lock(self, lock_key: str, lock_id: str) -> bool:
646
+ """Release distributed lock. Internal helper for ``acquire_lock``'s cleanup.
647
+
648
+ Best-effort: swallows ``BackendError`` and returns False so a release failure
649
+ inside ``__aexit__`` cannot mask the user's exception. The server-side ``timeout``
650
+ on the lock is the safety net if the DELETE never lands.
579
651
  """
652
+ # URL-encode both segments: lock_key is caller-controlled, lock_id is server-issued
653
+ # but the SaaS contract doesn't pin a charset.
654
+ encoded_key = quote(lock_key, safe="")
655
+ encoded_id = quote(lock_id, safe="")
580
656
  try:
581
- # DELETE /v1/cache/{key}/lock?lock_id=xxx
582
- await self._request_async("DELETE", f"{lock_key}/lock?lock_id={lock_id}")
657
+ await self._request_async("DELETE", f"{encoded_key}/lock?lock_id={encoded_id}")
583
658
  return True
584
659
  except BackendError:
585
660
  return False
@@ -102,32 +102,99 @@ class DefaultCacheClientProvider(CacheClientProvider):
102
102
 
103
103
 
104
104
  class DefaultBackendProvider(BackendProviderInterface):
105
- """Default backend provider using Redis backend.
106
-
107
- Creates RedisBackendProvider singleton with connection pooling.
108
- Delegates to RedisBackendProvider.get_backend() for per-request wrappers.
109
-
110
- For single-tenant deployments (default), sets tenant_context to "default".
111
- For multi-tenant deployments, tenant_context must be set externally.
105
+ """Default backend provider with env-based auto-detection.
106
+
107
+ Selection is by a single, unambiguous environment signal. Priority order:
108
+ 1. CACHEKIT_API_KEY → CachekitIOBackend (SaaS)
109
+ 2. CACHEKIT_REDIS_URL → RedisBackend
110
+ 3. CACHEKIT_MEMCACHED_SERVERS → MemcachedBackend
111
+ 4. CACHEKIT_FILE_CACHE_DIR → FileBackend
112
+ 5. REDIS_URL, or nothing set → RedisBackend (12-factor / localhost default)
113
+
114
+ Setting more than one of the four prefixed selectors (1-4) raises
115
+ ``ConfigurationError`` — auto-detection must be unambiguous; pass
116
+ ``backend=`` explicitly to override. The non-prefixed ``REDIS_URL`` is only a
117
+ fallback and never counts as a conflict (12-factor convention).
118
+
119
+ CachekitIO/Memcached/File backends are stateless singletons (cached). Redis
120
+ backends are per-request tenant-scoped wrappers (not cached —
121
+ RedisBackendProvider.get_backend() reads the tenant_context ContextVar). For
122
+ single-tenant deployments (default), tenant_context is set to "default".
112
123
  """
113
124
 
125
+ # Prefixed selectors in priority order. REDIS_URL is the implicit fallback
126
+ # and intentionally excluded so it never triggers a conflict.
127
+ _SELECTORS = (
128
+ ("CACHEKIT_API_KEY", "cachekitio"),
129
+ ("CACHEKIT_REDIS_URL", "redis"),
130
+ ("CACHEKIT_MEMCACHED_SERVERS", "memcached"),
131
+ ("CACHEKIT_FILE_CACHE_DIR", "file"),
132
+ )
133
+
114
134
  def __init__(self):
115
- self._provider = None
135
+ self._cachekitio_backend = None
136
+ self._redis_provider = None
137
+ self._memcached_backend = None
138
+ self._file_backend = None
139
+
140
+ def _detect(self):
141
+ """Return the chosen backend key, or None to use the Redis fallback.
142
+
143
+ Raises ConfigurationError if more than one prefixed selector is set.
144
+ """
145
+ import os
146
+
147
+ matched = [(env_var, key) for env_var, key in self._SELECTORS if os.environ.get(env_var)]
148
+ if len(matched) > 1:
149
+ from cachekit.config.validation import ConfigurationError
150
+
151
+ names = ", ".join(env_var for env_var, _ in matched)
152
+ raise ConfigurationError(
153
+ f"Ambiguous backend auto-detection: multiple selectors set ({names}). "
154
+ "Set exactly one of CACHEKIT_API_KEY / CACHEKIT_REDIS_URL / "
155
+ "CACHEKIT_MEMCACHED_SERVERS / CACHEKIT_FILE_CACHE_DIR, or pass backend= explicitly."
156
+ )
157
+ return matched[0][1] if matched else None
116
158
 
117
159
  def get_backend(self):
118
- """Get per-request backend instance from singleton provider."""
119
- if self._provider is None:
160
+ """Get backend instance, auto-detected from environment on first call."""
161
+ choice = self._detect()
162
+
163
+ if choice == "cachekitio":
164
+ if self._cachekitio_backend is None:
165
+ from cachekit.backends.cachekitio import CachekitIOBackend
166
+
167
+ self._cachekitio_backend = CachekitIOBackend()
168
+ return self._cachekitio_backend
169
+
170
+ if choice == "memcached":
171
+ if self._memcached_backend is None:
172
+ from cachekit.backends.memcached import MemcachedBackend, MemcachedBackendConfig
173
+
174
+ self._memcached_backend = MemcachedBackend(MemcachedBackendConfig.from_env())
175
+ return self._memcached_backend
176
+
177
+ if choice == "file":
178
+ if self._file_backend is None:
179
+ from cachekit.backends.file import FileBackend, FileBackendConfig
180
+
181
+ self._file_backend = FileBackend(FileBackendConfig.from_env())
182
+ return self._file_backend
183
+
184
+ # choice == "redis" (explicit CACHEKIT_REDIS_URL) or None (REDIS_URL / localhost fallback).
185
+ # Tenant-scoped: call the provider each time so it re-reads tenant_context.
186
+ if self._redis_provider is None:
120
187
  from cachekit.backends.redis.config import RedisBackendConfig
121
188
  from cachekit.backends.redis.provider import RedisBackendProvider, tenant_context
122
189
 
123
190
  redis_config = RedisBackendConfig.from_env()
124
- self._provider = RedisBackendProvider(redis_url=redis_config.redis_url)
191
+ self._redis_provider = RedisBackendProvider(redis_url=redis_config.redis_url)
125
192
 
126
193
  # Set default tenant for single-tenant mode (if not already set)
127
194
  if tenant_context.get() is None:
128
195
  tenant_context.set("default")
129
196
 
130
- return self._provider.get_backend()
197
+ return self._redis_provider.get_backend()
131
198
 
132
199
 
133
200
  __all__ = [
@@ -327,7 +327,10 @@ class PerRequestRedisBackend:
327
327
  """Acquire distributed lock (LockableBackend protocol).
328
328
 
329
329
  Args:
330
- key: Lock key (will be tenant-scoped)
330
+ key: Bare cache key (will be tenant-scoped and ``:lock``-suffixed internally).
331
+ The LockableBackend protocol passes the same key as ``get``/``set``/``delete``;
332
+ the ``:lock`` namespace is a Redis-backend implementation detail kept
333
+ on-wire for zero-migration compatibility with existing deployments.
331
334
  timeout: How long to hold lock (seconds) before auto-release
332
335
  blocking_timeout: Max time to wait for lock (None = non-blocking)
333
336
 
@@ -343,7 +346,11 @@ class PerRequestRedisBackend:
343
346
  """
344
347
  import asyncio
345
348
 
346
- scoped_key = self._scoped_key(key)
349
+ # Derive the on-wire Redis lock name from the bare cache key: ``<scoped_key>:lock``.
350
+ # Keeping this suffix on the wire preserves compatibility with existing Redis
351
+ # deployments — the lock identity didn't change, only the protocol boundary
352
+ # (the wrapper no longer pollutes the cache_key passed in).
353
+ scoped_key = f"{self._scoped_key(key)}:lock"
347
354
  lock = None
348
355
  try:
349
356
  from redis.lock import Lock