diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml
index 6a91622..2aa4f3e 100644
--- a/.github/workflows/auto-tag.yml
+++ b/.github/workflows/auto-tag.yml
@@ -35,6 +35,12 @@ jobs:
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
+ - name: Verify release readiness
+ if: steps.check_tag.outputs.exists == 'false'
+ run: |
+ pip install build twine
+ python scripts/verify_release_v6.py
+
- name: Create and push tag
if: steps.check_tag.outputs.exists == 'false'
run: |
@@ -65,6 +71,10 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
+ PRERELEASE_FLAG=""
+ if echo "${{ steps.version.outputs.version }}" | grep -Eq 'rc|a|b|dev'; then
+ PRERELEASE_FLAG="--prerelease"
+ fi
gh release create "${{ steps.version.outputs.version }}" \
--title "${{ steps.version.outputs.version }}" \
- --notes-file /tmp/release_notes.md
+ --notes-file /tmp/release_notes.md $PRERELEASE_FLAG
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 85228e7..8f26cfe 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -78,9 +78,8 @@ jobs:
if: matrix.python-version == '3.12'
run: uv run --no-sync python -m evaluation.rag --output evaluation/reports/v0_4_rag.json
- - name: Run two-instance shared Redis example
- if: matrix.python-version == '3.12'
- run: uv run --no-sync python examples/multi_instance_rag.py
+ - name: Run all documented examples and quickstarts
+ run: uv run --no-sync python scripts/wheel_examples_v6.py
- name: Smoke deployment benchmark against actual v0.3 source
if: matrix.python-version == '3.12'
@@ -89,12 +88,6 @@ jobs:
git archive e384615 | tar -x -C /tmp/longcache-v03-baseline
uv run --no-sync python -m evaluation.deployment --baseline-root /tmp/longcache-v03-baseline --smoke
- - name: Run offline public-interface example
- run: uv run --no-sync python examples/safe_reuse.py
-
- - name: Run offline reliability example
- run: uv run --no-sync python examples/reliability.py
-
- name: Typecheck consumer against wheel
run: uv run --no-sync python scripts/wheel_typecheck_v5.py
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index bc068b7..872da5c 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -31,7 +31,10 @@ jobs:
run: python -m build
- name: Check package distribution
- run: twine check --strict dist/*
+ run: |
+ twine check --strict dist/*
+ python scripts/check_package_metadata.py --dist-dir dist
+ python scripts/verify_release_v6.py --dist-dir dist
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f4fa896..34d5c0b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,17 @@
All notable changes are recorded here using [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+## [0.6.0] - 2026-11-05
+
+### Added
+
+- **Multi-Process Redis Qualification & Stress Suite:** Added `tests/test_qualification_stress_v6.py` covering multi-worker write/delete/invalidate churn, 5-cycle transient connection outage/reconnect (`raise` vs `bypass`), volatile Redis restart / manifest-loss quarantine, and singleflight lease takeover under worker kill faults.
+- **Paged Vector-Only Rebuilds & Memory Bounds:** Implemented `iter_index_entries()` on `AbstractCacheBackend` (`SQLiteCacheBackend`, `RedisCacheBackend`, `SharedRedisCacheBackend`), streaming `(id, vector, metadata)` without loading large query/response payloads into RAM, bounding rebuild memory overhead.
+- **Bounded Local Stale-Vector Compaction:** Added `record_remote_invalidation()` to `SimilarityIndex` to compact stale local vectors when the orphan ratio reaches 25% (or 2,048 orphans) during sustained multi-instance write/delete churn.
+- **Automated Example & Quickstart Verification:** Added `scripts/wheel_examples_v6.py` and `tests/test_examples_and_migrations_v6.py` covering all 7 documented examples, README quickstarts, Format-1 legacy rejection, and Format-2 codec roundtrips (`json-v1`, `openai-dict-v1`, `openai-sdk-v1`).
+- **Reproducible Qualification & Cost Methodology:** Added `evaluation/qualification_v6.py` and `evaluation/cost_model.py` with reproducible benchmark budgets and an audited mathematical formula for net cost savings and break-even analysis across LLM model tiers.
+- **Release Verification & Rollback Runbook:** Added `scripts/verify_release_v6.py` to audit version synchronization, changelog entries, package metadata, and benchmark evidence hashes; documented PyPI Trusted Publishing and emergency yank procedures in `docs/release-process.md`.
+
## [0.5.0] - 2026-11-04
### Added
diff --git a/README.md b/README.md
index ca9a455..e04f9fd 100755
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
-
+
-Semantic similarity cache for LLM and RAG pipelines .
+Production-grade semantic similarity cache for LLM and RAG pipelines — eliminate repetitive token costs and latency.
@@ -21,11 +21,9 @@
Changelog
----
-
-## What is LongCache?
+> *"What is your refund policy?" costs $0.003 the first time. It should cost $0.0000 every time after that.*
-**LongCache** is an open-source semantic cache for LLM and RAG applications. Instead of matching exact text, it understands the meaning behind user queries and safely reuses previous answers when the question and context match—helping you cut token costs, speed up responses, and avoid repetitive model calls.
+**LongCache** is a production-grade semantic similarity cache for LLM and RAG pipelines. Unlike traditional string-matching caches, it recognizes that *"How do I return a product?"* and *"What's your return process?"* deserve the same cached answer — and serves it in milliseconds.
---
@@ -885,16 +883,24 @@ longcache/
│ ├── test_langchain.py
│ ├── test_middleware.py
│ ├── test_cli.py
-│ └── test_shared_redis_v4.py # two-process shared Redis acceptance
+│ ├── test_shared_redis_v4.py # two-process shared Redis acceptance
+│ ├── test_v06_slice_a.py # paged vector loading and compaction bounds
+│ ├── test_qualification_stress_v6.py # multi-process Redis crash/churn stress suite
+│ └── test_examples_and_migrations_v6.py # 0.x migration and example validation
├── evaluation/
│ ├── run.py, rag.py, deployment.py
+│ ├── qualification_v6.py # v0.6 qualification benchmark runner
+│ ├── cost_model.py # audited LLM net cost and latency model
+│ ├── reports/
│ └── configs/
└── examples/
├── basic_langchain.py
├── fastapi_middleware.py
+ ├── multi_instance_rag.py # two-instance shared Redis freshness demo
├── redis_production.py
- ├── versioned_rag.py
- └── multi_instance_rag.py # two-instance shared Redis freshness demo
+ ├── reliability.py # singleflight lease and execution reliability
+ ├── safe_reuse.py # scoped lookup, inspection, and invalidation
+ └── versioned_rag.py
```
---
@@ -973,6 +979,16 @@ LONGCACHE_TEST_REDIS_URL=redis://localhost:16379/0 uv run --no-sync pytest tests
LONGCACHE_TEST_REDIS_URL=redis://localhost:16379/0 uv run --no-sync python examples/multi_instance_rag.py
```
+### Examples and Release Qualification Verification
+
+```bash
+# Verify all 7 examples and README quickstart workflow
+LONGCACHE_TEST_REDIS_URL=redis://localhost:16379/0 uv run --no-sync python scripts/wheel_examples_v6.py
+
+# Verify distribution build, hashes, changelog, and qualification benchmark evidence
+uv run --no-sync python scripts/verify_release_v6.py --dist-dir dist --check-evidence
+```
+
---
## License
diff --git a/assets/LongCache-logo.png b/assets/LongCache-logo.png
new file mode 100644
index 0000000..c85dc97
Binary files /dev/null and b/assets/LongCache-logo.png differ
diff --git a/docs/assets/LongCache-logo.png b/docs/assets/LongCache-logo.png
new file mode 100644
index 0000000..c85dc97
Binary files /dev/null and b/docs/assets/LongCache-logo.png differ
diff --git a/docs/benchmarks.md b/docs/benchmarks.md
index d4e0eba..3afd495 100644
--- a/docs/benchmarks.md
+++ b/docs/benchmarks.md
@@ -1,5 +1,45 @@
# Evaluation and benchmarks
+## v0.6.0 qualification and cost methodology
+
+The v0.6.0 qualification benchmark produces hash-verified latency, memory, origin-coalescing, and cost-savings evidence prior to the `1.0.0` stable API freeze.
+
+### Reproduce the qualification benchmark
+
+```bash
+mkdir -p /tmp/longcache-v03-baseline
+git archive e384615 | tar -x -C /tmp/longcache-v03-baseline
+LONGCACHE_TEST_REDIS_URL=redis://localhost:16379/0 uv run python -m evaluation.qualification_v6 \
+ --baseline-root /tmp/longcache-v03-baseline \
+ --smoke
+```
+
+For controlled reference-host release qualification:
+```bash
+LONGCACHE_TEST_REDIS_URL=redis://localhost:16379/0 uv run python -m evaluation.qualification_v6 \
+ --baseline-root /tmp/longcache-v03-baseline \
+ --release \
+ --reference-host-id
+```
+
+### Audited cost savings and net latency methodology
+
+LongCache evaluates cost and latency according to an audited mathematical model rather than arbitrary synthetic claims:
+
+1. **Net Cost per Request**:
+ $$\text{Net Cost} = (1 - h_{\text{useful}}) \cdot C_{\text{origin}} + C_{\text{infra}}$$
+ where $h_{\text{useful}}$ is the measured useful-hit rate under zero false hits, $C_{\text{origin}}$ is the provider token cost, and $C_{\text{infra}}$ is the amortized cache infrastructure cost.
+
+2. **Net Mean Latency**:
+ $$\text{Net Mean Latency} = h_{\text{useful}} \cdot L_{\text{hit}} + (1 - h_{\text{useful}}) \cdot (L_{\text{miss\_overhead}} + L_{\text{origin}})$$
+ where $L_{\text{hit}}$ is the local/network hit latency (10–25 ms), $L_{\text{miss\_overhead}}$ is the cache miss evaluation overhead (1–3 ms), and $L_{\text{origin}}$ is the provider response latency (600–1400 ms).
+
+3. **Break-Even Analysis**:
+ $$\text{Break-Even Hit Rate} = \frac{C_{\text{infra}}}{C_{\text{origin}}}$$
+ For high-volume production deployments ($1{,}000{,}000$ requests/month on a dedicated Redis cache, $C_{\text{infra}} \approx \$0.00002$ per request):
+ - **GPT-4o** ($C_{\text{origin}} \approx \$0.00425$ per 500-in/300-out request): break-even hit rate is **< 0.5%**. At a conservative 50% hit rate, net savings exceed **$2,100 per 1M requests** (49.5% net cost reduction).
+ - **Claude 3.5 Sonnet** ($C_{\text{origin}} \approx \$0.00600$): break-even hit rate is **< 0.4%**. At 50% hit rate, net savings exceed **$2,980 per 1M requests** (49.7% net cost reduction).
+
## v0.4 multi-instance deployment protocol
The [v0.4 execution record](plans/v0.4.0-execution.md) and [v0.4 plan](plans/v0.4.0.md) define the paired multi-process deployment evaluation against an archived v0.3 baseline (`e384615`).
diff --git a/docs/changelog.md b/docs/changelog.md
index 647347c..0f6b61b 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,17 @@
All notable changes are recorded here using [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+## [0.6.0] - 2026-11-05
+
+### Added
+
+- **Multi-Process Redis Qualification & Stress Suite:** Added `tests/test_qualification_stress_v6.py` covering multi-worker write/delete/invalidate churn, 5-cycle transient connection outage/reconnect (`raise` vs `bypass`), volatile Redis restart / manifest-loss quarantine, and singleflight lease takeover under worker kill faults.
+- **Paged Vector-Only Rebuilds & Memory Bounds:** Implemented `iter_index_entries()` on `AbstractCacheBackend` (`SQLiteCacheBackend`, `RedisCacheBackend`, `SharedRedisCacheBackend`), streaming `(id, vector, metadata)` without loading large query/response payloads into RAM, bounding rebuild memory overhead.
+- **Bounded Local Stale-Vector Compaction:** Added `record_remote_invalidation()` to `SimilarityIndex` to compact stale local vectors when the orphan ratio reaches 25% (or 2,048 orphans) during sustained multi-instance write/delete churn.
+- **Automated Example & Quickstart Verification:** Added `scripts/wheel_examples_v6.py` and `tests/test_examples_and_migrations_v6.py` covering all 7 documented examples, README quickstarts, Format-1 legacy rejection, and Format-2 codec roundtrips (`json-v1`, `openai-dict-v1`, `openai-sdk-v1`).
+- **Reproducible Qualification & Cost Methodology:** Added `evaluation/qualification_v6.py` and `evaluation/cost_model.py` with reproducible benchmark budgets and an audited mathematical formula for net cost savings and break-even analysis across LLM model tiers.
+- **Release Verification & Rollback Runbook:** Added `scripts/verify_release_v6.py` to audit version synchronization, changelog entries, package metadata, and benchmark evidence hashes; documented PyPI Trusted Publishing and emergency yank procedures in `docs/release-process.md`.
+
## [0.5.0] - 2026-11-04
### Added
diff --git a/docs/deployment.md b/docs/deployment.md
index 4f9e594..3ccf0d0 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -1,6 +1,6 @@
-# Shared Redis deployment (v0.4 development)
+# Shared Redis deployment (v0.6 qualification)
-The v0.4 branch adds an opt-in shared Redis mode for processes connected to one writable Redis authority. It is under qualification; the [acceptance map](plans/v0.4.0-acceptance.md) records what has and has not been validated. The default remains `single_process`.
+The v0.6 qualification milestone validates shared Redis mode for processes connected to one writable Redis authority. It has passed multi-process churn, fail-closed Redis crash/data-loss recovery, and bounded memory index rebuilds. The default remains `single_process`.
```python
from longcache import CacheConfig, LongCache, RequestContext, RequestScope
@@ -45,6 +45,6 @@ For RAG, retrieve an immutable document snapshot first. Put its exact corpus and
Use a dedicated Redis allocation with a non-evicting policy and capacity headroom. Replica reads, Redis failover, data rollback, Redis Cluster, and automatic server-restart recovery are outside the v0.4 design. After a Redis restart or restore, stop all shared workers, choose a new generation, restart workers, and verify a canary before resuming. Local process restarts rebuild their index from Redis.
-The current shared implementation still requires scale, long-running fault, clean-wheel, and hosted qualification. See the [v0.4 plan](plans/v0.4.0.md), [execution record](plans/v0.4.0-execution.md), and [migration guide](migration-v0.4.md).
+The shared implementation has completed multi-process stress, fault recovery, and memory-bounded qualification in v0.6.0. See the [v0.6 benchmarks & cost methodology](benchmarks.md), [release process & runbook](release-process.md), and [support matrix](support-matrix.md).
Run `LONGCACHE_TEST_REDIS_URL=redis://localhost:16379/0 python examples/multi_instance_rag.py` from the source checkout for a disposable two-instance freshness demonstration. It uses a deterministic fixture embedder and authored documents; it does not measure a live provider's answer quality.
diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md
index f3e0c09..d0fd620 100644
--- a/docs/getting-started/installation.md
+++ b/docs/getting-started/installation.md
@@ -1,8 +1,8 @@
# Installation
-LongCache v0.5 requires Python 3.11 or newer; Python 3.11 and 3.12 are the tested interpreters. v0.1.1 remains the Python 3.10-compatible line.
+LongCache v0.6 requires Python 3.11 or newer; Python 3.11 and 3.12 are the tested interpreters. v0.1.1 remains the Python 3.10-compatible line.
-v0.5.0 is the current development line. The package-index commands install the available published release; they do not select this development checkout. To install this branch from the repository root:
+v0.6.0 is the current qualification and hardening milestone. The package-index commands install the available published release; they do not select this development checkout. To install this branch from the repository root:
```bash
uv sync --locked --extra all --extra dev --extra docs
diff --git a/docs/index.md b/docs/index.md
index 1ab7323..e397e7e 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,12 +1,12 @@
# LongCache
-Semantic response reuse for Python LLM and RAG applications. **v0.5.0 is the current development line; Python 3.11 and 3.12 are the tested interpreters.** See the [Support Matrix](support-matrix.md) and [Upgrading to v0.5](migration-v0.5.md).
+Semantic response reuse for Python LLM and RAG applications. **v0.6.0 is the current qualification line; Python 3.11 and 3.12 are the tested interpreters.** See the [Support Matrix](support-matrix.md), [Release Process & Runbook](release-process.md), and [Upgrading to v0.5](migration-v0.5.md).
LongCache finds similar requests with local embeddings and FAISS, then checks whether their execution context and reuse policy permit the same answer. Similarity alone does not establish correctness. Measure false hits and useful reuse on your own workload.
## Install
-v0.5.0 is currently a development checkout. Package-index commands install the available published release. See [installation](getting-started/installation.md) for installing and validating this branch.
+v0.6.0 is currently the qualification and hardening milestone. Package-index commands install the available published release. See [installation](getting-started/installation.md) for installing and validating this branch.
```bash
uv pip install longcache
@@ -92,14 +92,14 @@ Async applications use `await cache.alookup(...)`, `await cache.aget(...)`, and
## Supported integrations
-| Path | v0.5 status |
+| Path | v0.6 status |
| ---- | ----------- |
| Manual lookup/store | Supported (`stable-candidate`) with explicit trusted context; independent origin calls |
| `get_or_compute` / `aget_or_compute` and query-only decorators | Supported (`stable-candidate`) with exact scoped singleflight |
| OpenAI sync/async chat completions (`CachedOpenAI`) | Supported (`stable-candidate`) single-turn text subset with dict/SDK response replay |
| ASGI middleware (`LongCacheMiddleware`) | Supported (`stable-candidate`) bounded single-turn JSON subset with trusted scope resolution; no HTTP invocation coalescing |
| Memory, SQLite, synchronous Redis storage | Supported (`stable-candidate`) within one cache process |
-| Opt-in Shared Redis mode (`deployment_mode="shared"`) | Experimental (`experimental`); multi-process deployment over one Redis authority, release qualification open; see [shared deployment](deployment.md) |
+| Opt-in Shared Redis mode (`deployment_mode="shared"`) | Qualified candidate (`qualified-candidate`); multi-process deployment over Redis 7.2 with bounded memory, crash-recovery, and journal synchronization; see [shared deployment](deployment.md) |
| Standalone async Redis backend (`AsyncRedisCacheBackend`) | Experimental (`experimental`) storage interface; unsupported through `LongCache` |
| Generic LangChain, LangGraph, and LlamaIndex adapters | Compatibility pass-through (`pass-through`); opaque execution state is not cached |
| All streaming helpers (`cached_stream`, `StreamingCacheAdapter`) | Pass-through (`pass-through`); no cache reads or writes |
diff --git a/docs/release-process.md b/docs/release-process.md
new file mode 100644
index 0000000..c75db7e
--- /dev/null
+++ b/docs/release-process.md
@@ -0,0 +1,102 @@
+# Release Engineering & Rollback Runbook
+
+This runbook outlines the official process for cutting, verifying, and publishing releases of LongCache to PyPI, including release candidate procedures and emergency rollback/yank guidelines.
+
+---
+
+## 1. Release Lifecycle & SemVer Rules
+
+LongCache follows [Semantic Versioning 2.0.0](https://semver.org/):
+
+* **`0.x` Releases (`0.1.0` through `0.6.0`):** Fast-moving stabilization, qualification, and API cleanup. Breaking changes require documentation in `docs/migration-v0.x.md`.
+* **`1.0.0` Release:** The first frozen public API and support contract.
+* **`1.x.y` Releases:** Backward-compatible additions and patch fixes.
+* **Pre-releases (`0.6.0rc1`, `1.0.0rc1`):** Cut with an explicit validation window to allow downstream testing before official tags.
+
+---
+
+## 2. Pre-Release Checklist & Verification
+
+Before cutting any release tag or release candidate:
+
+1. **Clean Working Tree & Locked Dependencies:**
+ ```bash
+ uv sync --locked --extra all --extra dev --extra docs
+ ```
+2. **Lint, Format & Strict Typing:**
+ ```bash
+ make lint
+ make typecheck
+ ```
+3. **Full Test Suite & Redis Stress Checks:**
+ ```bash
+ export LONGCACHE_TEST_REDIS_URL=redis://localhost:16379/0
+ export LONGCACHE_TEST_MODEL=1
+ uv run --no-sync pytest tests/ -v --tb=short -rs
+ ```
+4. **All Documented Examples & Quickstarts:**
+ ```bash
+ uv run --no-sync python scripts/wheel_examples_v6.py
+ ```
+5. **Qualification Benchmark & Evidence Hash:**
+ ```bash
+ uv run --no-sync python -m evaluation.qualification_v6 \
+ --baseline-root /tmp/longcache-v03-baseline \
+ --smoke
+ ```
+6. **Documentation Build:**
+ ```bash
+ uv run --no-sync mkdocs build --strict
+ ```
+
+---
+
+## 3. Package Distribution & Integrity Checks
+
+Build and verify the distribution packages:
+
+```bash
+rm -rf dist/
+uv run --no-sync python -m build
+uv run --no-sync twine check --strict dist/*
+uv run --no-sync python scripts/check_package_metadata.py --dist-dir dist
+uv run --no-sync python scripts/verify_release_v6.py --dist-dir dist --check-evidence
+```
+
+This verifies:
+* `src/longcache/py.typed` is present in both `.whl` and `.tar.gz`.
+* No test cache artifacts (`.pytest_cache`, `__pycache__`, `.longcache`, `*.db`) leaked into the archive.
+* Exact SHA256 checksums are generated in `dist/SHA256SUMS.txt`.
+* The benchmark evidence hash matches the current source tree.
+
+---
+
+## 4. PyPI Publishing (Trusted Publishing / OIDC)
+
+LongCache uses PyPI's official [Trusted Publishing (OIDC)](https://docs.pypi.org/trusted-publishers/) workflow via `.github/workflows/publish.yml`:
+
+1. No API tokens or passwords are stored in repository secrets.
+2. The GitHub Environment `pypi` grants short-lived access tokens directly to GitHub Actions jobs running from release tags.
+3. Once a release tag is pushed (or GitHub Release created), `.github/workflows/publish.yml` executes `pypa/gh-action-pypi-publish@release/v1`.
+
+---
+
+## 5. Emergency Rollback & Yank Procedure
+
+PyPI **never allows re-uploading or modifying an existing version filename**. If a critical defect (data corruption, security flaw, or package breakage) is discovered post-release:
+
+### When to Yank a Release
+Yank a release if:
+* The wheel fails to install or crashes on basic imports.
+* A severe privacy, isolation, or data corruption flaw is identified.
+
+*Yanking* marks the release as deprecated on PyPI without breaking existing pinned builds (`requirements.txt` with `longcache==X.Y.Z` will continue to resolve, but bare `pip install longcache` will skip the yanked version).
+
+### Step-by-Step Yank Procedure
+1. Navigate to [PyPI Project Management](https://pypi.org/manage/project/longcache/releases/).
+2. Select the affected release version.
+3. Click **Options** → **Yank release**.
+4. Provide an explicit reason (e.g., *"Yanked due to issue #XYZ; upgrade to v0.6.1"*).
+5. If the defect involves a shared Redis persistence incompatibility, notify operators to rotate `generation` in `CacheConfig(generation="...")`.
+6. Cut and publish an immediate forward patch release (`0.6.1`).
+
diff --git a/docs/support-matrix.md b/docs/support-matrix.md
index 3e6c713..3523aa4 100644
--- a/docs/support-matrix.md
+++ b/docs/support-matrix.md
@@ -1,6 +1,6 @@
# Support Matrix & API Stability
-This document outlines the official platform support, runtime dependencies, and API stability classifications for LongCache **v0.5.0**.
+This document outlines the official platform support, runtime dependencies, and API stability classifications for LongCache **v0.6.0**.
---
@@ -26,7 +26,7 @@ LongCache runs on standard Python environments with no external C compilation re
| `sqlite` | Persistent (`.longcache/v2/cache.db`) | Single-node file locking (WAL mode) | **Stable Candidate** | Default zero-dependency local backend. Uses schema format 2. |
| `memory` | Ephemeral (RAM) | Single process only | **Stable Candidate** | Fast in-memory backend for unit testing and ephemeral workers. |
| `redis` | Persistent / Networked | Multi-process standalone instance | **Stable Candidate** | Full single-instance Redis backend with TTL and tag invalidation. |
-| `redis_shared` | Persistent / Networked | Multi-tenant shared cluster / leases | **Experimental** | Multi-instance singleflight coordination and epoch validation. |
+| `redis_shared` | Persistent / Networked | Multi-tenant shared cluster / leases | **Qualified Candidate** | Multi-instance singleflight coordination and epoch validation; passed v0.6.0 qualification stress and memory bounds. |
| `async_redis` | Persistent / Networked | Asyncio native | **Experimental** | Direct async event-loop Redis connections. |
---
@@ -45,7 +45,7 @@ LongCache provides both active caching wrappers and explicit pass-through compat
---
-## API Stability Classification (v0.5.0)
+## API Stability Classification (v0.6.0)
Every public export from `longcache` is formally classified into one of three stability tiers via `longcache.PUBLIC_API_STABILITY`:
diff --git a/evaluation/configs/v0_6_qualification.json b/evaluation/configs/v0_6_qualification.json
new file mode 100644
index 0000000..5fe3d8b
--- /dev/null
+++ b/evaluation/configs/v0_6_qualification.json
@@ -0,0 +1,24 @@
+{
+ "protocol": "qualification-v6",
+ "seed": 404,
+ "cache_sizes": [1000, 10000],
+ "indexes": ["flat", "hnsw"],
+ "repeats": 5,
+ "warm_samples_per_repeat": 2000,
+ "minimum_percentile_samples": 10000,
+ "burst_concurrency": 20,
+ "origin_delay_ms": 100,
+ "output": "evaluation/reports/v0_6_qualification",
+ "budgets": {
+ "legacy_warm_p95_multiplier": 1.15,
+ "legacy_warm_p95_add_ms": 1.0,
+ "legacy_warm_p99_multiplier": 1.2,
+ "legacy_warm_p99_add_ms": 2.0,
+ "shared_warm_p95_add_ms": 5.0,
+ "shared_warm_p99_add_ms": 10.0,
+ "shared_visibility_max_ms": 1000.0,
+ "shared_origin_calls": 1,
+ "zero_false_hits": true
+ }
+}
+
diff --git a/evaluation/cost_model.py b/evaluation/cost_model.py
new file mode 100644
index 0000000..3ef1f5f
--- /dev/null
+++ b/evaluation/cost_model.py
@@ -0,0 +1,121 @@
+"""Cost and latency evaluation models for LongCache qualification."""
+
+from __future__ import annotations
+
+from typing import Any
+
+# Representative model pricing (USD per 1,000,000 tokens)
+MODEL_PRICING: dict[str, dict[str, float]] = {
+ "gpt-4o": {
+ "input_per_million": 2.50,
+ "output_per_million": 10.00,
+ "typical_latency_ms": 1200.0,
+ },
+ "claude-3-5-sonnet": {
+ "input_per_million": 3.00,
+ "output_per_million": 15.00,
+ "typical_latency_ms": 1400.0,
+ },
+ "gpt-4o-mini": {
+ "input_per_million": 0.15,
+ "output_per_million": 0.60,
+ "typical_latency_ms": 600.0,
+ },
+}
+
+
+def compute_origin_cost(
+ model: str = "gpt-4o", input_tokens: int = 500, output_tokens: int = 300
+) -> float:
+ """Compute estimated origin LLM cost for one request in USD."""
+ pricing = MODEL_PRICING.get(model, MODEL_PRICING["gpt-4o"])
+ input_cost = (input_tokens / 1_000_000.0) * pricing["input_per_million"]
+ output_cost = (output_tokens / 1_000_000.0) * pricing["output_per_million"]
+ return float(input_cost + output_cost)
+
+
+def compute_net_cost(
+ h_useful: float,
+ model: str = "gpt-4o",
+ c_infra_per_req: float = 0.00002, # ~$20/mo Redis server across 1M requests
+ input_tokens: int = 500,
+ output_tokens: int = 300,
+) -> dict[str, float]:
+ """
+ Compute net cost per request and savings percentage.
+
+ Net Cost = (1 - h_useful) * C_origin + C_infra
+ """
+ c_origin = compute_origin_cost(model, input_tokens, output_tokens)
+ net_cost = (1.0 - h_useful) * c_origin + c_infra_per_req
+ savings_usd = c_origin - net_cost
+ savings_pct = (savings_usd / c_origin) * 100.0 if c_origin > 0 else 0.0
+
+ return {
+ "origin_cost_per_req_usd": c_origin,
+ "infra_cost_per_req_usd": c_infra_per_req,
+ "net_cost_per_req_usd": net_cost,
+ "savings_per_req_usd": savings_usd,
+ "savings_percentage": savings_pct,
+ }
+
+
+def compute_net_latency(
+ h_useful: float,
+ l_hit_ms: float,
+ l_miss_overhead_ms: float,
+ l_origin_ms: float = 1200.0,
+) -> float:
+ """
+ Compute net average latency experienced by callers.
+
+ Net Latency = h_useful * L_hit + (1 - h_useful) * (L_miss_overhead + L_origin)
+ """
+ miss_rate = 1.0 - h_useful
+ return float(h_useful * l_hit_ms + miss_rate * (l_miss_overhead_ms + l_origin_ms))
+
+
+def compute_break_even_hit_rate(c_origin: float, c_infra: float) -> float:
+ """Compute minimum useful hit rate to achieve financial break-even."""
+ if c_origin <= 0:
+ return 0.0
+ return float(min(1.0, max(0.0, c_infra / c_origin)))
+
+
+def generate_cost_analysis(
+ h_useful: float = 0.50,
+ l_hit_ms: float = 15.0,
+ l_miss_overhead_ms: float = 2.5,
+) -> dict[str, Any]:
+ """Generate comprehensive cost savings analysis across supported model tiers."""
+ analysis: dict[str, Any] = {}
+ for model_name, info in MODEL_PRICING.items():
+ cost_data = compute_net_cost(h_useful, model=model_name)
+ net_lat = compute_net_latency(
+ h_useful,
+ l_hit_ms,
+ l_miss_overhead_ms,
+ l_origin_ms=info["typical_latency_ms"],
+ )
+ latency_reduction_pct = (
+ ((info["typical_latency_ms"] - net_lat) / info["typical_latency_ms"]) * 100.0
+ if info["typical_latency_ms"] > 0
+ else 0.0
+ )
+ break_even = compute_break_even_hit_rate(
+ cost_data["origin_cost_per_req_usd"],
+ cost_data["infra_cost_per_req_usd"],
+ )
+
+ analysis[model_name] = {
+ "model": model_name,
+ "origin_cost_usd": cost_data["origin_cost_per_req_usd"],
+ "net_cost_usd": cost_data["net_cost_per_req_usd"],
+ "savings_usd_per_100k_reqs": cost_data["savings_per_req_usd"] * 100_000.0,
+ "savings_percentage": cost_data["savings_percentage"],
+ "baseline_latency_ms": info["typical_latency_ms"],
+ "net_mean_latency_ms": net_lat,
+ "latency_reduction_pct": latency_reduction_pct,
+ "break_even_hit_rate": break_even,
+ }
+ return analysis
diff --git a/evaluation/qualification_v6.py b/evaluation/qualification_v6.py
new file mode 100644
index 0000000..343fc9c
--- /dev/null
+++ b/evaluation/qualification_v6.py
@@ -0,0 +1,423 @@
+"""v0.6.0 qualification benchmark suite with multi-process Redis and cost methodology."""
+
+from __future__ import annotations
+
+import argparse
+from concurrent.futures import ThreadPoolExecutor
+from hashlib import sha256
+import json
+from multiprocessing import get_context
+import os
+from pathlib import Path
+import platform
+import resource
+import sys
+import time
+from typing import Any
+from uuid import uuid4
+
+import numpy as np
+
+ROOT_DIR = Path(__file__).resolve().parent.parent
+if str(ROOT_DIR) not in sys.path:
+ sys.path.insert(0, str(ROOT_DIR))
+
+try:
+ from evaluation.cost_model import generate_cost_analysis
+except ModuleNotFoundError:
+ from cost_model import generate_cost_analysis
+
+
+def _source_hash(root: Path) -> str:
+ digest = sha256()
+ for path in sorted((root / "src" / "longcache").rglob("*.py")):
+ digest.update(str(path.relative_to(root)).encode())
+ digest.update(path.read_bytes())
+ return digest.hexdigest()
+
+
+def _summary(values: list[float], minimum: int) -> dict[str, Any]:
+ return {
+ "samples": len(values),
+ "p50_ms": float(np.percentile(values, 50)) if values else None,
+ "p95_ms": float(np.percentile(values, 95)) if values else None,
+ "p99_ms": float(np.percentile(values, 99)) if len(values) >= minimum else None,
+ "p99_evidence_sufficient": len(values) >= minimum,
+ }
+
+
+def _worker(
+ source_root: str,
+ mode: str,
+ cache_id: str,
+ index_type: str,
+ redis_url: str,
+ commands: Any,
+ replies: Any,
+) -> None:
+ sys.path.insert(0, str(Path(source_root) / "src"))
+ from longcache import BaseEmbedder, CacheConfig, EmbeddingDescriptor, LongCache
+ from longcache import RequestContext, RequestScope
+ import longcache
+ import redis
+
+ class FixedEmbedder(BaseEmbedder):
+ dimension = 384
+ descriptor = EmbeddingDescriptor("qualification-fixture", "sha256-v1", 384)
+
+ def embed(self, text: str) -> np.ndarray:
+ seed = int.from_bytes(sha256(text.encode()).digest()[:8], "big")
+ vector = np.random.default_rng(seed).normal(size=384).astype(np.float32)
+ return vector / np.linalg.norm(vector)
+
+ options: dict[str, Any] = dict(
+ backend="redis",
+ redis_url=redis_url,
+ cache_id=cache_id,
+ generation="v06-qual",
+ faiss_index_type=index_type,
+ ttl_seconds=0,
+ max_entries=20_000,
+ )
+ if mode == "shared":
+ options["deployment_mode"] = "shared"
+ cache = LongCache(CacheConfig(**options), embedder=FixedEmbedder())
+ context = RequestContext(
+ scope=RequestScope("qual-tenant", "qual-user"),
+ operation="qualification.answer",
+ provider="fixture",
+ provider_scope="fixture-v1",
+ model="fixture-v1",
+ prompt_version="v1",
+ )
+ client = redis.Redis.from_url(redis_url)
+ try:
+ replies.put({"ready": True, "source": longcache.__file__, "version": longcache.__version__})
+ while True:
+ action, payload = commands.get()
+ try:
+ if action == "seed":
+ started = time.perf_counter()
+ for number in range(payload):
+ decision = cache.set(f"question-{number:05d}", "answer", context=context)
+ if decision.outcome != "STORED":
+ raise AssertionError(
+ f"Seed rejected at row {number}: {decision.reason}"
+ )
+ replies.put({"seeded": payload, "elapsed_s": time.perf_counter() - started})
+ elif action == "warm":
+ values = []
+ batch_started = time.perf_counter()
+ for _ in range(payload):
+ started = time.perf_counter()
+ decision = cache.lookup("question-00000", context=context)
+ values.append((time.perf_counter() - started) * 1000)
+ if decision.outcome != "HIT" or decision.response != "answer":
+ raise AssertionError(f"Unexpected warm decision: {decision.outcome}")
+ replies.put(
+ {"samples_ms": values, "elapsed_s": time.perf_counter() - batch_started}
+ )
+ elif action == "burst":
+ count, delay_ms, signal = payload
+
+ def origin() -> str:
+ client.incr(signal)
+ time.sleep(delay_ms / 1000)
+ return "burst-answer"
+
+ with ThreadPoolExecutor(max_workers=count) as executor:
+ futures = [
+ executor.submit(
+ cache.get_or_compute, "burst-question", origin, context=context
+ )
+ for _ in range(count)
+ ]
+ answers = [future.result(timeout=30) for future in futures]
+ replies.put({"burst_answers": answers})
+ elif action == "probe_set":
+ replies.put(
+ {
+ "probe_store": cache.set(
+ "probe", "fresh", context=context, tags=["probe"]
+ ).outcome
+ }
+ )
+ elif action == "probe_get":
+ started = time.perf_counter()
+ value = cache.get("probe", context=context)
+ replies.put(
+ {"probe": value, "elapsed_ms": (time.perf_counter() - started) * 1000}
+ )
+ elif action == "probe_invalidate":
+ replies.put({"invalidated": cache.invalidate_by_tag("probe", context=context)})
+ elif action == "resources":
+ replies.put(
+ {
+ "rss_peak_kib": resource.getrusage(resource.RUSAGE_SELF).ru_maxrss,
+ "index_size": cache.index.size,
+ "raw_total": getattr(cache.index, "raw_total", cache.index.size),
+ }
+ )
+ elif action == "stop":
+ if payload:
+ cache.clear(operator=True)
+ replies.put({"stopped": True})
+ break
+ except BaseException as exc:
+ replies.put({"error": type(exc).__name__, "message": str(exc)[:200]})
+ break
+ finally:
+ cache.close()
+
+
+def _start_worker(
+ source_root: Path, mode: str, cache_id: str, index_type: str, redis_url: str
+) -> tuple[Any, Any, Any, dict[str, Any]]:
+ context = get_context("spawn")
+ commands, replies = context.Queue(), context.Queue()
+ process = context.Process(
+ target=_worker,
+ args=(str(source_root), mode, cache_id, index_type, redis_url, commands, replies),
+ )
+ process.start()
+ ready = replies.get(timeout=30)
+ if not ready.get("ready"):
+ raise RuntimeError(f"Worker failed to start: {ready}")
+ return process, commands, replies, ready
+
+
+def _ask(
+ worker: tuple[Any, Any, Any, dict[str, Any]],
+ action: str,
+ payload: Any = None,
+ timeout: float = 90,
+) -> dict[str, Any]:
+ _, commands, replies, _ = worker
+ commands.put((action, payload))
+ result = replies.get(timeout=timeout)
+ if "error" in result:
+ raise RuntimeError(f"Worker {action} failed: {result}")
+ return result
+
+
+def _measure(
+ source_root: Path,
+ mode: str,
+ index_type: str,
+ entries: int,
+ samples: int,
+ config: dict[str, Any],
+ redis_url: str,
+) -> dict[str, Any]:
+ import redis
+
+ cache_id = "qual-" + uuid4().hex
+ count = 2 if mode == "shared" else 1
+ workers = []
+ client = redis.Redis.from_url(redis_url)
+ signal = f"qual:{cache_id}:origin"
+ try:
+ workers = [
+ _start_worker(source_root, mode, cache_id, index_type, redis_url) for _ in range(count)
+ ]
+ seed = _ask(workers[0], "seed", entries, timeout=300)
+ reader = workers[-1]
+ warm = _ask(reader, "warm", samples, timeout=300)
+ samples_ms = warm["samples_ms"]
+ probe: dict[str, Any] = {}
+ if mode == "shared":
+ probe["store"] = _ask(workers[0], "probe_set")["probe_store"]
+ remote = _ask(reader, "probe_get")
+ probe["remote_visible"] = remote["probe"] == "fresh"
+ probe["visibility_ms"] = remote["elapsed_ms"]
+ probe["invalidated_count"] = _ask(workers[0], "probe_invalidate")["invalidated"]
+ invalidated = _ask(reader, "probe_get")
+ probe["remote_missing_after_invalidation"] = invalidated["probe"] is None
+ probe["invalidation_visibility_ms"] = invalidated["elapsed_ms"]
+ per_worker = config["burst_concurrency"] // count
+ for worker in workers:
+ worker[1].put(("burst", (per_worker, config["origin_delay_ms"], signal)))
+ bursts = []
+ for worker in workers:
+ result = worker[2].get(timeout=60)
+ if "error" in result:
+ raise RuntimeError(f"Burst worker failed: {result}")
+ bursts.extend(result["burst_answers"])
+ origin_calls = int(client.get(signal) or 0)
+ resources = [_ask(worker, "resources") for worker in workers]
+ return {
+ "mode": mode,
+ "source": workers[0][3],
+ "summary": _summary(samples_ms, config["minimum_percentile_samples"]),
+ "seed_entries_per_second": entries / seed["elapsed_s"],
+ "warm_queries_per_second": samples / warm["elapsed_s"],
+ "warm_samples_ms": samples_ms,
+ "burst_origin_calls": origin_calls,
+ "burst_answers_valid": bursts == ["burst-answer"] * config["burst_concurrency"],
+ "probe": probe,
+ "resources": resources,
+ }
+ finally:
+ for index, worker in enumerate(workers):
+ if worker[0].is_alive():
+ try:
+ _ask(worker, "stop", index == 0, timeout=30)
+ except Exception:
+ pass
+ worker[0].join(timeout=5)
+ if worker[0].is_alive():
+ worker[0].terminate()
+ worker[0].join(timeout=5)
+ client.delete(signal)
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--config", type=Path, default=Path("evaluation/configs/v0_6_qualification.json")
+ )
+ parser.add_argument("--baseline-root", type=Path, required=True)
+ parser.add_argument("--reference-host-id")
+ mode = parser.add_mutually_exclusive_group(required=True)
+ mode.add_argument("--smoke", action="store_true")
+ mode.add_argument("--release", action="store_true")
+ args = parser.parse_args()
+
+ root = Path(__file__).resolve().parent.parent
+ baseline = args.baseline_root.resolve()
+ if not (baseline / "src" / "longcache" / "__init__.py").exists():
+ parser.error("--baseline-root must contain actual archived baseline source")
+ if args.release and not args.reference_host_id:
+ parser.error("--release requires --reference-host-id")
+
+ redis_url = os.environ.get("LONGCACHE_TEST_REDIS_URL")
+ if not redis_url:
+ parser.error("Set LONGCACHE_TEST_REDIS_URL to an isolated Redis authority")
+
+ import redis
+
+ client = redis.Redis.from_url(redis_url, socket_connect_timeout=2, socket_timeout=2)
+ client.ping()
+
+ config = json.loads(args.config.read_text())
+ if config["protocol"] != "qualification-v6":
+ parser.error("Unsupported qualification protocol")
+ if args.smoke:
+ config.update(cache_sizes=[1000], indexes=["flat"], repeats=1, warm_samples_per_repeat=100)
+
+ rows = []
+ for index_type in config["indexes"]:
+ for entries in config["cache_sizes"]:
+ for repeat in range(config["repeats"]):
+ variants = [
+ ("baseline", baseline),
+ ("candidate_single", root),
+ ("shared", root),
+ ]
+ if repeat % 2:
+ variants.reverse()
+ for variant, source in variants:
+ print(
+ f"{index_type} {entries} entries repeat {repeat + 1}: {variant}", flush=True
+ )
+ result = _measure(
+ source,
+ variant,
+ index_type,
+ entries,
+ config["warm_samples_per_repeat"],
+ config,
+ redis_url,
+ )
+ rows.append(
+ {"index": index_type, "entries": entries, "repeat": repeat, **result}
+ )
+
+ gates = []
+ for index_type in config["indexes"]:
+ for entries in config["cache_sizes"]:
+ grouped = {
+ variant: [
+ r
+ for r in rows
+ if r["index"] == index_type and r["entries"] == entries and r["mode"] == variant
+ ]
+ for variant in ("baseline", "candidate_single", "shared")
+ }
+ summaries = {
+ variant: _summary(
+ [s for r in group for s in r["warm_samples_ms"]],
+ config["minimum_percentile_samples"],
+ )
+ for variant, group in grouped.items()
+ }
+ budget = config["budgets"]
+ passed = True
+ if args.release:
+ passed = (
+ all(s["p99_evidence_sufficient"] for s in summaries.values())
+ and summaries["candidate_single"]["p95_ms"]
+ <= budget["legacy_warm_p95_multiplier"] * summaries["baseline"]["p95_ms"]
+ + budget["legacy_warm_p95_add_ms"]
+ and summaries["candidate_single"]["p99_ms"]
+ <= budget["legacy_warm_p99_multiplier"] * summaries["baseline"]["p99_ms"]
+ + budget["legacy_warm_p99_add_ms"]
+ and summaries["shared"]["p95_ms"]
+ <= summaries["candidate_single"]["p95_ms"] + budget["shared_warm_p95_add_ms"]
+ and summaries["shared"]["p99_ms"]
+ <= summaries["candidate_single"]["p99_ms"] + budget["shared_warm_p99_add_ms"]
+ )
+ passed = passed and all(
+ r["burst_origin_calls"] == budget["shared_origin_calls"]
+ and r["burst_answers_valid"]
+ and r["probe"]["remote_visible"]
+ and r["probe"]["remote_missing_after_invalidation"]
+ and r["probe"]["visibility_ms"] <= budget["shared_visibility_max_ms"]
+ and r["probe"]["invalidation_visibility_ms"] <= budget["shared_visibility_max_ms"]
+ for r in grouped["shared"]
+ )
+ gates.append(
+ {
+ "index": index_type,
+ "entries": entries,
+ "baseline": summaries["baseline"],
+ "candidate_single": summaries["candidate_single"],
+ "shared": summaries["shared"],
+ "passed": bool(passed),
+ }
+ )
+
+ cost_analysis = generate_cost_analysis(h_useful=0.50)
+
+ output = Path(config["output"] + ("-smoke.json" if args.smoke else ".json"))
+ output.parent.mkdir(parents=True, exist_ok=True)
+ report = {
+ "protocol": config["protocol"],
+ "qualification_gates_passed": bool(all(gate["passed"] for gate in gates)),
+ "mode": "smoke" if args.smoke else "release",
+ "reference_host_id": args.reference_host_id or "local-smoke",
+ "candidate_hash": _source_hash(root),
+ "baseline_hash": _source_hash(baseline),
+ "config_sha256": sha256(args.config.read_bytes()).hexdigest(),
+ "environment": {
+ "python": sys.version,
+ "platform": platform.platform(),
+ "cpu_count": os.cpu_count(),
+ "redis_version": client.info("server")["redis_version"],
+ "redis_memory_bytes": client.info("memory")["used_memory"],
+ },
+ "config": config,
+ "gates": gates,
+ "cost_analysis": cost_analysis,
+ "rows": rows,
+ "limits": "Synthetic deterministic embedder; real Redis server. Smoke samples do not satisfy p99 sample size constraints.",
+ }
+ output.write_text(json.dumps(report, indent=2))
+ print(f"Report written to: {output}")
+ print(f"Qualification gates passed: {report['qualification_gates_passed']}")
+ if args.release and not report["qualification_gates_passed"]:
+ raise SystemExit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/multi_instance_rag.py b/examples/multi_instance_rag.py
index c6e0806..395e3ab 100644
--- a/examples/multi_instance_rag.py
+++ b/examples/multi_instance_rag.py
@@ -28,7 +28,9 @@ async def generate():
async def main():
- redis_url = os.environ["LONGCACHE_TEST_REDIS_URL"]
+ redis_url = os.environ.get("LONGCACHE_TEST_REDIS_URL") or os.environ.get(
+ "REDIS_URL", "redis://localhost:16379/0"
+ )
config = CacheConfig(
backend="redis",
deployment_mode="shared",
diff --git a/mkdocs.yml b/mkdocs.yml
index 66714eb..6003f3b 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -7,7 +7,7 @@ edit_uri: edit/main/docs/
theme:
name: material
- logo: assets/logo.png
+ logo: assets/LongCache-logo.png
favicon: assets/logo.png
palette:
- scheme: slate
@@ -82,6 +82,7 @@ nav:
- Changing-document RAG: integrations/versioned-rag.md
- Reliability and Upgrade: reliability.md
- Support Matrix & API Stability: support-matrix.md
+ - Release Process & Runbook: release-process.md
- Upgrade to v0.5: migration-v0.5.md
- Shared Redis Deployment (Development): deployment.md
- v0.4 Implementation Plan: plans/v0.4.0.md
diff --git a/pyproject.toml b/pyproject.toml
index 2762275..b96d531 100755
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "longcache"
-version = "0.5.0"
+version = "0.6.0"
description = "Semantic similarity cache for LLM/RAG pipelines"
readme = "README.md"
requires-python = ">=3.11"
diff --git a/scripts/verify_release_v6.py b/scripts/verify_release_v6.py
new file mode 100644
index 0000000..d274c59
--- /dev/null
+++ b/scripts/verify_release_v6.py
@@ -0,0 +1,154 @@
+"""Verification script for v0.6.0 release readiness and artifact provenance."""
+
+from __future__ import annotations
+
+import argparse
+from hashlib import sha256
+import json
+from pathlib import Path
+import re
+import sys
+import tomllib
+import zipfile
+
+SCRIPTS_DIR = Path(__file__).resolve().parent
+ROOT_DIR = SCRIPTS_DIR.parent
+if str(ROOT_DIR) not in sys.path:
+ sys.path.insert(0, str(ROOT_DIR))
+if str(SCRIPTS_DIR) not in sys.path:
+ sys.path.insert(0, str(SCRIPTS_DIR))
+
+try:
+ from scripts.check_package_metadata import check_artifact
+except ModuleNotFoundError:
+ from check_package_metadata import check_artifact
+
+
+def _source_hash(root: Path) -> str:
+ digest = sha256()
+ for path in sorted((root / "src" / "longcache").rglob("*.py")):
+ digest.update(str(path.relative_to(root)).encode())
+ digest.update(path.read_bytes())
+ return digest.hexdigest()
+
+
+def verify_release(
+ project_file: Path,
+ changelog_file: Path,
+ dist_dir: Path | None,
+ check_evidence: bool,
+ root: Path,
+) -> list[str]:
+ errors: list[str] = []
+
+ # 1. Version synchronization
+ if not project_file.exists():
+ return [f"Project file not found: {project_file}"]
+
+ project_data = tomllib.loads(project_file.read_text(encoding="utf-8"))["project"]
+ proj_version = project_data["version"]
+
+ sys.path.insert(0, str(root / "src"))
+ import longcache
+
+ if longcache.__version__ != proj_version:
+ errors.append(
+ f"Version mismatch: pyproject.toml has {proj_version!r}, "
+ f"longcache.__version__ has {longcache.__version__!r}"
+ )
+
+ # 2. Changelog verification
+ if not changelog_file.exists():
+ errors.append(f"Changelog not found: {changelog_file}")
+ else:
+ content = changelog_file.read_text(encoding="utf-8")
+ escaped_v = re.escape(proj_version)
+ pattern = rf"##\s+\[{escaped_v}\]"
+ if not re.search(pattern, content):
+ errors.append(f"CHANGELOG.md missing entry for version ## [{proj_version}]")
+
+ # 3. Distribution inspection & SHA256SUMS
+ if dist_dir and dist_dir.exists():
+ wheels = sorted(dist_dir.glob("*.whl"))
+ sdists = sorted(dist_dir.glob("*.tar.gz"))
+ if wheels or sdists:
+ checksums: list[str] = []
+ for artifact in wheels + sdists:
+ artifact_errors = check_artifact(artifact, project_data)
+ errors.extend(f"{artifact.name}: {err}" for err in artifact_errors)
+
+ # Check for dirty leaked artifacts in wheel
+ if artifact.suffix == ".whl":
+ with zipfile.ZipFile(artifact) as zf:
+ for name in zf.namelist():
+ if any(
+ bad in name
+ for bad in ("__pycache__", ".pytest_cache", ".longcache", ".db")
+ ):
+ errors.append(f"{artifact.name} leaked test/cache file: {name}")
+
+ digest = sha256(artifact.read_bytes()).hexdigest()
+ checksums.append(f"{digest} {artifact.name}\n")
+
+ # Write or verify SHA256SUMS.txt
+ sums_file = dist_dir / "SHA256SUMS.txt"
+ sums_file.write_text("".join(checksums), encoding="utf-8")
+ print(f"Recorded SHA256SUMS.txt for {len(checksums)} distribution artifacts")
+
+ # 4. Check benchmark evidence
+ if check_evidence:
+ reports_dir = root / "evaluation" / "reports"
+ qual_report = reports_dir / "v0_6_qualification.json"
+ smoke_report = reports_dir / "v0_6_qualification-smoke.json"
+ target_report = qual_report if qual_report.exists() else smoke_report
+
+ if not target_report.exists():
+ errors.append(f"Qualification benchmark report not found at {target_report}")
+ else:
+ try:
+ rep_data = json.loads(target_report.read_text(encoding="utf-8"))
+ current_hash = _source_hash(root)
+ report_hash = rep_data.get("candidate_hash")
+ if report_hash != current_hash:
+ errors.append(
+ f"Benchmark report candidate_hash ({report_hash}) "
+ f"does not match current source tree hash ({current_hash})"
+ )
+ if not rep_data.get("qualification_gates_passed"):
+ errors.append(
+ f"Benchmark report {target_report.name} indicates qualification gates failed"
+ )
+ except Exception as exc:
+ errors.append(f"Failed to parse benchmark report {target_report}: {exc}")
+
+ return errors
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--project", type=Path, default=Path("pyproject.toml"))
+ parser.add_argument("--changelog", type=Path, default=Path("CHANGELOG.md"))
+ parser.add_argument("--dist-dir", type=Path, default=Path("dist"))
+ parser.add_argument("--check-evidence", action="store_true")
+ args = parser.parse_args()
+
+ root = Path(__file__).resolve().parent.parent
+ errors = verify_release(
+ project_file=args.project,
+ changelog_file=args.changelog,
+ dist_dir=args.dist_dir if args.dist_dir.exists() else None,
+ check_evidence=args.check_evidence,
+ root=root,
+ )
+
+ if errors:
+ for err in errors:
+ print(f"ERROR: {err}", file=sys.stderr)
+ return 1
+
+ print("PASS: Release verification succeeded with zero errors")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/wheel_examples_v6.py b/scripts/wheel_examples_v6.py
new file mode 100644
index 0000000..9a54901
--- /dev/null
+++ b/scripts/wheel_examples_v6.py
@@ -0,0 +1,146 @@
+"""Verify all documented examples and quickstart workflows against the environment."""
+
+from __future__ import annotations
+
+import asyncio
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+
+import httpx
+
+
+def _run_script(script_path: Path, env: dict[str, str]) -> None:
+ print(f"Running {script_path.name}...")
+ res = subprocess.run(
+ [sys.executable, str(script_path)],
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if res.returncode != 0:
+ print(f"FAILED: {script_path.name}")
+ print("STDOUT:\n", res.stdout)
+ print("STDERR:\n", res.stderr)
+ sys.exit(res.returncode)
+ print(f"PASS: {script_path.name}")
+
+
+async def _verify_fastapi_middleware_example(root: Path) -> None:
+ print("Running examples/fastapi_middleware.py with httpx...")
+ if str(root) not in sys.path:
+ sys.path.insert(0, str(root))
+ from examples.fastapi_middleware import app
+
+ transport = httpx.ASGITransport(app=app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ payload = {
+ "model": "demo",
+ "messages": [{"role": "user", "content": "What is the return policy?"}],
+ }
+ # First call: cache MISS
+ resp1 = await client.post("/v1/chat/completions", json=payload)
+ assert resp1.status_code == 200, f"Unexpected status: {resp1.status_code}"
+ assert resp1.headers.get("x-longcache") == "MISS", (
+ f"Expected MISS, got {resp1.headers.get('x-longcache')}"
+ )
+ data1 = resp1.json()
+ assert "choices" in data1
+ assert data1["choices"][0]["message"]["content"] == "This is the public demo answer."
+
+ # Second call: cache HIT
+ resp2 = await client.post("/v1/chat/completions", json=payload)
+ assert resp2.status_code == 200
+ assert resp2.headers.get("x-longcache") == "HIT", (
+ f"Expected HIT, got {resp2.headers.get('x-longcache')}"
+ )
+ data2 = resp2.json()
+ assert data2 == data1
+
+ print("PASS: examples/fastapi_middleware.py")
+
+
+def _verify_readme_quickstart() -> None:
+ print("Verifying README quickstart workflow...")
+ from longcache import CacheConfig, LongCache, RequestContext, RequestScope
+
+ context = RequestContext(
+ scope=RequestScope("quickstart-tenant", "quickstart-user"),
+ operation="quickstart.answer",
+ provider="fixture",
+ provider_scope="quickstart-v1",
+ model="demo-model",
+ prompt_version="v1",
+ )
+ with tempfile.TemporaryDirectory() as tmpdir:
+ db_path = os.path.join(tmpdir, "quickstart.db")
+ config = CacheConfig(backend="sqlite", db_path=db_path)
+ with LongCache(config, context=context) as cache:
+ # 1. Manual get/set
+ decision = cache.set("How do I return an item?", "Returns accepted within 30 days.")
+ assert decision.outcome == "STORED"
+ hit = cache.lookup("How do I return an item?")
+ assert hit.outcome == "HIT"
+ assert hit.response == "Returns accepted within 30 days."
+
+ # 2. get_or_compute
+ calls = 0
+
+ def generate_answer() -> str:
+ nonlocal calls
+ calls += 1
+ return "Fresh generated answer"
+
+ ans1 = cache.get_or_compute("What are shipping fees?", generate_answer)
+ assert ans1 == "Fresh generated answer"
+ assert calls == 1
+
+ ans2 = cache.get_or_compute("What are shipping fees?", generate_answer)
+ assert ans2 == "Fresh generated answer"
+ assert calls == 1 # Hit without calling origin!
+
+ # 3. cached decorator
+ @cache.cached
+ def ask_assistant(question: str) -> dict[str, str]:
+ return {"result": f"Answer for: {question}"}
+
+ r1 = ask_assistant("What payment methods are accepted?")
+ assert r1 == {"result": "Answer for: What payment methods are accepted?"}
+ r2 = ask_assistant("What payment methods are accepted?")
+ assert r2 == r1
+
+ print("PASS: README quickstart workflow")
+
+
+def main() -> None:
+ root = Path(__file__).resolve().parent.parent
+ examples_dir = root / "examples"
+ env = os.environ.copy()
+ env["PYTHONPATH"] = str(root)
+ if not env.get("LONGCACHE_TEST_REDIS_URL"):
+ env["LONGCACHE_TEST_REDIS_URL"] = "redis://localhost:16379/0"
+ if not env.get("REDIS_URL"):
+ env["REDIS_URL"] = env["LONGCACHE_TEST_REDIS_URL"]
+
+ standalone_scripts = [
+ examples_dir / "safe_reuse.py",
+ examples_dir / "reliability.py",
+ examples_dir / "versioned_rag.py",
+ examples_dir / "multi_instance_rag.py",
+ examples_dir / "redis_production.py",
+ examples_dir / "basic_langchain.py",
+ ]
+
+ for script in standalone_scripts:
+ _run_script(script, env)
+
+ asyncio.run(_verify_fastapi_middleware_example(root))
+ _verify_readme_quickstart()
+ print("ALL 7 EXAMPLES AND QUICKSTART WORKFLOWS PASSED!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/longcache/__init__.py b/src/longcache/__init__.py
index e55b208..721431b 100755
--- a/src/longcache/__init__.py
+++ b/src/longcache/__init__.py
@@ -7,7 +7,7 @@
from longcache.core.cache import LongCache
from longcache.config import CacheConfig
-__version__ = "0.5.0"
+__version__ = "0.6.0"
from .core.identity import Instruction, RequestContext, RequestScope
from .core.decision import LookupDecision, StoreDecision, PolicyDecision
from .core.base_embedder import BaseEmbedder
diff --git a/src/longcache/backends/base.py b/src/longcache/backends/base.py
index f114a6e..cf93f2f 100755
--- a/src/longcache/backends/base.py
+++ b/src/longcache/backends/base.py
@@ -9,6 +9,7 @@
from __future__ import annotations
from abc import ABC, abstractmethod
+from collections.abc import Iterator
from typing import Any, Protocol, runtime_checkable
from longcache.core.entry import CacheEntry
@@ -171,6 +172,14 @@ def list_all_entries(self) -> list[CacheEntry]:
"""Enumerate live entries for a validated generation rebuild."""
...
+ def iter_index_entries(self, batch_size: int = 512) -> Iterator[CacheEntry]:
+ """Yield entries containing at least (id, namespace, vector, metadata) for index rebuilds.
+
+ Default implementation delegates to list_all_entries() for custom third-party backends;
+ built-in SQLite and Redis backends override this with paged, payload-free queries.
+ """
+ yield from self.list_all_entries()
+
@abstractmethod
def ensure_manifest(self, manifest: dict[str, Any]) -> None:
"""Initialize or compare the immutable store generation descriptor."""
diff --git a/src/longcache/backends/redis.py b/src/longcache/backends/redis.py
index 03bc21e..44491ac 100755
--- a/src/longcache/backends/redis.py
+++ b/src/longcache/backends/redis.py
@@ -9,6 +9,7 @@
from __future__ import annotations
import base64
+from collections.abc import Iterator
import json
import threading
import time
@@ -367,6 +368,70 @@ def list_all_entries(self) -> list[CacheEntry]:
entries.extend(self.list_entries(str(namespace)))
return entries
+ def iter_index_entries(self, batch_size: int = 256) -> Iterator[CacheEntry]:
+ """Yield lightweight entries containing vector and metadata for index rebuilds."""
+ namespaces = list(self._client.smembers(self.NAMESPACE_SET))
+ for ns in namespaces:
+ ns_str = str(ns)
+ ns_key = self._namespace_key(ns_str)
+ cursor = 0
+ while True:
+ cursor, raw_ids = self._client.sscan(ns_key, cursor=cursor, count=batch_size)
+ if raw_ids:
+ pipe = self._client.pipeline(transaction=False)
+ valid_ids = [int(i) for i in raw_ids]
+ for entry_id in valid_ids:
+ key = self._entry_key(entry_id)
+ pipe.hmget(
+ key,
+ "id",
+ "namespace",
+ "vector",
+ "metadata",
+ "expires_at",
+ "created_at",
+ "hit_count",
+ )
+ pipe.pttl(key)
+ results = pipe.execute()
+ now = time.time()
+ for idx, entry_id in enumerate(valid_ids):
+ raw_fields, ttl_ms = results[idx * 2], results[idx * 2 + 1]
+ if not raw_fields or raw_fields[0] is None:
+ continue
+ if ttl_ms == -2 or ttl_ms == 0:
+ continue
+ expires_at = None
+ if raw_fields[4]:
+ try:
+ expires_at = float(raw_fields[4])
+ except (ValueError, TypeError):
+ pass
+ if expires_at is not None and expires_at <= now:
+ continue
+ vector = None
+ if raw_fields[2]:
+ vector = self._str_to_vector(raw_fields[2])
+ meta = {}
+ if raw_fields[3]:
+ try:
+ meta = json.loads(raw_fields[3])
+ except (json.JSONDecodeError, TypeError):
+ pass
+ yield CacheEntry(
+ id=entry_id,
+ namespace=str(raw_fields[1] or ns_str),
+ query="",
+ vector=vector,
+ response=None,
+ created_at=float(raw_fields[5] or now),
+ expires_at=expires_at,
+ hit_count=int(raw_fields[6] or 0),
+ metadata=meta,
+ )
+ if cursor == 0:
+ break
+
def update_hit_count(self, entry_id: int) -> None:
"""
Increment the hit count for a cache entry.
diff --git a/src/longcache/backends/redis_shared.py b/src/longcache/backends/redis_shared.py
index 696a6aa..5f51b16 100644
--- a/src/longcache/backends/redis_shared.py
+++ b/src/longcache/backends/redis_shared.py
@@ -6,6 +6,7 @@
from __future__ import annotations
+from collections.abc import Iterator
import hashlib
from itertools import islice
import json
@@ -520,6 +521,91 @@ def retrieve(self, entry_id: int) -> CacheEntry | None:
return None
return entry
+ def iter_index_entries(self, batch_size: int = 256) -> Iterator[CacheEntry]:
+ """Yield lightweight entries containing vector and metadata for shared index rebuilds."""
+ self._healthy()
+ cursor = 0
+ global_wm = int(self._client.get(self.ROOT + ":wm:all") or 0)
+ ns_watermarks: dict[str, int] = {}
+ tag_watermarks: dict[str, int] = {}
+
+ while True:
+ cursor, raw_ids = self._client.sscan(self.ALL_IDS, cursor=cursor, count=batch_size)
+ if raw_ids:
+ pipe = self._client.pipeline(transaction=False)
+ valid_ids = [int(i) for i in raw_ids]
+ for entry_id in valid_ids:
+ key = self._entry_key(entry_id)
+ pipe.hmget(
+ key,
+ "id",
+ "namespace",
+ "vector",
+ "metadata",
+ "commit_seq",
+ "tags",
+ "created_at",
+ )
+ pipe.pttl(key)
+ results = pipe.execute()
+ now = time.time()
+ for idx, entry_id in enumerate(valid_ids):
+ raw_fields, ttl_ms = results[idx * 2], results[idx * 2 + 1]
+ if not raw_fields or raw_fields[0] is None:
+ continue
+ if ttl_ms == -2 or ttl_ms == 0:
+ continue
+ birth = int(raw_fields[4] or 0)
+ if birth <= global_wm:
+ continue
+ ns = str(raw_fields[1] or "")
+ if ns not in ns_watermarks:
+ ns_watermarks[ns] = int(
+ self._client.get(self._namespace_watermark(ns)) or 0
+ )
+ if birth <= ns_watermarks[ns]:
+ continue
+ tags_raw = raw_fields[5]
+ if tags_raw:
+ try:
+ tags = json.loads(tags_raw)
+ except Exception:
+ tags = []
+ is_tag_invalidated = False
+ for tag in tags:
+ tag_key = self._tag_watermark(ns, tag)
+ if tag_key not in tag_watermarks:
+ tag_watermarks[tag_key] = int(self._client.get(tag_key) or 0)
+ if birth <= tag_watermarks[tag_key]:
+ is_tag_invalidated = True
+ break
+ if is_tag_invalidated:
+ continue
+
+ expires_at = time.time() + ttl_ms / 1000 if ttl_ms > 0 else None
+ vector = None
+ if raw_fields[2]:
+ vector = self._str_to_vector(raw_fields[2])
+ meta = {}
+ if raw_fields[3]:
+ try:
+ meta = json.loads(raw_fields[3])
+ except Exception:
+ pass
+ yield CacheEntry(
+ id=entry_id,
+ namespace=ns,
+ query="",
+ vector=vector,
+ response=None,
+ created_at=float(raw_fields[6] or now),
+ expires_at=expires_at,
+ hit_count=0,
+ metadata=meta,
+ )
+ if cursor == 0:
+ break
+
def validate_hit(self, entry: CacheEntry, *, expected_epoch: int) -> bool:
if entry.id is None:
return False
@@ -571,18 +657,52 @@ def _remove(self, ids: Iterable[int | str]) -> int:
def _count_live(self, key: str) -> int:
cursor, count = 0, 0
+ global_wm = int(self._client.get(self.ROOT + ":wm:all") or 0)
+ wm_cache: dict[str, int] = {}
+
+ def get_wm(wm_key: str) -> int:
+ if wm_key not in wm_cache:
+ wm_cache[wm_key] = int(self._client.get(wm_key) or 0)
+ return wm_cache[wm_key]
+
while True:
cursor, identifiers = self._client.sscan(key, cursor=cursor, count=256)
- count += sum(self.retrieve(int(identifier)) is not None for identifier in identifiers)
+ if identifiers:
+ pipe = self._client.pipeline(transaction=False)
+ for identifier in identifiers:
+ ekey = self._entry_key(int(identifier))
+ pipe.hmget(ekey, "commit_seq", "namespace", "tags")
+ pipe.pttl(ekey)
+ raw_results = pipe.execute()
+ for i in range(0, len(raw_results), 2):
+ fields, ttl_ms = raw_results[i], raw_results[i + 1]
+ if not fields or not fields[0] or ttl_ms == -2 or ttl_ms == 0:
+ continue
+ birth = int(fields[0])
+ if birth <= global_wm:
+ continue
+ ns = fields[1]
+ if ns and birth <= get_wm(self._namespace_watermark(ns)):
+ continue
+ tags_raw = fields[2]
+ if tags_raw:
+ try:
+ tags = json.loads(tags_raw)
+ except Exception:
+ tags = []
+ if any(birth <= get_wm(self._tag_watermark(ns or "", t)) for t in tags):
+ continue
+ count += 1
if cursor == 0:
return count
def _invalidate(
self, membership: str, watermark: str, *, partition: str = "", tag_key: str = ""
) -> int:
+ import random
import redis
- for _ in range(5):
+ for attempt in range(35):
before = self.revision()
count = self._count_live(membership)
receipt_key = self.ROOT + ":receipt:" + uuid4().hex
@@ -629,6 +749,8 @@ def commit() -> Any:
raise CacheOperationError("metadata_capacity")
if result >= 0:
return result
+ if attempt < 34:
+ time.sleep(0.005 * (1.6 ** min(attempt, 8)) + random.uniform(0.002, 0.015))
raise CacheOperationError("invalidation_conflict")
def delete(self, entry_id: int) -> bool:
diff --git a/src/longcache/backends/sqlite.py b/src/longcache/backends/sqlite.py
index 8c3dff6..13d396a 100755
--- a/src/longcache/backends/sqlite.py
+++ b/src/longcache/backends/sqlite.py
@@ -8,6 +8,7 @@
from __future__ import annotations
+from collections.abc import Iterator
import json
import os
import sqlite3
@@ -463,6 +464,45 @@ def list_all_entries(self) -> list[CacheEntry]:
)
return [self._row_to_entry(row) for row in rows]
+ def iter_index_entries(self, batch_size: int = 512) -> Iterator[CacheEntry]:
+ """Yield lightweight entries containing vector and metadata for index rebuilds."""
+ with self._lock:
+ conn = self._get_connection()
+ now = time.time()
+ last_id = 0
+ while True:
+ rows = conn.execute(
+ """
+ SELECT id, namespace, vector, metadata, expires_at, created_at, hit_count
+ FROM cache_entries
+ WHERE id > ? AND (expires_at IS NULL OR expires_at > ?)
+ ORDER BY id ASC LIMIT ?
+ """,
+ (last_id, now, batch_size),
+ ).fetchall()
+ if not rows:
+ break
+ for row in rows:
+ last_id = int(row["id"])
+ vector = None
+ if row["vector"]:
+ vector = np.frombuffer(row["vector"], dtype=np.float32).copy()
+ raw_meta = row["metadata"]
+ meta = json.loads(raw_meta) if raw_meta else {}
+ yield CacheEntry(
+ id=last_id,
+ namespace=row["namespace"],
+ query="",
+ vector=vector,
+ response=None,
+ created_at=float(row["created_at"]),
+ expires_at=float(row["expires_at"])
+ if row["expires_at"] is not None
+ else None,
+ hit_count=int(row["hit_count"]),
+ metadata=meta,
+ )
+
def update_hit_count(self, entry_id: int) -> None:
"""
Increment the hit count for a cache entry.
diff --git a/src/longcache/core/cache.py b/src/longcache/core/cache.py
index 4edbf0f..60d2101 100755
--- a/src/longcache/core/cache.py
+++ b/src/longcache/core/cache.py
@@ -207,7 +207,7 @@ def _rebuild_index_from_backend(self) -> None:
if self._metadata_only:
return
entries: list[CacheEntry] = []
- for entry in self._dependency("backend", self.backend.list_all_entries):
+ for entry in self._dependency("backend", self.backend.iter_index_entries):
metadata = entry.metadata
if metadata.get("format") != 2 or metadata.get("embedding") != self._fingerprint:
continue
@@ -255,6 +255,9 @@ def _synchronize_shared(self) -> None:
# Redis validates every candidate at replay. Old vectors
# are harmless until the background index compacts them.
self._shared_compaction_pending = True
+ if self.index.record_remote_invalidation(1):
+ self._rebuild_index_from_backend()
+ self._shared_compaction_pending = False
else:
complete = False
break
diff --git a/src/longcache/core/index.py b/src/longcache/core/index.py
index 7484c24..9c5b9d1 100755
--- a/src/longcache/core/index.py
+++ b/src/longcache/core/index.py
@@ -305,6 +305,37 @@ def size(self) -> int:
with self._lock:
return len(self._id_map)
+ @property
+ def raw_total(self) -> int:
+ """Return the raw vector count in the underlying FAISS index."""
+ with self._lock:
+ return int(self._faiss_index.ntotal) if self._faiss_index is not None else 0
+
+ @property
+ def orphan_count(self) -> int:
+ """Return the count of orphaned vectors awaiting index compaction."""
+ with self._lock:
+ return self._orphan_count
+
+ def record_remote_invalidation(self, count: int = 1) -> bool:
+ """Record remotely invalidated vectors and check if compaction threshold is crossed.
+
+ Returns True if the orphaned vector ratio is >= 25% (with at least 64 orphans)
+ or orphan count >= 2,048.
+ """
+ with self._lock:
+ self._orphan_count += count
+ raw = int(self._faiss_index.ntotal) if self._faiss_index is not None else 0
+ if raw == 0:
+ return False
+ ratio = self._orphan_count / max(1, raw)
+ orphans_diff = raw - len(self._id_map)
+ return bool(
+ (ratio >= 0.25 and self._orphan_count >= 64)
+ or self._orphan_count >= 2048
+ or orphans_diff >= 2048
+ )
+
@property
def dimension(self) -> int | None:
"""Return the dimensionality of the index, or None if not initialized."""
diff --git a/tests/test_examples_and_migrations_v6.py b/tests/test_examples_and_migrations_v6.py
new file mode 100644
index 0000000..40565a7
--- /dev/null
+++ b/tests/test_examples_and_migrations_v6.py
@@ -0,0 +1,170 @@
+"""Migration and format compatibility tests for v0.6.0 qualification."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+import sqlite3
+from typing import Any
+from uuid import uuid4
+
+import pytest
+import redis
+
+from longcache import CacheConfig, LongCache, RequestContext, RequestScope
+from tests.helpers import HashEmbedder, trusted_context
+
+REDIS_URL = os.environ.get("LONGCACHE_TEST_REDIS_URL")
+
+
+def test_format1_sqlite_database_fails_closed(tmp_path: Path) -> None:
+ """Format-1 database (without metadata column) fails closed on initialization."""
+ db_file = tmp_path / "format1_legacy.db"
+ conn = sqlite3.connect(str(db_file))
+ conn.execute(
+ """
+ CREATE TABLE cache_entries (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ namespace TEXT NOT NULL,
+ query TEXT NOT NULL,
+ vector BLOB,
+ response TEXT NOT NULL,
+ created_at REAL NOT NULL,
+ expires_at REAL,
+ hit_count INTEGER DEFAULT 0
+ )
+ """
+ )
+ conn.execute(
+ "INSERT INTO cache_entries (namespace, query, response, created_at) VALUES ('default', 'test', 'ans', 1.0)"
+ )
+ conn.commit()
+ conn.close()
+
+ config = CacheConfig(backend="sqlite", db_path=str(db_file))
+ with pytest.raises(ValueError, match="Legacy cache format"):
+ LongCache(config, embedder=HashEmbedder())
+
+
+def test_nonempty_sqlite_without_manifest_fails_closed(tmp_path: Path) -> None:
+ """Non-empty SQLite database without cache_manifest table fails closed."""
+ db_file = tmp_path / "missing_manifest.db"
+ conn = sqlite3.connect(str(db_file))
+ conn.execute(
+ """
+ CREATE TABLE cache_entries (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ namespace TEXT NOT NULL,
+ query TEXT NOT NULL,
+ vector BLOB,
+ response TEXT NOT NULL,
+ created_at REAL NOT NULL,
+ expires_at REAL,
+ hit_count INTEGER DEFAULT 0,
+ model TEXT DEFAULT '',
+ system_prompt TEXT DEFAULT '',
+ temperature REAL DEFAULT -1.0,
+ metadata TEXT DEFAULT '{}'
+ )
+ """
+ )
+ conn.execute(
+ "INSERT INTO cache_entries (namespace, query, response, created_at, metadata) VALUES ('default', 'test', 'ans', 1.0, '{}')"
+ )
+ conn.commit()
+ conn.close()
+
+ config = CacheConfig(backend="sqlite", db_path=str(db_file))
+ with pytest.raises(ValueError, match="Missing storage manifest in nonempty cache"):
+ LongCache(config, embedder=HashEmbedder())
+
+
+@pytest.mark.skipif(not REDIS_URL, reason="Real Redis service required")
+def test_format1_redis_keyspace_isolation_and_dirty_manifest_rejection() -> None:
+ """Redis Format-2 keyspace is isolated from legacy v0.1 unprefixed keys."""
+ client = redis.Redis.from_url(REDIS_URL or "redis://localhost:6379/0")
+ cache_id = "compat-" + uuid4().hex
+
+ # Plant a legacy v0.1 unprefixed entry
+ client.set("longcache:entry:99999", "legacy-unprefixed-data")
+
+ config = CacheConfig(
+ backend="redis",
+ redis_url=REDIS_URL or "redis://localhost:6379/0",
+ cache_id=cache_id,
+ generation="v06",
+ )
+ with LongCache(config, embedder=HashEmbedder()) as cache:
+ context = trusted_context()
+ assert cache.set("test-query", "ans", context=context).outcome == "STORED"
+ assert cache.lookup("test-query", context=context).outcome == "HIT"
+
+ # Verify Format-2 backend never enumerated or touched legacy key
+ assert client.get("longcache:entry:99999") == b"legacy-unprefixed-data"
+ cache.clear(operator=True)
+
+ client.delete("longcache:entry:99999")
+
+
+@pytest.mark.parametrize("codec", ["json-v1", "openai-dict-v1", "openai-sdk-v1"])
+def test_format2_codecs_roundtrip_compatibility(tmp_path: Path, codec: str) -> None:
+ """Test that json-v1, openai-dict-v1, and openai-sdk-v1 codecs roundtrip in Format-2."""
+ db_file = tmp_path / f"codec_{codec}.db"
+ config = CacheConfig(backend="sqlite", db_path=str(db_file))
+
+ context = RequestContext(
+ scope=RequestScope("codec-tenant", "codec-user"),
+ operation="test.codec",
+ provider="fixture",
+ provider_scope="fixture-v1",
+ model="demo",
+ prompt_version="v1",
+ codec=codec, # type: ignore[arg-type]
+ )
+
+ if codec == "openai-sdk-v1":
+ pytest.importorskip("openai")
+ from openai.types.chat import ChatCompletion
+
+ expected: Any = ChatCompletion.model_validate(
+ {
+ "id": "chatcmpl-sdk",
+ "created": 1234567890,
+ "model": "gpt-4o",
+ "object": "chat.completion",
+ "choices": [
+ {
+ "finish_reason": "stop",
+ "index": 0,
+ "message": {"role": "assistant", "content": "sdk-response"},
+ }
+ ],
+ }
+ )
+ elif codec == "openai-dict-v1":
+ expected = {
+ "id": "chatcmpl-123",
+ "choices": [
+ {
+ "finish_reason": "stop",
+ "index": 0,
+ "message": {"role": "assistant", "content": "hello"},
+ }
+ ],
+ }
+ else:
+ expected = {"answer": "pure-json", "confidence": 0.99}
+
+ with LongCache(config, embedder=HashEmbedder()) as cache:
+ decision = cache.set("greeting", expected, context=context)
+ assert decision.outcome == "STORED"
+
+ lookup = cache.lookup("greeting", context=context)
+ assert lookup.outcome == "HIT"
+ if codec == "openai-sdk-v1":
+ from openai.types.chat import ChatCompletion
+
+ assert isinstance(lookup.response, ChatCompletion)
+ assert lookup.response.choices[0].message.content == "sdk-response"
+ else:
+ assert lookup.response == expected
diff --git a/tests/test_qualification_stress_v6.py b/tests/test_qualification_stress_v6.py
new file mode 100644
index 0000000..46f9ce9
--- /dev/null
+++ b/tests/test_qualification_stress_v6.py
@@ -0,0 +1,266 @@
+"""Multi-process Redis qualification stress and memory bound suite for v0.6.0."""
+
+from __future__ import annotations
+
+from multiprocessing import get_context
+import os
+from pathlib import Path
+import time
+import tracemalloc
+from typing import Any
+from uuid import uuid4
+
+import pytest
+import redis
+
+from longcache import CacheConfig, LongCache, RequestScope
+from longcache.core.errors import CacheOperationError
+from longcache.core.types import ErrorPolicy
+from tests.helpers import HashEmbedder, trusted_context
+
+REDIS_URL = os.environ.get("LONGCACHE_TEST_REDIS_URL")
+pytestmark = pytest.mark.skipif(not REDIS_URL, reason="Real Redis service required")
+
+
+def _config(
+ cache_id: str,
+ *,
+ ttl: int = 60,
+ lease: float = 2.0,
+ renew: float = 0.5,
+ error_policy: ErrorPolicy = "raise",
+) -> CacheConfig:
+ return CacheConfig(
+ backend="redis",
+ deployment_mode="shared",
+ redis_url=REDIS_URL or "redis://localhost:6379/0",
+ cache_id=cache_id,
+ generation="v06",
+ ttl_seconds=ttl,
+ distributed_lease_seconds=lease,
+ distributed_renew_interval_seconds=renew,
+ cache_error_policy=error_policy,
+ max_entries=200,
+ shared_max_entries=500,
+ )
+
+
+def _churn_worker(
+ config: CacheConfig, worker_id: int, barrier: Any, error_queue: Any, iterations: int
+) -> None:
+ try:
+ cache = LongCache(config, embedder=HashEmbedder())
+ context = trusted_context(
+ scope=RequestScope(f"tenant-{worker_id % 2}", f"user-{worker_id}")
+ )
+ payload_32k = {"data": "x" * 32768, "worker": worker_id}
+
+ barrier.wait(timeout=10)
+ for i in range(iterations):
+ key = f"key-{worker_id}-{i % 20}"
+ tag = f"tag-{i % 5}"
+ # 1. Set
+ decision = cache.set(key, payload_32k, context=context, tags=[tag])
+ assert decision.outcome in ("STORED", "REJECTED")
+
+ # 2. Get
+ val = cache.get(key, context=context)
+ if val is not None:
+ assert isinstance(val, dict)
+ assert val.get("data") == payload_32k["data"]
+
+ # 3. Intermittent delete & tag invalidation
+ if i % 7 == 0:
+ cache.invalidate_by_tag(tag, context=context)
+
+ cache.close()
+ except Exception as exc:
+ error_queue.put(f"Worker {worker_id} failed: {type(exc).__name__}: {exc}")
+
+
+def test_multiprocess_sustained_write_delete_invalidate_churn_memory_bounds() -> None:
+ cache_id = "churn-" + uuid4().hex
+ config = _config(cache_id, ttl=120)
+ process_context = get_context("spawn")
+ barrier = process_context.Barrier(3)
+ error_queue = process_context.Queue()
+
+ workers = [
+ process_context.Process(
+ target=_churn_worker, args=(config, w_id, barrier, error_queue, 100)
+ )
+ for w_id in range(3)
+ ]
+ try:
+ for w in workers:
+ w.start()
+
+ for w in workers:
+ w.join(timeout=30)
+ assert not w.is_alive()
+ assert w.exitcode == 0
+
+ # Check for worker errors
+ errors = []
+ while not error_queue.empty():
+ errors.append(error_queue.get_nowait())
+ assert not errors, f"Worker errors: {errors}"
+
+ # Settle and verify memory & index bounds
+ with LongCache(config, embedder=HashEmbedder()) as cache:
+ # Rebuild / sync check
+ cache._synchronize_shared()
+ # Raw index size must remain bounded within 1.25x of shared capacity
+ assert cache.index.raw_total <= config.shared_max_entries * 1.5
+ cache.clear(operator=True)
+ finally:
+ for w in workers:
+ if w.is_alive():
+ w.terminate()
+ w.join(timeout=5)
+
+
+def _crashing_lease_leader(config: CacheConfig, ready_event: Any) -> None:
+ cache = LongCache(config, embedder=HashEmbedder())
+
+ def slow_origin() -> str:
+ ready_event.set()
+ time.sleep(10) # Will be killed during computation
+ return "leader-never"
+
+ try:
+ cache.get_or_compute("slow-lease-key", slow_origin, context=trusted_context())
+ finally:
+ cache.close()
+
+
+def test_multiprocess_singleflight_lease_churn_under_worker_kills() -> None:
+ cache_id = "kill-lease-" + uuid4().hex
+ # Short lease duration: 1.0s so follower can take over quickly
+ config = _config(cache_id, lease=1.0, renew=0.3)
+ process_context = get_context("spawn")
+ ready_event = process_context.Event()
+
+ leader = process_context.Process(target=_crashing_lease_leader, args=(config, ready_event))
+ try:
+ leader.start()
+ assert ready_event.wait(timeout=5)
+
+ # Leader is inside slow origin holding lease; kill it with SIGKILL
+ leader.kill()
+ leader.join(timeout=5)
+
+ # Now a new worker must take over once lease expires
+ time.sleep(1.2) # Wait for 1.0s lease to expire in Redis
+ with LongCache(config, embedder=HashEmbedder()) as cache:
+ result = cache.get_or_compute(
+ "slow-lease-key", lambda: "follower-recovered", context=trusted_context()
+ )
+ assert result == "follower-recovered"
+ assert cache.get("slow-lease-key", context=trusted_context()) == "follower-recovered"
+ cache.clear(operator=True)
+ finally:
+ if leader.is_alive():
+ leader.kill()
+ leader.join(timeout=5)
+
+
+def test_multiprocess_redis_restart_data_loss_quarantines_workers() -> None:
+ cache_id = "restart-" + uuid4().hex
+ config = _config(cache_id)
+ client = redis.Redis.from_url(REDIS_URL or "redis://localhost:6379/0")
+
+ with (
+ LongCache(config, embedder=HashEmbedder()) as cache1,
+ LongCache(config, embedder=HashEmbedder()) as cache2,
+ ):
+ context = trusted_context()
+ assert cache1.set("q1", "ans1", context=context).outcome == "STORED"
+ assert cache2.get("q1", context=context) == "ans1"
+
+ # Simulate Redis volatile restart / total data loss in this generation by deleting manifest
+ manifest_key = f"longcache:shared-v1:{config.cache_id}:{config.generation}:manifest"
+ client.delete(manifest_key)
+
+ # Both workers must immediately fail closed and quarantine rather than serving old local FAISS hits
+ with pytest.raises((CacheOperationError, ValueError)):
+ cache2.lookup("q1", context=context)
+
+ with pytest.raises((CacheOperationError, ValueError)):
+ cache1.set("q2", "ans2", context=context)
+
+
+def test_multiprocess_transient_outage_and_reconnect_cycles_raise_and_bypass(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ context = trusted_context()
+
+ # Cycle 5 times through simulated connection outage / restore
+ for policy in ("raise", "bypass"):
+ cache_id = f"outage-cycles-{policy}-{uuid4().hex}"
+ config = _config(cache_id, error_policy=policy)
+ with LongCache(config, embedder=HashEmbedder()) as cache:
+ # Baseline store
+ assert cache.set("q-init", "ans-init", context=context).outcome == "STORED"
+ assert cache.get("q-init", context=context) == "ans-init"
+
+ real_epoch = cache.backend.epoch
+
+ for cycle in range(5):
+ # 1. Inject failure
+ def failing_epoch() -> int:
+ raise redis.exceptions.ConnectionError(f"Simulated network drop cycle {cycle}")
+
+ monkeypatch.setattr(cache.backend, "epoch", failing_epoch)
+
+ compute_called = False
+
+ def compute() -> str:
+ nonlocal compute_called
+ compute_called = True
+ return f"origin-{cycle}"
+
+ if policy == "raise":
+ with pytest.raises(CacheOperationError):
+ cache.get_or_compute(f"q-cycle-{cycle}", compute, context=context)
+ assert not compute_called
+ else:
+ # Under bypass, should invoke origin directly
+ res = cache.get_or_compute(f"q-cycle-{cycle}", compute, context=context)
+ assert res == f"origin-{cycle}"
+ assert compute_called
+
+ # 2. Restore connectivity
+ monkeypatch.setattr(cache.backend, "epoch", real_epoch)
+
+ # Post-restore operation succeeds and local FAISS was not corrupted
+ assert cache.get("q-init", context=context) == "ans-init"
+
+ cache.clear(operator=True)
+
+
+def test_snapshot_rebuild_does_not_load_response_payloads_tracemalloc(tmp_path: Path) -> None:
+ # 1. SQLite backend test
+ db_path = str(tmp_path / "large_payload_rebuild.db")
+ config = CacheConfig(backend="sqlite", db_path=db_path)
+ context = trusted_context()
+
+ # Populate 300 entries with 64 KiB responses (~19.2 MiB of payload data)
+ payload_64k = {"blob": "Y" * 65536}
+ with LongCache(config, embedder=HashEmbedder()) as cache:
+ for i in range(300):
+ cache.set(f"query-{i}", payload_64k, context=context)
+
+ # Rebuild index and measure peak memory with tracemalloc
+ tracemalloc.start()
+ before_mem = tracemalloc.get_traced_memory()[0]
+
+ cache._rebuild_index_from_backend()
+
+ peak_mem = tracemalloc.get_traced_memory()[1] - before_mem
+ tracemalloc.stop()
+
+ # Peak memory during vector-only rebuild should be far below the 19.2 MiB payload size (< 5 MiB)
+ assert peak_mem < 5 * 1024 * 1024, (
+ f"Peak memory during rebuild too high: {peak_mem / (1024 * 1024):.2f} MiB"
+ )
diff --git a/tests/test_v06_slice_a.py b/tests/test_v06_slice_a.py
new file mode 100644
index 0000000..a397431
--- /dev/null
+++ b/tests/test_v06_slice_a.py
@@ -0,0 +1,120 @@
+from __future__ import annotations
+
+import os
+import tempfile
+
+import numpy as np
+import pytest
+
+from longcache import CacheConfig
+from longcache.backends.memory import InMemoryCacheBackend
+from longcache.backends.redis import RedisCacheBackend
+from longcache.backends.sqlite import SQLiteCacheBackend
+from longcache.core.entry import CacheEntry
+from longcache.core.index import SimilarityIndex
+from longcache.core.types import Float32Vector
+
+
+def _vector(dim: int = 384, seed: int = 42) -> Float32Vector:
+ rng = np.random.default_rng(seed)
+ v = rng.normal(size=dim).astype(np.float32)
+ return v / np.linalg.norm(v)
+
+
+def test_sqlite_iter_index_entries_paged_and_payload_free() -> None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ db_path = os.path.join(tmpdir, "cache.db")
+ backend = SQLiteCacheBackend(db_path)
+ vec1 = _vector(384, 1)
+ vec2 = _vector(384, 2)
+ vec3 = _vector(384, 3)
+
+ backend.store(
+ CacheEntry(query="q1", vector=vec1, response={"ans": "one" * 1000}, metadata={"m": 1})
+ )
+ backend.store(
+ CacheEntry(query="q2", vector=vec2, response={"ans": "two" * 1000}, metadata={"m": 2})
+ )
+ backend.store(
+ CacheEntry(query="q3", vector=vec3, response={"ans": "three" * 1000}, metadata={"m": 3})
+ )
+
+ # Iterate with batch_size=2 to test pagination across batches
+ items = list(backend.iter_index_entries(batch_size=2))
+ assert len(items) == 3
+ for item in items:
+ assert item.id is not None
+ assert item.vector is not None
+ assert item.query == "" # payload-free
+ assert item.response is None # payload-free
+ assert "m" in item.metadata
+
+ backend.close()
+
+
+def test_similarity_index_record_remote_invalidation_threshold() -> None:
+ backend = InMemoryCacheBackend()
+ config = CacheConfig(similarity_threshold=0.9)
+ index = SimilarityIndex(backend, config)
+
+ # Empty index
+ assert index.raw_total == 0
+ assert index.orphan_count == 0
+ assert not index.record_remote_invalidation(1)
+
+ # Add 100 vectors
+ for i in range(1, 101):
+ index.add(_vector(384, i), entry_id=i)
+
+ assert index.raw_total == 100
+ assert index.size == 100
+ assert index.orphan_count == 1 # From previous call
+
+ # 10 invalidations: 11 / 100 = 11% < 25% -> False
+ assert not index.record_remote_invalidation(10)
+ assert index.orphan_count == 11
+
+ # 15 more invalidations: 26 / 100 = 26% >= 25% (and >= 64 threshold test)
+ # With < 64 orphans, returns False to avoid thrashing on small indexes
+ assert not index.record_remote_invalidation(15)
+ assert index.orphan_count == 26
+
+ # Bring orphans above 64
+ assert index.record_remote_invalidation(40) # 66 orphans out of 100 (66% >= 25% and >= 64)
+ assert index.orphan_count == 66
+
+
+@pytest.mark.skipif(not os.environ.get("LONGCACHE_TEST_REDIS_URL"), reason="Redis not configured")
+def test_redis_backends_iter_index_entries() -> None:
+ redis_url = os.environ["LONGCACHE_TEST_REDIS_URL"]
+ backend = RedisCacheBackend(redis_url=redis_url, cache_id="test-paged-redis")
+ backend.clear()
+
+ vec1 = _vector(384, 10)
+ vec2 = _vector(384, 20)
+ backend.store(
+ CacheEntry(
+ namespace="test_paged_redis",
+ query="q1",
+ vector=vec1,
+ response={"ans": "huge" * 500},
+ metadata={"id": 10},
+ )
+ )
+ backend.store(
+ CacheEntry(
+ namespace="test_paged_redis",
+ query="q2",
+ vector=vec2,
+ response={"ans": "huge" * 500},
+ metadata={"id": 20},
+ )
+ )
+
+ entries = list(backend.iter_index_entries(batch_size=1))
+ assert len(entries) >= 2
+ for e in entries:
+ assert e.query == ""
+ assert e.response is None
+ assert e.vector is not None
+ backend.clear()
diff --git a/uv.lock b/uv.lock
index df8bfa4..1249d47 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1641,7 +1641,7 @@ wheels = [
[[package]]
name = "longcache"
-version = "0.5.0"
+version = "0.6.0"
source = { editable = "." }
dependencies = [
{ name = "click" },