From cb6ab70cdbdc636fa306f9d90aac77b7892e8d55 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Tue, 22 Sep 2026 00:19:06 -0700 Subject: [PATCH 1/4] feat(python): add asyncio port with shared conformance replay --- .github/actions/setup-validation/action.yml | 20 +- .github/workflows/docs.yaml | 1 + .github/workflows/formal-full.yaml | 44 +- .github/workflows/formal.yaml | 1 + .github/workflows/python.yml | 46 + .gitignore | 8 + AGENTS.md | 16 +- Makefile | 4 +- README.md | 10 +- docs/.vitepress/config.mts | 1 + docs/api.md | 10 + docs/authoring.md | 32 +- docs/coalescing.md | 31 + docs/concepts.md | 17 + docs/configuration.md | 21 +- docs/getting-started.md | 38 + docs/index.md | 5 +- docs/invalidation.md | 18 + docs/keys.md | 27 + docs/languages/python.md | 118 ++ docs/maintainers.md | 16 +- docs/observability.md | 42 + docs/ports.json | 3 +- docs/redis.md | 96 ++ docs/shadow-validation.md | 34 + docs/stale-on-error.md | 23 + docs/upgrading.md | 25 + formal/check-python-replay.mjs | 65 + formal/conformance-adapters.mjs | 11 +- formal/conformance-bindings.mjs | 4 +- formal/conformance.mjs | 8 +- formal/explore.mjs | 41 +- formal/go-parity.json | 21 +- formal/profiles.json | 24 + formal/run-python-integration.mjs | 132 ++ formal/run-python-replay.mjs | 15 + formal/source-audit.json | 299 ++--- formal/validation.mjs | 42 +- python/API-DESIGN.md | 24 + python/LICENSE | 21 + python/README.md | 218 ++++ python/dialcache/__init__.py | 38 + python/dialcache/cache.py | 1101 +++++++++++++++++ python/dialcache/clock.py | 53 + python/dialcache/config.py | 296 +++++ python/dialcache/context.py | 133 ++ python/dialcache/errors.py | 46 + python/dialcache/key.py | 149 +++ python/dialcache/local.py | 77 ++ python/dialcache/metrics.py | 38 + python/dialcache/protocol.py | 251 ++++ python/dialcache/py.typed | 0 python/dialcache/redis.py | 185 +++ python/dialcache/serializer.py | 55 + python/pyproject.toml | 48 + python/tests/formal/__init__.py | 1 + python/tests/formal/coordinator.py | 123 ++ python/tests/formal/driver.py | 534 ++++++++ python/tests/formal/executor.py | 140 +++ python/tests/formal/scenarios.py | 42 + python/tests/formal/schema.py | 144 +++ python/tests/formal/simple_drivers.py | 170 +++ python/tests/formal/witness.py | 109 ++ python/tests/run_conformance.py | 309 +++++ python/tests/test_cache.py | 456 +++++++ python/tests/test_config.py | 142 +++ python/tests/test_conformance.py | 195 +++ python/tests/test_context.py | 81 ++ python/tests/test_docs_examples.py | 122 ++ python/tests/test_engine_review.py | 87 ++ python/tests/test_local.py | 64 + python/tests/test_protocol_native.py | 199 +++ python/tests/test_protocol_vectors.py | 226 ++++ python/tests/test_redis_adapter.py | 118 ++ python/tests/test_redis_integration.py | 193 +++ .../tests/test_shadow_deadline_retention.py | 51 + scripts/check-docs.mjs | 7 +- scripts/check-docs.test.mjs | 14 +- scripts/generate-docs.mjs | 18 +- test/formal-exploration.test.ts | 53 +- test/formal-rust-replay.test.ts | 17 +- test/formal-validation.test.ts | 52 +- 82 files changed, 7526 insertions(+), 243 deletions(-) create mode 100644 .github/workflows/python.yml create mode 100644 docs/languages/python.md create mode 100644 formal/check-python-replay.mjs create mode 100644 formal/run-python-integration.mjs create mode 100644 formal/run-python-replay.mjs create mode 100644 python/API-DESIGN.md create mode 100644 python/LICENSE create mode 100644 python/README.md create mode 100644 python/dialcache/__init__.py create mode 100644 python/dialcache/cache.py create mode 100644 python/dialcache/clock.py create mode 100644 python/dialcache/config.py create mode 100644 python/dialcache/context.py create mode 100644 python/dialcache/errors.py create mode 100644 python/dialcache/key.py create mode 100644 python/dialcache/local.py create mode 100644 python/dialcache/metrics.py create mode 100644 python/dialcache/protocol.py create mode 100644 python/dialcache/py.typed create mode 100644 python/dialcache/redis.py create mode 100644 python/dialcache/serializer.py create mode 100644 python/pyproject.toml create mode 100644 python/tests/formal/__init__.py create mode 100644 python/tests/formal/coordinator.py create mode 100644 python/tests/formal/driver.py create mode 100644 python/tests/formal/executor.py create mode 100644 python/tests/formal/scenarios.py create mode 100644 python/tests/formal/schema.py create mode 100644 python/tests/formal/simple_drivers.py create mode 100644 python/tests/formal/witness.py create mode 100644 python/tests/run_conformance.py create mode 100644 python/tests/test_cache.py create mode 100644 python/tests/test_config.py create mode 100644 python/tests/test_conformance.py create mode 100644 python/tests/test_context.py create mode 100644 python/tests/test_docs_examples.py create mode 100644 python/tests/test_engine_review.py create mode 100644 python/tests/test_local.py create mode 100644 python/tests/test_protocol_native.py create mode 100644 python/tests/test_protocol_vectors.py create mode 100644 python/tests/test_redis_adapter.py create mode 100644 python/tests/test_redis_integration.py create mode 100644 python/tests/test_shadow_deadline_retention.py diff --git a/.github/actions/setup-validation/action.yml b/.github/actions/setup-validation/action.yml index 4cfda85e..588d8e51 100644 --- a/.github/actions/setup-validation/action.yml +++ b/.github/actions/setup-validation/action.yml @@ -1,5 +1,5 @@ name: Set up validation -description: Install the shared pinned Node/pnpm environment and optional Go/Quint tools. +description: Install the shared pinned Node/pnpm environment and optional native language and Quint tools. inputs: go: description: Install the pinned Go toolchain. @@ -13,6 +13,12 @@ inputs: rust: description: Install the pinned Rust toolchain (rust/rust-toolchain.toml) with clippy and rustfmt. default: "false" + python: + description: Install Python and the editable package with test and Redis dependencies. + default: "false" + python-version: + description: Python version for the native validation lane. + default: "3.14" runs: using: composite steps: @@ -26,6 +32,18 @@ runs: - name: Install project dependencies shell: bash run: corepack pnpm install --frozen-lockfile + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + if: inputs.python == 'true' + with: + python-version: ${{ inputs.python-version }} + cache: pip + cache-dependency-path: python/pyproject.toml + - name: Install Python package and validation dependencies + if: inputs.python == 'true' + shell: bash + run: | + python -m venv python/.venv + python/.venv/bin/python -m pip install -e './python[test,redis]' - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 if: inputs.go == 'true' with: diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index dcd1145b..cfea9493 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -23,6 +23,7 @@ jobs: with: go: "true" rust: "true" + python: "true" - run: make docs - uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' diff --git a/.github/workflows/formal-full.yaml b/.github/workflows/formal-full.yaml index 6aa4b9c5..4a942dd5 100644 --- a/.github/workflows/formal-full.yaml +++ b/.github/workflows/formal-full.yaml @@ -33,6 +33,7 @@ jobs: quint: "true" go: "true" rust: "true" + python: "true" - name: Explore a fresh recorded seed and replay every port run: make explore - name: Preserve exploratory sources, seed and counterexamples @@ -297,6 +298,35 @@ jobs: # test/formal-validation.test.ts pins the mutants a shard may hold within # the timeout, so catalog growth fails the pull request until the matrix # grows. + python-parity: + needs: generate + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: ./.github/actions/setup-validation + with: + python: "true" + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: formal-traces + path: .formal-traces + - name: Require Python replay of every generated obligation + run: make formal-python + - name: Preserve Python completion evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: python-parity-evidence + path: | + .formal-traces/python-replay.jsonl + .formal-traces/python-replay-summary.json + .formal-traces/python-context.json + .formal-traces/python-completion.json + include-hidden-files: true + if-no-files-found: warn + retention-days: 14 + go-mutations: needs: generate runs-on: ubuntu-latest @@ -421,7 +451,7 @@ jobs: # The mutation lanes count through their merge jobs: a merge succeeds only # when every shard's report is present, consistent and free of lost detections. formal-full: - needs: [check-models, generate, typescript-parity, symbolic, typescript-mutations-merge, go-parity, go-mutations-merge, rust-parity, rust-mutations-merge, exploration] + needs: [check-models, generate, typescript-parity, symbolic, typescript-mutations-merge, go-parity, go-mutations-merge, rust-parity, rust-mutations-merge, python-parity, exploration] if: always() runs-on: ubuntu-latest timeout-minutes: 5 @@ -458,6 +488,13 @@ jobs: with: name: model-check-evidence path: formal-summary/model-check + - name: Collect Python completion evidence + if: always() + continue-on-error: true + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: python-parity-evidence + path: formal-summary/python - name: Collect symbolic verification report if: always() continue-on-error: true @@ -486,6 +523,9 @@ jobs: formal-summary/rust/rust-completion.json formal-summary/rust/rust-context.json formal-summary/rust/rust-replay-summary.json + formal-summary/python/python-completion.json + formal-summary/python/python-context.json + formal-summary/python/python-replay-summary.json formal-summary/model-check/model-properties/report.json formal-summary/symbolic/report.json formal-summary/exploration/*/report.json @@ -502,6 +542,7 @@ jobs: GO_MUTATIONS_MERGE_RESULT: ${{ needs.go-mutations-merge.result }} RUST_MUTATIONS_MERGE_RESULT: ${{ needs.rust-mutations-merge.result }} RUST_RESULT: ${{ needs.rust-parity.result }} + PYTHON_RESULT: ${{ needs.python-parity.result }} SYMBOLIC_RESULT: ${{ needs.symbolic.result }} EXPLORATION_RESULT: ${{ needs.exploration.result }} EXPLORATION_REQUIRED: ${{ github.event_name == 'schedule' || inputs.exploration == true }} @@ -514,6 +555,7 @@ jobs: test "$GO_MUTATIONS_MERGE_RESULT" = success test "$RUST_MUTATIONS_MERGE_RESULT" = success test "$RUST_RESULT" = success + test "$PYTHON_RESULT" = success test "$SYMBOLIC_RESULT" = success if [ "$EXPLORATION_REQUIRED" = true ]; then test "$EXPLORATION_RESULT" = success diff --git a/.github/workflows/formal.yaml b/.github/workflows/formal.yaml index cbd0633b..c9beeff2 100644 --- a/.github/workflows/formal.yaml +++ b/.github/workflows/formal.yaml @@ -25,6 +25,7 @@ jobs: with: go: "true" rust: "true" + python: "true" - name: Audit contracts and replay committed Quint smoke in every port run: make audit smoke - name: Detect changes requiring fresh Quint artifact generation diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 00000000..5210714b --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,46 @@ +name: Python + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: python-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + native: + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + python: ["3.11", "3.14"] + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: ./.github/actions/setup-validation + with: + python: "true" + python-version: ${{ matrix.python }} + - name: Check native behavior, portable wire vectors and shared smoke histories + run: make check-python + - name: Check Redis, Valkey, Cluster and TypeScript interoperability + run: make integration-python + - name: Build a distributable wheel + run: python/.venv/bin/python -m pip wheel --no-deps ./python --wheel-dir .formal-traces/python-dist + - name: Retain the built wheel and integration diagnostics + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: python-${{ matrix.python }}-validation + path: | + .formal-traces/python-dist/ + .formal-traces/python-integration* + include-hidden-files: true + if-no-files-found: warn + retention-days: 14 diff --git a/.gitignore b/.gitignore index d594f309..fc456757 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,11 @@ docs/generated/ .vscode/ *.log rust/target/ +python/.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +*.egg-info/ +python/build/ +python/dist/ diff --git a/AGENTS.md b/AGENTS.md index c7bec034..96181048 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project overview -DialCache has TypeScript, Go and Rust implementations with explicit request-scoped enablement, local and Redis layers, runtime rollout controls, request coalescing, targeted invalidation, and adapter-based observability. +DialCache has TypeScript, Go, Rust and Python implementations with explicit request-scoped enablement, local and Redis layers, runtime rollout controls, request coalescing, targeted invalidation, and adapter-based observability. ## Structure @@ -28,6 +28,7 @@ src/ test/ # Unit and Redis integration tests go/ # Go module, public cache and adapters, shared-corpus replay rust/ # Rust crate, public cache and adapters, shared-corpus replay (tests/conformance.rs) +python/ # Async Python package, borrowed Redis adapter, native tests and shared-corpus replay formal/ # Quint behavioral source of truth, contracts and portable vectors ``` @@ -61,17 +62,17 @@ formal/ # Quint behavioral source of truth, contracts and portab prose once, import executable native examples by named region, and keep real language differences in `LanguageContent` notes or the short native guides. `make docs` generates all native references and checks snippet sources and - internal links; it requires the pinned Go and Rust toolchains as well as Node. + internal links; it requires the pinned Go and Rust toolchains, Python and Node. Run changed native examples with assertions (including Redis when relevant). - Start formal work at `formal/README.md`. `formal/WALKTHROUGH.md` follows one - contract through Quint, generated inputs and all three language replays; + contract through Quint, generated inputs and the native language replays; `formal/AUTHORING.md` explains how to extend that chain. Read the relevant model and profile bindings before opening large generated JSON artifacts. - For formal specification changes, follow `formal/AUTHORING.md`: keep models readable as behavior definitions, share helpers with identical meaning, retain independent property checks, and register executable evidence in the catalogs. - Define portable behavior in Quint first. Require consequential generated - witnesses and replay the same histories in TypeScript, Go and Rust; keep + witnesses and replay the same histories in TypeScript, Go, Rust and Python; keep native API, wire and integration tests for their explicit boundaries. - Use the shared behavioral testbed for portable features and bug fixes, with TypeScript as the executable reference. Follow the workflow in @@ -89,6 +90,13 @@ make integration `make check-rust` runs the Rust crate's fmt, clippy, unit, vector, scenario and smoke checks; `make formal-rust` completes its replay of the generated corpus. +For Python, create `python/.venv` with Python 3.11 or later and install +`python/.venv/bin/python -m pip install -e './python[test,redis]'`. +`make check-python` runs native, wire and committed smoke tests; +`make integration-python` provisions isolated Redis/Valkey/Cluster servers; +`make formal-python` runs the prepared generated corpus and completion checks. +Set `PYTHON` to use another prepared interpreter. Python's package has no Node +runtime dependency; the shared replay and validation tools require Node 24. Use `make formal` for complete Quint model checks, corpus generation and every port's full replay, then `make mutations` for assertion-strength checks. `make ci` runs all validation in the required order. `make help` lists targets diff --git a/Makefile b/Makefile index 76097672..5fc3e473 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ NODE ?= node -.PHONY: help check check-ts check-go check-rust docs audit smoke formal formal-check formal-generate formal-ts formal-go formal-rust fixtures-check kernel-fixtures differential mutations mutations-ts mutations-go mutations-merge-ts mutations-merge-go mutations-rust mutations-merge-rust integration integration-ts integration-go integration-rust package-floor ci explore model-check +.PHONY: help check check-ts check-go check-rust check-python docs audit smoke formal formal-check formal-generate formal-ts formal-go formal-rust formal-python fixtures-check kernel-fixtures differential mutations mutations-ts mutations-go mutations-merge-ts mutations-merge-go mutations-rust mutations-merge-rust integration integration-ts integration-go integration-rust integration-python package-floor ci explore model-check -help check check-ts check-go check-rust docs audit smoke formal formal-check formal-generate formal-ts formal-go formal-rust fixtures-check kernel-fixtures differential mutations mutations-ts mutations-go mutations-merge-ts mutations-merge-go mutations-rust mutations-merge-rust integration integration-ts integration-go integration-rust package-floor ci explore model-check: +help check check-ts check-go check-rust check-python docs audit smoke formal formal-check formal-generate formal-ts formal-go formal-rust formal-python fixtures-check kernel-fixtures differential mutations mutations-ts mutations-go mutations-merge-ts mutations-merge-go mutations-rust mutations-merge-rust integration integration-ts integration-go integration-rust integration-python package-floor ci explore model-check: $(NODE) formal/validation.mjs $@ diff --git a/README.md b/README.md index 9f5c3990..b38ccbcc 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # DialCache Read the [shared behavior guides](https://lan17.github.io/DialCache/) with a -language selector for TypeScript, Go and Rust. Native examples in those guides +language selector for TypeScript, Go, Rust and Python. Native examples in those guides are imported from source files that CI executes. -**TypeScript is the reference implementation. Go and Rust are experimental.** +**TypeScript is the reference implementation. Go, Rust, and Python are experimental.** +The [Python asyncio guide](python/README.md) covers its native decorator API, +request scopes, and checkout installation. [![npm version](https://img.shields.io/npm/v/dialcache.svg)](https://www.npmjs.com/package/dialcache) [![Codecov](https://codecov.io/gh/lan17/DialCache/branch/main/graph/badge.svg)](https://codecov.io/gh/lan17/DialCache) @@ -138,9 +140,9 @@ for sampling and comparison behavior. | Recovery from selected source failures | [Stale-on-error](https://lan17.github.io/DialCache/stale-on-error.html) | | Shared execution and deadlines | [Coalescing and liveness](https://lan17.github.io/DialCache/coalescing.html) | | Methods, options, and exports | [API reference](https://lan17.github.io/DialCache/api.html) | -| Go and Rust implementations and shared behavior contracts | [Go guide](go/README.md) · [Rust guide](rust/README.md) · [Quint specification](formal/README.md) · [Worked walkthrough](formal/WALKTHROUGH.md) | +| Other implementations and shared behavior contracts | [Go guide](go/README.md) · [Rust guide](rust/README.md) · [Python guide](python/README.md) · [Quint specification](formal/README.md) · [Worked walkthrough](formal/WALKTHROUGH.md) | -The Go port, the Rust port and the TypeScript library replay the same +The Go, Rust, and Python ports and the TypeScript library replay the same Quint-generated histories. Whether those histories reach every required boundary is decided by one language-neutral evaluator, `node formal/witnesses.mjs evaluate`, that any port runs over the same corpus; no port depends on another port's test suite diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 6a33c578..f967bacb 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -28,6 +28,7 @@ export default defineConfig({ { text: "TypeScript setup", link: "/languages/typescript" }, { text: "Go setup", link: "/languages/go" }, { text: "Rust setup", link: "/languages/rust" }, + { text: "Python setup", link: "/languages/python" }, ], }, { diff --git a/docs/api.md b/docs/api.md index ae97016b..c28fd220 100644 --- a/docs/api.md +++ b/docs/api.md @@ -15,6 +15,7 @@ behavior; the native references give each language's signatures and types. | TypeScript | TypeDoc | [TypeScript guide](languages/typescript.md) | | Go | Go package documentation | [Go guide](languages/go.md) | | Rust | rustdoc | [Rust guide](languages/rust.md) | +| Python | pydoc | [Python guide](languages/python.md) | @@ -33,6 +34,15 @@ values. [Rust setup](languages/rust.md) explains ownership and feature flags. + + +Use the generated Python reference for `DialCache`, `Policy`, `Key`, serializers, +semantic Redis requests and protocol helpers. Methods use Python snake_case; +cache calls are awaitable. [Python setup](languages/python.md) explains context +lifetime, argument binding and application-owned clients. + + + The following TypeScript usage notes retain the established guide anchors. diff --git a/docs/authoring.md b/docs/authoring.md index 5a8c58c9..183fa4a0 100644 --- a/docs/authoring.md +++ b/docs/authoring.md @@ -1,8 +1,8 @@ # Writing shared documentation Maintain one feature guide for the portable behavior. Keep native setup and -idioms in [TypeScript](languages/typescript.md), [Go](languages/go.md), and -[Rust](languages/rust.md). The language selector changes examples, binding notes, +idioms in [TypeScript](languages/typescript.md), [Go](languages/go.md), +[Rust](languages/rust.md), and [Python](languages/python.md). The language selector changes examples, binding notes, and API links while preserving the shared explanation and feature URL. ## Change a feature guide @@ -42,11 +42,18 @@ and `tracked-invalidation` in every port. A shared page includes all variants: <<< @/../rust/tests/docs_examples.rs#request-scope{rust} + + + + +<<< @/../python/tests/test_docs_examples.py#request-scope{python} + ```` Mark the source with `// #region request-scope` and -`// #endregion request-scope`. Keep setup and assertions in the full executable +`// #endregion request-scope`; Python uses `# region request-scope` and +`# endregion request-scope`. Keep setup and assertions in the full executable file, even when the displayed region omits some of that setup. State any omitted prerequisite beside the snippet. The source check rejects missing files, missing/duplicate regions, mismatched language sections, and a shared scenario @@ -59,8 +66,9 @@ fragments as excerpts; compilation of a different example does not validate them ## Run the checks -Use the repository's pinned Node, pnpm, Go, and Rust versions. The full site -build needs all three native toolchains because references come from this +Use the repository's pinned Node, pnpm, Go, and Rust versions and Python 3.11 or +later with the package's test dependencies. The full site build needs all four +native toolchains because references come from this checkout. It does not need Redis or Quint exploration. ```sh @@ -71,7 +79,8 @@ corepack pnpm docs:dev # Generate references/catalogue, then serve with hot re Native examples execute in the existing language validation jobs. TypeScript is compiled and run against the packed npm package, Go examples are ordinary -tests, and Rust examples are integration tests. Redis tests use a dedicated +tests, Rust examples are integration tests, and Python examples run with pytest. +Redis tests use a dedicated service in CI; locally point `DOCS_REDIS_URL` at a disposable Redis instance: ```sh @@ -81,11 +90,16 @@ corepack pnpm test:package go -C go test -race -count=1 -run '^TestDocs' ./... cd rust cargo test --locked --all-features --test docs_examples -- --include-ignored +# From the repository root, with Python dependencies installed: +python/.venv/bin/python -m pytest python/tests/test_docs_examples.py ``` Without the URL, TypeScript and Go explicitly skip the Redis scenario. Rust's Redis scenario is ignored by default and requires the explicit command above; -running that ignored test without the URL fails. The request-scope and policy +running that ignored test without the URL fails. Python's example is marked +`integration` and runs in `make integration-python`; without either +`DOCS_REDIS_URL` or `TEST_REDIS_URL`, a direct pytest run skips it. +The request-scope and policy examples run without external services. The Redis scenarios use unique keys and clean up their own data. @@ -98,7 +112,9 @@ the full validation requirements in the formal authoring guide. ## Generated references and evidence TypeDoc reads TypeScript's exported entry points, `go doc -all` reads Go's public -package, and rustdoc reads the Rust crate with all features. Their output and the +package, rustdoc reads the Rust crate with all features, and standard-library +`pydoc` reads Python's public package and integration modules. Python uses the +interpreter in `PYTHON`, or `python/.venv/bin/python` by default. Their output and the [behavior catalogue](generated/behavior.md) are generated on every site build, ignored by Git, and published with the site. Edit public doc comments or the underlying reviewed inventory to change them. The existing TypeScript API usage diff --git a/docs/coalescing.md b/docs/coalescing.md index c2757e4d..abf0f1b5 100644 --- a/docs/coalescing.md +++ b/docs/coalescing.md @@ -116,6 +116,13 @@ policy to a sparse `RuntimePolicy` for the provider. Omitted leaves inherit. + + +Use `Policy(coalesce=False)` as the default or runtime overlay. Omitted +`coalesce` leaves inherit; false disables sharing while preserving settled hits. + + + Concurrent same-key callers then each perform: - their own active-layer reads with a full independent remote-read budget; @@ -237,6 +244,13 @@ Set the operation's `SourceBudget::Millis(n)`; `Default` uses 60 seconds and + + +Set `fallback_timeout_ms` on the operation. Omission uses 60,000 ms; `None` +disables the deadline. A DialCache source deadline raises `FallbackTimeoutError`. + + + ### When the timer runs The timer starts only when the fallback begins: @@ -303,6 +317,15 @@ use bounded native source I/O budgets. + + +Keep the event loop and application-owned dependencies alive while work settles. +Canceling one awaiting task does not cancel a shared execution or another +caller. Event-loop shutdown can still terminate tasks. Avoid blocking loaders +and configure finite source I/O budgets. + + + Caller completion does not drain detached shadow jobs. See [Redis lifecycle ownership](redis.md#lifecycle-ownership) for dependency shutdown. @@ -379,6 +402,14 @@ snapshot fields and optional oldest-age representation. + + +Call `cache.get_coalescing_state()`. The `process` dictionary reports +`active_leaders`, `active_followers`, and `oldest_leader_age_ms`; idle age is +`None`. See the [Python API](api.md). + + + A leader is one exact cache key currently tracked by the instance-scoped coalescer. A follower is each later invocation that joined that pending leader; the initiating invocation is not counted as a follower. diff --git a/docs/concepts.md b/docs/concepts.md index 23fc1e8e..36b34284 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -79,6 +79,15 @@ derive nested scopes. See the [Rust guide](languages/rust.md). + + +Enabled state travels through `contextvars`. Enter `cache.enable()` with `with` +or `async with`; nested `enable()` and `disable()` preserve the live outer +memo. Calls from retained task contexts pass through after that outer scope +closes. See the [Python guide](languages/python.md). + + + After the outer scope closes, new invocations through retained context are pass-through. Already admitted cache operations can finish and publish to shared layers, but cannot repopulate closed request-local state. An invocation still @@ -214,6 +223,14 @@ callers, even through a shared `Arc`. + + +Python memory entries and coalesced results share the same object references. +Treat values as immutable or copy before mutation. `None` is a present value; +custom serializers handle result types outside the default JSON domain. + + + ## Where to go next - [Keys and identity](keys.md) defines results and invalidation groups. diff --git a/docs/configuration.md b/docs/configuration.md index 24c0eb7b..49ef34fe 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,6 +68,14 @@ inherited defaults fails the example. + + +<<< @/../python/tests/test_docs_examples.py#runtime-policy{python} + +[Complete executable example](https://github.com/lan17/DialCache/blob/main/python/tests/test_docs_examples.py) + + + `defaultConfig` accepts `DialCacheKeyConfig`; `cacheConfigProvider` returns a @@ -95,8 +103,19 @@ milliseconds. See the [Rust guide](languages/rust.md#policy-and-errors). + + +Operation defaults use `Policy`; the `policy_provider` returns a sparse `Policy`, +mapping or `None`. A whole reply of `None` inherits; an explicit `None` leaf is +invalid. TTLs use integer seconds and deadlines use integer milliseconds. +Python names include `ttl_sec`, `request_local`, and `remote_read_timeout_ms`; +mappings also accept the shared camelCase names. See the +[Python guide](languages/python.md#identity-and-policy). + + + The policy names in the tables below use the shared JSON configuration shape, -also accepted by the Go and Rust policy parsers. Native names and units differ; +also accepted by the Go, Rust and Python policy parsers. Native names and units differ; use the selected language's API reference when constructing typed policy. A configured TTL implies a 100% serving ramp unless overridden. Without a TTL, a local or remote layer is off. Request-local caching and shadow work are off diff --git a/docs/getting-started.md b/docs/getting-started.md index 6c5afbfc..b7146836 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -53,6 +53,19 @@ runtime, feature and value-ownership details. + + +The Python package is currently **unpublished**. Install from a repository checkout: + +```sh +python3 -m pip install './python[redis]' +``` + +Python 3.11 or later is required. Omit the Redis extra for local-only use. +See the [Python guide](languages/python.md) for asyncio and client ownership. + + + ## Wrap a reader Start with request-local caching so no Redis server or expiration timer is @@ -89,6 +102,14 @@ file supplies imports and test setup; the source link opens the complete file. + + +<<< @/../python/tests/test_docs_examples.py#request-scope{python} + +[Complete executable example](https://github.com/lan17/DialCache/blob/main/python/tests/test_docs_examples.py) + + + The example enables only request-local storage. It has no TTL or capacity limit and disappears when the outer scope closes. Keep requests and their key counts bounded. Add a process-local TTL when reuse across requests is appropriate; @@ -132,6 +153,15 @@ pass-through scope. Reader values are shared as `Arc`. + + +Use `async with cache.enable():` or `with cache.enable():` around request reads. +`cache.disable()` temporarily bypasses caching. The outer scope owns the memo; +tasks inheriting its context pass through after it closes. Values are shared +Python references and should be treated as immutable. + + + Disabling caching does not invalidate stored data. After a source mutation, freshness still depends on TTLs and [invalidation policy](invalidation.md). Treat reused in-memory values as immutable. @@ -163,6 +193,14 @@ The result is still `Arc`. See the [Rust API](api.md). + + +Use `await cache.get_or_load(loader, key=..., key_type=..., use_case=..., +default_config=...)` for an inline read, or `await cache.aget(key, loader, ...)` +for a structured `Key`. See the [Python API](api.md). + + + ## Introduce runtime policy Keep stable defaults next to the reader and use a runtime provider for sparse diff --git a/docs/index.md b/docs/index.md index a89bb39f..649e8e47 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,7 +3,7 @@ DialCache wraps application reads with explicit enablement, layered storage, and runtime policy. These guides explain its model and feature behavior; the API reference collects each port's native options and contracts. Choose TypeScript, -Go or Rust in the site selector: shared behavior stays on the same page, while +Go, Rust or Python in the site selector: shared behavior stays on the same page, while examples and integration notes follow your selection. @@ -46,6 +46,7 @@ examples and integration notes follow your selection. | [TypeScript](languages/typescript.md) | Node.js async context, serializers, optional client adapters | | [Go](languages/go.md) | Context propagation, generic operations, duration and overlay types | | [Rust](languages/rust.md) | Scope guards, `Arc` values, runtime and feature flags | +| [Python](languages/python.md) | Async decorators, `contextvars` scopes, native values and application-owned Redis clients | [Behavior catalogue](generated/behavior.md) connects shared contracts to the existing formal cases. [Documentation authoring](authoring.md) explains how to @@ -65,7 +66,7 @@ change shared prose, tested native examples and generated references together. The published site and generated native API references follow `main`, which may be ahead of a released npm package or Go module. For an installed version, use its [release notes](https://github.com/lan17/DialCache/releases) and matching -[release tag](https://github.com/lan17/DialCache/tags). Rust is currently +[release tag](https://github.com/lan17/DialCache/tags). Rust and Python are currently unpublished and used from a checkout. [Project overview](https://github.com/lan17/DialCache#readme) diff --git a/docs/invalidation.md b/docs/invalidation.md index 9fdb13c4..2ea1a09c 100644 --- a/docs/invalidation.md +++ b/docs/invalidation.md @@ -82,6 +82,14 @@ its own watermark observation. + + +<<< @/../python/tests/test_docs_examples.py#tracked-invalidation{python} + +[Complete executable example](https://github.com/lan17/DialCache/blob/main/python/tests/test_docs_examples.py) + + + The complete files provide the client, source and cleanup. The example uses a zero buffer because it has no overlapping stale writer. That is not a production recommendation: choose timing bounds using the next section. @@ -112,6 +120,16 @@ namespace. Handle the returned error as failed maintenance. + + +Set `track_for_invalidation=True` and call +`await cache.invalidate_remote(key_type, id, future_buffer_ms)` after the source +commit. `ainvalidate()` is an alias. The buffer uses integer milliseconds. +Missing remote configuration raises `MissingRemoteError`; mutation failures +propagate to the maintenance caller. + + + Invalidation works outside an enabled scope. Call it **after the source mutation commits** and surface failures to the application's maintenance path. [Redis setup](redis.md) covers client connections and ownership. diff --git a/docs/keys.md b/docs/keys.md index a4e5ae3f..4505b555 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -90,6 +90,16 @@ See the [Rust guide](languages/rust.md). + + +The `cached` decorator takes `key_type`, `use_case`, and either a `cache_key` +callback or `id_arg`. `id_arg=(name, adapter)` extracts an ID from a native object; +other bound arguments use `arg_adapters` and `ignore_args`. Inline calls accept +a structured `Key` or an ID plus normalized argument mapping. See the +[Python guide](languages/python.md#identity-and-policy). + + + Include every input that can change the result. Omitting an authorization scope, tenant, or locale can make callers reuse the wrong value. Disabling coalescing does not fix an incomplete key. Add a Redis client, remote policy and tracked identity to use @@ -157,6 +167,15 @@ rules. `f32` is promoted to `f64` before formatting. + + +Python primitive integers retain their exact digits, floats use the shared +JavaScript-compatible spelling, and strings use the shared escaping rules. +`normalize_args` sorts names by UTF-16 units; it omits `UNDEFINED` and preserves +`None` as the literal null value. + + + See the [native API reference](api.md) for direct key construction. ## Namespace @@ -213,4 +232,12 @@ without rebuilding the operation's baseline. + + +The provider receives the normalized `Key`, including namespace, key type, +ID, use case, ordered arguments and tracking. Return a sparse policy overlay +without reconstructing the operation baseline. + + + See [runtime overlays](configuration.md#baseline-and-overlay-precedence). diff --git a/docs/languages/python.md b/docs/languages/python.md new file mode 100644 index 00000000..292fa243 --- /dev/null +++ b/docs/languages/python.md @@ -0,0 +1,118 @@ +# Python integration + +[Shared guides](../index.md) · [Package guide](https://github.com/lan17/DialCache/blob/main/python/README.md) + +The experimental Python port uses awaitable operations and `contextvars` scopes +while following the shared behavioral contract. It requires Python 3.11 or +later. The shared guides import executed Python examples; the API reference is +generated from this checkout with Python's standard `pydoc` tool. + +## Installation and runtime + +The package is currently **unpublished**. Install from a repository checkout: + +```sh +python3 -m pip install './python[redis]' +``` + +Omit the `redis` extra for local-only use. Run operations on one asyncio event +loop per cache instance. The application owns that loop and Redis client +connections. Synchronous loaders and serializers are accepted, but execute on +the event loop; use asynchronous implementations for blocking I/O. + +## Request scope and operations + +Create a long-lived `DialCache` and register readers with `@cache.cached(...)`. +The wrapper is awaitable even when the underlying loader is synchronous. +`get_or_load()` accepts an inline loader; `aget()` accepts a structured `Key`. + +Use `with cache.enable():` or `async with cache.enable():` around request reads. +Nested scopes share the live outer request memo. `cache.disable()` and +`cache.enable(False)` temporarily bypass caching without clearing that memo. +Tasks inherit the scope through `contextvars`, but calls made after its outer +scope closes pass directly through. Instances have independent contexts. + +The [native binding tests](https://github.com/lan17/DialCache/blob/main/python/tests/test_cache.py) +exercise both forms of identity, ordinary and canceled concurrent callers, +deadline boundaries, sparse runtime policies, and argument adaptation through +the public API. + +## Identity and policy + +Specify `cache_key=` for explicit identity selection or `id_arg=` for a named +function argument. An `id_arg=(name, adapter)` pair converts a native object +into a primitive entity ID. Other bound arguments, including default values, +participate in identity; `arg_adapters` converts them and `ignore_args` excludes +inputs that do not affect the result. The default use-case name is the source +function's module and qualified name. Explicit names provide stability across +refactoring. + +`Policy` uses snake_case fields such as `ttl_sec`, `request_local`, and +`remote_read_timeout_ms`. Static settings are captured when registering the +reader. A synchronous or asynchronous `policy_provider` receives the structured +key and returns a sparse `Policy`, mapping, or `None`. Whole-provider `None` +inherits; an explicitly supplied `None` leaf is invalid. Runtime mappings also +accept the shared camelCase names. Omitted fields, false flags, and zero +recovery/shadow settings remain distinct. + +TTL and recovery ages use integer seconds; deadlines use integer milliseconds. +`fallback_timeout_ms=None` explicitly removes the source deadline. Static +configuration errors raise `ConfigError`; malformed runtime policy follows the +shared fail-open rules. + +## Values and cancellation + +In-memory values are shared references. Treat them as immutable or copy before +modifying. `None` remains a present cached value. The default `JsonSerializer` +uses JSON and exposes `UNDEFINED` for the protocol's distinct undefined result; +use a custom serializer for non-JSON native values. + +Canceling a caller raises `asyncio.CancelledError` for that caller without +canceling another caller's shared execution. A DialCache deadline raises +`FallbackTimeoutError`; it ends the wait and revokes late publication without +claiming to stop the underlying source operation. The Python binding does not +automatically schedule synchronous work on threads. + +The default shadow comparator recursively compares JSON-like values and keeps +booleans distinct from numbers. Custom native objects may have different +equality semantics; supply `shadow_comparator` for domain-specific equality. +Shadow admission requires a metrics observer for terminal outcomes. + +## Redis and observability + +`dialcache.redis.RedisAdapter` borrows a `redis.asyncio.Redis` or `RedisCluster` +client configured with `decode_responses=False`. The application supplies +finite connection, socket, and retry budgets and closes the client. The adapter +routes tracked atomic reads to primaries even when the cluster client otherwise +permits replica reads. `invalidate_remote()` and its `ainvalidate()` alias +surface maintenance failures. + +Pass a synchronous `metrics` callable or an object with `observe(event)` to +receive backend-neutral event dictionaries. Labels use the common names, +including `cacheNamespace`, `useCase`, and `keyType`. Observer failures do not +alter application results. This port currently supplies the observer contract; +applications connect it to their metrics backend. + +## Validation + +Prepare a development environment from the repository root: + +```sh +python3 -m venv python/.venv +python/.venv/bin/python -m pip install -e './python[test,redis]' +corepack pnpm install --frozen-lockfile +make check-python +make integration-python +``` + +`check-python` executes native tests, shared wire vectors, fixed scenarios, +committed behavioral histories, and the settlement control. `integration-python` +uses isolated Redis, Valkey, and Redis Cluster servers, including the invalidation +vectors and bidirectional TypeScript interoperability. It requires Docker. + +Generate the shared corpus with `make formal-generate`, then run +`make formal-python` for complete prepared replay and completion checks. The +[porting guide](https://github.com/lan17/DialCache/blob/main/formal/PORTING.md) +defines that evidence boundary. Passing committed smoke tests alone does not +establish complete conformance. Node 24 is a test-tool dependency, not a Python +package runtime dependency. diff --git a/docs/maintainers.md b/docs/maintainers.md index 5f3ed3a4..7cce9962 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -8,7 +8,10 @@ benchmarks, and the existing release process. ## Validation Use Node.js 24 and the repository's pinned pnpm through Corepack. Go and Rust -checks use their CI-pinned toolchains; full formal checks also require the pinned +checks use their CI-pinned toolchains. Prepare Python 3.11 or later with +`python3 -m venv python/.venv` and +`python/.venv/bin/python -m pip install -e './python[test,redis]'`. +Full formal checks also require the pinned Quint executable. Run `make help` for targets and prerequisites: ```bash @@ -19,14 +22,14 @@ make integration `make check` runs strict TypeScript checks and coverage, bundles/declarations, packed ESM/CJS consumer checks, Go vet/formatting/race tests, Rust formatting, -Clippy and native tests, documentation builds, and evidence inventories. -TypeScript, Go and Rust replay the committed +Clippy and native tests, Python native tests, documentation builds, and evidence inventories. +TypeScript, Go, Rust and Python replay the committed Quint smoke fixtures and protocol cases. Integration tests require a Docker-compatible runtime for Redis, Valkey, and Redis Cluster and exercise -all three language bindings. +all four language bindings. `make formal` checks the scheduled Quint models, generates the complete corpus, -and requires full TypeScript, Go and Rust replay with matching evidence fingerprints. +and requires full TypeScript, Go, Rust and Python replay with matching evidence fingerprints. `make model-check` runs the separate finite symbolic checks; it needs Java 21, `tar` and a checksummed Apalache release and is the only lane that does. `make mutations` challenges the tests with the catalogued implementation @@ -268,7 +271,8 @@ git push origin go/vX.Y.Z Rust currently has `publish = false` and is not part of this registry release flow. Its site reference follows repository source, not a crates.io/docs.rs -release. +release. Python is also unpublished and is installed from a checkout; its CI +builds a wheel but does not publish to PyPI. The repository must enable **Allow GitHub Actions to create and approve pull requests** under Actions workflow permissions. diff --git a/docs/observability.md b/docs/observability.md index 3863e719..674df7fe 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -124,6 +124,14 @@ The bundled observer opts into shadow outcomes. See the [Rust API](api.md). + + +Python exposes the backend-neutral `metrics` callback contract. Connect events +to your application-owned Prometheus registry; this port does not currently +ship a Prometheus exporter. Preserve the shared labels, names and units below. + + + ### Histogram buckets Bucket boundaries are fixed; the adapter has no bucket customization option: @@ -220,6 +228,14 @@ shutdown. See the [Rust API](api.md). + + +Connect the Python `metrics` observer to your application-owned DogStatsD +client. This port does not currently ship a Datadog exporter. The application +owns metric delivery, flushing and transport shutdown. + + + ### Distribution or histogram The observation mode is required. @@ -278,6 +294,13 @@ for them when checking final name length and series cardinality. + + +Any metric-name prefix or global tags come from the application's observer. +Account for them when checking final name length and series cardinality. + + + ### Datadog metrics The adapter emits exact increments of `1` for counters and preserves seconds @@ -334,6 +357,13 @@ still owns delivery errors and shutdown after a callback returns. + + +Observer and logger failures are isolated from cache and source outcomes. +The application owns delivery errors and shutdown after a callback returns. + + + ## Metric catalog This table uses Prometheus names and types, without the optional prefix. @@ -660,6 +690,18 @@ traits and event variants. + + +Pass `metrics=callback` or an object with synchronous `observe(event)`. Event +dictionaries use the shared names and camelCase labels, such as `cacheNamespace`, +`useCase` and `keyType`. Supplying an observer enables shadow-outcome reporting; +an optional `supports("shadowValidation")` method can opt out. + +The `logger` option accepts a standard Python-compatible warning logger. +See the [generated Python API](api.md) for signatures. + + + A custom adapter may buffer or transmit asynchronously, but it owns delivery, flushing, resources, and shutdown after the call returns. Keep application-owned namespace, use-case, and key-type labels stable and diff --git a/docs/ports.json b/docs/ports.json index 3faa4b1b..96e1cb0a 100644 --- a/docs/ports.json +++ b/docs/ports.json @@ -1,5 +1,6 @@ [ { "id": "typescript", "label": "TypeScript", "guide": "/languages/typescript", "reference": "/reference/typescript/index.html", "example": "examples/typescript/docs.mts" }, { "id": "go", "label": "Go", "guide": "/languages/go", "reference": "/reference/go/index.html", "example": "go/docs_examples_test.go" }, - { "id": "rust", "label": "Rust", "guide": "/languages/rust", "reference": "/reference/rust/dialcache/index.html", "example": "rust/tests/docs_examples.rs" } + { "id": "rust", "label": "Rust", "guide": "/languages/rust", "reference": "/reference/rust/dialcache/index.html", "example": "rust/tests/docs_examples.rs" }, + { "id": "python", "label": "Python", "guide": "/languages/python", "reference": "/reference/python/index.html", "example": "python/tests/test_docs_examples.py" } ] diff --git a/docs/redis.md b/docs/redis.md index b3d74483..959c512a 100644 --- a/docs/redis.md +++ b/docs/redis.md @@ -163,6 +163,19 @@ setup and cleanup. + + +Install the checkout with the Redis extra and borrow an application-owned +`redis.asyncio.Redis` or `RedisCluster` client through +`dialcache.redis.RedisAdapter`. Pass `redis=RedisAdapter(client)` to `DialCache`. +Use `decode_responses=False` and finite connection, socket and retry budgets. +Tracked Cluster reads explicitly select the primary. + +The [executed invalidation example](invalidation.md#configure-a-tracked-use-case) +includes complete client setup and cleanup in its source. + + + ## Remote-read deadlines and async liveness The read deadline resolves from the runtime overlay, operation defaults, instance @@ -203,6 +216,14 @@ client. Cancellation does not guarantee that a dispatched server command stops. + + +The cache bounds its remote-read wait and sends a cooperative abort signal. +Configure finite socket, connection and retry budgets on the borrowed client; +ending the Python wait cannot retract a dispatched Redis command. + + + See [Coalescing and liveness](coalescing.md). ## Lifecycle ownership @@ -237,6 +258,14 @@ application's connection owners only after draining dependent work. + + +Keep the asyncio event loop alive until dependent work settles. Close the +application-owned client with `await client.aclose()` during application +shutdown. The adapter never closes, flushes or disconnects it. + + + Detached shadow work has no public drain handle. Already-started Redis, source, codec or telemetry work may outlive its shadow deadline. Account for it when closing dependencies; shutdown can lose a best-effort outcome even if a fill @@ -357,6 +386,16 @@ budgets and lifetime of work they start. See the + + +`JsonSerializer` supports JSON-like native values and the distinct `UNDEFINED` +sentinel. It returns text or bytes through `dump` and native values through +`load`. Supply synchronous or asynchronous serializer methods for other value +domains. Custom serializers own their scheduling and budgets. Memory entries +retain the native object rather than a serialized copy. + + + A fresh frame whose `load` fails becomes a refreshable miss: DialCache records `serialization_load`, calls the source, and attempts replacement. The default codec validates JSON syntax, not your application schema. For incompatible @@ -397,6 +436,14 @@ Invalid settings return `ConfigError` at build time. + + +Use `compression=True`, `False`, or an options mapping with `threshold_bytes` +and `level`. The default threshold is 4,096 bytes and level is 3. Invalid static +settings raise `ConfigError`. + + + Payloads meeting the threshold are compressed with zstd only when the stored form is smaller. Reads always interpret the compression envelope, including when new-write compression is disabled. Binary payloads beginning with an @@ -428,6 +475,15 @@ the write. Custom codecs still choose their own scheduling. + + +Compression and decompression use the `zstandard` package on the event loop. +They enforce the shared 512 MiB decompressed-size bound. Custom serializer +implementations can schedule their own CPU work; the library does not +automatically move synchronous callbacks onto threads. + + + Use the size, ratio and duration [metrics](observability.md#compression-metrics) to evaluate the tradeoff. @@ -526,6 +582,14 @@ server command statistics can reveal unexpected `EVAL` activity. + + +A failed retry raises its native error. A successful idempotent fallback +retains the original invalidation timestamp. Track unexpected `EVAL` activity +through server command statistics when operationally useful. + + + A rejected or timed-out dispatched mutation does not prove that Redis remained unchanged. Native writes do not implement compare-and-set or deduplicate retries performed by an application or client. @@ -616,6 +680,15 @@ ownership types. + + +Implement `dialcache.redis.RedisClient.read`, `write`, and `invalidate`; each +method may return a value or awaitable. Use `ReadRequest`, `ReadContext`, +`WriteRequest`, `InvalidationRequest`, `Frame` and `Miss`. The +[Python API](api.md) documents their fields and the public protocol helpers. + + + Bound connection, queue, dispatch, retries and settlement. The read deadline bounds DialCache's wait; it does not supply write or invalidation budgets. @@ -657,6 +730,14 @@ watermark rules below are shared across ports. + + +The `dialcache.protocol` module exports frame codecs, miss decoding, payload +compression, and validation helpers. The [Python API](api.md) gives native +signatures. The wire layout and watermark rules below are shared. + + + A stored value has a ten-byte header followed by payload: | Bytes | Meaning | @@ -696,6 +777,14 @@ metadata into absence. + + +Python adapters use bytes or text payloads and `RedisProtocolError` subclasses. +Preserve the shared decoding order; malformed present metadata must not be +converted into an absent watermark. + + + After reply validation, a null value is `value_absent`; a short frame or unknown version is `unclassified`. Either tracked miss can preserve a valid paired watermark. Watermark text must contain decimal digits only and represent a value @@ -738,6 +827,13 @@ timestamp domain. + + +Python integers can exceed the protocol domain. Timestamp validation still +requires a nonnegative integer at most JavaScript's safe-integer ceiling. + + + See the [age and clock rules](observability.md#value-ages-and-clock-offsets). ### Invalidation script and payload envelope diff --git a/docs/shadow-validation.md b/docs/shadow-validation.md index 07daa1a8..3046053e 100644 --- a/docs/shadow-validation.md +++ b/docs/shadow-validation.md @@ -95,6 +95,14 @@ Use a policy with `.remote_ttl_sec(300).remote_ramp(0.0)` and a + + +Use `Policy(ttl_sec={"remote": 300}, ramp={"remote": 0}, +shadow={"ramp": 5})` and instance `shadow_max_in_flight=4`. Supply a metrics +callback or observer for terminal shadow outcomes. See [observability](observability.md). + + + Inside an enabled scope, callers use the source. Eligible keys in the independent 5% shadow cohort exercise Redis in the background. A semantic miss authorizes a fill. Use the selected language's outcome opt-in described above. @@ -247,6 +255,15 @@ panics report `comparison_error`; returned values must be safe to share. + + +The default recursively compares JSON-like values and distinguishes booleans +from numbers. Use `shadow_comparator` for custom native domains; it must return +a synchronous boolean. Exceptions and non-boolean results report +`comparison_error`. + + + Comparison uses the decoded cache value and raw source value intentionally: it can reveal lossy serialization. Ignore differences only when they are acceptable application semantics. @@ -307,6 +324,14 @@ codecs control their own scheduling. + + +The asyncio event loop owns detached shadow work. Keep it alive while work +settles. Loaders, codecs and compression execute on that event loop; use native +async I/O and application-controlled scheduling for blocking custom work. + + + Underlying I/O is not generally canceled. Give dependencies finite native budgets, including commands that can settle after a DialCache timeout. @@ -385,6 +410,15 @@ metadata warning. The logical-key preview is bounded to 2 KiB. + + +Set `shadow={"ramp": 5, "log_mismatches": True}`. Confirmed mismatches emit +bounded native JSON previews (8 KiB) and a logical-key preview (2 KiB). +Serialization failures omit unavailable value previews; warning failures do +not alter cache results. + + + Truncation is not redaction. Keys and values can contain sensitive application data. Preview byte limits do not by themselves bound serialization traversal or CPU cost. Logger failures are isolated from cache behavior. diff --git a/docs/stale-on-error.md b/docs/stale-on-error.md index ff19c15c..63d816f8 100644 --- a/docs/stale-on-error.md +++ b/docs/stale-on-error.md @@ -57,6 +57,14 @@ deadline separately from both ages. + + +Use `Policy(ttl_sec={"remote": 60}, stale_on_error_max_age_sec=300)` with a +configured Redis adapter. The operation's `fallback_timeout_ms` controls the +source deadline separately from both ages. + + + Inside an enabled scope, a frame younger than 60 seconds serves normally. From 60 seconds until strictly before 300 seconds, it can serve only after an eligible source failure. The built-in classifier accepts the native fallback-timeout @@ -121,6 +129,14 @@ timeouts, then add narrowly classified application source errors. + + +Set `should_attempt_stale_recovery` on the operation or instance. Preserve +`isinstance(error, FallbackTimeoutError)` when extending the default to narrowly +classified transient source errors. + + + An application transient-error predicate should classify infrastructure failures narrowly. Deny authoritative outcomes such as permission or entitlement failures, revocation, deletion/not-found, validation, @@ -156,6 +172,13 @@ recovery; it does not authorize a retained value. + + +The predicate must synchronously return a boolean. Exceptions, awaitables and +non-boolean values deny recovery without replacing the original source error. + + + ## Snapshot and invalidation boundaries For a tracked key, the initial primary `MGET` applies the watermark that existed diff --git a/docs/upgrading.md b/docs/upgrading.md index 6f116123..2ff008f9 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -82,6 +82,14 @@ rollout. See [configuration](configuration.md) and the [Rust API](api.md). + + +Use current `Policy` snake_case fields or the accepted shared camelCase +mapping names. Legacy `shadowRamp` is rejected; migrate it to `shadow.ramp`. +See [configuration](configuration.md) and the [Python API](api.md). + + + ## Custom Redis adapters A custom adapter must preserve complete-frame writes, classified primary reads, @@ -134,6 +142,15 @@ inventing alternate framing. See [custom-client contract](redis.md#custom-client + + +Implement `dialcache.redis.RedisClient` with its request/result types. Return +one primary snapshot for tracked reads, preserve observed fences on misses, +and honor explicit write timestamps. Use `dialcache.protocol` helpers for wire +framing; see the [custom-client contract](redis.md#custom-client-contract). + + + Invalidation is the only Lua script. It receives `[futureBufferMs, invalidatedAtMs]`; reuse the second argument across retries of one logical operation. See [Targeted invalidation](invalidation.md) for timing and retention. @@ -242,4 +259,12 @@ outcomes, names and units. Native types are in the [API reference](api.md). + + +Update event dictionary handling for new outcomes and labels. Preserve +`tracked_ttl_clamped`, `fill_fenced` and recovery outcomes with their shared +units. See the [Python API](api.md). + + + See [Observability](observability.md) for current names, units, and hooks. diff --git a/formal/check-python-replay.mjs b/formal/check-python-replay.mjs new file mode 100644 index 00000000..4bc28daa --- /dev/null +++ b/formal/check-python-replay.mjs @@ -0,0 +1,65 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { conformanceInventory } from './conformance.mjs'; +import { root } from './execution.mjs'; + +const timestamp = value => Number.isSafeInteger(value) && value > 0; + +// Exact assertion records, never a test count or a command's exit status, own +// completion. Smoke/selected/behavior-only reports cannot be relabeled complete. +export function checkPythonReplay(text, inventory = conformanceInventory(), { corpus } = {}) { + if (typeof text !== 'string' || !text.trim()) throw new Error('Empty Python replay report'); + if (!Array.isArray(inventory) || !inventory.length) throw new Error('Empty Python replay inventory'); + const required = new Map(inventory.map(entry => [entry.id, entry])); + if (required.size !== inventory.length) throw new Error('Duplicate Python conformance inventory'); + const cases = new Map(); + let start, finish; + for (const [index, line] of text.trim().split('\n').entries()) { + let event; + try { event = JSON.parse(line); } catch { throw new Error(`Invalid Python JSON record at line ${index + 1}`); } + if (!event || typeof event !== 'object' || Array.isArray(event)) throw new Error('Invalid Python replay record'); + if (finish) throw new Error('Python report continues after its finish record'); + if (event.kind === 'start') { + if (index !== 0 || start) throw new Error('Duplicate or misplaced Python start record'); + if (event.schemaVersion !== 1 || event.implementation !== 'python' || event.scope !== 'conformance' + || event.selection !== 'generated' || event.partial !== false || !timestamp(event.startedAt)) { + throw new Error('Python report is not a complete conformance execution'); + } + start = event; + } else if (event.kind === 'case') { + if (!start) throw new Error('Python case precedes its start record'); + if (!required.has(event.id)) throw new Error(`Unknown Python conformance case: ${event.id}`); + if (cases.has(event.id)) throw new Error(`Duplicate Python conformance case: ${event.id}`); + if (event.status !== 'passed') throw new Error(`Python replay failed or skipped: ${event.id}${event.message ? ` (${event.message})` : ''}`); + if (!timestamp(event.startedAt) || !timestamp(event.finishedAt) || event.startedAt < start.startedAt || event.finishedAt < event.startedAt) { + throw new Error(`Invalid Python case timing: ${event.id}`); + } + if (required.get(event.id).path && !/^[a-f\d]{64}$/.test(event.historySha256 ?? '')) throw new Error(`Missing Python history fingerprint: ${event.id}`); + if (required.get(event.id).path && corpus !== undefined + && event.historySha256 !== corpus[required.get(event.id).path]) throw new Error(`Python history fingerprint differs from its execution context: ${event.id}`); + cases.set(event.id, event); + } else if (event.kind === 'finish') { + if (!start || event.status !== 'passed' || event.failed !== 0 || event.cases !== cases.size + || !timestamp(event.finishedAt) || event.finishedAt < start.startedAt + || [...cases.values()].some(item => item.finishedAt > event.finishedAt)) throw new Error('Incomplete or inconsistent Python finish record'); + finish = event; + } else throw new Error(`Unsupported Python report record: ${event.kind}`); + } + if (!finish) throw new Error('Python replay is incomplete: missing finish record'); + for (const id of required.keys()) if (!cases.has(id)) throw new Error(`Missing passed Python conformance case: ${id}`); + const counts = {}; + for (const entry of inventory) counts[entry.category] = (counts[entry.category] ?? 0) + 1; + return { schemaVersion: 1, implementation: 'python', status: 'pass', executedCases: cases.size, counts }; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + if (process.argv.length > 3) throw new Error('Usage: node formal/check-python-replay.mjs [python-replay.jsonl]'); + const path = process.argv[2] ? resolve(process.argv[2]) : resolve(root, '.formal-traces/python-replay.jsonl'); + const text = readFileSync(path, 'utf8'); + const inventory = conformanceInventory(); + const corpus = Object.fromEntries(inventory.filter(entry => entry.path).map(entry => [entry.path, + createHash('sha256').update(readFileSync(resolve(root, entry.path))).digest('hex')])); + console.log(JSON.stringify({ ...checkPythonReplay(text, inventory, { corpus }), reportSha256: createHash('sha256').update(text).digest('hex') }, null, 2)); +} diff --git a/formal/conformance-adapters.mjs b/formal/conformance-adapters.mjs index 03f8d9f7..afe3b80c 100644 --- a/formal/conformance-adapters.mjs +++ b/formal/conformance-adapters.mjs @@ -3,6 +3,7 @@ import { basename, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { checkGoReplay, loadGoReplayInventory } from './check-go-replay.mjs'; import { checkRustReplay } from './check-rust-replay.mjs'; +import { checkPythonReplay } from './check-python-replay.mjs'; import { root } from './execution.mjs'; import { readJSON, digest, fingerprint, validateContext, checkCompletion } from './conformance.mjs'; @@ -62,11 +63,19 @@ export function parseRustReport(text, inventory) { return { startedAt, finishedAt, results: inventory.map(entry => ({ id: entry.id, status: 'passed' })) }; } +export function parsePythonReport(text, inventory, corpus) { + checkPythonReplay(text, inventory, { corpus }); + const events = text.trim().split('\n').map(line => JSON.parse(line)); + return { startedAt: events[0].startedAt, finishedAt: events.at(-1).finishedAt, + results: inventory.map(entry => ({ id: entry.id, status: 'passed' })) }; +} + export function adaptReport(language, text, context) { validateContext(context); if (context.language !== language) throw new Error('Wrong port context'); const parsed = language === 'typescript' ? parseTypeScriptReport(text, context.inventory) : language === 'go' ? parseGoReport(text, context.inventory) : language === 'rust' ? parseRustReport(text, context.inventory) + : language === 'python' ? parsePythonReport(text, context.inventory, context.corpus) : (() => { throw new Error('Unsupported native report adapter'); })(); const report = { schemaVersion: 1, language, runId: context.runId, contextSha256: fingerprint(context), ...parsed, status: 'passed', nativeReportSha256: digest(text) }; @@ -75,6 +84,6 @@ export function adaptReport(language, text, context) { } if (process.argv[1] === fileURLToPath(import.meta.url)) { const [language, nativePath, contextPath, ...extra] = process.argv.slice(2); - if (extra.length || !language || !nativePath || !contextPath) throw new Error('Usage: node formal/conformance-adapters.mjs '); + if (extra.length || !language || !nativePath || !contextPath) throw new Error('Usage: node formal/conformance-adapters.mjs '); console.log(JSON.stringify(adaptReport(language, readFileSync(resolve(root, nativePath), 'utf8'), readJSON(contextPath)), null, 2)); } diff --git a/formal/conformance-bindings.mjs b/formal/conformance-bindings.mjs index 9efe96f8..d36884a4 100644 --- a/formal/conformance-bindings.mjs +++ b/formal/conformance-bindings.mjs @@ -39,8 +39,8 @@ export function nativeBinding(entry, language, workspace = root) { } // The Rust harness names every case by its shared inventory id, so the // binding is the identity: the report is read against the inventory directly. - if (language === 'rust') return entry.id; - if (language !== 'typescript') throw new Error('Native report adapter is only supplied for TypeScript, Go and Rust'); + if (language === 'rust' || language === 'python') return entry.id; + if (language !== 'typescript') throw new Error('Native report adapter is only supplied for TypeScript, Go, Rust and Python'); if (entry.category === 'sampled' || entry.category === 'regression') return [profileFile(entry.profile), `${profileSuite(entry.profile)} replays ${resolve(workspace, entry.path)}`]; if (entry.category === 'scenario') return ['formal-behavior.test.ts', `portable behavioral scenarios ${entry.feature}: ${entry.name}`]; diff --git a/formal/conformance.mjs b/formal/conformance.mjs index 889e5f1c..925a89f2 100644 --- a/formal/conformance.mjs +++ b/formal/conformance.mjs @@ -60,6 +60,12 @@ export function defaultSources(language) { ...filesBelow('rust').filter(path => !path.startsWith('rust/target/') && /\.(rs|toml|lock)$/.test(path)), ...readExecution().models.filter(model => model.profile && model.profile !== 'core') .map(model => `.formal-traces/go-parity-witnesses/${model.profile}.json`)]; + if (language === 'python') return [ + ...filesBelow('python/dialcache').filter(path => path.endsWith('.py')), + ...filesBelow('python/tests').filter(path => path.endsWith('.py') || path.endsWith('.mjs')), + 'python/pyproject.toml', + ...readExecution().models.filter(model => model.profile && model.profile !== 'core') + .map(model => `.formal-traces/go-parity-witnesses/${model.profile}.json`)]; fail('New languages must supply an explicit JSON list of implementation and harness source paths'); } function hashes(paths) { @@ -106,7 +112,7 @@ export function validateContext(context, { current = true } = {}) { if (!isDeepStrictEqual(context.implementation, hashes(Object.keys(context.implementation)))) fail('Implementation inputs changed during run'); // Default bindings must include new files too; custom port inventories are // an explicit, reviewable declaration of the complete execution inputs. - if (['typescript', 'go', 'rust'].includes(context.language) && !isDeepStrictEqual(Object.keys(context.implementation).sort(), defaultSources(context.language).sort())) fail('Implementation source inventory changed during run'); + if (['typescript', 'go', 'rust', 'python'].includes(context.language) && !isDeepStrictEqual(Object.keys(context.implementation).sort(), defaultSources(context.language).sort())) fail('Implementation source inventory changed during run'); if (!isDeepStrictEqual(context.corpus, corpusInputs(context.inventory))) fail('Shared corpus changed during run'); } return context; diff --git a/formal/explore.mjs b/formal/explore.mjs index f7f1e374..88318669 100644 --- a/formal/explore.mjs +++ b/formal/explore.mjs @@ -8,13 +8,14 @@ import { nativeBinding } from './conformance-bindings.mjs'; import { parseTypeScriptReport } from './conformance-adapters.mjs'; import { checkGoReplay } from './check-go-replay.mjs'; import { checkRustReplay } from './check-rust-replay.mjs'; +import { checkPythonReplay } from './check-python-replay.mjs'; import { canonicalSeed, reportFileName } from './witnesses.mjs'; const root = fileURLToPath(new URL('../', import.meta.url)); const hash = value => createHash('sha256').update(value).digest('hex'); const inside = (directory, path) => path.startsWith(directory + sep); -const reportPaths = { typescript: '.formal-traces/ts-replay.json', go: '.formal-traces/go-replay.jsonl', rust: '.formal-traces/rust-replay.jsonl' }; -const contextPaths = { typescript: '.formal-traces/ts-context.json', go: '.formal-traces/go-context.json', rust: '.formal-traces/rust-context.json' }; +const reportPaths = { typescript: '.formal-traces/ts-replay.json', go: '.formal-traces/go-replay.jsonl', rust: '.formal-traces/rust-replay.jsonl', python: '.formal-traces/python-replay.jsonl' }; +const contextPaths = { typescript: '.formal-traces/ts-context.json', go: '.formal-traces/go-context.json', rust: '.formal-traces/rust-context.json', python: '.formal-traces/python-context.json' }; export function explorationSeed(value = `0x${randomBytes(8).toString('hex')}`) { try { return canonicalSeed(value); } @@ -31,12 +32,13 @@ export function explorationPlan(directory, seed, options = {}) { // This campaign uses the manifest's pinned seed, not the exploration seed. // Full acceptance keeps it; exploration retains every unmodified model job. if (script === 'formal/check-model-properties.mjs') return []; - if (step.remove || ['formal/conformance-adapters.mjs', 'formal/check-go-replay.mjs', 'formal/check-rust-replay.mjs'].includes(script) + if (step.remove || ['formal/conformance-adapters.mjs', 'formal/check-go-replay.mjs', 'formal/check-rust-replay.mjs', 'formal/check-python-replay.mjs'].includes(script) || script === 'formal/conformance.mjs' && step.args[1] === 'check') return []; if (script === 'formal/conformance.mjs' && step.args[1] === 'prepare') { return [{ label: `Prepare exploratory ${step.args[2]} context`, explorationContext: step.args[2] }]; } if (script === 'formal/run-models.mjs') return [{ ...step, env: { ...step.env, QUINT_SEED: normalized } }]; + if (script === 'formal/run-python-replay.mjs') return [{ ...step, nativeReport: 'python' }]; if (step.env?.DIALCACHE_MBT_TRACE_DIR) return [{ ...step, nativeReport: step.command === 'go' ? 'go' : step.command === 'cargo' ? 'rust' : 'typescript' }]; // A seed's missing witness is classified by every native report. The shared // evaluator runs before native replay and still writes evidence for complete @@ -159,21 +161,24 @@ export function nativeExplorationResult(language, text, context, directory, pack required: inventory.map(entry => ({ name: nativeBinding(entry, language), category: entry.category })) }; checkGoReplay(events.map(event => JSON.stringify(event.Action === 'fail' ? { ...event, Action: 'pass' } : event)).join('\n'), native); startedAt = Date.parse(events[0].Time); finishedAt = Date.parse(events.at(-1).Time); - } else if (language === 'rust') { - // The Rust report names cases by inventory id; a failed case record is the + } else if (language === 'rust' || language === 'python') { + // These reports name cases by inventory id; a failed case record is the // native counterexample. The all-passed copy reuses the strict report gate. const records = text.trim().split('\n').map(line => JSON.parse(line)); const cases = new Map(inventory.map(entry => [nativeBinding(entry, language), entry])); for (const record of records) { + if (record.kind === 'case' && !['passed', 'failed'].includes(record.status)) throw new Error(`Skipped or unfinished ${language} case.`); if (record.kind !== 'case' || record.status !== 'failed') continue; const entry = cases.get(record.id); if (entry) failed.push(entry); else otherFailures.push(record.id); } const finish = records.at(-1); - if (finish?.kind !== 'finish') throw new Error('Rust report has no finish record: the harness crashed or timed out before completing.'); - if ((finish.status === 'failed') !== (failed.length + otherFailures.length > 0)) throw new Error('Rust report status disagrees with its case records.'); - checkRustReplay(records.map(record => JSON.stringify(record.kind === 'case' ? { ...record, status: 'passed', message: undefined } - : record.kind === 'finish' ? { ...record, status: 'passed', failed: 0 } : record)).join('\n'), inventory); + if (finish?.kind !== 'finish') throw new Error(`${language} report has no finish record: the harness crashed or timed out before completing.`); + if (!['passed', 'failed'].includes(finish.status) || (finish.status === 'failed') !== (failed.length + otherFailures.length > 0) + || finish.failed !== failed.length + otherFailures.length) throw new Error(`${language} report status disagrees with its case records.`); + (language === 'rust' ? checkRustReplay : checkPythonReplay)(records.map(record => JSON.stringify(record.kind === 'case' ? { ...record, status: 'passed', message: undefined } + : record.kind === 'finish' ? { ...record, status: 'passed', failed: 0 } : record)).join('\n'), inventory, + language === 'python' ? { corpus: context.corpus } : undefined); startedAt = records[0]?.startedAt; finishedAt = finish.finishedAt; } else throw new Error('Unsupported exploratory port.'); if (!Number.isFinite(startedAt) || !Number.isFinite(finishedAt) || startedAt < context.createdAt @@ -240,9 +245,9 @@ function savedExploration(path) { } function linkDependencies(directory, workspace, sources, replay) { - if (replay) for (const path of ['package.json', 'pnpm-lock.yaml']) { + if (replay) for (const path of ['package.json', 'pnpm-lock.yaml', ...(Object.hasOwn(sources, 'python/pyproject.toml') ? ['python/pyproject.toml'] : [])]) { if (!Object.hasOwn(sources, path) || hash(readFileSync(resolve(directory, path))) !== sources[path]) { - throw new Error(`Saved ${path} differs from the current dependency runtime; replay requires matching package and lockfile bytes.`); + throw new Error(`Saved ${path} differs from the current dependency runtime; replay requires matching dependency manifest bytes.`); } } symlinkSync(resolve(directory, 'node_modules'), resolve(workspace, 'node_modules'), 'dir'); @@ -301,6 +306,12 @@ async function executeExploration(seed, { directory = root, environment = proces const parent = resolve(directory, '.formal-traces/exploration'); mkdirSync(parent, { recursive: true }); const output = mkdtempSync(resolve(parent, `${selectedSeed}-`)), workspace = resolve(output, 'workspace'); + // Reuse only the dependency interpreter. The runner prepends its own source + // tree; the prerequisite probe also imports the snapshot, never an editable + // installation's original checkout. A virtualenv is not copied into evidence. + const runtimeEnvironment = { ...environment, + PYTHON: environment.PYTHON ?? resolve(directory, 'python/.venv/bin/python'), + PYTHONPATH: resolve(workspace, 'python') }; const report = { schemaVersion: 1, kind: 'exploration', acceptance: false, seed: selectedSeed, status: 'running', startedAt: new Date().toISOString(), sources: {}, native: [], ...(origin ? { replayOrigin: { path: origin.reportPath, reportSha256: origin.reportSha256, @@ -334,17 +345,17 @@ async function executeExploration(seed, { directory = root, environment = proces : await import(pathToFileURL(resolve(workspace, 'formal/explore.mjs')).href); if (!run) { const validation = await import(pathToFileURL(resolve(workspace, 'formal/validation.mjs')).href); - validation.checkPrerequisites('explore', { directory: workspace, environment: cleanEnvironment(environment) }); + validation.checkPrerequisites('explore', { directory: workspace, environment: cleanEnvironment(runtimeEnvironment) }); } - report.native = await snapshot.runExplorationSteps(snapshot.explorationPlan(workspace, selectedSeed, { environment }), { - directory: workspace, environment: cleanEnvironment(environment), onResult: results => { report.native = results; save(); }, + report.native = await snapshot.runExplorationSteps(snapshot.explorationPlan(workspace, selectedSeed, { environment: runtimeEnvironment }), { + directory: workspace, environment: cleanEnvironment(runtimeEnvironment), onResult: results => { report.native = results; save(); }, // The evaluator step is tolerated so both ports replay; its failure is // still part of the record so a missing report explains itself. onToleratedFailure: (step, error) => { report.witnessStepError = `${step.label ?? 'tolerated step'}: ${error}`; save(); }, }); verifyHashes(workspace, [report.sources]); report.sourcesUnchanged = true; - if (report.native.map(result => result.language).sort().join() !== 'go,rust,typescript' + if (report.native.map(result => result.language).sort().join() !== Object.keys(reportPaths).sort().join() || report.native.some(result => !['passed', 'native-failure', 'witness-check-failure'].includes(result.status))) { throw new Error('Exploration did not finish every native port.'); } diff --git a/formal/go-parity.json b/formal/go-parity.json index 2fecdc61..f8359ea3 100644 --- a/formal/go-parity.json +++ b/formal/go-parity.json @@ -10,10 +10,10 @@ "inputs": { "semanticCasesSha256": "faa768fbf06eddc03dc4f26570094b1df066fb075f898043b40c6ce9d8c952a1", "executionSha256": "3df06c53eb69f02fb6eafabd51c6cc7a306c6739e04ab3fa521b53389da559f2", - "sourceAuditSha256": "d1d7b67b16d64b2ca31da3b0632a2379760d024d265e3f581586e9d453c7a3d6", + "sourceAuditSha256": "eb346f5919cc0158afa8f0e565025bb7f03a81c5b231a7f6b1684c8c15afc7c3", "featureCoverageSha256": "91723cc5398c8fff102d2beb199ebcab82af137e11f1cb9771b8d16d0304a95c", "coverageWitnessesSha256": "2b2d5d57daacba3ecd3637f5a917d58b0b63fcc88893e651cd602cdba1ef4244", - "profilesSha256": "2ebb3ba9c9b71334bcbbaa1e2d9c1d539664ed69e826942ff829a2c31fe9434b" + "profilesSha256": "15fbdb476b5ee9f39ea017ef5668d9c26cb7d53ebc96a6b7c4f531cccfa8d897" }, "inventory": { "behavioralCases": 244, @@ -4526,6 +4526,23 @@ ], "rationale": "The native setup and binding guide explains request lifetime, sparse policy, errors, codec domains and integrations relative to the same shared behavior. Go API and codec artifacts establish the relevant native distinctions; documentation itself is not execution evidence. The primary-read contract requires primary-backed standalone/Sentinel handles; the concrete Go ClusterClient adapter overrides replica routing, while Rust-specific connection types remain native binding guidance." }, + { + "path": "docs/languages/python.md", + "goFiles": [ + "go/api.go", + "go/bindings.go", + "go/policy.go", + "go/codec.go", + "go/docs_examples_test.go", + "go/redis_adapter.go" + ], + "nativeAdaptations": [ + "B01", + "B02", + "B03" + ], + "rationale": "The Python binding guide explains shared request lifetime, policy inheritance, identities, deadlines, invalidation and observer boundaries. Those portable rules apply to Go through the referenced APIs and tests; Python decorators, contextvars, asyncio cancellation, native values and package installation are language-specific guidance, not claims about Go execution. The borrowed-client and primary-read requirements remain shared adapter obligations." + }, { "path": "docs/languages/rust.md", "goFiles": [ diff --git a/formal/profiles.json b/formal/profiles.json index 23bc2b3e..01517e3d 100644 --- a/formal/profiles.json +++ b/formal/profiles.json @@ -369,6 +369,30 @@ "shadow-read-deadlines" ], "limits": "Same generated histories and portable scenarios as TypeScript and Go and all protocol vectors, replayed by the Rust conformance harness against the shared witness evidence. Claims require current corpus and witness fingerprints; finite evidence does not prove every possible schedule. Native Redis integration is not yet part of the claim." + }, + { + "id": "python", + "profiles": [ + "core", + "effects", + "scope", + "recovery", + "policy", + "shadow", + "admission", + "layers", + "independent", + "recovery-read", + "local-failure", + "runtime-boundaries", + "shadow-layers", + "local-clock", + "source-budgets", + "dark-layers", + "shadow-read-deadlines" + ], + "definition": "python/README.md", + "limits": "Asyncio implementation with all shared histories, fixed scenarios, protocol vectors and corpus-bound witness evidence in the complete replay lane. Claims require a current prepared completion report; smoke results and partial reports do not establish completion. Native Redis/Valkey/Cluster integration is validated separately. Finite evidence does not prove every possible schedule; Python semantic mutation coverage is not yet registered." } ], "replaySources": [ diff --git a/formal/run-python-integration.mjs b/formal/run-python-integration.mjs new file mode 100644 index 00000000..86db5a89 --- /dev/null +++ b/formal/run-python-integration.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +/** Disposable real-server evidence; every required server run must have zero skips. */ +import { spawn } from 'node:child_process'; +import { randomInt, randomUUID } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; +import { createServer } from 'node:net'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const python = process.env.PYTHON ?? (existsSync(join(root, 'python/.venv/bin/python')) + ? join(root, 'python/.venv/bin/python') : 'python3'); +const docker = process.env.DOCKER ?? 'docker'; +const reports = join(root, '.formal-traces'); +mkdirSync(reports, { recursive: true }); +for (const label of ['Redis', 'Valkey']) { + rmSync(join(reports, `python-integration-${label}.xml`), { force: true }); +} +const names = []; +let active; +let interrupted = false; +for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => { + interrupted = true; + active?.kill('SIGTERM'); +}); + +function run(command, args, { capture = false, env = process.env, allowFailure = false } = {}) { + return new Promise((resolveRun, reject) => { + if (interrupted && command !== docker) return reject(new Error('Interrupted')); + const child = spawn(command, args, { cwd: root, env, stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit' }); + active = child; + let stdout = '', stderr = ''; + child.stdout?.on('data', chunk => { stdout += chunk; }); + child.stderr?.on('data', chunk => { stderr += chunk; }); + child.once('error', reject); + child.once('close', code => { + if (active === child) active = undefined; + if (code === 0 || allowFailure) resolveRun({ code, stdout, stderr }); + else reject(new Error(`${command} ${args.join(' ')} failed (${code}): ${stderr}`)); + }); + }); +} + +async function waitFor(name, port, command = ['PING'], predicate = output => output.includes('PONG')) { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (interrupted) throw new Error('Interrupted'); + const result = await run(docker, ['exec', name, 'redis-cli', '-p', String(port), ...command], { capture: true, allowFailure: true }); + if (result.code === 0 && predicate(result.stdout)) return; + await new Promise(resolveWait => setTimeout(resolveWait, 100)); + } + throw new Error(`Timed out waiting for ${name}:${port}`); +} + +async function standalone(image, kind) { + const name = `dialcache-python-${kind}-${randomUUID().slice(0, 8)}`; + names.push(name); + await run(docker, ['run', '--rm', '--detach', '--name', name, + '--label', 'dialcache.python-integration=true', '--publish', '127.0.0.1::6379', image], { capture: true }); + await waitFor(name, 6379); + const result = await run(docker, ['port', name, '6379/tcp'], { capture: true }); + const match = result.stdout.match(/127\.0\.0\.1:(\d+)/); + if (!match) throw new Error(`Missing loopback port mapping for ${name}`); + return `redis://127.0.0.1:${match[1]}`; +} + +async function freePorts() { + for (let attempt = 0; attempt < 30; attempt++) { + const base = randomInt(18000, 38000); + const sockets = []; + try { + for (let offset = 0; offset < 6; offset++) { + const server = createServer(); + sockets.push(server); + await new Promise((done, reject) => { server.once('error', reject); server.listen(base + offset, '127.0.0.1', done); }); + } + return Array.from({ length: 6 }, (_, offset) => base + offset); + } catch { /* Retry another bounded, nonprivileged loopback range. */ } + finally { await Promise.all(sockets.map(server => new Promise(done => server.close(done)))); } + } + throw new Error('Could not allocate six loopback ports for Redis Cluster'); +} + +async function cluster() { + const ports = await freePorts(); + const name = `dialcache-python-cluster-${randomUUID().slice(0, 8)}`; + names.push(name); + // All six nodes share a disposable container. Matching published ports keep + // CLUSTER SLOTS addresses valid both within the container and on the host. + const command = `for port in ${ports.join(' ')}; do redis-server --port "$port" --cluster-enabled yes --cluster-config-file "/tmp/nodes-$port.conf" --cluster-announce-ip 127.0.0.1 --cluster-announce-port "$port" --cluster-announce-bus-port "$((port + 10000))" --appendonly no --save "" --protected-mode no --daemonize yes; done; tail -f /dev/null`; + await run(docker, ['run', '--rm', '--detach', '--name', name, '--label', 'dialcache.python-integration=true', + ...ports.flatMap(port => ['--publish', `127.0.0.1:${port}:${port}`]), 'redis:7-alpine', 'sh', '-c', command], { capture: true }); + for (const port of ports) await waitFor(name, port); + await run(docker, ['exec', name, 'redis-cli', '--cluster', 'create', + ...ports.map(port => `127.0.0.1:${port}`), '--cluster-replicas', '1', '--cluster-yes'], { capture: true }); + await waitFor(name, ports[0], ['CLUSTER', 'INFO'], output => output.includes('cluster_state:ok')); + return `redis://127.0.0.1:${ports[0]}`; +} + +async function testServer(label, url, clusterUrl, isolated) { + console.log(`Python integration: ${label}, tracked primary reads on a six-node Redis Cluster, TypeScript interoperability`); + const report = join(reports, `python-integration-${label}.xml`); + await run(python, ['-m', 'pytest', 'python/tests/test_redis_integration.py', 'python/tests/test_docs_examples.py', '-m', 'integration', '-q', `--junitxml=${report}`], { + env: { ...process.env, NODE: process.env.NODE ?? process.execPath, + PYTHONPATH: [join(root, 'python'), process.env.PYTHONPATH].filter(Boolean).join(process.platform === 'win32' ? ';' : ':'), + TEST_REDIS_URL: url, TEST_REDIS_CLUSTER_URL: clusterUrl, + DIALCACHE_TEST_CLUSTER_ISOLATED: isolated ? '1' : '0' }, + }); + const xml = readFileSync(report, 'utf8'); + if (/\bskipped="[1-9]\d*"/.test(xml)) throw new Error(`${label}: skipped integration cases earn no acceptance credit`); +} + +try { + const external = [process.env.TEST_REDIS_URL, process.env.TEST_VALKEY_URL, process.env.TEST_REDIS_CLUSTER_URL]; + if (external.some(Boolean) && !external.every(Boolean)) { + throw new Error('Supply all TEST_REDIS_URL, TEST_VALKEY_URL and TEST_REDIS_CLUSTER_URL, or none to use disposable Docker servers'); + } + let [redisUrl, valkeyUrl, clusterUrl] = external; + if (!redisUrl) { + console.log('Provisioning disposable Redis 6.2, Valkey 8 and Redis 7 Cluster containers'); + redisUrl = await standalone('redis:6.2-alpine', 'redis'); + valkeyUrl = await standalone('valkey/valkey:8-alpine', 'valkey'); + clusterUrl = await cluster(); + } + await testServer('Redis', redisUrl, clusterUrl, !external[0]); + await testServer('Valkey', valkeyUrl, clusterUrl, !external[0]); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = interrupted ? 130 : 1; +} finally { + for (const name of names.reverse()) await run(docker, ['rm', '--force', name], { capture: true, allowFailure: true }); +} diff --git a/formal/run-python-replay.mjs b/formal/run-python-replay.mjs new file mode 100644 index 00000000..c1beb535 --- /dev/null +++ b/formal/run-python-replay.mjs @@ -0,0 +1,15 @@ +// Python behavior replay deliberately uses the existing coordinator and corpus. +// Node is a test-tool dependency; the Python runtime package never invokes it. +import { spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const python = process.env.PYTHON ?? resolve(root, 'python/.venv/bin/python'); +const result = spawnSync(python, [resolve(root, 'python/tests/run_conformance.py'), ...process.argv.slice(2)], { + cwd: root, + stdio: 'inherit', + env: { ...process.env, NODE: process.execPath }, +}); +if (result.error) throw result.error; +process.exit(result.status ?? 1); diff --git a/formal/source-audit.json b/formal/source-audit.json index df8707da..8e77a1d5 100644 --- a/formal/source-audit.json +++ b/formal/source-audit.json @@ -3,53 +3,53 @@ "sources": [ { "path": "README.md", - "sha256": "c57210b637682308d7d5d16a01cdaa768ecf4a1949793d6cff3aa20e0836c978", + "sha256": "d6b83f3b358412756ead98afa0707e254d1aa0065090c6e8fd1ad613cd50bda4", "entries": [ {"line":1,"title":"DialCache","contracts":["B01"]}, - {"line":30,"title":"Install","contracts":["B01"]}, - {"line":43,"title":"Usage","contracts":["C01","C05","W01"]}, - {"line":87,"title":"How reads work","contracts":["C05","C31"]}, - {"line":111,"title":"Runtime control","contracts":["C16","C18"]}, - {"line":130,"title":"Documentation","contracts":["B01"]} + {"line":32,"title":"Install","contracts":["B01"]}, + {"line":45,"title":"Usage","contracts":["C01","C05","W01"]}, + {"line":89,"title":"How reads work","contracts":["C05","C31"]}, + {"line":113,"title":"Runtime control","contracts":["C16","C18"]}, + {"line":132,"title":"Documentation","contracts":["B01"]} ] }, { "path": "docs/api.md", - "sha256": "9116750dd7fa64277ff78673f8963ecb6dac0f864408aa2d1aa2e527e0619e42", + "sha256": "04683617993432aea655d42d7fac51c2e9b3553d4f8c5535f1cc1a5e5e0ce56e", "entries": [ {"line":5,"title":"API reference","contracts":["B01"]}, - {"line":46,"title":"Imports","contracts":["B01"]}, - {"line":60,"title":"Constructor","contracts":["B01"]}, - {"line":81,"title":"`RedisConfig`","contracts":["C23","E01","B01"]}, - {"line":96,"title":"Scope methods","contracts":["C01","C02","C03","B01"]}, - {"line":117,"title":"`cached`","contracts":["B01"]}, - {"line":134,"title":"`getOrLoad`","contracts":["C11","C18","B01"]}, - {"line":153,"title":"Operation options","contracts":["C18","C23","C42","B01"]}, - {"line":181,"title":"`DialCacheKeyConfig`","contracts":["C16","B01"]}, - {"line":210,"title":"Validation and snapshots","contracts":["C18","C20","B01"]}, - {"line":245,"title":"`invalidateRemote`","contracts":["C29","C32","C39","B01"]}, - {"line":261,"title":"`getCoalescingState`","contracts":["X01","B01"]}, - {"line":278,"title":"Keys and serializers","contracts":["W01","W02","B03"]}, - {"line":295,"title":"Constructing keys directly","contracts":["W01","B01"]}, - {"line":339,"title":"Errors","contracts":["C27","B01"]}, - {"line":357,"title":"Custom integrations","contracts":["E01","B01","X01"]} + {"line":56,"title":"Imports","contracts":["B01"]}, + {"line":70,"title":"Constructor","contracts":["B01"]}, + {"line":91,"title":"`RedisConfig`","contracts":["C23","E01","B01"]}, + {"line":106,"title":"Scope methods","contracts":["C01","C02","C03","B01"]}, + {"line":127,"title":"`cached`","contracts":["B01"]}, + {"line":144,"title":"`getOrLoad`","contracts":["C11","C18","B01"]}, + {"line":163,"title":"Operation options","contracts":["C18","C23","C42","B01"]}, + {"line":191,"title":"`DialCacheKeyConfig`","contracts":["C16","B01"]}, + {"line":220,"title":"Validation and snapshots","contracts":["C18","C20","B01"]}, + {"line":255,"title":"`invalidateRemote`","contracts":["C29","C32","C39","B01"]}, + {"line":271,"title":"`getCoalescingState`","contracts":["X01","B01"]}, + {"line":288,"title":"Keys and serializers","contracts":["W01","W02","B03"]}, + {"line":305,"title":"Constructing keys directly","contracts":["W01","B01"]}, + {"line":349,"title":"Errors","contracts":["C27","B01"]}, + {"line":367,"title":"Custom integrations","contracts":["E01","B01","X01"]} ] }, { "path": "docs/authoring.md", - "sha256": "c35d73efa5ce269767b7d604110ac279e15c45e268eba34219a2d43ff3ca65e5", + "sha256": "a30e9329344cc3912f3454ed6fa28a53682c8feb2c89089045e2211d701f16b4", "entries": [ {"line":1,"title":"Writing shared documentation","contracts":["B01"]}, {"line":8,"title":"Change a feature guide","contracts":["B01"]}, {"line":21,"title":"Import tested examples","contracts":["B01"]}, - {"line":60,"title":"Run the checks","contracts":["B01"]}, - {"line":98,"title":"Generated references and evidence","contracts":["B01"]}, - {"line":112,"title":"Add a port","contracts":["B01"]} + {"line":67,"title":"Run the checks","contracts":["B01"]}, + {"line":112,"title":"Generated references and evidence","contracts":["B01"]}, + {"line":128,"title":"Add a port","contracts":["B01"]} ] }, { "path": "docs/coalescing.md", - "sha256": "da42893a0cd6bf938821fa31bf513a46e971a4f34afe23d94813da98da87ab8e", + "sha256": "cc3c776553de55a2c384c747f7804556e3d0196b3fde53a18cd628cec968556e", "entries": [ {"line":1,"title":"Coalescing and liveness","contracts":["C11"]}, {"line":20,"title":"Request coalescing","contracts":["C11"]}, @@ -57,110 +57,110 @@ {"line":44,"title":"Process scope","contracts":["C11","C12"]}, {"line":67,"title":"What followers inherit","contracts":["C18","C24"]}, {"line":93,"title":"Per-use-case opt-out","contracts":["C13"]}, - {"line":153,"title":"When calls do not coalesce","contracts":["C01","C14"]}, - {"line":181,"title":"Shadow work does not enable caller coalescing","contracts":["C49"]}, - {"line":201,"title":"Stale recovery shares the flight","contracts":["C43"]}, - {"line":209,"title":"Fallback deadlines","contracts":["C23"]}, - {"line":240,"title":"When the timer runs","contracts":["C23","C26"]}, - {"line":255,"title":"Application-owned budgets","contracts":["C26","E03"]}, - {"line":274,"title":"Event-loop behavior","contracts":["C25","B02"]}, - {"line":309,"title":"Timeout does not cancel the source","contracts":["C25"]}, - {"line":336,"title":"Shadow deadlines are separate","contracts":["C54"]}, - {"line":356,"title":"Inspecting process-scoped flights","contracts":["X01"]}, - {"line":398,"title":"Admission control remains application-owned","contracts":["E03","X02"]} + {"line":160,"title":"When calls do not coalesce","contracts":["C01","C14"]}, + {"line":188,"title":"Shadow work does not enable caller coalescing","contracts":["C49"]}, + {"line":208,"title":"Stale recovery shares the flight","contracts":["C43"]}, + {"line":216,"title":"Fallback deadlines","contracts":["C23"]}, + {"line":254,"title":"When the timer runs","contracts":["C23","C26"]}, + {"line":269,"title":"Application-owned budgets","contracts":["C26","E03"]}, + {"line":288,"title":"Event-loop behavior","contracts":["C25","B02"]}, + {"line":332,"title":"Timeout does not cancel the source","contracts":["C25"]}, + {"line":359,"title":"Shadow deadlines are separate","contracts":["C54"]}, + {"line":379,"title":"Inspecting process-scoped flights","contracts":["X01"]}, + {"line":429,"title":"Admission control remains application-owned","contracts":["E03","X02"]} ] }, { "path": "docs/concepts.md", - "sha256": "d243554cf4cd78d0dd0d214060d5af0c56b794fd834199ff8f020d3d94f05097", + "sha256": "f8c042f4a7f446a3305563730062fd048d70c5424a38a3c71bc5cfa2627b5271", "entries": [ {"line":1,"title":"How DialCache works","contracts":["C01"]}, {"line":9,"title":"Identity governs reuse","contracts":["W01","E04"]}, {"line":22,"title":"The read path","contracts":["C05","C11"]}, {"line":50,"title":"Enable and disable scopes","contracts":["C01","C02","C03"]}, - {"line":92,"title":"Three lifetimes","contracts":["C07","C08"]}, - {"line":104,"title":"Request-local cache","contracts":["C06","C07"]}, - {"line":111,"title":"Process-local cache","contracts":["C08","C09"]}, - {"line":122,"title":"What gets stored after a miss?","contracts":["C05","C31"]}, - {"line":145,"title":"Freshness boundaries","contracts":["C09","C22","C36"]}, - {"line":168,"title":"Fail-open and liveness","contracts":["C23","C25","C27","C28"]}, - {"line":185,"title":"Value ownership","contracts":["E04","B03"]}, - {"line":217,"title":"Where to go next","contracts":["B01"]} + {"line":101,"title":"Three lifetimes","contracts":["C07","C08"]}, + {"line":113,"title":"Request-local cache","contracts":["C06","C07"]}, + {"line":120,"title":"Process-local cache","contracts":["C08","C09"]}, + {"line":131,"title":"What gets stored after a miss?","contracts":["C05","C31"]}, + {"line":154,"title":"Freshness boundaries","contracts":["C09","C22","C36"]}, + {"line":177,"title":"Fail-open and liveness","contracts":["C23","C25","C27","C28"]}, + {"line":194,"title":"Value ownership","contracts":["E04","B03"]}, + {"line":234,"title":"Where to go next","contracts":["B01"]} ] }, { "path": "docs/configuration.md", - "sha256": "b1c46085d6874f132bdab3948eea7736014c0c95d90d4a619f7d1450d2552e3a", + "sha256": "2abc0f2984a36fcc132b66e89485302182a8bba570963998728876d3b66ad195", "entries": [ {"line":1,"title":"Configuration and rollout","contracts":["C16"]}, {"line":11,"title":"What belongs where","contracts":["C16","B01"]}, {"line":26,"title":"Baseline and overlay precedence","contracts":["C16"]}, - {"line":106,"title":"Turning features off","contracts":["C17"]}, - {"line":125,"title":"Stable key cohorts","contracts":["W03"]}, - {"line":149,"title":"Changing policy on a running service","contracts":["C18","C22"]}, - {"line":174,"title":"Provider behavior","contracts":["C20","C21"]}, - {"line":186,"title":"Deadlines","contracts":["C23"]}, - {"line":198,"title":"Related reference","contracts":["B01"]} + {"line":125,"title":"Turning features off","contracts":["C17"]}, + {"line":144,"title":"Stable key cohorts","contracts":["W03"]}, + {"line":168,"title":"Changing policy on a running service","contracts":["C18","C22"]}, + {"line":193,"title":"Provider behavior","contracts":["C20","C21"]}, + {"line":205,"title":"Deadlines","contracts":["C23"]}, + {"line":217,"title":"Related reference","contracts":["B01"]} ] }, { "path": "docs/getting-started.md", - "sha256": "6398f11ab7014788c16d556d1a35e61a6a8cff90b57d80227af43ddbc776064d", + "sha256": "9c700fba3d6385a5d19d856d4f7723d8b7afb5d3a4789fc15b5ee1ff656d312f", "entries": [ {"line":1,"title":"Getting started","contracts":["B01"]}, {"line":13,"title":"Install","contracts":["B01"]}, - {"line":56,"title":"Wrap a reader","contracts":["C01","C05","W01"]}, - {"line":101,"title":"Choose the enabled scope","contracts":["C01","C02"]}, - {"line":139,"title":"Keep a calculation inline","contracts":["C11","B01"]}, - {"line":166,"title":"Introduce runtime policy","contracts":["C16","C18"]}, - {"line":178,"title":"Add shared caching when needed","contracts":["E01"]} + {"line":69,"title":"Wrap a reader","contracts":["C01","C05","W01"]}, + {"line":122,"title":"Choose the enabled scope","contracts":["C01","C02"]}, + {"line":169,"title":"Keep a calculation inline","contracts":["C11","B01"]}, + {"line":204,"title":"Introduce runtime policy","contracts":["C16","C18"]}, + {"line":216,"title":"Add shared caching when needed","contracts":["E01"]} ] }, { "path": "docs/index.md", - "sha256": "186d51bc831587d99f757d4defdfdf7871b55814484c523b32a2e8e878f46c52", + "sha256": "c4df3d15901fcf38af024f2092d9b3b3edaaf36707775bdac13d3deb2cf5c871", "entries": [ {"line":1,"title":"DialCache documentation","contracts":["B01"]}, {"line":11,"title":"Learn the model","contracts":["B01"]}, {"line":21,"title":"Feature guides","contracts":["B01"]}, {"line":32,"title":"Reference and integrations","contracts":["B01"]}, {"line":42,"title":"Language guides","contracts":["B01"]}, - {"line":54,"title":"Find an answer","contracts":["B01"]} + {"line":55,"title":"Find an answer","contracts":["B01"]} ] }, { "path": "docs/invalidation.md", - "sha256": "50e7285a64dcd2ab7eda3da84935fc92213274c0ac7049355e041946dabf31f4", + "sha256": "20b355bf5821b586a9d82209bd2b8d2469f8e1b609dec1e96d770d7d01652c37", "entries": [ {"line":1,"title":"Targeted invalidation","contracts":["C32"]}, {"line":10,"title":"Watermarks: an entity-wide cutoff","contracts":["C32","C33"]}, {"line":53,"title":"Configure a tracked use case","contracts":["C31","B01"]}, - {"line":119,"title":"Choosing `futureBufferMs`","contracts":["E02","E03"]}, - {"line":147,"title":"Reuse boundaries","contracts":["C36"]}, - {"line":159,"title":"Independent fence checks","contracts":["C34","C35"]}, - {"line":175,"title":"Application clock contract","contracts":["E03"]}, - {"line":190,"title":"Watermark durability","contracts":["E02"]}, - {"line":201,"title":"Protocol reference","contracts":["W09"]}, - {"line":203,"title":"Read and write behavior","contracts":["C33","C35"]}, - {"line":213,"title":"Conditional refills","contracts":["C34"]}, - {"line":242,"title":"In-memory publication","contracts":["C31","C36"]}, - {"line":253,"title":"Shadow reads and fills","contracts":["C53"]}, - {"line":264,"title":"Identity and Redis Cluster placement","contracts":["W01"]}, - {"line":289,"title":"Watermark lifetime","contracts":["C37","C38","C39"]}, - {"line":317,"title":"Failure behavior and telemetry","contracts":["C29","C58"]} + {"line":137,"title":"Choosing `futureBufferMs`","contracts":["E02","E03"]}, + {"line":165,"title":"Reuse boundaries","contracts":["C36"]}, + {"line":177,"title":"Independent fence checks","contracts":["C34","C35"]}, + {"line":193,"title":"Application clock contract","contracts":["E03"]}, + {"line":208,"title":"Watermark durability","contracts":["E02"]}, + {"line":219,"title":"Protocol reference","contracts":["W09"]}, + {"line":221,"title":"Read and write behavior","contracts":["C33","C35"]}, + {"line":231,"title":"Conditional refills","contracts":["C34"]}, + {"line":260,"title":"In-memory publication","contracts":["C31","C36"]}, + {"line":271,"title":"Shadow reads and fills","contracts":["C53"]}, + {"line":282,"title":"Identity and Redis Cluster placement","contracts":["W01"]}, + {"line":307,"title":"Watermark lifetime","contracts":["C37","C38","C39"]}, + {"line":335,"title":"Failure behavior and telemetry","contracts":["C29","C58"]} ] }, { "path": "docs/keys.md", - "sha256": "6e59640019afabfbff89b2aa0daaf8c5b57e9484f3b2ad903db0d1c4ae22d342", + "sha256": "9848b0713587794f0fb7f92c7143b1fae50fc2684242c0cdeb6c97804f8fb24f", "entries": [ {"line":1,"title":"Keys and identity","contracts":["W01"]}, {"line":14,"title":"Anatomy of a key","contracts":["W01"]}, {"line":42,"title":"Define a result identity","contracts":["W01","E04"]}, - {"line":110,"title":"Normalization and encoding","contracts":["W02"]}, - {"line":162,"title":"Namespace","contracts":["W01"]}, - {"line":169,"title":"Changing a namespace","contracts":["W01","E05"]}, - {"line":186,"title":"The key passed to runtime policy","contracts":["C16","B01"]} + {"line":120,"title":"Normalization and encoding","contracts":["W02"]}, + {"line":181,"title":"Namespace","contracts":["W01"]}, + {"line":188,"title":"Changing a namespace","contracts":["W01","E05"]}, + {"line":205,"title":"The key passed to runtime policy","contracts":["C16","B01"]} ] }, { @@ -176,6 +176,19 @@ {"line":90,"title":"Conformance","contracts":["B01"]} ] }, + { + "path": "docs/languages/python.md", + "sha256": "d9734d563424e2ce18b45cae562df9a72ad0d9d309b2c057c9f45415d1039fb9", + "entries": [ + {"line":1,"title":"Python integration","contracts":["B01"]}, + {"line":10,"title":"Installation and runtime","contracts":["B01","B02"]}, + {"line":23,"title":"Request scope and operations","contracts":["C01","C02","C03","B01","B02"]}, + {"line":40,"title":"Identity and policy","contracts":["W01","W02","C16","C18","C20","B01"]}, + {"line":63,"title":"Values and cancellation","contracts":["B03","C25","C26","E04","B02"]}, + {"line":81,"title":"Redis and observability","contracts":["C29","C32","W04","E01","X01","B01"]}, + {"line":96,"title":"Validation","contracts":["B01"]} + ] + }, { "path": "docs/languages/rust.md", "sha256": "c79462b736c463722d5fe1769650a4de6af751f2eceac649d3537e9eae585cc4", @@ -203,112 +216,112 @@ }, { "path": "docs/maintainers.md", - "sha256": "ef9c0a2efe6ab64a40078fe6e19a1f4dd4d381189e9188268d66b3c3f7ee008f", + "sha256": "e7b388b0b90ca6fb5d5f316211235b143d85b5201e82571b7b6a7aba6586926a", "entries": [ {"line":1,"title":"Maintainer guide","contracts":["B01"]}, {"line":8,"title":"Validation","contracts":["B01"]}, - {"line":61,"title":"Maintaining the reference","contracts":["E05","B01"]}, - {"line":85,"title":"Run the documentation site","contracts":["B01"]}, - {"line":112,"title":"Publish to GitHub Pages","contracts":["B01"]}, - {"line":128,"title":"Cache-path benchmark","contracts":["X02"]}, - {"line":150,"title":"Redis write benchmark","contracts":["X02"]}, - {"line":173,"title":"Stale-on-error benchmark","contracts":["X02"]}, - {"line":201,"title":"Releasing","contracts":["B01","E05"]} + {"line":64,"title":"Maintaining the reference","contracts":["E05","B01"]}, + {"line":88,"title":"Run the documentation site","contracts":["B01"]}, + {"line":115,"title":"Publish to GitHub Pages","contracts":["B01"]}, + {"line":131,"title":"Cache-path benchmark","contracts":["X02"]}, + {"line":153,"title":"Redis write benchmark","contracts":["X02"]}, + {"line":176,"title":"Stale-on-error benchmark","contracts":["X02"]}, + {"line":204,"title":"Releasing","contracts":["B01","E05"]} ] }, { "path": "docs/observability.md", - "sha256": "909d500472340c70fc3a2ae4ffc01ce59da63dc232606b87c77d5985435978d5", + "sha256": "bdafd62305526062fe913fada398d9356c4d081b6c2f1c2744b1cc7454bbe97c", "entries": [ {"line":1,"title":"Observability","contracts":["C30","X01"]}, {"line":16,"title":"Reading the signals","contracts":["C58"]}, {"line":37,"title":"Miss reasons","contracts":["C55","C58"]}, {"line":53,"title":"Prometheus","contracts":["X01"]}, - {"line":127,"title":"Histogram buckets","contracts":["X01"]}, - {"line":139,"title":"Prometheus metrics","contracts":["X01"]}, - {"line":143,"title":"Datadog","contracts":["X01"]}, - {"line":223,"title":"Distribution or histogram","contracts":["X01"]}, - {"line":243,"title":"Datadog namespaces","contracts":["X01"]}, - {"line":281,"title":"Datadog metrics","contracts":["X01"]}, - {"line":337,"title":"Metric catalog","contracts":["C58","C59"]}, - {"line":390,"title":"Shadow outcomes","contracts":["C50","C51","C52","C53","C54"]}, - {"line":418,"title":"Stale recovery outcomes","contracts":["C41","C45"]}, - {"line":434,"title":"Value ages and clock offsets","contracts":["C57"]}, - {"line":456,"title":"Compression metrics","contracts":["W06","W07","C58","C59","X02"]}, - {"line":512,"title":"Confirmed mismatch warnings","contracts":["C60","X01"]}, - {"line":529,"title":"Error categories","contracts":["C58"]}, - {"line":579,"title":"Custom adapters","contracts":["C30","X01"]} + {"line":135,"title":"Histogram buckets","contracts":["X01"]}, + {"line":147,"title":"Prometheus metrics","contracts":["X01"]}, + {"line":151,"title":"Datadog","contracts":["X01"]}, + {"line":239,"title":"Distribution or histogram","contracts":["X01"]}, + {"line":259,"title":"Datadog namespaces","contracts":["X01"]}, + {"line":304,"title":"Datadog metrics","contracts":["X01"]}, + {"line":367,"title":"Metric catalog","contracts":["C58","C59"]}, + {"line":420,"title":"Shadow outcomes","contracts":["C50","C51","C52","C53","C54"]}, + {"line":448,"title":"Stale recovery outcomes","contracts":["C41","C45"]}, + {"line":464,"title":"Value ages and clock offsets","contracts":["C57"]}, + {"line":486,"title":"Compression metrics","contracts":["W06","W07","C58","C59","X02"]}, + {"line":542,"title":"Confirmed mismatch warnings","contracts":["C60","X01"]}, + {"line":559,"title":"Error categories","contracts":["C58"]}, + {"line":609,"title":"Custom adapters","contracts":["C30","X01"]} ] }, { "path": "docs/redis.md", - "sha256": "c059a113b938dc349b190553ba072b6170b0e242cdd5c0e1b259c1dacad42b25", + "sha256": "0bc133a4897f509b07953682ecb6017a785a6fa4cfee93adbae88ee6cb47864f", "entries": [ {"line":1,"title":"Redis and Valkey","contracts":["E01"]}, {"line":14,"title":"Install a client","contracts":["B01","E01"]}, - {"line":166,"title":"Remote-read deadlines and async liveness","contracts":["C23","C56"]}, - {"line":208,"title":"Lifecycle ownership","contracts":["E03","X02"]}, - {"line":245,"title":"Serialization","contracts":["W06","B03"]}, - {"line":254,"title":"Default JSON behavior","contracts":["B03","C27","C45","C52"]}, - {"line":371,"title":"Compression","contracts":["W06","W07","W08","X02"]}, - {"line":448,"title":"Bundled Redis operations","contracts":["E01"]}, - {"line":450,"title":"Reads","contracts":["E01","W04"]}, - {"line":474,"title":"Writes","contracts":["E01","W04","W05"]}, - {"line":498,"title":"Invalidation retries and ambiguity","contracts":["E01","E03"]}, - {"line":533,"title":"Redis compatibility and ACLs","contracts":["E01"]}, - {"line":544,"title":"Custom-client contract","contracts":["C55","C56","E01","E04"]}, - {"line":622,"title":"Advanced wire protocol","contracts":["W04"]}, - {"line":669,"title":"Read decoding and validation order","contracts":["W04"]}, - {"line":743,"title":"Invalidation script and payload envelope","contracts":["W09","W06"]} + {"line":179,"title":"Remote-read deadlines and async liveness","contracts":["C23","C56"]}, + {"line":229,"title":"Lifecycle ownership","contracts":["E03","X02"]}, + {"line":274,"title":"Serialization","contracts":["W06","B03"]}, + {"line":283,"title":"Default JSON behavior","contracts":["B03","C27","C45","C52"]}, + {"line":410,"title":"Compression","contracts":["W06","W07","W08","X02"]}, + {"line":504,"title":"Bundled Redis operations","contracts":["E01"]}, + {"line":506,"title":"Reads","contracts":["E01","W04"]}, + {"line":530,"title":"Writes","contracts":["E01","W04","W05"]}, + {"line":554,"title":"Invalidation retries and ambiguity","contracts":["E01","E03"]}, + {"line":597,"title":"Redis compatibility and ACLs","contracts":["E01"]}, + {"line":608,"title":"Custom-client contract","contracts":["C55","C56","E01","E04"]}, + {"line":695,"title":"Advanced wire protocol","contracts":["W04"]}, + {"line":750,"title":"Read decoding and validation order","contracts":["W04"]}, + {"line":839,"title":"Invalidation script and payload envelope","contracts":["W09","W06"]} ] }, { "path": "docs/shadow-validation.md", - "sha256": "c38dfa6cb65d72921202f83c723723cbeb328d2b674437c26a7a34dbd9e5ff24", + "sha256": "98c932e4c75653c132d8a2408c3c8d64e4f498f88d01b56edae59a9e8c10d473", "entries": [ {"line":1,"title":"Shadow validation","contracts":["C47"]}, {"line":15,"title":"What a check establishes","contracts":["C50","C51"]}, {"line":28,"title":"Caller paths","contracts":["C48","C49"]}, {"line":40,"title":"Configure a shadow cohort","contracts":["W03","C47"]}, - {"line":105,"title":"Eligibility","contracts":["C47"]}, - {"line":123,"title":"Serving-hit and ramped-down paths","contracts":["C48","C49"]}, - {"line":137,"title":"The `C0` / `S` / `C1` algorithm","contracts":["C50","C51"]}, - {"line":151,"title":"Clean-miss fill","contracts":["C52","C53"]}, - {"line":176,"title":"Comparison and confirmation","contracts":["C50","C51"]}, - {"line":195,"title":"Comparison semantics","contracts":["C50","B03"]}, - {"line":254,"title":"Data ownership and custom integrations","contracts":["E04"]}, - {"line":267,"title":"Capacity, deadlines, and detachment","contracts":["C54","E03"]}, - {"line":313,"title":"Consistency modes and race boundaries","contracts":["C36","C51"]}, - {"line":329,"title":"Command amplification","contracts":["C49","C51","C52"]}, - {"line":342,"title":"Confirmed mismatch logging","contracts":["C60","B03","X01"]}, - {"line":392,"title":"Metrics and shutdown","contracts":["C57","C58","C59","E03"]} + {"line":113,"title":"Eligibility","contracts":["C47"]}, + {"line":131,"title":"Serving-hit and ramped-down paths","contracts":["C48","C49"]}, + {"line":145,"title":"The `C0` / `S` / `C1` algorithm","contracts":["C50","C51"]}, + {"line":159,"title":"Clean-miss fill","contracts":["C52","C53"]}, + {"line":184,"title":"Comparison and confirmation","contracts":["C50","C51"]}, + {"line":203,"title":"Comparison semantics","contracts":["C50","B03"]}, + {"line":271,"title":"Data ownership and custom integrations","contracts":["E04"]}, + {"line":284,"title":"Capacity, deadlines, and detachment","contracts":["C54","E03"]}, + {"line":338,"title":"Consistency modes and race boundaries","contracts":["C36","C51"]}, + {"line":354,"title":"Command amplification","contracts":["C49","C51","C52"]}, + {"line":367,"title":"Confirmed mismatch logging","contracts":["C60","B03","X01"]}, + {"line":426,"title":"Metrics and shutdown","contracts":["C57","C58","C59","E03"]} ] }, { "path": "docs/stale-on-error.md", - "sha256": "b08303ad0a0c2943e53841a64b2d27979d6ef9b8b1fced705e6e00ac47f053ac", + "sha256": "c8309c1502169d15ec960014e9f9fa7201a0968ad18eac7bcea46626d0a56a4f", "entries": [ {"line":1,"title":"Stale-on-error","contracts":["C40"]}, {"line":14,"title":"Fresh age and maximum age","contracts":["C40"]}, {"line":31,"title":"Configure the ages","contracts":["C37","C40"]}, - {"line":65,"title":"Follow one invocation","contracts":["C41","C43","C45"]}, - {"line":89,"title":"Choose which errors permit recovery","contracts":["C42"]}, - {"line":159,"title":"Snapshot and invalidation boundaries","contracts":["C44"]}, - {"line":176,"title":"Retention, clocks, and memory","contracts":["C37","E03","E04"]}, - {"line":199,"title":"Observability","contracts":["C57","C58"]} + {"line":73,"title":"Follow one invocation","contracts":["C41","C43","C45"]}, + {"line":97,"title":"Choose which errors permit recovery","contracts":["C42"]}, + {"line":182,"title":"Snapshot and invalidation boundaries","contracts":["C44"]}, + {"line":199,"title":"Retention, clocks, and memory","contracts":["C37","E03","E04"]}, + {"line":222,"title":"Observability","contracts":["C57","C58"]} ] }, { "path": "docs/upgrading.md", - "sha256": "ac71a12ac4a237b14d1eba389d127ac04eb0c3b3dbeabc2803a8811c40f6cdc7", + "sha256": "a02cb92599073440b0bc6f693b064236759dced02d578591934830fc4c9ff425", "entries": [ {"line":1,"title":"Upgrading","contracts":["E05"]}, {"line":13,"title":"Tracked protocol cutover","contracts":["E05","W09"]}, {"line":47,"title":"Removed configuration fields","contracts":["B01"]}, - {"line":85,"title":"Custom Redis adapters","contracts":["E01","C55"]}, - {"line":141,"title":"Stale retention and downgrades","contracts":["E05","C37"]}, - {"line":160,"title":"Compression and value schemas","contracts":["E05","W06","W08"]}, - {"line":190,"title":"Metric migrations","contracts":["X01"]} + {"line":93,"title":"Custom Redis adapters","contracts":["E01","C55"]}, + {"line":158,"title":"Stale retention and downgrades","contracts":["E05","C37"]}, + {"line":177,"title":"Compression and value schemas","contracts":["E05","W06","W08"]}, + {"line":207,"title":"Metric migrations","contracts":["X01"]} ] }, { diff --git a/formal/validation.mjs b/formal/validation.mjs index bd0c5376..5db47784 100644 --- a/formal/validation.mjs +++ b/formal/validation.mjs @@ -8,26 +8,28 @@ const root = fileURLToPath(new URL('../', import.meta.url)); const replayTests = ['test/formal-conformance.test.ts', 'test/formal-effects.test.ts', 'test/formal-features.test.ts', 'test/formal-local-clock.test.ts', 'test/formal-behavior.test.ts', 'test/formal-protocol-vectors.test.ts']; const aggregateTargets = { - check: ['check-ts', 'check-go', 'check-rust', 'docs', 'audit'], - formal: ['formal-check', 'formal-generate', 'formal-ts', 'formal-go', 'formal-rust'], + check: ['check-ts', 'check-go', 'check-rust', 'check-python', 'docs', 'audit'], + formal: ['formal-check', 'formal-generate', 'formal-ts', 'formal-go', 'formal-rust', 'formal-python'], mutations: ['mutations-ts', 'mutations-go', 'mutations-rust'], - integration: ['integration-ts', 'integration-go', 'integration-rust'], + integration: ['integration-ts', 'integration-go', 'integration-rust', 'integration-python'], ci: ['check', 'package-floor', 'formal', 'model-check', 'integration', 'mutations'], }; export const targetDescriptions = { - check: 'TypeScript, Go and Rust checks, docs build and reviewed inventories; no Quint generation or Docker', + check: 'TypeScript, Go, Rust and Python checks, docs build and reviewed inventories; no Quint generation or Docker', 'check-ts': 'Typecheck, unit coverage, build and packed-package checks on Node 24', 'check-go': 'Go vet, formatting check and default tests with race detection', 'check-rust': 'Rust formatting check, clippy with warnings denied and default tests including the smoke conformance run', - docs: 'Check shared examples and links; generate native API references and the documentation site (Go and Rust required)', + 'check-python': 'Python native, protocol, scenario and committed smoke tests, excluding real Redis integrations', + docs: 'Check shared examples and links; generate native API references and the documentation site (Go, Rust and Python required)', audit: 'Check source, behavior, feature, Go and generated-fixture freshness inventories', - smoke: 'Replay committed Quint-derived fixtures in TypeScript, Go and Rust; no full completion claim', - formal: 'Check every scheduled Quint model, generate the complete corpus and shared witness evidence, then complete TypeScript, Go and Rust replay', + smoke: 'Replay committed Quint-derived fixtures in TypeScript, Go, Rust and Python; no full completion claim', + formal: 'Check every scheduled Quint model, generate the complete corpus and shared witness evidence, then replay TypeScript, Go, Rust and Python', 'formal-check': 'Typecheck and run every scheduled Quint model, its public regressions and the model mutation challenges', 'formal-generate': 'Generate/recompute artifacts and evaluate shared witness evidence over the complete corpus', 'formal-ts': 'Complete prepared TypeScript replay of the generated corpus', 'formal-go': 'Complete prepared Go replay of the generated corpus with race detection', 'formal-rust': 'Complete prepared Rust replay of the generated corpus in release mode', + 'formal-python': 'Replay the complete generated Python corpus, scenarios, protocol obligations and witnesses', 'fixtures-check': 'Recompute every committed model-derived artifact with pinned Quint', 'kernel-fixtures': 'Typecheck the kernel library fixtures (test/fixtures/kernel) and run every run they declare', differential: 'Check the composition lint baseline, then replay every composed profile against its reference corpus (merge base with DIFFERENTIAL_REFERENCE, default origin/main) in both directions (DIFFERENTIAL_SHARD=/ replays one shard balanced by estimated profile replay time, as the hosted lane does with four)', @@ -40,10 +42,11 @@ export const targetDescriptions = { 'mutations-merge-ts': 'Merge TypeScript mutation shards into the complete report; refuses inconsistent or missing shards', 'mutations-merge-go': 'Merge Go mutation shards into the complete report; refuses inconsistent or missing shards', 'mutations-merge-rust': 'Merge Rust mutation shards into the complete report; refuses inconsistent or missing shards', - integration: 'Run real TypeScript, Go and Rust Redis/Valkey/Cluster integration checks', + integration: 'Run real TypeScript, Go, Rust and Python Redis/Valkey/Cluster integration checks', 'integration-ts': 'Run TypeScript real integration checks', 'integration-go': 'Run Go real integration and interoperability checks with race detection', 'integration-rust': 'Run Rust real Redis/Valkey/Cluster integration checks and invalidation vector replay', + 'integration-python': 'Run Python real Redis/Valkey/Cluster integration checks, invalidation vectors and TypeScript interoperability', 'package-floor': 'Check zstd and the packed package on exact Node 22.15.0 (NODE22_BIN)', ci: 'Run check, package-floor, formal, model-check, integration and mutations in dependency order', }; @@ -124,6 +127,8 @@ export function validationPlan(target, { directory = root, environment = process // Cargo runs inside rust/ so rustup resolves rust/rust-toolchain.toml; the // crate's tests locate the repository through CARGO_MANIFEST_DIR, not cwd. const cargo = (label, subcommand, ...args) => ({ label, command: 'cargo', args: [subcommand, ...args], cwd: 'rust' }); + const pythonExecutable = environment.PYTHON ?? resolve(directory, 'python/.venv/bin/python'); + const python = (label, ...args) => ({ label, command: pythonExecutable, args, env: { NODE: runnerNode } }); const reportPath = (language, suffix) => `.formal-traces/${language}-${suffix}.json`; const completion = language => ({ ...node(`Validate current ${language} completion`, 'formal/conformance.mjs', 'check', reportPath(language, 'completion'), reportPath(language, 'context')), failureHint: 'A current complete replay is required. Run make formal first; missing or stale reports cannot be reused.' }); @@ -174,12 +179,14 @@ export function validationPlan(target, { directory = root, environment = process 'check-rust': [cargo('Check Rust formatting', 'fmt', '--check'), cargo('Lint Rust with clippy', 'clippy', '--all-targets', '--all-features', '--', '-D', 'warnings'), cargo('Run Rust default tests', 'test', '--all-features')], + 'check-python': [python('Run Python native, wire, scenario and smoke tests', '-m', 'pytest', 'python/tests', '-m', 'not integration')], docs: [pnpm('Build documentation', 'docs:build')], audit: ['execution', 'check-source-audit', 'check-semantic-coverage', 'check-feature-coverage', 'check-go-parity'] .map(name => node(`Check ${name}`, `formal/${name}.mjs`)) .concat(node('Verify committed fixture fingerprints', 'formal/generated-fixtures.mjs', '--verify'), node('Check conditional fixture regeneration scope', '--test', '.github/scripts/fixture-scope.test.mjs')), - smoke: [tsReplay(false), nativeGo(false), nativeRust(false)], + smoke: [tsReplay(false), nativeGo(false), nativeRust(false), + python('Replay committed Python fixtures and scenarios', '-m', 'pytest', 'python/tests/test_conformance.py')], 'fixtures-check': [node('Recompute all committed Quint artifacts', 'formal/generate-artifacts.mjs', '--check')], 'kernel-fixtures': [kernelFixtures], differential: [lintBaseline, kernelFixtures, node('Replay composed profiles against their reference corpus', 'formal/differential.mjs', '--composed', `--reference=${environment.DIFFERENTIAL_REFERENCE ?? 'origin/main'}`, ...differentialShard)], @@ -193,7 +200,7 @@ export function validationPlan(target, { directory = root, environment = process // Generation is the single shared producer: the corpus, wire artifacts and // witness evidence depend only on the models. Every port replay and mutation // measurement read that output and can run in parallel off it. - 'formal-generate': [invalidate('ts', 'go', 'rust'), + 'formal-generate': [invalidate('ts', 'go', 'rust', 'python'), node('Generate complete corpus and recompute wire artifacts', 'formal/run-models.mjs', 'generate'), node('Recompute committed Quint smoke and witness fixtures', 'formal/generated-fixtures.mjs', '--check'), witnesses], 'formal-ts': [invalidate('ts'), node('Prepare TypeScript execution context', 'formal/conformance.mjs', 'prepare', 'typescript', reportPath('ts', 'context')), @@ -209,6 +216,11 @@ export function validationPlan(target, { directory = root, environment = process node('Prepare Rust execution context', 'formal/conformance.mjs', 'prepare', 'rust', reportPath('rust', 'context')), nativeRust(true), { ...node('Check complete Rust native report', 'formal/check-rust-replay.mjs'), stdoutFile: '.formal-traces/rust-replay-summary.json' }, { ...node('Adapt Rust native assertion report', 'formal/conformance-adapters.mjs', 'rust', '.formal-traces/rust-replay.jsonl', reportPath('rust', 'context')), stdoutFile: reportPath('rust', 'completion') }, completion('rust')], + 'formal-python': [invalidate('python'), + node('Prepare Python execution context', 'formal/conformance.mjs', 'prepare', 'python', reportPath('python', 'context')), + { ...node('Replay complete Python corpus and obligations', 'formal/run-python-replay.mjs', '--generated', '--scenarios', '--complete', '--report', '.formal-traces/python-replay.jsonl'), env: { PYTHON: pythonExecutable } }, + { ...node('Check complete Python native report', 'formal/check-python-replay.mjs'), stdoutFile: '.formal-traces/python-replay-summary.json' }, + { ...node('Adapt Python native assertion report', 'formal/conformance-adapters.mjs', 'python', '.formal-traces/python-replay.jsonl', reportPath('python', 'context')), stdoutFile: reportPath('python', 'completion') }, completion('python')], 'mutations-ts': [node('Measure TypeScript semantic mutations', 'formal/measure-semantics.mjs', ...selection)], 'mutations-go': [node('Measure Go semantic mutations', 'formal/measure-go-semantics.mjs', ...selection)], 'mutations-rust': [node('Measure Rust semantic mutations', 'formal/measure-rust-semantics.mjs', ...selection)], @@ -222,6 +234,7 @@ export function validationPlan(target, { directory = root, environment = process // The Rust integration tests are #[ignore]d, so a plain cargo test reports // them as ignored and never needs Docker; this lane runs exactly them. 'integration-rust': [cargo('Run Rust Redis/Valkey/Cluster integrations', 'test', '--all-features', '--test', 'redis_integration', '--', '--ignored')], + 'integration-python': [{ ...node('Run Python Redis/Valkey/Cluster integrations', 'formal/run-python-integration.mjs'), env: { PYTHON: pythonExecutable } }], 'package-floor': [{ label: 'Require a built package for floor checks', requireFile: 'dist/index.js', failureHint: 'Build first with make check-ts, or run make ci with NODE22_BIN set.' }, { label: 'Check Node 22.15 zstd round trip and output ceiling', command: node22, args: ['--eval', floorSmoke], env: { PATH: floorEnvironment(environment, node22).PATH } }, { label: 'Check packed package on Node 22.15', command: node22, args: ['scripts/test-package.mjs'], env: { PATH: floorEnvironment(environment, node22).PATH } }], @@ -250,6 +263,13 @@ export function checkPrerequisites(target, { directory = root, environment = pro const version = probe('cargo', ['--version'], { directory: resolve(directory, 'rust'), environment }); if (!/^cargo 1\.98\.1(?:\s|$)/.test(version)) throw new Error(`Validation requires cargo 1.98.1; found ${version}. Install Rust 1.98.1 (rustup reads rust/rust-toolchain.toml) and put it on PATH.`); } + if (targets.some(name => ['check-python', 'smoke', 'formal-python', 'integration-python', 'explore', 'docs'].includes(name))) { + const executable = environment.PYTHON ?? resolve(directory, 'python/.venv/bin/python'); + const version = probe(executable, ['--version'], { directory, environment }); + const parsed = /^Python (\d+)\.(\d+)(?:\.|\s|$)/.exec(version); + if (!parsed || Number(parsed[1]) !== 3 || Number(parsed[2]) < 11) throw new Error(`Python validation requires Python 3.11 or later; found ${version}. Set PYTHON or create python/.venv and install './python[test,redis]'.`); + probe(executable, ['-c', 'import dialcache, pytest, pytest_asyncio, jsonschema, zstandard, redis'], { directory, environment }); + } if (targets.some(name => ['formal-check', 'formal-generate', 'fixtures-check', 'explore', 'model-check', 'differential'].includes(name))) { const requiredQuint = JSON.parse(readFileSync(resolve(directory, 'formal/generated-fixtures.lock.json'), 'utf8')).quintVersion; const version = probe('quint', ['--version'], { directory, environment }); @@ -411,7 +431,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { if (target === 'help') { const width = Math.max(...Object.keys(targetDescriptions).map(name => name.length)); console.log(Object.entries(targetDescriptions).map(([name, description]) => `make ${name.padEnd(width)} ${description}`).join('\n')); - console.log('\nPrerequisites: frozen pnpm install; Node 24, pinned pnpm; Go 1.27.1 / cargo 1.98.1 / Docker where required; Quint 0.32.0 for formal-check, formal-generate, fixtures-check, explore and model-check; Java 21 and tar for model-check and ci.'); + console.log('\nPrerequisites: frozen pnpm install; Node 24, pinned pnpm; Go 1.27.1 / cargo 1.98.1 / Python >=3.11 (PYTHON or python/.venv/bin/python, install ./python[test,redis]) / Docker where required; Quint 0.32.0 for formal-check, formal-generate, fixtures-check, explore and model-check; Java 21 and tar for model-check and ci.'); console.log('formal-check is the Quint evidence lane (models, regressions, challenges); the port and mutation lanes read only the formal-generate output and do not wait for it.'); console.log('Sharded mutation runs: MUTATION_SHARD=/ make mutations-ts for every index, matching the workflow matrix, on any machines with the same corpus, then make mutations-merge-ts; the merged report is the only complete evidence.'); console.log('One mutant locally: MUTATION_ONLY=M01,M02 make mutations-ts (or mutations-go / mutations-rust) writes a partial report under partial/ and leaves the complete report alone.'); diff --git a/python/API-DESIGN.md b/python/API-DESIGN.md new file mode 100644 index 00000000..3beb39ca --- /dev/null +++ b/python/API-DESIGN.md @@ -0,0 +1,24 @@ +# Python API design and gcache lineage + +The Python binding was designed after reviewing [rungalileo/gcache at a688049](https://github.com/rungalileo/gcache/tree/a68804986782cb0b7e8b7dc6bfc34c718c177189), including `src/gcache/gcache.py`, `config.py`, `proto_serializer.py`, and the context, layer wrappers, local cache, Redis cache, and event-loop thread implementations under `_internal/`. + +DialCache's [Quint specification](../formal/SPEC.md) and TypeScript implementation define portable behavior. Gcache supplies useful Python interface ideas; its implementation is not a compatible DialCache backend. + +| Gcache interface or behavior | Python DialCache decision | +| --- | --- | +| `with cache.enable(enabled=True)` | Retained, with per-instance context variables and explicit outer-scope lifetime. Also supports `async with` and `disable()`. | +| `@cache.cached(key_type=..., id_arg=...)` | Retained. `id_arg` can be a parameter name or `(name, adapter)` pair. Signature binding includes defaults. | +| `arg_adapters`, `ignore_args`, inferred use case | Retained; inferred names include module and qualified function name. Argument names use the portable UTF-16 ordering and scalar normalization. | +| Direct `aget(key, fallback)` | Retained as a structured-key convenience; `get_or_load` is the primary inline-loader API. | +| `ainvalidate` | Retained as an alias for `invalidate_remote`; missing Redis and failed mutations raise. | +| `GCacheKeyConfig` and per-use-case provider | `Policy` / `DialCacheKeyConfig` use sparse per-leaf inheritance and deterministic per-key cohorts. `Policy.enabled(ttl_sec)` is available. | +| Async `Serializer.dump/load` | Retained; synchronous implementations are also accepted. Default JSON supports the portable top-level `UNDEFINED` value. | +| Synchronous wrapper and background event-loop pool | The binding is asyncio based. Every cached wrapper is awaitable; synchronous loaders execute on the caller's event loop. One cache belongs to one event loop. No implicit threads or client factories are created. | +| Singleton, global namespace, global metrics | Instances own their namespace, request scopes, local capacity, flights and observer. The application owns its Redis client. | +| Pickle / JSON / protobuf envelope choice | DialCache always uses the portable version-1 frame and compression wrapper. A custom serializer can produce text or binary payloads, including protobuf. There is no pickle fallback or gcache envelope compatibility. | +| `aput`, `adelete`, `aflushall` and their synchronous counterparts | These are not part of the existing DialCache public contract and are not added by this port. Tracked invalidation is the explicit maintenance API. | +| Random sampling and per-use-case local TTL cache | Replaced by DialCache's deterministic key cohorts and a bounded per-instance LRU, with expiry captured at each insertion. | + +Disabled calls bypass key selection, argument adaptation, policy resolution, deadlines and coalescing. Redis reads acquire the value and watermark atomically from a primary; writes use one native `SET` of a complete frame. None of these rules are inherited from gcache's implementation. + +The Python API is a binding of the existing behavior, not a migration that reads existing gcache keys or envelopes. Applications sharing entries across ports must use the same namespace, entity identity, use case, argument order and payload schema. diff --git a/python/LICENSE b/python/LICENSE new file mode 100644 index 00000000..89cf9702 --- /dev/null +++ b/python/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Galileo Technologies Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/python/README.md b/python/README.md new file mode 100644 index 00000000..32d5e589 --- /dev/null +++ b/python/README.md @@ -0,0 +1,218 @@ +# DialCache for Python + +An asyncio port of DialCache for Python 3.11 and later. Each use case declares +its identity and policy; an enabled request scope opts into request memoization, +local storage, Redis, and concurrent request sharing. The behavioral contract is +the repository's [portable specification](../formal/SPEC.md). + +This package is developed in this repository and has not been published to PyPI. +Install it from a checkout: + +```sh +python3 -m pip install './python[redis]' +``` + +For local-only caching, omit the `redis` extra. Zstandard is included for the +portable Redis payload format. The application owns the asyncio event loop and +any Redis client connections. + +## A cached function + +```python +from dialcache import DialCache, Policy + +cache = DialCache(namespace="my-service") + + +@cache.cached( + use_case="user-profile", + key_type="user", + id_arg="user_id", + default_config=Policy(ttl_sec={"local": 5}, request_local=True), +) +async def get_profile(user_id: str) -> dict: + return await database.fetch_profile(user_id) + + +async def handle_request(user_id: str) -> dict: + async with cache.enable(): + first = await get_profile(user_id) + second = await get_profile(user_id) # Same request memo. + return second +``` + +Calls outside `enable()` go directly to the source. They do not construct cache +keys, resolve policy, share concurrent work, or apply a DialCache source +deadline. Both `with cache.enable():` and `async with cache.enable():` are valid; +the wrapped function is always awaitable. Synchronous loaders are accepted and +run on the event loop, so use async loaders for blocking I/O. + +Nested enabled scopes share the live outer request memo. A nested +`cache.disable()` temporarily bypasses caching without deleting that memo. +Closing the outer scope clears the memo and prevents late publication. Async +tasks that inherited a scope use pass-through behavior for calls made after +that scope closes. Cache instances keep independent contexts. + +## Policies and runtime changes + +No layer is enabled by default. A positive TTL enables that shared layer, with +a default rollout percentage of 100. Request memoization defaults to false; +concurrent same-key sharing defaults to true. + +```python +policy = Policy( + ttl_sec={"local": 5, "remote": 60}, + ramp={"remote": 25}, + request_local=True, + coalesce=True, + remote_read_timeout_ms=50, + stale_on_error_max_age_sec=120, +) +``` + +TTLs are integer seconds from 1 through 31,536,000. Rollout percentages are +finite numbers from 0 through 100. Sampling is stable per exact key and layer, +using the same cohort algorithm as the TypeScript, Go, and Rust ports. + +Pass a synchronous or asynchronous `policy_provider` to `DialCache` to resolve +runtime settings once per enabled invocation. It receives the structured key +and returns a `Policy`, a mapping, or `None`: + +```python +async def policy_provider(key): + if key.use_case == "user-profile": + return {"ramp": {"remote": 50}} + return None + + +cache = DialCache(policy_provider=policy_provider) +``` + +Runtime replies are sparse: an omitted field inherits the operation default. +A whole reply of `None` inherits the complete operation policy. An explicit +`None` leaf is malformed and cannot silently inherit a valid setting. Python +snake_case names and the shared corpus's camelCase mapping names are accepted. +Policy objects snapshot their input maps so later mutation cannot alter an +already admitted invocation. + +`Policy.disabled()` explicitly disables inherited request memoization, local +and remote serving, recovery, and shadow work. It does not cancel work that +was already admitted or disable explicit invalidation. `Policy.enabled(ttl)` +enables local and remote TTLs; it does not opt into request memoization. + +Invalid static defaults raise `ConfigError` at registration. At runtime, +invalid TTLs or ramps disable their own layer; malformed boolean switches, +read deadlines, containers, or provider failures bypass caching for that +enabled invocation. Optional recovery and shadow failures leave ordinary +serving available. + +## Redis and tracked invalidation + +```python +from redis.asyncio import Redis +from dialcache import DialCache, Policy +from dialcache.redis import RedisAdapter + +client = Redis.from_url( + "redis://localhost:6379", + decode_responses=False, + socket_connect_timeout=0.5, + socket_timeout=0.5, +) +cache = DialCache(redis=RedisAdapter(client)) + + +@cache.cached( + use_case="user-profile", + key_type="user", + id_arg="user_id", + track_for_invalidation=True, + default_config=Policy(ttl_sec={"remote": 60}), +) +async def get_profile(user_id): + return await database.fetch_profile(user_id) + + +async def update_profile(user_id, changes): + await database.update_profile(user_id, changes) + await cache.invalidate_remote("user", user_id) +``` + +The adapter borrows a `redis.asyncio.Redis` or `RedisCluster` client; close it +with `await client.aclose()` when your application shuts down. Configure +finite connection, socket, and retry budgets on the client. Tracked reads +atomically read the value and watermark from a primary, including when a +cluster client otherwise permits replica reads. Keys for one tracked entity +share a Redis Cluster hash tag. + +Each write stores a complete version-1 frame using one native `SET`. A tracked +frame is readable only if its writer timestamp is strictly greater than the +invalidation watermark. Value writes never create or extend watermarks. +Tracked physical value TTLs are capped at one hour. Invalidation raises on +mutation failure; ordinary cache plumbing fails open to the source. + +Local storage is process-local. Remote invalidation does not synchronously +clear already warmed local entries or request memos on any instance. Choose +local TTLs with that explicit consistency limit in mind. + +## Deadlines, recovery, and observability + +The default source deadline is 60,000 ms for enabled calls. The default Redis +read deadline is 50 ms and can be overridden by operation or runtime policy. +Deadline budgets are integer milliseconds from 1 through 2,147,483,647; an +explicit `fallback_timeout_ms=None` disables the source deadline. Timing uses +the monotonic clock, while Redis frames and invalidation use wall time. + +Deadline expiration stops the caller's wait. It cannot retract a source +operation or a Redis command that already started. Late results cannot +publish through an expired source execution. Caller cancellation likewise +must not cancel another caller's shared execution. + +Stale recovery is optional and requires a maximum age strictly greater than +the remote TTL. A valid candidate is retained from the original remote read; +an eligible source rejection can use it only before the exclusive maximum +age. The default recovery predicate admits DialCache's own +`FallbackTimeoutError`. Recovered values may memoize in still-open request +scopes; recovery does not refresh Redis or local storage. + +Pass a synchronous `metrics` callback or an object with `observe(event)` to +receive the backend-neutral diagnostic event dictionaries. Their label names +match the shared contract, including `cacheNamespace`, `useCase`, `keyType`, +and `layer`. Observer failures do not alter cache results. Local capacity +defaults to 10,000 entries; zero capacity disables storage while preserving +eligible concurrent sharing. + +## Relationship to gcache + +The Python API takes inspiration from [Galileo gcache](https://github.com/rungalileo/gcache): +decorated functions, argument-based identity, explicit context managers, and +pluggable serializers. DialCache follows its own portable +specification for behavior and wire compatibility. +The [API design notes](API-DESIGN.md) record the source-reviewed gcache revision +and the native API choices made for this port. + +This binding exposes awaitable operations. It does not introduce a global +singleton, implicitly run synchronous I/O in a thread pool, serialize with +pickle, take ownership of Redis connections, or change the rollout cohort +randomly. Direct `put`, `delete`, and `flush` cache APIs from gcache are outside +DialCache's portable contract; writes come from successful source loads and +entity-level invalidation is explicit. + +## Development and conformance + +From the repository root: + +```sh +python3 -m venv python/.venv +python/.venv/bin/python -m pip install -e './python[test,redis]' +python/.venv/bin/python -m pytest python/tests +``` + +The native tests cover Python API behavior, policy validation, scope lifetime, +local expiry, cancellation, and wire boundaries. Shared replay runs the real +Python API through the repository's Node coordinator. Its inputs and expected +observations come from the same Quint-generated histories used by the other +ports; Node is a development dependency, not a runtime dependency of the +Python library. See [the porting guide](../formal/PORTING.md) for the completion +and settlement requirements and [the feature map](../formal/FEATURE-COVERAGE.md) +for portable behavior versus native adapter obligations. diff --git a/python/dialcache/__init__.py b/python/dialcache/__init__.py new file mode 100644 index 00000000..f22e3d40 --- /dev/null +++ b/python/dialcache/__init__.py @@ -0,0 +1,38 @@ +"""DialCache: explicit scopes, layered caching, and portable Redis frames.""" + +from .cache import DialCache +from .config import UNSET, CacheLayer, DialCacheKeyConfig, KeyConfig, Policy +from .errors import ( + ConfigError, + DialCacheError, + FallbackTimeoutError, + MissingRemoteError, + RedisReadTimeoutError, + RemoteReadTimeoutError, + UseCaseIsAlreadyRegisteredError, + UseCaseNameIsReservedError, +) +from .key import Key, normalize_args +from .serializer import UNDEFINED, JsonSerializer, Serializer + +__all__ = [ + "DialCache", + "Policy", + "KeyConfig", + "DialCacheKeyConfig", + "CacheLayer", + "Key", + "normalize_args", + "Serializer", + "JsonSerializer", + "UNDEFINED", + "UNSET", + "DialCacheError", + "ConfigError", + "FallbackTimeoutError", + "RemoteReadTimeoutError", + "RedisReadTimeoutError", + "MissingRemoteError", + "UseCaseIsAlreadyRegisteredError", + "UseCaseNameIsReservedError", +] diff --git a/python/dialcache/cache.py b/python/dialcache/cache.py new file mode 100644 index 00000000..9ec48313 --- /dev/null +++ b/python/dialcache/cache.py @@ -0,0 +1,1101 @@ +"""Async DialCache engine: admission, captured policy, traversal and ownership. + +The implementation follows formal/SPEC.md. External work is kept alive when a +deadline stops a caller waiting: timing out never grants late work permission +to publish a value or cancels another caller's shared source. +""" + +from __future__ import annotations + +import asyncio +import functools +import inspect +import json +import logging +import math +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass, field +from typing import Any, ParamSpec, TypeVar + +from .clock import SystemClock +from .config import UNSET, Policy, merge_policy, resolve_layer, validate_static_policy +from .context import DialCacheContext +from .errors import ( + ConfigError, + FallbackTimeoutError, + MissingRemoteError, + RemoteReadTimeoutError, + UseCaseIsAlreadyRegisteredError, + UseCaseNameIsReservedError, +) +from .key import Key, invalidation_prefix, normalize_args, ramp_sample +from .local import LocalCache +from .protocol import Frame, Miss, compress_payload, decompress_payload, escape_raw_payload, utf8_bytes +from .redis import InvalidationRequest, ReadContext, ReadRequest, WriteRequest +from .serializer import JsonSerializer + +T = TypeVar("T") +P = ParamSpec("P") +MAX_SAFE = 9_007_199_254_740_991 + + +async def _await(value: Any) -> Any: + return await value if inspect.isawaitable(value) else value + + +def _valid_integer(value: Any, minimum: int = 0, maximum: int = MAX_SAFE) -> bool: + return ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and minimum <= value <= maximum + and math.isfinite(value) + and int(value) == value + ) + + +def _deep_equal(left: Any, right: Any) -> bool: + """Keep booleans distinct from numbers in JSON-like semantic comparisons.""" + if isinstance(left, bool) or isinstance(right, bool): + return left is right + if isinstance(left, (int, float)) and isinstance(right, (int, float)): + if left == 0 and right == 0: + return math.copysign(1, left) == math.copysign(1, right) + return left == right or ( + isinstance(left, float) and isinstance(right, float) and math.isnan(left) and math.isnan(right) + ) + if type(left) is not type(right): + return False + if isinstance(left, dict): + return left.keys() == right.keys() and all(_deep_equal(left[k], right[k]) for k in left) + if isinstance(left, (list, tuple)): + return len(left) == len(right) and all(_deep_equal(a, b) for a, b in zip(left, right)) + return left == right + + +def _budget(value: Any) -> int | None: + if value is None: + return None + if not _valid_integer(value, 1, 2_147_483_647): + raise ConfigError("fallback_timeout_ms must be None or a positive integer <= 2147483647") + return int(value) + + +class AbortSignal: + """Cooperative read cancellation; adapters may register an abort callback.""" + + def __init__(self) -> None: + self.aborted = False + self._callbacks: list[Callable[[], Any]] = [] + + def add_callback(self, callback: Callable[[], Any]) -> None: + if self.aborted: + callback() + else: + self._callbacks.append(callback) + + def abort(self) -> None: + if self.aborted: + return + self.aborted = True + callbacks, self._callbacks = self._callbacks, [] + for callback in callbacks: + try: + callback() + except Exception: + pass + + +@dataclass +class _Flight: + task: asyncio.Future[Any] + started: float + followers: int = 0 + + +@dataclass +class _Operation: + load: Callable[[], Any] + select_key: Callable[[], Any] + key_type: str + use_case: str + policy: Policy + timeout: int | None + serializer: Any + tracked: bool + comparator: Callable[[Any, Any], bool] + recovery: Callable[[BaseException], bool] + did_timeout: bool = False + + +@dataclass +class _Shadow: + started: float + budget: int + pending_reads: set[asyncio.Task[Any]] = field(default_factory=set) + finished: bool = False + abandoned: bool = False + + +class DialCache: + """Explicitly enabled caching for one asyncio event loop. + + The application owns Redis connections. Instances have independent scopes, + LRU storage and in-flight tables. Values held in memory are shared by + reference and should be treated as immutable. + """ + + def __init__( + self, + *, + namespace: str = "urn", + redis: Any = None, + policy_provider: Callable[[Key], Any] | None = None, + metrics: Any = None, + logger: Any = None, + clock: Any = None, + local_max_size: int = 10_000, + local_store: Any = None, + shadow_max_in_flight: int = 1, + read_timeout_ms: int = 50, + should_attempt_stale_recovery: Callable[[BaseException], bool] | None = None, + serializer: Any = None, + compression: Any = True, + ) -> None: + if not isinstance(namespace, str) or "{" in namespace or "}" in namespace: + raise ConfigError("namespace must be a string without braces") + if not _valid_integer(local_max_size): + raise ConfigError("local_max_size must be a nonnegative safe integer") + if not _valid_integer(shadow_max_in_flight, 1): + raise ConfigError("shadow_max_in_flight must be a positive safe integer") + if not _valid_integer(read_timeout_ms, 1, 2_147_483_647): + raise ConfigError("read_timeout_ms must be a positive bounded integer") + if should_attempt_stale_recovery is not None and not callable(should_attempt_stale_recovery): + raise ConfigError("should_attempt_stale_recovery must be callable") + if compression is not True and compression is not False and not isinstance(compression, Mapping): + raise ConfigError("compression must be True, False, or an options mapping") + if isinstance(compression, Mapping): + compression = dict(compression) + if set(compression) - {"threshold_bytes", "level"}: + raise ConfigError("compression supports threshold_bytes and level") + if not _valid_integer(compression.get("threshold_bytes", 4096), 1): + raise ConfigError("compression.threshold_bytes must be a positive safe integer") + if not _valid_integer(compression.get("level", 3), 1, 22): + raise ConfigError("compression.level must be an integer from 1 through 22") + self.namespace, self.redis = namespace, redis + self.policy_provider, self.metrics = policy_provider, metrics + self.logger = logger or logging.getLogger("dialcache") + self.clock = clock or SystemClock() + self._context = DialCacheContext() + self._local = local_store if local_store is not None else LocalCache(local_max_size, self.clock) + self._flights: dict[str, _Flight] = {} + self._shadows: dict[str, _Shadow] = {} + self._registered: set[str] = set() + self._tasks: set[asyncio.Task[Any]] = set() + self._shadow_max = shadow_max_in_flight + self.read_timeout_ms = read_timeout_ms + self.serializer = serializer or JsonSerializer() + self.compression = compression + self._recovery = should_attempt_stale_recovery or ( + lambda error: isinstance(error, FallbackTimeoutError) + ) + + def enable(self, enabled: bool = True) -> Any: + """Enable a request scope; nested scopes share the live outer memo.""" + return self._context.enable() if enabled else self._context.disable() + + def disable(self) -> Any: + """Temporarily disable caching, preserving a live outer request memo.""" + return self._context.disable() + + def is_enabled(self) -> bool: + return self._context.is_enabled() + + def get_coalescing_state(self) -> dict[str, Any]: + """Report live process leaders, followers and oldest leader age.""" + return { + "process": { + "active_leaders": len(self._flights), + "active_followers": sum(f.followers for f in self._flights.values()), + "oldest_leader_age_ms": max( + 0, self.clock.monotonic_ms() - next(iter(self._flights.values())).started + ) + if self._flights + else None, + } + } + + def cached( + self, + *, + key_type: str, + cache_key: Callable[..., Any] | None = None, + id_arg: str | tuple[str, Callable[[Any], Any]] | None = None, + use_case: str | None = None, + arg_adapters: Mapping[str, Callable[[Any], Any]] | None = None, + ignore_args: list[str] | tuple[str, ...] = (), + **options: Any, + ) -> Callable[..., Any]: + """Decorate a loader using an explicit selector or gcache-style arguments. + + The wrapper is always awaitable, including for a synchronous loader. + Key callbacks and argument adapters are never called while disabled. + """ + if (cache_key is None) == (id_arg is None): + raise ConfigError("Supply exactly one of cache_key or id_arg") + + def decorate(fn: Callable[P, T | Awaitable[T]]) -> Callable[P, Awaitable[T]]: + name = use_case or f"{fn.__module__}.{fn.__qualname__}" + self._check_use_case(name) + if name in self._registered: + raise UseCaseIsAlreadyRegisteredError(name) + signature = inspect.signature(fn) + adapters = dict(arg_adapters or {}) + ignored = frozenset(ignore_args) + # Validate/snapshot static settings once, before reserving the name. + prototype = self._operation(lambda: None, lambda: None, key_type, name, **options) + id_name = id_arg[0] if isinstance(id_arg, tuple) else id_arg + if id_name is not None and id_name not in signature.parameters: + raise ConfigError(f"id_arg does not name a function parameter: {id_name}") + if any(n not in signature.parameters for n in (*adapters, *ignored)): + raise ConfigError("arg_adapters and ignore_args must name function parameters") + self._registered.add(name) + + @functools.wraps(fn) + async def wrapped(*args: P.args, **kwargs: P.kwargs) -> T: + def select() -> Any: + if cache_key is not None: + return cache_key(*args, **kwargs) + bound = signature.bind(*args, **kwargs) + bound.apply_defaults() + entity_id = bound.arguments[id_name] + if isinstance(id_arg, tuple): + entity_id = id_arg[1](entity_id) + key_args = { + n: adapters[n](v) if n in adapters else v + for n, v in bound.arguments.items() + if n != "self" and n not in ignored and (n != id_name or n in adapters) + } + return {"id": entity_id, "args": key_args} + + op = _Operation( + lambda: fn(*args, **kwargs), + select, + prototype.key_type, + name, + prototype.policy, + prototype.timeout, + prototype.serializer, + prototype.tracked, + prototype.comparator, + prototype.recovery, + ) + return await self._execute(op) + + return wrapped + + return decorate + + async def get_or_load( + self, + load: Callable[[], T | Awaitable[T]], + *, + key: Any = None, + key_type: str, + use_case: str, + key_selector: Callable[[], Any] | None = None, + **options: Any, + ) -> T: + """Read a key or run its loader. Repeated inline use-case names are valid.""" + op = self._operation(load, key_selector or (lambda: key), key_type, use_case, **options) + return await self._execute(op) + + async def aget(self, key: Key, fallback: Callable[[], Any], **options: Any) -> Any: + """Structured-key form of get_or_load, familiar to gcache callers.""" + return await self.get_or_load( + fallback, key=key, key_type=key.key_type, use_case=key.use_case, **options + ) + + def _operation( + self, + load: Callable[[], Any], + select: Callable[[], Any], + key_type: str, + use_case: str, + *, + default_config: Any = None, + fallback_timeout_ms: Any = 60_000, + serializer: Any = None, + track_for_invalidation: bool = False, + shadow_comparator: Any = None, + should_attempt_stale_recovery: Any = None, + ) -> _Operation: + self._check_use_case(use_case) + policy = validate_static_policy(default_config) or Policy() + comparator = shadow_comparator if shadow_comparator is not None else _deep_equal + recovery = ( + should_attempt_stale_recovery if should_attempt_stale_recovery is not None else self._recovery + ) + if not callable(comparator) or not callable(recovery): + raise ConfigError("Comparator and recovery predicate must be callable") + return _Operation( + load, + select, + key_type, + use_case, + policy, + _budget(fallback_timeout_ms), + serializer or self.serializer, + track_for_invalidation, + comparator, + recovery, + ) + + @staticmethod + def _check_use_case(name: str) -> None: + if name == "watermark": + raise UseCaseNameIsReservedError(name) + + def _spawn(self, work: Awaitable[Any]) -> asyncio.Task[Any]: + task = asyncio.ensure_future(work) + self._tasks.add(task) + + def consume(done: asyncio.Task[Any]) -> None: + self._tasks.discard(done) + if not done.cancelled(): + done.exception() + + task.add_done_callback(consume) + return task + + def _emit(self, event: str, labels: Mapping[str, Any], **fields: Any) -> None: + if self.metrics is None: + return + record = {"event": event, **labels, **fields} + try: + if callable(self.metrics): + result = self.metrics(record) + else: + result = self.metrics.observe(record) + self._discard_awaitable(result) + except Exception: + pass + + def _log(self, message: str, error: Any = None) -> None: + try: + self.logger.warning(message, error) if error is not None else self.logger.warning(message) + except Exception: + pass + + def _discard_awaitable(self, value: Any) -> None: + if inspect.isawaitable(value): + self._spawn(_await(value)) + + def _labels(self, key: Key | _Operation, layer: str | None = None) -> dict[str, Any]: + labels = {"cacheNamespace": self.namespace, "useCase": key.use_case, "keyType": key.key_type} + if layer is not None: + labels["layer"] = layer + return labels + + def _error(self, key: Any, layer: str, kind: str) -> None: + self._emit("error", self._labels(key, layer), error=kind, inFallback=False) + + def _seconds(self, start: float) -> float: + return max(0, self.clock.monotonic_ms() - start) / 1000 + + async def _deadline( + self, + pending: asyncio.Future[Any], + budget: int | None, + error: Callable[[], Exception], + *, + started: float | None = None, + on_timeout: Callable[[], Any] | None = None, + ) -> Any: + if budget is None: + return await asyncio.shield(pending) + start = self.clock.monotonic_ms() if started is None else started + result = asyncio.get_running_loop().create_future() + handle: Any = None + + def timeout() -> None: + nonlocal handle + if result.done(): + return + remaining = budget - max(0, self.clock.monotonic_ms() - start) + if remaining > 0: + handle = self.clock.call_later(math.ceil(remaining), timeout) + return + if on_timeout is not None: + try: + on_timeout() + except Exception: + pass + result.set_exception(error()) + + def settled(done: asyncio.Future[Any]) -> None: + if result.done(): + return + if max(0, self.clock.monotonic_ms() - start) >= budget: + timeout() + elif done.cancelled(): + result.cancel() + elif done.exception() is not None: + result.set_exception(done.exception()) + else: + result.set_result(done.result()) + + pending.add_done_callback(settled) + remaining = budget - max(0, self.clock.monotonic_ms() - start) + handle = self.clock.call_later(max(0, math.ceil(remaining)), timeout) + try: + return await result + finally: + handle.cancel() + pending.remove_done_callback(settled) + + async def _source(self, op: _Operation, layer: str) -> Any: + start = self.clock.monotonic_ms() + + async def invoke() -> Any: + return await _await(op.load()) + + pending = self._spawn(invoke()) + + def timeout_error() -> Exception: + op.did_timeout = True + return FallbackTimeoutError(op.use_case, op.timeout) + + try: + return await self._deadline(pending, op.timeout, timeout_error, started=start) + except Exception: + self._emit("error", self._labels(op, layer), error="fallback", inFallback=True) + raise + finally: + self._emit("fallback", self._labels(op, layer), seconds=self._seconds(start)) + + async def _execute(self, op: _Operation) -> Any: + if not self.is_enabled(): + self._emit("disabled", self._labels(op, "noop"), reason="context") + return await _await(op.load()) + try: + selected = op.select_key() + if isinstance(selected, Key): + key = selected + if key.namespace != self.namespace: + raise ValueError("Key namespace differs from cache namespace") + else: + spec = selected if isinstance(selected, Mapping) else {"id": selected} + key = Key( + self.namespace, + op.key_type, + spec["id"], + op.use_case, + normalize_args(spec.get("args", {})), + op.tracked, + ) + except Exception as error: + self._error(op, "noop", "key_construction") + self._log("Could not construct DialCache key: %s", error) + return await self._source(op, "noop") + try: + overlay = await _await(self.policy_provider(key)) if self.policy_provider is not None else None + policy = merge_policy(op.policy, overlay) or Policy() + except Exception as error: + self._error(key, "noop", "config_resolution") + self._emit("disabled", self._labels(key, "noop"), reason="config_error") + self._log("Could not resolve DialCache policy: %s", error) + return await self._source(op, "noop") + if not self.is_enabled(): + self._emit("disabled", self._labels(key, "noop"), reason="context") + return await self._source(op, "noop") + memo = self._context.request_cache() if policy.request_local is True else None + if memo is None: + return await self._shared(op, key, policy, "local") + + async def request() -> Any: + start = self.clock.monotonic_ms() + found, value = memo.read(key.logical) + self._emit("request", self._labels(key, "request_local")) + self._emit("get", self._labels(key, "request_local"), seconds=self._seconds(start)) + if found: + return value + self._emit("miss", self._labels(key, "request_local"), reason="value_absent") + value = await self._shared(op, key, policy, "request_local") + memo.set(key.logical, value) + return value + + if policy.coalesce is False: + return await request() + return await self._single_flight(memo.in_flight, key, request, "request_local") + + async def _single_flight( + self, table: dict[str, Any], key: Key, run: Callable[[], Awaitable[Any]], scope: str + ) -> Any: + existing = table.get(key.logical) + if existing is not None: + existing.followers += 1 + self._emit("coalesced", self._labels(key), scope=scope) + return await asyncio.shield(existing.task) + + # Publish the result holder before scheduling the leader. Python's + # eager task factory can run a complete cache hit inside create_task. + # Followers (including reentrant observers) must already have a valid + # result to join, and completion must never resurrect a settled flight. + flight = _Flight(asyncio.get_running_loop().create_future(), self.clock.monotonic_ms()) + flight.task.add_done_callback(lambda done: None if done.cancelled() else done.exception()) + table[key.logical] = flight + + async def lead() -> Any: + try: + return await run() + finally: + if table.get(key.logical) is flight: + del table[key.logical] + + def transfer(done: asyncio.Task[Any]) -> None: + if done.cancelled(): + flight.task.cancel() + elif done.exception() is not None: + flight.task.set_exception(done.exception()) + else: + flight.task.set_result(done.result()) + + self._spawn(lead()).add_done_callback(transfer) + return await asyncio.shield(flight.task) + + def _layer(self, key: Key, policy: Policy, name: str) -> Any: + layer = resolve_layer(policy, key.logical, name) + if getattr(layer, "stale_on_error_config_error", False): + self._error(key, name, "config_resolution") + if not layer.enabled: + self._emit("disabled", self._labels(key, name), reason=layer.reason) + if layer.reason in ("invalid_ttl", "invalid_ramp"): + self._error(key, name, "config_resolution") + return layer + + async def _shared(self, op: _Operation, key: Key, policy: Policy, fallback_layer: str) -> Any: + local = self._layer(key, policy, "local") + if local.enabled: + + async def run() -> Any: + start = self.clock.monotonic_ms() + can_put = True + try: + found, value = self._local.read(key.logical) + self._emit("request", self._labels(key, "local")) + self._emit("get", self._labels(key, "local"), seconds=self._seconds(start)) + if found: + return value + self._emit("miss", self._labels(key, "local"), reason="value_absent") + except Exception: + can_put = False + self._error(key, "local", "cache_read") + self._emit("disabled", self._labels(key, "local"), reason="config_error") + return await self._lower(op, key, policy, local if can_put else None, "local") + + return ( + await run() + if policy.coalesce is False + else await self._single_flight(self._flights, key, run, "process") + ) + if self.redis is None: + return await self._source(op, fallback_layer) + remote = self._layer(key, policy, "remote") + if not remote.enabled: + return await self._disabled_remote(op, key, policy, None, remote, fallback_layer) + + async def run_remote() -> Any: + return await self._remote_chain(op, key, policy, None, remote) + + return ( + await run_remote() + if policy.coalesce is False + else await self._single_flight(self._flights, key, run_remote, "process") + ) + + async def _lower(self, op: _Operation, key: Key, policy: Policy, local: Any, fallback_layer: str) -> Any: + if self.redis is None: + value = await self._source(op, fallback_layer) + self._put_local(key, value, local) + return value + remote = self._layer(key, policy, "remote") + if not remote.enabled: + return await self._disabled_remote(op, key, policy, local, remote, fallback_layer) + return await self._remote_chain(op, key, policy, local, remote) + + async def _disabled_remote( + self, op: _Operation, key: Key, policy: Policy, local: Any, remote: Any, fallback_layer: str + ) -> Any: + start = self.clock.monotonic_ms() + source = self._spawn(self._source(op, fallback_layer)) + if remote.reason == "ramped_down": + self._schedule_shadow(op, key, policy, remote, source=source, started=start) + value = await asyncio.shield(source) + self._put_local(key, value, local) + return value + + def _put_local(self, key: Key, value: Any, local: Any) -> None: + if local is not None: + try: + self._local.put(key.logical, value, local.ttl_sec) + except Exception: + self._error(key, "local", "cache_write") + + def _read_budget(self, policy: Policy) -> int: + return ( + self.read_timeout_ms if policy.remote_read_timeout_ms is UNSET else policy.remote_read_timeout_ms + ) + + async def _raw_read(self, key: Key, policy: Policy, job: _Shadow | None = None) -> Frame | Miss: + budget = self._read_budget(policy) + signal = AbortSignal() + + async def invoke() -> Any: + return await _await( + self.redis.read(ReadRequest(key.value_key, key.watermark_key), ReadContext(budget, signal)) + ) + + pending = self._spawn(invoke()) + if job is not None: + job.pending_reads.add(pending) + + def finished(done: asyncio.Task[Any]) -> None: + job.pending_reads.discard(done) + self._release_shadow(key, job) + + pending.add_done_callback(finished) + value = await self._deadline( + pending, budget, lambda: RemoteReadTimeoutError(key.use_case, budget), on_timeout=signal.abort + ) + if isinstance(value, Miss): + fence = ( + value.observed_watermark_ms + if key.tracked and _valid_integer(value.observed_watermark_ms) + else None + ) + reason = ( + value.reason + if value.reason in ("value_absent", "expired", "watermark_fenced", "unclassified") + else "unclassified" + ) + if reason == "watermark_fenced" and fence is None: + reason = "unclassified" + return Miss(reason, fence) + return value if isinstance(value, Frame) else Miss("unclassified") + + def _age(self, key: Key, frame: Frame, layer: str) -> float | None: + if not _valid_integer(frame.created_at_ms): + return None + age = self.clock.wall_ms() - frame.created_at_ms + if age < 0: + self._emit("futureOffset", self._labels(key, layer), seconds=-age / 1000) + return age + + async def _decode(self, op: _Operation, key: Key, payload: Any, layer: str) -> Any: + decompressed = decompress_payload(payload) + if decompressed.outcome != "passthrough": + self._emit("compression", self._labels(key, layer), outcome=decompressed.outcome) + start = self.clock.monotonic_ms() + try: + return await _await(op.serializer.load(decompressed.payload)) + except Exception: + self._error(key, layer, "serialization_load") + raise + finally: + self._emit( + "serialization", self._labels(key, layer), operation="load", seconds=self._seconds(start) + ) + + async def _serving_read( + self, op: _Operation, key: Key, policy: Policy, remote: Any + ) -> tuple[str, Any, Any]: + start = self.clock.monotonic_ms() + labels = self._labels(key, "remote") + self._emit("request", labels) + try: + try: + read = await self._raw_read(key, policy) + except Exception as error: + self._error( + key, + "remote", + "cache_read_timeout" if isinstance(error, RemoteReadTimeoutError) else "cache_read", + ) + return "error", None, None + if isinstance(read, Miss): + self._emit("miss", labels, reason=read.reason) + return "miss", None, read.observed_watermark_ms + age = self._age(key, read, "remote") + maximum = remote.stale_on_error_max_age_sec or remote.ttl_sec + if age is None or age < 0: + self._emit("miss", labels, reason="unclassified") + return "miss", None, None + if age >= remote.ttl_sec * 1000: + self._emit("miss", labels, reason="expired") + return ("retained", read, None) if age < maximum * 1000 else ("miss", None, None) + try: + value = await self._decode(op, key, read.payload, "remote") + return "hit", (value, read), None + except Exception: + self._emit("miss", labels, reason="unclassified") + return "decode_error", None, None + finally: + self._emit("get", labels, seconds=self._seconds(start)) + + async def _remote_chain(self, op: _Operation, key: Key, policy: Policy, local: Any, remote: Any) -> Any: + status, acquired, fence = await self._serving_read(op, key, policy, remote) + if status == "hit": + value, frame = acquired + self._put_local(key, value, local) + self._schedule_shadow(op, key, policy, remote, frame=frame) + return value + try: + value = await self._source(op, "remote") + except Exception as error: + maximum = remote.stale_on_error_max_age_sec + if maximum and status in ("miss", "retained"): + try: + allow = op.recovery(error) + if allow is True: + present, value = await self._recover(op, key, acquired, maximum) + if present: + return value + else: + self._discard_awaitable(allow) + except Exception: + pass + raise + if status != "error": + try: + await self._write(op, key, value, remote, "remote", fence) + except Exception: + pass + if not key.tracked: + self._put_local(key, value, local) + return value + + async def _recover(self, op: _Operation, key: Key, frame: Frame | None, maximum: int) -> tuple[bool, Any]: + def record(outcome: str, age: float | None = None) -> None: + self._emit("staleRecovery", self._labels(key), outcome=outcome) + if age is not None: + self._emit("recoveryAge", self._labels(key), outcome=outcome, seconds=age / 1000) + + age = self._age(key, frame, "remote") if frame is not None else None + if age is None or age < 0 or age >= maximum * 1000: + record("miss") + return False, None + try: + value = await self._decode(op, key, frame.payload, "remote") + except Exception: + record("deserialization_error") + return False, None + age = self._age(key, frame, "remote") + if age is None or age < 0 or age >= maximum * 1000: + record("miss") + return False, None + record("served", age) + return True, value + + async def _write( + self, + op: _Operation, + key: Key, + value: Any, + remote: Any, + layer: str, + fence: int | None, + live: Callable[[], bool] | None = None, + ) -> bool: + labels = self._labels(key, layer) + if not key.tracked: + fence = None + if fence is not None and self.clock.wall_ms() <= fence: + return False + start = self.clock.monotonic_ms() + try: + payload = await _await(op.serializer.dump(value)) + if not isinstance(payload, (str, bytes)): + raise TypeError("Serializer.dump must return str or bytes") + except Exception: + self._error(key, layer, "serialization_dump") + raise + finally: + self._emit("serialization", labels, operation="dump", seconds=self._seconds(start)) + size = len(utf8_bytes(payload) if isinstance(payload, str) else payload) + self._emit("size", labels, bytes=size) + try: + if self.compression is False: + payload = escape_raw_payload(payload) + else: + options = self.compression if isinstance(self.compression, Mapping) else {} + compressed = compress_payload(payload, **options) + payload = compressed.payload + self._emit("compression", labels, outcome=compressed.outcome) + except Exception: + self._error(key, layer, "compression") + raise + self._emit( + "storedSize", labels, bytes=len(utf8_bytes(payload) if isinstance(payload, str) else payload) + ) + if live is not None and not live(): + return False + stamp = self.clock.wall_ms() + if not _valid_integer(stamp): + self._error(key, layer, "cache_write") + raise ValueError("Invalid writer timestamp") + if fence is not None and stamp <= fence: + return False + ttl_ms = (remote.stale_on_error_max_age_sec or remote.ttl_sec) * 1000 + if key.tracked and ttl_ms > 3_600_000: + ttl_ms = 3_600_000 + self._error(key, layer, "tracked_ttl_clamped") + try: + await _await(self.redis.write(WriteRequest(key.value_key, ttl_ms, payload, stamp))) + except Exception: + self._error(key, layer, "cache_write") + raise + return True + + async def invalidate_remote(self, key_type: str, id: Any, future_buffer_ms: int = 0) -> None: + """Write an entity fence after its source mutation commits; failures raise.""" + if not _valid_integer(future_buffer_ms, 0, 31_536_000_000): + raise ConfigError("future_buffer_ms must be a nonnegative integer <= 31536000000") + labels = {"cacheNamespace": self.namespace, "keyType": key_type, "layer": "remote"} + self._emit("invalidation", labels) + try: + if self.redis is None: + raise MissingRemoteError("invalidate_remote requires a configured Redis client") + watermark = "{" + invalidation_prefix(self.namespace, key_type, id) + "}#watermark" + await _await( + self.redis.invalidate(InvalidationRequest(watermark, future_buffer_ms, self.clock.wall_ms())) + ) + except Exception: + self._emit("error", {**labels, "useCase": "watermark"}, error="invalidation", inFallback=False) + raise + + ainvalidate = invalidate_remote + + def _schedule_shadow( + self, + op: _Operation, + key: Key, + policy: Policy, + remote: Any, + *, + frame: Frame | None = None, + source: asyncio.Task[Any] | None = None, + started: float | None = None, + ) -> None: + shadow = policy.shadow + if shadow is UNSET: + return + if not isinstance(shadow, Mapping): + self._error(key, "remote", "config_resolution") + return + ramp = shadow.get("ramp", 0) + if ( + not isinstance(ramp, (int, float)) + or isinstance(ramp, bool) + or not math.isfinite(ramp) + or not 0 <= ramp <= 100 + ): + self._error(key, "remote", "config_resolution") + return + if ramp == 0 or self.metrics is None: + return + try: + if hasattr(self.metrics, "supports") and not self.metrics.supports("shadowValidation"): + return + except Exception: + return + if ramp < 100 and ramp_sample(key, "shadow") >= ramp: + return + if key.logical in self._shadows or len(self._shadows) >= self._shadow_max: + self._emit("shadowValidation", self._labels(key), outcome="dropped") + return + log = shadow.get("log_mismatches", shadow.get("logMismatches", False)) + if type(log) is not bool: + self._error(key, "remote", "config_resolution") + log = False + job = _Shadow(self.clock.monotonic_ms() if started is None else started, op.timeout or 60_000) + self._shadows[key.logical] = job + self._spawn(self._run_shadow(op, key, policy, remote, job, frame, source, log)) + + def _release_shadow(self, key: Key, job: _Shadow) -> None: + if job.finished and not job.pending_reads and self._shadows.get(key.logical) is job: + del self._shadows[key.logical] + + async def _shadow_read(self, key: Key, policy: Policy, maximum: int | None, job: _Shadow) -> Frame | Miss: + labels = self._labels(key, "remote_shadow") + start = self.clock.monotonic_ms() + self._emit("request", labels) + try: + result = await self._raw_read(key, policy, job) + if isinstance(result, Frame): + age = self._age(key, result, "remote_shadow") + if age is None or (age < 0 and maximum is not None): + result = Miss("unclassified") + elif maximum is not None and age >= maximum * 1000: + result = Miss("expired") + if isinstance(result, Miss): + self._emit("miss", labels, reason=result.reason) + return result + except Exception as error: + self._error( + key, + "remote_shadow", + "cache_read_timeout" if isinstance(error, RemoteReadTimeoutError) else "cache_read", + ) + raise + finally: + self._emit("get", labels, seconds=self._seconds(start)) + + async def _run_shadow( + self, + op: _Operation, + key: Key, + policy: Policy, + remote: Any, + job: _Shadow, + frame: Frame | None, + source: asyncio.Task[Any] | None, + log: bool, + ) -> None: + if source is None: + job.started = self.clock.monotonic_ms() + + def abandon() -> None: + nonlocal frame + job.abandoned = True + frame = None + + def live() -> bool: + if self.clock.monotonic_ms() - job.started >= job.budget: + abandon() + return not job.abandoned + + details: dict[str, Any] = {} + + async def work() -> str: + nonlocal frame + try: + if not live(): + return "timeout" + miss: Miss | None = None + if source is not None: + try: + read = await self._shadow_read(key, policy, remote.ttl_sec, job) + except Exception: + return "redis_error" + if not live(): + return "timeout" + if isinstance(read, Miss): + miss = read + else: + frame = read + try: + if source is not None: + # This source belongs to the caller. A dark job stops + # waiting at its deadline without retaining capacity + # for an unbounded caller-owned operation. + value = await self._deadline( + source, job.budget, lambda: TimeoutError("shadow deadline"), started=job.started + ) + await asyncio.sleep(0) + else: + with self.disable(): + value = await _await(op.load()) + except Exception: + return ( + "timeout" if not live() or (source is not None and op.did_timeout) else "source_error" + ) + if not live(): + return "timeout" + if miss is not None: + try: + filled = await self._write( + op, key, value, remote, "remote_shadow", miss.observed_watermark_ms, live + ) + return ("filled" if filled else "fill_fenced") if live() else "timeout" + except Exception: + return "fill_error" + if frame is None: + return "timeout" + try: + cached = await self._decode(op, key, frame.payload, "remote_shadow") + except Exception: + return "deserialization_error" + if not live(): + return "timeout" + try: + matched = op.comparator(cached, value) + if type(matched) is not bool: + try: + await _await(matched) + except Exception: + pass + return "comparison_error" if live() else "timeout" + except Exception: + return "comparison_error" + if not live(): + return "timeout" + if not matched: + try: + confirmation = await self._shadow_read(key, policy, None, job) + except Exception: + return "confirmation_error" + if not live(): + return "timeout" + if not isinstance(confirmation, Frame) or self._payload_bytes( + confirmation.payload + ) != self._payload_bytes(frame.payload): + return "superseded" + if log: + details.update( + cacheKey=self._clamp(key.logical, 2048), + cachedValueJson=self._preview(cached), + sourceValueJson=self._preview(value), + ) + details["age"] = max(0, self.clock.wall_ms() - frame.created_at_ms) / 1000 + return "match" if matched else "mismatch" + finally: + job.finished = True + self._release_shadow(key, job) + + pending = self._spawn(work()) + try: + outcome = await self._deadline( + pending, + job.budget, + lambda: TimeoutError("shadow deadline"), + started=job.started, + on_timeout=abandon, + ) + except Exception: + outcome = "timeout" + self._emit("shadowValidation", self._labels(key), outcome=outcome) + if "age" in details and outcome in ("match", "mismatch"): + self._emit("shadowAge", self._labels(key), outcome=outcome, seconds=details.pop("age")) + if outcome == "mismatch" and log: + self._emit("mismatchWarning", self._labels(key), outcome=outcome, **details) + self._log("DialCache shadow validation mismatch: %s", {**self._labels(key), **details}) + + @staticmethod + def _payload_bytes(value: str | bytes) -> bytes: + return utf8_bytes(value) if isinstance(value, str) else value + + @staticmethod + def _clamp(text: str, limit: int) -> str: + encoded = utf8_bytes(text) + marker = b"...[truncated]" + if len(encoded) <= limit: + return text + return encoded[: limit - len(marker)].decode("utf-8", "ignore") + marker.decode() + + @staticmethod + def _preview(value: Any) -> str | None: + try: + text = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + return DialCache._clamp(text, 8192) + except Exception: + return None diff --git a/python/dialcache/clock.py b/python/dialcache/clock.py new file mode 100644 index 00000000..5ca24bb0 --- /dev/null +++ b/python/dialcache/clock.py @@ -0,0 +1,53 @@ +"""Separate wall timestamps, monotonic elapsed time, and timer delivery.""" + +from __future__ import annotations + +import asyncio +import time +from collections.abc import Callable +from typing import Protocol + + +class TimerHandle(Protocol): + def cancel(self) -> None: ... + + +class Clock(Protocol): + """Injectable clocks and timers for deterministic application tests. + + Wall time stamps Redis frames. Monotonic time governs local expiry and + deadlines. A controlled clock may advance elapsed time without delivering + timers, so callers also check elapsed time when operations settle. + """ + + def wall_ms(self) -> int | float: ... + + def monotonic_ms(self) -> int | float: ... + + def call_later(self, milliseconds: float, callback: Callable[[], None]) -> TimerHandle: ... + + +class SystemClock: + """System wall time and the process-wide monotonic millisecond grid.""" + + def wall_ms(self) -> int: + return time.time_ns() // 1_000_000 + + def monotonic_ms(self) -> float: + return time.monotonic_ns() / 1_000_000 + + def call_later(self, milliseconds: float, callback: Callable[[], None]) -> TimerHandle: + return asyncio.get_running_loop().call_later(milliseconds / 1_000, callback) + + async def sleep_ms(self, milliseconds: float) -> None: + future: asyncio.Future[None] = asyncio.get_running_loop().create_future() + + def wake() -> None: + if not future.done(): + future.set_result(None) + + timer = self.call_later(milliseconds, wake) + try: + await future + finally: + timer.cancel() diff --git a/python/dialcache/config.py b/python/dialcache/config.py new file mode 100644 index 00000000..ce587183 --- /dev/null +++ b/python/dialcache/config.py @@ -0,0 +1,296 @@ +"""Sparse runtime policy and the portable DialCache configuration domains.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping +from dataclasses import dataclass, field +from enum import StrEnum +from types import MappingProxyType +from typing import Any + +from .errors import ConfigError + +MAX_SAFE_INTEGER = 9_007_199_254_740_991 +MAX_CACHE_TTL_SEC = 31_536_000 +MAX_SUPPORTED_DURATION_MS = MAX_CACHE_TTL_SEC * 1_000 +MAX_TRACKED_REDIS_VALUE_TTL_MS = 3_600_000 +MAX_TIMER_DELAY_MS = 2_147_483_647 +DEFAULT_REMOTE_READ_TIMEOUT_MS = 50 +DEFAULT_FALLBACK_TIMEOUT_MS = 60_000 + + +class _Unset: + __slots__ = () + + def __repr__(self) -> str: + return "UNSET" + + +UNSET = _Unset() + + +class CacheLayer(StrEnum): + LOCAL = "local" + REMOTE = "remote" + + +def is_safe_integer(value: Any) -> bool: + return ( + not isinstance(value, bool) + and isinstance(value, (int, float)) + and abs(value) <= MAX_SAFE_INTEGER + and math.isfinite(value) + and value == int(value) + ) + + +def is_supported_cache_ttl_sec(value: Any) -> bool: + return is_safe_integer(value) and 0 < value <= MAX_CACHE_TTL_SEC + + +def cache_ttl_sec_to_ms(value: Any) -> int: + if not is_supported_cache_ttl_sec(value): + raise ConfigError(f"cache TTL must be an integer from 1 through {MAX_CACHE_TTL_SEC} seconds") + return int(value) * 1_000 + + +def validate_deadline_ms(value: Any, name: str = "deadline") -> int: + if not is_safe_integer(value) or value <= 0 or value > MAX_TIMER_DELAY_MS: + raise ConfigError(f"{name} must be an integer from 1 through {MAX_TIMER_DELAY_MS} milliseconds") + return int(value) + + +def _layer_map(value: Any, name: str) -> Mapping[str, Any]: + if value is UNSET: + return MappingProxyType({}) + if not isinstance(value, Mapping): + raise ConfigError(f"{name} must be a layer map") + # Only the supported layers participate in the portable policy. + return MappingProxyType({layer: value[layer] for layer in ("local", "remote") if layer in value}) + + +def _shadow_map(value: Any) -> Any: + if value is UNSET: + return UNSET + if not isinstance(value, Mapping): + raise ConfigError("shadow must be an object") + result: dict[str, Any] = {} + if "ramp" in value: + result["ramp"] = value["ramp"] + if "log_mismatches" in value: + result["log_mismatches"] = value["log_mismatches"] + elif "logMismatches" in value: + result["log_mismatches"] = value["logMismatches"] + return MappingProxyType(result) + + +@dataclass(frozen=True) +class Policy: + """Per-use-case policy; omitted fields inherit in runtime overlays. + + ``None`` is a supplied value, never an omitted leaf. Constructing a Policy + validates container shapes, boolean switches and read deadlines. TTL, + ramp and optional shadow/recovery leaves remain available for narrow + runtime fail-open resolution. Static operation setup uses + :func:`validate_static_policy` to reject invalid defaults before calls. + """ + + ttl_sec: Mapping[str, Any] = field(default_factory=dict) + ramp: Mapping[str, Any] = field(default_factory=dict) + request_local: Any = UNSET + coalesce: Any = UNSET + stale_on_error_max_age_sec: Any = UNSET + remote_read_timeout_ms: Any = UNSET + shadow: Any = UNSET + + def __post_init__(self) -> None: + object.__setattr__(self, "ttl_sec", _layer_map(self.ttl_sec, "ttl_sec")) + object.__setattr__(self, "ramp", _layer_map(self.ramp, "ramp")) + object.__setattr__(self, "shadow", _shadow_map(self.shadow)) + for name in ("request_local", "coalesce"): + value = getattr(self, name) + if value is not UNSET and not isinstance(value, bool): + raise ConfigError(f"{name} must be a boolean") + if self.remote_read_timeout_ms is not UNSET: + validate_deadline_ms(self.remote_read_timeout_ms, "remote_read_timeout_ms") + + @classmethod + def enabled(cls, ttl_sec: int) -> Policy: + return cls(ttl_sec={"local": ttl_sec, "remote": ttl_sec}, ramp={"local": 100, "remote": 100}) + + @classmethod + def disabled(cls) -> Policy: + """Disable every inherited serving, recovery and shadow path.""" + return cls( + request_local=False, + stale_on_error_max_age_sec=0, + shadow={"ramp": 0, "log_mismatches": False}, + ramp={"local": 0, "remote": 0}, + ) + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> Policy: + result = normalize_policy(value) + assert result is not None + return result + + +KeyConfig = Policy +DialCacheKeyConfig = Policy +PolicyInput = Policy | Mapping[str, Any] | None + +_ALIASES = { + "ttl_sec": "ttlSec", + "ramp": "ramp", + "request_local": "requestLocal", + "coalesce": "coalesce", + "stale_on_error_max_age_sec": "staleOnErrorMaxAgeSec", + "remote_read_timeout_ms": "remoteReadTimeoutMs", + "shadow": "shadow", +} + + +def normalize_policy(value: PolicyInput) -> Policy | None: + if value is None: + return None + if isinstance(value, Policy): + return value + if not isinstance(value, Mapping): + raise ConfigError("DialCache policy must be an object") + if "shadowRamp" in value or "shadow_ramp" in value: + raise ConfigError('shadow_ramp was replaced by "shadow.ramp"') + supplied: dict[str, Any] = {} + for native, portable in _ALIASES.items(): + if native in value: + supplied[native] = value[native] + elif portable in value: + supplied[native] = value[portable] + return Policy(**supplied) + + +def merge_policy(defaults: PolicyInput, runtime: PolicyInput) -> Policy | None: + """Snapshot a sparse provider reply over the operation's static policy. + + A whole-provider ``None`` inherits the static policy. Invalid explicitly + supplied leaves stay supplied, including ``None``, so runtime resolution + cannot accidentally enable an inherited layer. + """ + base = normalize_policy(defaults) + overlay = normalize_policy(runtime) + if overlay is None: + return base + if base is None: + return overlay + values: dict[str, Any] = { + "ttl_sec": {**base.ttl_sec, **overlay.ttl_sec}, + "ramp": {**base.ramp, **overlay.ramp}, + } + for name in ("request_local", "coalesce", "stale_on_error_max_age_sec", "remote_read_timeout_ms"): + value = getattr(overlay, name) + values[name] = getattr(base, name) if value is UNSET else value + if base.shadow is UNSET and overlay.shadow is UNSET: + values["shadow"] = UNSET + else: + values["shadow"] = { + **({} if base.shadow is UNSET else base.shadow), + **({} if overlay.shadow is UNSET else overlay.shadow), + } + return Policy(**values) + + +def _valid_ramp(value: Any) -> bool: + return ( + not isinstance(value, bool) + and isinstance(value, (int, float)) + and 0 <= value <= 100 + and math.isfinite(value) + ) + + +def validate_static_policy(value: PolicyInput) -> Policy | None: + """Validate and capture operation defaults before registering the use case.""" + policy = normalize_policy(value) + if policy is None: + return None + for layer in ("local", "remote"): + if layer in policy.ttl_sec and not is_supported_cache_ttl_sec(policy.ttl_sec[layer]): + raise ConfigError(f"ttl_sec.{layer} must be an integer from 1 through {MAX_CACHE_TTL_SEC}") + if layer in policy.ramp and not _valid_ramp(policy.ramp[layer]): + raise ConfigError(f"ramp.{layer} must be a finite number from 0 through 100") + age = policy.stale_on_error_max_age_sec + if age is not UNSET: + if not is_safe_integer(age) or age < 0 or age > MAX_CACHE_TTL_SEC: + raise ConfigError("stale_on_error_max_age_sec must be a supported nonnegative integer") + if age > 0 and ("remote" not in policy.ttl_sec or age <= policy.ttl_sec["remote"]): + raise ConfigError("stale_on_error_max_age_sec requires a smaller positive remote TTL") + if policy.shadow is not UNSET: + if "ramp" in policy.shadow and not _valid_ramp(policy.shadow["ramp"]): + raise ConfigError("shadow.ramp must be a finite number from 0 through 100") + if "log_mismatches" in policy.shadow and not isinstance(policy.shadow["log_mismatches"], bool): + raise ConfigError("shadow.log_mismatches must be a boolean") + return policy + + +def deterministic_ramp_sample(urn: str, layer: str) -> float: + """Stable FNV-1a over UTF-16 code units, identical across DialCache ports.""" + encoded = f"{urn}:{layer}".encode("utf-16-le", errors="surrogatepass") + value = 0x811C9DC5 + for index in range(0, len(encoded), 2): + value ^= encoded[index] | (encoded[index + 1] << 8) + value = (value * 0x01000193) & 0xFFFFFFFF + return value / 0x1_0000_0000 * 100 + + +def deterministic_shadow_ramp_sample(urn: str) -> float: + return deterministic_ramp_sample(urn, "shadow") + + +@dataclass(frozen=True) +class LayerResolution: + status: str + reason: str | None = None + ttl_sec: int | None = None + ramp: float | None = None + stale_on_error_max_age_sec: int | None = None + stale_on_error_config_error: bool = False + + @property + def enabled(self) -> bool: + return self.status == "enabled" + + +def resolve_layer(policy: Policy | None, urn: str, layer: str | CacheLayer) -> LayerResolution: + """Resolve one serving layer; malformed leaves disable only that layer.""" + if layer not in ("local", "remote"): + raise ConfigError(f"Unknown cache layer: {layer}") + age = UNSET if policy is None else policy.stale_on_error_max_age_sec + recovery_off = age is UNSET or (is_safe_integer(age) and age == 0) + if policy is None or layer not in policy.ttl_sec: + return LayerResolution( + "disabled", + "policy_disabled", + stale_on_error_config_error=layer == "remote" and not recovery_off, + ) + ttl = policy.ttl_sec[layer] + if not is_supported_cache_ttl_sec(ttl): + return LayerResolution("disabled", "invalid_ttl") + ramp = policy.ramp.get(layer, 100) + if not _valid_ramp(ramp): + return LayerResolution("disabled", "invalid_ramp") + enabled = ramp >= 100 or (ramp > 0 and deterministic_ramp_sample(urn, str(layer)) < ramp) + recovery = None + recovery_error = False + if layer == "remote" and not recovery_off: + if is_supported_cache_ttl_sec(age) and age > ttl: + recovery = int(age) + else: + recovery_error = True + return LayerResolution( + status="enabled" if enabled else "disabled", + reason=None if enabled else "ramped_down", + ttl_sec=int(ttl), + ramp=float(ramp), + stale_on_error_max_age_sec=recovery, + stale_on_error_config_error=recovery_error, + ) diff --git a/python/dialcache/context.py b/python/dialcache/context.py new file mode 100644 index 00000000..402b6770 --- /dev/null +++ b/python/dialcache/context.py @@ -0,0 +1,133 @@ +"""Per-instance request scope with a shared, explicitly closed memo holder.""" + +from __future__ import annotations + +from contextvars import ContextVar, Token +from dataclasses import dataclass, field +from types import TracebackType +from typing import Any + + +@dataclass +class RequestLocalCache: + """Request values and flights; closing prevents all late publication.""" + + in_flight: dict[str, Any] = field(default_factory=dict) + _values: dict[str, Any] = field(default_factory=dict) + closed: bool = False + + def read(self, key: str) -> tuple[bool, Any]: + if self.closed or key not in self._values: + return False, None + return True, self._values[key] + + def set(self, key: str, value: Any) -> None: + if not self.closed: + self._values[key] = value + + def close(self) -> None: + self.closed = True + self._values.clear() + self.in_flight.clear() + + +@dataclass +class _Holder: + closed: bool = False + memo: RequestLocalCache | None = None + + def close(self) -> None: + self.closed = True + if self.memo is not None: + self.memo.close() + self.memo = None + + +@dataclass(frozen=True) +class _Store: + enabled: bool + holder: _Holder | None + + +class _Scope: + def __init__(self, context: DialCacheContext, enabled: bool) -> None: + self._context = context + self._enabled = enabled + self._token: Token[_Store | None] | None = None + self._owned: _Holder | None = None + self._entered = False + + def __enter__(self) -> _Scope: + if self._entered: + raise RuntimeError("A DialCache scope context manager can only be entered once") + self._entered = True + holder = self._context._live_holder() + if self._enabled and holder is None: + holder = self._owned = _Holder() + self._token = self._context._storage.set(_Store(self._enabled, holder)) + return self + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> None: + if self._token is None: + raise RuntimeError("DialCache scope was not entered or was already closed") + if self._owned is not None: + self._owned.close() + self._context._storage.reset(self._token) + self._token = None + + async def __aenter__(self) -> _Scope: + return self.__enter__() + + async def __aexit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.__exit__(exception_type, exception, traceback) + + +class DialCacheContext: + """An independently enabled context for one cache instance. + + Both ``with context.enable():`` and ``async with context.enable():`` are + supported. Async tasks inherit the live holder, but calls made after its + outer scope exits are disabled, even from a copied context. + """ + + def __init__(self) -> None: + self._storage: ContextVar[_Store | None] = ContextVar("dialcache_scope", default=None) + + def _live_holder(self) -> _Holder | None: + store = self._storage.get() + if store is None or store.holder is None or store.holder.closed: + return None + return store.holder + + def is_enabled(self) -> bool: + store = self._storage.get() + return store is not None and store.enabled and self._live_holder() is not None + + def enable(self) -> _Scope: + return _Scope(self, True) + + def disable(self) -> _Scope: + return _Scope(self, False) + + def request_cache(self) -> RequestLocalCache | None: + if not self.is_enabled(): + return None + holder = self._live_holder() + assert holder is not None + if holder.memo is None: + holder.memo = RequestLocalCache() + return holder.memo + + +def get_or_create_request_local_cache(context: DialCacheContext) -> RequestLocalCache | None: + return context.request_cache() diff --git a/python/dialcache/errors.py b/python/dialcache/errors.py new file mode 100644 index 00000000..72dc8a35 --- /dev/null +++ b/python/dialcache/errors.py @@ -0,0 +1,46 @@ +"""Public errors raised by DialCache operations and configuration.""" + + +class DialCacheError(Exception): + """Base class for errors owned by DialCache.""" + + +class ConfigError(DialCacheError, ValueError): + """Invalid static configuration or malformed runtime policy.""" + + +class FallbackTimeoutError(DialCacheError, TimeoutError): + """The enabled source invocation exceeded its DialCache deadline.""" + + def __init__(self, use_case: str, timeout_ms: int) -> None: + self.use_case = use_case + self.timeout_ms = timeout_ms + super().__init__(f'DialCache fallback for use case "{use_case}" timed out after {timeout_ms} ms') + + +class RemoteReadTimeoutError(DialCacheError, TimeoutError): + """DialCache stopped waiting for a remote read.""" + + def __init__(self, use_case: str, timeout_ms: int) -> None: + self.use_case = use_case + self.timeout_ms = timeout_ms + super().__init__(f'DialCache Redis read for use case "{use_case}" timed out after {timeout_ms} ms') + + +RedisReadTimeoutError = RemoteReadTimeoutError + + +class UseCaseIsAlreadyRegisteredError(DialCacheError): + def __init__(self, use_case: str) -> None: + self.use_case = use_case + super().__init__(f"Use case already registered: {use_case}") + + +class UseCaseNameIsReservedError(DialCacheError): + def __init__(self, use_case: str) -> None: + self.use_case = use_case + super().__init__(f"Use case name is reserved: {use_case}") + + +class MissingRemoteError(DialCacheError): + """An explicit remote maintenance operation has no remote adapter.""" diff --git a/python/dialcache/key.py b/python/dialcache/key.py new file mode 100644 index 00000000..7be630e7 --- /dev/null +++ b/python/dialcache/key.py @@ -0,0 +1,149 @@ +"""Portable DialCache identity, URI escaping, and deterministic cohorts.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from urllib.parse import quote + +from .serializer import UNDEFINED + + +def _integer_string(value: int) -> str: + # Python's configurable decimal-digit guard must not truncate bigint identity. + negative = value < 0 + value = abs(value) + parts: list[int] = [] + while value >= 1_000_000_000: + value, remainder = divmod(value, 1_000_000_000) + parts.append(remainder) + result = str(value) + "".join(f"{part:09d}" for part in reversed(parts)) + return "-" + result if negative else result + + +def scalar_string(value: object) -> str: + """JavaScript-compatible scalar spelling; Python integers retain all digits. + + Python's shortest-round-trip float digits use the same nearest-even rule; + ECMAScript differs in the decimal/exponent presentation thresholds. + """ + if isinstance(value, str): + return value + if value is None: + return "null" + if value is True: + return "true" + if value is False: + return "false" + if isinstance(value, int): + return _integer_string(value) + if isinstance(value, float): + if math.isnan(value): + return "NaN" + if math.isinf(value): + return "-Infinity" if value < 0 else "Infinity" + if value == 0: + return "0" + sign = "-" if value < 0 else "" + raw = repr(abs(value)).lower() + coefficient, _, exponent = raw.partition("e") + whole, _dot, fraction = coefficient.partition(".") + digits = (whole + fraction).lstrip("0") + position = len(whole) + (int(exponent) if exponent else 0) + if whole == "0": + position -= len(whole + fraction) - len(digits) + digits = digits.rstrip("0") + if 0 < position <= 21: + return sign + ( + digits[:position] + "." + digits[position:] + if position < len(digits) + else digits + "0" * (position - len(digits)) + ) + if -6 < position <= 0: + return sign + "0." + "0" * -position + digits + mantissa = digits[0] + ("." + digits[1:] if len(digits) > 1 else "") + power = position - 1 + return sign + mantissa + "e" + ("+" if power >= 0 else "") + str(power) + raise TypeError("Cache key scalar must be str, int, float, bool, or None") + + +def _scalar_text(value: str, *, replace: bool = False) -> str: + """Combine explicit UTF-16 pairs; reject (keys) or replace (payloads) lone units.""" + if not isinstance(value, str): + raise TypeError("Cache key components must be strings") + return value.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "replace" if replace else "strict") + + +def encode_component(value: str) -> str: + return quote(_scalar_text(value), safe="~!*'()-._", encoding="utf-8", errors="strict") + + +def normalize_args(args: Mapping[str, object]) -> tuple[tuple[str, str], ...]: + """Omit UNDEFINED and sort names by UTF-16 units; None remains literal null.""" + pairs = [(name, scalar_string(value)) for name, value in args.items() if value is not UNDEFINED] + pairs.sort(key=lambda pair: pair[0].encode("utf-16-be", "surrogatepass")) + return tuple(pairs) + + +def invalidation_prefix(namespace: str, key_type: str, id: object) -> str: + entity_id = scalar_string(id) + for name, value in [("namespace", namespace), ("key_type", key_type), ("id", entity_id)]: + if "{" in value or "}" in value: + raise ValueError(f"Tracked {name} must not contain braces") + return ":".join(encode_component(part) for part in (namespace, key_type, entity_id)) + + +@dataclass(frozen=True) +class Key: + namespace: str + key_type: str + id: object + use_case: str + args: Sequence[tuple[str, str]] = () + tracked: bool = False + prefix: str = field(init=False) + logical: str = field(init=False) + value_key: str = field(init=False) + watermark_key: str | None = field(init=False) + + def __post_init__(self) -> None: + if "{" in self.namespace or "}" in self.namespace: + raise ValueError("DialCache namespace must not contain braces") + entity_id = scalar_string(self.id) + pairs = tuple((name, value) for name, value in self.args) + if self.tracked: + prefix = "{" + invalidation_prefix(self.namespace, self.key_type, entity_id) + "}" + else: + prefix = ":".join(encode_component(part) for part in (self.namespace, self.key_type, entity_id)) + query = ( + "?" + "&".join(f"{encode_component(name)}={encode_component(value)}" for name, value in pairs) + if pairs + else "" + ) + logical = prefix + query + "#" + encode_component(self.use_case) + object.__setattr__(self, "id", entity_id) + object.__setattr__(self, "args", pairs) + object.__setattr__(self, "prefix", prefix) + object.__setattr__(self, "logical", logical) + object.__setattr__(self, "value_key", logical + ":dialcache-frame-v1") + object.__setattr__(self, "watermark_key", prefix + "#watermark" if self.tracked else None) + + @property + def urn(self) -> str: + return self.logical + + def __str__(self) -> str: + return self.logical + + +def ramp_hash(key: Key | str, discriminator: str) -> int: + units = (str(key) + ":" + discriminator).encode("utf-16-le", "surrogatepass") + hashed = 0x811C9DC5 + for offset in range(0, len(units), 2): + hashed = ((hashed ^ (units[offset] | units[offset + 1] << 8)) * 0x01000193) & 0xFFFFFFFF + return hashed + + +def ramp_sample(key: Key | str, discriminator: str) -> float: + return ramp_hash(key, discriminator) / 0x1_0000_0000 * 100 diff --git a/python/dialcache/local.py b/python/dialcache/local.py new file mode 100644 index 00000000..a9e812bc --- /dev/null +++ b/python/dialcache/local.py @@ -0,0 +1,77 @@ +"""Bounded process-local LRU storage with whole-millisecond expiry.""" + +from __future__ import annotations + +import math +from collections import OrderedDict +from dataclasses import dataclass +from typing import Any + +from .clock import Clock, SystemClock +from .errors import ConfigError + +MISSING = object() +MAX_SAFE_INTEGER = 9_007_199_254_740_991 + + +@dataclass(frozen=True) +class _Entry: + value: Any + expires_at_ms: int + + +class LocalCache: + """Local entries are shared by the use cases on one cache instance. + + Reads promote recency but never renew expiration. ``None`` is a present + value. A zero capacity disables storage while leaving policy eligibility + and coalescing to the core engine. + """ + + def __init__(self, max_size: int = 10_000, clock: Clock | None = None) -> None: + if ( + isinstance(max_size, bool) + or not isinstance(max_size, int) + or max_size < 0 + or max_size > MAX_SAFE_INTEGER + ): + raise ConfigError("local_max_size must be a nonnegative safe integer") + self.max_size = max_size + self.clock = clock if clock is not None else SystemClock() + self._entries: OrderedDict[str, _Entry] = OrderedDict() + + def _get_entry(self, key: str, now_ms: float | None = None) -> _Entry | None: + entry = self._entries.get(key) + if entry is None: + return None + now = math.floor(self.clock.monotonic_ms() if now_ms is None else now_ms) + if now >= entry.expires_at_ms: + del self._entries[key] + return None + self._entries.move_to_end(key) + return entry + + def get(self, key: str, now_ms: float | None = None) -> Any: + entry = self._get_entry(key, now_ms) + return MISSING if entry is None else entry.value + + def read(self, key: str) -> tuple[bool, Any]: + entry = self._get_entry(key) + return (False, None) if entry is None else (True, entry.value) + + def put(self, key: str, value: Any, ttl_sec: int) -> None: + if self.max_size == 0: + return + from .config import cache_ttl_sec_to_ms + + ttl_ms = cache_ttl_sec_to_ms(ttl_sec) + self._entries[key] = _Entry(value, math.floor(self.clock.monotonic_ms()) + ttl_ms) + self._entries.move_to_end(key) + while len(self._entries) > self.max_size: + self._entries.popitem(last=False) + + def clear(self) -> None: + self._entries.clear() + + def __len__(self) -> int: + return len(self._entries) diff --git a/python/dialcache/metrics.py b/python/dialcache/metrics.py new file mode 100644 index 00000000..42b88fd9 --- /dev/null +++ b/python/dialcache/metrics.py @@ -0,0 +1,38 @@ +"""Backend-neutral metrics observer contract.""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable, Mapping +from typing import Any, Protocol, TypeAlias + +MetricEvent: TypeAlias = dict[str, Any] + + +class MetricsObserver(Protocol): + """Receive bounded events with the shared DialCache diagnostic labels. + + Events use the cross-port names, including ``cacheNamespace``, ``useCase`` + and ``keyType``. Observers are synchronous. Their failures never change a + cache call's outcome. + """ + + def observe(self, event: MetricEvent) -> None: ... + + +Metrics: TypeAlias = MetricsObserver | Callable[[MetricEvent], None] + + +def emit_metric(metrics: Metrics | None, event: str | Mapping[str, Any], **labels: Any) -> None: + if metrics is None: + return + payload = {"event": event, **labels} if isinstance(event, str) else dict(event) + try: + callback = metrics if callable(metrics) else metrics.observe + result = callback(payload) + # An async observer violates the synchronous contract. Do not schedule + # it, and avoid leaving an un-awaited coroutine warning behind. + if inspect.iscoroutine(result): + result.close() + except Exception: + pass diff --git a/python/dialcache/protocol.py b/python/dialcache/protocol.py new file mode 100644 index 00000000..98a4bc2f --- /dev/null +++ b/python/dialcache/protocol.py @@ -0,0 +1,251 @@ +"""DialCache version-1 frames and payload envelopes, independent of Redis clients.""" + +from __future__ import annotations + +import math +import re +from dataclasses import dataclass, field + +from .errors import DialCacheError +from .key import _scalar_text +from .serializer import Payload + +MAX_SAFE_INTEGER = 9_007_199_254_740_991 +MAX_SUPPORTED_DURATION_MS = 31_536_000_000 +MAX_TRACKED_REDIS_VALUE_TTL_MS = 3_600_000 +MAX_DECOMPRESSED_BYTES = 512 * 1024 * 1024 + + +class RedisPayloadError(DialCacheError): + """A Redis reply is not a bulk byte string or nil.""" + + +class RedisPayloadEncodingError(DialCacheError): + """An eligible frame carries an unsupported payload encoding.""" + + +class RedisProtocolError(DialCacheError): + """Redis returned an unexpected command reply.""" + + +@dataclass(frozen=True) +class Frame: + created_at_ms: int + payload: Payload + + +@dataclass(frozen=True) +class Miss: + reason: str + observed_watermark_ms: int | None = None + kind: str = field(default="miss", init=False) + + +ReadResult = Frame | Miss + + +def valid_timestamp(value: object) -> bool: + return ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and 0 <= value <= MAX_SAFE_INTEGER + and value == int(value) + ) + + +def validate_timestamp(value: object) -> int: + if not valid_timestamp(value): + raise ValueError("DialCache timestamp must be a nonnegative safe integer") + return int(value) # type: ignore[arg-type] + + +def ceil_supported_cache_ttl_ms(value: float) -> int: + if ( + not isinstance(value, (int, float)) + or isinstance(value, bool) + or not 0 < value <= MAX_SUPPORTED_DURATION_MS + ): + raise ValueError("DialCache Redis TTL must be a positive finite duration") + result = math.ceil(value) + if not 0 < result <= MAX_SUPPORTED_DURATION_MS: + raise ValueError("DialCache Redis TTL must be positive and no greater than 365 days") + return result + + +def validate_future_buffer_ms(value: object) -> int: + result = validate_timestamp(value) + if result > MAX_SUPPORTED_DURATION_MS: + raise ValueError("DialCache future buffer must be no greater than 365 days") + return result + + +def utf8_bytes(value: str) -> bytes: + return _scalar_text(value, replace=True).encode("utf-8") + + +def _payload_bytes(payload: Payload) -> bytes: + if isinstance(payload, bytes): + return payload + if isinstance(payload, str): + return utf8_bytes(payload) + raise TypeError("DialCache serializer payload must be str or immutable bytes") + + +def encode_frame(payload: Payload, created_at_ms: int) -> bytes: + timestamp = validate_timestamp(created_at_ms) + return ( + b"\x01" + + timestamp.to_bytes(8, "big") + + bytes([int(isinstance(payload, bytes))]) + + _payload_bytes(payload) + ) + + +def _bulk(raw: object) -> bytes | None: + if raw is None or isinstance(raw, bytes): + return raw + raise RedisPayloadError("Invalid Redis read reply; expected immutable bytes or None") + + +def _supported(raw: bytes) -> bool: + return len(raw) >= 10 and raw[0] == 1 + + +def _watermark(raw: bytes | None) -> int | None: + if raw is None or not re.fullmatch(rb"[0-9]+", raw): + return None + # Avoid Python's decimal conversion guard on hostile or huge numeric strings. + digits = raw.lstrip(b"0") or b"0" + if len(digits) > 16 or (len(digits) == 16 and digits > b"9007199254740991"): + return None + return int(digits) + + +def _frame(raw: bytes) -> Frame: + tag = raw[9] + if tag == 0: + payload: Payload = raw[10:].decode("utf-8", errors="replace") + elif tag == 1: + payload = raw[10:] + else: + raise RedisPayloadEncodingError("Invalid DialCache Redis payload encoding") + return Frame(int.from_bytes(raw[1:9], "big"), payload) + + +def decode_read(raw: object) -> ReadResult: + frame = _bulk(raw) + if frame is None: + return Miss("value_absent") + if not _supported(frame): + return Miss("unclassified") + return _frame(frame) + + +def decode_tracked_read(raw: object, raw_watermark: object) -> ReadResult: + frame, watermark_bytes = _bulk(raw), _bulk(raw_watermark) + watermark = _watermark(watermark_bytes) + if frame is None: + return Miss("value_absent", watermark) + if not _supported(frame): + return Miss("unclassified", watermark) + if watermark_bytes is not None and watermark is None: + return Miss("unclassified") + timestamp = int.from_bytes(frame[1:9], "big") + if timestamp == 0: + return Miss("unclassified", watermark) + if watermark is not None and timestamp <= watermark: + return Miss("watermark_fenced", watermark) + return _frame(frame) + + +def validate_set_reply(reply: object) -> None: + # redis-py maps the native OK status to True through its SET response callback. + if reply is not True and reply not in ("OK", b"OK"): + raise RedisProtocolError("Invalid Redis SET reply; expected OK") + + +def validate_invalidation_reply(reply: object) -> None: + if type(reply) is not int or reply != 1: + raise RedisProtocolError("Invalid Redis invalidate reply; expected integer 1") + + +@dataclass(frozen=True) +class CompressionResult: + payload: Payload + outcome: str + original_bytes: int + stored_bytes: int + + +@dataclass(frozen=True) +class DecompressionResult: + payload: Payload + outcome: str + + +def escape_raw_payload(payload: Payload) -> Payload: + _payload_bytes(payload) + return b"\x00" + payload if isinstance(payload, bytes) and payload and payload[0] <= 2 else payload + + +def compress_payload( + payload: Payload, threshold_bytes: int = 4096, level: int = 3, maximum: int = MAX_DECOMPRESSED_BYTES +) -> CompressionResult: + import zstandard + + raw = _payload_bytes(payload) + escaped = escape_raw_payload(payload) + stored_size = len(_payload_bytes(escaped)) + if len(raw) < threshold_bytes: + return CompressionResult(escaped, "below_threshold", len(raw), stored_size) + if len(raw) > maximum: + return CompressionResult(escaped, "write_over_limit", len(raw), stored_size) + encoded = zstandard.ZstdCompressor(level=level).compress(raw) + if len(encoded) + 1 >= stored_size: + return CompressionResult(escaped, "not_smaller", len(raw), stored_size) + result = bytes([2 if isinstance(payload, bytes) else 1]) + encoded + return CompressionResult(result, "compressed", len(raw), len(result)) + + +def decompress_payload(payload: Payload, maximum: int = MAX_DECOMPRESSED_BYTES) -> DecompressionResult: + if not isinstance(payload, bytes) or not payload: + return DecompressionResult(payload, "passthrough") + marker = payload[0] + if marker == 0: + value = payload[1:] if len(payload) > 1 and payload[1] <= 2 else payload + return DecompressionResult(value, "passthrough") + if marker not in (1, 2): + return DecompressionResult(payload, "passthrough") + import io + + import zstandard + + try: + encoded = payload[1:] + content_size = zstandard.frame_content_size(encoded) + unknown_size = content_size in (-1, zstandard.CONTENTSIZE_UNKNOWN) + if unknown_size or content_size > maximum: + # The one-shot decoder ignores max_output_size when the header + # declares a size. Probe at most cap+1 bytes before allowing an + # allocation, and stop at the first frame just as Node does. A + # truncated large frame remains fallback_raw rather than being + # classified from an untrusted header alone. + with zstandard.ZstdDecompressor().stream_reader( + io.BytesIO(encoded), read_across_frames=False + ) as reader: + prefix = reader.read(maximum + 1) + if len(prefix) > maximum: + return DecompressionResult(payload, "read_over_limit") + del prefix + if not unknown_size: + return DecompressionResult(payload, "fallback_raw") + decoded = zstandard.ZstdDecompressor().decompress( + encoded, max_output_size=max(1, maximum), allow_extra_data=True + ) + if len(decoded) > maximum: + return DecompressionResult(payload, "read_over_limit") + except (zstandard.ZstdError, ValueError, OverflowError, OSError): + return DecompressionResult(payload, "fallback_raw") + return DecompressionResult( + decoded.decode("utf-8", errors="replace") if marker == 1 else decoded, "decompressed" + ) diff --git a/python/dialcache/py.typed b/python/dialcache/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/python/dialcache/redis.py b/python/dialcache/redis.py new file mode 100644 index 00000000..e984e630 --- /dev/null +++ b/python/dialcache/redis.py @@ -0,0 +1,185 @@ +"""Semantic Redis boundary and a resource-free adapter for redis.asyncio clients. + +The application owns connections and finite socket/retry budgets. Cancellation +can stop a Python wait but cannot retract a dispatched Redis command. Writes +may have executed after a connection error. Invalidation retries the idempotent +script once with EVAL, preserving its original timestamp. This adapter never +connects, disconnects, flushes, or closes the borrowed client. +""" + +from __future__ import annotations + +import asyncio +import hashlib +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, Protocol + +from .protocol import ( + ReadResult, + RedisProtocolError, + ceil_supported_cache_ttl_ms, + decode_read, + decode_tracked_read, + encode_frame, + validate_future_buffer_ms, + validate_invalidation_reply, + validate_set_reply, + validate_timestamp, +) +from .serializer import Payload + + +@dataclass(frozen=True) +class ReadRequest: + value_key: str + watermark_key: str | None = None + + +class AbortSignal(Protocol): + @property + def aborted(self) -> bool: ... + def add_callback(self, callback: Callable[[], Any]) -> None: ... + + +@dataclass(frozen=True) +class ReadContext: + timeout_ms: float + signal: AbortSignal | None = None + + +@dataclass(frozen=True) +class WriteRequest: + value_key: str + cache_ttl_ms: float + value: Payload + created_at_ms: int + + +@dataclass(frozen=True) +class InvalidationRequest: + watermark_key: str + future_buffer_ms: int + invalidated_at_ms: int + + +class RedisClient(Protocol): + def read( + self, request: ReadRequest, context: ReadContext | None = None + ) -> ReadResult | Awaitable[ReadResult]: ... + def write(self, request: WriteRequest) -> None | Awaitable[None]: ... + def invalidate(self, request: InvalidationRequest) -> None | Awaitable[None]: ... + + +# Same atomic transition as the TypeScript/Go/Rust adapters. Values never write +# watermarks. The clock sample belongs to one logical invalidation invocation. +INVALIDATE_CACHE_SCRIPT = """local function parse_safe_integer(raw) + if not string.match(raw, "^%d+$") then + return nil + end + local value = tonumber(raw) + if not value or value > 9007199254740991 then + return nil + end + return value +end + +local future_buffer_ms = parse_safe_integer(ARGV[1]) +if not future_buffer_ms or future_buffer_ms < 0 or future_buffer_ms > 31536000000 then + return redis.error_reply("ERR invalid DialCache future buffer") +end +local invalidated_at_ms = parse_safe_integer(ARGV[2]) +if not invalidated_at_ms or invalidated_at_ms > 9007199254740991 - future_buffer_ms then + return redis.error_reply("ERR invalid DialCache invalidatedAtMs") +end + +local proposed_watermark = invalidated_at_ms + future_buffer_ms +local raw_watermark = redis.pcall("GET", KEYS[1]) +if type(raw_watermark) == "table" and raw_watermark.err then + if not string.match(raw_watermark.err, "^WRONGTYPE ") then + return raw_watermark + end + -- A wrong-type key cannot contain a valid watermark. Treat it as absent so + -- the final SET repairs it, while preserving every other Redis error. + raw_watermark = false +end +local current_watermark = 0 + +if raw_watermark then + local parsed_watermark = parse_safe_integer(raw_watermark) + if parsed_watermark then + current_watermark = parsed_watermark + end +end + +local watermark = math.max(current_watermark, proposed_watermark) +local current_ttl_ms = -2 +if raw_watermark then + current_ttl_ms = redis.call("PTTL", KEYS[1]) +end +local desired_ttl_ms = math.max( + 7200000, + watermark - invalidated_at_ms + 3600000 + 60000 +) +if current_ttl_ms > desired_ttl_ms then + desired_ttl_ms = current_ttl_ms +end + +local encoded_watermark = string.format("%.0f", watermark) +if current_ttl_ms == -1 then + redis.call("SET", KEYS[1], encoded_watermark) +else + redis.call("SET", KEYS[1], encoded_watermark, "PX", desired_ttl_ms) +end + +return 1""" +INVALIDATE_CACHE_SCRIPT_SHA1 = hashlib.sha1(INVALIDATE_CACHE_SCRIPT.encode()).hexdigest() + + +class RedisAdapter: + """Borrow a redis.asyncio.Redis or RedisCluster with decode_responses=False. + + Cluster reads explicitly select the key's primary, even when the supplied + cluster client uses replica reads. Tracked MGET is one atomic snapshot. + ReadContext is informational; core owns its authoritative deadline. + """ + + def __init__(self, client: Any) -> None: + self.client = client + + async def _command(self, key: str, *arguments: object) -> Any: + options: dict[str, object] = {} + if hasattr(self.client, "get_node_from_key"): + # RedisCluster initializes its topology lazily. Explicit routing + # must wait for that initialization before looking up the primary. + await self.client.initialize() + options["target_nodes"] = self.client.get_node_from_key(key, replica=False) + return await self.client.execute_command(*arguments, **options) + + async def read(self, request: ReadRequest, context: ReadContext | None = None) -> ReadResult: + if context is not None and context.signal is not None and context.signal.aborted: + raise asyncio.CancelledError() + if request.watermark_key is None: + return decode_read(await self._command(request.value_key, "GET", request.value_key)) + result = await self._command(request.value_key, "MGET", request.value_key, request.watermark_key) + if not isinstance(result, (list, tuple)) or len(result) != 2: + raise RedisProtocolError("Invalid Redis MGET reply; expected two bulk strings") + return decode_tracked_read(result[0], result[1]) + + async def write(self, request: WriteRequest) -> None: + ttl = ceil_supported_cache_ttl_ms(request.cache_ttl_ms) + frame = encode_frame(request.value, request.created_at_ms) + result = await self._command(request.value_key, "SET", request.value_key, frame, "PX", str(ttl)) + validate_set_reply(result) + + async def invalidate(self, request: InvalidationRequest) -> None: + buffer = validate_future_buffer_ms(request.future_buffer_ms) + timestamp = validate_timestamp(request.invalidated_at_ms) + args = ("1", request.watermark_key, str(buffer), str(timestamp)) + try: + result = await self._command( + request.watermark_key, "EVALSHA", INVALIDATE_CACHE_SCRIPT_SHA1, *args + ) + except Exception: # noqa: BLE001 -- Any EVALSHA rejection gets one idempotent recovery. + result = await self._command(request.watermark_key, "EVAL", INVALIDATE_CACHE_SCRIPT, *args) + validate_invalidation_reply(result) diff --git a/python/dialcache/serializer.py b/python/dialcache/serializer.py new file mode 100644 index 00000000..4e8e8ef2 --- /dev/null +++ b/python/dialcache/serializer.py @@ -0,0 +1,55 @@ +"""Caller-supplied serialization and the portable JSON value binding.""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable +from typing import Protocol, TypeVar + +T = TypeVar("T") +Payload = str | bytes + + +class _Undefined: + __slots__ = () + + def __repr__(self) -> str: + return "UNDEFINED" + + +UNDEFINED = _Undefined() +JSON_UNDEFINED_SENTINEL = "__dialcache_json_undefined_v1__" + + +class Serializer(Protocol[T]): + """Payloads are immutable. Asynchronous methods need an application deadline.""" + + def dump(self, value: T) -> Payload | Awaitable[Payload]: ... + def load(self, value: Payload) -> T | Awaitable[T]: ... + + +class JsonSerializer: + """Compact native JSON, plus the cross-language top-level undefined sentinel. + + Nonfinite numbers and values outside JSON's domain are rejected. For custom + Python types, supply a Serializer with an explicit portable representation. + """ + + def dump(self, value: object) -> str: + if value is UNDEFINED: + return JSON_UNDEFINED_SENTINEL + payload = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + # JSON must escape lone surrogate units before the frame's UTF-8 text + # boundary replaces malformed text. This preserves JSON string values + # in the same way as well-formed ECMAScript JSON.stringify. + return payload.encode("utf-8", errors="backslashreplace").decode("utf-8") + + def load(self, value: Payload) -> object: + payload = value.decode("utf-8", errors="replace") if isinstance(value, bytes) else value + if payload == JSON_UNDEFINED_SENTINEL: + return UNDEFINED + return json.loads(payload, parse_constant=self._reject_constant) + + @staticmethod + def _reject_constant(value: str) -> object: + raise ValueError(f"Invalid JSON constant: {value}") diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 00000000..8ffabe60 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["hatchling>=1.26,<2"] +build-backend = "hatchling.build" + +[project] +name = "dialcache" +version = "0.1.0" +description = "Explicitly enabled async caching with runtime policies, coalescing, and tracked Redis invalidation." +readme = "README.md" +requires-python = ">=3.11" +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "Lev Neiman" }] +dependencies = ["zstandard>=0.23,<1"] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Framework :: AsyncIO", + "Topic :: Software Development :: Libraries :: Python Modules", +] + +[project.optional-dependencies] +redis = ["redis>=5,<7"] +test = ["pytest>=8,<10", "pytest-asyncio>=0.24,<2", "jsonschema>=4.23,<5"] + +[project.urls] +Repository = "https://github.com/lan17/DialCache" +Documentation = "https://github.com/lan17/DialCache/tree/main/python" + +[tool.hatch.build.targets.wheel] +packages = ["dialcache"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +testpaths = ["tests"] +markers = [ + "integration: requires a real Redis or Valkey server, with optional cluster or mixed-language coverage", +] + +[tool.ruff] +target-version = "py311" +line-length = 110 + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I"] diff --git a/python/tests/formal/__init__.py b/python/tests/formal/__init__.py new file mode 100644 index 00000000..194fd24c --- /dev/null +++ b/python/tests/formal/__init__.py @@ -0,0 +1 @@ +"""Native Python binding for the shared DialCache replay coordinator.""" diff --git a/python/tests/formal/coordinator.py b/python/tests/formal/coordinator.py new file mode 100644 index 00000000..14015b56 --- /dev/null +++ b/python/tests/formal/coordinator.py @@ -0,0 +1,123 @@ +"""Persistent, checked JSONL transport to the authoritative shared coordinator.""" + +from __future__ import annotations + +import json +import os +import select +import subprocess +from pathlib import Path + +from .schema import ROOT, strict_json, validate + + +def node_binary(): + return os.environ.get("NODE", "node") + + +class Coordinator: + def __init__(self): + self.process = subprocess.Popen( + [node_binary(), str(ROOT / "formal/replay/coordinator.mjs")], + cwd=ROOT, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + self.sequence = 0 + self.buffer = b"" + + def request(self, **fields): + self.sequence += 1 + request = {"version": 1, "id": self.sequence, **fields} + validate(request, "request") + self.process.stdin.write(json.dumps(request, separators=(",", ":"), allow_nan=False).encode() + b"\n") + while b"\n" not in self.buffer: + ready, _, _ = select.select([self.process.stdout], [], [], 30) + if not ready: + raise RuntimeError("Replay coordinator transport timeout") + chunk = os.read(self.process.stdout.fileno(), 65536) + if not chunk: + raise RuntimeError(f"Replay coordinator exited: {self.process.stderr.read().decode()}") + self.buffer += chunk + line, self.buffer = self.buffer.split(b"\n", 1) + response = strict_json(line) + validate(response, "response") + if response["version"] != 1 or response["id"] != self.sequence: + raise RuntimeError("Mismatched coordinator response version or id") + if not response["ok"]: + raise AssertionError(response["error"]) + return response["result"] + + def replay(self, profile, path, *, settle=True): + # Delayed imports allow schema and transport checks independently of the + # implementation. Expected records remain exclusively in Node. + from .driver import BehaviorDriver + from .simple_drivers import CoreDriver, LocalClockDriver + + prepared = self.request(op="prepare", profile=profile, path=str(Path(path).resolve())) + if prepared["settlement"] != "causally-ready-v1": + raise RuntimeError("Unsupported replay settlement contract") + driver = ( + CoreDriver() + if profile == "core" + else LocalClockDriver() + if profile == "local-clock" + else BehaviorDriver(prepared["fixture"], settle=settle) + ) + complete = False + try: + for command in prepared["setup"]: + driver.apply(command) + index = 0 + while True: + observation = driver.observe() + validate(observation, prepared["observation"]) + receipt = driver.receipt() + fields = {} + if prepared["receipt"] is not None: + validate(receipt, prepared["receipt"]) + fields["receipt"] = receipt + elif receipt is not None: + raise RuntimeError("Unexpected native settlement receipt") + result = self.request( + op="observe", + session=prepared["session"], + index=index, + settlement=prepared["settlement"], + observed=observation, + environment={"wallMs": driver.clock.wall_ms()}, + **fields, + ) + if result["complete"]: + complete = True + return result["steps"] + if result["index"] != index + 1: + raise RuntimeError("Skipped coordinator observation index") + index += 1 + for command in result["inputs"]: + driver.apply(command) + finally: + if not complete: + try: + self.request(op="discard", session=prepared["session"]) + except (RuntimeError, AssertionError): + pass + driver.close() + + def close(self): + self.process.stdin.close() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait() + self.process.stdout.close() + self.process.stderr.close() + + def __enter__(self): + return self + + def __exit__(self, *_): + self.close() diff --git a/python/tests/formal/driver.py b/python/tests/formal/driver.py new file mode 100644 index 00000000..59f08bac --- /dev/null +++ b/python/tests/formal/driver.py @@ -0,0 +1,534 @@ +"""Effects observed through the actual Python API, never expected model state.""" + +from __future__ import annotations + +import asyncio +import contextvars +import copy +import json +import math +from dataclasses import dataclass + +from dialcache import DialCache +from dialcache.errors import FallbackTimeoutError, MissingRemoteError +from dialcache.key import Key +from dialcache.local import LocalCache +from dialcache.protocol import Frame, Miss, decode_read, decode_tracked_read, encode_frame + +from .executor import WALL_EPOCH_MS, Executor + +ABSENT = object() + + +def empty_observation(fixture=None): + result = { + "calls": [], + "loaders": 0, + "reads": 0, + "writes": 0, + "invalidations": 0, + "maintenance": [], + "loads": 0, + "dumps": 0, + "policyCalls": 0, + "classifications": 0, + "comparisons": 0, + "sourceScopes": [], + "writeTtls": [], + "shadow": [], + "recovery": [], + } + if fixture is not None and "observe" in fixture: + result["events"] = [] + return result + + +def json_value(value): + return {"absent": True} if value is ABSENT else value + + +def dump_value(value): + return "undefined" if value is ABSENT else json.dumps(value, ensure_ascii=False, separators=(",", ":")) + + +class FakeRedis: + """Controlled atomic adapter using the real frame codec and elapsed TTLs.""" + + def __init__(self, clock, owner=None): + self.clock = clock + self.owner = owner + self.values = {} + self.reads = 0 + self.writes = 0 + self.fail_read = False + + def raw(self, key): + entry = self.values.get(key) + if entry is None: + return None + if entry[1] <= self.clock.monotonic_ms(): + del self.values[key] + return None + return entry[0] + + def seed(self, key, value, ttl=60_000): + self.values[key] = (value, self.clock.monotonic_ms() + ttl) + + async def read(self, request, context=None): + self.reads += 1 + if self.owner: + owner = self.owner + index = owner.count("reads") + if context is not None: + signal = context.signal + aborted = ( + (signal.aborted if hasattr(signal, "aborted") else signal.is_set()) + if signal is not None + else False + ) + owner.record("readContext", index=index, timeoutMs=context.timeout_ms, aborted=aborted) + if hasattr(signal, "add_callback"): + signal.add_callback(lambda: owner.record("readAbort", index=index)) + elif signal is not None: + + async def abort(): + await signal.wait() + owner.record("readAbort", index=index) + + owner.executor.task(abort()) + if owner.faults.get("holdReads"): + await owner.hold("read", index) + if owner.faults.get("read"): + raise RuntimeError("Controlled read failure") + if owner.adapter_reply is not ABSENT: + result, owner.adapter_reply = owner.adapter_reply, ABSENT + # Bind the coordinator's JSON record to the native adapter + # variants. Keep malformed reason/timestamp leaves intact so + # the production trust boundary, not the driver, validates them. + if isinstance(result, dict) and result.get("kind") == "miss": + return Miss(result.get("reason"), result.get("observedWatermarkMs")) + if isinstance(result, dict) and "createdAtMs" in result and "payload" in result: + return Frame(result["createdAtMs"], result["payload"]) + return copy.deepcopy(result) + if self.fail_read: + raise RuntimeError("Controlled read failure") + value = self.raw(request.value_key) + return ( + decode_read(value) + if request.watermark_key is None + else decode_tracked_read(value, self.raw(request.watermark_key)) + ) + + async def write(self, request): + # The writer stamps before the adapter gate, even if SET happens later. + stamped = encode_frame(request.value, request.created_at_ms) + self.writes += 1 + if self.owner: + owner = self.owner + index = owner.count("writes") + owner.record("writeDispatch", index=index) + owner.observed["writeTtls"].append(request.cache_ttl_ms) + if owner.faults.get("holdWrites"): + await owner.hold("write", index) + if owner.faults.get("write"): + raise owner.maintenance_error + self.seed(request.value_key, stamped, math.ceil(request.cache_ttl_ms)) + + async def invalidate(self, request): + self.writes += 1 + if self.owner: + self.owner.count("invalidations") + if self.owner.faults.get("write"): + raise self.owner.maintenance_error + raw = self.raw(request.watermark_key) + try: + old = int(raw) if raw is not None else 0 + except (TypeError, ValueError): + old = 0 + watermark = max(old, request.invalidated_at_ms + request.future_buffer_ms) + ttl = max( + self.ttl(request.watermark_key), + 7_200_000, + watermark - request.invalidated_at_ms + 3_600_000 + 60_000, + ) + self.seed(request.watermark_key, str(math.ceil(watermark)).encode(), ttl) + + def ttl(self, key): + entry = self.values.get(key) + return -2 if entry is None else max(0, entry[1] - self.clock.monotonic_ms()) + + +class Metrics: + def __init__(self, owner): + self.owner = owner + + def supports(self, event): + return event != "shadowValidation" or self.owner.fixture.get("shadowHook", True) + + def __call__(self, event): + owner = self.owner + event = dict(event) + kind = event.pop("event") + if kind == "shadowValidation" and self.supports(kind): + owner.observed["shadow"].append(event["outcome"]) + elif kind == "staleRecovery": + owner.observed["recovery"].append(event["outcome"]) + else: + owner.record(kind, **event) + if owner.fixture.get("observerFailure") or owner.faults.get("observer"): + raise RuntimeError("Controlled observer failure") + + +class Logger: + def __init__(self, owner): + self.owner = owner + + def warning(self, *args, **kwargs): + if self.owner.fixture.get("observerFailure") or self.owner.faults.get("observer"): + raise RuntimeError("Controlled observer failure") + + debug = error = warning + + +class Serializer: + def __init__(self, owner): + self.owner = owner + + async def dump(self, value): + index = self.owner.count("dumps") + if self.owner.faults.get("holdDumps"): + await self.owner.hold("dump", index) + if self.owner.faults.get("dump"): + raise RuntimeError("Controlled serialization failure") + return dump_value(value) + + async def load(self, raw): + index = self.owner.count("loads") + if self.owner.faults.get("holdLoads"): + await self.owner.hold("load", index) + if self.owner.faults.get("load"): + raise RuntimeError("Controlled deserialization failure") + text = raw.decode() if isinstance(raw, bytes) else raw + return ABSENT if text == "undefined" else json.loads(text) + + +class FaultingLocal: + def __init__(self, owner, max_size): + self.owner = owner + self.store = LocalCache(max_size=max_size, clock=owner.clock) + + def read(self, key): + if self.owner.faults.get("localStorage"): + raise RuntimeError("Controlled local storage failure") + return self.store.read(key) + + def put(self, key, value, ttl_sec): + if self.owner.faults.get("localStorage"): + raise RuntimeError("Controlled local storage failure") + self.store.put(key, value, ttl_sec) + + +@dataclass +class Scope: + instance: str + gate: asyncio.Future + lifetime: asyncio.Task | None = None + context: contextvars.Context | None = None + + +class BehaviorDriver: + def __init__(self, fixture, *, settle=True): + self.fixture = fixture + self.should_settle = settle + self.executor = Executor() + self.clock = self.executor.clock + self.observed = empty_observation(fixture) + self.reported = copy.deepcopy(self.observed) + self.faults = {} + self.effects = {name: {} for name in ("read", "write", "dump", "load", "policy")} + self.loaders = [] + self.source_errors = [] + self.timeout_errors = [] + self.scopes = {} + self.instances = {} + self.runtime_policy = {} + self.adapter_reply = ABSENT + self.maintenance_error = RuntimeError("Controlled mutation failure") + self.redis = FakeRedis(self.clock, self) + self.cache = self.instance("default") + self.serializer = Serializer(self) + self.settlement = self._receipt(0) + + def count(self, name): + value = self.observed[name] + self.observed[name] += 1 + return value + + def record(self, event, **fields): + if event in self.fixture.get("observe", []): + self.observed["events"].append({"event": event, **fields}) + + def classifier(self, outcome): + def classify(*args, **kwargs): + self.count("classifications") + if outcome == "error": + raise RuntimeError("Controlled classification failure") + return outcome == "allow" + + return classify + + def instance(self, name): + if name in self.instances: + return self.instances[name] + fixture = self.fixture + options = { + "redis": None if fixture.get("remote") is False else self.redis, + "policy_provider": self.policy_provider, + "clock": self.clock, + "metrics": Metrics(self), + "logger": Logger(self), + "compression": False, + } + for source, target in ( + ("localMaxSize", "local_max_size"), + ("shadowMaxInFlight", "shadow_max_in_flight"), + ): + if source in fixture: + options[target] = fixture[source] + if fixture.get("readTimeoutMs") != "default": + options["read_timeout_ms"] = fixture.get("readTimeoutMs", 50) + if fixture.get("recovery", "default") != "default": + options["should_attempt_stale_recovery"] = self.classifier(fixture["recovery"]) + if fixture.get("localFaultInjection"): + options["local_store"] = FaultingLocal(self, fixture.get("localMaxSize", 10000)) + cache = DialCache(**options) + self.instances[name] = cache + return cache + + async def policy_provider(self, *args, **kwargs): + index = self.count("policyCalls") + if self.faults.get("holdPolicies"): + await self.hold("policy", index) + if self.faults.get("policy"): + raise RuntimeError("Controlled policy failure") + return copy.deepcopy(self.runtime_policy) + + def hold(self, kind, index): + gate = self.executor.future() + self.effects[kind][index] = gate + return gate + + def begin(self, command): + index = len(self.observed["calls"]) + self.observed["calls"].append({"status": "pending"}) + scope = self.scopes.get(command.get("scope")) + cache = self.instance(command.get("instance", scope.instance if scope else "default")) + + def source(): + if self.fixture.get("probeSourceScope"): + self.observed["sourceScopes"].append(cache.is_enabled()) + gate = self.executor.future() + self.loaders.append(gate) + self.source_errors.append(RuntimeError(f"Source failure {len(self.source_errors)}")) + self.count("loaders") + self.clock.consume(self.fixture.get("sourceWorkMs", 0)) + return gate + + def compare(*args, **kwargs): + self.count("comparisons") + self.clock.consume(self.fixture.get("comparisonMs", 0)) + if self.fixture["comparator"] == "error": + raise RuntimeError("Controlled comparison failure") + return self.fixture["comparator"] == "equal" + + options = { + "key_type": "id", + "key": command.get("key", "1"), + "use_case": command.get("useCase", "Behavior"), + "serializer": self.serializer, + "track_for_invalidation": self.fixture.get("tracked", False), + "default_config": self.fixture["policy"], + } + if "recovery" in command: + options["should_attempt_stale_recovery"] = self.classifier(command["recovery"]) + if "comparator" in self.fixture: + options["shadow_comparator"] = compare + if self.fixture.get("fallbackTimeoutMs") != "default": + options["fallback_timeout_ms"] = self.fixture.get("fallbackTimeoutMs", 10) + + async def execute(): + if command.get("disabled"): + with cache.disable(): + return await cache.get_or_load(source, **options) + return await cache.get_or_load(source, **options) + + async def call(): + try: + if scope is not None or command.get("outside"): + value = await execute() + else: + with cache.enable(): + value = await execute() + self.observed["calls"][index] = {"status": "value", "value": json_value(value)} + except Exception as error: + self.observed["calls"][index] = {"status": "error", "error": self.classify(error)} + + self.executor.task(call(), scope.context if scope else None) + + def classify(self, error): + for index, source in enumerate(self.source_errors): + if error is source: + return f"source:{index}" + if isinstance(error, FallbackTimeoutError): + index = next((i for i, item in enumerate(self.timeout_errors) if item is error), None) + if index is None: + index = len(self.timeout_errors) + self.timeout_errors.append(error) + return f"timeout:{index}" + return f"unexpected:{error}" + + def value_key(self, command, *, tracked=None): + return Key( + namespace="urn", + key_type="id", + id=command.get("key", "1"), + use_case=command.get("useCase", "Behavior"), + tracked=self.fixture.get("tracked", False) if tracked is None else tracked, + ) + + def apply(self, command): + op = command["op"] + if op == "begin": + self.begin(command) + elif op == "resolve": + self.loaders[command["loader"]].set_result(command.get("value", ABSENT)) + elif op == "reject": + index = command["loader"] + if command.get("error") == "timeout": + self.source_errors[index] = FallbackTimeoutError("NestedSource", 10) + self.loaders[index].set_exception(self.source_errors[index]) + elif op == "advance": + self.clock.advance(command["ms"], command.get("deliverTimers", True)) + elif op == "shiftWall": + self.clock.wall += command["ms"] + elif op == "policy": + self.runtime_policy = command["value"] + elif op == "faults": + self.faults.update(command["value"]) + elif op == "adapterReply": + if self.adapter_reply is not ABSENT: + raise RuntimeError("Unconsumed adapter reply") + self.adapter_reply = command["value"] + elif op == "release": + gate = self.effects[command["effect"]].pop(command["index"]) + if command.get("fail"): + gate.set_exception(RuntimeError(f"Controlled {command['effect']} failure")) + else: + gate.set_result(None) + elif op == "seed": + if "frameHex" in command: + frame = bytes.fromhex(command["frameHex"]) + else: + payload = ( + bytes.fromhex(command["payloadHex"]) + if "payloadHex" in command + else command.get("payloadText", dump_value(command.get("value", ABSENT))) + ) + frame = encode_frame(payload, self.clock.wall_ms() - command.get("ageMs", 0)) + self.redis.seed(self.value_key(command).value_key, frame, command.get("ttlMs", 60_000)) + elif op == "invalidate": + + async def invalidate(): + try: + await self.cache.invalidate_remote( + "id", command.get("key", "1"), command.get("futureBufferMs", 0) + ) + self.observed["maintenance"].append("ok") + except Exception as error: + if error is self.maintenance_error: + self.observed["maintenance"].append("mutation_error") + elif self.fixture.get("remote") is False and isinstance( + error, (TypeError, ValueError, MissingRemoteError) + ): + self.observed["maintenance"].append("missing_remote") + else: + raise + + self.executor.finish(invalidate()) + elif op == "observeMarker": + key = self.value_key(command, tracked=True).watermark_key + raw = self.redis.raw(key) + self.record( + "marker", cutoffMs=-1 if raw is None else int(raw) - WALL_EPOCH_MS, ttlMs=self.redis.ttl(key) + ) + elif op == "inspectCoalescing": + name = command.get("instance", "default") + state = self.instance(name).get_coalescing_state()["process"] + self.record( + "coalescingState", + instance=name, + activeLeaders=state["active_leaders"], + activeFollowers=state["active_followers"], + oldestLeaderAgeMs=state["oldest_leader_age_ms"], + ) + elif op == "openScope": + name = command["id"] + if name in self.scopes: + raise RuntimeError(f"Duplicate scope {name}") + parent = self.scopes.get(command.get("parent")) + instance = command.get("instance", parent.instance if parent else "default") + cache = self.instance(instance) + scope = Scope(instance, self.executor.future()) + + async def lifetime(): + with cache.disable() if command.get("disabled") else cache.enable(): + scope.context = contextvars.copy_context() + await scope.gate + + scope.lifetime = self.executor.task(lifetime(), parent.context if parent else None) + self.scopes[name] = scope + elif op == "closeScope": + self.scopes[command["id"]].gate.set_result(None) + else: + raise RuntimeError(f"Unknown behavior command: {op}") + self.settle() + + def _receipt(self, runnable): + return { + "elapsedMs": self.clock.monotonic_ms(), + "runnable": runnable, + "held": { + "loaders": sum(not gate.done() for gate in self.loaders), + **{ + plural: len(self.effects[kind]) + for kind, plural in ( + ("read", "reads"), + ("write", "writes"), + ("dump", "dumps"), + ("load", "loads"), + ("policy", "policies"), + ) + }, + "scopes": sum(not scope.gate.done() for scope in self.scopes.values()), + }, + } + + def settle(self): + if self.should_settle: + self.executor.drain() + self.reported = copy.deepcopy(self.observed) + self.settlement = self._receipt(0) + # An additional drain directly counts work, including internal callbacks + # which produce no user-visible event. No guessed sleep/turn count. + self.settlement["runnable"] = self.executor.drain() + + def observe(self): + return copy.deepcopy(self.reported) + + def receipt(self): + return copy.deepcopy(self.settlement) + + def close(self): + # Teardown happens after assertions. Cancel held work and drain callback + # cleanup; no pending task is allowed to enter the next isolated loop. + self.executor.close() diff --git a/python/tests/formal/executor.py b/python/tests/formal/executor.py new file mode 100644 index 00000000..e07a5841 --- /dev/null +++ b/python/tests/formal/executor.py @@ -0,0 +1,140 @@ +"""Causally ready asyncio execution, without wall sleeps or fixed turn counts. + +Each history owns a SelectorEventLoop. Only callbacks already on its runnable +queue are drained; timers are explicit driver gates and are delivered solely by +the history's advance command. This module intentionally uses CPython's event +loop seam, not any cache internals or expected model state. +""" + +from __future__ import annotations + +import asyncio +import contextvars +import heapq +import threading +from dataclasses import dataclass, field +from typing import Callable + +WALL_EPOCH_MS = 1788868800000 + + +@dataclass(order=True) +class Timer: + due: float + sequence: int + callback: Callable = field(compare=False) + context: contextvars.Context = field(compare=False) + cancelled: bool = field(default=False, compare=False) + + def cancel(self): + self.cancelled = True + # Match asyncio.TimerHandle.cancel(): cancelled timer storage must not + # retain callback closure/context values until the original due time. + self.callback = None + self.context = None + + +class ControlledClock: + def __init__(self, executor): + self.executor = executor + self.elapsed = 0.0 + self.wall = float(WALL_EPOCH_MS) + self.timer_time = 0.0 + self.timers = [] + self.sequence = 0 + + def wall_ms(self): + return self.wall + + def monotonic_ms(self): + return self.elapsed + + def call_later(self, ms, callback): + self.sequence += 1 + timer = Timer(self.timer_time + max(0, ms), self.sequence, callback, contextvars.copy_context()) + heapq.heappush(self.timers, timer) + return timer + + async def sleep_ms(self, ms): + gate = asyncio.get_running_loop().create_future() + timer = self.call_later(ms, lambda: None if gate.done() else gate.set_result(None)) + try: + await gate + finally: + timer.cancel() + + def consume(self, ms): + # Synchronous external work and silent shifts consume elapsed time but + # cannot deliver timer callbacks while the callback is still running. + self.elapsed += ms + self.wall += ms + + def advance(self, ms, deliver=True): + if not deliver: + self.consume(ms) + return + target = self.timer_time + ms + while self.timers and self.timers[0].due <= target: + timer = heapq.heappop(self.timers) + if timer.cancelled: + continue + delta = max(0, timer.due - self.timer_time) + self.timer_time += delta + self.consume(delta) + self.executor.loop.call_soon(timer.callback, context=timer.context) + self.executor.drain() + delta = target - self.timer_time + self.timer_time = target + self.consume(delta) + + +class Executor: + def __init__(self): + self.loop = asyncio.SelectorEventLoop() + self.clock = ControlledClock(self) + self.errors = [] + self.loop.set_exception_handler(lambda loop, context: self.errors.append(context)) + + def task(self, coroutine, context=None): + if context is None: + return self.loop.create_task(coroutine) + return context.run(self.loop.create_task, coroutine) + + def future(self): + return self.loop.create_future() + + def drain(self): + """Run the complete causal closure of ready callbacks at zero time.""" + previous = asyncio.events._get_running_loop() + previous_thread = self.loop._thread_id + asyncio.events._set_running_loop(self.loop) + self.loop._thread_id = threading.get_ident() + callbacks = 0 + try: + while self.loop._ready: + callbacks += sum(not item._cancelled for item in self.loop._ready) + self.loop._run_once() + if callbacks > 1_000_000: + raise RuntimeError("Native executor did not reach quiescence") + finally: + self.loop._thread_id = previous_thread + asyncio.events._set_running_loop(previous) + if self.errors: + context = self.errors.pop(0) + raise RuntimeError(f"Unhandled native task: {context.get('message')}") from context.get( + "exception" + ) + return callbacks + + def finish(self, coroutine, context=None): + task = self.task(coroutine, context) + self.drain() + if not task.done(): + raise RuntimeError("Synchronous replay command remains blocked") + return task.result() + + def close(self): + for task in asyncio.all_tasks(self.loop): + task.cancel() + self.drain() + self.loop.close() diff --git a/python/tests/formal/scenarios.py b/python/tests/formal/scenarios.py new file mode 100644 index 00000000..c958eb14 --- /dev/null +++ b/python/tests/formal/scenarios.py @@ -0,0 +1,42 @@ +"""Replay the shared fixed supplement, keeping expectations outside drivers.""" + +import copy +import json + +from .driver import BehaviorDriver, empty_observation +from .schema import ROOT, json_equal, strict_json, validate + + +def scenarios(): + corpus = strict_json((ROOT / "formal/behavioral-scenarios.json").read_text()) + if corpus["schemaVersion"] != 2 or not corpus["scenarios"]: + raise RuntimeError("Unsupported or empty behavioral scenario corpus") + names = [item["name"] for item in corpus["scenarios"]] + if len(set(names)) != len(names): + raise RuntimeError("Duplicate behavioral scenario name") + return corpus["scenarios"] + + +def replay_scenario(scenario): + fixture = scenario["fixture"] + validate(fixture, "behaviorFixture") + driver = BehaviorDriver(fixture) + expected = empty_observation(fixture) + try: + for index, step in enumerate(scenario["steps"]): + unknown = set(step["expect"]) - set(expected) + if unknown: + raise RuntimeError(f"Unknown scenario expectation fields: {unknown}") + expected.update(copy.deepcopy(step["expect"])) + validate(step["input"], "command") + driver.apply(step["input"]) + actual = driver.observe() + validate(actual, "behaviorObservation") + if driver.receipt()["runnable"]: + raise RuntimeError("Settlement violation: runnable task(s) at observation") + if not json_equal(actual, expected): + raise AssertionError( + f"{scenario['name']} step {index} input {step['input']}\nexpected: {json.dumps(expected)}\nactual: {json.dumps(actual)}" + ) + finally: + driver.close() diff --git a/python/tests/formal/schema.py b/python/tests/formal/schema.py new file mode 100644 index 00000000..dbe6271e --- /dev/null +++ b/python/tests/formal/schema.py @@ -0,0 +1,144 @@ +"""Validate the coordinator's JSON Schema locally before every round trip.""" + +from __future__ import annotations + +import json +import math +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] + + +def strict_json(text): + def object_pairs(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"Duplicate JSON object member: {key}") + result[key] = value + return result + + def constant(value): + raise ValueError(f"Non-JSON numeric constant: {value}") + + return json.loads(text, object_pairs_hook=object_pairs, parse_constant=constant) + + +SCHEMA = strict_json((ROOT / "formal/replay/protocol.schema.json").read_text()) +KEYWORDS = { + "$schema", + "$id", + "$defs", + "$ref", + "title", + "description", + "oneOf", + "anyOf", + "const", + "enum", + "type", + "properties", + "required", + "additionalProperties", + "items", + "minItems", + "minimum", + "maximum", + "minLength", + "pattern", +} + + +def _supported(rule): + unknown = set(rule) - KEYWORDS + if unknown: + raise RuntimeError(f"Unsupported replay schema keywords: {unknown}") + for key in ("$defs", "properties"): + for child in rule.get(key, {}).values(): + _supported(child) + for key in ("oneOf", "anyOf"): + for child in rule.get(key, []): + _supported(child) + if "items" in rule: + _supported(rule["items"]) + if isinstance(rule.get("additionalProperties"), dict): + _supported(rule["additionalProperties"]) + + +def _equal(left, right): + if type(left) in (int, float) and type(right) in (int, float): + return left == right + if type(left) is not type(right): + return False + if isinstance(left, list): + return len(left) == len(right) and all(_equal(a, b) for a, b in zip(left, right)) + if isinstance(left, dict): + return left.keys() == right.keys() and all(_equal(value, right[key]) for key, value in left.items()) + return left == right + + +json_equal = _equal + + +def matches(value, rule): + if "$ref" in rule: + return matches(value, SCHEMA["$defs"][rule["$ref"].removeprefix("#/$defs/")]) + if "oneOf" in rule and sum(matches(value, item) for item in rule["oneOf"]) != 1: + return False + if "anyOf" in rule and not any(matches(value, item) for item in rule["anyOf"]): + return False + if "const" in rule and not _equal(value, rule["const"]): + return False + if "enum" in rule and not any(_equal(value, option) for option in rule["enum"]): + return False + if "type" in rule: + types = rule["type"] if isinstance(rule["type"], list) else [rule["type"]] + predicates = { + "null": lambda: value is None, + "array": lambda: isinstance(value, list), + "object": lambda: isinstance(value, dict), + "boolean": lambda: type(value) is bool, + "string": lambda: isinstance(value, str), + "number": lambda: type(value) in (int, float) and math.isfinite(value), + "integer": lambda: type(value) in (int, float) and math.isfinite(value) and int(value) == value, + } + if not any(predicates[kind]() for kind in types): + return False + if type(value) in (int, float): + if value < rule.get("minimum", -math.inf) or value > rule.get("maximum", math.inf): + return False + if isinstance(value, str): + if len(value) < rule.get("minLength", 0) or ( + "pattern" in rule and not re.search(rule["pattern"], value) + ): + return False + if isinstance(value, list): + if len(value) < rule.get("minItems", 0) or ( + "items" in rule and not all(matches(item, rule["items"]) for item in value) + ): + return False + if isinstance(value, dict): + if any(key not in value for key in rule.get("required", [])): + return False + for key, item in value.items(): + if key in rule.get("properties", {}): + if not matches(item, rule["properties"][key]): + return False + elif rule.get("additionalProperties") is False: + return False + elif isinstance(rule.get("additionalProperties"), dict) and not matches( + item, rule["additionalProperties"] + ): + return False + return True + + +def validate(value, definition): + if definition not in SCHEMA["$defs"]: + raise RuntimeError(f"Unknown replay schema definition: {definition}") + if not matches(value, SCHEMA["$defs"][definition]): + raise RuntimeError(f"Malformed native replay {definition}: {json.dumps(value, default=str)}") + + +_supported(SCHEMA) diff --git a/python/tests/formal/simple_drivers.py b/python/tests/formal/simple_drivers.py new file mode 100644 index 00000000..1f717802 --- /dev/null +++ b/python/tests/formal/simple_drivers.py @@ -0,0 +1,170 @@ +"""Core and process-clock bindings through the public Python cache API.""" + +from __future__ import annotations + +from unittest.mock import patch + +from dialcache import DialCache +from dialcache.clock import SystemClock + +from .driver import FakeRedis, empty_observation, json_value +from .executor import Executor + + +class CoreDriver: + def __init__(self): + self.executor = Executor() + self.clock = self.executor.clock + self.redis = FakeRedis(self.clock) + self.cache = DialCache(redis=self.redis, clock=self.clock) + self.version = 1 + self.last = 0 + self.counters = { + name: 0 + for name in ( + "outsideLoaderCalls", + "requestLoaderCalls", + "localLoaderCalls", + "coalescedLoaderCalls", + "remoteLoaderCalls", + ) + } + + def apply(self, command): + op = command["op"] + if op == "advanceWall": + self.clock.consume(command["ms"]) + return + if op == "bumpSource": + self.version += 1 + return + if op == "invalidate": + identity = command["identity"] + self.executor.finish(self.cache.invalidate_remote(identity["keyType"], identity["id"])) + return + if op != "call": + raise RuntimeError(f"Unknown core command {op}") + identity = command["identity"] + options = { + "key_type": identity["keyType"], + "key": identity["id"], + "use_case": identity["useCase"], + "track_for_invalidation": identity["tracked"], + "default_config": command["policy"], + } + + async def call(gate=None): + async def source(): + self.counters[command["counter"]] += 1 + if gate is not None: + await gate + return self.version + + return await self.cache.get_or_load(source, **options) + + async def sequential(): + with self.cache.enable(): + first = await call() + second = await call() + if first != second: + raise AssertionError(f"Pair returned unequal values: {first}, {second}") + return second + + async def enabled(): + with self.cache.enable(): + return await call() + + self.redis.fail_read = command["readFailure"] + try: + mode = command["mode"] + if mode == "coalesced-pair": + # Establish both callers while holding the real first source. + gate = self.executor.future() + with self.cache.enable(): + first = self.executor.task(call(gate)) + second = self.executor.task(call()) + self.executor.drain() + gate.set_result(None) + self.executor.drain() + if not first.done() or not second.done(): + raise RuntimeError("Core pair remains blocked") + if first.result() != second.result(): + raise AssertionError("Core pair returned unequal values") + self.last = second.result() + else: + self.last = self.executor.finish( + call() if mode == "outside" else enabled() if mode == "single" else sequential() + ) + finally: + self.redis.fail_read = False + + def observe(self): + return { + "sourceVersion": self.version, + "lastResult": json_value(self.last), + **self.counters, + "redisReads": self.redis.reads, + "redisWrites": self.redis.writes, + } + + def receipt(self): + return None + + def close(self): + self.executor.close() + + +class LocalClockDriver: + def __init__(self): + self.executor = Executor() + self.clock = self.executor.clock + self.ticks = 0 + self.actual = empty_observation() + self.caches = {} + # This profile constructs real default caches. Patch the native process + # clock itself, preserving fractional milliseconds at construction. + owner = self + self.patch = patch.object(SystemClock, "monotonic_ms", lambda _clock: owner.ticks / 1000) + self.patch.start() + + def apply(self, command): + op = command["op"] + if op == "constructInstance": + index = command["instance"] + if index in self.caches: + raise RuntimeError("Instance already constructed") + self.caches[index] = DialCache() + elif op == "advanceTicks": + self.ticks += command["ticks"] + elif op == "call": + cache = self.caches[command["instance"]] + + async def call(): + async def source(): + self.actual["loaders"] += 1 + return command["offered"] + + with cache.enable(): + return await cache.get_or_load( + source, + key_type="clock", + key="one", + use_case="QuintLocalGrid", + default_config={"ttlSec": {"local": 1}}, + ) + + self.actual["calls"].append(self.executor.finish(call())) + else: + raise RuntimeError(f"Unknown local-clock command {op}") + + def observe(self): + return self.actual + + def receipt(self): + return None + + def close(self): + try: + self.executor.close() + finally: + self.patch.stop() diff --git a/python/tests/formal/witness.py b/python/tests/formal/witness.py new file mode 100644 index 00000000..6fb04024 --- /dev/null +++ b/python/tests/formal/witness.py @@ -0,0 +1,109 @@ +"""Verify shared reachability evidence against the histories actually replayed. + +The shared evaluator owns labels. This checker verifies its definition/corpus +fingerprints and provenance; it never substitutes reachability for native cache +observation assertions, which the runner must have completed first. +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +from .schema import ROOT, strict_json + + +def sha256(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def check_witness(profile, cases, completed, directory=None): + directory = Path(directory) if directory else ROOT / ".formal-traces/go-parity-witnesses" + evidence = strict_json((directory / f"{profile}.json").read_text()) + selected = [case for case in cases if case.get("profile") == profile and case.get("path")] + if not selected or any(case["id"] not in completed for case in selected): + raise AssertionError(f"{profile} witness cannot credit an unexecuted native history") + if ( + evidence.get("schemaVersion") != 2 + or evidence.get("profile") != profile + or evidence.get("traces") != len(selected) + ): + raise AssertionError(f"Unsupported or incomplete {profile} witness evidence") + required = strict_json((ROOT / "formal/coverage-witnesses.json").read_text())[profile] + if not required or evidence.get("required") != required: + raise AssertionError(f"{profile} required witness registry differs") + seen = evidence.get("seen", []) + if len(set(seen)) != len(seen) or not set(required).issubset(seen): + raise AssertionError(f"{profile} missing or duplicate witnessed labels") + actual = {} + for case in selected: + path = ROOT / case["path"] + if path.name in actual: + raise AssertionError(f"Duplicate witness history filename: {path.name}") + result = completed[case["id"]] + digest = sha256(path) + if result["sha256"] != digest: + raise AssertionError(f"History changed since native replay: {path}") + actual[path.name] = (digest, case["category"], result["steps"]) + corpus = evidence.get("corpus", []) + if len(corpus) != len(actual) or len({item["name"] for item in corpus}) != len(actual): + raise AssertionError(f"Incomplete {profile} witness corpus fingerprints") + for item in corpus: + if item["name"] not in actual or item["sha256"] != actual[item["name"]][0]: + raise AssertionError(f"Stale {profile} witness history: {item['name']}") + for name in required: + label = evidence.get("labels", {}).get(name) + if not isinstance(label, dict) or not label.get("traces"): + raise AssertionError(f"{profile} witness {name} lacks provenance") + counts = {"sampled": 0, "regression": 0} + cited = set() + for trace in label["traces"]: + record = actual.get(trace["name"]) + if record is None or trace["name"] in cited or trace["kind"] != record[1]: + raise AssertionError( + f"{profile} witness {name} cites an unknown, duplicate or wrong-kind history" + ) + cited.add(trace["name"]) + checkpoints = trace.get("checkpoints", []) + if not checkpoints or any( + type(index) is not int or index < 0 or index >= record[2] for index in checkpoints + ): + raise AssertionError(f"{profile} witness {name} has an invalid checkpoint") + counts[record[1]] += 1 + if any(label.get(kind) != count for kind, count in counts.items()): + raise AssertionError(f"{profile} witness {name} provenance counts differ") + + registry = strict_json((ROOT / "formal/profiles.json").read_text()) + execution = strict_json((ROOT / "formal/execution.json").read_text()) + models = {entry["path"] for entry in execution["models"]} + libraries = sorted( + str(path.relative_to(ROOT)) + for folder in (ROOT / "formal", ROOT / "formal/kernel") + for path in folder.glob("*.qnt") + if str(path.relative_to(ROOT)) not in models + ) + replay = sorted( + str(path.relative_to(ROOT)) + for path in (ROOT / "formal/replay").rglob("*") + if path.is_file() and path.suffix in (".mjs", ".mts", ".json") + ) + if registry["replaySources"] != replay: + raise AssertionError("Shared replay source inventory differs from formal/replay") + definition = next(entry for entry in registry["profiles"] if entry["id"] == profile) + inputs = list( + dict.fromkeys( + [ + "formal/profiles.json", + "formal/coverage-witnesses.json", + "formal/execution.json", + f"formal/dialcache-{profile}-conformance.qnt", + "formal/conformance-observations.qnt", + *libraries, + *replay, + *definition.get("witnessSources", []), + ] + ) + ) + expected = [{"path": path, "sha256": sha256(ROOT / path)} for path in inputs] + if evidence.get("inputs") != expected: + raise AssertionError(f"Stale or incomplete {profile} witness definition fingerprints") diff --git a/python/tests/run_conformance.py b/python/tests/run_conformance.py new file mode 100644 index 00000000..f07fd1c4 --- /dev/null +++ b/python/tests/run_conformance.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""Replay shared histories with an explicit scope and per-case JSONL evidence. + +--complete asserts every shared inventory case: generated histories, exported +regressions, fixed scenarios, wire vectors and corpus-bound witness evidence. +The prepared-context checker produces the final certificate. Native Redis +integration and mutation attribution remain separate validation obligations. +A selected subset can never claim completeness. +""" + +from __future__ import annotations + +import argparse +import atexit +import hashlib +import json +import os +import subprocess +import sys +import time +from concurrent.futures import ProcessPoolExecutor, as_completed +from pathlib import Path +from urllib.parse import quote + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from formal.coordinator import Coordinator, node_binary +from formal.schema import ROOT, strict_json + +_worker_coordinator = None + + +def _start_worker(): + global _worker_coordinator + _worker_coordinator = Coordinator() + atexit.register(_worker_coordinator.close) + + +def _replay_case(case): + """One actual native history in an isolated worker's persistent transport.""" + started = time.time_ns() // 1_000_000 + path = ROOT / case["path"] + digest = hashlib.sha256(path.read_bytes()).hexdigest() + try: + count = _worker_coordinator.replay(case["profile"], path) + if hashlib.sha256(path.read_bytes()).hexdigest() != digest: + raise RuntimeError(f"History changed during native replay: {path}") + status, message = "passed", None + except Exception as error: + count = 0 + status, message = "failed", str(error) + return { + "kind": "case", + "id": case["id"], + "status": status, + "startedAt": started, + "finishedAt": time.time_ns() // 1_000_000, + "historySha256": digest, + "steps": count, + **({"message": message} if message else {}), + } + + +def _replay_cases(cases, workers): + if workers == 1: + _start_worker() + try: + for case in cases: + yield _replay_case(case) + finally: + atexit.unregister(_worker_coordinator.close) + _worker_coordinator.close() + return + # No executor or cache is shared across workers. Every observation still + # uses the same coordinator protocol and per-history settlement receipts. + pool = ProcessPoolExecutor(max_workers=workers, initializer=_start_worker) + try: + futures = {pool.submit(_replay_case, case): case for case in cases} + for future in as_completed(futures): + yield future.result() + finally: + pool.shutdown(wait=True, cancel_futures=True) + + +def main(): + if not __debug__: + raise RuntimeError( + "Conformance assertions require normal Python execution; disable -O and PYTHONOPTIMIZE" + ) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--generated", + action="store_true", + help="All scheduled sampled histories and exported Quint regressions", + ) + parser.add_argument( + "--scenarios", action="store_true", help="Also replay the shared fixed behavioral scenarios" + ) + parser.add_argument( + "--complete", + action="store_true", + help="Execute every shared conformance inventory case; requires --report", + ) + parser.add_argument("--profile", help="Restrict histories to one profile (partial evidence)") + parser.add_argument("--trace", help="One explicit history; requires --profile") + parser.add_argument("--report", help="Write native JSONL case evidence") + parser.add_argument("--fail-fast", action="store_true") + parser.add_argument( + "--workers", + type=int, + default=min(4, os.cpu_count() or 1), + help="Independent native replay processes (default: up to four)", + ) + args = parser.parse_args() + if args.workers < 1: + parser.error("--workers must be positive") + if args.complete: + if args.profile or args.trace or not args.report: + parser.error("--complete requires --report and cannot select a profile or trace") + args.generated = args.scenarios = True + profiles = json.loads((ROOT / "formal/profiles.json").read_text())["profiles"] + known = {entry["id"] for entry in profiles} + if args.profile and args.profile not in known: + parser.error(f"Unknown profile {args.profile}") + if args.trace and not args.profile: + parser.error("--trace requires --profile") + if args.trace and args.generated: + parser.error("--trace conflicts with --generated") + if args.trace: + cases = [ + { + "id": f"selected/{args.profile}/{Path(args.trace).name}", + "profile": args.profile, + "path": args.trace, + } + ] + elif args.generated: + # Inventory is owned by the shared execution manifest; no native copy of + # trace counts, exported regression names or profile tables may drift. + listing = subprocess.run( + [node_binary(), "formal/conformance.mjs", "inventory"], + cwd=ROOT, + text=True, + capture_output=True, + check=True, + ) + inventory = strict_json(listing.stdout) + cases = [case for case in inventory if case["category"] in ("sampled", "regression")] + else: + cases = [ + {"id": f"smoke/{entry['id']}", "profile": entry["id"], "path": entry["smoke"]} + for entry in profiles + ] + if args.profile: + cases = [case for case in cases if case["profile"] == args.profile] + if not cases: + raise RuntimeError("Empty Python replay selection") + missing = [case["path"] for case in cases if not (ROOT / case["path"]).is_file()] + if missing: + raise RuntimeError( + f"Incomplete scheduled replay corpus: {len(missing)} missing histories; first: {missing[0]}" + ) + if args.generated and not args.profile: + directories = {} + for case in cases: + path = ROOT / case["path"] + directories.setdefault(path.parent, set()).add(path.name) + for directory, expected in directories.items(): + actual = {path.name for path in directory.glob("*.itf.json")} + if actual != expected: + raise RuntimeError(f"Missing or extra scheduled histories in {directory}") + report = open(args.report, "w") if args.report else None + + def emit(record): + line = json.dumps(record, separators=(",", ":"), allow_nan=False) + if report: + report.write(line + "\n") + report.flush() + + def now(): + return time.time_ns() // 1_000_000 + + emit( + { + "kind": "start", + "schemaVersion": 1, + "implementation": "python", + "scope": "conformance" + if args.complete + else "behavior-histories-and-scenarios" + if args.scenarios + else "behavior-histories", + "selection": "generated" if args.generated else "smoke" if not args.trace else "selected", + "partial": bool(args.profile or args.trace), + "startedAt": now(), + } + ) + failed = 0 + executed = 0 + steps = 0 + completed = {} + for record in _replay_cases(cases, args.workers): + if record["status"] == "passed": + steps += record["steps"] + completed[record["id"]] = {"sha256": record["historySha256"], "steps": record["steps"]} + else: + failed += 1 + print(f"FAIL {record['id']}: {record['message']}", file=sys.stderr, flush=True) + executed += 1 + emit(record) + if executed % 50 == 0: + print( + f"Python replay: {executed}/{len(cases)} histories, {failed} failed", + file=sys.stderr, + flush=True, + ) + if failed and args.fail_fast: + break + if args.scenarios and not (failed and args.fail_fast): + from formal.scenarios import replay_scenario, scenarios + + for scenario in scenarios(): + + def encode(text): + return quote(text, safe="~()*!.'-") + + case_id = f"scenario/{encode(scenario['feature'])}/{encode(scenario['name'])}" + started = now() + try: + replay_scenario(scenario) + status, message = "passed", None + except Exception as error: + failed += 1 + status, message = "failed", str(error) + print(f"FAIL {case_id}: {message}", file=sys.stderr, flush=True) + executed += 1 + emit( + { + "kind": "case", + "id": case_id, + "status": status, + "startedAt": started, + "finishedAt": now(), + **({"message": message} if message else {}), + } + ) + if failed and args.fail_fast: + break + if args.complete and not (failed and args.fail_fast): + from formal.witness import check_witness + from test_protocol_vectors import CORPORA, assert_wire_vector, test_schemas_provenance_and_inventory + + test_schemas_provenance_and_inventory() + vectors = {} + for corpus in CORPORA: + for group, rows in corpus.items(): + if isinstance(rows, list): + for vector in rows: + key = (group, vector["name"]) + if key in vectors: + raise RuntimeError(f"Duplicate wire vector {key}") + vectors[key] = vector + protocol = [case for case in inventory if case["category"] == "protocol"] + if set(vectors) != {(case["group"], case["name"]) for case in protocol}: + raise RuntimeError("Native wire vectors differ from shared conformance inventory") + for case in [*protocol, *(entry for entry in inventory if entry["category"] == "witness")]: + started = now() + try: + if case["category"] == "protocol": + assert_wire_vector(case["group"], vectors[(case["group"], case["name"])]) + else: + check_witness( + case["profile"], cases, completed, os.getenv("DIALCACHE_WITNESS_EVIDENCE_DIR") + ) + status, message = "passed", None + except Exception as error: + failed += 1 + status, message = "failed", str(error) + print(f"FAIL {case['id']}: {message}", file=sys.stderr, flush=True) + executed += 1 + emit( + { + "kind": "case", + "id": case["id"], + "status": status, + "startedAt": started, + "finishedAt": now(), + **({"message": message} if message else {}), + } + ) + if failed and args.fail_fast: + break + result = { + "kind": "finish", + "status": "failed" if failed else "passed", + "cases": executed, + "failed": failed, + "steps": steps, + "finishedAt": now(), + } + emit(result) + print(json.dumps(result), flush=True) + if report: + report.close() + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python/tests/test_cache.py b/python/tests/test_cache.py new file mode 100644 index 00000000..03f538cc --- /dev/null +++ b/python/tests/test_cache.py @@ -0,0 +1,456 @@ +"""Python binding checks independent of the portable observation corpus.""" + +import pytest +from formal.executor import Executor + +from dialcache import ( + ConfigError, + DialCache, + FallbackTimeoutError, + Key, + MissingRemoteError, + Policy, + UseCaseIsAlreadyRegisteredError, + UseCaseNameIsReservedError, +) +from dialcache.protocol import Frame + + +@pytest.fixture +def executor(): + result = Executor() + try: + yield result + finally: + result.close() + + +def test_disabled_calls_skip_keys_policy_coalescing_and_default_deadline(executor): + calls = [] + gate = executor.future() + + def forbidden(*args): + raise AssertionError("Disabled invocation touched cache plumbing") + + cache = DialCache(policy_provider=forbidden, clock=executor.clock) + + @cache.cached(key_type="user", cache_key=forbidden, default_config=Policy.enabled(10)) + async def source(user_id): + calls.append(user_id) + return await gate + + first = executor.task(source("a")) + second = executor.task(source("a")) + executor.drain() + assert calls == ["a", "a"] + assert cache.get_coalescing_state()["process"]["active_leaders"] == 0 + executor.clock.advance(120_000) + assert not first.done() and not second.done() + gate.set_result(7) + executor.drain() + assert first.result() == second.result() == 7 + + +@pytest.mark.parametrize("cancel_first", [True, False]) +def test_caller_cancellation_cannot_cancel_shared_source(executor, cancel_first): + gate = executor.future() + calls = [] + cache = DialCache(clock=executor.clock) + + async def source(): + calls.append(1) + return await gate + + def call(): + return cache.get_or_load( + source, key="a", key_type="user", use_case="cancellation", default_config=Policy.enabled(10) + ) + + with cache.enable(): + first, second = executor.task(call()), executor.task(call()) + executor.drain() + assert len(calls) == 1 + cancelled, survivor = (first, second) if cancel_first else (second, first) + cancelled.cancel() + executor.drain() + assert cancelled.cancelled() + assert not gate.cancelled() + assert not survivor.done() + gate.set_result({"id": "a"}) + executor.drain() + assert survivor.result() == {"id": "a"} + assert executor.finish(call()) == {"id": "a"} + assert len(calls) == 1 + + +def test_source_default_deadline_exact_boundary_and_late_value_not_published(executor): + gates = [] + cache = DialCache(clock=executor.clock) + + async def source(): + gate = executor.future() + gates.append(gate) + return await gate + + def call(**options): + return cache.get_or_load( + source, + key="a", + key_type="user", + use_case="deadline", + default_config=Policy.enabled(10), + **options, + ) + + with cache.enable(): + first = executor.task(call()) + executor.drain() + executor.clock.advance(59_999) + executor.drain() + assert not first.done() + executor.clock.advance(1) + executor.drain() + assert isinstance(first.exception(), FallbackTimeoutError) + assert first.exception().timeout_ms == 60_000 + assert cache.get_coalescing_state()["process"]["active_leaders"] == 0 + gates[0].set_result("late") + executor.drain() + second = executor.task(call(fallback_timeout_ms=None)) + executor.drain() + assert len(gates) == 2 + executor.clock.advance(120_000) + executor.drain() + assert not second.done() + gates[1].set_result("fresh") + executor.drain() + assert second.result() == "fresh" + assert executor.finish(call()) == "fresh" + + +def test_elapsed_deadline_wins_when_source_settles_before_timer_delivery(executor): + cache = DialCache(clock=executor.clock) + + def source(): + executor.clock.consume(10) + return "too late" + + with cache.enable(): + task = executor.task( + cache.get_or_load(source, key="a", key_type="user", use_case="elapsed", fallback_timeout_ms=10) + ) + executor.drain() + assert isinstance(task.exception(), FallbackTimeoutError) + + +def test_default_remote_read_budget_aborts_wait_and_never_refills_failed_read(executor): + gate = executor.future() + contexts = [] + writes = [] + + class Remote: + def read(self, request, context): + contexts.append(context) + return gate + + def write(self, request): + writes.append(request) + + cache = DialCache(redis=Remote(), clock=executor.clock) + with cache.enable(): + call = executor.task( + cache.get_or_load( + lambda: "source", + key="a", + key_type="user", + use_case="read-budget", + default_config=Policy.enabled(10), + ) + ) + executor.drain() + assert contexts[0].timeout_ms == 50 + executor.clock.advance(49) + executor.drain() + assert not call.done() + executor.clock.advance(1) + executor.drain() + assert call.result() == "source" + assert contexts[0].signal.aborted + assert writes == [] + gate.set_result(Frame(int(executor.clock.wall_ms()), '"late"')) + executor.drain() + assert writes == [] + assert ( + executor.finish( + cache.get_or_load( + lambda: "unexpected", + key="a", + key_type="user", + use_case="read-budget", + default_config=Policy.enabled(10), + ) + ) + == "source" + ) + + +def test_coalescing_disabled_gives_each_caller_its_own_deadline_and_publication(executor): + gates = [] + cache = DialCache(clock=executor.clock) + policy = Policy(ttl_sec={"local": 60}, request_local=True, coalesce=False) + + async def source(): + gate = executor.future() + gates.append(gate) + return await gate + + def call(timeout=10): + return cache.get_or_load( + source, + key="a", + key_type="user", + use_case="independent", + default_config=policy, + fallback_timeout_ms=timeout, + ) + + with cache.enable(): + first = executor.task(call()) + executor.drain() + executor.clock.advance(5) + second = executor.task(call()) + executor.drain() + assert len(gates) == 2 + assert cache.get_coalescing_state()["process"]["active_leaders"] == 0 + executor.clock.advance(5) + executor.drain() + assert isinstance(first.exception(), FallbackTimeoutError) + assert not second.done() + gates[1].set_result("second") + executor.drain() + gates[0].set_result("too late") + executor.drain() + assert second.result() == "second" + assert executor.finish(call()) == "second" + assert len(gates) == 2 + + +async def test_gcache_argument_binding_defaults_adapters_and_ignored_arguments(): + cache = DialCache() + calls = [] + adapters = [] + + def adapt_role(role): + adapters.append(role) + return role.lower() + + @cache.cached( + key_type="user", + id_arg=("user", lambda user: user["id"]), + arg_adapters={"role": adapt_role}, + ignore_args=["trace_id"], + default_config=Policy(request_local=True), + ) + async def load(user, role="member", trace_id=None): + calls.append((user, role, trace_id)) + return len(calls) + + user = {"id": "u1", "ignored": object()} + async with cache.enable(): + assert await load(user) == 1 + assert await load(user=user, role="MEMBER", trace_id="trace") == 1 + assert await load(user, role="admin") == 2 + async with cache.enable(False): + assert await load(user) == 3 + assert await load(user) == 1 + assert len(calls) == 3 + assert adapters == ["member", "MEMBER", "admin", "member"] + assert load.__name__ == "load" + + +async def test_explicit_cache_key_and_sync_loader_are_awaitable(): + cache = DialCache() + seen = [] + + @cache.cached( + key_type="sum", + cache_key=lambda a, b: {"id": a, "args": {"b": b}}, + default_config=Policy(request_local=True), + ) + def add(a, b): + seen.append((a, b)) + return a + b + + with cache.enable(): + assert await add(1, 2) == await add(1, 2) == 3 + assert await add(1, 3) == 4 + assert seen == [(1, 2), (1, 3)] + + +async def test_runtime_malformed_flag_bypasses_existing_cache_without_erasing_it(): + runtime = None + calls = [] + cache = DialCache(policy_provider=lambda key: runtime) + + @cache.cached(key_type="user", id_arg="id", default_config=Policy.enabled(60)) + async def load(id): + calls.append(id) + return len(calls) + + with cache.enable(): + assert await load("a") == 1 + runtime = {"requestLocal": None} + assert await load("a") == 2 + assert await load("a") == 3 + runtime = None + assert await load("a") == 1 + assert len(calls) == 3 + + +async def test_metrics_failures_do_not_change_source_or_hit_results(): + class Observer: + def observe(self, event): + raise RuntimeError("metrics backend unavailable") + + cache = DialCache(metrics=Observer()) + calls = [] + + def load(): + calls.append(1) + return None + + with cache.enable(): + for _ in range(2): + assert ( + await cache.get_or_load( + load, key="a", key_type="user", use_case="observer", default_config=Policy.enabled(60) + ) + is None + ) + assert len(calls) == 1 + + +def test_default_recovery_admits_timeout_and_preserves_other_source_errors(executor): + class Remote: + writes = 0 + + def read(self, request, context): + return Frame(int(executor.clock.wall_ms()) - 2_000, '"stale"') + + def write(self, request): + self.writes += 1 + + remote = Remote() + cache = DialCache(redis=remote, clock=executor.clock) + policy = Policy(ttl_sec={"remote": 1}, stale_on_error_max_age_sec=10) + failure = ValueError("permission denied") + + def rejected(): + raise failure + + with cache.enable(): + first = executor.task( + cache.get_or_load(rejected, key="a", key_type="user", use_case="recovery", default_config=policy) + ) + executor.drain() + assert first.exception() is failure + gate = executor.future() + second = executor.task( + cache.get_or_load( + lambda: gate, + key="a", + key_type="user", + use_case="recovery", + default_config=policy, + fallback_timeout_ms=10, + ) + ) + executor.drain() + executor.clock.advance(10) + executor.drain() + assert second.result() == "stale" + assert remote.writes == 0 + gate.set_result("late") + executor.drain() + + +async def test_aget_uses_structured_identity_and_rejects_foreign_namespace(): + cache = DialCache(namespace="service") + calls = [] + + def source(): + calls.append(1) + return len(calls) + + key = Key("service", "user", "a", "structured") + with cache.enable(): + assert await cache.aget(key, source, default_config=Policy(request_local=True)) == 1 + assert await cache.aget(key, source, default_config=Policy(request_local=True)) == 1 + assert ( + await cache.aget( + Key("other", "user", "a", "structured"), source, default_config=Policy(request_local=True) + ) + == 2 + ) + + +@pytest.mark.parametrize("options", [{}, {"cache_key": lambda x: x, "id_arg": "x"}]) +def test_decorator_requires_exactly_one_identity_source(options): + with pytest.raises(ConfigError): + DialCache().cached(key_type="user", **options) + + +def test_registration_uses_public_owned_error_types(): + cache = DialCache() + cache.cached(key_type="user", id_arg="id", use_case="unique")(lambda id: id) + with pytest.raises(UseCaseIsAlreadyRegisteredError): + cache.cached(key_type="user", id_arg="id", use_case="unique")(lambda id: id) + with pytest.raises(UseCaseNameIsReservedError): + cache.cached(key_type="user", id_arg="id", use_case="watermark")(lambda id: id) + + +async def test_missing_remote_maintenance_error_is_public(): + with pytest.raises(MissingRemoteError): + await DialCache().invalidate_remote("user", "a") + + +@pytest.mark.parametrize( + "compression", + [ + None, + 1, + "zstd", + {"maximum": 10}, + {"threshold_bytes": 0}, + {"threshold_bytes": True}, + {"level": 0}, + {"level": 23}, + {"level": 1.5}, + ], +) +def test_compression_settings_fail_at_construction(compression): + with pytest.raises(ConfigError): + DialCache(compression=compression) + + +def test_default_shadow_comparison_distinguishes_nested_boolean_from_number(executor): + events = [] + + class Remote: + def read(self, request, context): + return Frame(int(executor.clock.wall_ms()), '{"values":[true]}') + + cache = DialCache(redis=Remote(), clock=executor.clock, metrics=events.append) + policy = Policy(ttl_sec={"remote": 10}, shadow={"ramp": 100}) + with cache.enable(): + result = executor.finish( + cache.get_or_load( + lambda: {"values": [1]}, + key="a", + key_type="user", + use_case="shadow-equality", + default_config=policy, + ) + ) + executor.drain() + assert result == {"values": [True]} + assert [event["outcome"] for event in events if event["event"] == "shadowValidation"] == ["mismatch"] diff --git a/python/tests/test_config.py b/python/tests/test_config.py new file mode 100644 index 00000000..1b0f6040 --- /dev/null +++ b/python/tests/test_config.py @@ -0,0 +1,142 @@ +import math + +import pytest + +from dialcache.config import ( + MAX_CACHE_TTL_SEC, + MAX_TIMER_DELAY_MS, + UNSET, + Policy, + deterministic_ramp_sample, + merge_policy, + normalize_policy, + resolve_layer, + validate_deadline_ms, + validate_static_policy, +) +from dialcache.errors import ConfigError + + +def test_sparse_merge_snapshots_every_leaf_and_inherits_provider_none(): + ttl = {"local": 10, "remote": 60} + defaults = Policy( + ttl_sec=ttl, + ramp={"local": 50}, + request_local=True, + coalesce=False, + remote_read_timeout_ms=75, + shadow={"ramp": 20, "log_mismatches": True}, + stale_on_error_max_age_sec=120, + ) + ttl["local"] = 500 + merged = merge_policy(defaults, {"ttlSec": {"remote": 30}, "shadow": {"ramp": 0}}) + assert merged.ttl_sec == {"local": 10, "remote": 30} + assert merged.ramp == {"local": 50} + assert merged.request_local is True + assert merged.coalesce is False + assert merged.remote_read_timeout_ms == 75 + assert merged.stale_on_error_max_age_sec == 120 + assert merged.shadow == {"ramp": 0, "log_mismatches": True} + assert merge_policy(defaults, None) is defaults + assert merge_policy(None, None) is None + with pytest.raises(TypeError): + merged.ttl_sec["local"] = 100 + + +@pytest.mark.parametrize("field", ["requestLocal", "coalesce", "remoteReadTimeoutMs"]) +@pytest.mark.parametrize("value", [None, "false", [], True]) +def test_malformed_flags_and_budget_fail_resolution(field, value): + if field != "remoteReadTimeoutMs" and value is True: + return + with pytest.raises(ConfigError): + merge_policy(Policy(request_local=True), {field: value}) + + +@pytest.mark.parametrize("bad", [None, [], 5, "policy"]) +@pytest.mark.parametrize("field", ["ttlSec", "ramp", "shadow"]) +def test_malformed_containers_fail_resolution(field, bad): + with pytest.raises(ConfigError): + normalize_policy({field: bad}) + + +@pytest.mark.parametrize( + "bad", [None, False, 0, -1, 1.5, math.inf, math.nan, MAX_CACHE_TTL_SEC + 1, 10**1000] +) +def test_invalid_ttl_disables_only_affected_layer(bad): + policy = merge_policy(Policy.enabled(10), {"ttl_sec": {"local": bad}}) + assert resolve_layer(policy, "urn:user:a:case", "local").reason == "invalid_ttl" + assert resolve_layer(policy, "urn:user:a:case", "remote").enabled + with pytest.raises(ConfigError): + validate_static_policy(policy) + + +@pytest.mark.parametrize("bad", [None, True, -1, 101, math.inf, math.nan, "50", 10**1000]) +def test_invalid_ramp_disables_only_affected_layer(bad): + policy = merge_policy(Policy.enabled(10), {"ramp": {"remote": bad}}) + assert resolve_layer(policy, "urn:user:a:case", "remote").reason == "invalid_ramp" + assert resolve_layer(policy, "urn:user:a:case", "local").enabled + + +def test_ramp_is_strict_stable_and_utf16_compatible(): + urn = "urn:user:\U0001f600:case" + sample = deterministic_ramp_sample(urn, "remote") + # Computed independently with JavaScript UTF-16 FNV-1a. + assert sample == 8.489463897421956 + assert ( + resolve_layer(Policy(ttl_sec={"remote": 1}, ramp={"remote": sample - 0.001}), urn, "remote").reason + == "ramped_down" + ) + assert ( + resolve_layer(Policy(ttl_sec={"remote": 1}, ramp={"remote": sample}), urn, "remote").reason + == "ramped_down" + ) + assert resolve_layer( + Policy(ttl_sec={"remote": 1}, ramp={"remote": sample + 0.001}), urn, "remote" + ).enabled + + +def test_disabled_overlay_turns_off_all_inherited_paths(): + default = Policy( + ttl_sec={"local": 10, "remote": 10}, + request_local=True, + stale_on_error_max_age_sec=20, + shadow={"ramp": 100, "log_mismatches": True}, + ) + merged = merge_policy(default, Policy.disabled()) + assert merged.request_local is False + assert merged.stale_on_error_max_age_sec == 0 + assert merged.shadow == {"ramp": 0, "log_mismatches": False} + assert merged.coalesce is UNSET + assert resolve_layer(merged, "key", "local").reason == "ramped_down" + assert resolve_layer(merged, "key", "remote").reason == "ramped_down" + + +@pytest.mark.parametrize("age", [None, False, 1, 10, 1.5, MAX_CACHE_TTL_SEC + 1, "20"]) +def test_bad_optional_recovery_keeps_remote_serving(age): + resolved = resolve_layer(Policy(ttl_sec={"remote": 10}, stale_on_error_max_age_sec=age), "key", "remote") + assert resolved.enabled + assert resolved.stale_on_error_config_error + assert resolved.stale_on_error_max_age_sec is None + + +def test_recovery_requires_remote_and_strictly_larger_positive_age(): + assert resolve_layer(Policy(stale_on_error_max_age_sec=20), "key", "remote").stale_on_error_config_error + resolved = resolve_layer(Policy(ttl_sec={"remote": 10}, stale_on_error_max_age_sec=11), "key", "remote") + assert resolved.stale_on_error_max_age_sec == 11 + assert not resolved.stale_on_error_config_error + assert not resolve_layer( + Policy(stale_on_error_max_age_sec=0), "key", "remote" + ).stale_on_error_config_error + + +@pytest.mark.parametrize("value", [True, False, None, 0, -1, 0.5, MAX_TIMER_DELAY_MS + 1, 10**1000]) +def test_deadlines_reject_unsupported_domain(value): + with pytest.raises(ConfigError): + validate_deadline_ms(value) + + +def test_deadline_and_ttl_boundaries_are_inclusive(): + assert validate_deadline_ms(1) == 1 + assert validate_deadline_ms(MAX_TIMER_DELAY_MS) == MAX_TIMER_DELAY_MS + policy = validate_static_policy(Policy.enabled(MAX_CACHE_TTL_SEC)) + assert resolve_layer(policy, "key", "local").ttl_sec == MAX_CACHE_TTL_SEC diff --git a/python/tests/test_conformance.py b/python/tests/test_conformance.py new file mode 100644 index 00000000..b167a5d0 --- /dev/null +++ b/python/tests/test_conformance.py @@ -0,0 +1,195 @@ +"""Smoke histories, fixed scenarios and the no-settle infrastructure control.""" + +import json +import os +import subprocess +import sys + +import pytest +from formal.coordinator import Coordinator, node_binary +from formal.scenarios import replay_scenario, scenarios +from formal.schema import ROOT, json_equal, strict_json, validate + +PROFILES = json.loads((ROOT / "formal/profiles.json").read_text())["profiles"] +SELECTED = [ + entry + for entry in PROFILES + if not os.getenv("DIALCACHE_PYTHON_PROFILE") or entry["id"] == os.environ["DIALCACHE_PYTHON_PROFILE"] +] + + +@pytest.fixture(scope="module") +def coordinator(): + with Coordinator() as value: + yield value + + +@pytest.mark.parametrize("profile", SELECTED, ids=lambda item: item["id"]) +def test_profile_smoke(coordinator, profile): + assert coordinator.replay(profile["id"], ROOT / profile["smoke"]) > 0 + + +@pytest.mark.parametrize( + "profile", + [entry for entry in SELECTED if entry["id"] not in ("core", "local-clock")], + ids=lambda item: item["id"], +) +def test_no_settle_control(coordinator, profile): + # Skipping settlement must be diagnosed as infrastructure failure before + # comparing observations, never as evidence of a behavioral divergence. + with pytest.raises(AssertionError, match="Settlement violation") as error: + coordinator.replay(profile["id"], ROOT / profile["smoke"], settle=False) + assert "Observation mismatch" not in str(error.value) + + +@pytest.mark.parametrize("scenario", scenarios(), ids=lambda item: item["name"]) +def test_shared_scenario(scenario): + replay_scenario(scenario) + + +def test_executor_drains_complete_causal_work_without_advancing_time(): + from formal.executor import Executor + + executor = Executor() + observed = [] + held = executor.future() + + def ready(index): + observed.append(index) + if index < 1024: + executor.loop.call_soon(ready, index + 1) + + async def parked(): + await held + observed.append("released") + + try: + executor.task(parked()) + executor.loop.call_soon(ready, 0) + executor.clock.call_later(1, lambda: observed.append("timer")) + executor.drain() + assert observed == list(range(1025)) + assert executor.clock.monotonic_ms() == 0 + assert not held.done() + assert executor.drain() == 0 + held.set_result(None) + executor.drain() + assert observed[-1] == "released" + executor.clock.advance(1) + assert observed[-1] == "timer" + finally: + executor.close() + + +def test_silent_clock_advance_keeps_timer_delivery_held(): + from formal.executor import Executor + + executor = Executor() + fired = [] + try: + executor.clock.call_later(5, lambda: fired.append(executor.clock.monotonic_ms())) + executor.clock.advance(10, deliver=False) + executor.drain() + assert fired == [] + assert executor.clock.monotonic_ms() == 10 + executor.clock.advance(5) + assert fired == [15] + finally: + executor.close() + + +def test_protocol_rejects_non_json_and_boolean_integer_confusion(): + with pytest.raises(ValueError, match="Duplicate JSON"): + strict_json('{"id": 1, "id": 2}') + with pytest.raises(ValueError, match="Non-JSON"): + strict_json('{"x": NaN}') + with pytest.raises(RuntimeError, match="Malformed native replay request"): + validate({"version": 1, "id": True, "op": "profiles"}, "request") + assert not json_equal({"calls": [True]}, {"calls": [1]}) + assert json_equal({"calls": [1.0]}, {"calls": [1]}) + + +def test_persistent_transport_rejects_duplicate_ids(): + with Coordinator() as coordinator: + coordinator.request(op="profiles") + coordinator.sequence -= 1 + with pytest.raises(AssertionError, match="Duplicate or out-of-order replay request"): + coordinator.request(op="profiles") + + +def test_transport_rejects_mismatched_response_id(monkeypatch): + import formal.coordinator as transport + + real_popen = subprocess.Popen + program = "import json,sys\nfor line in sys.stdin:\n r=json.loads(line); print(json.dumps({'version':1,'id':r['id']+1,'ok':False,'error':'controlled'}),flush=True)" + monkeypatch.setattr( + transport.subprocess, + "Popen", + lambda command, **options: real_popen([sys.executable, "-u", "-c", program], **options), + ) + with Coordinator() as coordinator: + with pytest.raises(RuntimeError, match="Mismatched coordinator response"): + coordinator.request(op="profiles") + + +def test_complete_report_gate_requires_every_native_assertion(): + # These synthetic records challenge only the report parser; none are saved + # or presented as implementation evidence. + program = r""" + import assert from 'node:assert/strict'; + import { checkPythonReplay } from './formal/check-python-replay.mjs'; + const inventory = ['sampled','regression','scenario','protocol','witness'].map(category => ({id: `${category}/example`, category})); + const make = () => [ + {kind:'start',schemaVersion:1,implementation:'python',scope:'conformance',selection:'generated',partial:false,startedAt:1}, + ...inventory.map(entry => ({kind:'case',id:entry.id,status:'passed',startedAt:2,finishedAt:3})), + {kind:'finish',status:'passed',cases:5,failed:0,finishedAt:4}, + ]; + const check = records => checkPythonReplay(records.map(x => JSON.stringify(x)).join('\n'), inventory); + assert.equal(check(make()).executedCases,5); + for (let index=1; index<=5; index++) { + const missing=make(); missing.splice(index,1); missing.at(-1).cases--; + assert.throws(() => check(missing),/Missing passed/); + } + for (const status of ['failed','skipped','running']) { + const report=make(); report[1].status=status; + assert.throws(() => check(report),/failed or skipped/); + } + const duplicate=make(); duplicate[2]=duplicate[1]; assert.throws(() => check(duplicate),/Duplicate/); + const countOnly=[make()[0],make().at(-1)]; assert.throws(() => check(countOnly),/Incomplete/); + for (const field of [{scope:'behavior-histories'},{selection:'smoke'},{partial:true}]) { + const report=make(); Object.assign(report[0],field); assert.throws(() => check(report),/not a complete/); + } + const incomplete=make(); incomplete.pop(); assert.throws(() => check(incomplete),/missing finish/); + const unknown=make(); unknown[1].id='unknown'; assert.throws(() => check(unknown),/Unknown/); + const withPath=inventory.map((entry,index) => index===0 ? {...entry,path:'trace.itf.json'} : entry); + const bound=make(); bound[1].historySha256='a'.repeat(64); + const serialized=bound.map(x=>JSON.stringify(x)).join('\n'); + assert.throws(() => checkPythonReplay(serialized,withPath,{corpus:{'trace.itf.json':'b'.repeat(64)}}),/fingerprint differs/); + assert.equal(checkPythonReplay(serialized,withPath,{corpus:{'trace.itf.json':'a'.repeat(64)}}).executedCases,5); + """ + subprocess.run( + [node_binary(), "--input-type=module", "-e", program], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ) + + +def test_complete_runner_refuses_stripped_python_assertions(tmp_path): + result = subprocess.run( + [ + sys.executable, + "-O", + str(ROOT / "python/tests/run_conformance.py"), + "--complete", + "--report", + str(tmp_path / "replay.jsonl"), + ], + cwd=ROOT, + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "disable -O and PYTHONOPTIMIZE" in result.stderr + assert not (tmp_path / "replay.jsonl").exists() diff --git a/python/tests/test_context.py b/python/tests/test_context.py new file mode 100644 index 00000000..124eeee4 --- /dev/null +++ b/python/tests/test_context.py @@ -0,0 +1,81 @@ +import asyncio +from contextvars import copy_context + +import pytest + +from dialcache.context import DialCacheContext + + +def test_nested_enable_disable_preserves_holder_without_clearing_values(): + context = DialCacheContext() + assert not context.is_enabled() + assert context.request_cache() is None + with context.enable(): + outer = context.request_cache() + outer.set("key", None) + with context.disable(): + assert not context.is_enabled() + assert context.request_cache() is None + with context.enable(): + assert context.is_enabled() + assert context.request_cache() is outer + assert outer.read("key") == (True, None) + assert outer.read("key") == (True, None) + assert outer.closed + assert outer.read("key") == (False, None) + outer.set("late", 1) + assert outer.read("late") == (False, None) + + +def test_instances_are_independent_and_outer_exception_closes_holder(): + first, second = DialCacheContext(), DialCacheContext() + with pytest.raises(ValueError): + with first.enable(): + memo = first.request_cache() + memo.in_flight["key"] = object() + assert first.is_enabled() + assert not second.is_enabled() + raise ValueError("source failure") + assert memo.closed + assert not memo.in_flight + assert not first.is_enabled() + + +async def test_closed_context_disables_detached_calls_and_reenable_owns_new_holder(): + context = DialCacheContext() + release = asyncio.Event() + + async def detached(): + await release.wait() + assert not context.is_enabled() + assert context.request_cache() is None + with context.enable(): + assert context.is_enabled() + assert context.request_cache() is not original + + async with context.enable(): + original = context.request_cache() + task = asyncio.create_task(detached()) + copied = copy_context() + assert copied.run(context.is_enabled) is False + release.set() + await task + + +async def test_independent_sibling_scopes_never_share_memo(): + context = DialCacheContext() + release = asyncio.Event() + memos = [] + + async def sibling(): + async with context.enable(): + memos.append(context.request_cache()) + await release.wait() + + first, second = asyncio.create_task(sibling()), asyncio.create_task(sibling()) + await asyncio.sleep(0) + assert len(memos) == 2 + assert memos[0] is not memos[1] + release.set() + await asyncio.gather(first, second) + assert all(memo.closed for memo in memos) diff --git a/python/tests/test_docs_examples.py b/python/tests/test_docs_examples.py new file mode 100644 index 00000000..5b8d01cc --- /dev/null +++ b/python/tests/test_docs_examples.py @@ -0,0 +1,122 @@ +"""Executed teaching scenarios imported by the shared documentation site.""" + +import os +from uuid import uuid4 + +import pytest + +from dialcache import DialCache, Policy +from dialcache.redis import RedisAdapter + + +async def test_request_scope(): + # region request-scope + cache = DialCache() + source_calls = 0 + + @cache.cached( + key_type="user", + use_case="requestScope", + id_arg="id", + # Only request-local storage is enabled; shared layers stay off. + default_config=Policy(request_local=True), + ) + async def lookup(id): + nonlocal source_calls + source_calls += 1 + return source_calls + + # Outside an enabled scope, every call reaches the source. + assert await lookup("42") == 1 + assert await lookup("42") == 2 + async with cache.enable(): + assert await lookup("42") == 3 + assert await lookup("42") == 3 + # A new request starts with an empty memo. + async with cache.enable(): + assert await lookup("42") == 4 + assert source_calls == 4 + # endregion request-scope + + +async def test_runtime_policy(): + # region runtime-policy + # An application may fetch this overlay from its runtime config service. + overlay = Policy(coalesce=False) + cache = DialCache(policy_provider=lambda key: overlay) + source_calls = 0 + + @cache.cached( + key_type="user", + use_case="runtimePolicy", + id_arg="id", + default_config=Policy(request_local=True, ttl_sec={"local": 60}, ramp={"local": 100}), + ) + async def lookup(id): + nonlocal source_calls + source_calls += 1 + return source_calls + + # The sparse overlay inherits the local TTL across separate requests. + async with cache.enable(): + assert await lookup("42") == 1 + async with cache.enable(): + assert await lookup("42") == 1 + + # False and zero explicitly disable the two configured cache paths. + overlay = Policy(request_local=False, ramp={"local": 0}) + async with cache.enable(): + assert await lookup("42") == 2 + assert await lookup("42") == 3 + assert source_calls == 3 + # endregion runtime-policy + + +@pytest.mark.integration +async def test_tracked_invalidation(): + from redis.asyncio import Redis + + url = os.environ.get("DOCS_REDIS_URL") or os.environ.get("TEST_REDIS_URL") + if not url: + pytest.skip("Set DOCS_REDIS_URL or TEST_REDIS_URL for the documented Redis scenario") + namespace = f"docs-python-{uuid4().hex}" + client = Redis.from_url(url, decode_responses=False, socket_timeout=2, socket_connect_timeout=2) + await client.ping() + try: + # region tracked-invalidation + # client is a connected, caller-owned redis.asyncio client. + cache = DialCache(namespace=namespace, redis=RedisAdapter(client), read_timeout_ms=1_000) + source_version = 1 + source_calls = 0 + + @cache.cached( + key_type="user", + use_case="profileVersion", + id_arg="id", + track_for_invalidation=True, + # Keep local layers off so each request observes the watermark. + default_config=Policy(ttl_sec={"remote": 60}, ramp={"remote": 100}), + ) + async def profile_version(id): + nonlocal source_calls + source_calls += 1 + return source_version + + async with cache.enable(): + assert await profile_version("42") == 1 + source_version = 2 # Represents a successfully committed source update. + async with cache.enable(): + assert await profile_version("42") == 1 + assert source_calls == 1 # The previous value really was cached. + + await cache.invalidate_remote("user", "42") + async with cache.enable(): + assert await profile_version("42") == 2 + assert source_calls == 2 + # endregion tracked-invalidation + finally: + prefix = f"{{{namespace}:user:42}}" + try: + await client.delete(f"{prefix}#profileVersion:dialcache-frame-v1", f"{prefix}#watermark") + finally: + await client.aclose() diff --git a/python/tests/test_engine_review.py b/python/tests/test_engine_review.py new file mode 100644 index 00000000..a695fdc1 --- /dev/null +++ b/python/tests/test_engine_review.py @@ -0,0 +1,87 @@ +"""Independent native scheduling boundaries from the Python port review.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from dialcache import DialCache, Policy + + +@pytest.mark.skipif( + not hasattr(asyncio, "eager_task_factory"), reason="Eager task factories require Python 3.12+" +) +@pytest.mark.parametrize( + "policy", + [ + Policy(ttl_sec={"local": 60}), + Policy(request_local=True), + Policy(ttl_sec={"local": 60}, request_local=True), + ], +) +async def test_cached_hit_is_safe_under_native_eager_task_factory(policy): + loop = asyncio.get_running_loop() + previous = loop.get_task_factory() + loop.set_task_factory(asyncio.eager_task_factory) + calls = [] + cache = DialCache() + + def source(): + calls.append(1) + return "cached" + + async def get(): + return await cache.get_or_load( + source, key="id", key_type="entity", use_case="eager-hit", default_config=policy + ) + + try: + with cache.enable(): + assert await get() == "cached" + assert await get() == "cached" + assert calls == [1] + assert cache.get_coalescing_state()["process"]["active_leaders"] == 0 + finally: + loop.set_task_factory(previous) + + +@pytest.mark.skipif( + not hasattr(asyncio, "eager_task_factory"), reason="Eager task factories require Python 3.12+" +) +@pytest.mark.parametrize("cancel_leader", [True, False]) +@pytest.mark.parametrize("policy", [Policy(ttl_sec={"local": 60}), Policy(request_local=True)]) +async def test_eager_shared_source_survives_caller_cancellation(cancel_leader, policy): + loop = asyncio.get_running_loop() + previous = loop.get_task_factory() + loop.set_task_factory(asyncio.eager_task_factory) + gate = loop.create_future() + started = [] + cache = DialCache() + + async def source(): + started.append(1) + return await gate + + async def get(): + return await cache.get_or_load( + source, key="id", key_type="entity", use_case="eager-cancel", default_config=policy + ) + + try: + with cache.enable(): + leader = asyncio.create_task(get()) + follower = asyncio.create_task(get()) + assert started == [1] + cancelled, survivor = (leader, follower) if cancel_leader else (follower, leader) + cancelled.cancel() + with pytest.raises(asyncio.CancelledError): + await cancelled + assert not gate.cancelled() + gate.set_result("shared") + assert await survivor == "shared" + assert await get() == "shared" + assert started == [1] + assert cache.get_coalescing_state()["process"]["active_leaders"] == 0 + finally: + loop.set_task_factory(previous) diff --git a/python/tests/test_local.py b/python/tests/test_local.py new file mode 100644 index 00000000..be0e137b --- /dev/null +++ b/python/tests/test_local.py @@ -0,0 +1,64 @@ +import pytest + +from dialcache.errors import ConfigError +from dialcache.local import LocalCache + + +class Clock: + now = 0.0 + + def monotonic_ms(self): + return self.now + + +def test_fractional_insertion_and_read_use_whole_millisecond_grid(): + clock = Clock() + cache = LocalCache(clock=clock) + clock.now = 0.7 + cache.put("key", None, 1) + clock.now = 999.999 + assert cache.read("key") == (True, None) + clock.now = 1000.0 + assert cache.read("key") == (False, None) + + +def test_hit_promotes_lru_without_renewing_expiry(): + clock = Clock() + cache = LocalCache(max_size=2, clock=clock) + cache.put("a", 1, 1) + cache.put("b", 2, 2) + clock.now = 900 + assert cache.read("a") == (True, 1) + cache.put("c", 3, 3) + assert cache.read("b") == (False, None) + clock.now = 1000 + assert cache.read("a") == (False, None) + assert cache.read("c") == (True, 3) + + +def test_new_publication_replaces_value_and_expiration(): + clock = Clock() + cache = LocalCache(clock=clock) + cache.put("key", "first", 1) + clock.now = 900.8 + cache.put("key", "second", 2) + clock.now = 2899.9 + assert cache.read("key") == (True, "second") + clock.now = 2900 + assert cache.read("key") == (False, None) + + +def test_zero_capacity_and_large_sparse_capacity(): + cache = LocalCache(max_size=0) + cache.put("key", 1, 1) + assert cache.read("key") == (False, None) + assert len(cache) == 0 + large = LocalCache(max_size=9_007_199_254_740_991) + large.put("key", 1, 1) + assert len(large) == 1 + + +@pytest.mark.parametrize("capacity", [True, None, -1, 1.5, 9_007_199_254_740_992]) +def test_invalid_capacity_fails_at_construction(capacity): + with pytest.raises(ConfigError): + LocalCache(max_size=capacity) diff --git a/python/tests/test_protocol_native.py b/python/tests/test_protocol_native.py new file mode 100644 index 00000000..d3e8ce00 --- /dev/null +++ b/python/tests/test_protocol_native.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import json +import math +import os +import random +import shutil +import struct +import subprocess +from pathlib import Path + +import pytest +import zstandard + +from dialcache.key import Key, normalize_args, ramp_hash, scalar_string +from dialcache.protocol import ( + Frame, + Miss, + RedisPayloadError, + RedisProtocolError, + decode_read, + decode_tracked_read, + decompress_payload, + encode_frame, + validate_invalidation_reply, + validate_set_reply, +) +from dialcache.serializer import UNDEFINED, JsonSerializer + +ROOT = Path(__file__).resolve().parents[2] + +# Import the actual TypeScript module after stripping types with native Node. +# No copied key/frame algorithm and no package build is used as the oracle. +NODE_BRIDGE = r""" +import fs from 'node:fs'; +import path from 'node:path'; +import { stripTypeScriptTypes } from 'node:module'; +const cache = new Map(); +function moduleUrl(file) { + if (cache.has(file)) return cache.get(file); + let source = stripTypeScriptTypes(fs.readFileSync(file, 'utf8'), {mode:'strip'}); + source = source.replace(/(from\s+["'])(\.{1,2}\/[^"']+\.js)(["'])/g, (_, before, spec, after) => { + const resolved = path.resolve(path.dirname(file), spec.slice(0, -3) + '.ts'); + return before + moduleUrl(resolved) + after; + }); + const url = 'data:text/javascript;base64,' + Buffer.from(source).toString('base64'); + cache.set(file, url); return url; +} +let text = ''; for await (const chunk of process.stdin) text += chunk; +const input = JSON.parse(text); +let output; +if (input.op === 'numbers') output = input.values.map(String); +else if (input.op === 'script') output = (await import(moduleUrl(path.resolve('src/internal/redis-scripts.ts')))).INVALIDATE_CACHE_SCRIPT; +else { + const wire = await import(moduleUrl(path.resolve('src/internal/redis-payload.ts'))); + if (input.op === 'encode') output = wire.encodeRedisFrame(input.binary ? Buffer.from(input.payload,'hex') : input.payload,input.at).toString('hex'); + if (input.op === 'decode') { + const frame = Buffer.from(input.hex,'hex'); + const result = input.tracked ? wire.decodeTrackedRedisReadResult(frame,input.watermark == null ? null : Buffer.from(input.watermark)) : wire.decodeRedisReadResult(frame); + output = result.kind === 'miss' ? result : { at:result.createdAtMs, binary:Buffer.isBuffer(result.payload), payload:Buffer.isBuffer(result.payload)?result.payload.toString('hex'):result.payload }; + } +} +process.stdout.write(JSON.stringify(output)); +""" + + +def node_bridge(message): + node = os.environ.get("NODE", shutil.which("node")) + if node is None: + pytest.fail("Node 24+ is required for native cross-language conformance") + result = subprocess.run( + [node, "--input-type=module", "-e", NODE_BRIDGE], + cwd=ROOT, + input=json.dumps(message), + text=True, + capture_output=True, + timeout=30, + check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_native_ieee754_scalar_identity_matches_javascript(): + values = [ + 0.0, + -0.0, + 1e-7, + 1e-6, + 1e20, + 1e21, + 1e23, + 1.0000000000000001e18, + 9007199254740992.0, + 1000000000000000100.0, + 5e-324, + float.fromhex("0x1.fffffffffffffp+1023"), + ] + for value in list(values): + values.extend( + candidate + for candidate in [math.nextafter(value, -math.inf), math.nextafter(value, math.inf)] + if math.isfinite(candidate) + ) + generator = random.Random(31001) + while len(values) < 10000: + value = struct.unpack(">d", generator.randbytes(8))[0] + if math.isfinite(value): + values.append(value) + assert [scalar_string(value) for value in values] == node_bridge({"op": "numbers", "values": values}) + + +def test_arbitrary_bigints_and_scalar_domains(): + assert scalar_string(10**5000 + 7) == "1" + "0" * 4999 + "7" + assert scalar_string(-(10**5000 + 7)) == "-1" + "0" * 4999 + "7" + assert scalar_string(-0.0) == "0" + assert scalar_string(math.nan) == "NaN" + assert scalar_string(math.inf) == "Infinity" + assert scalar_string(-math.inf) == "-Infinity" + with pytest.raises(TypeError): + scalar_string([]) + + +def test_utf16_order_pairs_and_payload_surrogates(): + assert normalize_args({"\ue000": 1, "\U00010000": 2, "missing": UNDEFINED}) == ( + ("\U00010000", "2"), + ("\ue000", "1"), + ) + pair_key = Key("urn", "id", "\ud83d\ude00", "Get") + assert pair_key.logical == Key("urn", "id", "😀", "Get").logical + assert encode_frame("\ud83d\ude00", 1) == encode_frame("😀", 1) + assert encode_frame("\ud800", 1)[10:] == b"\xef\xbf\xbd" + assert ramp_hash("😀", "local") == ramp_hash("\ud83d\ude00", "local") + + +@pytest.mark.parametrize("raw", ["text", bytearray(b"frame"), memoryview(b"frame"), 1, False, [], {}]) +def test_protocol_rejects_nonbulk_runtime_replies(raw): + with pytest.raises(RedisPayloadError): + decode_read(raw) + with pytest.raises(RedisPayloadError): + decode_tracked_read(None, raw) + + +def test_fence_grammar_and_classification_precedence(): + frame = encode_frame("x", 1) + assert decode_tracked_read(None, b"bad") == Miss("value_absent") + assert decode_tracked_read(frame, b"0" * 10000) == Frame(1, "x") + assert decode_tracked_read(frame, b"9" * 10000) == Miss("unclassified") + assert decode_tracked_read(frame[:9] + b"\xffx", b"1") == Miss("watermark_fenced", 1) + with pytest.raises(ValueError): + encode_frame("x", 10**5000) + + +def test_mutation_replies_are_strict(): + for reply in [None, False, "1", b"1", 1.0, True, 0, 2]: + with pytest.raises(RedisProtocolError): + validate_invalidation_reply(reply) + validate_invalidation_reply(1) + for reply in [None, False, "ok", 1, b"no"]: + with pytest.raises(RedisProtocolError): + validate_set_reply(reply) + for reply in ["OK", b"OK", True]: + validate_set_reply(reply) + + +def test_compression_resource_and_native_stream_boundaries(): + for known_size in [True, False]: + compressed = zstandard.ZstdCompressor(write_content_size=known_size).compress(b"a" * 10000) + raw = b"\x02" + compressed + assert decompress_payload(raw, 9999).outcome == "read_over_limit" + assert decompress_payload(raw, 10000).payload == b"a" * 10000 + assert decompress_payload(raw[:-1], 10000).outcome == "fallback_raw" + assert decompress_payload(raw[:-1], 2).outcome == "fallback_raw" + first = zstandard.ZstdCompressor().compress(b"first") + second = zstandard.ZstdCompressor().compress(b"second") + # Match Node's native decoder: only the first completed stream is consumed. + assert decompress_payload(b"\x01" + first + second).payload == "first" + assert decompress_payload(b"\x01" + first + b"trailer").payload == "first" + + +def test_json_roundtrips_and_undefined(): + serializer = JsonSerializer() + values = [None, False, True, 0, -42, 1.5, "雪", {"k": [1, None, "x"]}, UNDEFINED] + for value in values: + assert serializer.load(serializer.dump(value)) == value + assert serializer.dump(UNDEFINED) == "__dialcache_json_undefined_v1__" + assert serializer.load('"__dialcache_json_undefined_v1__"') == "__dialcache_json_undefined_v1__" + assert serializer.load(b'"\xff"') == "\ufffd" + for value in [math.nan, math.inf, -math.inf, object()]: + with pytest.raises((TypeError, ValueError)): + serializer.dump(value) + + +@pytest.mark.parametrize("value", ["\ud800", "\udc00", "A\ud800B", {"\ud800": ["\udc00", "雪"]}]) +def test_json_strings_survive_the_frame_utf8_boundary(value): + serializer = JsonSerializer() + decoded = decode_read(encode_frame(serializer.dump(value), 1)) + assert isinstance(decoded, Frame) + assert serializer.load(decoded.payload) == value diff --git a/python/tests/test_protocol_vectors.py b/python/tests/test_protocol_vectors.py new file mode 100644 index 00000000..50336180 --- /dev/null +++ b/python/tests/test_protocol_vectors.py @@ -0,0 +1,226 @@ +"""Replay every fixed and generated portable wire vector against the real API.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest +import zstandard + +from dialcache.key import Key, normalize_args, ramp_hash, ramp_sample +from dialcache.protocol import ( + Frame, + Miss, + RedisPayloadEncodingError, + ceil_supported_cache_ttl_ms, + compress_payload, + decode_read, + decode_tracked_read, + decompress_payload, + encode_frame, + escape_raw_payload, + utf8_bytes, +) +from dialcache.serializer import UNDEFINED + +ROOT = Path(__file__).resolve().parents[2] +CORPORA = [ + json.loads((ROOT / "formal" / name).read_text()) + for name in ( + "protocol-vectors.json", + "quint-key-vectors.json", + "quint-frame-vectors.json", + "quint-envelope-vectors.json", + ) +] + + +def vectors(group): + return [pytest.param(vector, id=vector["name"]) for corpus in CORPORA for vector in corpus.get(group, [])] + + +def payload(vector): + return bytes.fromhex(vector["payloadHex"]) if vector["payloadType"] == "binary" else vector["payloadUtf8"] + + +def key_from(vector): + value = vector["input"] + return Key( + namespace=value["namespace"], + key_type=value["keyType"], + id=value["id"], + use_case=value["useCase"], + args=value["args"], + tracked=value["trackForInvalidation"], + ) + + +def test_schemas_provenance_and_inventory(): + assert all(corpus["schemaVersion"] == 3 for corpus in CORPORA) + expected_counts = [134, 457, 589, 297] + for corpus, expected in zip(CORPORA, expected_counts, strict=True): + rows = [row for value in corpus.values() if isinstance(value, list) for row in value] + assert len(rows) == expected + for path, expected_hash in corpus.get("provenance", {}).get("sourceSha256", {}).items(): + assert hashlib.sha256((ROOT / path).read_bytes()).hexdigest() == expected_hash, path + + +@pytest.mark.parametrize("vector", vectors("keyVectors")) +def test_keys(vector): + key = key_from(vector) + assert key.logical == vector["logicalKey"] + assert key.value_key == vector["valueKey"] + assert key.watermark_key == vector["watermarkKey"] + + +@pytest.mark.parametrize("vector", vectors("invalidKeyVectors")) +def test_invalid_keys(vector): + with pytest.raises((ValueError, TypeError)): + key_from(vector) + + +@pytest.mark.parametrize("vector", vectors("normalizeArgsVectors")) +def test_normalize_args(vector): + values = { + name: UNDEFINED if "undefinedSentinel" in vector and value == vector["undefinedSentinel"] else value + for name, value in vector["input"].items() + } + values.update({name: int(value) for name, value in vector.get("bigintArgs", {}).items()}) + values.update({name: float(value) for name, value in vector.get("specialArgs", {}).items()}) + assert normalize_args(values) == tuple(tuple(pair) for pair in vector["expected"]) + + +@pytest.mark.parametrize("vector", vectors("rampVectors")) +def test_rollout(vector): + key = key_from(vector) + assert ramp_sample(key, vector["layer"]) == vector["sample"] + if "hashNumerator" in vector: + assert ramp_hash(key, vector["layer"]) == vector["hashNumerator"] + + +@pytest.mark.parametrize("vector", vectors("frameVectors")) +def test_encode(vector): + assert encode_frame(payload(vector), vector["createdAtMs"]).hex() == vector["frameHex"] + + +@pytest.mark.parametrize("vector", vectors("invalidTimestampVectors")) +def test_invalid_timestamp(vector): + value = float(vector["specialInput"]) if "specialInput" in vector else vector["input"] + with pytest.raises(ValueError): + encode_frame("value", value) + + +@pytest.mark.parametrize("vector", vectors("durationVectors")) +def test_duration(vector): + value = float(vector["specialInput"]) if "specialInput" in vector else vector["input"] + if vector["expected"] is None: + with pytest.raises(ValueError): + ceil_supported_cache_ttl_ms(value) + else: + assert ceil_supported_cache_ttl_ms(value) == vector["expected"] + + +def assert_decode(vector, *, tracked): + raw = None if vector["frameHex"] is None else bytes.fromhex(vector["frameHex"]) + watermark = vector.get("watermarkUtf8") + + def decode(): + return ( + decode_tracked_read(raw, None if watermark is None else watermark.encode()) + if tracked + else decode_read(raw) + ) + + expected = vector["expected"] + if expected["kind"] == "payload_encoding_error": + with pytest.raises(RedisPayloadEncodingError): + decode() + elif expected["kind"] == "miss": + assert decode() == Miss(expected["reason"], expected.get("observedWatermarkMs")) + else: + assert decode() == Frame(expected["createdAtMs"], payload(expected)) + + +@pytest.mark.parametrize("vector", vectors("trackedDecodeVectors")) +def test_tracked_decode(vector): + assert_decode(vector, tracked=True) + + +@pytest.mark.parametrize("vector", vectors("untrackedDecodeVectors")) +def test_untracked_decode(vector): + assert_decode(vector, tracked=False) + + +@pytest.mark.parametrize("vector", vectors("envelopeVectors")) +def test_envelopes(vector): + raw = bytes.fromhex(vector["inputHex"]) + escaped = bytes.fromhex(vector["escapedHex"]) + assert escape_raw_payload(raw) == escaped + result = decompress_payload(raw) + assert result.payload == bytes.fromhex(vector["decodedHex"]) + assert result.outcome == vector["outcome"] + assert decompress_payload(escaped).payload == raw + + +@pytest.mark.parametrize("vector", vectors("compressedDecodeVectors")) +def test_compressed_decode(vector): + raw = bytes.fromhex(vector["inputHex"]) + if "codecFixture" in vector: + fixture = vector["codecFixture"] + if fixture["succeeds"]: + assert zstandard.ZstdDecompressor().decompress(raw[1:]).hex() == fixture["decodedHex"] + else: + with pytest.raises(zstandard.ZstdError): + zstandard.ZstdDecompressor().decompress(raw[1:]) + result = decompress_payload(raw, vector.get("maxDecompressedBytes", 536870912)) + assert result.payload == payload(vector) + assert result.outcome == vector.get("outcome", "decompressed") + + +@pytest.mark.parametrize("vector", vectors("compressionWriteVectors")) +def test_compression_selection(vector): + raw = payload(vector) + data = raw if isinstance(raw, bytes) else utf8_bytes(raw) + if "codecBytes" in vector: + # zstandard's level-3 binding matches Node's codec environment for this + # complete generated domain; establish that before comparing its model. + native = zstandard.ZstdCompressor(level=3).compress(data) + assert len(native) == vector["codecBytes"]["typescript"] + result = compress_payload( + raw, threshold_bytes=vector["thresholdBytes"], maximum=vector.get("maxDecompressedBytes", 536870912) + ) + expected = vector.get("expectedByBinding", {}).get("typescript") + assert result.outcome == (expected["outcome"] if expected else vector["outcome"]) + if expected: + assert result.stored_bytes == expected["storedBytes"] + assert result.original_bytes == vector["originalBytes"] + escaped = escape_raw_payload(raw) + assert (escaped if isinstance(escaped, bytes) else utf8_bytes(escaped)).hex() == vector["escapedHex"] + if result.outcome == "compressed": + assert result.payload[0] == expected["marker"] + assert decompress_payload(result.payload).payload == raw + + +def assert_wire_vector(group, vector): + """Completion reporters call these same real assertions before granting credit.""" + assertions = { + "keyVectors": test_keys, + "invalidKeyVectors": test_invalid_keys, + "normalizeArgsVectors": test_normalize_args, + "rampVectors": test_rollout, + "frameVectors": test_encode, + "invalidTimestampVectors": test_invalid_timestamp, + "durationVectors": test_duration, + "trackedDecodeVectors": test_tracked_decode, + "untrackedDecodeVectors": test_untracked_decode, + "envelopeVectors": test_envelopes, + "compressedDecodeVectors": test_compressed_decode, + "compressionWriteVectors": test_compression_selection, + } + try: + assertion = assertions[group] + except KeyError: + raise ValueError(f"Unsupported wire vector group: {group}") from None + assertion(vector) diff --git a/python/tests/test_redis_adapter.py b/python/tests/test_redis_adapter.py new file mode 100644 index 00000000..07e06004 --- /dev/null +++ b/python/tests/test_redis_adapter.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import asyncio + +import pytest +from test_protocol_native import node_bridge + +from dialcache.protocol import Frame, Miss, RedisProtocolError, encode_frame +from dialcache.redis import ( + INVALIDATE_CACHE_SCRIPT, + INVALIDATE_CACHE_SCRIPT_SHA1, + InvalidationRequest, + ReadContext, + ReadRequest, + RedisAdapter, + WriteRequest, +) + + +class Client: + def __init__(self, replies): + self.replies = iter(replies) + self.calls = [] + + async def execute_command(self, *args, **kwargs): + self.calls.append((args, kwargs)) + value = next(self.replies) + if isinstance(value, BaseException): + raise value + return value + + +async def test_semantic_reads_and_single_complete_frame_set(): + client = Client([None, [encode_frame("old", 1000), b"1000"], [encode_frame(b"new", 1001), b"1000"], True]) + adapter = RedisAdapter(client) + assert await adapter.read(ReadRequest("value")) == Miss("value_absent") + assert await adapter.read(ReadRequest("value", "watermark")) == Miss("watermark_fenced", 1000) + assert await adapter.read(ReadRequest("value", "watermark")) == Frame(1001, b"new") + await adapter.write(WriteRequest("value", 1.25, b"data", 17)) + assert client.calls == [ + (("GET", "value"), {}), + (("MGET", "value", "watermark"), {}), + (("MGET", "value", "watermark"), {}), + (("SET", "value", encode_frame(b"data", 17), "PX", "2"), {}), + ] + + +async def test_invalidation_retry_preserves_exact_arguments_and_error(): + client = Client([ConnectionError("ambiguous"), 1]) + await RedisAdapter(client).invalidate(InvalidationRequest("watermark", 200, 1000)) + assert client.calls == [ + (("EVALSHA", INVALIDATE_CACHE_SCRIPT_SHA1, "1", "watermark", "200", "1000"), {}), + (("EVAL", INVALIDATE_CACHE_SCRIPT, "1", "watermark", "200", "1000"), {}), + ] + error = RuntimeError("second rejection") + client = Client([ConnectionError(), error]) + with pytest.raises(RuntimeError) as raised: + await RedisAdapter(client).invalidate(InvalidationRequest("watermark", 0, 1000)) + assert raised.value is error + + +async def test_accepted_bad_mutation_replies_are_never_retried(): + for reply in [None, "1", True, 1.0]: + client = Client([reply]) + with pytest.raises(RedisProtocolError): + await RedisAdapter(client).invalidate(InvalidationRequest("watermark", 0, 1000)) + assert len(client.calls) == 1 + + +async def test_mutation_input_validation_precedes_dispatch(): + client = Client([]) + adapter = RedisAdapter(client) + for timestamp in [-1, True, 1.5, float("inf"), 9007199254740992]: + with pytest.raises(ValueError): + await adapter.write(WriteRequest("value", 10, "x", timestamp)) + with pytest.raises(ValueError): + await adapter.invalidate(InvalidationRequest("watermark", 0, timestamp)) + for ttl in [0, -1, True, float("inf"), 31536000001]: + with pytest.raises(ValueError): + await adapter.write(WriteRequest("value", ttl, "x", 1)) + assert client.calls == [] + + +async def test_cluster_primary_routing_overrides_replica_reads(): + class Cluster(Client): + async def initialize(self): + self.initialized = True + + def get_node_from_key(self, key, replica=False): + assert self.initialized + assert replica is False + return ("primary", key) + + client = Cluster([[encode_frame("value", 2), b"1"]]) + assert await RedisAdapter(client).read(ReadRequest("{entity}#value", "{entity}#watermark")) == Frame( + 2, "value" + ) + assert client.calls[0][1] == {"target_nodes": ("primary", "{entity}#value")} + + +async def test_preaborted_read_does_not_dispatch(): + class Signal: + aborted = True + + client = Client([]) + with pytest.raises(asyncio.CancelledError): + await RedisAdapter(client).read(ReadRequest("key"), ReadContext(10, Signal())) + assert client.calls == [] + + +@pytest.mark.parametrize("reply", [None, [], [b"a"], [b"a", b"b", b"c"], "ab"]) +async def test_invalid_atomic_snapshot_shape(reply): + with pytest.raises(RedisProtocolError): + await RedisAdapter(Client([reply])).read(ReadRequest("value", "watermark")) + + +def test_lua_source_is_identical_to_current_typescript(): + assert INVALIDATE_CACHE_SCRIPT == node_bridge({"op": "script"}) diff --git a/python/tests/test_redis_integration.py b/python/tests/test_redis_integration.py new file mode 100644 index 00000000..a806879d --- /dev/null +++ b/python/tests/test_redis_integration.py @@ -0,0 +1,193 @@ +"""Real Redis/Valkey transition and Python/TypeScript interoperability evidence.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +from uuid import uuid4 + +import pytest +import redis.asyncio as redis +from redis.exceptions import ResponseError +from test_protocol_native import node_bridge + +from dialcache.key import Key +from dialcache.protocol import Frame, Miss, encode_frame +from dialcache.redis import ( + INVALIDATE_CACHE_SCRIPT, + InvalidationRequest, + ReadRequest, + RedisAdapter, + WriteRequest, +) + +pytestmark = pytest.mark.integration +ROOT = Path(__file__).resolve().parents[2] +INVALIDATION_CORPORA = [ + json.loads((ROOT / "formal" / name).read_text()) + for name in ["invalidation-vectors.json", "quint-invalidation-vectors.json"] +] +INVALIDATIONS = [ + pytest.param(vector, id=vector["name"]) for corpus in INVALIDATION_CORPORA for vector in corpus["vectors"] +] + + +@pytest.fixture +async def server(): + url = os.environ.get("TEST_REDIS_URL") + if not url: + pytest.skip("Set TEST_REDIS_URL to run against a real Redis or Valkey server") + client = redis.Redis.from_url(url, decode_responses=False, socket_timeout=5, socket_connect_timeout=5) + await client.ping() + try: + yield client + finally: + await client.aclose() + + +@pytest.fixture +async def owned_key(server): + key = "dialcache-python-test:" + uuid4().hex + try: + yield key + finally: + await server.delete(key) + + +def test_invalidation_corpus_inventory_and_provenance(): + assert [len(corpus["vectors"]) for corpus in INVALIDATION_CORPORA] == [49, 288] + for corpus in INVALIDATION_CORPORA: + assert corpus["schemaVersion"] == 2 + assert len({vector["name"] for vector in corpus["vectors"]}) == len(corpus["vectors"]) + for path, expected in corpus.get("provenance", {}).get("sourceSha256", {}).items(): + assert hashlib.sha256((ROOT / path).read_bytes()).hexdigest() == expected + + +@pytest.mark.parametrize("vector", INVALIDATIONS) +async def test_all_invalidation_transitions(server, owned_key, vector): + existing, expected = vector["existing"], vector["expected"]["state"] + commands = server.pipeline(transaction=True) + commands.time() + commands.delete(owned_key) + if existing["kind"] == "string": + commands.set(owned_key, existing["value"]) + elif existing["kind"] == "list": + commands.rpush(owned_key, *existing["values"]) + if existing["ttlMs"] > 0: + commands.pexpire(owned_key, existing["ttlMs"]) + result_index = len(commands.command_stack) + commands.eval(INVALIDATE_CACHE_SCRIPT, 1, owned_key, vector["futureBufferMs"], vector["invalidatedAtMs"]) + commands.type(owned_key) + content_index = len(commands.command_stack) + if expected["kind"] == "string": + commands.get(owned_key) + elif expected["kind"] == "list": + commands.lrange(owned_key, 0, -1) + commands.pttl(owned_key) + commands.time() + results = await commands.execute(raise_on_error=False) + result = results[result_index] + if vector["expected"].get("error"): + assert isinstance(result, ResponseError), result + else: + assert result == 1 + assert ( + results[result_index + 1] + == {"absent": b"none", "string": b"string", "list": b"list"}[expected["kind"]] + ) + if expected["kind"] == "string": + assert results[content_index] == expected["value"].encode() + elif expected["kind"] == "list": + assert results[content_index] == [value.encode() for value in expected["values"]] + actual_ttl = results[-2] + expected_ttl = expected["ttlMs"] + if expected_ttl < 0: + assert actual_ttl == expected_ttl + else: + # Only the Redis clock measured around the atomic setup/transition/read + # widens the bound; no arbitrary network tolerance hides TTL defects. + before, after = results[0], results[-1] + elapsed_ms = (after[0] * 1_000_000 + after[1]) // 1000 - (before[0] * 1_000_000 + before[1]) // 1000 + assert expected_ttl - elapsed_ms <= actual_ttl <= expected_ttl + + +async def test_adapter_real_frames_fencing_and_retention(server): + key = Key("dialcache-python-test-" + uuid4().hex, "user", "1", "Get", tracked=True) + adapter = RedisAdapter(server) + try: + assert await adapter.read(ReadRequest(key.value_key, key.watermark_key)) == Miss("value_absent") + await adapter.write(WriteRequest(key.value_key, 60000, "value", 1000)) + assert await server.get(key.value_key) == encode_frame("value", 1000) + assert await server.get(key.watermark_key) is None + before = await server.time() + await adapter.invalidate(InvalidationRequest(key.watermark_key, 200, 1000)) + assert await adapter.read(ReadRequest(key.value_key, key.watermark_key)) == Miss( + "watermark_fenced", 1200 + ) + retention = await server.pttl(key.watermark_key) + after = await server.time() + elapsed = (after[0] * 1_000_000 + after[1]) // 1000 - (before[0] * 1_000_000 + before[1]) // 1000 + assert 7_200_000 - elapsed <= retention <= 7_200_000 + await adapter.write(WriteRequest(key.value_key, 60000, b"fresh", 1201)) + assert await adapter.read(ReadRequest(key.value_key, key.watermark_key)) == Frame(1201, b"fresh") + assert await server.get(key.watermark_key) == b"1200" + await server.delete(key.value_key) + assert await adapter.read(ReadRequest(key.value_key, key.watermark_key)) == Miss("value_absent", 1200) + finally: + await server.delete(key.value_key, key.watermark_key) + + +@pytest.mark.parametrize("value", ["snow: 雪 😀", b"\x00\xff\x01\x02"]) +async def test_bidirectional_typescript_protocol_through_real_redis(server, owned_key, value): + adapter = RedisAdapter(server) + await adapter.write(WriteRequest(owned_key, 60000, value, 1234)) + raw = await server.get(owned_key) + result = node_bridge({"op": "decode", "hex": raw.hex(), "tracked": True, "watermark": "1233"}) + assert result == { + "at": 1234, + "binary": isinstance(value, bytes), + "payload": value.hex() if isinstance(value, bytes) else value, + } + encoded = node_bridge( + { + "op": "encode", + "at": 1235, + "binary": isinstance(value, bytes), + "payload": value.hex() if isinstance(value, bytes) else value, + } + ) + await server.set(owned_key, bytes.fromhex(encoded), px=60000) + assert await adapter.read(ReadRequest(owned_key)) == Frame(1235, value) + + +async def test_cluster_atomic_primary_snapshot_and_native_writes(): + url = os.environ.get("TEST_REDIS_CLUSTER_URL") + if not url: + pytest.skip("Set TEST_REDIS_CLUSTER_URL to test an actual Redis Cluster") + client = redis.RedisCluster.from_url( + url, read_from_replicas=True, decode_responses=False, socket_timeout=5, socket_connect_timeout=5 + ) + key = Key("dialcache-python-cluster-" + uuid4().hex, "entity", "1", "Get", tracked=True) + adapter = RedisAdapter(client) + try: + await client.initialize() + await adapter.write(WriteRequest(key.value_key, 60000, "before", 1000)) + await adapter.invalidate(InvalidationRequest(key.watermark_key, 0, 1000)) + assert await adapter.read(ReadRequest(key.value_key, key.watermark_key)) == Miss( + "watermark_fenced", 1000 + ) + await adapter.write(WriteRequest(key.value_key, 60000, "after", 1001)) + for _ in range(20): + assert await adapter.read(ReadRequest(key.value_key, key.watermark_key)) == Frame(1001, "after") + primary = client.get_node_from_key(key.value_key, replica=False) + assert await client.execute_command("GET", key.value_key, target_nodes=primary) == encode_frame( + "after", 1001 + ) + assert await client.execute_command("GET", key.watermark_key, target_nodes=primary) == b"1000" + finally: + try: + await client.delete(key.value_key, key.watermark_key) + finally: + await client.aclose() diff --git a/python/tests/test_shadow_deadline_retention.py b/python/tests/test_shadow_deadline_retention.py new file mode 100644 index 00000000..10e69186 --- /dev/null +++ b/python/tests/test_shadow_deadline_retention.py @@ -0,0 +1,51 @@ +"""Native ownership check: abandoned shadow work releases retained raw bytes.""" + +import gc +import weakref + +from formal.executor import Executor + +from dialcache import DialCache +from dialcache.protocol import Frame + + +def test_timed_out_shadow_releases_frame_while_source_is_still_held(): + executor = Executor() + retained = [] + events = [] + source = executor.future() + + class EphemeralRedis: + async def read(self, request, context=None): + # Unlike a storage fake, this adapter retains no strong reference + # after returning its frame, so only native cache ownership remains. + frame = Frame(int(executor.clock.wall_ms()), "1") + retained.append(weakref.ref(frame)) + return frame + + cache = DialCache(redis=EphemeralRedis(), clock=executor.clock, metrics=events.append) + + async def call(): + with cache.enable(): + return await cache.get_or_load( + lambda: source, + key_type="id", + key="1", + use_case="Retention", + fallback_timeout_ms=10, + default_config={"ttlSec": {"remote": 60}, "shadow": {"ramp": 100}}, + ) + + try: + assert executor.finish(call()) == 1 + assert len(retained) == 1 + assert retained[0]() is not None + assert not source.done() + executor.clock.advance(10) + executor.drain() + assert [event["outcome"] for event in events if event["event"] == "shadowValidation"] == ["timeout"] + assert not source.done() + gc.collect() + assert retained[0]() is None + finally: + executor.close() diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index 4061f0ca..a8bb7a67 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -58,8 +58,11 @@ export function checkDocsSources(directory = root) { if (!port) { failures.push(`${at}: snippet must use a registered, executed example file`); continue; } if (port.id !== selected) failures.push(`${at}: ${port.id} example requires its matching LanguageContent`); const code = readFileSync(path, 'utf8'); - const starts = [...code.matchAll(new RegExp(`^\\s*//\\s*#region ${region}\\s*$`, 'gm'))]; - const ends = [...code.matchAll(new RegExp(`^\\s*//\\s*#endregion ${region}\\s*$`, 'gm'))]; + // Match VitePress's native region syntax. In Python the leading # is + // already the region marker: '# region', never '# #region'. + const regionMarker = path.endsWith('.py') ? '# ?' : '//\\s*#'; + const starts = [...code.matchAll(new RegExp(`^\\s*${regionMarker}region ${region}\\s*$`, 'gm'))]; + const ends = [...code.matchAll(new RegExp(`^\\s*${regionMarker}endregion ${region}\\s*$`, 'gm'))]; if (starts.length !== 1 || ends.length !== 1 || starts[0].index >= ends[0].index) failures.push(`${at}: missing, duplicate or unclosed region ${region} in ${input}`); if (!regions.has(region)) regions.set(region, new Set()); regions.get(region).add(port.id); diff --git a/scripts/check-docs.test.mjs b/scripts/check-docs.test.mjs index 81c30693..5b2d2e71 100644 --- a/scripts/check-docs.test.mjs +++ b/scripts/check-docs.test.mjs @@ -29,20 +29,26 @@ test('rejects missing regions instead of letting VitePress silently show the who const root = mkdtempSync(join(tmpdir(), 'dialcache-docs-')); t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'docs/languages'), { recursive: true }); - const ports = ['typescript', 'go', 'rust'].map(id => ({ id, guide: `/languages/${id}`, example: `${id}.txt` })); + const ports = ['typescript', 'go', 'rust', 'python'].map(id => ({ id, guide: `/languages/${id}`, example: id === 'python' ? 'python.py' : `${id}.txt` })); writeFileSync(join(root, 'docs/ports.json'), JSON.stringify(ports)); for (const port of ports) { writeFileSync(join(root, `docs${port.guide}.md`), '# Install\n'); - writeFileSync(join(root, port.example), '// #region scope\nreal_code();\n// #endregion scope\n'); + const marker = port.id === 'python' ? '# ' : '// #'; + writeFileSync(join(root, port.example), `${marker}region scope\nreal_code();\n${marker}endregion scope\n`); } - const section = id => `\n\n<<< @/../${id}.txt#scope\n\n\n`; + const section = id => `\n\n<<< @/../${ports.find(port => port.id === id).example}#scope\n\n\n`; writeFileSync(join(root, 'docs/concepts.md'), ports.map(port => section(port.id)).join('\n')); - assert.deepEqual(checkDocsSources(root), { ports: 3, imports: 3 }); + assert.deepEqual(checkDocsSources(root), { ports: 4, imports: 4 }); + writeFileSync(join(root, 'python.py'), '# #region scope\nreal_code()\n# #endregion scope\n'); + assert.throws(() => checkDocsSources(root), /missing, duplicate or unclosed region/); + writeFileSync(join(root, 'python.py'), '# region scope\nreal_code()\n# endregion scope\n'); writeFileSync(join(root, 'rust.txt'), 'code_without_the_named_region();\n'); assert.throws(() => checkDocsSources(root), /missing, duplicate or unclosed region/); writeFileSync(join(root, 'rust.txt'), '// #region scope\nreal_code();\n// #endregion scope\n'); writeFileSync(join(root, 'docs/concepts.md'), section('typescript') + section('go')); assert.throws(() => checkDocsSources(root), /scope missing rust/); + writeFileSync(join(root, 'docs/concepts.md'), ports.filter(port => port.id !== 'python').map(port => section(port.id)).join('\n')); + assert.throws(() => checkDocsSources(root), /scope missing python/); writeFileSync(join(root, 'docs/concepts.md'), section('typescript').replace('language="typescript"', 'language="ruby"')); assert.throws(() => checkDocsSources(root), /unknown language ruby/); }); diff --git a/scripts/generate-docs.mjs b/scripts/generate-docs.mjs index 224fcce5..830df1ad 100644 --- a/scripts/generate-docs.mjs +++ b/scripts/generate-docs.mjs @@ -28,6 +28,22 @@ if (!process.argv.includes('--catalogue-only')) { run('cargo', ['doc', '--locked', '--all-features', '--no-deps'], resolve(root, 'rust')); const metadata = JSON.parse(execFileSync('cargo', ['metadata', '--format-version=1', '--no-deps'], { cwd: resolve(root, 'rust'), encoding: 'utf8' })); cpSync(resolve(metadata.target_directory, 'doc'), resolve(destination, 'rust'), { recursive: true }); + const python = process.env.PYTHON ?? resolve(root, 'python/.venv/bin/python'); + // Use this checkout even when PYTHON points at an editable installation in + // another directory. pydoc is part of the standard library. + const pythonModules = JSON.parse(execFileSync(python, ['-c', `import importlib, json, pydoc, sys +sys.path.insert(0, sys.argv[1]) +names = ['dialcache', 'dialcache.cache', 'dialcache.config', 'dialcache.key', 'dialcache.serializer', 'dialcache.redis', 'dialcache.protocol', 'dialcache.metrics', 'dialcache.clock', 'dialcache.errors'] +print(json.dumps({name: pydoc.render_doc(importlib.import_module(name), renderer=pydoc.plaintext) for name in names})) +`, resolve(root, 'python')], { cwd: root, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 })); + mkdirSync(resolve(destination, 'python'), { recursive: true }); + writeFileSync(resolve(destination, 'python/index.html'), ` + +DialCache Python API + +

DialCache Python API

Generated with pydoc from ${revision.slice(0, 7)}. Use your browser's Find command to locate a symbol.

+
    ${Object.keys(pythonModules).map(name => `
  • ${name}
  • `).join('')}
+${Object.entries(pythonModules).map(([name, text]) => `

${name}

${escape(text)}
`).join('\n')}\n`); writeFileSync(resolve(destination, 'revision.json'), JSON.stringify({ revision }, null, 2) + '\n'); } @@ -55,7 +71,7 @@ const pages = ['---', 'editLink: false', '---', '', '# Behavior catalogue', '', 'These links describe registered evidence and its scope. They are not a fresh test result or a claim of exhaustive coverage. ' + 'See the [validation guide](' + source('formal/VALIDATION.md') + ') for how a completed run is established.', '', 'All supported ports replay the shared histories through their native drivers. ' + - [link('TypeScript replay tests', 'test/formal-features.test.ts'), link('Go replay tests', 'go/feature_replay_test.go'), link('Rust replay tests', 'rust/tests/conformance.rs')].join(' · ') + '.', '', + [link('TypeScript replay tests', 'test/formal-features.test.ts'), link('Go replay tests', 'go/feature_replay_test.go'), link('Rust replay tests', 'rust/tests/conformance.rs'), link('Python replay tests', 'python/tests/test_conformance.py')].join(' · ') + '.', '', '| Case | Behavior | Model and regression evidence | Shared replay evidence |', '| --- | --- | --- | --- |']; for (const item of inventory.cases) { diff --git a/test/formal-exploration.test.ts b/test/formal-exploration.test.ts index 66e6dd39..60f52b4f 100644 --- a/test/formal-exploration.test.ts +++ b/test/formal-exploration.test.ts @@ -28,7 +28,8 @@ const inventory: Entry[] = [ ]; const packageName = "example.com/exploration"; const context = (language: string) => ({ kind: "exploration", language, createdAt: 1, inventory, - specification: {}, implementation: {}, corpus: {} }); + specification: {}, implementation: {}, corpus: language === "python" ? Object.fromEntries(inventory.filter(entry => entry.path) + .map(entry => [entry.path!, createHash("sha256").update("synthetic exploratory history").digest("hex")])) : {} }); const { selectedProfiles } = await import(new URL("../formal/witnesses.mjs", import.meta.url).href) as { selectedProfiles(selection: string): string[] }; function tsReport(directory: string, failure?: string) { const ancestorTitles = ["generated recovery conformance"]; @@ -53,6 +54,19 @@ function rustReport(failure?: string) { records.push({ kind: "finish", status: failure ? "failed" : "passed", finishedAt: 20, cases: inventory.length, failed: failure ? 1 : 0 }); return records.map(record => JSON.stringify(record)).join("\n"); } +function pythonReport(failure?: string) { + const records: Record[] = [{ schemaVersion: 1, kind: "start", implementation: "python", + scope: "conformance", selection: "generated", partial: false, startedAt: 10 }]; + for (const [index, entry] of inventory.entries()) { + const failed = entry.id === failure; + records.push({ kind: "case", id: nativeBinding(entry, "python"), status: failed ? "failed" : "passed", + startedAt: 11 + index, finishedAt: 12 + index, + ...(entry.path ? { historySha256: createHash("sha256").update("synthetic exploratory history").digest("hex") } : {}), + ...(failed ? { message: "Observation mismatch\nexpected: 1\nactual: 2" } : {}) }); + } + records.push({ kind: "finish", status: failure ? "failed" : "passed", finishedAt: 20, cases: inventory.length, failed: failure ? 1 : 0 }); + return records.map(record => JSON.stringify(record)).join("\n"); +} function goReport(failure?: string) { const events: Record[] = []; const event = (Action: string, Test?: string) => events.push({ Action, ...(Test ? { Test } : {}), Package: packageName, Time: new Date(10 + events.length).toISOString() }); @@ -102,7 +116,7 @@ function savedFixture(directory: string) { writeFileSync(options.directory + '/.formal-traces/saved-runner.json', JSON.stringify(plan)); // A saved run's evaluator also has to leave a completed witness report behind. writeFileSync(options.directory + '/.formal-traces/witness-report.json', ${JSON.stringify(JSON.stringify(completedWitnessReport("0x2a", ["effects"])))}); - return [{ language: 'typescript', status: 'passed' }, { language: 'go', status: 'passed' }, { language: 'rust', status: 'passed' }]; + return [{ language: 'typescript', status: 'passed' }, { language: 'go', status: 'passed' }, { language: 'rust', status: 'passed' }, { language: 'python', status: 'passed' }]; }`, "formal/validation.mjs": `import { mkdirSync, writeFileSync } from 'node:fs'; export function checkPrerequisites(target, { directory }) { @@ -150,13 +164,14 @@ describe("isolated exploratory validation", () => { ["formal/generated-fixtures.mjs", "--check"], ["formal/witnesses.mjs", "evaluate", "--profile", "all"], ["formal/check-go-parity.mjs"], + ["formal/run-python-replay.mjs", "--generated", "--scenarios", "--complete", "--report", ".formal-traces/python-replay.jsonl"], ]); const replays = plan.filter(step => step.nativeReport); - expect(replays.map(step => step.nativeReport)).toEqual(["typescript", "go", "rust"]); - for (const step of replays) expect(step.env?.DIALCACHE_FEATURE_TRACE_DIR).toBe(`${directory}/.formal-traces/features`); - expect(plan.filter(step => step.explorationContext).map(step => step.explorationContext)).toEqual(["typescript", "go", "rust"]); + expect(replays.map(step => step.nativeReport)).toEqual(["typescript", "go", "rust", "python"]); + for (const step of replays.filter(item => item.nativeReport !== "python")) expect(step.env?.DIALCACHE_FEATURE_TRACE_DIR).toBe(`${directory}/.formal-traces/features`); + expect(plan.filter(step => step.explorationContext).map(step => step.explorationContext)).toEqual(["typescript", "go", "rust", "python"]); expect(plan.some(step => step.args?.includes("formal/check-go-replay.mjs") || step.args?.includes("formal/check-rust-replay.mjs") - || step.args?.includes("formal/conformance-adapters.mjs"))).toBe(false); + || step.args?.includes("formal/check-python-replay.mjs") || step.args?.includes("formal/conformance-adapters.mjs"))).toBe(false); expect(plan.some(step => step.args?.[0] === "formal/conformance.mjs" && step.args[1] === "check")).toBe(false); }); @@ -183,9 +198,9 @@ describe("isolated exploratory validation", () => { } finally { rmSync(directory, { recursive: true, force: true }); } }); - it.each(["typescript", "go", "rust"])("classifies exact %s witness leaves separately from replay failures", language => { + it.each(["typescript", "go", "rust", "python"])("classifies exact %s witness leaves separately from replay failures", language => { const native = (failure?: string) => language === "typescript" ? JSON.stringify(tsReport("/snapshot", failure)) - : language === "go" ? goReport(failure) : rustReport(failure); + : language === "go" ? goReport(failure) : language === "rust" ? rustReport(failure) : pythonReport(failure); expect(nativeExplorationResult(language, native(), context(language), "/snapshot", packageName).status).toBe("passed"); expect(nativeExplorationResult(language, native("witness/recovery"), context(language), "/snapshot", packageName)).toMatchObject({ status: "witness-check-failure", witnessFailures: ["witness/recovery"], caseFailures: [], @@ -262,7 +277,7 @@ describe("isolated exploratory validation", () => { await expect(explore("42", { directory, run: async () => { const output = join(directory, ".formal-traces/exploration", readdirSync(join(directory, ".formal-traces/exploration"))[0]!); writeFileSync(join(output, "workspace/rule.qnt"), "changed"); - return [{ language: "typescript", status: "passed" }, { language: "go", status: "passed" }, { language: "rust", status: "passed" }]; + return [{ language: "typescript", status: "passed" }, { language: "go", status: "passed" }, { language: "rust", status: "passed" }, { language: "python", status: "passed" }]; } })).rejects.toThrow(/changed/); const output = join(directory, ".formal-traces/exploration", readdirSync(join(directory, ".formal-traces/exploration"))[0]!); expect(readFileSync(join(directory, "rule.qnt"), "utf8")).toBe("original"); @@ -271,7 +286,7 @@ describe("isolated exploratory validation", () => { } finally { rmSync(directory, { recursive: true, force: true }); } }); - it("returns a nonzero failure after both ports report incomplete witness coverage", async () => { + it("returns a nonzero failure after every port reports incomplete witness coverage", async () => { const directory = mkdtempSync(join(tmpdir(), "dialcache-exploration-coverage-")); try { execFileSync("git", ["init", "--quiet"], { cwd: directory }); @@ -279,12 +294,12 @@ describe("isolated exploratory validation", () => { writeFileSync(join(directory, ".gitignore"), ".formal-traces/\n"); writeWitnessInventory(directory, selectedProfiles("all")); await expect(explore("42", { directory, run: async () => [ - { language: "typescript", status: "witness-check-failure" }, { language: "go", status: "witness-check-failure" }, { language: "rust", status: "witness-check-failure" }, + { language: "typescript", status: "witness-check-failure" }, { language: "go", status: "witness-check-failure" }, { language: "rust", status: "witness-check-failure" }, { language: "python", status: "witness-check-failure" }, ] })).rejects.toThrow(/witness-check-failure/); const output = join(directory, ".formal-traces/exploration", readdirSync(join(directory, ".formal-traces/exploration"))[0]!); expect(JSON.parse(readFileSync(join(output, "report.json"), "utf8"))).toMatchObject({ kind: "exploration", acceptance: false, status: "witness-check-failure", sourcesUnchanged: true, - native: [{ language: "typescript" }, { language: "go" }, { language: "rust" }], + native: [{ language: "typescript" }, { language: "go" }, { language: "rust" }, { language: "python" }], }); expect(existsSync(join(output, "workspace/node_modules"))).toBe(false); } finally { rmSync(directory, { recursive: true, force: true }); } @@ -346,7 +361,7 @@ describe("isolated exploratory validation", () => { expect(existsSync(join(output, "workspace/node_modules"))).toBe(false); } finally { rmSync(directory, { recursive: true, force: true }); } }); - it("fails exploration after both ports replay when the witness baseline gate tripped", async () => { + it("fails exploration after every port replays when the witness baseline gate tripped", async () => { const directory = mkdtempSync(join(tmpdir(), "dialcache-exploration-witness-report-")); try { execFileSync("git", ["init", "--quiet"], { cwd: directory }); @@ -359,10 +374,10 @@ describe("isolated exploratory validation", () => { const workspace = join(directory, ".formal-traces/exploration", readdirSync(join(directory, ".formal-traces/exploration"))[0]!, "workspace"); mkdirSync(join(workspace, ".formal-traces"), { recursive: true }); writeFileSync(join(workspace, ".formal-traces/witness-report.json"), JSON.stringify(witnesses)); - replays = 2; - return [{ language: "typescript", status: "passed" }, { language: "go", status: "passed" }, { language: "rust", status: "passed" }]; + replays = 4; + return [{ language: "typescript", status: "passed" }, { language: "go", status: "passed" }, { language: "rust", status: "passed" }, { language: "python", status: "passed" }]; } })).rejects.toThrow(/coverage-gate-failure[\s\S]*reply:13 reached by 1 sampled histories/); - expect(replays).toBe(2); + expect(replays).toBe(4); const output = join(directory, ".formal-traces/exploration", readdirSync(join(directory, ".formal-traces/exploration"))[0]!); expect(JSON.parse(readFileSync(join(output, "report.json"), "utf8"))).toMatchObject({ status: "coverage-gate-failure", witnesses, sourcesUnchanged: true }); } finally { rmSync(directory, { recursive: true, force: true }); } @@ -375,7 +390,7 @@ describe("isolated exploratory validation", () => { ["written by the report command", JSON.stringify({ ...completedWitnessReport("0x2a"), command: "report" }), /not a completed evaluation/], ["judged under the pinned seed", JSON.stringify(completedWitnessReport("0xd1a1ca")), /judged under seed 0xd1a1ca, not this exploration's 0x2a/], ["missing a scheduled profile", JSON.stringify({ ...completedWitnessReport("0x2a"), profiles: { effects: {} } }), /covers no evaluation of/], - ])("fails as infrastructure when the witness report is %s although both ports passed", async (_name, contents, message) => { + ])("fails as infrastructure when the witness report is %s although every port passed", async (_name, contents, message) => { const directory = mkdtempSync(join(tmpdir(), "dialcache-exploration-witness-missing-")); try { execFileSync("git", ["init", "--quiet"], { cwd: directory }); @@ -386,7 +401,7 @@ describe("isolated exploratory validation", () => { const workspace = join(directory, ".formal-traces/exploration", readdirSync(join(directory, ".formal-traces/exploration"))[0]!, "workspace"); mkdirSync(join(workspace, ".formal-traces"), { recursive: true }); if (contents !== undefined) writeFileSync(join(workspace, ".formal-traces/witness-report.json"), contents); - return [{ language: "typescript", status: "passed" }, { language: "go", status: "passed" }, { language: "rust", status: "passed" }]; + return [{ language: "typescript", status: "passed" }, { language: "go", status: "passed" }, { language: "rust", status: "passed" }, { language: "python", status: "passed" }]; } })).rejects.toThrow(message); const output = join(directory, ".formal-traces/exploration", readdirSync(join(directory, ".formal-traces/exploration"))[0]!); expect(JSON.parse(readFileSync(join(output, "report.json"), "utf8")).status).toBe("infrastructure-failure"); @@ -413,7 +428,7 @@ describe("isolated exploratory validation", () => { const workspace = join(directory, ".formal-traces/exploration", readdirSync(join(directory, ".formal-traces/exploration"))[0]!, "workspace"); mkdirSync(join(workspace, ".formal-traces"), { recursive: true }); writeFileSync(join(workspace, ".formal-traces/witness-report.json"), JSON.stringify(witnesses)); - return [{ language: "typescript", status: "passed" }, { language: "go", status: "passed" }, { language: "rust", status: "passed" }]; + return [{ language: "typescript", status: "passed" }, { language: "go", status: "passed" }, { language: "rust", status: "passed" }, { language: "python", status: "passed" }]; } }); expect(JSON.parse(readFileSync(join(output, "report.json"), "utf8"))).toMatchObject({ status: "passed", witnesses }); } finally { rmSync(directory, { recursive: true, force: true }); } diff --git a/test/formal-rust-replay.test.ts b/test/formal-rust-replay.test.ts index d30447ed..453d73ee 100644 --- a/test/formal-rust-replay.test.ts +++ b/test/formal-rust-replay.test.ts @@ -43,7 +43,7 @@ describe("Rust replay report gate", () => { it("binds every inventory entry to its own id", () => { for (const entry of inventory) expect(nativeBinding(entry, "rust")).toBe(entry.id); expect(nativeBinding(inventory[0]!, "go")).not.toBe(inventory[0]!.id); - expect(() => nativeBinding(inventory[0]!, "zig")).toThrow(/TypeScript, Go and Rust/); + expect(() => nativeBinding(inventory[0]!, "zig")).toThrow(/TypeScript, Go, Rust and Python/); }); it("accepts a complete report and summarizes it by category and profile", () => { @@ -143,7 +143,7 @@ describe("Rust validation lanes", () => { expect(step.env, step.label).toBeUndefined(); expect(step.cwd, step.label).toBe("rust"); } - expect(validationPlan("check", { directory })).toEqual(["check-ts", "check-go", "check-rust", "docs", "audit"].flatMap(target => validationPlan(target, { directory }))); + expect(validationPlan("check", { directory })).toEqual(["check-ts", "check-go", "check-rust", "check-python", "docs", "audit"].flatMap(target => validationPlan(target, { directory }))); }); it("replays the complete corpus against the shared evidence and adapts the harness report into a completion", () => { @@ -170,19 +170,21 @@ describe("Rust validation lanes", () => { expect(plan.some(step => step.args?.some(argument => /\.formal-traces\/(ts|go)-/.test(argument)))).toBe(false); const go = validationPlan("formal-go", { directory }).find(step => step.command === "go" && step.env)!; for (const key of Object.keys(go.env!)) expect(replay.env![key], key).toBe(go.env![key]); - expect(validationPlan("formal", { directory }).slice(-plan.length)).toEqual(plan); + const aggregate = validationPlan("formal", { directory }); + const rustStart = aggregate.findIndex(step => step.label === plan[0]!.label); + expect(rustStart).toBeGreaterThanOrEqual(0); + expect(aggregate.slice(rustStart, rustStart + plan.length)).toEqual(plan); }); it("adds the smoke conformance run in default mode with no corpus selectors", () => { const smoke = validationPlan("smoke", { directory }); - expect(smoke.at(-1)).toEqual({ label: "Replay committed Rust fixtures", command: "cargo", args: ["test", "--all-features", "--test", "conformance"], cwd: "rust" }); - expect(smoke.filter(step => step.command === "cargo")).toHaveLength(1); + expect(smoke.filter(step => step.command === "cargo")).toEqual([{ label: "Replay committed Rust fixtures", command: "cargo", args: ["test", "--all-features", "--test", "conformance"], cwd: "rust" }]); }); it("runs the real-server integration binary only through the integration lane, which selects its ignored tests", () => { const lane = validationPlan("integration-rust", { directory }); expect(lane).toEqual([{ label: "Run Rust Redis/Valkey/Cluster integrations", command: "cargo", args: ["test", "--all-features", "--test", "redis_integration", "--", "--ignored"], cwd: "rust" }]); - expect(validationPlan("integration", { directory })).toEqual(["integration-ts", "integration-go", "integration-rust"].flatMap(target => validationPlan(target, { directory }))); + expect(validationPlan("integration", { directory })).toEqual(["integration-ts", "integration-go", "integration-rust", "integration-python"].flatMap(target => validationPlan(target, { directory }))); for (const target of ["check-rust", "smoke", "formal-rust"]) expect(validationPlan(target, { directory }).some(step => step.args?.includes("--ignored")), target).toBe(false); }); @@ -200,7 +202,8 @@ describe("Rust validation lanes", () => { tool("corepack", 'console.log("10.33.0")'); tool("go", 'console.log("go version go1.27.1 test/test")'); tool("quint", 'console.log("0.32.0")'); - const environment = { ...process.env, PATH: `${join(temporary, "bin")}${delimiter}${process.env.PATH ?? ""}` }; + tool("python", 'if (process.argv.includes("--version")) console.log("Python 3.11.9")'); + const environment = { ...process.env, PYTHON: join(temporary, "bin", "python"), PATH: `${join(temporary, "bin")}${delimiter}${process.env.PATH ?? ""}` }; const options = { directory: temporary, environment, nodeVersion: "v24.20.0" }; tool("cargo", 'console.error("cargo: command not found"); process.exit(127)'); for (const target of ["check-rust", "formal-rust", "smoke", "check", "integration-rust", "mutations-rust", "mutations", "explore"]) expect(() => checkPrerequisites(target, options), target).toThrow(/Cannot run cargo/); diff --git a/test/formal-validation.test.ts b/test/formal-validation.test.ts index c8e09843..dc807959 100644 --- a/test/formal-validation.test.ts +++ b/test/formal-validation.test.ts @@ -64,6 +64,7 @@ process.exit(Number(process.argv[3] ?? 0));\n`); fakeTool("corepack", 'console.log("10.33.0")'); fakeTool("go", 'console.log("go version go1.27.1 test/test")'); fakeTool("cargo", 'console.log("cargo 1.98.1 (test 2026-08-05)")'); + environment.PYTHON = fakeTool("python", 'if (process.argv.includes("--version")) console.log("Python 3.11.14")'); fakeTool("quint", 'console.log("0.32.0")'); fakeTool("java", 'console.log("openjdk 21.0.11")'); fakeTool("tar", 'console.log("bsdtar 3.5.3")'); @@ -164,10 +165,10 @@ process.exit(Number(process.argv[3] ?? 0));\n`); expect(plan.filter(step => step.args?.[0] === "formal/witnesses.mjs")).toHaveLength(1); expect(plan.some(step => step.args?.[0] === "formal/generate-artifacts.mjs")).toBe(false); expect(plan[0]!.args).toEqual(["formal/run-models.mjs", "check"]); - expect(plan.find(step => step.remove)!.remove).toEqual([".formal-traces/ts-completion.json", ".formal-traces/go-completion.json", ".formal-traces/rust-completion.json"]); + expect(plan.find(step => step.remove)!.remove).toEqual([".formal-traces/ts-completion.json", ".formal-traces/go-completion.json", ".formal-traces/rust-completion.json", ".formal-traces/python-completion.json"]); // The aggregate is exactly these lanes in order, so a CI job running // one lane executes the same steps as the local sequential run. - expect(plan).toEqual(["formal-check", "formal-generate", "formal-ts", "formal-go", "formal-rust"].flatMap(target => validationPlan(target, { directory }))); + expect(plan).toEqual(["formal-check", "formal-generate", "formal-ts", "formal-go", "formal-rust", "formal-python"].flatMap(target => validationPlan(target, { directory }))); }); it("keeps the model check as its own lane that produces nothing the port lanes consume", () => { @@ -182,7 +183,7 @@ process.exit(Number(process.argv[3] ?? 0));\n`); const generate = validationPlan("formal-generate", { directory }); expect(generate.some(step => step.args?.[0] === "formal/run-models.mjs" && step.args[1] === "check")).toBe(false); expect(generate.some(step => step.args?.[0] === "formal/check-model-properties.mjs")).toBe(false); - for (const target of ["formal-ts", "formal-go", "formal-rust", "mutations"]) { + for (const target of ["formal-ts", "formal-go", "formal-rust", "formal-python", "mutations"]) { expect(validationPlan(target, { directory }).some(step => step.args?.[0] === "formal/run-models.mjs")).toBe(false); } // Every acceptance entry point keeps one complete campaign, after all @@ -256,6 +257,32 @@ process.exit(Number(process.argv[3] ?? 0));\n`); expect(smoke.env).toBeUndefined(); }); + it("runs Python from its prepared interpreter and checks complete evidence after replay", () => { + const native = validationPlan("check-python", { directory, environment }); + expect(native).toEqual([{ + label: "Run Python native, wire, scenario and smoke tests", command: environment.PYTHON, + args: ["-m", "pytest", "python/tests", "-m", "not integration"], env: { NODE: process.execPath }, + }]); + const plan = validationPlan("formal-python", { directory, environment }); + expect(plan[0]!.remove).toEqual([".formal-traces/python-completion.json"]); + expect(plan[1]!.args).toEqual(["formal/conformance.mjs", "prepare", "python", ".formal-traces/python-context.json"]); + expect(plan[2]!.args).toEqual(["formal/run-python-replay.mjs", "--generated", "--scenarios", "--complete", "--report", ".formal-traces/python-replay.jsonl"]); + expect(plan[2]!.env).toEqual({ PYTHON: environment.PYTHON }); + expect(plan.at(-1)!.args).toEqual(["formal/conformance.mjs", "check", ".formal-traces/python-completion.json", ".formal-traces/python-context.json"]); + expect(validationPlan("smoke", { directory, environment }).at(-1)!.args).toEqual(["-m", "pytest", "python/tests/test_conformance.py"]); + expect(validationPlan("integration-python", { directory, environment })[0]!.args).toEqual(["formal/run-python-integration.mjs"]); + }); + + it("requires the Python floor and dependencies only for the Python lanes", () => { + environment.PYTHON = fakeTool("python", 'console.log("Python 3.10.16")'); + for (const target of ["check-python", "formal-python", "integration-python", "smoke", "check"]) { + expect(() => checkPrerequisites(target, { directory, environment, nodeVersion: "v24.20.0" }), target).toThrow(/Python 3.11 or later/); + } + expect(() => checkPrerequisites("formal-rust", { directory, environment, nodeVersion: "v24.20.0" })).not.toThrow(); + environment.PYTHON = fakeTool("python", 'if (process.argv.includes("--version")) console.log("Python 3.14.7"); else { console.error("missing pytest"); process.exit(1); }'); + expect(() => checkPrerequisites("check-python", { directory, environment, nodeVersion: "v24.20.0" })).toThrow(/missing pytest/); + }); + it("lets Go parity and every mutation measurement run off the generated corpus without completion checks", () => { const isCompletionCheck = (step: Step) => step.args?.[0] === "formal/conformance.mjs" && step.args[1] === "check"; const go = validationPlan("formal-go", { directory }); @@ -382,7 +409,7 @@ process.exit(Number(process.argv[3] ?? 0));\n`); for (const target of ["check-ts", "formal", "formal-check", "formal-generate", "formal-ts", "explore"]) { expect(() => checkPrerequisites(target, { directory, environment, nodeVersion: "v24.20.0" })).not.toThrow(); } - }); + }, 15_000); it("requires Docker for mutation measurements but not report merging", () => { fakeTool("docker", 'console.error("Docker not running"); process.exit(1)'); @@ -424,7 +451,7 @@ else { describe("full formal workflow shape", () => { type Step = { name?: string; run?: string; uses?: string; if?: string; env?: Record; with?: Record }; type Job = { needs?: string | string[]; if?: string; env?: Record; strategy?: { "fail-fast"?: boolean; matrix?: Record }; "timeout-minutes"?: number; steps: Step[] }; - const lanes = ["typescript-parity", "go-parity", "rust-parity", "typescript-mutations", "go-mutations", "rust-mutations"]; + const lanes = ["typescript-parity", "go-parity", "rust-parity", "python-parity", "typescript-mutations", "go-mutations", "rust-mutations"]; const needsOf = (job: Job) => (job.needs === undefined ? [] : [job.needs].flat()); let jobs: Record; @@ -536,6 +563,21 @@ describe("full formal workflow shape", () => { expect(summary.path).toContain("formal-summary/rust/rust-replay-summary.json"); }); + it("requires Python complete replay and retains its actual completion evidence", () => { + const parity = jobs["python-parity"]!; + expect(parity.steps.find(step => step.uses === "./.github/actions/setup-validation")!.with).toEqual({ python: "true" }); + expect(parity.steps.map(step => step.run).filter(Boolean)).toEqual(["make formal-python"]); + const aggregate = jobs["formal-full"]!; + expect(needsOf(aggregate)).toContain("python-parity"); + const gate = aggregate.steps.find(step => step.run?.includes("_RESULT"))!; + expect(gate.env).toMatchObject({ PYTHON_RESULT: "$" + "{{ needs.python-parity.result }}" }); + expect(gate.run).toMatch(/test "\$PYTHON_RESULT" = success/); + const summary = aggregate.steps.find(step => step.uses?.startsWith("actions/upload-artifact"))!.with!; + expect(summary.path).toContain("formal-summary/python/python-completion.json"); + expect(summary.path).toContain("formal-summary/python/python-context.json"); + expect(summary.path).toContain("formal-summary/python/python-replay-summary.json"); + }); + it("requires the model check in the aggregate and retains its report in the long-lived summary", () => { const aggregate = jobs["formal-full"]!; expect(needsOf(aggregate)).toEqual(expect.arrayContaining(["check-models", "generate", "typescript-parity", "go-parity"])); From 1bdd0a0827026bde4ad0aabbff81d4bcb635f832 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Tue, 22 Sep 2026 01:35:04 -0700 Subject: [PATCH 2/4] fix(python): tighten observer and validation boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep mismatch details in logger events, discard denied async predicates without starting work, bound oversized decoder memory, and bind validation and saved replay inventories to their source snapshots. — Levicus 🤖 --- formal/explore.mjs | 10 +- formal/validation.mjs | 10 +- python/dialcache/cache.py | 24 +- python/dialcache/protocol.py | 5 +- python/tests/formal/driver.py | 6 + .../test_observer_recovery_boundaries.py | 210 ++++++++++++++++++ python/tests/test_protocol_native.py | 18 ++ python/tests/test_validation_environment.py | 124 +++++++++++ typescript/test/formal-exploration.test.ts | 81 ++++--- typescript/test/formal-validation.test.ts | 14 +- 10 files changed, 464 insertions(+), 38 deletions(-) create mode 100644 python/tests/test_observer_recovery_boundaries.py create mode 100644 python/tests/test_validation_environment.py diff --git a/formal/explore.mjs b/formal/explore.mjs index a54e8bcc..f2c641a6 100644 --- a/formal/explore.mjs +++ b/formal/explore.mjs @@ -355,7 +355,13 @@ async function executeExploration(seed, { directory = root, environment = proces const validation = await import(pathToFileURL(resolve(workspace, 'formal/validation.mjs')).href); validation.checkPrerequisites('explore', { directory: workspace, environment: cleanEnvironment(runtimeEnvironment) }); } - report.native = await snapshot.runExplorationSteps(snapshot.explorationPlan(workspace, selectedSeed, { environment: runtimeEnvironment }), { + const plan = snapshot.explorationPlan(workspace, selectedSeed, { environment: runtimeEnvironment }); + // The copied plan owns its port inventory, just as it owns execution. A + // newer caller must not require a language absent from a saved snapshot. + const requiredLanguages = plan.filter(step => step.nativeReport !== undefined).map(step => step.nativeReport).sort(); + if (!requiredLanguages.length || requiredLanguages.some(language => typeof language !== 'string' || !language) + || new Set(requiredLanguages).size !== requiredLanguages.length) throw new Error('Exploration plan has an empty or invalid native port inventory.'); + report.native = await snapshot.runExplorationSteps(plan, { directory: workspace, environment: cleanEnvironment(runtimeEnvironment), onResult: results => { report.native = results; save(); }, // The evaluator step is tolerated so both ports replay; its failure is // still part of the record so a missing report explains itself. @@ -363,7 +369,7 @@ async function executeExploration(seed, { directory = root, environment = proces }); verifyHashes(workspace, [report.sources]); report.sourcesUnchanged = true; - if (report.native.map(result => result.language).sort().join() !== Object.keys(reportPaths).sort().join() + if (JSON.stringify(report.native.map(result => result.language).sort()) !== JSON.stringify(requiredLanguages) || report.native.some(result => !['passed', 'native-failure', 'witness-check-failure'].includes(result.status))) { throw new Error('Exploration did not finish every native port.'); } diff --git a/formal/validation.mjs b/formal/validation.mjs index 9cd63223..99645793 100644 --- a/formal/validation.mjs +++ b/formal/validation.mjs @@ -120,6 +120,10 @@ let code; try { z.zstdDecompressSync(bytes, { maxOutputLength: 4 }); } catch (error) { code = error.code; } if (code !== 'ERR_BUFFER_TOO_LARGE') throw new Error('zstd output cap not enforced at floor: ' + code);`; +function pythonSourceEnvironment(directory, environment) { + return { PYTHONPATH: [resolve(directory, 'python'), environment.PYTHONPATH].filter(Boolean).join(delimiter) }; +} + export function validationPlan(target, { directory = root, environment = process.env, runnerNode = process.execPath, nodeVersion = process.version } = {}) { const node = (label, ...args) => ({ label, command: runnerNode, args }); const pnpm = (label, ...args) => ({ label, command: 'corepack', args: ['pnpm', ...args] }); @@ -128,7 +132,8 @@ export function validationPlan(target, { directory = root, environment = process // crate's tests locate the repository through CARGO_MANIFEST_DIR, not cwd. const cargo = (label, subcommand, ...args) => ({ label, command: 'cargo', args: [subcommand, ...args], cwd: 'rust' }); const pythonExecutable = environment.PYTHON ?? resolve(directory, 'python/.venv/bin/python'); - const python = (label, ...args) => ({ label, command: pythonExecutable, args, env: { NODE: runnerNode } }); + const python = (label, ...args) => ({ label, command: pythonExecutable, args, + env: { NODE: runnerNode, ...pythonSourceEnvironment(directory, environment) } }); const reportPath = (language, suffix) => `.formal-traces/${language}-${suffix}.json`; const completion = language => ({ ...node(`Validate current ${language} completion`, 'formal/conformance.mjs', 'check', reportPath(language, 'completion'), reportPath(language, 'context')), failureHint: 'A current complete replay is required. Run make formal first; missing or stale reports cannot be reused.' }); @@ -268,7 +273,8 @@ export function checkPrerequisites(target, { directory = root, environment = pro const version = probe(executable, ['--version'], { directory, environment }); const parsed = /^Python (\d+)\.(\d+)(?:\.|\s|$)/.exec(version); if (!parsed || Number(parsed[1]) !== 3 || Number(parsed[2]) < 11) throw new Error(`Python validation requires Python 3.11 or later; found ${version}. Set PYTHON or create python/.venv and install './python[test,redis]'.`); - probe(executable, ['-c', 'import dialcache, pytest, pytest_asyncio, jsonschema, zstandard, redis'], { directory, environment }); + probe(executable, ['-c', 'import dialcache, pytest, pytest_asyncio, jsonschema, zstandard, redis'], + { directory, environment: { ...environment, ...pythonSourceEnvironment(directory, environment) } }); } if (targets.some(name => ['formal-check', 'formal-generate', 'fixtures-check', 'explore', 'model-check', 'differential'].includes(name))) { const requiredQuint = JSON.parse(readFileSync(resolve(directory, 'formal/generated-fixtures.lock.json'), 'utf8')).quintVersion; diff --git a/python/dialcache/cache.py b/python/dialcache/cache.py index 64ec8af3..12c5691d 100644 --- a/python/dialcache/cache.py +++ b/python/dialcache/cache.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio +import contextvars import functools import inspect import json @@ -381,9 +382,20 @@ def _log(self, message: str, error: Any = None) -> None: except Exception: pass - def _discard_awaitable(self, value: Any) -> None: - if inspect.isawaitable(value): - self._spawn(_await(value)) + @staticmethod + def _discard_awaitable(value: Any) -> None: + # Recovery predicates are synchronous. A rejected result must not + # start work or take ownership of an application's existing task. + if inspect.iscoroutine(value): + if inspect.getcoroutinestate(value) == inspect.CORO_CREATED: + value.close() + elif asyncio.isfuture(value): + + def consume(done: asyncio.Future[Any]) -> None: + if not done.cancelled(): + done.exception() + + value.add_done_callback(consume, context=contextvars.Context()) def _labels(self, key: Key | _Operation, layer: str | None = None) -> dict[str, Any]: labels = {"cacheNamespace": self.namespace, "useCase": key.use_case, "keyType": key.key_type} @@ -1119,8 +1131,10 @@ async def work() -> str: if "age" in details and outcome in ("match", "mismatch"): self._emit("shadowAge", self._labels(key), outcome=outcome, seconds=details.pop("age")) if outcome == "mismatch" and log: - self._emit("mismatchWarning", self._labels(key), outcome=outcome, **details) - self._log("DialCache shadow validation mismatch: %s", {**self._labels(key), **details}) + self._log( + "DialCache shadow validation mismatch: %s", + {**self._labels(key), "outcome": outcome, **details}, + ) @staticmethod def _payload_bytes(value: str | bytes) -> bytes: diff --git a/python/dialcache/protocol.py b/python/dialcache/protocol.py index c8d30e1e..54a777c6 100644 --- a/python/dialcache/protocol.py +++ b/python/dialcache/protocol.py @@ -274,7 +274,10 @@ def decompress_payload(payload: Payload, maximum: int = MAX_DECOMPRESSED_BYTES) total += len(chunk) if total > maximum: return DecompressionResult(payload, "read_over_limit") - chunks.append(chunk) + # Known oversized frames can only return the raw input; + # validate their stream without retaining unusable output. + if unknown_size: + chunks.append(chunk) except zstandard.ZstdError: chunks.clear() outcome = ( diff --git a/python/tests/formal/driver.py b/python/tests/formal/driver.py index 59f08bac..87d4d506 100644 --- a/python/tests/formal/driver.py +++ b/python/tests/formal/driver.py @@ -184,6 +184,12 @@ def __init__(self, owner): self.owner = owner def warning(self, *args, **kwargs): + if ( + len(args) == 2 + and args[0] == "DialCache shadow validation mismatch: %s" + and isinstance(args[1], dict) + ): + self.owner.record("mismatchWarning", **args[1]) if self.owner.fixture.get("observerFailure") or self.owner.faults.get("observer"): raise RuntimeError("Controlled observer failure") diff --git a/python/tests/test_observer_recovery_boundaries.py b/python/tests/test_observer_recovery_boundaries.py new file mode 100644 index 00000000..21dccd59 --- /dev/null +++ b/python/tests/test_observer_recovery_boundaries.py @@ -0,0 +1,210 @@ +"""Observer privacy and synchronous recovery ownership regressions.""" + +import asyncio +import inspect +import json + +import pytest +from formal.executor import Executor + +from dialcache import DialCache, Policy +from dialcache.protocol import Frame + + +@pytest.fixture +def executor(): + instance = Executor() + try: + yield instance + finally: + instance.close() + + +@pytest.mark.parametrize("log", [False, True]) +@pytest.mark.parametrize("observer_failure", [False, True]) +def test_mismatch_details_are_logger_only(executor, log, observer_failure): + events, warnings = [], [] + + def observer(event): + events.append(event) + if observer_failure: + raise RuntimeError("metrics unavailable") + + class Logger: + def warning(self, *args): + warnings.append(args) + if observer_failure: + raise RuntimeError("logger unavailable") + + class Redis: + def read(self, request, context): + return Frame(int(executor.clock.wall_ms()), '"cached-synthetic-credential"') + + cache = DialCache(redis=Redis(), clock=executor.clock, metrics=observer, logger=Logger()) + policy = Policy(ttl_sec={"remote": 10}, shadow={"ramp": 100, "log_mismatches": log}) + with cache.enable(): + result = executor.finish( + cache.get_or_load( + lambda: "source-synthetic-credential", + key="entity-synthetic-id", + key_type="entity", + use_case="privacy", + default_config=policy, + ) + ) + executor.drain() + assert result == "cached-synthetic-credential" + assert [e["outcome"] for e in events if e["event"] == "shadowValidation"] == ["mismatch"] + assert [e["outcome"] for e in events if e["event"] == "shadowAge"] == ["mismatch"] + assert not any(e["event"] == "mismatchWarning" for e in events) + serialized = json.dumps(events) + for sensitive in [ + "entity-synthetic-id", + "cached-synthetic-credential", + "source-synthetic-credential", + "cacheKey", + "cachedValueJson", + "sourceValueJson", + ]: + assert sensitive not in serialized + if log: + assert len(warnings) == 1 + message, details = warnings[0] + assert message == "DialCache shadow validation mismatch: %s" + assert details["outcome"] == "mismatch" + assert "entity-synthetic-id" in details["cacheKey"] + assert details["cachedValueJson"] == '"cached-synthetic-credential"' + assert details["sourceValueJson"] == '"source-synthetic-credential"' + else: + assert warnings == [] + + +def failing_recovery_call(executor, cache, failure): + def source(): + raise failure + + with cache.enable(): + with pytest.raises(ValueError) as caught: + executor.finish( + cache.get_or_load( + source, + key="id", + key_type="entity", + use_case="recovery", + default_config=Policy(ttl_sec={"remote": 1}, stale_on_error_max_age_sec=10), + ) + ) + assert caught.value is failure + executor.drain() + + +def recovery_cache(executor, predicate): + class Redis: + def read(self, request, context): + return Frame(int(executor.clock.wall_ms()) - 2000, '"stale"') + + return DialCache(redis=Redis(), clock=executor.clock, should_attempt_stale_recovery=predicate) + + +def test_denied_async_recovery_starts_no_work(executor): + started, returned = [], [] + gate = executor.future() + + async def predicate(error): + started.append(error) + await gate + return True + + def classify(error): + result = predicate(error) + returned.append(result) + return result + + cache = recovery_cache(executor, classify) + failure = ValueError("source failure") + for _ in range(20): + failing_recovery_call(executor, cache, failure) + assert started == [] + assert asyncio.all_tasks(executor.loop) == set() + assert all(inspect.getcoroutinestate(c) == inspect.CORO_CLOSED for c in returned) + + +def test_denied_custom_recovery_awaitable_is_not_driven(executor): + attempts = [] + + class Awaitable: + def __await__(self): + attempts.append("started") + yield + return True + + cache = recovery_cache(executor, lambda error: Awaitable()) + failing_recovery_call(executor, cache, ValueError("source failure")) + assert attempts == [] + assert asyncio.all_tasks(executor.loop) == set() + + +def test_borrowed_started_coroutine_stays_application_owned(executor): + actions = [] + + async def work(): + actions.append("started") + await asyncio.sleep(0) + actions.append("finished") + return True + + coroutine = work() + coroutine.send(None) + cache = recovery_cache(executor, lambda error: coroutine) + failing_recovery_call(executor, cache, ValueError("source failure")) + assert actions == ["started"] + assert executor.finish(coroutine) is True + assert actions == ["started", "finished"] + + +@pytest.mark.parametrize("kind", ["future", "task"]) +@pytest.mark.parametrize("settlement", ["failure", "cancel"]) +@pytest.mark.parametrize("already_done", [False, True]) +def test_denied_recovery_observes_borrowed_future_without_owning_it(executor, kind, settlement, already_done): + observed = [] + + class ObserveException: + def exception(self): + observed.append("observed") + return super().exception() + + class Future(ObserveException, asyncio.Future): + pass + + class Task(ObserveException, asyncio.Task): + pass + + failure = RuntimeError("borrowed task failure") + gate = executor.future() + + async def work(): + await gate + raise failure + + borrowed = Future(loop=executor.loop) if kind == "future" else Task(work(), loop=executor.loop) + executor.drain() + + def settle(): + if settlement == "cancel": + borrowed.cancel() + elif kind == "future": + borrowed.set_exception(failure) + else: + gate.set_result(None) + executor.drain() + + if already_done: + settle() + cache = recovery_cache(executor, lambda error: borrowed) + failing_recovery_call(executor, cache, ValueError("source failure")) + assert asyncio.all_tasks(executor.loop) == ({borrowed} if kind == "task" and not already_done else set()) + assert borrowed.done() is already_done + if not already_done: + settle() + assert borrowed.cancelled() is (settlement == "cancel") + assert bool(observed) is (settlement == "failure") diff --git a/python/tests/test_protocol_native.py b/python/tests/test_protocol_native.py index 93dfd71c..e5c52970 100644 --- a/python/tests/test_protocol_native.py +++ b/python/tests/test_protocol_native.py @@ -240,3 +240,21 @@ def test_unknown_size_decode_allocates_for_output_instead_of_ceiling(corrupt): # This small output used to allocate the 512 MiB decompression ceiling. # Keep generous headroom for interpreter/dependency allocation differences. assert peak < 8 * 1024 * 1024 + + +def test_known_oversized_decode_does_not_retain_unusable_output(): + import tracemalloc + + maximum = 8 * 1024 * 1024 + payload = b"\x02" + zstandard.ZstdCompressor().compress(b"a" * (maximum + 1)) + tracemalloc.start() + try: + result = decompress_payload(payload, maximum) + _, peak = tracemalloc.get_traced_memory() + finally: + tracemalloc.stop() + assert result.outcome == "read_over_limit" + assert result.payload is payload + # The frame header already rules out returning decoded bytes. Classification + # must use bounded chunks rather than retaining approximately the full cap. + assert peak < 2 * 1024 * 1024 diff --git a/python/tests/test_validation_environment.py b/python/tests/test_validation_environment.py new file mode 100644 index 00000000..fcb2caa1 --- /dev/null +++ b/python/tests/test_validation_environment.py @@ -0,0 +1,124 @@ +"""Native validation must import the selected checkout with any prepared interpreter.""" + +from __future__ import annotations + +import json +import os +import shutil +import site +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +VALIDATION = ROOT / "formal/validation.mjs" +NODE_BRIDGE = r""" +import { pathToFileURL } from 'node:url'; +let input = ''; for await (const chunk of process.stdin) input += chunk; +const { module, directory, environment, boundary } = JSON.parse(input); +const { checkPrerequisites, validationPlan, executeSteps } = await import(pathToFileURL(module)); +if (boundary === 'prerequisites') { + checkPrerequisites('check-python', { directory, environment }); +} else { + const steps = validationPlan(boundary, { directory, environment }) + .filter(step => step.command === environment.PYTHON); + if (steps.length !== 1) throw new Error('Expected exactly one direct Python step'); + await executeSteps(steps, { directory, environment }); +} +""" +NATIVE_TEST = """ +from pathlib import Path +import dialcache +import validation_caller_marker + +def test_selected_checkout_import(): + assert Path(dialcache.__file__).resolve() == (Path.cwd() / 'python/dialcache/__init__.py').resolve() + assert dialcache.DialCache.__module__ == 'dialcache.cache' + assert validation_caller_marker.VALUE == 'caller path retained' +""" + + +@pytest.mark.parametrize("boundary", ["check-python", "smoke", "prerequisites"]) +def test_validation_selects_checkout_over_foreign_editable(tmp_path, boundary): + node = os.environ.get("NODE") or shutil.which("node") + assert node is not None, "Node 24 is required by Python validation" + checkout = tmp_path / "selected-checkout" + tests = checkout / "python/tests" + tests.mkdir(parents=True) + shutil.copytree( + ROOT / "python/dialcache", checkout / "python/dialcache", ignore=shutil.ignore_patterns("__pycache__") + ) + (tests / "test_conformance.py").write_text(NATIVE_TEST) + # Both generated native commands run their exact argv, but this small + # checkout contains only the sentinel test, so this test cannot recurse. + foreign = tmp_path / "foreign-editable" + (foreign / "dialcache").mkdir(parents=True) + (foreign / "dialcache/__init__.py").write_text( + "raise RuntimeError('foreign editable dialcache was imported')\n" + ) + env_dir = tmp_path / "venv" + # venv uses this test process's sys.executable; no pip or network is needed. + subprocess.run( + [sys.executable, "-m", "venv", "--without-pip", str(env_dir)], + check=True, + capture_output=True, + text=True, + ) + python = env_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + library = Path( + subprocess.check_output( + [str(python), "-c", "import sysconfig; print(sysconfig.get_path('purelib'))"], text=True + ).strip() + ) + # Reuse installed dependencies without processing the source environment's + # editable-install .pth. The foreign package is the only editable on sys.path. + (library / "probe.pth").write_text("\n".join([str(foreign), *site.getsitepackages()]) + "\n") + inherited = tmp_path / "caller-path" + inherited.mkdir() + (inherited / "validation_caller_marker.py").write_text("VALUE = 'caller path retained'\n") + environment = { + **os.environ, + "PYTHON": str(python), + "PYTHONPATH": os.pathsep.join([str(foreign), str(inherited)]), + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTEST_ADDOPTS": "", + } + # Prove this interpreter really imports the foreign package absent the fix. + control_env = {key: value for key, value in environment.items() if key != "PYTHONPATH"} + control = subprocess.run( + [str(python), "-c", "import dialcache"], cwd=checkout, env=control_env, text=True, capture_output=True + ) + assert control.returncode != 0 and "foreign editable dialcache was imported" in control.stderr + # Satisfy unrelated Node/package-manager prerequisites without installations. + if boundary == "prerequisites": + (checkout / "node_modules/typescript").mkdir(parents=True) + (checkout / "node_modules/typescript/package.json").write_text("{}") + package = json.loads((ROOT / "package.json").read_text()) + (checkout / "package.json").write_text(json.dumps({"packageManager": package["packageManager"]})) + tools = tmp_path / "bin" + tools.mkdir() + corepack = tools / "corepack" + corepack.write_text( + f"#!{node}\nconsole.log({json.dumps(package['packageManager'].removeprefix('pnpm@'))});\n" + ) + corepack.chmod(0o755) + environment["PATH"] = str(tools) + os.pathsep + environment.get("PATH", "") + result = subprocess.run( + [node, "--input-type=module", "-e", NODE_BRIDGE], + cwd=ROOT, + env=environment, + input=json.dumps( + { + "module": str(VALIDATION), + "directory": str(checkout), + "environment": environment, + "boundary": boundary, + } + ), + text=True, + capture_output=True, + timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/typescript/test/formal-exploration.test.ts b/typescript/test/formal-exploration.test.ts index 40c89456..5d7dc8ed 100644 --- a/typescript/test/formal-exploration.test.ts +++ b/typescript/test/formal-exploration.test.ts @@ -99,7 +99,7 @@ function writeWitnessInventory(directory: string, profiles: readonly string[]) { writeFileSync(join(directory, "formal/coverage-witnesses.json"), JSON.stringify(Object.fromEntries(profiles.map(profile => [profile, ["required"]])))); } -function savedFixture(directory: string) { +function savedFixture(directory: string, languages: readonly unknown[] = ["typescript", "go", "rust"], results: readonly unknown[] = languages) { const saved = join(directory, "saved"), workspace = join(saved, "workspace"); mkdirSync(join(workspace, "formal"), { recursive: true }); mkdirSync(join(directory, "node_modules")); @@ -115,12 +115,12 @@ function savedFixture(directory: string) { "formal/execution.json": JSON.stringify({ models: [{ profile: "effects" }] }), "formal/coverage-witnesses.json": JSON.stringify({ effects: ["required"] }), "formal/explore.mjs": `import { writeFileSync } from 'node:fs'; - export const explorationPlan = (directory, seed) => [{ directory, seed, runner: 'saved' }]; + export const explorationPlan = (directory, seed) => ${JSON.stringify(languages)}.map(nativeReport => ({ directory, seed, runner: 'saved', nativeReport })); export async function runExplorationSteps(plan, options) { writeFileSync(options.directory + '/.formal-traces/saved-runner.json', JSON.stringify(plan)); // A saved run's evaluator also has to leave a completed witness report behind. writeFileSync(options.directory + '/.formal-traces/witness-report.json', ${JSON.stringify(JSON.stringify(completedWitnessReport("0x2a", ["effects"])))}); - return [{ language: 'typescript', status: 'passed' }, { language: 'go', status: 'passed' }, { language: 'rust', status: 'passed' }, { language: 'python', status: 'passed' }]; + return ${JSON.stringify(results)}.map(language => ({ language, status: 'passed' })); }`, "formal/validation.mjs": `import { mkdirSync, writeFileSync } from 'node:fs'; export function checkPrerequisites(target, { directory }) { @@ -312,34 +312,61 @@ describe("isolated exploratory validation", () => { } finally { rmSync(directory, { recursive: true, force: true }); } }); - it("replays saved bytes with their own runner and prerequisites without Git, preserving original evidence", async () => { - const directory = mkdtempSync(join(tmpdir(), "dialcache-exploration-reproduce-")); + it.each([{ languages: ["typescript", "go", "rust"] }, { languages: ["typescript", "go", "rust", "python"] }])( + "replays saved $languages bytes with their own runner and prerequisites without Git, preserving original evidence", async ({ languages }) => { + const directory = mkdtempSync(join(tmpdir(), "dialcache-exploration-reproduce-")); + try { + const saved = savedFixture(directory, languages), original = readFileSync(saved.path, "utf8"); + mkdirSync(join(directory, "formal")); + writeFileSync(join(directory, "formal/explore.mjs"), 'throw new Error("new checkout runner must not execute")'); + writeFileSync(join(directory, "formal/validation.mjs"), 'throw new Error("new checkout prerequisites must not execute")'); + const output = await replayExploration(saved.path, { directory }); + const report = JSON.parse(readFileSync(join(output, "report.json"), "utf8")); + expect(report).toMatchObject({ status: "passed", acceptance: false, seed: "0x2a", baseRevision: saved.report.baseRevision, + sources: saved.report.sources, replayOrigin: { path: realpathSync(saved.path), + reportSha256: createHash("sha256").update(original).digest("hex") } }); + expect(JSON.parse(readFileSync(join(output, "workspace/.formal-traces/saved-runner.json"), "utf8"))).toEqual( + languages.map(nativeReport => ({ directory: join(output, "workspace"), seed: "0x2a", runner: "saved", nativeReport })), + ); + expect(report.native.map((result: Result) => result.language)).toEqual(languages); + expect(readFileSync(join(output, "workspace/.formal-traces/saved-prerequisites.txt"), "utf8")).toBe("explore"); + // The saved inventory has one profile while this checkout schedules many: + // the replay was judged against the snapshot's inventory, not the checkout's. + expect(selectedProfiles("all").length).toBeGreaterThan(1); + expect(Object.keys(report.witnesses.profiles)).toEqual(["effects"]); + expect(readFileSync(saved.path, "utf8")).toBe(original); + expect(readFileSync(join(saved.workspace, ".formal-traces/original-evidence.txt"), "utf8")).toBe("retain original native evidence"); + for (const [path, content] of Object.entries(saved.sources)) expect(readFileSync(join(saved.workspace, path), "utf8")).toBe(content); + expect(existsSync(join(output, "workspace/node_modules"))).toBe(false); + expect(existsSync(join(output, "workspace/typescript/node_modules"))).toBe(false); + } finally { rmSync(directory, { recursive: true, force: true }); } + }, + ); + + it.each([ + { name: "missing", results: ["typescript", "go"] }, + { name: "duplicate", results: ["typescript", "go", "go"] }, + { name: "unexpected", results: ["typescript", "go", "rust", "python"] }, + ])("rejects $name results against the saved plan's port inventory", async ({ results }) => { + const directory = mkdtempSync(join(tmpdir(), "dialcache-exploration-port-results-")); try { - const saved = savedFixture(directory), original = readFileSync(saved.path, "utf8"); - mkdirSync(join(directory, "formal")); - writeFileSync(join(directory, "formal/explore.mjs"), 'throw new Error("new checkout runner must not execute")'); - writeFileSync(join(directory, "formal/validation.mjs"), 'throw new Error("new checkout prerequisites must not execute")'); - const output = await replayExploration(saved.path, { directory }); - const report = JSON.parse(readFileSync(join(output, "report.json"), "utf8")); - expect(report).toMatchObject({ status: "passed", acceptance: false, seed: "0x2a", baseRevision: saved.report.baseRevision, - sources: saved.report.sources, replayOrigin: { path: realpathSync(saved.path), - reportSha256: createHash("sha256").update(original).digest("hex") } }); - expect(JSON.parse(readFileSync(join(output, "workspace/.formal-traces/saved-runner.json"), "utf8"))).toEqual([ - { directory: join(output, "workspace"), seed: "0x2a", runner: "saved" }, - ]); - expect(readFileSync(join(output, "workspace/.formal-traces/saved-prerequisites.txt"), "utf8")).toBe("explore"); - // The saved inventory has one profile while this checkout schedules many: - // the replay was judged against the snapshot's inventory, not the checkout's. - expect(selectedProfiles("all").length).toBeGreaterThan(1); - expect(Object.keys(report.witnesses.profiles)).toEqual(["effects"]); - expect(readFileSync(saved.path, "utf8")).toBe(original); - expect(readFileSync(join(saved.workspace, ".formal-traces/original-evidence.txt"), "utf8")).toBe("retain original native evidence"); - for (const [path, content] of Object.entries(saved.sources)) expect(readFileSync(join(saved.workspace, path), "utf8")).toBe(content); - expect(existsSync(join(output, "workspace/node_modules"))).toBe(false); - expect(existsSync(join(output, "workspace/typescript/node_modules"))).toBe(false); + const saved = savedFixture(directory, ["typescript", "go", "rust"], results); + await expect(replayExploration(saved.path, { directory })).rejects.toThrow(/did not finish every native port/); } finally { rmSync(directory, { recursive: true, force: true }); } }); + it.each([{ languages: [] }, { languages: ["go", "go"] }, { languages: [""] }, { languages: [42] }])( + "rejects invalid saved port inventory $languages before native execution", async ({ languages }) => { + const directory = mkdtempSync(join(tmpdir(), "dialcache-exploration-port-plan-")); + try { + const saved = savedFixture(directory, languages); + await expect(replayExploration(saved.path, { directory })).rejects.toThrow(/invalid native port inventory/); + const output = join(directory, ".formal-traces/exploration", readdirSync(join(directory, ".formal-traces/exploration"))[0]!); + expect(existsSync(join(output, "workspace/.formal-traces/saved-runner.json"))).toBe(false); + } finally { rmSync(directory, { recursive: true, force: true }); } + }, + ); + it.each(["changed", "deleted"])("rejects a %s saved source before rerunning", async change => { const directory = mkdtempSync(join(tmpdir(), "dialcache-exploration-drift-")); try { diff --git a/typescript/test/formal-validation.test.ts b/typescript/test/formal-validation.test.ts index edb0dbbb..9815e2c1 100644 --- a/typescript/test/formal-validation.test.ts +++ b/typescript/test/formal-validation.test.ts @@ -261,7 +261,7 @@ process.exit(Number(process.argv[3] ?? 0));\n`); const native = validationPlan("check-python", { directory, environment }); expect(native).toEqual([{ label: "Run Python native, wire, scenario and smoke tests", command: environment.PYTHON, - args: ["-m", "pytest", "python/tests", "-m", "not integration"], env: { NODE: process.execPath }, + args: ["-m", "pytest", "python/tests", "-m", "not integration"], env: { NODE: process.execPath, PYTHONPATH: [join(directory, "python"), environment.PYTHONPATH].filter(Boolean).join(delimiter) }, }]); const plan = validationPlan("formal-python", { directory, environment }); expect(plan[0]!.remove).toEqual([".formal-traces/python-completion.json"]); @@ -273,6 +273,18 @@ process.exit(Number(process.argv[3] ?? 0));\n`); expect(validationPlan("integration-python", { directory, environment })[0]!.args).toEqual(["formal/run-python-integration.mjs"]); }); + it("prepends the selected Python sources for both native commands and prerequisite imports", () => { + environment.PYTHONPATH = ["/foreign/checkout/python", "/caller/dependencies"].join(delimiter); + const expected = [join(directory, "python"), environment.PYTHONPATH].join(delimiter); + for (const target of ["check-python", "smoke"]) { + const step = validationPlan(target, { directory, environment }).find(item => item.command === environment.PYTHON)!; + expect(step.env).toEqual({ NODE: process.execPath, PYTHONPATH: expected }); + } + environment.PYTHON = fakeTool("python", `if (process.argv.includes("--version")) console.log("Python 3.14.7"); +else if (process.env.PYTHONPATH !== ${JSON.stringify(expected)}) throw new Error("wrong checkout import path");`); + expect(() => checkPrerequisites("check-python", { directory, environment, nodeVersion: "v24.20.0" })).not.toThrow(); + }); + it("requires the Python floor and dependencies only for the Python lanes", () => { environment.PYTHON = fakeTool("python", 'console.log("Python 3.10.16")'); for (const target of ["check-python", "formal-python", "integration-python", "smoke", "check"]) { From abc569c518642fcb3af62d9931b0caf633c89e94 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Tue, 22 Sep 2026 02:06:13 -0700 Subject: [PATCH 3/4] fix(python): enforce primary reads and complete integration evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Require primary-only Cluster connections for tracked reads across redirects. Make integration acceptance verify every required case and bind documented scenarios to the selected backend. — Levicus 🤖 --- docs/languages/python.md | 10 +- docs/redis.md | 8 +- formal/go-parity.json | 2 +- formal/run-python-integration.mjs | 10 +- formal/source-audit.json | 34 ++-- python/README.md | 12 +- python/dialcache/redis.py | 38 ++++- python/tests/run_integration.py | 75 +++++++++ python/tests/test_cluster_read_routing.py | 174 ++++++++++++++++++++ python/tests/test_integration_acceptance.py | 145 ++++++++++++++++ python/tests/test_redis_adapter.py | 7 +- python/tests/test_redis_integration.py | 13 +- 12 files changed, 489 insertions(+), 39 deletions(-) create mode 100644 python/tests/run_integration.py create mode 100644 python/tests/test_cluster_read_routing.py create mode 100644 python/tests/test_integration_acceptance.py diff --git a/docs/languages/python.md b/docs/languages/python.md index 292fa243..b0d2b439 100644 --- a/docs/languages/python.md +++ b/docs/languages/python.md @@ -83,9 +83,13 @@ Shadow admission requires a metrics observer for terminal outcomes. `dialcache.redis.RedisAdapter` borrows a `redis.asyncio.Redis` or `RedisCluster` client configured with `decode_responses=False`. The application supplies finite connection, socket, and retry budgets and closes the client. The adapter -routes tracked atomic reads to primaries even when the cluster client otherwise -permits replica reads. `invalidate_remote()` and its `ainvalidate()` alias -surface maintenance failures. +requires a dedicated primary-only Cluster client for tracked atomic reads: +construct it with `read_from_replicas=False`, `load_balancing_strategy=None` where supported, +and no custom connection hook. Keep its configuration and connection mode +unchanged; create a new client instead of repurposing a previously `READONLY` +pool. Unsafe tracked reads fail open to the source. Replica-enabled clients +remain usable for untracked reads and maintenance. `invalidate_remote()` and +its `ainvalidate()` alias surface maintenance failures. Pass a synchronous `metrics` callable or an object with `observe(event)` to receive backend-neutral event dictionaries. Labels use the common names, diff --git a/docs/redis.md b/docs/redis.md index c2ee711a..9d035b93 100644 --- a/docs/redis.md +++ b/docs/redis.md @@ -171,7 +171,13 @@ Install the checkout with the Redis extra and borrow an application-owned `redis.asyncio.Redis` or `RedisCluster` client through `dialcache.redis.RedisAdapter`. Pass `redis=RedisAdapter(client)` to `DialCache`. Use `decode_responses=False` and finite connection, socket and retry budgets. -Tracked Cluster reads explicitly select the primary. +Tracked Cluster reads require a dedicated client constructed with primary-only +defaults: `read_from_replicas=False`, `load_balancing_strategy=None` where supported, and no +custom connection hook. Keep the routing configuration and connection mode +unchanged while borrowed; do not send `READONLY` or repurpose a previously +replica-enabled pool by resetting its flags. Create a new primary-only client. +The adapter rejects unsafe tracked reads, and the cache fails open to the source. +Untracked reads and maintenance remain available on replica-enabled clients. The [executed invalidation example](invalidation.md#configure-a-tracked-use-case) includes complete client setup and cleanup in its source. diff --git a/formal/go-parity.json b/formal/go-parity.json index 3af638e6..c7cd18fe 100644 --- a/formal/go-parity.json +++ b/formal/go-parity.json @@ -10,7 +10,7 @@ "inputs": { "semanticCasesSha256": "f3989f3032706ec6880ea29805dd1634964d868a2c3d99188adc6eaa1aac68ca", "executionSha256": "bdb8428c4fc4b34d7eed84d60f53fce53331bc3d3f16433c720e34331a9ea64f", - "sourceAuditSha256": "be71dd0e7ec3ea4e42d53be4cdd8ca98c82a2417fa94ef4b9debf923de35982f", + "sourceAuditSha256": "10ed2758e7fa6b8c17d4182f106e72ebf33795090b8037da747d969f335bd5dd", "featureCoverageSha256": "92c2961ec6a97d0bc0c7aeeac3041404ee3a7ed358b1a09ccab9de84b6abf047", "coverageWitnessesSha256": "2b2d5d57daacba3ecd3637f5a917d58b0b63fcc88893e651cd602cdba1ef4244", "profilesSha256": "15fbdb476b5ee9f39ea017ef5668d9c26cb7d53ebc96a6b7c4f531cccfa8d897" diff --git a/formal/run-python-integration.mjs b/formal/run-python-integration.mjs index 86db5a89..1bf67196 100644 --- a/formal/run-python-integration.mjs +++ b/formal/run-python-integration.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node -/** Disposable real-server evidence; every required server run must have zero skips. */ +/** Disposable real-server evidence; the native launcher requires every assertion. */ import { spawn } from 'node:child_process'; import { randomInt, randomUUID } from 'node:crypto'; -import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; +import { existsSync, mkdirSync, rmSync } from 'node:fs'; import { createServer } from 'node:net'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -100,14 +100,12 @@ async function cluster() { async function testServer(label, url, clusterUrl, isolated) { console.log(`Python integration: ${label}, tracked primary reads on a six-node Redis Cluster, TypeScript interoperability`); const report = join(reports, `python-integration-${label}.xml`); - await run(python, ['-m', 'pytest', 'python/tests/test_redis_integration.py', 'python/tests/test_docs_examples.py', '-m', 'integration', '-q', `--junitxml=${report}`], { + await run(python, ['python/tests/run_integration.py', report], { env: { ...process.env, NODE: process.env.NODE ?? process.execPath, PYTHONPATH: [join(root, 'python'), process.env.PYTHONPATH].filter(Boolean).join(process.platform === 'win32' ? ';' : ':'), - TEST_REDIS_URL: url, TEST_REDIS_CLUSTER_URL: clusterUrl, + TEST_REDIS_URL: url, DOCS_REDIS_URL: url, TEST_REDIS_CLUSTER_URL: clusterUrl, DIALCACHE_TEST_CLUSTER_ISOLATED: isolated ? '1' : '0' }, }); - const xml = readFileSync(report, 'utf8'); - if (/\bskipped="[1-9]\d*"/.test(xml)) throw new Error(`${label}: skipped integration cases earn no acceptance credit`); } try { diff --git a/formal/source-audit.json b/formal/source-audit.json index 6bfc4aaa..581ccf80 100644 --- a/formal/source-audit.json +++ b/formal/source-audit.json @@ -178,7 +178,7 @@ }, { "path": "docs/languages/python.md", - "sha256": "d9734d563424e2ce18b45cae562df9a72ad0d9d309b2c057c9f45415d1039fb9", + "sha256": "891eaff52a69017d0a96e78f80bea64b2f13228f802efd5d6b3d985fb2b8318d", "entries": [ {"line":1,"title":"Python integration","contracts":["B01"]}, {"line":10,"title":"Installation and runtime","contracts":["B01","B02"]}, @@ -186,7 +186,7 @@ {"line":40,"title":"Identity and policy","contracts":["W01","W02","C16","C18","C20","B01"]}, {"line":63,"title":"Values and cancellation","contracts":["B03","C25","C26","E04","B02"]}, {"line":81,"title":"Redis and observability","contracts":["C29","C32","W04","E01","X01","B01"]}, - {"line":96,"title":"Validation","contracts":["B01"]} + {"line":100,"title":"Validation","contracts":["B01"]} ] }, { @@ -257,24 +257,24 @@ }, { "path": "docs/redis.md", - "sha256": "c9d47b971bfdc4c8cde0a6198fafc6cabfc20c7098895786a4d667793a40a40f", + "sha256": "f7a7ab8e6d181641484a03ad797a4d9aea82a0c7251a23566234e41b20dd210a", "entries": [ {"line":1,"title":"Redis and Valkey","contracts":["E01"]}, {"line":14,"title":"Install a client","contracts":["B01","E01"]}, - {"line":181,"title":"Remote-read deadlines and async liveness","contracts":["C23","C56"]}, - {"line":231,"title":"Lifecycle ownership","contracts":["E03","X02"]}, - {"line":276,"title":"Serialization","contracts":["W06","B03"]}, - {"line":285,"title":"Default JSON behavior","contracts":["B03","C27","C45","C52"]}, - {"line":412,"title":"Compression","contracts":["W06","W07","W08","X02"]}, - {"line":506,"title":"Bundled Redis operations","contracts":["E01"]}, - {"line":508,"title":"Reads","contracts":["E01","W04"]}, - {"line":532,"title":"Writes","contracts":["E01","W04","W05"]}, - {"line":556,"title":"Invalidation retries and ambiguity","contracts":["E01","E03"]}, - {"line":599,"title":"Redis compatibility and ACLs","contracts":["E01"]}, - {"line":610,"title":"Custom-client contract","contracts":["C55","C56","E01","E04"]}, - {"line":697,"title":"Advanced wire protocol","contracts":["W04"]}, - {"line":752,"title":"Read decoding and validation order","contracts":["W04"]}, - {"line":841,"title":"Invalidation script and payload envelope","contracts":["W09","W06"]} + {"line":187,"title":"Remote-read deadlines and async liveness","contracts":["C23","C56"]}, + {"line":237,"title":"Lifecycle ownership","contracts":["E03","X02"]}, + {"line":282,"title":"Serialization","contracts":["W06","B03"]}, + {"line":291,"title":"Default JSON behavior","contracts":["B03","C27","C45","C52"]}, + {"line":418,"title":"Compression","contracts":["W06","W07","W08","X02"]}, + {"line":512,"title":"Bundled Redis operations","contracts":["E01"]}, + {"line":514,"title":"Reads","contracts":["E01","W04"]}, + {"line":538,"title":"Writes","contracts":["E01","W04","W05"]}, + {"line":562,"title":"Invalidation retries and ambiguity","contracts":["E01","E03"]}, + {"line":605,"title":"Redis compatibility and ACLs","contracts":["E01"]}, + {"line":616,"title":"Custom-client contract","contracts":["C55","C56","E01","E04"]}, + {"line":703,"title":"Advanced wire protocol","contracts":["W04"]}, + {"line":758,"title":"Read decoding and validation order","contracts":["W04"]}, + {"line":847,"title":"Invalidation script and payload envelope","contracts":["W09","W06"]} ] }, { diff --git a/python/README.md b/python/README.md index 32d5e589..dab7382e 100644 --- a/python/README.md +++ b/python/README.md @@ -141,9 +141,15 @@ async def update_profile(user_id, changes): The adapter borrows a `redis.asyncio.Redis` or `RedisCluster` client; close it with `await client.aclose()` when your application shuts down. Configure finite connection, socket, and retry budgets on the client. Tracked reads -atomically read the value and watermark from a primary, including when a -cluster client otherwise permits replica reads. Keys for one tracked entity -share a Redis Cluster hash tag. +atomically read the value and watermark from a primary. For tracked Cluster +reads, use a dedicated client constructed with primary-only defaults: +`read_from_replicas=False`, `load_balancing_strategy=None` where supported, and no custom +connection hook. Keep its configuration and connection mode unchanged while +borrowed. Do not repurpose a previously `READONLY` pool by resetting flags; +create a new primary-only client. Unsafe tracked reads raise `RedisProtocolError` +at the adapter boundary and ordinary cache calls fail open to the source. +Replica-enabled clients remain usable for untracked reads and maintenance. +Keys for one tracked entity share a Redis Cluster hash tag. Each write stores a complete version-1 frame using one native `SET`. A tracked frame is readable only if its writer timestamp is strictly greater than the diff --git a/python/dialcache/redis.py b/python/dialcache/redis.py index e984e630..3ea66eee 100644 --- a/python/dialcache/redis.py +++ b/python/dialcache/redis.py @@ -11,7 +11,7 @@ import asyncio import hashlib -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass from typing import Any, Protocol @@ -139,20 +139,46 @@ def invalidate(self, request: InvalidationRequest) -> None | Awaitable[None]: .. class RedisAdapter: """Borrow a redis.asyncio.Redis or RedisCluster with decode_responses=False. - Cluster reads explicitly select the key's primary, even when the supplied - cluster client uses replica reads. Tracked MGET is one atomic snapshot. + Tracked Cluster reads require a client constructed for primary-only reads, + with unchanged connection settings and no READONLY connection hook. A + replica-configured client remains usable for untracked reads and mutations. + Tracked MGET is one atomic snapshot, including through client redirects. ReadContext is informational; core owns its authoritative deadline. """ def __init__(self, client: Any) -> None: self.client = client - async def _command(self, key: str, *arguments: object) -> Any: + def _require_primary_connections(self) -> None: + message = ( + "Tracked reads require a dedicated primary-only RedisCluster with unchanged connection settings" + ) + try: + configuration = self.client.get_connection_kwargs() + safe = ( + not self.client.read_from_replicas + and getattr(self.client, "load_balancing_strategy", None) is None + and isinstance(configuration, Mapping) + and configuration.get("redis_connect_func") is None + ) + except Exception as error: + raise RedisProtocolError(message) from error + if not safe: + raise RedisProtocolError(message) + + async def _command(self, key: str, *arguments: object, tracked_read: bool = False) -> Any: options: dict[str, object] = {} if hasattr(self.client, "get_node_from_key"): + if tracked_read: + self._require_primary_connections() # RedisCluster initializes its topology lazily. Explicit routing # must wait for that initialization before looking up the primary. await self.client.initialize() + if tracked_read: + # Replica routing affects redirects too. Constructor-installed + # READONLY hooks survive flag changes and can serve a demoted + # primary without any redirect; never borrow those connections. + self._require_primary_connections() options["target_nodes"] = self.client.get_node_from_key(key, replica=False) return await self.client.execute_command(*arguments, **options) @@ -161,7 +187,9 @@ async def read(self, request: ReadRequest, context: ReadContext | None = None) - raise asyncio.CancelledError() if request.watermark_key is None: return decode_read(await self._command(request.value_key, "GET", request.value_key)) - result = await self._command(request.value_key, "MGET", request.value_key, request.watermark_key) + result = await self._command( + request.value_key, "MGET", request.value_key, request.watermark_key, tracked_read=True + ) if not isinstance(result, (list, tuple)) or len(result) != 2: raise RedisProtocolError("Invalid Redis MGET reply; expected two bulk strings") return decode_tracked_read(result[0], result[1]) diff --git a/python/tests/run_integration.py b/python/tests/run_integration.py new file mode 100644 index 00000000..1df8496d --- /dev/null +++ b/python/tests/run_integration.py @@ -0,0 +1,75 @@ +"""Complete integration evidence from actual pytest collection and execution.""" + +from __future__ import annotations + +import os +import sys +from collections import Counter +from pathlib import Path + +# The acceptance lane owns its command and plugins, including configured addopts. +os.environ.pop("PYTEST_ADDOPTS", None) +os.environ.pop("PYTEST_PLUGINS", None) +os.environ["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] = "1" + +import pytest + + +class Acceptance: + def __init__(self): + self.required = [] + self.selected = None + self.reports = Counter() + + def pytest_itemcollected(self, item): + if item.get_closest_marker("integration") is not None: + self.required.append(item.nodeid) + + def pytest_collection_finish(self, session): + self.selected = [item.nodeid for item in session.items] + + def pytest_runtest_logreport(self, report): + self.reports[(report.nodeid, report.when, report.outcome, hasattr(report, "wasxfail"))] += 1 + + def error(self): + if not self.required: + return "required integration inventory is empty" + if len(self.required) != len(set(self.required)): + return "required integration inventory contains duplicate node IDs" + if Counter(self.selected or []) != Counter(self.required): + return "selected cases do not equal the required integration inventory" + expected = Counter( + (nodeid, phase, "passed", False) + for nodeid in self.required + for phase in ("setup", "call", "teardown") + ) + if self.reports != expected: + return "required integration cases did not each pass setup, call and teardown exactly once" + return None + + +root = Path(__file__).resolve().parents[2] +gate = Acceptance() +status = pytest.main( + [ + "-c", + str(root / "python/pyproject.toml"), + "-o", + "addopts=", + "--noconftest", + "-p", + "pytest_asyncio.plugin", + str(root / "python/tests/test_redis_integration.py"), + str(root / "python/tests/test_docs_examples.py"), + "-m", + "integration", + "-q", + "--maxfail=1", + f"--junitxml={sys.argv[1]}", + ], + plugins=[gate], +) +error = gate.error() +if error: + print(f"Integration acceptance failed: {error}", file=sys.stderr) +sys.exit(int(status) or (1 if error else 0)) diff --git a/python/tests/test_cluster_read_routing.py b/python/tests/test_cluster_read_routing.py new file mode 100644 index 00000000..e1cd79d1 --- /dev/null +++ b/python/tests/test_cluster_read_routing.py @@ -0,0 +1,174 @@ +"""Tracked snapshots must stay authoritative across actual redis-py retries.""" + +import asyncio + +import pytest +import redis.cluster as cluster_module +from redis.asyncio.cluster import ClusterNode, RedisCluster +from redis.crc import key_slot +from redis.exceptions import AskError, MovedError + +from dialcache import DialCache, Policy +from dialcache.protocol import Frame, Miss, RedisProtocolError, encode_frame +from dialcache.redis import InvalidationRequest, ReadRequest, RedisAdapter, WriteRequest + +slot = key_slot(b"{entity}#value") +strategies = getattr(cluster_module, "LoadBalancingStrategy", None) +replica_modes = [("read_from_replicas", {"read_from_replicas": True})] +if strategies is not None: + replica_modes += [(s.name, {"load_balancing_strategy": s}) for s in strategies] + + +def make_client(options=None, redirect="MOVED", initialize_flip=False): + calls = [] + + class Node(ClusterNode): + def __init__(self, port, kind, current): + super().__init__("127.0.0.1", port, server_type=kind) + self.current = current + self.redirect = None + + async def execute_command(self, *args, **kwargs): + calls.append((self.port, args[0])) + if args[0] == "ASKING": + return b"OK" + if args[0] == "SET": + return True + if args[0] in ("EVAL", "EVALSHA"): + return 1 + if self.redirect is not None: + error, self.redirect = self.redirect, None + raise error + raw = encode_frame("primary" if self.current else "stale", 1000) + return [raw, b"2000" if self.current else None] if args[0] == "MGET" else raw + + old = Node(7000, "primary", False) + primary = Node(7001, "replica", True) + replica = Node(7002, "replica", False) + old.redirect = (MovedError if redirect == "MOVED" else AskError)(f"{slot} 127.0.0.1:7001") + + class Client(RedisCluster): + def __init__(self): + super().__init__(host="127.0.0.1", port=7000, reinitialize_steps=5, **(options or {})) + self.nodes_manager.nodes_cache = {n.name: n for n in (old, primary, replica)} + self.nodes_manager.slots_cache = {slot: [old, primary, replica]} + self.nodes_manager.default_node = old + self.nodes_manager.read_load_balancer.primary_to_idx[primary.name] = 1 + self.initialize_calls = 0 + + async def initialize(self): + self.initialize_calls += 1 + self._initialize = False + if initialize_flip: + await asyncio.sleep(0) + self.read_from_replicas = True + return self + + async def _determine_slot(self, *args): + return slot + + def __del__(self): + pass + + return Client(), calls + + +@pytest.mark.parametrize("redirect", ["MOVED", "ASK"]) +async def test_primary_only_redirects_preserve_authoritative_fence(redirect): + client, calls = make_client(redirect=redirect) + result = await RedisAdapter(client).read(ReadRequest("{entity}#value", "{entity}#watermark")) + assert result == Miss("watermark_fenced", 2000) + expected = [(7000, "MGET"), (7001, "MGET")] + if redirect == "ASK": + expected.insert(1, (7001, "ASKING")) + assert calls == expected + + +@pytest.mark.parametrize("mode,options", replica_modes) +@pytest.mark.parametrize("reset_flags", [False, True]) +async def test_replica_connections_cannot_supply_tracked_snapshots(mode, options, reset_flags): + client, calls = make_client(options) + adapter = RedisAdapter(client) + if reset_flags: + client.read_from_replicas = False + if hasattr(client, "load_balancing_strategy"): + client.load_balancing_strategy = None + configuration = dict(client.get_connection_kwargs()) + flags = (client.read_from_replicas, getattr(client, "load_balancing_strategy", None)) + with pytest.raises(RedisProtocolError, match="primary-only RedisCluster"): + await adapter.read(ReadRequest("{entity}#value", "{entity}#watermark")) + assert calls == [] + assert client.initialize_calls == 0 + assert client.get_connection_kwargs() == configuration + assert (client.read_from_replicas, getattr(client, "load_balancing_strategy", None)) == flags + # Only tracked reads require the stronger acquisition guarantee. + assert isinstance(await adapter.read(ReadRequest("{entity}#value")), Frame) + await adapter.write(WriteRequest("{entity}#value", 1000, "value", 1000)) + await adapter.invalidate(InvalidationRequest("{entity}#watermark", 0, 1000)) + assert {command for _, command in calls} >= {"GET", "SET", "EVALSHA"} + assert client.get_connection_kwargs() == configuration + + +@pytest.mark.parametrize("mode,options", replica_modes) +async def test_tracked_policy_is_checked_after_adapter_construction(mode, options): + client, calls = make_client() + adapter = RedisAdapter(client) + for name, value in options.items(): + setattr(client, name, value) + with pytest.raises(RedisProtocolError, match="primary-only RedisCluster"): + await adapter.read(ReadRequest("{entity}#value", "{entity}#watermark")) + assert calls == [] + + +async def test_tracked_policy_is_rechecked_after_awaited_initialization(): + client, calls = make_client(initialize_flip=True) + with pytest.raises(RedisProtocolError, match="primary-only RedisCluster"): + await RedisAdapter(client).read(ReadRequest("{entity}#value", "{entity}#watermark")) + assert client.initialize_calls == 1 + assert calls == [] + + +@pytest.mark.parametrize("configuration", [None, {"redis_connect_func": object()}]) +async def test_uninspectable_or_custom_connection_configuration_fails_closed(configuration): + client, calls = make_client() + client.connection_kwargs = configuration + with pytest.raises(RedisProtocolError, match="primary-only RedisCluster"): + await RedisAdapter(client).read(ReadRequest("{entity}#value", "{entity}#watermark")) + assert calls == [] + + +async def test_missing_cluster_configuration_accessor_fails_closed(): + class Client: + read_from_replicas = False + + def get_node_from_key(self, *args, **kwargs): + raise AssertionError("must reject before node lookup") + + with pytest.raises(RedisProtocolError, match="primary-only RedisCluster"): + await RedisAdapter(Client()).read(ReadRequest("value", "watermark")) + + +async def test_denied_tracked_cluster_read_fails_open_without_refill_or_local_warming(): + client, calls = make_client({"read_from_replicas": True}) + cache = DialCache(redis=RedisAdapter(client)) + source_calls = [] + + def source(): + source_calls.append(1) + return len(source_calls) + + with cache.enable(): + for expected in [1, 2]: + assert ( + await cache.get_or_load( + source, + key="a", + key_type="entity", + use_case="guard", + track_for_invalidation=True, + default_config=Policy(ttl_sec={"local": 60, "remote": 60}), + ) + == expected + ) + assert len(source_calls) == 2 + assert calls == [] diff --git a/python/tests/test_integration_acceptance.py b/python/tests/test_integration_acceptance.py new file mode 100644 index 00000000..1baec425 --- /dev/null +++ b/python/tests/test_integration_acceptance.py @@ -0,0 +1,145 @@ +"""The real integration coordinator must require all assertions on its labeled backend.""" + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] + +TEST = """ +from pathlib import Path +import os +import pytest +pytestmark = pytest.mark.integration +{plugins} + +def test_inventory_only(): + pass + +@pytest.mark.parametrize("value", ["雪", ""]) +def test_required(value): + mode = os.environ.get("PROBE_MODE") + if mode == "skip": + pytest.skip("must not earn acceptance") + if mode == "fail": + assert False, "sentinel required assertion" + with open(os.environ["PROBE_LOG"], "a") as output: + output.write(os.environ["TEST_REDIS_URL"] + " " + value + "\\n") +""" +DOCS = """ +import os +import pytest +def test_request_scope(): + assert False, "unmarked docs example was incorrectly selected" +def test_runtime_policy(): + assert False, "unmarked docs example was incorrectly selected" +@pytest.mark.integration +def test_tracked_invalidation(): + assert os.environ["DOCS_REDIS_URL"] == os.environ["TEST_REDIS_URL"] +""" +PLUGIN = """ +import os +import pytest +@pytest.hookimpl(trylast=True) +def pytest_collection_modifyitems(session, config, items): + mode = os.environ.get("PROBE_MODE") + if mode == "subset": + items[:] = [item for item in items if "inventory_only" in item.nodeid] + elif mode == "duplicate": + items.append(next(item for item in items if item.get_closest_marker("integration"))) + elif mode == "empty": + items[:] = [] + elif mode == "xpass": + for item in items: + item.add_marker(pytest.mark.xfail(reason="unexpected passes do not earn acceptance")) +def pytest_runtestloop(session): + if os.environ.get("PROBE_MODE") == "no_reports": + return True +""" + + +@pytest.mark.parametrize( + "challenge", + [ + "baseline", + "inherited_keyword", + "configured_keyword", + "inherited_collect_only", + "external_plugin_ignored", + "subset", + "duplicate", + "empty", + "skip", + "fail", + "xpass", + "no_reports", + ], +) +def test_integration_coordinator_requires_complete_cases_on_each_backend(tmp_path, challenge): + node = os.environ.get("NODE") or shutil.which("node") + assert node, "Node 24 is required by Python validation" + checkout = tmp_path + (checkout / "formal").mkdir() + tests = checkout / "python/tests" + tests.mkdir(parents=True) + shutil.copyfile( + ROOT / "formal/run-python-integration.mjs", checkout / "formal/run-python-integration.mjs" + ) + shutil.copyfile(ROOT / "python/tests/run_integration.py", tests / "run_integration.py") + configured = "-k test_inventory_only" if challenge == "configured_keyword" else "" + (checkout / "python/pyproject.toml").write_text( + '[tool.pytest.ini_options]\nasyncio_mode="auto"\n' + 'markers=["integration: required real-server case"]\n' + f"addopts={json.dumps(configured)}\n" + ) + failures = {"subset", "duplicate", "empty", "skip", "fail", "xpass", "no_reports"} + mode = challenge if challenge in failures else "pass" + external_plugin = challenge == "external_plugin_ignored" + if external_plugin: + mode = "subset" + (tests / "test_redis_integration.py").write_text( + TEST.format(plugins='pytest_plugins = ["challenge_plugin"]' if challenge in failures else "") + ) + (tests / "test_docs_examples.py").write_text(DOCS) + (tests / "challenge_plugin.py").write_text(PLUGIN) + log = checkout / "executed.txt" + addopts = "-k test_inventory_only" if challenge == "inherited_keyword" else "" + if challenge == "inherited_collect_only": + addopts = "--collect-only" + environment = { + **os.environ, + "PYTHON": sys.executable, + "NODE": node, + "PYTHONPATH": str(tests), + "TEST_REDIS_URL": "redis://127.0.0.1:9", + "TEST_VALKEY_URL": "redis://127.0.0.1:19", + "TEST_REDIS_CLUSTER_URL": "redis://127.0.0.1:29", + "DOCS_REDIS_URL": "redis://127.0.0.1:39", + "PROBE_MODE": mode, + "PROBE_LOG": str(log), + "PYTEST_ADDOPTS": addopts, + "PYTEST_PLUGINS": "challenge_plugin" if external_plugin else "", + } + result = subprocess.run( + [node, "formal/run-python-integration.mjs"], + cwd=checkout, + env=environment, + text=True, + capture_output=True, + timeout=30, + ) + if challenge in failures: + assert result.returncode != 0, result.stdout + result.stderr + assert "Integration acceptance failed:" in result.stderr, result.stdout + result.stderr + else: + assert result.returncode == 0, result.stdout + result.stderr + assert log.read_text().splitlines() == [ + f"{url} {value}" + for url in [environment["TEST_REDIS_URL"], environment["TEST_VALKEY_URL"]] + for value in ["雪", ""] + ] diff --git a/python/tests/test_redis_adapter.py b/python/tests/test_redis_adapter.py index 07e06004..9cad057e 100644 --- a/python/tests/test_redis_adapter.py +++ b/python/tests/test_redis_adapter.py @@ -81,8 +81,13 @@ async def test_mutation_input_validation_precedes_dispatch(): assert client.calls == [] -async def test_cluster_primary_routing_overrides_replica_reads(): +async def test_cluster_primary_routing_with_primary_only_connections(): class Cluster(Client): + read_from_replicas = False + + def get_connection_kwargs(self): + return {} + async def initialize(self): self.initialized = True diff --git a/python/tests/test_redis_integration.py b/python/tests/test_redis_integration.py index a806879d..e7964c66 100644 --- a/python/tests/test_redis_integration.py +++ b/python/tests/test_redis_integration.py @@ -14,7 +14,7 @@ from test_protocol_native import node_bridge from dialcache.key import Key -from dialcache.protocol import Frame, Miss, encode_frame +from dialcache.protocol import Frame, Miss, RedisProtocolError, encode_frame from dialcache.redis import ( INVALIDATE_CACHE_SCRIPT, InvalidationRequest, @@ -167,7 +167,7 @@ async def test_cluster_atomic_primary_snapshot_and_native_writes(): if not url: pytest.skip("Set TEST_REDIS_CLUSTER_URL to test an actual Redis Cluster") client = redis.RedisCluster.from_url( - url, read_from_replicas=True, decode_responses=False, socket_timeout=5, socket_connect_timeout=5 + url, decode_responses=False, socket_timeout=5, socket_connect_timeout=5 ) key = Key("dialcache-python-cluster-" + uuid4().hex, "entity", "1", "Get", tracked=True) adapter = RedisAdapter(client) @@ -186,6 +186,15 @@ async def test_cluster_atomic_primary_snapshot_and_native_writes(): "after", 1001 ) assert await client.execute_command("GET", key.watermark_key, target_nodes=primary) == b"1000" + replica_client = redis.RedisCluster.from_url( + url, read_from_replicas=True, decode_responses=False, socket_timeout=5, socket_connect_timeout=5 + ) + try: + await replica_client.initialize() + with pytest.raises(RedisProtocolError, match="primary-only RedisCluster"): + await RedisAdapter(replica_client).read(ReadRequest(key.value_key, key.watermark_key)) + finally: + await replica_client.aclose() finally: try: await client.delete(key.value_key, key.watermark_key) From a34989f6a087f39500b0945db0b3f70bdc166891 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Tue, 22 Sep 2026 10:44:04 -0700 Subject: [PATCH 4/4] fix(python): isolate dependency cancellation and reuse payload bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve caller and source cancellation while failing open for cache dependencies and observers. Keep read deadlines authoritative when abort callbacks fail, retain shadow outcomes, and normalize compression payloads once. — Levicus 🤖 --- python/dialcache/cache.py | 72 ++- python/dialcache/metrics.py | 3 +- python/dialcache/protocol.py | 5 +- python/tests/test_callback_cancellation.py | 509 +++++++++++++++++++ python/tests/test_dependency_cancellation.py | 352 +++++++++++++ python/tests/test_protocol_native.py | 38 ++ 6 files changed, 954 insertions(+), 25 deletions(-) create mode 100644 python/tests/test_callback_cancellation.py create mode 100644 python/tests/test_dependency_cancellation.py diff --git a/python/dialcache/cache.py b/python/dialcache/cache.py index 12c5691d..867b0a05 100644 --- a/python/dialcache/cache.py +++ b/python/dialcache/cache.py @@ -45,6 +45,17 @@ async def _await(value: Any) -> Any: return await value if inspect.isawaitable(value) else value +async def _call_dependency(callback: Callable[..., Any], *args: Any) -> Any: + """Classify dependency cancellation without swallowing cancellation of this task.""" + try: + return await _await(callback(*args)) + except asyncio.CancelledError as error: + task = asyncio.current_task() + if task is not None and task.cancelling(): + raise + raise RuntimeError("DialCache dependency was cancelled") from error + + def _valid_integer(value: Any, minimum: int = 0, maximum: int = MAX_SAFE) -> bool: return ( isinstance(value, (int, float)) @@ -103,7 +114,7 @@ def abort(self) -> None: for callback in callbacks: try: callback() - except Exception: + except (Exception, asyncio.CancelledError): pass @@ -379,7 +390,7 @@ def _emit(self, event: str, labels: Mapping[str, Any], **fields: Any) -> None: def _log(self, message: str, error: Any = None) -> None: try: self.logger.warning(message, error) if error is not None else self.logger.warning(message) - except Exception: + except (Exception, asyncio.CancelledError): pass @staticmethod @@ -435,7 +446,7 @@ def timeout() -> None: if on_timeout is not None: try: on_timeout() - except Exception: + except (Exception, asyncio.CancelledError): pass result.set_exception(error()) @@ -535,12 +546,16 @@ async def _execute_enabled(self, op: _Operation) -> Any: normalize_args(spec.get("args", {})), op.tracked, ) - except Exception as error: + except (Exception, asyncio.CancelledError) as error: self._error(op, "noop", "key_construction") self._log("Could not construct DialCache key: %s", error) return await self._source(op, "noop") try: - overlay = await _await(self.policy_provider(key)) if self.policy_provider is not None else None + overlay = ( + await _call_dependency(self.policy_provider, key) + if self.policy_provider is not None + else None + ) policy = merge_policy(op.policy, overlay) or Policy() except Exception as error: self._error(key, "noop", "config_resolution") @@ -633,7 +648,7 @@ async def run() -> Any: if found: return value self._emit("miss", self._labels(key, "local"), reason="value_absent") - except Exception: + except (Exception, asyncio.CancelledError): can_put = False self._error(key, "local", "cache_read") self._emit("disabled", self._labels(key, "local"), reason="config_error") @@ -684,7 +699,7 @@ def _put_local(self, key: Key, value: Any, local: Any) -> None: if local is not None: try: self._local.put(key.logical, value, local.ttl_sec) - except Exception: + except (Exception, asyncio.CancelledError): self._error(key, "local", "cache_write") def _read_budget(self, policy: Policy) -> int: @@ -699,8 +714,8 @@ async def _raw_read(self, key: Key, policy: Policy, job: _Shadow | None = None) async def invoke() -> Any: invoked.set_result(self.clock.monotonic_ms()) - return await _await( - self.redis.read(ReadRequest(key.value_key, key.watermark_key), ReadContext(budget, signal)) + return await _call_dependency( + self.redis.read, ReadRequest(key.value_key, key.watermark_key), ReadContext(budget, signal) ) pending = self._spawn(invoke()) @@ -750,7 +765,7 @@ async def _decode(self, op: _Operation, key: Key, payload: Any, layer: str) -> A self._emit("compression", self._labels(key, layer), outcome=decompressed.outcome) start = self.clock.monotonic_ms() try: - return await _await(op.serializer.load(decompressed.payload)) + return await _call_dependency(op.serializer.load, decompressed.payload) except Exception: self._error(key, layer, "serialization_load") raise @@ -808,7 +823,12 @@ async def _remote_chain(self, op: _Operation, key: Key, policy: Policy, local: A maximum = remote.stale_on_error_max_age_sec if maximum and status in ("miss", "retained"): try: - allow = op.recovery(error) + try: + allow = op.recovery(error) + except asyncio.CancelledError: + # Only the synchronous predicate is isolated here; + # caller cancellation during recovery must propagate. + allow = False if allow is True: present, value = await self._recover(op, key, acquired, maximum) if present: @@ -866,7 +886,7 @@ async def _write( return False start = self.clock.monotonic_ms() try: - payload = await _await(op.serializer.dump(value)) + payload = await _call_dependency(op.serializer.dump, value) if not isinstance(payload, (str, bytes)): raise TypeError("Serializer.dump must return str or bytes") except Exception: @@ -879,17 +899,17 @@ async def _write( try: if self.compression is False: payload = escape_raw_payload(payload) + stored_size = len(utf8_bytes(payload) if isinstance(payload, str) else payload) else: options = self.compression if isinstance(self.compression, Mapping) else {} compressed = compress_payload(payload, **options) payload = compressed.payload + stored_size = compressed.stored_bytes self._emit("compression", labels, outcome=compressed.outcome) except Exception: self._error(key, layer, "compression") raise - self._emit( - "storedSize", labels, bytes=len(utf8_bytes(payload) if isinstance(payload, str) else payload) - ) + self._emit("storedSize", labels, bytes=stored_size) if live is not None and not live(): return False stamp = self.clock.wall_ms() @@ -903,7 +923,7 @@ async def _write( ttl_ms = 3_600_000 self._error(key, layer, "tracked_ttl_clamped") try: - await _await(self.redis.write(WriteRequest(key.value_key, ttl_ms, payload, stamp))) + await _call_dependency(self.redis.write, WriteRequest(key.value_key, ttl_ms, payload, stamp)) except Exception: self._error(key, layer, "cache_write") raise @@ -954,7 +974,7 @@ def _schedule_shadow( try: if hasattr(self.metrics, "supports") and not self.metrics.supports("shadowValidation"): return - except Exception: + except (Exception, asyncio.CancelledError): return if ramp < 100 and ramp_sample(key, "shadow") >= ramp: return @@ -1052,13 +1072,18 @@ async def work() -> str: # This source belongs to the caller. A dark job stops # waiting at its deadline without retaining capacity # for an unbounded caller-owned operation. - value = await self._deadline( - source, job.budget, lambda: TimeoutError("shadow deadline"), started=job.started + value = await _call_dependency( + lambda: self._deadline( + source, + job.budget, + lambda: TimeoutError("shadow deadline"), + started=job.started, + ) ) await self._await_delivery(op) else: with self.disable(): - value = await _await(op.load()) + value = await _call_dependency(op.load) except Exception: return ( "timeout" if not live() or (source is not None and op.did_timeout) else "source_error" @@ -1082,10 +1107,13 @@ async def work() -> str: if not live(): return "timeout" try: - matched = op.comparator(cached, value) + try: + matched = op.comparator(cached, value) + except asyncio.CancelledError: + return "comparison_error" if type(matched) is not bool: try: - await _await(matched) + await _call_dependency(lambda: matched) except Exception: pass return "comparison_error" if live() else "timeout" diff --git a/python/dialcache/metrics.py b/python/dialcache/metrics.py index 42b88fd9..a2a227ad 100644 --- a/python/dialcache/metrics.py +++ b/python/dialcache/metrics.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import inspect from collections.abc import Callable, Mapping from typing import Any, Protocol, TypeAlias @@ -34,5 +35,5 @@ def emit_metric(metrics: Metrics | None, event: str | Mapping[str, Any], **label # it, and avoid leaving an un-awaited coroutine warning behind. if inspect.iscoroutine(result): result.close() - except Exception: + except (Exception, asyncio.CancelledError): pass diff --git a/python/dialcache/protocol.py b/python/dialcache/protocol.py index 54a777c6..61d2feae 100644 --- a/python/dialcache/protocol.py +++ b/python/dialcache/protocol.py @@ -185,7 +185,8 @@ class DecompressionResult: def escape_raw_payload(payload: Payload) -> Payload: - _payload_bytes(payload) + if not isinstance(payload, (str, bytes)): + raise TypeError("DialCache serializer payload must be str or immutable bytes") return b"\x00" + payload if isinstance(payload, bytes) and payload and payload[0] <= 2 else payload @@ -196,7 +197,7 @@ def compress_payload( raw = _payload_bytes(payload) escaped = escape_raw_payload(payload) - stored_size = len(_payload_bytes(escaped)) + stored_size = len(escaped) if isinstance(escaped, bytes) else len(raw) if len(raw) < threshold_bytes: return CompressionResult(escaped, "below_threshold", len(raw), stored_size) if len(raw) > maximum: diff --git a/python/tests/test_callback_cancellation.py b/python/tests/test_callback_cancellation.py new file mode 100644 index 00000000..cc37ca80 --- /dev/null +++ b/python/tests/test_callback_cancellation.py @@ -0,0 +1,509 @@ +"""Native cancellation boundaries: synchronous callbacks and detached shadows. + +Injected synchronous cancellations come from result() on an independently +cancelled application Future. +""" + +import asyncio + +import pytest +from formal.executor import Executor + +from dialcache import DialCache, FallbackTimeoutError, Policy +from dialcache.protocol import Frame + + +@pytest.fixture +def executor(): + instance = Executor() + try: + yield instance + finally: + instance.close() + + +@pytest.fixture(params=[False, True], ids=["ordinary", "eager"]) +def scheduled(request, executor): + if request.param: + if not hasattr(asyncio, "eager_task_factory"): + pytest.skip("Eager task factories require Python 3.12+") + executor.loop.set_task_factory(asyncio.eager_task_factory) + return executor + + +def cancelled_result(executor): + future = executor.future() + future.cancel() + return future.result + + +def call(cache, source, policy=None, **options): + return cache.get_or_load( + source, + key="id", + key_type="entity", + use_case="callback-cancellation", + default_config=policy, + **options, + ) + + +def outcomes(events): + return [event["outcome"] for event in events if event["event"] == "shadowValidation"] + + +@pytest.mark.parametrize("path", ["disabled", "source", "hit"]) +@pytest.mark.parametrize("object_observer", [False, True]) +def test_cancelled_metrics_preserve_disabled_source_and_hit(executor, path, object_observer): + cancel = cancelled_result(executor) + events, calls = [], [] + accepted = object() + + def source(): + calls.append(1) + return accepted + + def observer(event): + events.append(event) + cancel() + + class Observer: + observe = staticmethod(observer) + + cache = DialCache(clock=executor.clock) + policy = Policy(ttl_sec={"local": 10}) + with cache.enable(path != "disabled"): + if path == "hit": + assert executor.finish(call(cache, source, policy)) is accepted + cache.metrics = Observer() if object_observer else observer + result = executor.task(call(cache, source, policy)) + executor.drain() + assert result.result() is accepted + assert result.cancelling() == 0 + assert calls == [1] + assert events + assert cache.get_coalescing_state()["process"]["active_leaders"] == 0 + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_cancelled_metrics_preserve_original_source_error(executor, enabled): + cancel = cancelled_result(executor) + original = ValueError("original source failure") + calls = [] + + def source(): + calls.append(1) + raise original + + cache = DialCache(clock=executor.clock, metrics=lambda event: cancel()) + with cache.enable(enabled): + with pytest.raises(ValueError) as caught: + executor.finish(call(cache, source, Policy(ttl_sec={"local": 10}))) + assert caught.value is original + assert calls == [1] + + +@pytest.mark.parametrize("source_fails", [False, True]) +def test_cancelled_warning_logger_preserves_source_outcome(executor, source_fails): + cancel = cancelled_result(executor) + warnings, calls = [], [] + config_error = RuntimeError("policy unavailable") + source_error = ValueError("original source failure") + accepted = object() + + class Logger: + def warning(self, *args): + warnings.append(args) + cancel() + + def provider(key): + raise config_error + + def source(): + calls.append(1) + if source_fails: + raise source_error + return accepted + + cache = DialCache(clock=executor.clock, policy_provider=provider, logger=Logger()) + with cache.enable(): + if source_fails: + with pytest.raises(ValueError) as caught: + executor.finish(call(cache, source)) + assert caught.value is source_error + else: + assert executor.finish(call(cache, source)) is accepted + assert calls == [1] + assert len(warnings) == 1 + assert warnings[0][1] is config_error + + +@pytest.mark.parametrize("served", [False, True], ids=["dark", "served"]) +def test_cancelled_supports_preserves_caller_and_starts_no_shadow(executor, served): + cancel = cancelled_result(executor) + events, supported, calls, reads = [], [], [], [] + + class Observer: + def observe(self, event): + events.append(event) + + def supports(self, event): + supported.append(event) + return cancel() + + class Redis: + def read(self, request, context): + reads.append(request) + return Frame(int(executor.clock.wall_ms()), '"cached"') + + def source(): + calls.append(1) + return "source" + + cache = DialCache(clock=executor.clock, redis=Redis(), metrics=Observer()) + policy = Policy(ttl_sec={"remote": 10}, ramp={"remote": 100 if served else 0}, shadow={"ramp": 100}) + with cache.enable(): + assert executor.finish(call(cache, source, policy)) == ("cached" if served else "source") + executor.drain() + assert supported == ["shadowValidation"] + assert calls == ([] if served else [1]) + assert len(reads) == int(served) + assert outcomes(events) == [] + assert not asyncio.all_tasks(executor.loop) + + +@pytest.mark.parametrize("settlement", ["value", "error", "cancel"]) +def test_cancelled_metrics_do_not_prevent_maintenance_dispatch_or_replace_result(executor, settlement): + cancel = cancelled_result(executor) + gate = executor.future() + requests = [] + original = ValueError("original maintenance failure") + + class Redis: + def invalidate(self, request): + requests.append(request) + return gate + + cache = DialCache(clock=executor.clock, redis=Redis(), metrics=lambda event: cancel()) + caller = executor.task(cache.invalidate_remote("entity", "id", 7)) + executor.drain() + assert len(requests) == 1 + assert requests[0].future_buffer_ms == 7 + assert not caller.done() + if settlement == "cancel": + gate.cancel() + elif settlement == "error": + gate.set_exception(original) + else: + gate.set_result(None) + executor.drain() + assert caller.cancelling() == 0 + if settlement == "cancel": + assert caller.cancelled() + elif settlement == "error": + assert caller.exception() is original + else: + assert caller.result() is None + + +@pytest.mark.parametrize("enabled", [False, True]) +@pytest.mark.parametrize("observer_cancels", [False, True]) +def test_observer_containment_preserves_real_caller_cancellation(executor, enabled, observer_cancels): + cancel = cancelled_result(executor) + gate = executor.future() + started = [] + + async def source(): + started.append(1) + return await gate + + cache = DialCache(clock=executor.clock, metrics=(lambda event: cancel()) if observer_cancels else None) + with cache.enable(enabled): + caller = executor.task(call(cache, source, Policy(ttl_sec={"local": 10}, coalesce=False))) + executor.drain() + assert started == [1] and not caller.done() + caller.cancel() + executor.drain() + assert caller.cancelled() and caller.cancelling() == 1 + if enabled: + assert not gate.done() + gate.set_result("late") + executor.drain() + else: + assert gate.cancelled() + + +@pytest.mark.parametrize("observer_cancels", [False, True]) +def test_observer_containment_preserves_real_maintenance_cancellation(executor, observer_cancels): + cancel = cancelled_result(executor) + gate = executor.future() + requests = [] + + class Redis: + def invalidate(self, request): + requests.append(request) + return gate + + cache = DialCache( + clock=executor.clock, redis=Redis(), metrics=(lambda event: cancel()) if observer_cancels else None + ) + caller = executor.task(cache.invalidate_remote("entity", "id")) + executor.drain() + assert len(requests) == 1 and not caller.done() + caller.cancel() + executor.drain() + assert caller.cancelled() and caller.cancelling() == 1 + assert gate.cancelled() + + +@pytest.mark.parametrize("source_fails", [False, True]) +def test_cancelled_key_callback_preserves_source_and_skips_policy(executor, source_fails): + cancel = cancelled_result(executor) + events, calls, providers = [], [], [] + original = ValueError("original source failure") + accepted = object() + + def source(): + calls.append(1) + if source_fails: + raise original + return accepted + + cache = DialCache(clock=executor.clock, policy_provider=providers.append, metrics=events.append) + with cache.enable(): + pending = call(cache, source, Policy(ttl_sec={"local": 10}), key_selector=cancel) + if source_fails: + with pytest.raises(ValueError) as caught: + executor.finish(pending) + assert caught.value is original + else: + assert executor.finish(pending) is accepted + assert calls == [1] + assert providers == [] + assert [e["error"] for e in events if e["event"] == "error"] == ( + ["key_construction", "fallback"] if source_fails else ["key_construction"] + ) + + +@pytest.mark.parametrize("coalesce", [False, True]) +@pytest.mark.parametrize("phase", ["read", "put"]) +def test_cancelled_local_callback_preserves_accepted_value_and_failed_layer(scheduled, coalesce, phase): + executor = scheduled + cancel = cancelled_result(executor) + events, actions, sources = [], [], [] + accepted = object() + + class Local: + def read(self, key): + actions.append("read") + if phase == "read": + cancel() + return False, None + + def put(self, key, value, ttl_sec): + actions.append("put") + assert value is accepted + cancel() + + def source(): + sources.append(1) + return accepted + + cache = DialCache(clock=executor.clock, local_store=Local(), metrics=events.append) + with cache.enable(): + result = executor.task(call(cache, source, Policy(ttl_sec={"local": 10}, coalesce=coalesce))) + executor.drain() + assert result.result() is accepted + assert result.cancelling() == 0 + assert sources == [1] + assert actions == (["read"] if phase == "read" else ["read", "put"]) + assert [e["error"] for e in events if e["event"] == "error"] == [ + "cache_read" if phase == "read" else "cache_write" + ] + assert cache.get_coalescing_state()["process"]["active_leaders"] == 0 + + +@pytest.mark.parametrize("coalesce", [False, True]) +def test_cancelled_recovery_predicate_preserves_original_source_failure(scheduled, coalesce): + executor = scheduled + cancel = cancelled_result(executor) + original = ValueError("original source failure") + predicates, loads, writes = [], [], [] + + class Redis: + def read(self, request, context): + return Frame(int(executor.clock.wall_ms()) - 2000, '"retained"') + + def write(self, request): + writes.append(request) + + class Serializer: + def load(self, payload): + loads.append(payload) + return "retained" + + def source(): + raise original + + def recovery(error): + predicates.append(error) + return cancel() + + cache = DialCache( + clock=executor.clock, + redis=Redis(), + serializer=Serializer(), + should_attempt_stale_recovery=recovery, + ) + policy = Policy(ttl_sec={"remote": 1}, stale_on_error_max_age_sec=10, coalesce=coalesce) + with cache.enable(): + with pytest.raises(ValueError) as caught: + executor.finish(call(cache, source, policy)) + assert caught.value is original + assert predicates == [original] + assert loads == writes == [] + assert cache.get_coalescing_state()["process"]["active_leaders"] == 0 + + +@pytest.mark.parametrize("coalesce", [False, True]) +def test_abort_callback_cancellation_cannot_defeat_read_deadline(scheduled, coalesce): + executor = scheduled + cancel = cancelled_result(executor) + raw = executor.future() + contexts, aborts, sources, writes, events = [], [], [], [], [] + + def first_abort(): + aborts.append("first") + cancel() + + class Redis: + def read(self, request, context): + contexts.append(context) + context.signal.add_callback(first_abort) + context.signal.add_callback(lambda: aborts.append("second")) + return raw + + def write(self, request): + writes.append(request) + + def source(): + sources.append(1) + return "source" + + cache = DialCache(clock=executor.clock, redis=Redis(), metrics=events.append, read_timeout_ms=7) + with cache.enable(): + caller = executor.task(call(cache, source, Policy(ttl_sec={"remote": 10}, coalesce=coalesce))) + executor.drain() + executor.clock.advance(6) + assert not caller.done() and not raw.done() + executor.clock.advance(1) + assert caller.result() == "source" + assert caller.cancelling() == 0 + assert contexts[0].signal.aborted + assert aborts == ["first", "second"] + assert sources == [1] and writes == [] + assert not raw.done(), "Read timeout must leave the borrowed raw Future application owned" + assert [e["error"] for e in events if e["event"] == "error"] == ["cache_read_timeout"] + executor.clock.advance(70) + assert not raw.done() and sources == [1] and writes == [] + + +@pytest.mark.parametrize("result_kind", ["sync_result", "future", "coroutine"]) +def test_cancelled_comparator_emits_one_terminal_outcome_after_served_hit(scheduled, result_kind): + executor = scheduled + cancelled = executor.future() + cancelled.cancel() + events, comparisons, reads, sources = [], [], [], [] + + async def deferred_comparison(): + return await cancelled + + def compare(cached, source): + comparisons.append((cached, source)) + if result_kind == "sync_result": + return cancelled.result() + return cancelled if result_kind == "future" else deferred_comparison() + + class Redis: + def read(self, request, context): + reads.append(request) + return Frame(int(executor.clock.wall_ms()), '"cached"') + + def source(): + sources.append(1) + return "source" + + cache = DialCache(clock=executor.clock, redis=Redis(), metrics=events.append) + with cache.enable(): + caller = executor.task( + call( + cache, + source, + Policy(ttl_sec={"remote": 10}, shadow={"ramp": 100}), + shadow_comparator=compare, + fallback_timeout_ms=10, + ) + ) + executor.drain() + assert caller.result() == "cached" + assert caller.cancelling() == 0 + assert comparisons == [("cached", "source")] + assert sources == [1] and len(reads) == 1 + assert outcomes(events) == ["comparison_error"] + executor.clock.advance(20) + assert outcomes(events) == ["comparison_error"] + assert not asyncio.all_tasks(executor.loop) + + +@pytest.mark.parametrize("served", [False, True], ids=["dark", "served"]) +@pytest.mark.parametrize("cancel_at", ["before_start", "while_waiting", "deadline"]) +def test_cancelled_shadow_source_has_one_phase_appropriate_outcome(scheduled, served, cancel_at): + executor = scheduled + source = executor.future() + events, calls, reads, writes = [], [], [], [] + if cancel_at == "before_start": + source.cancel() + + class Redis: + def read(self, request, context): + reads.append(request) + return Frame(int(executor.clock.wall_ms()), '"cached"') + + def write(self, request): + writes.append(request) + + def load(): + calls.append(1) + return source + + cache = DialCache(clock=executor.clock, redis=Redis(), metrics=events.append) + policy = Policy(ttl_sec={"remote": 10}, ramp={"remote": 100 if served else 0}, shadow={"ramp": 100}) + with cache.enable(): + caller = executor.task(call(cache, load, policy, fallback_timeout_ms=10)) + executor.drain() + assert calls == [1] + if cancel_at != "before_start": + assert not source.done() + if served: + assert caller.result() == "cached" + else: + assert not caller.done() + if cancel_at == "deadline": + executor.clock.consume(10) + source.cancel() + executor.drain() + assert source.cancelled() + assert caller.cancelling() == 0 + if served: + assert caller.result() == "cached" + elif cancel_at == "deadline": + assert isinstance(caller.exception(), FallbackTimeoutError) + else: + assert caller.cancelled(), "A dark shadow must not replace the caller's source cancellation" + expected = "timeout" if cancel_at == "deadline" else "source_error" + assert outcomes(events) == [expected] + executor.clock.advance(20) + assert outcomes(events) == [expected] + assert calls == [1] and len(reads) == 1 and writes == [] + assert cache.get_coalescing_state()["process"]["active_leaders"] == 0 + assert not asyncio.all_tasks(executor.loop) diff --git a/python/tests/test_dependency_cancellation.py b/python/tests/test_dependency_cancellation.py new file mode 100644 index 00000000..39e83aa5 --- /dev/null +++ b/python/tests/test_dependency_cancellation.py @@ -0,0 +1,352 @@ +"""Independently cancelled extensions fail open; callers and sources retain cancellation.""" + +from __future__ import annotations + +import asyncio +import inspect +import json + +import pytest +from formal.executor import Executor + +from dialcache import DialCache, Policy +from dialcache.protocol import Frame, Miss + + +@pytest.fixture(params=[False, True], ids=["ordinary", "eager"]) +def executor(request): + instance = Executor() + if request.param: + if not hasattr(asyncio, "eager_task_factory"): + instance.close() + pytest.skip("Eager task factories require Python 3.12+") + instance.loop.set_task_factory(asyncio.eager_task_factory) + try: + yield instance + finally: + instance.close() + + +def cancel_dependency(executor, kind): + if kind == "raised": + raise asyncio.CancelledError("extension cancelled itself") + future = executor.future() + future.cancel() + return future + + +class BoundaryProbe: + """Application dependencies with visible source and publication effects.""" + + def __init__(self, executor, boundary, dependency, *, coalesce): + self.executor, self.boundary, self.dependency = executor, boundary, dependency + self.source_value, self.cached_value = {"value": "source"}, {"value": "cached"} + self.entered, self.sources, self.reads, self.dumps, self.writes = [], [], [], [], [] + self.completed_writes, self.events = [], [] + self.policy_config = Policy(ttl_sec={"local": 10, "remote": 10}, coalesce=coalesce) + self.cache = DialCache( + clock=executor.clock, + redis=self, + serializer=self, + policy_provider=self.policy, + metrics=self.events.append, + compression=False, + ) + + def at(self, boundary, normal): + if boundary == self.boundary: + self.entered.append(boundary) + return self.dependency() + return normal + + def policy(self, key): + return self.at("policy", None) + + def read(self, request, context): + self.reads.append(request) + normal = ( + Frame(int(self.executor.clock.wall_ms()), json.dumps(self.cached_value)) + if self.boundary == "load" + else Miss("value_absent") + ) + return self.at("read", normal) + + def load(self, payload): + return self.at("load", json.loads(payload)) + + def dump(self, value): + self.dumps.append(value) + return self.at("dump", json.dumps(value)) + + def write(self, request): + self.writes.append(request) + result = self.at("write", None) + if inspect.isawaitable(result): + + async def complete(): + await result + self.completed_writes.append(request) + + return complete() + self.completed_writes.append(request) + return result + + def source(self): + self.sources.append(self.source_value) + return self.source_value + + def call(self): + return self.cache.get_or_load( + self.source, + key="id", + key_type="entity", + use_case="dependency-cancellation", + default_config=self.policy_config, + ) + + +@pytest.mark.parametrize("coalesce", [False, True], ids=["unshared", "shared"]) +@pytest.mark.parametrize("kind", ["future", "raised"]) +@pytest.mark.parametrize("boundary", ["policy", "read", "load", "dump", "write"]) +def test_independent_dependency_cancellation_preserves_source_and_publication( + executor, boundary, kind, coalesce +): + probe = BoundaryProbe(executor, boundary, lambda: cancel_dependency(executor, kind), coalesce=coalesce) + with probe.cache.enable(): + caller = executor.task(probe.call()) + executor.drain() + assert caller.cancelling() == 0 + assert caller.result() is probe.source_value + assert len(probe.sources) == 1 + assert len(probe.reads) == (boundary != "policy") + assert len(probe.dumps) == (boundary in ("load", "dump", "write")) + assert len(probe.writes) == (boundary in ("load", "write")) + assert len(probe.completed_writes) == (boundary == "load") + assert [e["error"] for e in probe.events if e["event"] == "error"] == [ + { + "policy": "config_resolution", + "read": "cache_read", + "load": "serialization_load", + "dump": "serialization_dump", + "write": "cache_write", + }[boundary] + ] + # Failed policy resolution disables caching. Later phases still permit + # this untracked value to populate the healthy local layer. + assert executor.finish(probe.call()) is probe.source_value + assert len(probe.sources) == (2 if boundary == "policy" else 1) + assert probe.entered == [boundary] * (2 if boundary == "policy" else 1) + assert probe.cache.get_coalescing_state()["process"]["active_leaders"] == 0 + assert not asyncio.all_tasks(executor.loop) + + +@pytest.mark.parametrize("boundary", ["policy", "read", "load", "dump", "write"]) +def test_real_unshared_caller_cancellation_during_dependency_propagates(executor, boundary): + gate = executor.future() + probe = BoundaryProbe(executor, boundary, lambda: gate, coalesce=False) + with probe.cache.enable(): + caller = executor.task(probe.call()) + executor.drain() + assert probe.entered == [boundary] + assert not caller.done() + caller.cancel() + executor.drain() + assert caller.cancelled() + assert caller.cancelling() == 1 + assert len(probe.sources) == (boundary in ("dump", "write")) + assert probe.completed_writes == [] + if boundary == "read": + # The raw adapter operation retains its established independent + # lifetime, but its late result cannot publish for this caller. + assert not gate.cancelled() + gate.set_result(Miss("value_absent")) + executor.drain() + else: + assert gate.cancelled() + assert probe.completed_writes == [] + sources_before_retry = len(probe.sources) + probe.boundary = None + assert executor.finish(probe.call()) is probe.source_value + assert len(probe.sources) == sources_before_retry + 1 + assert len(probe.completed_writes) == 1 + assert not asyncio.all_tasks(executor.loop) + + +@pytest.mark.parametrize("coalesce", [False, True], ids=["unshared", "shared"]) +@pytest.mark.parametrize("enabled", [False, True], ids=["disabled", "enabled"]) +@pytest.mark.parametrize("kind", ["future", "raised", "pending"]) +def test_source_cancellation_propagates_and_never_publishes(executor, coalesce, enabled, kind): + probe = BoundaryProbe(executor, None, lambda: None, coalesce=coalesce) + gate = executor.future() + + def source(): + probe.sources.append(probe.source_value) + return gate if kind == "pending" else cancel_dependency(executor, kind) + + probe.source = source + with probe.cache.enable(enabled): + caller = executor.task(probe.call()) + executor.drain() + if kind == "pending": + assert not caller.done() + gate.cancel() + executor.drain() + assert caller.cancelling() == 0 + with pytest.raises(asyncio.CancelledError): + caller.result() + assert probe.sources == [probe.source_value] + assert probe.dumps == probe.writes == [] + assert len(probe.reads) == enabled + assert probe.cache.get_coalescing_state()["process"]["active_leaders"] == 0 + probe.source = lambda: probe.sources.append(probe.source_value) or probe.source_value + assert executor.finish(probe.call()) is probe.source_value + assert len(probe.sources) == 2 + assert len(probe.completed_writes) == enabled + assert not asyncio.all_tasks(executor.loop) + + +@pytest.mark.parametrize("cancel_leader", [False, True], ids=["cancel-follower", "cancel-first"]) +@pytest.mark.parametrize("boundary", ["read", "load", "dump", "write"]) +def test_cancelling_shared_waiter_preserves_dependency_for_survivor(executor, boundary, cancel_leader): + gate = executor.future() + probe = BoundaryProbe(executor, boundary, lambda: gate, coalesce=True) + with probe.cache.enable(): + first = executor.task(probe.call()) + executor.drain() + # Distinct request contexts still join the same process work. + with probe.cache.enable(): + second = executor.task(probe.call()) + executor.drain() + assert probe.entered == [boundary] + assert probe.cache.get_coalescing_state()["process"]["active_followers"] == 1 + cancelled, survivor = (first, second) if cancel_leader else (second, first) + cancelled.cancel() + executor.drain() + assert cancelled.cancelled() + assert not survivor.done() + assert not gate.cancelled() + # The documented follower count remains until leader settlement. + assert probe.cache.get_coalescing_state()["process"]["active_followers"] == 1 + gate.set_result( + { + "read": Miss("value_absent"), + "load": probe.cached_value, + "dump": json.dumps(probe.source_value), + "write": None, + }[boundary] + ) + executor.drain() + expected = probe.cached_value if boundary == "load" else probe.source_value + assert survivor.result() is expected + assert executor.finish(probe.call()) is expected + assert len(probe.sources) == (boundary != "load") + assert len(probe.completed_writes) == (boundary != "load") + assert probe.cache.get_coalescing_state()["process"]["active_leaders"] == 0 + assert not asyncio.all_tasks(executor.loop) + + +@pytest.mark.parametrize("boundary", ["read", "load", "dump", "write"]) +def test_cancelled_pending_dependency_fails_open_for_both_shared_waiters(executor, boundary): + gate = executor.future() + probe = BoundaryProbe(executor, boundary, lambda: gate, coalesce=True) + with probe.cache.enable(): + first = executor.task(probe.call()) + executor.drain() + second = executor.task(probe.call()) + executor.drain() + assert probe.entered == [boundary] + assert probe.cache.get_coalescing_state()["process"]["active_followers"] == 1 + gate.cancel() + executor.drain() + assert first.cancelling() == second.cancelling() == 0 + assert first.result() is second.result() is probe.source_value + assert executor.finish(probe.call()) is probe.source_value + assert len(probe.sources) == 1 + assert len(probe.writes) == (boundary in ("load", "write")) + assert len(probe.completed_writes) == (boundary == "load") + assert probe.cache.get_coalescing_state()["process"]["active_leaders"] == 0 + assert not asyncio.all_tasks(executor.loop) + + +def retained_recovery(executor, decode, *, coalesce): + failure = ValueError("original source failure") + sources, decodes, writes, events = [], [], [], [] + + class Redis: + def read(self, request, context): + return Frame(int(executor.clock.wall_ms()) - 2000, '"stale"') + + def write(self, request): + writes.append(request) + + class Serializer: + def load(self, payload): + decodes.append(payload) + return decode() + + def source(): + sources.append(failure) + raise failure + + cache = DialCache( + clock=executor.clock, + redis=Redis(), + serializer=Serializer(), + metrics=events.append, + should_attempt_stale_recovery=lambda error: error is failure, + ) + with cache.enable(): + caller = executor.task( + cache.get_or_load( + source, + key="id", + key_type="entity", + use_case="retained-decode", + default_config=Policy( + ttl_sec={"local": 1, "remote": 1}, + stale_on_error_max_age_sec=10, + coalesce=coalesce, + ), + ) + ) + executor.drain() + assert sources == [failure] + assert decodes == ['"stale"'] + return caller, cache, failure, writes, events + + +@pytest.mark.parametrize("coalesce", [False, True], ids=["unshared", "shared"]) +@pytest.mark.parametrize("kind", ["future", "raised", "pending"]) +def test_independently_cancelled_retained_decode_preserves_original_error(executor, coalesce, kind): + gate = executor.future() + caller, cache, failure, writes, events = retained_recovery( + executor, lambda: gate if kind == "pending" else cancel_dependency(executor, kind), coalesce=coalesce + ) + if kind == "pending": + assert not caller.done() + gate.cancel() + executor.drain() + assert caller.cancelling() == 0 + with pytest.raises(ValueError) as caught: + caller.result() + assert caught.value is failure + assert writes == [] + assert [e["outcome"] for e in events if e["event"] == "staleRecovery"] == ["deserialization_error"] + assert cache.get_coalescing_state()["process"]["active_leaders"] == 0 + assert not asyncio.all_tasks(executor.loop) + + +def test_real_caller_cancellation_during_retained_decode_is_not_replaced_by_source_error(executor): + gate = executor.future() + caller, cache, failure, writes, events = retained_recovery(executor, lambda: gate, coalesce=False) + assert not caller.done() + caller.cancel() + executor.drain() + with pytest.raises(asyncio.CancelledError): + caller.result() + assert caller.cancelling() == 1 + assert gate.cancelled() + assert writes == [] + assert not [e for e in events if e["event"] == "staleRecovery"] + assert cache.get_coalescing_state()["process"]["active_leaders"] == 0 + assert not asyncio.all_tasks(executor.loop) diff --git a/python/tests/test_protocol_native.py b/python/tests/test_protocol_native.py index e5c52970..66c0e681 100644 --- a/python/tests/test_protocol_native.py +++ b/python/tests/test_protocol_native.py @@ -18,6 +18,7 @@ Miss, RedisPayloadError, RedisProtocolError, + compress_payload, decode_read, decode_tracked_read, decompress_payload, @@ -258,3 +259,40 @@ def test_known_oversized_decode_does_not_retain_unusable_output(): # The frame header already rules out returning decoded bytes. Classification # must use bounded chunks rather than retaining approximately the full cap. assert peak < 2 * 1024 * 1024 + + +@pytest.mark.parametrize("text,normalized", [("雪", "雪"), ("\ud83d\ude00", "😀"), ("\ud800", "\ufffd")]) +def test_text_compression_uses_normalized_byte_sizes_without_changing_raw_text(text, normalized): + payload, decoded = text * 4096, normalized * 4096 + size = len(decoded.encode("utf-8")) + result = compress_payload(payload, threshold_bytes=1, maximum=size) + assert result.outcome == "compressed" + assert result.original_bytes == size + assert result.stored_bytes == len(result.payload) + assert decompress_payload(result.payload).payload == decoded + for threshold, maximum, outcome in [ + (size + 1, size, "below_threshold"), + (1, size - 1, "write_over_limit"), + ]: + result = compress_payload(payload, threshold_bytes=threshold, maximum=maximum) + assert result.outcome == outcome + assert result.payload is payload + assert result.original_bytes == result.stored_bytes == size + + +def test_text_compression_does_not_allocate_redundant_full_payload_buffers(): + import tracemalloc + + payload = "abcd" * (1024 * 1024) + tracemalloc.start() + try: + result = compress_payload(payload) + _, peak = tracemalloc.get_traced_memory() + finally: + tracemalloc.stop() + assert result.outcome == "compressed" + assert decompress_payload(result.payload).payload == payload + # Normalizing text needs temporary UTF-16/UTF-8 buffers. Re-encoding while + # retaining a previous full buffer used over 4x the input; allow headroom + # above one normalization's 3x peak without allowing that extra copy. + assert peak < len(payload) * 7 // 2