diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4a937e470..77487768e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -570,6 +570,101 @@ jobs: conclusion: ${{ job.status }} github_token: ${{ secrets.GITHUB_TOKEN }} + tests_pavex_session_redis_linux: + name: "Tests for `pavex_session_redis` (linux)" + # Jobs with Dockerized `services` can only be run on Linux + runs-on: ubuntu-latest + # Extra services that must spun up before this crate can be tested + services: + redis: + image: redis:8 + ports: + - "56379:6379" + options: >- + --health-cmd="redis-cli ping || exit 1" + --health-interval=5s + --health-timeout=5s + --health-retries=5 + needs: + - build_clis_linux + + permissions: + pull-requests: write + checks: write + # Run if it's a PR on the official repo or a push to `main` + if: | + (github.event_name == 'repository_dispatch' && + github.event.client_payload.slash_command.args.named.sha != '' && + contains( + github.event.client_payload.pull_request.head.sha, + github.event.client_payload.slash_command.args.named.sha + )) || + (github.event_name == 'push' && github.event.repository.full_name == github.repository) + steps: + - name: Checkout repository from source repo + if: | + (github.event_name == 'push' && github.event.repository.full_name == github.repository) + uses: actions/checkout@v4 + - name: Checkout repository from fork + if: | + github.event_name == 'repository_dispatch' && + github.event.client_payload.slash_command.args.named.sha != '' && + contains( + github.event.client_payload.pull_request.head.sha, + github.event.client_payload.slash_command.args.named.sha + ) + uses: actions/checkout@v4 + with: + ref: "refs/pull/${{ github.event.client_payload.pull_request.number }}/merge" + - uses: ./.github/actions/create-check + if: ${{ github.event_name != 'push' }} + with: + pr_number: ${{ github.event.client_payload.pull_request.number }} + job: Tests pavex_session_redis (linux) + workflow: "Build and store docs artifacts" + run_id: ${{ github.run_id }} + github_token: ${{ secrets.GITHUB_TOKEN }} + - name: Install Rust stable toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1.8.0 + with: + components: rustfmt + rustflags: "" + cache-workspaces: "./libs -> ./target" + - name: Download pavex CLI artifact + uses: actions/download-artifact@v4 + with: + name: pavex_cli_linux + path: ~/.cargo/bin + - name: Download pavexc CLI artifact + uses: actions/download-artifact@v4 + with: + name: pavexc_cli_linux + path: ~/.cargo/bin + - name: Mark pavex as executable + env: + PAVEX: /home/runner/.cargo/bin/pavex + PAVEXC: /home/runner/.cargo/bin/pavexc + run: | + chmod +x ${{ env.PAVEX }} + chmod +x ${{ env.PAVEXC }} + - name: Activate pavex + env: + PAVEX_ACTIVATION_KEY: ${{ secrets.pavex_activation_key }} + run: | + pavex self activate + pavexc self setup + - name: Run tests + working-directory: libs + run: | + cargo test --all-features --package pavex_session_redis + - uses: ./.github/actions/finalize-check + if: ${{ always() && github.event_name != 'push' }} + with: + pr_number: ${{ github.event.client_payload.pull_request.number }} + job: Tests pavex_session_redis (linux) + conclusion: ${{ job.status }} + github_token: ${{ secrets.GITHUB_TOKEN }} + tests_linux: name: "Run tests (linux)" @@ -645,7 +740,7 @@ jobs: - name: Run tests working-directory: libs run: | - cargo test --all-features --workspace --exclude="pavex_cli" --exclude="pavex_macros" --exclude="pavex_session_sqlx" + cargo test --all-features --workspace --exclude="pavex_cli" --exclude="pavex_macros" --exclude="pavex_session_sqlx" --exclude="pavex_session_redis" - uses: ./.github/actions/finalize-check if: ${{ always() && github.event_name != 'push' }} with: @@ -881,7 +976,7 @@ jobs: - name: Run tests working-directory: libs run: | - cargo test --all-features --workspace --exclude="pavex_cli" --exclude="pavex_macros" --exclude="pavex_session_sqlx" + cargo test --all-features --workspace --exclude="pavex_cli" --exclude="pavex_macros" --exclude="pavex_session_sqlx" --exclude="pavex_session_redis" - uses: ./.github/actions/finalize-check if: ${{ always() && github.event_name != 'push' }} with: @@ -1111,7 +1206,7 @@ jobs: - name: Run tests working-directory: libs run: | - cargo test --all-features --workspace --exclude="pavex_cli" --exclude="pavex_macros" --exclude="pavex_session_sqlx" + cargo test --all-features --workspace --exclude="pavex_cli" --exclude="pavex_macros" --exclude="pavex_session_sqlx" --exclude="pavex_session_redis" - uses: ./.github/actions/finalize-check if: ${{ always() && github.event_name != 'push' }} with: diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index f6e79156a..0d6a110aa 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -18,3 +18,4 @@ We contributors to Pavex: - Donmai (@donmai-me) - Leon Qadirie (@leonqadirie) - Oliver Barnes (@oliverbarnes) +- Joe Hasson (@joehasson) diff --git a/docs/tools/tutorial_generator/Cargo.lock b/docs/tools/tutorial_generator/Cargo.lock index 9f2b9f477..24bb7344d 100644 --- a/docs/tools/tutorial_generator/Cargo.lock +++ b/docs/tools/tutorial_generator/Cargo.lock @@ -13,27 +13,27 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.82" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f538837af36e6f6a9be0faa67f9a314f8119e4e4b5867c6ab40ed60360142519" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" [[package]] name = "autocfg" -version = "1.2.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "bitflags" -version = "2.5.0" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" [[package]] name = "bstr" -version = "1.9.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05efc5cfd9110c8416e471df0e96702d58690178e206e61b7173706673c93706" +checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" dependencies = [ "memchr", "serde", @@ -41,37 +41,37 @@ dependencies = [ [[package]] name = "camino" -version = "1.1.6" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59e92b5a388f549b863a7bea62612c09f24c8393560709a54558a9abdfb3b9c" +checksum = "5d07aa9a93b00c76f71bc35d598bed923f6d4f3a9ca5c24b7737ae1a292841c0" dependencies = [ "serde", ] [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "console" -version = "0.15.8" +version = "0.15.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" dependencies = [ "encode_unicode", - "lazy_static", "libc", + "once_cell", "unicode-width", - "windows-sys", + "windows-sys 0.59.0", ] [[package]] name = "crossbeam-deque" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -88,43 +88,43 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.19" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "dunce" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "encode_unicode" -version = "0.3.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "equivalent" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.8" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.60.2", ] [[package]] name = "fastrand" -version = "2.0.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fs-err" @@ -137,9 +137,9 @@ dependencies = [ [[package]] name = "fsio" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad0ce30be0cc441b325c5d705c8b613a0ca0d92b6a8953d41bd236dc09a36d0" +checksum = "f4944f16eb6a05b4b2b79986b4786867bb275f52882adea798f17cc2588f25b2" dependencies = [ "dunce", "rand", @@ -147,20 +147,21 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.14" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", "libc", + "r-efi", "wasi", ] [[package]] name = "globset" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" +checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" dependencies = [ "aho-corasick", "bstr", @@ -182,15 +183,15 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.14.3" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" [[package]] name = "ignore" -version = "0.4.22" +version = "0.4.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1" +checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" dependencies = [ "crossbeam-deque", "globset", @@ -204,9 +205,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.2.6" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" dependencies = [ "equivalent", "hashbrown", @@ -214,86 +215,94 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" - -[[package]] -name = "lazy_static" -version = "1.4.0" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "libc" -version = "0.2.153" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "linux-raw-sys" -version = "0.4.13" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" [[package]] name = "log" -version = "0.4.21" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "memchr" -version = "2.7.2" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "once_cell" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "pathdiff" -version = "0.2.1" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "ppv-lite86" -version = "0.2.17" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] [[package]] name = "proc-macro2" -version = "1.0.81" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.36" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" -version = "0.8.5" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "libc", "rand_chacha", "rand_core", ] [[package]] name = "rand_chacha" -version = "0.3.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", "rand_core", @@ -301,18 +310,18 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.6.4" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ "getrandom", ] [[package]] name = "regex-automata" -version = "0.4.6" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", @@ -321,9 +330,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "run_script" @@ -336,22 +345,22 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.34" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.60.2", ] [[package]] name = "ryu" -version = "1.0.17" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "same-file" @@ -364,18 +373,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.198" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9846a40c979031340571da2545a4e5b7c4163bdae79b301d5f86d03979451fcc" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.198" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e88edab869b01783ba905e7d0153f9fc1a6505a96e4ad3018011eedb838566d9" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", @@ -384,9 +393,9 @@ dependencies = [ [[package]] name = "serde_path_to_error" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" dependencies = [ "itoa", "serde", @@ -407,15 +416,15 @@ dependencies = [ [[package]] name = "similar" -version = "2.5.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42c91313f1d05da9b26f267f931cf178d4aba455b4c4622dd7355eb80c6640" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "syn" -version = "2.0.60" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "909518bc7b1c9b779f1bbf07f2929d35af9f0f37e47c6e9ef7f9dddc1e1821f3" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -424,14 +433,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.10.1" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" dependencies = [ - "cfg-if", "fastrand", + "getrandom", + "once_cell", "rustix", - "windows-sys", + "windows-sys 0.60.2", ] [[package]] @@ -454,15 +464,15 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" [[package]] name = "unicode-width" -version = "0.1.11" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" [[package]] name = "unsafe-libyaml" @@ -482,88 +492,200 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.14.2+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] [[package]] name = "winapi-util" -version = "0.1.8" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0978bf7171b3d90bac376700cb56d606feb40f251a475a5d6634613564460b22" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-sys" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-sys", + "windows-targets 0.52.6", ] [[package]] name = "windows-sys" -version = "0.52.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.3", ] [[package]] name = "windows-targets" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", ] [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.5" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" -version = "0.52.5" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" -version = "0.52.5" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" -version = "0.52.5" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" [[package]] name = "windows_i686_msvc" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" [[package]] name = "windows_x86_64_gnu" -version = "0.52.5" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.5" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" [[package]] name = "windows_x86_64_msvc" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "zerocopy" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/libs/Cargo.lock b/libs/Cargo.lock index bacfc7257..82ece75bf 100644 --- a/libs/Cargo.lock +++ b/libs/Cargo.lock @@ -161,6 +161,12 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + [[package]] name = "arraydeque" version = "0.5.1" @@ -223,6 +229,15 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "backon" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd0b50b1b78dbadd44ab18b3c794e496f3a139abb9fbc27d9c94c4eebbb96496" +dependencies = [ + "fastrand", +] + [[package]] name = "backtrace" version = "0.3.75" @@ -554,6 +569,20 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -2784,6 +2813,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "pavex_session_redis" +version = "0.2.4" +dependencies = [ + "anyhow", + "async-trait", + "pavex", + "pavex_session", + "redis", + "serde", + "serde_json", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "pavex_session_sqlx" version = "0.2.4" @@ -3479,6 +3524,31 @@ dependencies = [ "serde", ] +[[package]] +name = "redis" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bc1ea653e0b2e097db3ebb5b7f678be339620b8041f66b30a308c1d45d36a7f" +dependencies = [ + "arc-swap", + "backon", + "bytes", + "cfg-if", + "combine", + "futures-channel", + "futures-util", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.5.10", + "tokio", + "tokio-util", + "url", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -4053,6 +4123,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" diff --git a/libs/Cargo.toml b/libs/Cargo.toml index 46416e595..627c117fd 100644 --- a/libs/Cargo.toml +++ b/libs/Cargo.toml @@ -2,7 +2,6 @@ members = [ "./pavex*", "generate_from_path", - "pavexc_attr_parser", "persist_if_changed", "px_workspace_hack", ] @@ -83,6 +82,11 @@ r2d2 = "0.8" r2d2_sqlite = "0.25.0" rayon = "1.10" redact = "0.1.11" +redis = { version = "0.31.0", features = [ + "tokio-comp", + "aio", + "connection-manager", +] } regex = "1.11.1" relative-path = "2.0" remove_dir_all = "1" diff --git a/libs/pavex_session_redis/.github/services.yml b/libs/pavex_session_redis/.github/services.yml new file mode 100644 index 000000000..53c727efa --- /dev/null +++ b/libs/pavex_session_redis/.github/services.yml @@ -0,0 +1,11 @@ +# Extra services that must spun up before this crate can be tested +services: + redis: + image: redis:8 + ports: + - "56379:6379" + options: >- + --health-cmd="redis-cli ping || exit 1" + --health-interval=5s + --health-timeout=5s + --health-retries=5 diff --git a/libs/pavex_session_redis/CONTRIBUTING.md b/libs/pavex_session_redis/CONTRIBUTING.md new file mode 100644 index 000000000..598e1a9e5 --- /dev/null +++ b/libs/pavex_session_redis/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Contributing Guide + +This library relies on Redis for integration tests.\ +Before running the test suite, you must have the database up and running. + +--- + +## Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) +- [Docker Compose](https://docs.docker.com/compose/install/) + +--- + +## Running the Test Databases + +We provide a `docker-compose.yml` that starts Redis with predictable settings. + +Start the services: + +```sh +docker compose up -d +``` + +This will launch Redis (container: `test-pavex-session-redis`). + +You can check its status with: + +```sh +docker compose ps +``` + +and stop it when done with: + +```sh +docker compose down +``` + +## Running Tests + +Once the database is running, you can run the Rust test suite: + +```sh +cargo test +``` + +The test code is configured to connect to `redis://127.0.0.1:56379`. + +## Tips + +- If you need to reset the databases, simply run: + ```sh + docker compose down -v + docker compose up -d + ``` +- The containers have health checks configured, so they may take a few seconds before being ready. + If you see connection errors, wait a few seconds and try again. diff --git a/libs/pavex_session_redis/Cargo.toml b/libs/pavex_session_redis/Cargo.toml new file mode 100644 index 000000000..08a0bf9c7 --- /dev/null +++ b/libs/pavex_session_redis/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "pavex_session_redis" +edition.workspace = true +repository.workspace = true +homepage.workspace = true +license.workspace = true +version.workspace = true + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +pavex = { version = "0.2.4", path = "../pavex" } +pavex_session = { version = "0.2.4", path = "../pavex_session" } +redis = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +uuid = { workspace = true } diff --git a/libs/pavex_session_redis/docker-compose.yml b/libs/pavex_session_redis/docker-compose.yml new file mode 100644 index 000000000..abceffdfa --- /dev/null +++ b/libs/pavex_session_redis/docker-compose.yml @@ -0,0 +1,17 @@ +version: "3.9" + +services: + redis: + image: redis:8 + container_name: test-pavex-session-redis + ports: + - "56379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + +networks: + default: + name: test-session-redis diff --git a/libs/pavex_session_redis/src/lib.rs b/libs/pavex_session_redis/src/lib.rs new file mode 100644 index 000000000..551e7a7c2 --- /dev/null +++ b/libs/pavex_session_redis/src/lib.rs @@ -0,0 +1,334 @@ +#![deny(missing_docs)] +//! A Redis-based session store for [`pavex_session`](https://crates.io/crates/pavex_session), +//! implemented using the [`redis`](https://crates.io/crates/redis) crate. +use anyhow::Context; +use pavex::{config, methods}; +use pavex_session::{ + SessionId, SessionStore, + store::{ + SessionRecord, SessionRecordRef, SessionStorageBackend, + errors::{ + ChangeIdError, CreateError, DeleteError, DeleteExpiredError, DuplicateIdError, + LoadError, UnknownIdError, UpdateError, UpdateTtlError, + }, + }, +}; +use redis::{AsyncCommands, ExistenceCheck, SetExpiry, SetOptions, Value, aio::ConnectionManager}; +use std::num::NonZeroUsize; + +#[config(key = "redis_session_store", default_if_missing)] +#[derive(Clone, Debug, Default, serde::Deserialize)] +/// Configuration options for the Redis session store. +pub struct RedisSessionStoreConfig { + /// Optional namespace prefix for Redis keys. When set, all session keys will be prefixed with this value. + /// + /// Namespacing allows multiple applications to share the same Redis instance without interfering with each other. + /// + /// # Example + /// + /// If `namespace` is set to `myapp` and the session key is `12345`, then + /// the session state will be stored in Redis using the key `myapp:12345`. + #[serde(default)] + pub namespace: Option, +} + +#[derive(Clone)] +/// A server-side session store using Redis as its backend. +/// +/// # Implementation details +/// +/// This store uses the `redis` crate to interact with Redis. All session records are stored as individual +/// Redis keys with TTL set for automatic expiration. If the `namespace` value in [`RedisSessionStoreConfig`] +/// is `Some`, then all session keys are stored prefixed with this string, allowing multiple applications +/// to share the same Redis instance. +pub struct RedisSessionStore { + connection: ConnectionManager, + config: RedisSessionStoreConfig, +} + +#[methods] +impl From for SessionStore { + #[singleton] + fn from(s: RedisSessionStore) -> Self { + SessionStore::new(s) + } +} + +impl std::fmt::Debug for RedisSessionStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RedisSessionStore") + .field("connection", &"") + .field("config", &self.config) + .finish() + } +} + +#[methods] +impl RedisSessionStore { + /// Creates a new Redis session store instance. + /// + /// You must provide a connection as well as configuration. + #[singleton] + pub fn new(connection: ConnectionManager, config: RedisSessionStoreConfig) -> Self { + Self { connection, config } + } +} + +impl RedisSessionStore { + fn redis_key(&self, id: &SessionId) -> String { + if let Some(namespace) = &self.config.namespace { + format!("{}:{}", namespace, id.inner()) + } else { + id.inner().to_string() + } + } +} + +fn err_unknown(id: &SessionId) -> UnknownIdError { + UnknownIdError { id: id.to_owned() } +} + +fn err_duplicate(id: &SessionId) -> DuplicateIdError { + DuplicateIdError { id: id.to_owned() } +} + +fn redis_value_type_name(value: Value) -> &'static str { + match value { + Value::Nil => "Nil", + Value::Okay => "Okay", + Value::Int(_) => "Int", + Value::BulkString(_) => "BulkString", + Value::Array(_) => "Array", + Value::SimpleString(_) => "SimpleString", + Value::Map(_) => "Map", + Value::Set(_) => "Set", + Value::Attribute { .. } => "Attribute", + Value::Double(_) => "Double", + Value::Boolean(_) => "Boolean", + Value::VerbatimString { .. } => "VerbatimString", + Value::BigNumber(_) => "BigNumber", + Value::Push { .. } => "Push", + Value::ServerError(_) => "ServerError", + } +} + +#[async_trait::async_trait] +impl SessionStorageBackend for RedisSessionStore { + /// Creates a new session record in the store using the provided ID. + #[tracing::instrument(name = "Create server-side session record", level = tracing::Level::INFO, skip_all)] + async fn create( + &self, + id: &SessionId, + record: SessionRecordRef<'_>, + ) -> Result<(), CreateError> { + match self + .connection + .clone() + .set_options( + self.redis_key(id), + serde_json::to_vec(&record.state)?, + SetOptions::default() + .conditional_set(ExistenceCheck::NX) + .with_expiration(SetExpiry::EX(record.ttl.as_secs())), + ) + .await + .map_err(|e| CreateError::Other(e.into()))? + { + Value::Okay => Ok(()), + Value::Nil => Err(err_duplicate(id).into()), + val => Err(CreateError::Other(anyhow::anyhow!( + "Redis SET replied with {:?}. Expected Okay or Nil", + val + ))), + } + } + + /// Update the state of an existing session in the store. + /// + /// It overwrites the existing record with the provided one. + #[tracing::instrument(name = "Update server-side session record", level = tracing::Level::INFO, skip_all)] + async fn update( + &self, + id: &SessionId, + record: SessionRecordRef<'_>, + ) -> Result<(), UpdateError> { + match self + .connection + .clone() + .set_options( + self.redis_key(id), + serde_json::to_vec(&record.state)?, + SetOptions::default() + .conditional_set(ExistenceCheck::XX) + .with_expiration(SetExpiry::EX(record.ttl.as_secs())), + ) + .await + .map_err(|e| UpdateError::Other(e.into()))? + { + Value::Okay => Ok(()), + Value::Nil => Err(err_unknown(id).into()), + val => Err(UpdateError::Other(anyhow::anyhow!( + "Redis SET returned {:?}. Expected Okay or Nil", + val + ))), + } + } + + /// Update the TTL of an existing session record in the store. + /// + /// It leaves the session state unchanged. + #[tracing::instrument(name = "Update TTL for server-side session record", level = tracing::Level::INFO, skip_all)] + async fn update_ttl( + &self, + id: &SessionId, + ttl: std::time::Duration, + ) -> Result<(), UpdateTtlError> { + let k = self.redis_key(id); + let mut conn = self.connection.clone(); + match redis::pipe() + .cmd("EXISTS") + .arg(&k) + .cmd("EXPIRE") + .arg(&k) + .arg(ttl.as_secs()) + .query_async(&mut conn) + .await + .map_err(|e| UpdateTtlError::Other(e.into()))? + { + (1, 1) => Ok(()), + (0, 0) => Err(err_unknown(id).into()), + (1, 0) => Err(UpdateTtlError::Other(anyhow::anyhow!( + "Session key exists but redis failed to update TTL" + ))), + (0, 1) => Err(UpdateTtlError::Other(anyhow::anyhow!( + "Unexpected reply from redis: redis should not report successfully setting ttl for non-existent key" + ))), + (v, w) => Err(UpdateTtlError::Other(anyhow::anyhow!( + "Unexpected reply from redis: EXISTS and EXPIRE only return 0 or 1. EXIST returned {:?}, EXPIRE returned {:?}", + v, + w + ))), + } + } + + /// Loads an existing session record from the store using the provided ID. + /// + /// If a session with the given ID exists, it is returned. If the session + /// does not exist or has been invalidated (e.g., expired), `None` is + /// returned. + #[tracing::instrument(name = "Load server-side session record", level = tracing::Level::INFO, skip_all)] + async fn load(&self, session_id: &SessionId) -> Result, LoadError> { + let mut conn = self.connection.clone(); + let k = self.redis_key(session_id); + let (ttl_reply, get_reply): (Value, Value) = redis::pipe() + .cmd("TTL") + .arg(&k) + .cmd("GET") + .arg(&k) + .query_async(&mut conn) + .await + .map_err(|e| LoadError::Other(e.into()))?; + + let ttl = match ttl_reply { + Value::Int(s) if s >= 0 => std::time::Duration::from_secs(s as u64), + Value::Int(-1) => { + return Err(LoadError::Other(anyhow::anyhow!( + "Fatal session management error: no TTL set for this session." + ))); + } + Value::Int(-2) => return Ok(None), + _ => { + return Err(LoadError::Other(anyhow::anyhow!( + "Redis TTL returned {}. Expected integer >= -2", + redis_value_type_name(ttl_reply) + ))); + } + }; + + let state = match get_reply { + Value::BulkString(raw) => serde_json::from_slice(&raw) + .context("Failed to deserialize the retrieved session state") + .map_err(LoadError::DeserializationError)?, + _ => { + return Err(LoadError::Other(anyhow::anyhow!( + "Redis GET replied {}. Expected BulkString.", + redis_value_type_name(get_reply) + ))); + } + }; + + Ok(Some(SessionRecord { ttl, state })) + } + + /// Deletes a session record from the store using the provided ID. + /// + /// If the session exists, it is removed from the store. + #[tracing::instrument(name = "Delete server-side session record", level = tracing::Level::INFO, skip_all)] + async fn delete(&self, id: &SessionId) -> Result<(), DeleteError> { + let ndeleted: u64 = self + .connection + .clone() + .del(self.redis_key(id)) + .await + .map_err(|e| DeleteError::Other(e.into()))?; + + if ndeleted == 1 { + Ok(()) + } else if ndeleted == 0 { + Err(err_unknown(id).into()) + } else { + return Err(DeleteError::Other(anyhow::anyhow!( + "Redis DEL replied {:?}. Expected 0 or 1.", + ndeleted + ))); + } + } + + /// Change the session id associated with an existing session record. + /// + /// The server-side state is left unchanged. + #[tracing::instrument(name = "Change id for server-side session record", level = tracing::Level::INFO, skip_all)] + async fn change_id(&self, old_id: &SessionId, new_id: &SessionId) -> Result<(), ChangeIdError> { + // Atomically check whether a key exists and then rename if it does. + const LUA_RENAME_IF_EXISTS: &str = r#" + if redis.call('EXISTS', KEYS[1]) == 1 then + -- returns 1 on success or 0 if fails because KEYS[2] already exists + return redis.call('RENAMENX', KEYS[1], KEYS[2]) + else + return -1 + end + "#; + + let script = redis::Script::new(LUA_RENAME_IF_EXISTS); + let mut conn = self.connection.clone(); + let old_key = self.redis_key(old_id); + let new_key = self.redis_key(new_id); + let result: i32 = script + .key(&old_key) + .key(&new_key) + .invoke_async(&mut conn) + .await + .map_err(|e| ChangeIdError::Other(e.into()))?; + + match result { + -1 => Err(err_unknown(old_id).into()), // Key didn't exist + 1 => Ok(()), // Successfully renamed + 0 => Err(err_duplicate(new_id).into()), // Key existed but new_key already exists (RENAMENX failed) + other => Err(ChangeIdError::Other(anyhow::anyhow!( + "Redis RENAMENX replied {:?}. Expected INT 0 or 1", + other + ))), + } + } + + /// Deletes expired session records from the store. + /// + /// Redis handles the deletion of expired keys automatically, so this method always + /// returns Ok(0). + async fn delete_expired( + &self, + _batch_size: Option, + ) -> Result { + Ok(0) + } +} diff --git a/libs/pavex_session_redis/tests/tests.rs b/libs/pavex_session_redis/tests/tests.rs new file mode 100644 index 000000000..60cff8a8f --- /dev/null +++ b/libs/pavex_session_redis/tests/tests.rs @@ -0,0 +1,664 @@ +use pavex_session::store::{SessionRecordRef, SessionStorageBackend}; +use pavex_session::{SessionId, store::errors::*}; +use pavex_session_redis::{RedisSessionStore, RedisSessionStoreConfig}; +use redis::aio::ConnectionManager; +use std::borrow::Cow; +use std::collections::HashMap; +use std::time::Duration; + +async fn test_redis_connection() -> ConnectionManager { + let client = redis::Client::open("redis://127.0.0.1:56379").unwrap(); + tokio::time::timeout( + Duration::from_secs(2), + redis::aio::ConnectionManager::new(client), + ) + .await + .expect("Failed to connect to Redis within 2 seconds - is Redis running on localhost:6379?") + .unwrap() +} + +async fn test_store() -> RedisSessionStore { + // Use random namespace to avoid test collisions + let config = RedisSessionStoreConfig { + namespace: Some(format!("test_{}", uuid::Uuid::new_v4())), + }; + + RedisSessionStore::new(test_redis_connection().await, config) +} + +fn create_test_record( + _ttl_secs: u64, +) -> (SessionId, HashMap, serde_json::Value>) { + let session_id = SessionId::random(); + let mut state = HashMap::new(); + state.insert( + Cow::Borrowed("user_id"), + serde_json::Value::String("test-user-123".to_string()), + ); + state.insert( + Cow::Borrowed("login_time"), + serde_json::Value::String("2024-01-01T00:00:00Z".to_string()), + ); + state.insert( + Cow::Borrowed("permissions"), + serde_json::json!(["read", "write"]), + ); + state.insert( + Cow::Borrowed("metadata"), + serde_json::json!({ + "ip": "192.168.1.1", + "user_agent": "test-agent", + "session_start": 1640995200 + }), + ); + (session_id, state) +} + +#[tokio::test] +async fn test_create_and_load_roundtrip() { + let store = test_store().await; + let (session_id, state) = create_test_record(3600); + + let record = SessionRecordRef { + state: Cow::Borrowed(&state), + ttl: Duration::from_secs(3600), + }; + + // Create session + store.create(&session_id, record).await.unwrap(); + + // Load session + let loaded = store.load(&session_id).await.unwrap(); + assert!(loaded.is_some()); + + let loaded_record = loaded.unwrap(); + + // Verify all data is preserved correctly by comparing with original + for (key, expected_value) in &state { + assert_eq!( + loaded_record.state.get(key).unwrap(), + expected_value, + "Mismatch for key: {}", + key + ); + } + + // Verify we have the same number of keys + assert_eq!(loaded_record.state.len(), state.len()); + + // Verify TTL is reasonable (should be close to 3600 seconds) + assert!(loaded_record.ttl.as_secs() > 3550); + assert!(loaded_record.ttl.as_secs() <= 3600); +} + +#[tokio::test] +async fn test_update_roundtrip() { + let store = test_store().await; + let (session_id, mut state) = create_test_record(3600); + + let record = SessionRecordRef { + state: Cow::Borrowed(&state), + ttl: Duration::from_secs(3600), + }; + + // Create initial session + store.create(&session_id, record).await.unwrap(); + + // Update the state + state.insert( + Cow::Borrowed("updated_field"), + serde_json::Value::String("new_value".to_string()), + ); + state.insert( + Cow::Borrowed("user_id"), + serde_json::Value::String("updated-user-456".to_string()), + ); + state.insert( + Cow::Borrowed("new_metadata"), + serde_json::json!({ + "last_action": "update_session", + "timestamp": 1640995260, + "complex_data": { + "nested": { + "deeply": ["nested", "array", 123, true] + } + } + }), + ); + + let updated_record = SessionRecordRef { + state: Cow::Borrowed(&state), + ttl: Duration::from_secs(7200), + }; + + // Update session + store.update(&session_id, updated_record).await.unwrap(); + + // Load and verify updates + let loaded = store.load(&session_id).await.unwrap().unwrap(); + + // Verify all updated data is preserved correctly by comparing with updated state + for (key, expected_value) in &state { + assert_eq!( + loaded.state.get(key).unwrap(), + expected_value, + "Mismatch for updated key: {}", + key + ); + } + + // Verify we have the same number of keys + assert_eq!(loaded.state.len(), state.len()); + + // Verify TTL was updated + assert!(loaded.ttl.as_secs() > 3600); +} + +#[tokio::test] +async fn test_ttl_expiry() { + let store = test_store().await; + let (session_id, state) = create_test_record(1); + + let record = SessionRecordRef { + state: Cow::Borrowed(&state), + ttl: Duration::from_secs(1), // Very short TTL + }; + + // Create session with short TTL + store.create(&session_id, record).await.unwrap(); + + // Session should exist immediately + let loaded = store.load(&session_id).await.unwrap(); + assert!(loaded.is_some()); + + // Wait for expiration + tokio::time::sleep(Duration::from_secs(2)).await; + + // Session should be expired and not loadable + let expired = store.load(&session_id).await.unwrap(); + assert!(expired.is_none()); +} + +#[tokio::test] +async fn test_update_ttl_roundtrip() { + let store = test_store().await; + let (session_id, state) = create_test_record(3600); + + let record = SessionRecordRef { + state: Cow::Borrowed(&state), + ttl: Duration::from_secs(3600), + }; + + // Create session + store.create(&session_id, record).await.unwrap(); + + // Update TTL only + store + .update_ttl(&session_id, Duration::from_secs(7200)) + .await + .unwrap(); + + // Verify TTL was updated but data preserved + let loaded = store.load(&session_id).await.unwrap().unwrap(); + + // Verify original data is preserved by comparing with original state + for (key, expected_value) in &state { + assert_eq!( + loaded.state.get(key).unwrap(), + expected_value, + "Mismatch for key after TTL update: {}", + key + ); + } + assert!(loaded.ttl.as_secs() > 3600); +} + +#[tokio::test] +async fn test_delete_roundtrip() { + let store = test_store().await; + let (session_id, state) = create_test_record(3600); + + let record = SessionRecordRef { + state: Cow::Borrowed(&state), + ttl: Duration::from_secs(3600), + }; + + // Create session + store.create(&session_id, record).await.unwrap(); + + // Verify it exists + let loaded = store.load(&session_id).await.unwrap(); + assert!(loaded.is_some()); + + // Delete session + store.delete(&session_id).await.unwrap(); + + // Verify it's gone + let deleted = store.load(&session_id).await.unwrap(); + assert!(deleted.is_none()); +} + +#[tokio::test] +async fn test_change_id_roundtrip() { + let store = test_store().await; + let (old_session_id, state) = create_test_record(3600); + let new_session_id = SessionId::random(); + + let record = SessionRecordRef { + state: Cow::Borrowed(&state), + ttl: Duration::from_secs(3600), + }; + + // Create session with old ID + store.create(&old_session_id, record).await.unwrap(); + + // Change ID + store + .change_id(&old_session_id, &new_session_id) + .await + .unwrap(); + + // Old ID should not exist + let old_session = store.load(&old_session_id).await.unwrap(); + assert!(old_session.is_none()); + + // New ID should have the data + let new_session = store.load(&new_session_id).await.unwrap(); + assert!(new_session.is_some()); + + let new_record = new_session.unwrap(); + + // Verify all data was transferred to new session ID + for (key, expected_value) in &state { + assert_eq!( + new_record.state.get(key).unwrap(), + expected_value, + "Mismatch for key after ID change: {}", + key + ); + } +} + +#[tokio::test] +async fn test_concurrent_operations() { + let store = test_store().await; + let mut handles = vec![]; + + // Create multiple concurrent sessions + for i in 0..10 { + let store_clone = store.clone(); + let handle = tokio::spawn(async move { + let (session_id, state) = create_test_record(3600); + let mut modified_state = state; + modified_state.insert( + Cow::Borrowed("thread_id"), + serde_json::Value::Number(i.into()), + ); + + let record = SessionRecordRef { + state: Cow::Borrowed(&modified_state), + ttl: Duration::from_secs(3600), + }; + + store_clone.create(&session_id, record).await.unwrap(); + + // Verify we can load it back and all data is preserved + let loaded = store_clone.load(&session_id).await.unwrap().unwrap(); + + // Compare against the modified state we created + for (key, expected_value) in &modified_state { + assert_eq!( + loaded.state.get(key).unwrap(), + expected_value, + "Mismatch for key {} in concurrent operation {}", + key, + i + ); + } + + session_id + }); + handles.push(handle); + } + + // Wait for all operations to complete + let mut session_ids = Vec::new(); + for handle in handles { + session_ids.push(handle.await.unwrap()); + } + + // Verify all sessions exist + for session_id in session_ids { + let loaded = store.load(&session_id).await.unwrap(); + assert!(loaded.is_some()); + } +} + +#[tokio::test] +async fn test_namespace_isolation() { + // Create stores with different namespaces + let conn = test_redis_connection().await; + let store_a = RedisSessionStore::new( + conn.clone(), + RedisSessionStoreConfig { + namespace: Some("a".to_string()), + }, + ); + let store_b = RedisSessionStore::new( + conn.clone(), + RedisSessionStoreConfig { + namespace: Some("b".to_string()), + }, + ); + let store_c = RedisSessionStore::new(conn.clone(), RedisSessionStoreConfig { namespace: None }); + + // Generate and store some session data in each store + let (session_a, state_a) = create_test_record(3600); + let record_a = SessionRecordRef { + state: Cow::Borrowed(&state_a), + ttl: Duration::from_secs(3600), + }; + let (session_b, state_b) = create_test_record(3600); + let record_b = SessionRecordRef { + state: Cow::Borrowed(&state_b), + ttl: Duration::from_secs(3600), + }; + let (session_c, state_c) = create_test_record(3600); + let record_c = SessionRecordRef { + state: Cow::Borrowed(&state_c), + ttl: Duration::from_secs(3600), + }; + + store_a.create(&session_a, record_a).await.unwrap(); + store_b.create(&session_b, record_b).await.unwrap(); + store_c.create(&session_c, record_c).await.unwrap(); + + // Each store should only see its own data + assert!(matches!(store_a.load(&session_a).await.unwrap(), Some(_))); + assert!(matches!(store_a.load(&session_b).await.unwrap(), None)); + assert!(matches!(store_a.load(&session_c).await.unwrap(), None)); + + assert!(matches!(store_b.load(&session_a).await.unwrap(), None)); + assert!(matches!(store_b.load(&session_b).await.unwrap(), Some(_))); + assert!(matches!(store_b.load(&session_c).await.unwrap(), None)); + + assert!(matches!(store_c.load(&session_a).await.unwrap(), None)); + assert!(matches!(store_c.load(&session_b).await.unwrap(), None)); + assert!(matches!(store_c.load(&session_c).await.unwrap(), Some(_))); +} + +// Unhappy path tests - Error scenarios + +#[tokio::test] +async fn test_create_duplicate_id_error() { + let store = test_store().await; + let (session_id, state) = create_test_record(3600); + + let record = SessionRecordRef { + state: Cow::Borrowed(&state), + ttl: Duration::from_secs(3600), + }; + + // Create initial session + store.create(&session_id, record).await.unwrap(); + + // Try to create another session with the same ID but different data + let (_, different_state) = create_test_record(7200); + let mut conflicting_state = different_state; + conflicting_state.insert( + Cow::Borrowed("conflict_field"), + serde_json::Value::String("this should conflict".to_string()), + ); + + let conflicting_record = SessionRecordRef { + state: Cow::Borrowed(&conflicting_state), + ttl: Duration::from_secs(1), // Short TTL to force conflict + }; + + // Verify the original session exists + let loaded = store.load(&session_id).await.unwrap(); + assert!(loaded.is_some()); + + // Verify that attempt to create a duplicate session returns an error + match store.create(&session_id, conflicting_record).await { + Err(CreateError::DuplicateId(_)) => (), + other => panic!("Expected CreateError::DuplicateId, got {:?}", other), + }; + + // Verify the original data is still there (not overwritten) + let loaded_after = store.load(&session_id).await.unwrap().unwrap(); + for (key, expected_value) in &state { + assert_eq!( + loaded_after.state.get(key).unwrap(), + expected_value, + "Original data should be preserved when session is not expired" + ); + } + + // Verify conflicting data was not written + assert!(loaded_after.state.get("conflict_field").is_none()); +} + +#[tokio::test] +async fn test_update_unknown_id_error() { + let store = test_store().await; + let non_existent_id = SessionId::random(); + let (_, state) = create_test_record(3600); + + let record = SessionRecordRef { + state: Cow::Borrowed(&state), + ttl: Duration::from_secs(3600), + }; + + // Try to update a session that doesn't exist + let result = store.update(&non_existent_id, record).await; + + assert!(result.is_err()); + match result.unwrap_err() { + pavex_session::store::errors::UpdateError::UnknownIdError(err) => { + assert!(err.id == non_existent_id); + } + other => panic!("Expected UnknownId error, got: {:?}", other), + } +} + +#[tokio::test] +async fn test_update_ttl_unknown_id_error() { + let store = test_store().await; + let non_existent_id = SessionId::random(); + + // Try to update TTL for a session that doesn't exist + let result = store + .update_ttl(&non_existent_id, Duration::from_secs(7200)) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + pavex_session::store::errors::UpdateTtlError::UnknownId(err) => { + assert!(err.id == non_existent_id); + } + other => panic!("Expected UnknownId error, got: {:?}", other), + } +} + +#[tokio::test] +async fn test_delete_unknown_id_error() { + let store = test_store().await; + let non_existent_id = SessionId::random(); + + // Try to delete a session that doesn't exist + let result = store.delete(&non_existent_id).await; + + assert!(result.is_err()); + match result.unwrap_err() { + pavex_session::store::errors::DeleteError::UnknownId(err) => { + assert!(err.id == non_existent_id); + } + other => panic!("Expected UnknownId error, got: {:?}", other), + } +} + +#[tokio::test] +async fn test_change_id_unknown_old_id_error() { + let store = test_store().await; + let non_existent_old_id = SessionId::random(); + let new_id = SessionId::random(); + + // Try to change ID for a session that doesn't exist + let result = store.change_id(&non_existent_old_id, &new_id).await; + + assert!(result.is_err()); + match result.unwrap_err() { + pavex_session::store::errors::ChangeIdError::UnknownId(err) => { + assert!(err.id == non_existent_old_id); + } + other => panic!("Expected UnknownId error, got: {:?}", other), + } +} + +#[tokio::test] +async fn test_change_id_duplicate_new_id_error() { + let store = test_store().await; + let (session_id_1, state_1) = create_test_record(3600); + let (session_id_2, state_2) = create_test_record(3600); + + // Create two different sessions + let record_1 = SessionRecordRef { + state: Cow::Borrowed(&state_1), + ttl: Duration::from_secs(3600), + }; + let record_2 = SessionRecordRef { + state: Cow::Borrowed(&state_2), + ttl: Duration::from_secs(3600), + }; + + store.create(&session_id_1, record_1).await.unwrap(); + store.create(&session_id_2, record_2).await.unwrap(); + + // Try to change session_id_1 to session_id_2 (which already exists) + let result = store.change_id(&session_id_1, &session_id_2).await; + + assert!(result.is_err()); + match result.unwrap_err() { + pavex_session::store::errors::ChangeIdError::DuplicateId(err) => { + assert!(err.id == session_id_2); + } + other => panic!("Expected DuplicateId error, got: {:?}", other), + } + + // Verify both original sessions still exist + assert!(store.load(&session_id_1).await.unwrap().is_some()); + assert!(store.load(&session_id_2).await.unwrap().is_some()); +} + +#[tokio::test] +async fn test_operations_on_expired_session() { + let store = test_store().await; + let (session_id, state) = create_test_record(1); + + let record = SessionRecordRef { + state: Cow::Borrowed(&state), + ttl: Duration::from_secs(1), // Very short TTL + }; + + // Create session with short TTL + store.create(&session_id, record).await.unwrap(); + + // Wait for expiration + tokio::time::sleep(Duration::from_secs(2)).await; + + // Try to update expired session - should return UnknownId error + let (_, new_state) = create_test_record(3600); + let new_record = SessionRecordRef { + state: Cow::Borrowed(&new_state), + ttl: Duration::from_secs(3600), + }; + + let update_result = store.update(&session_id, new_record).await; + assert!(update_result.is_err()); + match update_result.unwrap_err() { + pavex_session::store::errors::UpdateError::UnknownIdError(err) => { + assert!(err.id == session_id); + } + other => panic!( + "Expected UnknownId error for expired session update, got: {:?}", + other + ), + } + + // Try to update TTL of expired session - should return UnknownId error + let update_ttl_result = store + .update_ttl(&session_id, Duration::from_secs(7200)) + .await; + assert!(update_ttl_result.is_err()); + match update_ttl_result.unwrap_err() { + pavex_session::store::errors::UpdateTtlError::UnknownId(err) => { + assert!(err.id == session_id); + } + other => panic!( + "Expected UnknownId error for expired session TTL update, got: {:?}", + other + ), + } + + // Try to delete expired session - should return UnknownId error + let delete_result = store.delete(&session_id).await; + assert!(delete_result.is_err()); + match delete_result.unwrap_err() { + pavex_session::store::errors::DeleteError::UnknownId(err) => { + assert!(err.id == session_id); + } + other => panic!( + "Expected UnknownId error for expired session delete, got: {:?}", + other + ), + } + + // Try to change ID of expired session - should return UnknownId error + let new_id = SessionId::random(); + let change_id_result = store.change_id(&session_id, &new_id).await; + assert!(change_id_result.is_err()); + match change_id_result.unwrap_err() { + pavex_session::store::errors::ChangeIdError::UnknownId(err) => { + assert!(err.id == session_id); + } + other => panic!( + "Expected UnknownId error for expired session ID change, got: {:?}", + other + ), + } +} + +#[tokio::test] +async fn test_serialization_error() { + let store = test_store().await; + let session_id = SessionId::random(); + + // Create a problematic state that might cause serialization issues + let mut state = HashMap::new(); + + // JSON serialization should handle this fine, but let's test with some edge cases + state.insert(Cow::Borrowed("inf_value"), serde_json::json!(f64::INFINITY)); + + let record = SessionRecordRef { + state: Cow::Borrowed(&state), + ttl: Duration::from_secs(3600), + }; + + // This should succeed because serde_json handles infinity as null in JSON + let result = store.create(&session_id, record).await; + + // If it fails, it should be a serialization error + match result { + Ok(_) => { + // Verify we can load it back + let loaded = store.load(&session_id).await.unwrap().unwrap(); + // Infinity becomes null in JSON + assert!(loaded.state.get("inf_value").unwrap().is_null()); + } + Err(pavex_session::store::errors::CreateError::SerializationError(_)) => { + // This is also acceptable - serialization failed as expected + } + Err(other) => panic!("Unexpected error type: {:?}", other), + } +}