Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,33 @@ jobs:
if: success() || failure()
run: cd rust && cargo fmt --check

# A leftover path dep or [patch.crates-io] redirect would make maturin build
# release wheels from unpublished local source instead of the published crate,
# producing a non-reproducible public wheel. Assert the POSITIVE invariant on
# the lockfile: cachekit-core must resolve to the crates.io registry. A path or
# patch dep has no `source` line, so this one check covers every Cargo.toml form
# (inline table, [dependencies.cachekit-core], [patch.crates-io]) and the
# workspace root — unlike a grep for `path =`, which only caught the inline form.
- name: Guard cachekit-core resolves to crates.io
if: success() || failure()
run: |
src=$(awk '
/^\[\[package\]\]/ { name=""; source="" }
/^name = / { name=$3 }
/^source = / { source=$0 }
name == "\"cachekit-core\"" && source != "" { print source; exit }
' Cargo.lock)
echo "cachekit-core resolved: ${src:-<no source line>}"
if [ "$src" != 'source = "registry+https://github.com/rust-lang/crates.io-index"' ]; then
echo "::error::cachekit-core must resolve to the crates.io registry, not a local path/patch dep"
exit 1
fi

# --locked: fail on a stale Cargo.lock so the build provably resolves
# cachekit-core from crates.io as committed
- name: Lint Rust
if: success() || failure()
run: cd rust && cargo clippy -- -D warnings
run: cd rust && cargo clippy --locked -- -D warnings

# PR: single Python version, critical tests only
# Push: full matrix, full test suite
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 33 additions & 7 deletions docs/features/rust-serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Serializer (MessagePack / Arrow / Orjson — your choice)
↓ [Rust ByteStorage takes over here]
LZ4 compression (fast, ~500MB/s)
↓
Blake3 integrity hash (~GB/s, detects corruption)
xxHash3-64 integrity hash (~GB/s, detects corruption)
↓
[Optional] AES-256-GCM encryption (if @cache.secure)
↓
Expand All @@ -38,7 +38,7 @@ The Rust layer is transparent — you configure serializers and encryption at th
| Operation | Python | Rust (ByteStorage) |
|-----------|--------|---------------------|
| LZ4 compression | ~50-100 MB/s | ~500 MB/s |
| Blake3 hashing | ~500 MB/s | ~15 GB/s |
| xxHash3-64 hashing | ~35 GB/s (`xxhash` C ext) | ~35 GB/s |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| AES-256-GCM | ~200 MB/s | ~1-4 GB/s (AES-NI) |

For most workloads the bottleneck is Redis RTT (~2-50ms), not serialization. The Rust layer matters for large payloads (DataFrames, bulk data) where serialization time approaches network time.
Expand All @@ -61,16 +61,42 @@ Compression runs automatically. It can be toggled via the `CACHEKIT_ENABLE_COMPR

---

## Blake3 Integrity
## xxHash3-64 Integrity

Every value stored includes a Blake3 hash. On retrieval:
Every value stored includes an xxHash3-64 checksum (8 bytes, big-endian). On retrieval:

1. Hash of retrieved bytes is computed
2. Stored hash is compared
1. Checksum of retrieved bytes is computed
2. Stored checksum is compared
3. Mismatch → `BackendError` (corrupted data, never returned to caller)

This protects against Redis memory corruption, storage bugs, and bit rot.

> **Non-cryptographic.** The checksum detects corruption, not tampering.
> Tamper-resistance comes from AES-256-GCM (`@cache.secure`), never from this checksum.

### Standalone checksum API

> **Available since v0.12.0.** On earlier releases,
> `from cachekit._rust_serializer import checksum` raises `ImportError`.

The same primitive is exposed directly — decoupled from LZ4 compression — for
serializers where compression is ineffective (Arrow IPC, compact JSON):

```python
from cachekit._rust_serializer import checksum, verify_checksum

digest = checksum(b"payload") # 8 bytes, big-endian
assert len(digest) == 8
assert verify_checksum(b"payload", digest) is True
assert verify_checksum(b"tampered", digest) is False
```

`verify_checksum` raises `ValueError` unless the expected checksum is exactly
8 bytes. Both functions accept any buffer-protocol object (`bytes`, `bytearray`,
`memoryview`), so a serializer holding its payload as a `memoryview` can hash it
without a `bytes` copy. The output is byte-identical to the checksum embedded in
every ByteStorage envelope and to `xxhash.xxh3_64_digest` from the `xxhash` package.

---

## AES-256-GCM Encryption
Expand Down Expand Up @@ -99,7 +125,7 @@ The Rust ByteStorage layer is orthogonal to the serializer. Mix and match:
| Typed models | [Pydantic](../serializers/pydantic.md) | Optional |
| Custom types | [Custom](../serializers/custom.md) | Optional |

All serializers pass through the same ByteStorage pipeline (LZ4 + Blake3 + optional AES-256-GCM).
All serializers pass through the same ByteStorage pipeline (LZ4 + xxHash3-64 + optional AES-256-GCM).

---

Expand Down
2 changes: 1 addition & 1 deletion rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ crate-type = ["cdylib", "rlib"]

[dependencies]
# Compression, checksums, encryption (https://crates.io/crates/cachekit-core)
cachekit-core = { version = "0.2.0", features = ["compression", "checksum", "messagepack", "encryption"] }
cachekit-core = { version = "0.3.0", features = ["compression", "checksum", "messagepack", "encryption"] }

# Python integration - optional for Rust-only builds
pyo3 = { workspace = true, optional = true }
Expand Down
9 changes: 7 additions & 2 deletions rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ fn _rust_serializer(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
// Add byte storage class
m.add_class::<python_bindings::PyByteStorage>()?;

// Standalone integrity primitive — registered unconditionally (usable with
// the checksum feature alone; must not vanish when encryption is off)
m.add_function(wrap_pyfunction!(python_bindings::checksum_py, m)?)?;
m.add_function(wrap_pyfunction!(python_bindings::verify_checksum_py, m)?)?;

// Add encryption functionality if feature is enabled
#[cfg(feature = "encryption")]
{
Expand All @@ -43,13 +48,13 @@ fn _rust_serializer(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
#[cfg(feature = "encryption")]
m.add(
"__description__",
"Raw byte storage with LZ4 compression, Blake3 checksums, and zero-knowledge encryption",
"Raw byte storage with LZ4 compression, xxHash3-64 checksums, and zero-knowledge encryption",
)?;

#[cfg(not(feature = "encryption"))]
m.add(
"__description__",
"Raw byte storage layer with LZ4 compression and Blake3 checksums",
"Raw byte storage layer with LZ4 compression and xxHash3-64 checksums",
)?;

Ok(())
Expand Down
42 changes: 42 additions & 0 deletions rust/src/python_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//! All business logic is delegated to cachekit-core.

use cachekit_core::ByteStorage;
use pyo3::buffer::PyBuffer;
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use pyo3::types::PyBytes;
Expand Down Expand Up @@ -340,6 +341,47 @@ pub fn key_fingerprint_py(key: &[u8]) -> Vec<u8> {
key_fingerprint(key).to_vec()
}

/// Compute the standalone xxHash3-64 checksum of `data` (8 bytes, big-endian).
///
/// Accepts any buffer-protocol object — `bytes`, `bytearray`, `memoryview`,
/// Arrow buffers — so a serializer holding its payload as a `memoryview`
/// (e.g. Arrow IPC) can hash it directly, without forcing a `bytes` copy.
///
/// NON-cryptographic: detects corruption, not tampering. For tamper-resistance
/// use @cache.secure (AES-256-GCM), never this checksum. Produces the exact
/// bytes embedded in every StorageEnvelope, without the LZ4 compression
/// overhead — for serializers where compression is ineffective (Arrow IPC, JSON).
#[pyfunction]
#[pyo3(name = "checksum")]
pub fn checksum_py(py: Python, data: PyBuffer<u8>) -> PyResult<Py<PyBytes>> {
let data = data.to_vec(py)?;
Ok(PyBytes::new(py, &cachekit_core::checksum(&data)).into())
}

/// Verify `data` against an expected 8-byte xxHash3-64 checksum.
///
/// Both arguments accept any buffer-protocol object (`bytes`, `bytearray`,
/// `memoryview`, …) — the Arrow verify path slices a `memoryview` (`mv[8:]`),
/// so a bytes-only signature would break the moment a serializer moves onto
/// this FFI.
///
/// NON-cryptographic: detects corruption, not tampering (see `checksum`).
/// Raises ValueError if `expected` is not exactly 8 bytes — a truncated
/// checksum must fail loudly, never return a wrong verdict.
#[pyfunction]
#[pyo3(name = "verify_checksum")]
pub fn verify_checksum_py(
py: Python,
data: PyBuffer<u8>,
expected: PyBuffer<u8>,
) -> PyResult<bool> {
let expected: [u8; 8] = expected.to_vec(py)?.try_into().map_err(|v: Vec<u8>| {
PyValueError::new_err(format!("expected must be exactly 8 bytes, got {}", v.len()))
})?;
let data = data.to_vec(py)?;
Ok(cachekit_core::verify_checksum(&data, &expected))
}

/// Register encryption module with Python
#[cfg(feature = "encryption")]
pub fn register_encryption_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
Expand Down
92 changes: 92 additions & 0 deletions tests/benchmarks/benchmark_checksum_ffi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Benchmark: pure-Python xxhash package vs Rust-FFI checksum (cachekit-core#13).

Pinned contract (spec m-cachekit-core-checksum-only-api, Task 7): fixed sizes
64 B / 1 KB / 64 KB / 1 MB, >=30 iterations per point, per-size reporting.
This is a deliverable artifact, not a pass/fail gate — the crossover size is
the go/no-go datum for migrating serializer envelopes (Arrow/orjson) from the
py-xxhash package to the shared Rust FFI. Tiny inputs favoring in-process
Python is expected information, not failure: both implementations produce
byte-identical output (see tests/unit/test_checksum_ffi.py), so the choice
is purely a per-call-overhead question.

Run:
uv run pytest tests/benchmarks/benchmark_checksum_ffi.py \
--benchmark-only --benchmark-group-by=group --benchmark-min-rounds=30

Results (2026-07-17, AMD Ryzen 9 5950X, CPython 3.13.12, median per call):

checksum (compute) py-xxhash Rust FFI winner
64 B 51.6 ns 39.7 ns FFI 1.30x
1 KB 77.8 ns 130.0 ns py 1.67x
64 KB 1.85 us 1.86 us wash (~1%)
1 MB 28.1 us 28.7 us wash (~2%)

verify py-xxhash Rust FFI winner
64 B 88.1 ns 34.8 ns FFI 2.53x
1 KB 125.0 ns 64.3 ns FFI 1.94x
64 KB 1.87 us 1.78 us FFI ~5%
1 MB 28.0 us 27.9 us wash

Crossover / go-no-go datum: there is NO size where either side wins by more
than ~65 ns/call on compute; both are throughput-bound and identical from
64 KB up. py-xxhash's C library has a stronger mid-size (240 B - 8 KB) path
than xxhash-rust, hence the 1 KB compute loss; the FFI wins verify at small
sizes because it is one boundary crossing instead of hash + compare in
Python. Verdict: serializer migration py-xxhash -> FFI is performance-neutral
(worst case ~50 ns/call against envelope operations measured in us-ms); decide
it on dependency hygiene, not speed.
"""

from __future__ import annotations

import pytest
import xxhash

from cachekit import _rust_serializer as rs

SIZES = [
(64, "64B"),
(1_024, "1KB"),
(65_536, "64KB"),
(1_048_576, "1MB"),
]

PAYLOADS = {label: bytes(i % 251 for i in range(size)) for size, label in SIZES}


@pytest.mark.benchmark
@pytest.mark.parametrize("label", [label for _, label in SIZES])
class TestChecksumComputeComparison:
"""py-xxhash vs Rust FFI, grouped per size for side-by-side comparison."""

def test_python_xxhash(self, benchmark, label):
data = PAYLOADS[label]
benchmark.group = f"checksum-{label}"
result = benchmark(xxhash.xxh3_64_digest, data)
assert len(result) == 8

def test_rust_ffi(self, benchmark, label):
data = PAYLOADS[label]
benchmark.group = f"checksum-{label}"
result = benchmark(rs.checksum, data)
assert len(result) == 8


@pytest.mark.benchmark
@pytest.mark.parametrize("label", [label for _, label in SIZES])
class TestVerifyComparison:
"""Verification path: python compare vs Rust FFI verify_checksum."""

def test_python_xxhash_verify(self, benchmark, label):
data = PAYLOADS[label]
expected = xxhash.xxh3_64_digest(data)
benchmark.group = f"verify-{label}"
result = benchmark(lambda: xxhash.xxh3_64_digest(data) == expected)
assert result is True

def test_rust_ffi_verify(self, benchmark, label):
data = PAYLOADS[label]
expected = bytes(rs.checksum(data))
benchmark.group = f"verify-{label}"
result = benchmark(rs.verify_checksum, data, expected)
assert result is True
Loading
Loading