├── .github └── workflows │ ├── api_ref.yml │ ├── cd.yml │ ├── ci.yml │ └── dst.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── pyproject.toml ├── resonate ├── __init__.py ├── bridge.py ├── clocks │ ├── __init__.py │ └── step.py ├── conventions │ ├── __init__.py │ ├── base.py │ ├── local.py │ ├── remote.py │ └── sleep.py ├── coroutine.py ├── delay_q.py ├── dependencies.py ├── encoders │ ├── __init__.py │ ├── base64.py │ ├── combined.py │ ├── header.py │ ├── json.py │ ├── jsonpickle.py │ ├── noop.py │ └── pair.py ├── errors │ ├── __init__.py │ └── errors.py ├── graph.py ├── loggers │ ├── __init__.py │ ├── context.py │ └── dst.py ├── message_sources │ ├── __init__.py │ ├── local.py │ └── poller.py ├── models │ ├── __init__.py │ ├── callback.py │ ├── clock.py │ ├── commands.py │ ├── context.py │ ├── convention.py │ ├── durable_promise.py │ ├── encoder.py │ ├── handle.py │ ├── logger.py │ ├── message.py │ ├── message_source.py │ ├── result.py │ ├── retry_policy.py │ ├── router.py │ ├── store.py │ └── task.py ├── options.py ├── py.typed ├── registry.py ├── resonate.py ├── retry_policies │ ├── __init__.py │ ├── constant.py │ ├── exponential.py │ ├── linear.py │ └── never.py ├── routers │ ├── __init__.py │ └── tag.py ├── scheduler.py ├── simulator.py ├── stores │ ├── __init__.py │ ├── local.py │ └── remote.py └── utils.py ├── ruff.toml ├── scripts └── new-release.py ├── tests ├── __init__.py ├── conftest.py ├── runners.py ├── test_bridge.py ├── test_delay_q.py ├── test_dst.py ├── test_encoders.py ├── test_equivalencies.py ├── test_resonate.py ├── test_retries.py ├── test_retry_policies.py ├── test_store_promise.py └── test_store_task.py └── uv.lock /.github/workflows/api_ref.yml: -------------------------------------------------------------------------------- 1 | name: api ref 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | pages: write 11 | id-token: write 12 | 13 | jobs: 14 | deploy: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: install uv 20 | uses: astral-sh/setup-uv@v5 21 | with: 22 | version: 0.5.23 23 | 24 | - name: set up python 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: 3.12 28 | 29 | - name: install 30 | run: uv sync --dev 31 | 32 | - name: generate api docs 33 | run: uv run pydoctor resonate --docformat=google --project-name "Resonate Python SDK" --project-url "https://github.com/resonatehq/resonate-sdk-py" --html-output docs/build 34 | 35 | - name: publish api docs to gh pages 36 | uses: peaceiris/actions-gh-pages@v3 37 | with: 38 | github_token: ${{ secrets.GITHUB_TOKEN }} 39 | publish_dir: ./docs/build 40 | publish_branch: gh-pages 41 | force_orphan: true # Ensure this creates a fresh gh-pages branch if needed 42 | commit_message: generate API docs 43 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: cd 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | run: 9 | name: release 10 | 11 | permissions: 12 | id-token: write 13 | 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: install uv 20 | uses: astral-sh/setup-uv@v5 21 | with: 22 | version: 0.5.23 23 | 24 | - name: set up Python 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: "3.12" 28 | 29 | - name: build library 30 | run: uv build 31 | 32 | - name: push build artifacts to PyPI 33 | uses: pypa/gh-action-pypi-publish@v1.12.3 34 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | workflow_dispatch: 8 | push: 9 | branches: [main] 10 | paths-ignore: 11 | - README.md 12 | pull_request: 13 | branches: [main] 14 | paths-ignore: 15 | - README.md 16 | 17 | jobs: 18 | run: 19 | runs-on: ${{ matrix.os }} 20 | timeout-minutes: 10 21 | 22 | strategy: 23 | fail-fast: true 24 | matrix: 25 | os: [ubuntu-latest, macos-latest] 26 | python-version: ["3.12", "3.13"] 27 | server-version: ["main", "latest"] 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - name: install uv 33 | uses: astral-sh/setup-uv@v5 34 | with: 35 | version: 0.5.23 36 | 37 | - name: set up python 38 | uses: actions/setup-python@v5 39 | with: 40 | python-version: ${{matrix.python-version}} 41 | 42 | - name: install 43 | run: uv sync --dev 44 | 45 | - name: check linting 46 | run: uv run ruff check 47 | 48 | - name: check types 49 | run: uv run pyright 50 | 51 | - name: set up go 52 | uses: actions/setup-go@v5 53 | with: 54 | go-version: "1.23.0" 55 | cache: false # turn caching off to avoid warning 56 | 57 | - name: Get latest Resonate release tag (if needed) 58 | if: matrix.server-version == 'latest' 59 | id: get-resonate-tag 60 | run: | 61 | LATEST_TAG=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ 62 | https://api.github.com/repos/resonatehq/resonate/releases/latest | jq -r .tag_name) 63 | echo "tag=$LATEST_TAG" >> $GITHUB_OUTPUT 64 | 65 | - name: checkout resonate repository 66 | uses: actions/checkout@v4 67 | with: 68 | repository: resonatehq/resonate 69 | path: server 70 | ref: ${{ matrix.server-version == 'latest' && steps.get-resonate-tag.outputs.tag || 'main' }} 71 | 72 | - name: build resonate 73 | run: go build -o resonate 74 | working-directory: server 75 | 76 | - name: start resonate server 77 | run: ./resonate serve --system-signal-timeout 0.1s & 78 | working-directory: server 79 | 80 | - name: run tests 81 | env: 82 | RESONATE_HOST: http://localhost 83 | 84 | run: uv run pytest 85 | -------------------------------------------------------------------------------- /.github/workflows/dst.yml: -------------------------------------------------------------------------------- 1 | name: dst 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | workflow_dispatch: 8 | inputs: 9 | seed: 10 | description: "seed" 11 | type: number 12 | steps: 13 | description: "steps" 14 | type: number 15 | schedule: 16 | - cron: "*/20 * * * *" # every 20 mins 17 | 18 | jobs: 19 | values: 20 | runs-on: ubuntu-22.04 21 | steps: 22 | - id: seed 23 | name: Set random seed 24 | run: echo seed=$RANDOM >> $GITHUB_OUTPUT 25 | outputs: 26 | seed: ${{ inputs.seed || steps.seed.outputs.seed }} 27 | steps: ${{ inputs.steps || 10000 }} 28 | 29 | dst: 30 | runs-on: ${{ matrix.os }} 31 | needs: [values] 32 | timeout-minutes: 15 33 | 34 | strategy: 35 | fail-fast: true 36 | matrix: 37 | os: [ubuntu-latest, windows-latest, macos-latest] 38 | python-version: ["3.10", "3.11", "3.12", "3.13"] 39 | run: [1, 2] 40 | 41 | steps: 42 | - uses: actions/checkout@v4 43 | 44 | - name: Install uv 45 | uses: astral-sh/setup-uv@v5 46 | 47 | - name: Set up python 48 | uses: actions/setup-python@v5 49 | with: 50 | python-version: ${{matrix.python-version}} 51 | 52 | - name: Install resonate 53 | run: uv sync --dev 54 | 55 | - name: Run dst (seed=${{ needs.values.outputs.seed }}, steps=${{ needs.values.outputs.steps }}) 56 | run: uv run pytest -s --seed ${{ needs.values.outputs.seed }} --steps ${{ needs.values.outputs.steps }} tests/test_dst.py 2> logs.txt 57 | 58 | - uses: actions/upload-artifact@v4 59 | if: ${{ always() }} 60 | with: 61 | name: ${{ matrix.os }}-${{ matrix.python-version }}-logs-${{ matrix.run }} 62 | path: logs.txt 63 | 64 | diff: 65 | runs-on: ubuntu-22.04 66 | needs: [values, dst] 67 | 68 | strategy: 69 | fail-fast: false 70 | matrix: 71 | os: [ubuntu-latest, windows-latest, macos-latest] 72 | python-version: ["3.10", "3.11", "3.12", "3.13"] 73 | 74 | steps: 75 | - name: Download logs from run 1 76 | uses: actions/download-artifact@v4 77 | with: 78 | name: ${{ matrix.os }}-${{ matrix.python-version }}-logs-1 79 | path: logs-1.txt 80 | - name: Download logs from run 2 81 | uses: actions/download-artifact@v4 82 | with: 83 | name: ${{ matrix.os }}-${{ matrix.python-version }}-logs-2 84 | path: logs-2.txt 85 | - name: Diff 86 | run: diff logs-1.txt logs-2.txt 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Python-generated files 4 | __pycache__/ 5 | *.py[oc] 6 | build/ 7 | dist/ 8 | wheels/ 9 | *.egg-info 10 | 11 | # Virtual environments 12 | .venv 13 | 14 | # cache 15 | .mypy_cache/ 16 | .ruff_cache/ 17 | .pytest_cache/ 18 | 19 | # cov 20 | .coverage 21 | 22 | # docs 23 | apidocs/ 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribute to the Resonate Python SDK 2 | 3 | Please [open a Github Issue](https://github.com/resonatehq/resonate-sdk-py/issues) prior to submitting a Pull Request. 4 | 5 | Join the #resonate-engineering channel in the [community Discord](https://www.resonatehq.io/discord) to discuss your changes. 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

Resonate Python SDK

6 | 7 |
8 | 9 | [![ci](https://github.com/resonatehq/resonate-sdk-py/actions/workflows/ci.yml/badge.svg)](https://github.com/resonatehq/resonate-sdk-py/actions/workflows/ci.yml) 10 | [![dst](https://github.com/resonatehq/resonate-sdk-py/actions/workflows/dst.yml/badge.svg)](https://github.com/resonatehq/resonate-sdk-py/actions/workflows/dst.yml) 11 | [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 12 | 13 |
14 | 15 | ## About this component 16 | 17 | The Resonate Python SDK enables you to build reliable and scalable applications when paired with at least one [Resonate Server](https://github.com/resonatehq/resonate). 18 | 19 | - [Contribute to the Resonate Python SDK](./CONTRIBUTING.md) 20 | - [License](./LICENSE) 21 | 22 | ## Directory 23 | 24 | - [Get started with Resonate](https://docs.resonatehq.io/get-started) 25 | - [Try an example application](https://github.com/resonatehq-examples) 26 | - [Join the community](https://resonatehq.io/discord) 27 | - [Subscribe to Resonate HQ](https://journal.resonatehq.io/subscribe) 28 | - [Follow on Twitter / X](https://twitter.com/resonatehqio) 29 | - [Follow on LinkedIn](https://www.linkedin.com/company/resonatehqio) 30 | - [Subscribe on YouTube](https://www.youtube.com/@resonatehqio) 31 | 32 | ## Distributed Async Await 33 | 34 | Resonate implements the Distributed Async Await specification — [Learn more](https://www.distributed-async-await.io/) 35 | 36 | ## Why Resonate 37 | 38 | Because developing distributed applications should be a delightful experience — [Learn more](https://docs.resonatehq.io/evaluate/why-resonate) 39 | 40 | ## Available SDKs 41 | 42 | Add reliablity and scalability to the language you love. 43 | 44 | | Language | Source Code | Package | Developer docs | 45 | | :-----------------------------------------------------------------------------------------------------------------: | --------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------- | 46 | | py sdk | https://github.com/resonatehq/resonate-sdk-py | [pypi](https://pypi.org/project/resonate-sdk/) | [docs](https://docs.resonatehq.io/develop/python) | 47 | | ts sdk | https://github.com/resonatehq/resonate-sdk-ts | [npm](https://www.npmjs.com/package/@resonatehq/sdk) | [docs](https://docs.resonatehq.io/develop/typescript) | 48 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "resonate-sdk" 3 | version = "0.5.4" 4 | description = "Distributed Async Await by Resonate HQ, Inc" 5 | readme = "README.md" 6 | authors = [{ name = "Resonate HQ, Inc", email = "contact@resonatehq.io" }] 7 | requires-python = ">=3.12" 8 | dependencies = ["jsonpickle >= 4, < 5", "requests >= 2, < 3"] 9 | 10 | [project.urls] 11 | Documentation = "https://github.com/resonatehq/resonate-sdk-py#readme" 12 | Issues = "https://github.com/resonatehq/resonate-sdk-py/issues" 13 | Source = "https://github.com/resonatehq/resonate-sdk-py" 14 | 15 | [build-system] 16 | requires = ["hatchling"] 17 | build-backend = "hatchling.build" 18 | 19 | [dependency-groups] 20 | dev = [ 21 | "docutils>=0.21.2", 22 | "pydoctor>=24.11.2", 23 | "pyright>=1.1.396", 24 | "pytest>=8.3.5", 25 | "pytest-cov>=6.1.1", 26 | "ruff>=0.11.0", 27 | "tabulate>=0.9.0", 28 | "types-requests>=2.32.0.20250306", 29 | ] 30 | 31 | [tool.pytest.ini_options] 32 | testpaths = ["tests"] 33 | addopts = ["--import-mode=importlib"] 34 | 35 | [tool.hatch.build.targets.wheel] 36 | packages = ["resonate"] 37 | 38 | [tool.pyright] 39 | venvPath = "." 40 | venv = ".venv" 41 | -------------------------------------------------------------------------------- /resonate/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .coroutine import Yieldable 4 | from .options import Options 5 | from .resonate import Context, Resonate 6 | 7 | __all__ = ["Context", "Options", "Resonate", "Yieldable"] 8 | -------------------------------------------------------------------------------- /resonate/bridge.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import queue 4 | import threading 5 | import time 6 | from concurrent.futures import Future 7 | from typing import TYPE_CHECKING, Any 8 | 9 | from resonate.conventions import Base 10 | from resonate.delay_q import DelayQ 11 | from resonate.errors import ResonateShutdownError 12 | from resonate.models.commands import ( 13 | CancelPromiseReq, 14 | CancelPromiseRes, 15 | Command, 16 | CreateCallbackReq, 17 | CreateCallbackRes, 18 | CreatePromiseReq, 19 | CreatePromiseRes, 20 | CreateSubscriptionReq, 21 | Delayed, 22 | Function, 23 | Invoke, 24 | Listen, 25 | Network, 26 | Notify, 27 | Receive, 28 | RejectPromiseReq, 29 | RejectPromiseRes, 30 | ResolvePromiseReq, 31 | ResolvePromiseRes, 32 | Resume, 33 | Retry, 34 | Return, 35 | ) 36 | from resonate.models.durable_promise import DurablePromise 37 | from resonate.models.result import Ko, Ok, Result 38 | from resonate.models.task import Task 39 | from resonate.options import Options 40 | from resonate.scheduler import Done, Info, More, Scheduler 41 | from resonate.utils import exit_on_exception 42 | 43 | if TYPE_CHECKING: 44 | from collections.abc import Callable 45 | 46 | from resonate.models.convention import Convention 47 | from resonate.models.message_source import MessageSource 48 | from resonate.models.store import Store 49 | from resonate.registry import Registry 50 | from resonate.resonate import Context 51 | 52 | 53 | class Bridge: 54 | def __init__( 55 | self, 56 | ctx: Callable[[str, str, Info], Context], 57 | pid: str, 58 | ttl: int, 59 | opts: Options, 60 | unicast: str, 61 | anycast: str, 62 | registry: Registry, 63 | store: Store, 64 | message_source: MessageSource, 65 | ) -> None: 66 | self._cq = queue.Queue[Command | tuple[Command, Future] | None]() 67 | self._promise_id_to_task: dict[str, Task] = {} 68 | 69 | self._ctx = ctx 70 | self._pid = pid 71 | self._ttl = ttl 72 | self._opts = opts 73 | self._unicast = unicast 74 | self._anycast = unicast 75 | 76 | self._registry = registry 77 | self._store = store 78 | self._message_source = message_source 79 | self._delay_q = DelayQ[Function | Retry]() 80 | 81 | self._scheduler = Scheduler( 82 | self._ctx, 83 | self._pid, 84 | self._unicast, 85 | self._anycast, 86 | ) 87 | 88 | self._bridge_thread = threading.Thread(target=self._process_cq, name="bridge", daemon=True) 89 | self._message_source_thread = threading.Thread(target=self._process_msgs, name="message-source", daemon=True) 90 | 91 | self._delay_q_thread = threading.Thread(target=self._process_delayed_events, name="delay-q", daemon=True) 92 | self._delay_q_condition = threading.Condition() 93 | 94 | self._heartbeat_thread = threading.Thread(target=self._heartbeat, name="heartbeat", daemon=True) 95 | self._heartbeat_active = threading.Event() 96 | 97 | self._shutdown = threading.Event() 98 | 99 | def run(self, conv: Convention, func: Callable, args: tuple, kwargs: dict, opts: Options, future: Future) -> DurablePromise: 100 | encoder = opts.get_encoder() 101 | 102 | headers, data = encoder.encode(conv.data) 103 | promise, task = self._store.promises.create_with_task( 104 | id=conv.id, 105 | ikey=conv.idempotency_key, 106 | timeout=int((time.time() + conv.timeout) * 1000), 107 | headers=headers, 108 | data=data, 109 | tags=conv.tags, 110 | pid=self._pid, 111 | ttl=self._ttl * 1000, 112 | ) 113 | 114 | if promise.completed: 115 | assert not task 116 | match promise.result(encoder): 117 | case Ok(v): 118 | future.set_result(v) 119 | case Ko(e): 120 | future.set_exception(e) 121 | elif task is not None: 122 | self._promise_id_to_task[promise.id] = task 123 | self.start_heartbeat() 124 | self._cq.put_nowait((Invoke(conv.id, conv, promise.abs_timeout, func, args, kwargs, opts, promise), future)) 125 | else: 126 | self._cq.put_nowait((Listen(promise.id), future)) 127 | 128 | return promise 129 | 130 | def rpc(self, conv: Convention, opts: Options, future: Future) -> DurablePromise: 131 | encoder = opts.get_encoder() 132 | 133 | headers, data = encoder.encode(conv.data) 134 | promise = self._store.promises.create( 135 | id=conv.id, 136 | ikey=conv.idempotency_key, 137 | timeout=int((time.time() + conv.timeout) * 1000), 138 | headers=headers, 139 | data=data, 140 | tags=conv.tags, 141 | ) 142 | 143 | if promise.completed: 144 | match promise.result(encoder): 145 | case Ok(v): 146 | future.set_result(v) 147 | case Ko(e): 148 | future.set_exception(e) 149 | else: 150 | self._cq.put_nowait((Listen(promise.id), future)) 151 | 152 | return promise 153 | 154 | def get(self, id: str, opts: Options, future: Future) -> DurablePromise: 155 | promise = self._store.promises.get(id=id) 156 | 157 | if promise.completed: 158 | match promise.result(opts.get_encoder()): 159 | case Ok(v): 160 | future.set_result(v) 161 | case Ko(e): 162 | future.set_exception(e) 163 | else: 164 | self._cq.put_nowait((Listen(id), future)) 165 | 166 | return promise 167 | 168 | def start(self) -> None: 169 | if not self._message_source_thread.is_alive(): 170 | self._message_source.start() 171 | self._message_source_thread.start() 172 | 173 | if not self._bridge_thread.is_alive(): 174 | self._bridge_thread.start() 175 | 176 | if not self._heartbeat_thread.is_alive(): 177 | self._heartbeat_thread.start() 178 | 179 | if not self._delay_q_thread.is_alive(): 180 | self._delay_q_thread.start() 181 | 182 | def stop(self) -> None: 183 | """Stop internal components and threads. Intended for use only within the resonate class.""" 184 | self._stop_no_join() 185 | if self._bridge_thread.is_alive(): 186 | self._bridge_thread.join() 187 | if self._message_source_thread.is_alive(): 188 | self._message_source_thread.join() 189 | if self._heartbeat_thread.is_alive(): 190 | self._heartbeat_thread.join() 191 | 192 | def _stop_no_join(self) -> None: 193 | """Stop internal components and threads. Does not join the threads, to be able to call it from the bridge itself.""" 194 | self._message_source.stop() 195 | self._cq.put_nowait(None) 196 | self._heartbeat_active.clear() 197 | self._shutdown.set() 198 | 199 | @exit_on_exception 200 | def _process_cq(self) -> None: 201 | while item := self._cq.get(): 202 | cmd, future = item if isinstance(item, tuple) else (item, None) 203 | match self._scheduler.step(cmd, future): 204 | case More(reqs): 205 | for req in reqs: 206 | match req: 207 | case Network(id, cid, n_req): 208 | try: 209 | cmd = self._handle_network_request(id, cid, n_req) 210 | self._cq.put_nowait(cmd) 211 | except Exception as e: 212 | err = ResonateShutdownError(mesg="An unexpected store error has occurred, shutting down") 213 | err.__cause__ = e # bind original error 214 | 215 | # bypass the cq and shutdown right away 216 | self._scheduler.shutdown(err) 217 | self._stop_no_join() 218 | return 219 | 220 | case Function(id, cid, func): 221 | self._cq.put_nowait(Return(id, cid, self._handle_function(func))) 222 | case Delayed() as item: 223 | self._handle_delay(item) 224 | 225 | case Done(reqs): 226 | cid = cmd.cid 227 | task = self._promise_id_to_task.get(cid, None) 228 | match reqs: 229 | case [Network(_, cid, CreateSubscriptionReq(id, promise_id, timeout, recv))]: 230 | # Current implementation returns a single CreateSubscriptionReq in the list 231 | # if we get more than one element they are all CreateCallbackReq 232 | 233 | durable_promise, callback = self._store.promises.subscribe( 234 | id=id, 235 | promise_id=promise_id, 236 | timeout=timeout, 237 | recv=recv, 238 | ) 239 | assert durable_promise.id == cid 240 | assert durable_promise.completed or callback 241 | 242 | if durable_promise.completed: 243 | self._cq.put_nowait(Notify(cid, durable_promise)) 244 | 245 | case _: 246 | got_resume = False 247 | for req in reqs: 248 | assert isinstance(req, Network) 249 | assert isinstance(req.req, CreateCallbackReq) 250 | 251 | res_cmd = self._handle_network_request(req.id, req.cid, req.req) 252 | if isinstance(res_cmd, Resume): 253 | # if we get a resume here we can bail the rest of the callback requests 254 | # and continue with the rest of the work in the cq. 255 | self._cq.put_nowait(res_cmd) 256 | got_resume = True 257 | break 258 | 259 | if got_resume: 260 | continue 261 | 262 | if task is not None: 263 | self._store.tasks.complete(id=task.id, counter=task.counter) 264 | 265 | @exit_on_exception 266 | def _process_msgs(self) -> None: 267 | encoder = self._opts.get_encoder() 268 | 269 | def _invoke(root: DurablePromise) -> Invoke: 270 | data = encoder.decode(root.param.to_tuple()) 271 | assert isinstance(data, dict) 272 | 273 | assert "func" in data 274 | assert "version" in data 275 | assert isinstance(data["func"], str) 276 | assert isinstance(data["version"], int) 277 | 278 | _, func, version = self._registry.get(data["func"], data["version"]) 279 | return Invoke( 280 | root.id, 281 | Base( 282 | root.id, 283 | root.rel_timeout, 284 | root.ikey_for_create, 285 | root.param.data, 286 | root.tags, 287 | ), 288 | root.abs_timeout, 289 | func, 290 | data.get("args", ()), 291 | data.get("kwargs", {}), 292 | Options(version=version), 293 | root, 294 | ) 295 | 296 | while msg := self._message_source.next(): 297 | match msg: 298 | case {"type": "invoke", "task": {"id": id, "counter": counter}}: 299 | task = Task(id, counter, self._store) 300 | root, _ = self._store.tasks.claim(id=task.id, counter=task.counter, pid=self._pid, ttl=self._ttl * 1000) 301 | self.start_heartbeat() 302 | self._promise_id_to_task[root.id] = task 303 | self._cq.put_nowait(_invoke(root)) 304 | 305 | case {"type": "resume", "task": {"id": id, "counter": counter}}: 306 | task = Task(id, counter, self._store) 307 | root, leaf = self._store.tasks.claim(id=task.id, counter=task.counter, pid=self._pid, ttl=self._ttl * 1000) 308 | self.start_heartbeat() 309 | assert leaf is not None, "leaf must not be None" 310 | cmd = Resume( 311 | id=leaf.id, 312 | cid=root.id, 313 | promise=leaf, 314 | invoke=_invoke(root), 315 | ) 316 | self._promise_id_to_task[root.id] = task 317 | self._cq.put_nowait(cmd) 318 | 319 | case {"type": "notify", "promise": promise}: 320 | durable_promise = DurablePromise.from_dict(self._store, promise) 321 | self._cq.put_nowait(Notify(durable_promise.id, durable_promise)) 322 | 323 | @exit_on_exception 324 | def _process_delayed_events(self) -> None: 325 | while not self._shutdown.is_set(): 326 | with self._delay_q_condition: 327 | while not self._delay_q.empty(): 328 | if self._shutdown.is_set(): 329 | self._delay_q_condition.release() 330 | return 331 | 332 | self._delay_q_condition.wait() 333 | 334 | now = time.time() 335 | events, next_time = self._delay_q.get(now) 336 | 337 | # Release the lock so more event can be added to the delay queue while 338 | # the ones just pulled get processed. 339 | self._delay_q_condition.release() 340 | 341 | for item in events: 342 | match item: 343 | case Function(id, cid, func): 344 | self._cq.put_nowait(Return(id, cid, self._handle_function(func))) 345 | case retry: 346 | self._cq.put_nowait(retry) 347 | 348 | if self._shutdown.is_set(): 349 | return 350 | 351 | timeout = max(0.0, next_time - now) 352 | self._delay_q_condition.acquire() 353 | self._delay_q_condition.wait(timeout=timeout) 354 | 355 | def start_heartbeat(self) -> None: 356 | self._heartbeat_active.set() 357 | 358 | @exit_on_exception 359 | def _heartbeat(self) -> None: 360 | while not self._shutdown.is_set(): 361 | # If this timeout don't execute the heartbeat 362 | if self._heartbeat_active.wait(0.3): 363 | heartbeated = self._store.tasks.heartbeat(pid=self._pid) 364 | if heartbeated == 0: 365 | self._heartbeat_active.clear() 366 | else: 367 | self._shutdown.wait(self._ttl * 0.5) 368 | 369 | def _handle_delay(self, delay: Delayed) -> None: 370 | """Add a command to the delay queue. 371 | 372 | Uses a threading.condition to synchronize access to the underlaying delay_q. 373 | """ 374 | with self._delay_q_condition: 375 | self._delay_q.add(delay.item, time.time() + delay.delay) 376 | self._delay_q_condition.notify() 377 | 378 | def _handle_network_request(self, cmd_id: str, cid: str, req: CreatePromiseReq | ResolvePromiseReq | RejectPromiseReq | CancelPromiseReq | CreateCallbackReq) -> Command: 379 | match req: 380 | case CreatePromiseReq(id, timeout, ikey, strict, headers, data, tags): 381 | promise = self._store.promises.create( 382 | id=id, 383 | timeout=timeout, 384 | ikey=ikey, 385 | strict=strict, 386 | headers=headers, 387 | data=data, 388 | tags=tags, 389 | ) 390 | return Receive(cmd_id, cid, CreatePromiseRes(promise)) 391 | 392 | case ResolvePromiseReq(id, ikey, strict, headers, data): 393 | promise = self._store.promises.resolve( 394 | id=id, 395 | ikey=ikey, 396 | strict=strict, 397 | headers=headers, 398 | data=data, 399 | ) 400 | return Receive(cmd_id, cid, ResolvePromiseRes(promise)) 401 | 402 | case RejectPromiseReq(id, ikey, strict, headers, data): 403 | promise = self._store.promises.reject( 404 | id=id, 405 | ikey=ikey, 406 | strict=strict, 407 | headers=headers, 408 | data=data, 409 | ) 410 | return Receive(cmd_id, cid, RejectPromiseRes(promise)) 411 | 412 | case CancelPromiseReq(id, ikey, strict, headers, data): 413 | promise = self._store.promises.cancel( 414 | id=id, 415 | ikey=ikey, 416 | strict=strict, 417 | headers=headers, 418 | data=data, 419 | ) 420 | return Receive(cmd_id, cid, CancelPromiseRes(promise)) 421 | 422 | case CreateCallbackReq(promise_id, root_promise_id, timeout, recv): 423 | promise, callback = self._store.promises.callback( 424 | promise_id=promise_id, 425 | root_promise_id=root_promise_id, 426 | timeout=timeout, 427 | recv=recv, 428 | ) 429 | 430 | if promise.completed: 431 | return Resume(cmd_id, cid, promise) 432 | 433 | return Receive(cmd_id, cid, CreateCallbackRes(promise, callback)) 434 | 435 | case _: 436 | raise NotImplementedError 437 | 438 | def _handle_function(self, func: Callable[[], Any]) -> Result: 439 | try: 440 | r = func() 441 | return Ok(r) 442 | except Exception as e: 443 | return Ko(e) 444 | -------------------------------------------------------------------------------- /resonate/clocks/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .step import StepClock 4 | 5 | __all__ = ["StepClock"] 6 | -------------------------------------------------------------------------------- /resonate/clocks/step.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import time 4 | 5 | 6 | class StepClock: 7 | def __init__(self) -> None: 8 | self._time = 0.0 9 | 10 | def step(self, time: float) -> None: 11 | assert time >= self._time, "The arrow of time only flows forward." 12 | self._time = time 13 | 14 | def time(self) -> float: 15 | """Return the current time in seconds.""" 16 | return self._time 17 | 18 | def strftime(self, format: str, /) -> str: 19 | return time.strftime(format, time.gmtime(self._time)) 20 | -------------------------------------------------------------------------------- /resonate/conventions/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .base import Base 4 | from .local import Local 5 | from .remote import Remote 6 | from .sleep import Sleep 7 | 8 | __all__ = ["Base", "Local", "Remote", "Sleep"] 9 | -------------------------------------------------------------------------------- /resonate/conventions/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import TYPE_CHECKING, Any 5 | 6 | if TYPE_CHECKING: 7 | from collections.abc import Callable 8 | 9 | 10 | @dataclass 11 | class Base: 12 | id: str 13 | timeout: float 14 | idempotency_key: str | None = None 15 | data: Any = None 16 | tags: dict[str, str] | None = None 17 | 18 | def options( 19 | self, 20 | id: str | None = None, 21 | idempotency_key: str | Callable[[str], str] | None = None, 22 | tags: dict[str, str] | None = None, 23 | target: str | None = None, 24 | timeout: float | None = None, 25 | version: int | None = None, 26 | ) -> Base: 27 | self.id = id or self.id 28 | self.idempotency_key = idempotency_key(self.id) if callable(idempotency_key) else (idempotency_key or self.idempotency_key) 29 | self.timeout = timeout or self.timeout 30 | self.tags = tags or self.tags 31 | 32 | return self 33 | -------------------------------------------------------------------------------- /resonate/conventions/local.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from typing import TYPE_CHECKING, Any 5 | 6 | from resonate.options import Options 7 | 8 | if TYPE_CHECKING: 9 | from collections.abc import Callable 10 | 11 | 12 | @dataclass 13 | class Local: 14 | id: str 15 | r_id: str 16 | p_id: str 17 | opts: Options = field(default_factory=Options, repr=False) 18 | 19 | @property 20 | def idempotency_key(self) -> str | None: 21 | return self.opts.get_idempotency_key(self.id) 22 | 23 | @property 24 | def data(self) -> Any: 25 | return None 26 | 27 | @property 28 | def timeout(self) -> float: 29 | return self.opts.timeout 30 | 31 | @property 32 | def tags(self) -> dict[str, str]: 33 | return {**self.opts.tags, "resonate:root": self.r_id, "resonate:parent": self.p_id, "resonate:scope": "local"} 34 | 35 | def options( 36 | self, 37 | id: str | None = None, 38 | idempotency_key: str | Callable[[str], str] | None = None, 39 | tags: dict[str, str] | None = None, 40 | target: str | None = None, 41 | timeout: float | None = None, 42 | version: int | None = None, 43 | ) -> Local: 44 | self.id = id or self.id 45 | 46 | # delibrately ignore target and version 47 | self.opts = self.opts.merge(id=id, idempotency_key=idempotency_key, tags=tags, timeout=timeout) 48 | return self 49 | -------------------------------------------------------------------------------- /resonate/conventions/remote.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from typing import TYPE_CHECKING, Any 5 | 6 | from resonate.options import Options 7 | 8 | if TYPE_CHECKING: 9 | from collections.abc import Callable 10 | 11 | 12 | @dataclass 13 | class Remote: 14 | id: str 15 | r_id: str 16 | p_id: str 17 | name: str 18 | args: tuple[Any, ...] = field(default_factory=tuple) 19 | kwargs: dict[str, Any] = field(default_factory=dict) 20 | opts: Options = field(default_factory=Options, repr=False) 21 | 22 | @property 23 | def idempotency_key(self) -> str | None: 24 | return self.opts.get_idempotency_key(self.id) 25 | 26 | @property 27 | def data(self) -> dict[str, Any]: 28 | return {"func": self.name, "args": self.args, "kwargs": self.kwargs, "version": self.opts.version} 29 | 30 | @property 31 | def timeout(self) -> float: 32 | return self.opts.timeout 33 | 34 | @property 35 | def tags(self) -> dict[str, str]: 36 | return {**self.opts.tags, "resonate:root": self.r_id, "resonate:parent": self.p_id, "resonate:scope": "global", "resonate:invoke": self.opts.target} 37 | 38 | def options( 39 | self, 40 | id: str | None = None, 41 | idempotency_key: str | Callable[[str], str] | None = None, 42 | tags: dict[str, str] | None = None, 43 | target: str | None = None, 44 | timeout: float | None = None, 45 | version: int | None = None, 46 | ) -> Remote: 47 | self.id = id or self.id 48 | self.opts = self.opts.merge(id=id, idempotency_key=idempotency_key, target=target, tags=tags, timeout=timeout, version=version) 49 | 50 | return self 51 | -------------------------------------------------------------------------------- /resonate/conventions/sleep.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import TYPE_CHECKING, Any 5 | 6 | if TYPE_CHECKING: 7 | from collections.abc import Callable 8 | 9 | 10 | @dataclass 11 | class Sleep: 12 | id: str 13 | secs: float 14 | 15 | @property 16 | def idempotency_key(self) -> str: 17 | return self.id 18 | 19 | @property 20 | def data(self) -> Any: 21 | return None 22 | 23 | @property 24 | def timeout(self) -> float: 25 | return self.secs 26 | 27 | @property 28 | def tags(self) -> dict[str, str]: 29 | return {"resonate:timeout": "true"} 30 | 31 | def options( 32 | self, 33 | id: str | None = None, 34 | idempotency_key: str | Callable[[str], str] | None = None, 35 | tags: dict[str, str] | None = None, 36 | target: str | None = None, 37 | timeout: float | None = None, 38 | version: int | None = None, 39 | ) -> Sleep: 40 | self.id = id or self.id 41 | return self 42 | -------------------------------------------------------------------------------- /resonate/coroutine.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from typing import TYPE_CHECKING, Any, Literal, Self 5 | 6 | from resonate.models.result import Ko, Ok, Result 7 | from resonate.options import Options 8 | 9 | if TYPE_CHECKING: 10 | from collections.abc import Callable, Generator 11 | 12 | from resonate.models.convention import Convention 13 | from resonate.models.encoder import Encoder 14 | from resonate.models.retry_policy import RetryPolicy 15 | 16 | 17 | @dataclass 18 | class LFX[T]: 19 | conv: Convention 20 | func: Callable[..., Generator[Any, Any, T] | T] 21 | args: tuple[Any, ...] 22 | kwargs: dict[str, Any] 23 | opts: Options = field(default_factory=Options) 24 | 25 | @property 26 | def id(self) -> str: 27 | return self.conv.id 28 | 29 | def options( 30 | self, 31 | *, 32 | durable: bool | None = None, 33 | encoder: Encoder[Any, str | None] | None = None, 34 | id: str | None = None, 35 | idempotency_key: str | Callable[[str], str] | None = None, 36 | non_retryable_exceptions: tuple[type[Exception], ...] | None = None, 37 | retry_policy: RetryPolicy | Callable[[Callable], RetryPolicy] | None = None, 38 | tags: dict[str, str] | None = None, 39 | timeout: float | None = None, 40 | version: int | None = None, 41 | ) -> Self: 42 | # Note: we deliberately ignore the version for LFX 43 | self.conv = self.conv.options( 44 | id=id, 45 | idempotency_key=idempotency_key, 46 | tags=tags, 47 | timeout=timeout, 48 | ) 49 | self.opts = self.opts.merge( 50 | durable=durable, 51 | encoder=encoder, 52 | non_retryable_exceptions=non_retryable_exceptions, 53 | retry_policy=retry_policy, 54 | ) 55 | return self 56 | 57 | 58 | @dataclass 59 | class LFI[T](LFX[T]): 60 | pass 61 | 62 | 63 | @dataclass 64 | class LFC[T](LFX[T]): 65 | pass 66 | 67 | 68 | @dataclass 69 | class RFX[T]: 70 | conv: Convention 71 | opts: Options = field(default_factory=Options) 72 | 73 | @property 74 | def id(self) -> str: 75 | return self.conv.id 76 | 77 | def options( 78 | self, 79 | *, 80 | encoder: Encoder[Any, str | None] | None = None, 81 | id: str | None = None, 82 | idempotency_key: str | Callable[[str], str] | None = None, 83 | target: str | None = None, 84 | tags: dict[str, str] | None = None, 85 | timeout: float | None = None, 86 | version: int | None = None, 87 | ) -> Self: 88 | self.conv = self.conv.options( 89 | id=id, 90 | idempotency_key=idempotency_key, 91 | target=target, 92 | tags=tags, 93 | timeout=timeout, 94 | version=version, 95 | ) 96 | self.opts = self.opts.merge( 97 | encoder=encoder, 98 | ) 99 | return self 100 | 101 | 102 | @dataclass 103 | class RFI[T](RFX[T]): 104 | mode: Literal["attached", "detached"] = "attached" 105 | 106 | 107 | @dataclass 108 | class RFC[T](RFX[T]): 109 | pass 110 | 111 | 112 | @dataclass 113 | class AWT: 114 | id: str 115 | 116 | 117 | @dataclass 118 | class TRM: 119 | id: str 120 | result: Result 121 | 122 | 123 | @dataclass(frozen=True) 124 | class Promise[T]: 125 | id: str 126 | cid: str 127 | 128 | 129 | type Yieldable = LFI | LFC | RFI | RFC | Promise 130 | 131 | 132 | class Coroutine: 133 | def __init__(self, id: str, cid: str, gen: Generator[Yieldable, Any, Any]) -> None: 134 | self.id = id 135 | self.cid = cid 136 | self.gen = gen 137 | 138 | self.done = False 139 | self.skip = False 140 | self.next: type[None | AWT] | tuple[type[Result], ...] = type(None) 141 | self.unyielded: list[AWT | TRM] = [] 142 | 143 | def __repr__(self) -> str: 144 | return f"Coroutine(done={self.done})" 145 | 146 | def send(self, value: None | AWT | Result) -> LFI | RFI | AWT | TRM: 147 | assert self.done or isinstance(value, self.next), "AWT must follow LFI/RFI. Value must follow AWT." 148 | assert not self.skip or isinstance(value, AWT), "If skipped, value must be an AWT." 149 | 150 | if self.done: 151 | # When done, yield all unyielded values to enforce structured concurrency, the final 152 | # value must be a TRM. 153 | 154 | match self.unyielded: 155 | case []: 156 | raise StopIteration 157 | case [trm]: 158 | assert isinstance(trm, TRM), "Last unyielded value must be a TRM." 159 | self.unyielded = [] 160 | return trm 161 | case [head, *tail]: 162 | self.unyielded = tail 163 | return head 164 | try: 165 | match value, self.skip: 166 | case None, _: 167 | yielded = next(self.gen) 168 | case Ok(v), _: 169 | yielded = self.gen.send(v) 170 | case Ko(e), _: 171 | yielded = self.gen.throw(e) 172 | case awt, True: 173 | # When skipped, pretend as if the generator yielded a promise 174 | self.skip = False 175 | yielded = Promise(awt.id, self.cid) 176 | case awt, False: 177 | yielded = self.gen.send(Promise(awt.id, self.cid)) 178 | 179 | match yielded: 180 | case LFI(conv) | RFI(conv, mode="attached"): 181 | # LFIs and attached RFIs require an AWT 182 | self.next = AWT 183 | self.unyielded.append(AWT(conv.id)) 184 | command = yielded 185 | case LFC(conv, func, args, kwargs, opts): 186 | # LFCs can be converted to an LFI+AWT 187 | self.next = AWT 188 | self.skip = True 189 | command = LFI(conv, func, args, kwargs, opts) 190 | case RFC(conv): 191 | # RFCs can be converted to an RFI+AWT 192 | self.next = AWT 193 | self.skip = True 194 | command = RFI(conv) 195 | case Promise(id): 196 | # When a promise is yielded we can remove it from unyielded 197 | self.next = (Ok, Ko) 198 | self.unyielded = [y for y in self.unyielded if y.id != id] 199 | command = AWT(id) 200 | case _: 201 | assert isinstance(yielded, RFI), "Yielded must be an RFI." 202 | assert yielded.mode == "detached", "RFI must be detached." 203 | self.next = AWT 204 | command = yielded 205 | 206 | except StopIteration as e: 207 | self.done = True 208 | self.unyielded.append(TRM(self.id, Ok(e.value))) 209 | return self.unyielded.pop(0) 210 | except Exception as e: 211 | self.done = True 212 | self.unyielded.append(TRM(self.id, Ko(e))) 213 | return self.unyielded.pop(0) 214 | else: 215 | assert not isinstance(yielded, Promise) or yielded.cid == self.cid, "If promise, cids must match." 216 | return command 217 | -------------------------------------------------------------------------------- /resonate/delay_q.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import heapq 4 | 5 | 6 | class DelayQ[T]: 7 | def __init__(self) -> None: 8 | self._delayed: list[tuple[float, T]] = [] 9 | 10 | def add(self, item: T, delay: float) -> None: 11 | heapq.heappush(self._delayed, (delay, item)) 12 | 13 | def get(self, time: float) -> tuple[list[T], float]: 14 | items: list[T] = [] 15 | while self._delayed and self._delayed[0][0] <= time: 16 | _, item = heapq.heappop(self._delayed) 17 | items.append(item) 18 | 19 | next_time = self._delayed[0][0] if self._delayed else 0 20 | return items, next_time 21 | 22 | def empty(self) -> bool: 23 | return bool(self._delayed) 24 | -------------------------------------------------------------------------------- /resonate/dependencies.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | 6 | class Dependencies: 7 | def __init__(self) -> None: 8 | self._deps: dict[str, Any] = {} 9 | 10 | def add(self, key: str, obj: Any) -> None: 11 | self._deps[key] = obj 12 | 13 | def get[T](self, key: str, default: T) -> Any | T: 14 | return self._deps.get(key, default) 15 | -------------------------------------------------------------------------------- /resonate/encoders/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .base64 import Base64Encoder 4 | from .combined import CombinedEncoder 5 | from .header import HeaderEncoder 6 | from .json import JsonEncoder 7 | from .jsonpickle import JsonPickleEncoder 8 | from .noop import NoopEncoder 9 | from .pair import PairEncoder 10 | 11 | __all__ = ["Base64Encoder", "CombinedEncoder", "CombinedEncoder", "HeaderEncoder", "JsonEncoder", "JsonPickleEncoder", "NoopEncoder", "PairEncoder"] 12 | -------------------------------------------------------------------------------- /resonate/encoders/base64.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | 5 | 6 | class Base64Encoder: 7 | def encode(self, obj: str | None) -> str | None: 8 | return base64.b64encode(obj.encode()).decode() if obj is not None else None 9 | 10 | def decode(self, obj: str | None) -> str | None: 11 | return base64.b64decode(obj).decode() if obj is not None else None 12 | -------------------------------------------------------------------------------- /resonate/encoders/combined.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from resonate.models.encoder import Encoder 7 | 8 | 9 | class CombinedEncoder[T, U, V]: 10 | def __init__(self, l: Encoder[T, U], r: Encoder[U, V]) -> None: 11 | self._l = l 12 | self._r = r 13 | 14 | def encode(self, obj: T) -> V: 15 | return self._r.encode(self._l.encode(obj)) 16 | 17 | def decode(self, obj: V) -> T: 18 | return self._l.decode(self._r.decode(obj)) 19 | -------------------------------------------------------------------------------- /resonate/encoders/header.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from resonate.models.encoder import Encoder 7 | 8 | 9 | class HeaderEncoder[T, U]: 10 | def __init__(self, key: str, encoder: Encoder[T, U]) -> None: 11 | self._key = key 12 | self._enc = encoder 13 | 14 | def encode(self, obj: T) -> dict[str, U] | None: 15 | return {self._key: self._enc.encode(obj)} 16 | 17 | def decode(self, obj: dict[str, U] | None) -> T | None: 18 | if obj and self._key in obj: 19 | return self._enc.decode(obj[self._key]) 20 | 21 | return None 22 | -------------------------------------------------------------------------------- /resonate/encoders/json.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from typing import Any 5 | 6 | 7 | class JsonEncoder: 8 | def encode(self, obj: Any) -> str | None: 9 | if obj is None: 10 | return None 11 | 12 | return json.dumps(obj, default=_encode_exception) 13 | 14 | def decode(self, obj: str | None) -> Any: 15 | if obj is None: 16 | return None 17 | 18 | return json.loads(obj, object_hook=_decode_exception) 19 | 20 | 21 | def _encode_exception(obj: Any) -> dict[str, Any]: 22 | if isinstance(obj, BaseException): 23 | return {"__error__": str(obj)} 24 | return {} # ignore unencodable objects 25 | 26 | 27 | def _decode_exception(obj: dict[str, Any]) -> Any: 28 | if "__error__" in obj: 29 | return Exception(obj["__error__"]) 30 | return obj 31 | -------------------------------------------------------------------------------- /resonate/encoders/jsonpickle.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | import jsonpickle 6 | 7 | 8 | class JsonPickleEncoder: 9 | def encode(self, obj: Any) -> str: 10 | data = jsonpickle.encode(obj, unpicklable=True) 11 | assert data 12 | return data 13 | 14 | def decode(self, obj: str | None) -> Any: 15 | return jsonpickle.decode(obj) # noqa: S301 16 | -------------------------------------------------------------------------------- /resonate/encoders/noop.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | 6 | class NoopEncoder: 7 | def encode(self, obj: Any) -> Any: 8 | return None 9 | 10 | def decode(self, obj: Any) -> Any: 11 | return None 12 | -------------------------------------------------------------------------------- /resonate/encoders/pair.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from resonate.models.encoder import Encoder 7 | 8 | 9 | class PairEncoder[T, U, V]: 10 | def __init__(self, l: Encoder[T, U], r: Encoder[T, V]) -> None: 11 | self._l = l 12 | self._r = r 13 | 14 | def encode(self, obj: T) -> tuple[U, V]: 15 | return self._l.encode(obj), self._r.encode(obj) 16 | 17 | def decode(self, obj: tuple[U, V]) -> T | None: 18 | u, v = obj 19 | 20 | if (t := self._l.decode(u)) is not None: 21 | return t 22 | 23 | if (t := self._r.decode(v)) is not None: 24 | return t 25 | 26 | return None 27 | -------------------------------------------------------------------------------- /resonate/errors/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .errors import ResonateCanceledError, ResonateError, ResonateShutdownError, ResonateStoreError, ResonateTimedoutError 4 | 5 | __all__ = ["ResonateCanceledError", "ResonateError", "ResonateShutdownError", "ResonateStoreError", "ResonateTimedoutError"] 6 | -------------------------------------------------------------------------------- /resonate/errors/errors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from typing import Any 5 | 6 | 7 | class ResonateError(Exception): 8 | def __init__(self, mesg: str, code: float, details: Any = None) -> None: 9 | super().__init__(mesg) 10 | self.mesg = mesg 11 | self.code = code 12 | try: 13 | self.details = json.dumps(details, indent=2) if details else None 14 | except Exception: 15 | self.details = details 16 | 17 | def __str__(self) -> str: 18 | return f"[{self.code:09.5f}] {self.mesg}{'\n' + self.details if self.details else ''}" 19 | 20 | def __reduce__(self) -> str | tuple[Any, ...]: 21 | return (self.__class__, (self.mesg, self.code, self.details)) 22 | 23 | 24 | # Error codes 100-199 25 | 26 | 27 | class ResonateStoreError(ResonateError): 28 | def __init__(self, mesg: str, code: int, details: Any = None) -> None: 29 | super().__init__(mesg, float(f"{100}.{code}"), details) 30 | 31 | def __reduce__(self) -> str | tuple[Any, ...]: 32 | return (self.__class__, (self.mesg, self.code, self.details)) 33 | 34 | 35 | # Error codes 200-299 36 | 37 | 38 | class ResonateCanceledError(ResonateError): 39 | def __init__(self, promise_id: str) -> None: 40 | super().__init__(f"Promise {promise_id} canceled", 200) 41 | self.promise_id = promise_id 42 | 43 | def __reduce__(self) -> str | tuple[Any, ...]: 44 | return (self.__class__, (self.promise_id,)) 45 | 46 | 47 | class ResonateTimedoutError(ResonateError): 48 | def __init__(self, promise_id: str, timeout: float) -> None: 49 | super().__init__(f"Promise {promise_id} timedout at {timeout}", 201) 50 | self.promise_id = promise_id 51 | self.timeout = timeout 52 | 53 | def __reduce__(self) -> str | tuple[Any, ...]: 54 | return (self.__class__, (self.promise_id, self.timeout)) 55 | 56 | 57 | # Error codes 300-399 58 | 59 | 60 | class ResonateShutdownError(ResonateError): 61 | def __init__(self, mesg: str) -> None: 62 | super().__init__(mesg, 300) 63 | 64 | def __reduce__(self) -> str | tuple[Any, ...]: 65 | return (self.__class__, (self.mesg,)) 66 | -------------------------------------------------------------------------------- /resonate/graph.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from collections.abc import Callable, Generator 7 | 8 | 9 | class Graph[T]: 10 | def __init__(self, id: str, root: T) -> None: 11 | self.id = id 12 | self.root = Node(id, root) 13 | 14 | def find(self, func: Callable[[Node[T]], bool], edge: str = "default") -> Node[T] | None: 15 | return self.root.find(func, edge) 16 | 17 | def filter(self, func: Callable[[Node[T]], bool], edge: str = "default") -> Generator[Node[T], None, None]: 18 | return self.root.filter(func, edge) 19 | 20 | def traverse(self, edge: str = "default") -> Generator[Node[T], None, None]: 21 | return self.root.traverse(edge) 22 | 23 | def traverse_with_level(self, edge: str = "default") -> Generator[tuple[Node[T], int], None, None]: 24 | return self.root.traverse_with_level(edge) 25 | 26 | 27 | class Node[T]: 28 | def __init__(self, id: str, value: T) -> None: 29 | self.id = id 30 | self._value = value 31 | self._edges: dict[str, list[Node[T]]] = {} 32 | 33 | def __repr__(self) -> str: 34 | edges = {e: [v.id for v in v] for e, v in self._edges.items()} 35 | return f"Node({self.id}, {self.value}, {edges})" 36 | 37 | @property 38 | def value(self) -> T: 39 | return self._value 40 | 41 | def transition(self, value: T) -> None: 42 | self._value = value 43 | 44 | def add_edge(self, node: Node[T], edge: str = "default") -> None: 45 | self._edges.setdefault(edge, []).append(node) 46 | 47 | def rmv_edge(self, node: Node[T], edge: str = "default") -> None: 48 | self._edges[edge].remove(node) 49 | 50 | def has_edge(self, node: Node[T], edge: str = "default") -> bool: 51 | return node in self._edges.get(edge, []) 52 | 53 | def get_edge(self, edge: str = "default") -> list[Node]: 54 | return self._edges.get(edge, []) 55 | 56 | def find(self, func: Callable[[Node[T]], bool], edge: str = "default") -> Node[T] | None: 57 | for node in self.traverse(edge): 58 | if func(node): 59 | return node 60 | 61 | return None 62 | 63 | def filter(self, func: Callable[[Node[T]], bool], edge: str = "default") -> Generator[Node[T], None, None]: 64 | for node in self.traverse(edge): 65 | if func(node): 66 | yield node 67 | 68 | def traverse(self, edge: str = "default") -> Generator[Node[T], None, None]: 69 | for node, _ in self._traverse(edge): 70 | yield node 71 | 72 | def traverse_with_level(self, edge: str = "default") -> Generator[tuple[Node[T], int], None, None]: 73 | return self._traverse(edge) 74 | 75 | def _traverse(self, edge: str, visited: set[str] | None = None, level: int = 0) -> Generator[tuple[Node[T], int], None, None]: 76 | if visited is None: 77 | visited = set() 78 | 79 | if self.id in visited: 80 | return 81 | 82 | visited.add(self.id) 83 | yield self, level 84 | 85 | for node in self._edges.get(edge, []): 86 | yield from node._traverse(edge, visited, level + 1) # noqa: SLF001 87 | -------------------------------------------------------------------------------- /resonate/loggers/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .context import ContextLogger 4 | from .dst import DSTLogger 5 | 6 | __all__ = ["ContextLogger", "DSTLogger"] 7 | -------------------------------------------------------------------------------- /resonate/loggers/context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import Any 5 | 6 | 7 | class ContextLogger: 8 | def __init__(self, cid: str, id: str, level: int | str = logging.NOTSET) -> None: 9 | self.cid = cid 10 | self.id = id 11 | 12 | self._logger = logging.getLogger(f"resonate:{cid}:{id}") 13 | self._logger.setLevel(level) 14 | self._logger.propagate = False 15 | 16 | if not self._logger.handlers: 17 | formatter = logging.Formatter( 18 | "[%(asctime)s] [%(levelname)s] [%(cid)s] [%(id)s] %(message)s", 19 | datefmt="%Y-%m-%d %H:%M:%S", 20 | ) 21 | handler = logging.StreamHandler() 22 | handler.setFormatter(formatter) 23 | self._logger.addHandler(handler) 24 | 25 | def _log(self, level: int, msg: Any, *args: Any, **kwargs: Any) -> None: 26 | self._logger.log(level, msg, *args, **{**kwargs, "extra": {"cid": self.cid, "id": self.id}}) 27 | 28 | def log(self, level: int, msg: Any, *args: Any, **kwargs: Any) -> None: 29 | self._log(level, msg, *args, **kwargs) 30 | 31 | def debug(self, msg: Any, *args: Any, **kwargs: Any) -> None: 32 | self._log(logging.DEBUG, msg, *args, **kwargs) 33 | 34 | def info(self, msg: Any, *args: Any, **kwargs: Any) -> None: 35 | self._log(logging.INFO, msg, *args, **kwargs) 36 | 37 | def warning(self, msg: Any, *args: Any, **kwargs: Any) -> None: 38 | self._log(logging.WARNING, msg, *args, **kwargs) 39 | 40 | def error(self, msg: Any, *args: Any, **kwargs: Any) -> None: 41 | self._log(logging.ERROR, msg, *args, **kwargs) 42 | 43 | def critical(self, msg: Any, *args: Any, **kwargs: Any) -> None: 44 | self._log(logging.CRITICAL, msg, *args, **kwargs) 45 | -------------------------------------------------------------------------------- /resonate/loggers/dst.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING, Any 5 | 6 | if TYPE_CHECKING: 7 | from resonate.models.clock import Clock 8 | 9 | 10 | class DSTFormatter(logging.Formatter): 11 | def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str: # noqa: N802 12 | time: float = getattr(record, "time", 0.0) 13 | return f"{time:09.0f}" 14 | 15 | 16 | class DSTLogger: 17 | def __init__(self, cid: str, id: str, clock: Clock, level: int = logging.NOTSET) -> None: 18 | self.cid = cid 19 | self.id = id 20 | self.clock = clock 21 | 22 | self._logger = logging.getLogger(f"resonate:{cid}:{id}") 23 | self._logger.setLevel(level) 24 | self._logger.propagate = False 25 | 26 | if not self._logger.handlers: 27 | formatter = DSTFormatter("[%(asctime)s] [%(levelname)s] [%(cid)s] [%(id)s] %(message)s") 28 | handler = logging.StreamHandler() 29 | handler.setFormatter(formatter) 30 | self._logger.addHandler(handler) 31 | 32 | def _log(self, level: int, msg: Any, *args: Any, **kwargs: Any) -> None: 33 | self._logger.log(level, msg, *args, **{**kwargs, "extra": {"cid": self.cid, "id": self.id, "time": self.clock.time()}}) 34 | 35 | def log(self, level: int, msg: Any, *args: Any, **kwargs: Any) -> None: 36 | self._log(level, msg, *args, **kwargs) 37 | 38 | def debug(self, msg: Any, *args: Any, **kwargs: Any) -> None: 39 | self._log(logging.DEBUG, msg, *args, **kwargs) 40 | 41 | def info(self, msg: Any, *args: Any, **kwargs: Any) -> None: 42 | self._log(logging.INFO, msg, *args, **kwargs) 43 | 44 | def warning(self, msg: Any, *args: Any, **kwargs: Any) -> None: 45 | self._log(logging.WARNING, msg, *args, **kwargs) 46 | 47 | def error(self, msg: Any, *args: Any, **kwargs: Any) -> None: 48 | self._log(logging.ERROR, msg, *args, **kwargs) 49 | 50 | def critical(self, msg: Any, *args: Any, **kwargs: Any) -> None: 51 | self._log(logging.CRITICAL, msg, *args, **kwargs) 52 | -------------------------------------------------------------------------------- /resonate/message_sources/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .local import LocalMessageSource 4 | from .poller import Poller 5 | 6 | __all__ = ["LocalMessageSource", "Poller"] 7 | -------------------------------------------------------------------------------- /resonate/message_sources/local.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import queue 4 | import urllib 5 | import urllib.parse 6 | from typing import TYPE_CHECKING 7 | 8 | from resonate.models.message import Mesg 9 | 10 | if TYPE_CHECKING: 11 | from resonate.stores import LocalStore 12 | 13 | 14 | class LocalMessageSource: 15 | def __init__(self, store: LocalStore, group: str, id: str, scheme: str = "local") -> None: 16 | self._messages = queue.Queue[Mesg | None]() 17 | self._store = store 18 | self._scheme = scheme 19 | self._group = group 20 | self._id = id 21 | 22 | @property 23 | def group(self) -> str: 24 | return self._group 25 | 26 | @property 27 | def id(self) -> str: 28 | return self._id 29 | 30 | @property 31 | def unicast(self) -> str: 32 | return f"{self._scheme}://uni@{self._group}/{self._id}" 33 | 34 | @property 35 | def anycast(self) -> str: 36 | return f"{self._scheme}://any@{self._group}/{self._id}" 37 | 38 | def start(self) -> None: 39 | # idempotently connect to the store 40 | self._store.connect(self) 41 | 42 | # idempotently start the store 43 | self._store.start() 44 | 45 | def stop(self) -> None: 46 | # disconnect from the store 47 | self._store.disconnect(self) 48 | 49 | # signal to consumers to disconnect 50 | self._messages.put(None) 51 | 52 | def enqueue(self, mesg: Mesg) -> None: 53 | self._messages.put(mesg) 54 | 55 | def next(self) -> Mesg | None: 56 | return self._messages.get() 57 | 58 | def match(self, addr: str) -> tuple[bool, bool]: 59 | parsed = urllib.parse.urlparse(addr) 60 | 61 | if parsed.username in ("uni", "any", None) and parsed.scheme == self._scheme and parsed.hostname == self._group and parsed.path == f"/{self._id}": 62 | return True, False 63 | if parsed.username in ("any", None) and parsed.scheme == self._scheme and parsed.hostname == self._group: 64 | return False, True 65 | 66 | return False, False 67 | -------------------------------------------------------------------------------- /resonate/message_sources/poller.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import os 5 | import queue 6 | import time 7 | from threading import Thread 8 | from typing import TYPE_CHECKING, Any 9 | 10 | import requests 11 | 12 | from resonate.encoders import JsonEncoder 13 | from resonate.models.message import InvokeMesg, Mesg, NotifyMesg, ResumeMesg 14 | from resonate.utils import exit_on_exception 15 | 16 | if TYPE_CHECKING: 17 | from resonate.models.encoder import Encoder 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class Poller: 23 | def __init__( 24 | self, 25 | group: str, 26 | id: str, 27 | host: str | None = None, 28 | port: str | None = None, 29 | timeout: float | None = None, 30 | encoder: Encoder[Any, str] | None = None, 31 | ) -> None: 32 | self._messages = queue.Queue[Mesg | None]() 33 | self._group = group 34 | self._id = id 35 | self._host = host or os.getenv("RESONATE_HOST_MESSAGE_SOURCE", os.getenv("RESONATE_HOST", "http://localhost")) 36 | self._port = port or os.getenv("RESONATE_PORT_MESSAGE_SOURCE", "8002") 37 | self._timeout = timeout 38 | self._encoder = encoder or JsonEncoder() 39 | self._thread = Thread(name="message-source::poller", target=self.loop, daemon=True) 40 | self._stopped = False 41 | 42 | @property 43 | def url(self) -> str: 44 | return f"{self._host}:{self._port}/{self._group}/{self._id}" 45 | 46 | @property 47 | def unicast(self) -> str: 48 | return f"poll://uni@{self._group}/{self._id}" 49 | 50 | @property 51 | def anycast(self) -> str: 52 | return f"poll://any@{self._group}/{self._id}" 53 | 54 | def start(self) -> None: 55 | if not self._thread.is_alive(): 56 | self._thread.start() 57 | 58 | def stop(self) -> None: 59 | # signal to consumer to disconnect 60 | self._messages.put(None) 61 | 62 | # TODO(avillega): Couldn't come up with a nice way of stoping this thread 63 | # iter_lines is blocking and request.get is also blocking, this makes it so 64 | # the only way to stop it is waiting for a timeout on the request itself 65 | # which could never happen. 66 | 67 | # This shutdown is only respected when the poller is instantiated with a timeout 68 | # value, which is not the default. This is still useful for tests. 69 | self._stopped = True 70 | 71 | def enqueue(self, mesg: Mesg) -> None: 72 | self._messages.put(mesg) 73 | 74 | def next(self) -> Mesg | None: 75 | return self._messages.get() 76 | 77 | @exit_on_exception 78 | def loop(self) -> None: 79 | while not self._stopped: 80 | try: 81 | with requests.get(self.url, stream=True, timeout=self._timeout) as res: 82 | res.raise_for_status() 83 | 84 | for line in res.iter_lines(chunk_size=None, decode_unicode=True): 85 | assert isinstance(line, str), "line must be a string" 86 | if msg := self._process_line(line): 87 | self._messages.put(msg) 88 | 89 | except requests.exceptions.Timeout: 90 | logger.warning("Polling request timed out for group %s. Retrying after delay...", self._group) 91 | time.sleep(0.5) 92 | continue 93 | except requests.exceptions.RequestException as e: 94 | logger.warning("Polling network error for group %s: %s. Retrying after delay...", self._group, str(e)) 95 | time.sleep(0.5) 96 | continue 97 | except Exception as e: 98 | logger.warning("Unexpected error in poller loop for group %s: %s. Retrying after delay...", self._group, e) 99 | time.sleep(0.5) 100 | continue 101 | 102 | def _process_line(self, line: str) -> Mesg | None: 103 | if not line: 104 | return None 105 | 106 | stripped = line.strip() 107 | if not stripped.startswith("data:"): 108 | return None 109 | 110 | d = self._encoder.decode(stripped[5:]) 111 | match d["type"]: 112 | case "invoke": 113 | return InvokeMesg(type="invoke", task=d["task"]) 114 | case "resume": 115 | return ResumeMesg(type="resume", task=d["task"]) 116 | case "notify": 117 | return NotifyMesg(type="notify", promise=d["promise"]) 118 | case _: 119 | # Unknown message type 120 | return None 121 | -------------------------------------------------------------------------------- /resonate/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resonatehq/resonate-sdk-py/d31ee539a944e8c3d6931ea063772a60b9d0bfbc/resonate/models/__init__.py -------------------------------------------------------------------------------- /resonate/models/callback.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import TYPE_CHECKING, Any, Self 5 | 6 | if TYPE_CHECKING: 7 | from collections.abc import Mapping 8 | 9 | 10 | @dataclass 11 | class Callback: 12 | id: str 13 | promise_id: str 14 | timeout: int 15 | created_on: int 16 | 17 | @classmethod 18 | def from_dict(cls, data: Mapping[str, Any]) -> Self: 19 | return cls( 20 | id=data["id"], 21 | promise_id=data["promiseId"], 22 | timeout=data["timeout"], 23 | created_on=data["createdOn"], 24 | ) 25 | -------------------------------------------------------------------------------- /resonate/models/clock.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Protocol, runtime_checkable 4 | 5 | 6 | @runtime_checkable 7 | class Clock(Protocol): 8 | def time(self) -> float: ... 9 | def strftime(self, format: str, /) -> str: ... 10 | -------------------------------------------------------------------------------- /resonate/models/commands.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from typing import TYPE_CHECKING, Any 5 | 6 | from resonate.options import Options 7 | 8 | if TYPE_CHECKING: 9 | from collections.abc import Callable 10 | 11 | from resonate.models.callback import Callback 12 | from resonate.models.convention import Convention 13 | from resonate.models.durable_promise import DurablePromise 14 | from resonate.models.result import Result 15 | from resonate.models.task import Task 16 | 17 | 18 | # Commands 19 | 20 | type Command = Invoke | Resume | Return | Receive | Retry | Listen | Notify 21 | 22 | 23 | @dataclass 24 | class Invoke: 25 | id: str 26 | conv: Convention 27 | timeout: float # absolute time in seconds 28 | func: Callable[..., Any] = field(repr=False) 29 | args: tuple[Any, ...] = field(default_factory=tuple) 30 | kwargs: dict[str, Any] = field(default_factory=dict) 31 | opts: Options = field(default_factory=Options, repr=False) 32 | promise: DurablePromise | None = None 33 | 34 | @property 35 | def cid(self) -> str: 36 | return self.id 37 | 38 | 39 | @dataclass 40 | class Resume: 41 | id: str 42 | cid: str 43 | promise: DurablePromise 44 | invoke: Invoke | None = None 45 | 46 | 47 | @dataclass 48 | class Return: 49 | id: str 50 | cid: str 51 | res: Result 52 | 53 | 54 | @dataclass 55 | class Receive: 56 | id: str 57 | cid: str 58 | res: CreatePromiseRes | CreatePromiseWithTaskRes | ResolvePromiseRes | RejectPromiseRes | CancelPromiseRes | CreateCallbackRes 59 | 60 | 61 | @dataclass 62 | class Listen: 63 | id: str 64 | 65 | @property 66 | def cid(self) -> str: 67 | return self.id 68 | 69 | 70 | @dataclass 71 | class Notify: 72 | id: str 73 | promise: DurablePromise 74 | 75 | @property 76 | def cid(self) -> str: 77 | return self.id 78 | 79 | 80 | @dataclass 81 | class Retry: 82 | id: str 83 | cid: str 84 | 85 | 86 | # Requests 87 | 88 | type Request = Network | Function | Delayed 89 | 90 | 91 | @dataclass 92 | class Network[T: CreatePromiseReq | ResolvePromiseReq | RejectPromiseReq | CancelPromiseReq | CreateCallbackReq | CreateSubscriptionReq]: 93 | id: str 94 | cid: str 95 | req: T 96 | 97 | 98 | @dataclass 99 | class Function: 100 | id: str 101 | cid: str 102 | func: Callable[[], Any] 103 | 104 | 105 | @dataclass 106 | class Delayed[T: Function | Retry]: 107 | item: T 108 | delay: float 109 | 110 | @property 111 | def id(self) -> str: 112 | return self.item.id 113 | 114 | 115 | @dataclass 116 | class CreatePromiseReq: 117 | id: str 118 | timeout: int 119 | ikey: str | None = None 120 | strict: bool = False 121 | headers: dict[str, str] | None = None 122 | data: str | None = None 123 | tags: dict[str, str] | None = None 124 | 125 | 126 | @dataclass 127 | class CreatePromiseRes: 128 | promise: DurablePromise 129 | 130 | 131 | @dataclass 132 | class CreatePromiseWithTaskReq: 133 | id: str 134 | timeout: int 135 | pid: str 136 | ttl: int 137 | ikey: str | None = None 138 | strict: bool = False 139 | headers: dict[str, str] | None = None 140 | data: str | None = None 141 | tags: dict[str, str] | None = None 142 | 143 | 144 | @dataclass 145 | class CreatePromiseWithTaskRes: 146 | promise: DurablePromise 147 | task: Task | None 148 | 149 | 150 | @dataclass 151 | class ResolvePromiseReq: 152 | id: str 153 | ikey: str | None = None 154 | strict: bool = False 155 | headers: dict[str, str] | None = None 156 | data: str | None = None 157 | 158 | 159 | @dataclass 160 | class ResolvePromiseRes: 161 | promise: DurablePromise 162 | 163 | 164 | @dataclass 165 | class RejectPromiseReq: 166 | id: str 167 | ikey: str | None = None 168 | strict: bool = False 169 | headers: dict[str, str] | None = None 170 | data: str | None = None 171 | 172 | 173 | @dataclass 174 | class RejectPromiseRes: 175 | promise: DurablePromise 176 | 177 | 178 | @dataclass 179 | class CancelPromiseReq: 180 | id: str 181 | ikey: str | None = None 182 | strict: bool = False 183 | headers: dict[str, str] | None = None 184 | data: str | None = None 185 | 186 | 187 | @dataclass 188 | class CancelPromiseRes: 189 | promise: DurablePromise 190 | 191 | 192 | @dataclass 193 | class CreateCallbackReq: 194 | promise_id: str 195 | root_promise_id: str 196 | timeout: int 197 | recv: str 198 | 199 | 200 | @dataclass 201 | class CreateCallbackRes: 202 | promise: DurablePromise 203 | callback: Callback | None 204 | 205 | 206 | @dataclass 207 | class CreateSubscriptionReq: 208 | id: str 209 | promise_id: str 210 | timeout: int 211 | recv: str 212 | 213 | 214 | @dataclass 215 | class CreateSubscriptionRes: 216 | promise: DurablePromise 217 | callback: Callback | None 218 | 219 | 220 | @dataclass 221 | class ClaimTaskReq: 222 | id: str 223 | counter: int 224 | pid: str 225 | ttl: int 226 | 227 | 228 | @dataclass 229 | class ClaimTaskRes: 230 | root: DurablePromise 231 | leaf: DurablePromise | None 232 | task: Task 233 | 234 | 235 | @dataclass 236 | class CompleteTaskReq: 237 | id: str 238 | counter: int 239 | 240 | 241 | @dataclass 242 | class CompleteTaskRes: 243 | pass 244 | 245 | 246 | @dataclass 247 | class HeartbeatTasksReq: 248 | pid: str 249 | 250 | 251 | @dataclass 252 | class HeartbeatTasksRes: 253 | affected: int 254 | -------------------------------------------------------------------------------- /resonate/models/context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Protocol 4 | 5 | if TYPE_CHECKING: 6 | from resonate.models.logger import Logger 7 | 8 | 9 | class Context(Protocol): 10 | @property 11 | def id(self) -> str: ... 12 | @property 13 | def info(self) -> Info: ... 14 | @property 15 | def logger(self) -> Logger: ... 16 | 17 | def get_dependency(self, key: str, default: Any = None) -> Any: ... 18 | 19 | 20 | class Info(Protocol): 21 | @property 22 | def attempt(self) -> int: ... 23 | @property 24 | def idempotency_key(self) -> str | None: ... 25 | @property 26 | def tags(self) -> dict[str, str] | None: ... 27 | @property 28 | def timeout(self) -> float: ... 29 | @property 30 | def version(self) -> int: ... 31 | -------------------------------------------------------------------------------- /resonate/models/convention.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Protocol 4 | 5 | if TYPE_CHECKING: 6 | from collections.abc import Callable 7 | 8 | 9 | class Convention(Protocol): 10 | @property 11 | def id(self) -> str: ... 12 | @property 13 | def idempotency_key(self) -> str | None: ... 14 | @property 15 | def data(self) -> Any: ... 16 | @property 17 | def timeout(self) -> float: ... # relative time in seconds 18 | @property 19 | def tags(self) -> dict[str, str] | None: ... 20 | 21 | def options( 22 | self, 23 | id: str | None = None, 24 | idempotency_key: str | Callable[[str], str] | None = None, 25 | tags: dict[str, str] | None = None, 26 | target: str | None = None, 27 | timeout: float | None = None, 28 | version: int | None = None, 29 | ) -> Convention: ... 30 | -------------------------------------------------------------------------------- /resonate/models/durable_promise.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from typing import TYPE_CHECKING, Any, Literal 5 | 6 | from resonate.errors import ResonateCanceledError, ResonateTimedoutError 7 | from resonate.models.result import Ko, Ok, Result 8 | 9 | if TYPE_CHECKING: 10 | from collections.abc import Mapping 11 | 12 | from resonate.models.callback import Callback 13 | from resonate.models.encoder import Encoder 14 | from resonate.models.store import Store 15 | 16 | 17 | @dataclass 18 | class DurablePromise: 19 | id: str 20 | state: Literal["PENDING", "RESOLVED", "REJECTED", "REJECTED_CANCELED", "REJECTED_TIMEDOUT"] 21 | timeout: int 22 | ikey_for_create: str | None 23 | ikey_for_complete: str | None 24 | param: DurablePromiseValue 25 | value: DurablePromiseValue 26 | tags: dict[str, str] 27 | created_on: int 28 | completed_on: int | None 29 | 30 | store: Store = field(repr=False) 31 | 32 | @property 33 | def pending(self) -> bool: 34 | return self.state == "PENDING" 35 | 36 | @property 37 | def completed(self) -> bool: 38 | return not self.pending 39 | 40 | @property 41 | def resolved(self) -> bool: 42 | return self.state == "RESOLVED" 43 | 44 | @property 45 | def rejected(self) -> bool: 46 | return self.state == "REJECTED" 47 | 48 | @property 49 | def canceled(self) -> bool: 50 | return self.state == "REJECTED_CANCELED" 51 | 52 | @property 53 | def timedout(self) -> bool: 54 | return self.state == "REJECTED_TIMEDOUT" 55 | 56 | @property 57 | def abs_timeout(self) -> float: 58 | return self.timeout / 1000 59 | 60 | @property 61 | def rel_timeout(self) -> float: 62 | return (self.timeout - self.created_on) / 1000 63 | 64 | def resolve(self, data: str | None, *, ikey: str | None = None, strict: bool = False, headers: dict[str, str] | None = None) -> None: 65 | promise = self.store.promises.resolve( 66 | id=self.id, 67 | ikey=ikey, 68 | strict=strict, 69 | headers=headers, 70 | data=data, 71 | ) 72 | self._complete(promise) 73 | 74 | def reject(self, data: str | None, *, ikey: str | None = None, strict: bool = False, headers: dict[str, str] | None = None) -> None: 75 | promise = self.store.promises.reject( 76 | id=self.id, 77 | ikey=ikey, 78 | strict=strict, 79 | headers=headers, 80 | data=data, 81 | ) 82 | self._complete(promise) 83 | 84 | def cancel(self, data: str | None, *, ikey: str | None = None, strict: bool = False, headers: dict[str, str] | None = None) -> None: 85 | promise = self.store.promises.cancel( 86 | id=self.id, 87 | ikey=ikey, 88 | strict=strict, 89 | headers=headers, 90 | data=data, 91 | ) 92 | self._complete(promise) 93 | 94 | def callback(self, id: str, root_promise_id: str, recv: str) -> Callback | None: 95 | promise, callback = self.store.promises.callback( 96 | promise_id=self.id, 97 | root_promise_id=root_promise_id, 98 | recv=recv, 99 | timeout=self.timeout, 100 | ) 101 | if callback is None: 102 | self._complete(promise) 103 | return callback 104 | 105 | def subscribe(self, id: str, recv: str) -> Callback | None: 106 | promise, callback = self.store.promises.subscribe( 107 | id=id, 108 | promise_id=self.id, 109 | recv=recv, 110 | timeout=self.timeout, 111 | ) 112 | if callback is None: 113 | self._complete(promise) 114 | return callback 115 | 116 | def _complete(self, promise: DurablePromise) -> None: 117 | assert promise.completed 118 | self.state = promise.state 119 | self.ikey_for_complete = promise.ikey_for_complete 120 | self.value = promise.value 121 | self.completed_on = promise.completed_on 122 | 123 | def result(self, encoder: Encoder[Any, tuple[dict[str, str] | None, str | None]]) -> Result[Any]: 124 | assert self.completed, "Promise must be completed" 125 | 126 | if self.rejected: 127 | v = encoder.decode(self.value.to_tuple()) 128 | 129 | # In python, only exceptions may be raised. Here, we are converting 130 | # a value that is not an exception into an exception. 131 | return Ko(v) if isinstance(v, BaseException) else Ko(Exception(v)) 132 | if self.canceled: 133 | return Ko(ResonateCanceledError(self.id)) 134 | if self.timedout: 135 | return Ko(ResonateTimedoutError(self.id, self.abs_timeout)) 136 | 137 | return Ok(encoder.decode(self.value.to_tuple())) 138 | 139 | @classmethod 140 | def from_dict(cls, store: Store, data: Mapping[str, Any]) -> DurablePromise: 141 | return cls( 142 | id=data["id"], 143 | state=data["state"], 144 | timeout=data["timeout"], 145 | ikey_for_create=data.get("idempotencyKeyForCreate"), 146 | ikey_for_complete=data.get("idempotencyKeyForComplete"), 147 | param=DurablePromiseValue.from_dict(store, data.get("param", {})), 148 | value=DurablePromiseValue.from_dict(store, data.get("value", {})), 149 | tags=data.get("tags", {}), 150 | created_on=data["createdOn"], 151 | completed_on=data.get("completedOn"), 152 | store=store, 153 | ) 154 | 155 | 156 | @dataclass 157 | class DurablePromiseValue: 158 | headers: dict[str, str] | None 159 | data: str | None 160 | 161 | def to_tuple(self) -> tuple[dict[str, str] | None, Any]: 162 | return self.headers, self.data 163 | 164 | @classmethod 165 | def from_dict(cls, store: Store, data: Mapping[str, Any]) -> DurablePromiseValue: 166 | return cls( 167 | headers=data.get("headers"), 168 | data=store.encoder.decode(data.get("data")), 169 | ) 170 | -------------------------------------------------------------------------------- /resonate/models/encoder.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Protocol 4 | 5 | 6 | class Encoder[I, O](Protocol): 7 | def encode(self, obj: I, /) -> O: ... 8 | def decode(self, obj: O, /) -> I: ... 9 | -------------------------------------------------------------------------------- /resonate/models/handle.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from concurrent.futures import Future 7 | 8 | 9 | class Handle[T]: 10 | def __init__(self, f: Future[T]) -> None: 11 | self._f = f 12 | 13 | def done(self) -> bool: 14 | return self._f.done() 15 | 16 | def result(self, timeout: float | None = None) -> T: 17 | return self._f.result(timeout) 18 | -------------------------------------------------------------------------------- /resonate/models/logger.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Protocol 4 | 5 | 6 | class Logger(Protocol): 7 | def log(self, level: int, msg: Any, *args: Any, **kwargs: Any) -> None: ... 8 | def debug(self, msg: Any, *args: Any, **kwargs: Any) -> None: ... 9 | def info(self, msg: Any, *args: Any, **kwargs: Any) -> None: ... 10 | def warning(self, msg: Any, *args: Any, **kwargs: Any) -> None: ... 11 | def error(self, msg: Any, *args: Any, **kwargs: Any) -> None: ... 12 | def critical(self, msg: Any, *args: Any, **kwargs: Any) -> None: ... 13 | -------------------------------------------------------------------------------- /resonate/models/message.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Literal, TypedDict 4 | 5 | type Mesg = InvokeMesg | ResumeMesg | NotifyMesg 6 | 7 | 8 | class InvokeMesg(TypedDict): 9 | type: Literal["invoke"] 10 | task: TaskMesg 11 | 12 | 13 | class ResumeMesg(TypedDict): 14 | type: Literal["resume"] 15 | task: TaskMesg 16 | 17 | 18 | class NotifyMesg(TypedDict): 19 | type: Literal["notify"] 20 | promise: DurablePromiseMesg 21 | 22 | 23 | class TaskMesg(TypedDict): 24 | id: str 25 | counter: int 26 | 27 | 28 | class DurablePromiseMesg(TypedDict): 29 | id: str 30 | state: str 31 | timeout: int 32 | idempotencyKeyForCreate: str | None 33 | idempotencyKeyForComplete: str | None 34 | param: DurablePromiseValueMesg 35 | value: DurablePromiseValueMesg 36 | tags: dict[str, str] | None 37 | createdOn: int 38 | completedOn: int | None 39 | 40 | 41 | class DurablePromiseValueMesg(TypedDict): 42 | headers: dict[str, str] | None 43 | data: str | None 44 | -------------------------------------------------------------------------------- /resonate/models/message_source.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Protocol 4 | 5 | if TYPE_CHECKING: 6 | from resonate.models.message import Mesg 7 | 8 | 9 | class MessageSource(Protocol): 10 | @property 11 | def unicast(self) -> str: ... 12 | @property 13 | def anycast(self) -> str: ... 14 | 15 | def start(self) -> None: ... 16 | def stop(self) -> None: ... 17 | def next(self) -> Mesg | None: ... 18 | def enqueue(self, mesg: Mesg) -> None: ... 19 | -------------------------------------------------------------------------------- /resonate/models/result.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Final 5 | 6 | type Result[T] = Ok[T] | Ko 7 | 8 | 9 | @dataclass 10 | class Ok[T]: 11 | value: Final[T] 12 | 13 | 14 | @dataclass 15 | class Ko: 16 | value: Final[BaseException] 17 | -------------------------------------------------------------------------------- /resonate/models/retry_policy.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Protocol 4 | 5 | 6 | class RetryPolicy(Protocol): 7 | def next(self, attempt: int) -> float | None: ... 8 | -------------------------------------------------------------------------------- /resonate/models/router.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Protocol 4 | 5 | if TYPE_CHECKING: 6 | from resonate.stores.local import DurablePromiseRecord 7 | 8 | 9 | class Router(Protocol): 10 | def route(self, promise: DurablePromiseRecord) -> str: ... 11 | -------------------------------------------------------------------------------- /resonate/models/store.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Protocol 4 | 5 | if TYPE_CHECKING: 6 | from resonate.models.callback import Callback 7 | from resonate.models.durable_promise import DurablePromise 8 | from resonate.models.encoder import Encoder 9 | from resonate.models.task import Task 10 | 11 | 12 | class Store(Protocol): 13 | @property 14 | def encoder(self) -> Encoder[str | None, str | None]: ... 15 | 16 | @property 17 | def promises(self) -> PromiseStore: ... 18 | 19 | @property 20 | def tasks(self) -> TaskStore: ... 21 | 22 | 23 | class PromiseStore(Protocol): 24 | def get( 25 | self, 26 | id: str, 27 | ) -> DurablePromise: ... 28 | 29 | def create( 30 | self, 31 | id: str, 32 | timeout: int, 33 | *, 34 | ikey: str | None = None, 35 | strict: bool = False, 36 | headers: dict[str, str] | None = None, 37 | data: str | None = None, 38 | tags: dict[str, str] | None = None, 39 | ) -> DurablePromise: ... 40 | 41 | def create_with_task( 42 | self, 43 | id: str, 44 | timeout: int, 45 | pid: str, 46 | ttl: int, 47 | *, 48 | ikey: str | None = None, 49 | strict: bool = False, 50 | headers: dict[str, str] | None = None, 51 | data: str | None = None, 52 | tags: dict[str, str] | None = None, 53 | ) -> tuple[DurablePromise, Task | None]: ... 54 | 55 | def resolve( 56 | self, 57 | id: str, 58 | *, 59 | ikey: str | None = None, 60 | strict: bool = False, 61 | headers: dict[str, str] | None = None, 62 | data: str | None = None, 63 | ) -> DurablePromise: ... 64 | 65 | def reject( 66 | self, 67 | id: str, 68 | *, 69 | ikey: str | None = None, 70 | strict: bool = False, 71 | headers: dict[str, str] | None = None, 72 | data: str | None = None, 73 | ) -> DurablePromise: ... 74 | 75 | def cancel( 76 | self, 77 | id: str, 78 | *, 79 | ikey: str | None = None, 80 | strict: bool = False, 81 | headers: dict[str, str] | None = None, 82 | data: str | None = None, 83 | ) -> DurablePromise: ... 84 | 85 | def callback( 86 | self, 87 | promise_id: str, 88 | root_promise_id: str, 89 | recv: str, 90 | timeout: int, 91 | ) -> tuple[DurablePromise, Callback | None]: ... 92 | 93 | def subscribe( 94 | self, 95 | id: str, 96 | promise_id: str, 97 | recv: str, 98 | timeout: int, 99 | ) -> tuple[DurablePromise, Callback | None]: ... 100 | 101 | 102 | class TaskStore(Protocol): 103 | def claim( 104 | self, 105 | id: str, 106 | counter: int, 107 | pid: str, 108 | ttl: int, 109 | ) -> tuple[DurablePromise, DurablePromise | None]: ... 110 | 111 | def complete( 112 | self, 113 | id: str, 114 | counter: int, 115 | ) -> bool: ... 116 | 117 | def heartbeat( 118 | self, 119 | pid: str, 120 | ) -> int: ... 121 | -------------------------------------------------------------------------------- /resonate/models/task.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from typing import TYPE_CHECKING, Any 5 | 6 | if TYPE_CHECKING: 7 | from collections.abc import Mapping 8 | 9 | from resonate.models.durable_promise import DurablePromise 10 | from resonate.models.store import Store 11 | 12 | 13 | @dataclass 14 | class Task: 15 | id: str 16 | counter: int 17 | store: Store = field(repr=False) 18 | 19 | def claim(self, pid: str, ttl: int) -> tuple[DurablePromise, DurablePromise | None]: 20 | return self.store.tasks.claim(id=self.id, counter=self.counter, pid=pid, ttl=ttl) 21 | 22 | def complete(self) -> None: 23 | self._completed = self.store.tasks.complete(id=self.id, counter=self.counter) 24 | 25 | @classmethod 26 | def from_dict(cls, store: Store, data: Mapping[str, Any]) -> Task: 27 | return cls( 28 | id=data["id"], 29 | counter=data["counter"], 30 | store=store, 31 | ) 32 | -------------------------------------------------------------------------------- /resonate/options.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from inspect import isgeneratorfunction 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from resonate.encoders import HeaderEncoder, JsonEncoder, JsonPickleEncoder, NoopEncoder, PairEncoder 8 | from resonate.retry_policies import Exponential, Never 9 | 10 | if TYPE_CHECKING: 11 | from collections.abc import Callable 12 | 13 | from resonate.models.encoder import Encoder 14 | from resonate.models.retry_policy import RetryPolicy 15 | 16 | 17 | @dataclass(frozen=True) 18 | class Options: 19 | durable: bool = True 20 | encoder: Encoder[Any, str | None] | None = None 21 | id: str | None = None 22 | idempotency_key: str | Callable[[str], str] | None = lambda id: id 23 | non_retryable_exceptions: tuple[type[Exception], ...] = () 24 | retry_policy: RetryPolicy | Callable[[Callable], RetryPolicy] = lambda f: Never() if isgeneratorfunction(f) else Exponential() 25 | target: str = "default" 26 | tags: dict[str, str] = field(default_factory=dict) 27 | timeout: float = 31536000 # relative time in seconds, default 1 year 28 | version: int = 0 29 | 30 | def __post_init__(self) -> None: 31 | if not (self.version >= 0): 32 | msg = "version must be greater than or equal to zero" 33 | raise ValueError(msg) 34 | if not (self.timeout >= 0): 35 | msg = "timeout must be greater than or equal to zero" 36 | raise ValueError(msg) 37 | 38 | def merge( 39 | self, 40 | *, 41 | durable: bool | None = None, 42 | encoder: Encoder[Any, str | None] | None = None, 43 | id: str | None = None, 44 | idempotency_key: str | Callable[[str], str] | None = None, 45 | non_retryable_exceptions: tuple[type[Exception], ...] | None = None, 46 | retry_policy: RetryPolicy | Callable[[Callable], RetryPolicy] | None = None, 47 | target: str | None = None, 48 | tags: dict[str, str] | None = None, 49 | timeout: float | None = None, 50 | version: int | None = None, 51 | ) -> Options: 52 | return Options( 53 | durable=durable if durable is not None else self.durable, 54 | encoder=encoder if encoder is not None else self.encoder, 55 | id=id if id is not None else self.id, 56 | idempotency_key=idempotency_key if idempotency_key is not None else self.idempotency_key, 57 | non_retryable_exceptions=non_retryable_exceptions if non_retryable_exceptions is not None else self.non_retryable_exceptions, 58 | retry_policy=retry_policy if retry_policy is not None else self.retry_policy, 59 | target=target if target is not None else self.target, 60 | tags=tags if tags is not None else self.tags, 61 | timeout=timeout if timeout is not None else self.timeout, 62 | version=version if version is not None else self.version, 63 | ) 64 | 65 | def get_encoder(self) -> Encoder[Any, tuple[dict[str, str] | None, str | None]]: 66 | l = NoopEncoder() if self.encoder else HeaderEncoder("resonate:format-py", JsonPickleEncoder()) 67 | r = self.encoder or JsonEncoder() 68 | return PairEncoder(l, r) 69 | 70 | def get_idempotency_key(self, id: str) -> str | None: 71 | return self.idempotency_key(id) if callable(self.idempotency_key) else self.idempotency_key 72 | 73 | def get_retry_policy(self, func: Callable) -> RetryPolicy: 74 | return self.retry_policy(func) if callable(self.retry_policy) else self.retry_policy 75 | -------------------------------------------------------------------------------- /resonate/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resonatehq/resonate-sdk-py/d31ee539a944e8c3d6931ea063772a60b9d0bfbc/resonate/py.typed -------------------------------------------------------------------------------- /resonate/registry.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | from collections.abc import Callable 5 | from typing import overload 6 | 7 | 8 | class Registry: 9 | def __init__(self) -> None: 10 | self._forward_registry: dict[str, dict[int, tuple[str, Callable, int]]] = {} 11 | self._reverse_registry: dict[Callable, tuple[str, Callable, int]] = {} 12 | 13 | def add(self, func: Callable, name: str | None = None, version: int = 1) -> None: 14 | if not inspect.isfunction(func): 15 | msg = "provided callable must be a function" 16 | raise ValueError(msg) 17 | 18 | if not name and func.__name__ == "": 19 | msg = "name required when registering a lambda function" 20 | raise ValueError(msg) 21 | 22 | if not version > 0: 23 | msg = "provided version must be greater than zero" 24 | raise ValueError(msg) 25 | 26 | name = name or func.__name__ 27 | if version in self._forward_registry.get(name, {}) or func in self._reverse_registry: 28 | msg = f"function {name} already registered" 29 | raise ValueError(msg) 30 | 31 | item = (name, func, version) 32 | self._forward_registry.setdefault(name, {})[version] = item 33 | self._reverse_registry[func] = item 34 | 35 | @overload 36 | def get(self, func: str, version: int = 0) -> tuple[str, Callable, int]: ... 37 | @overload 38 | def get(self, func: Callable, version: int = 0) -> tuple[str, Callable, int]: ... 39 | def get(self, func: str | Callable, version: int = 0) -> tuple[str, Callable, int]: 40 | if func not in (self._forward_registry if isinstance(func, str) else self._reverse_registry): 41 | msg = f"function {func if isinstance(func, str) else getattr(func, '__name__', 'unknown')} not found in registry" 42 | raise ValueError(msg) 43 | 44 | if version != 0 and version not in (self._forward_registry[func] if isinstance(func, str) else (self._reverse_registry[func][2],)): 45 | msg = f"function {func if isinstance(func, str) else getattr(func, '__name__', 'unknown')} version {version} not found in registry" 46 | raise ValueError(msg) 47 | 48 | match func: 49 | case str(): 50 | vers = max(self._forward_registry[func]) if version == 0 else version 51 | return self._forward_registry[func][vers] 52 | 53 | case Callable(): 54 | return self._reverse_registry[func] 55 | 56 | @overload 57 | def latest(self, func: str, default: int = 1) -> int: ... 58 | @overload 59 | def latest(self, func: Callable, default: int = 1) -> int: ... 60 | def latest(self, func: str | Callable, default: int = 1) -> int: 61 | match func: 62 | case str(): 63 | return max(self._forward_registry.get(func, [default])) 64 | case Callable(): 65 | _, _, version = self._reverse_registry.get(func, (None, None, default)) 66 | return version 67 | -------------------------------------------------------------------------------- /resonate/resonate.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import copy 4 | import functools 5 | import inspect 6 | import logging 7 | import random 8 | import time 9 | import uuid 10 | from concurrent.futures import Future 11 | from typing import TYPE_CHECKING, Any, Concatenate, Literal, ParamSpec, TypeVar, TypeVarTuple, overload 12 | 13 | from resonate.bridge import Bridge 14 | from resonate.conventions import Base, Local, Remote, Sleep 15 | from resonate.coroutine import LFC, LFI, RFC, RFI, Promise 16 | from resonate.dependencies import Dependencies 17 | from resonate.loggers import ContextLogger 18 | from resonate.message_sources import LocalMessageSource, Poller 19 | from resonate.models.handle import Handle 20 | from resonate.options import Options 21 | from resonate.registry import Registry 22 | from resonate.stores import LocalStore, RemoteStore 23 | 24 | if TYPE_CHECKING: 25 | from collections.abc import Callable, Generator, Sequence 26 | 27 | from resonate.models.context import Info 28 | from resonate.models.encoder import Encoder 29 | from resonate.models.logger import Logger 30 | from resonate.models.message_source import MessageSource 31 | from resonate.models.retry_policy import RetryPolicy 32 | from resonate.models.store import PromiseStore, Store 33 | 34 | 35 | class Resonate: 36 | def __init__( 37 | self, 38 | *, 39 | pid: str | None = None, 40 | ttl: int = 10, 41 | group: str = "default", 42 | registry: Registry | None = None, 43 | dependencies: Dependencies | None = None, 44 | log_level: int | Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = logging.NOTSET, 45 | store: Store | None = None, 46 | message_source: MessageSource | None = None, 47 | ) -> None: 48 | # enforce mutual inclusion/exclusion of store and message source 49 | assert (store is None) == (message_source is None), "store and message source must both be set or both be unset" 50 | assert not isinstance(store, LocalStore) or isinstance(message_source, LocalMessageSource), "message source must be local message source" 51 | assert not isinstance(store, RemoteStore) or not isinstance(message_source, LocalMessageSource), "message source must not be local message source" 52 | 53 | self._started = False 54 | self._pid = pid or uuid.uuid4().hex 55 | self._ttl = ttl 56 | self._group = group 57 | self._opts = Options() 58 | self._registry = registry or Registry() 59 | self._dependencies = dependencies or Dependencies() 60 | self._log_level = log_level 61 | 62 | if store and message_source: 63 | self._store = store 64 | self._message_source = message_source 65 | else: 66 | self._store = LocalStore() 67 | self._message_source = self._store.message_source(self._group, self._pid) 68 | 69 | self._bridge = Bridge( 70 | lambda id, cid, info: Context(id, cid, info, self._registry, self._dependencies, ContextLogger(cid, id, self._log_level)), 71 | self._pid, 72 | self._ttl, 73 | self._opts, 74 | self._message_source.anycast, 75 | self._message_source.unicast, 76 | self._registry, 77 | self._store, 78 | self._message_source, 79 | ) 80 | 81 | @classmethod 82 | def local( 83 | cls, 84 | pid: str | None = None, 85 | ttl: int = 10, 86 | group: str = "default", 87 | registry: Registry | None = None, 88 | dependencies: Dependencies | None = None, 89 | log_level: int = logging.INFO, 90 | ) -> Resonate: 91 | pid = pid or uuid.uuid4().hex 92 | store = LocalStore() 93 | 94 | return cls( 95 | pid=pid, 96 | ttl=ttl, 97 | group=group, 98 | registry=registry, 99 | dependencies=dependencies, 100 | log_level=log_level, 101 | store=store, 102 | message_source=store.message_source(group=group, id=pid), 103 | ) 104 | 105 | @classmethod 106 | def remote( 107 | cls, 108 | host: str | None = None, 109 | store_port: str | None = None, 110 | message_source_port: str | None = None, 111 | pid: str | None = None, 112 | ttl: int = 10, 113 | group: str = "default", 114 | registry: Registry | None = None, 115 | dependencies: Dependencies | None = None, 116 | log_level: int = logging.INFO, 117 | ) -> Resonate: 118 | pid = pid or uuid.uuid4().hex 119 | 120 | return cls( 121 | pid=pid, 122 | ttl=ttl, 123 | group=group, 124 | registry=registry, 125 | dependencies=dependencies, 126 | log_level=log_level, 127 | store=RemoteStore(host=host, port=store_port), 128 | message_source=Poller(group=group, id=pid, host=host, port=message_source_port), 129 | ) 130 | 131 | @property 132 | def promises(self) -> PromiseStore: 133 | return self._store.promises 134 | 135 | def start(self) -> None: 136 | if not self._started: 137 | self._bridge.start() 138 | 139 | def stop(self) -> None: 140 | self._started = False 141 | self._bridge.stop() 142 | 143 | def options( 144 | self, 145 | *, 146 | encoder: Encoder[Any, str | None] | None = None, 147 | idempotency_key: str | Callable[[str], str] | None = None, 148 | retry_policy: RetryPolicy | Callable[[Callable], RetryPolicy] | None = None, 149 | tags: dict[str, str] | None = None, 150 | target: str | None = None, 151 | timeout: float | None = None, 152 | version: int | None = None, 153 | ) -> Resonate: 154 | copied: Resonate = copy.copy(self) 155 | copied._opts = self._opts.merge( 156 | encoder=encoder, 157 | idempotency_key=idempotency_key, 158 | retry_policy=retry_policy, 159 | tags=tags, 160 | target=target, 161 | timeout=timeout, 162 | version=version, 163 | ) 164 | 165 | return copied 166 | 167 | @overload 168 | def register[**P, R]( 169 | self, 170 | func: Callable[Concatenate[Context, P], R], 171 | /, 172 | *, 173 | name: str | None = None, 174 | version: int = 1, 175 | ) -> Function[P, R]: ... 176 | @overload 177 | def register[**P, R]( 178 | self, 179 | *, 180 | name: str | None = None, 181 | version: int = 1, 182 | ) -> Callable[[Callable[Concatenate[Context, P], R]], Function[P, R]]: ... 183 | def register[**P, R]( 184 | self, 185 | *args: Callable[Concatenate[Context, P], R] | None, 186 | name: str | None = None, 187 | version: int = 1, 188 | ) -> Function[P, R] | Callable[[Callable[Concatenate[Context, P], R]], Function[P, R]]: 189 | def wrapper(func: Callable[..., Any]) -> Function[P, R]: 190 | if isinstance(func, Function): 191 | func = func.func 192 | 193 | self._registry.add(func, name, version) 194 | return Function(self, name or func.__name__, func, self._opts.merge(version=version)) 195 | 196 | if args and args[0] is not None: 197 | return wrapper(args[0]) 198 | 199 | return wrapper 200 | 201 | @overload 202 | def run[**P, R]( 203 | self, 204 | id: str, 205 | func: Callable[Concatenate[Context, P], Generator[Any, Any, R] | R], 206 | *args: P.args, 207 | **kwargs: P.kwargs, 208 | ) -> Handle[R]: ... 209 | @overload 210 | def run( 211 | self, 212 | id: str, 213 | func: str, 214 | *args: Any, 215 | **kwargs: Any, 216 | ) -> Handle[Any]: ... 217 | def run[**P, R]( 218 | self, 219 | id: str, 220 | func: Callable[Concatenate[Context, P], Generator[Any, Any, R] | R] | str, 221 | *args: P.args, 222 | **kwargs: P.kwargs, 223 | ) -> Handle[R]: 224 | self.start() 225 | future = Future[R]() 226 | 227 | name, func, version = self._registry.get(func, self._opts.version) 228 | opts = self._opts.merge(version=version) 229 | 230 | self._bridge.run(Remote(id, id, id, name, args, kwargs, opts), func, args, kwargs, opts, future) 231 | return Handle(future) 232 | 233 | @overload 234 | def rpc[**P, R]( 235 | self, 236 | id: str, 237 | func: Callable[Concatenate[Context, P], Generator[Any, Any, R] | R], 238 | *args: P.args, 239 | **kwargs: P.kwargs, 240 | ) -> Handle[R]: ... 241 | @overload 242 | def rpc( 243 | self, 244 | id: str, 245 | func: str, 246 | *args: Any, 247 | **kwargs: Any, 248 | ) -> Handle[Any]: ... 249 | def rpc[**P, R]( 250 | self, 251 | id: str, 252 | func: Callable[Concatenate[Context, P], Generator[Any, Any, R] | R] | str, 253 | *args: P.args, 254 | **kwargs: P.kwargs, 255 | ) -> Handle[R]: 256 | self.start() 257 | future = Future[R]() 258 | 259 | if isinstance(func, str): 260 | name = func 261 | version = self._registry.latest(func) 262 | else: 263 | name, _, version = self._registry.get(func, self._opts.version) 264 | 265 | opts = self._opts.merge(version=version) 266 | self._bridge.rpc(Remote(id, id, id, name, args, kwargs, opts), opts, future) 267 | return Handle(future) 268 | 269 | def get(self, id: str) -> Handle[Any]: 270 | self.start() 271 | future = Future() 272 | 273 | self._bridge.get(id, self._opts, future) 274 | return Handle(future) 275 | 276 | def set_dependency(self, name: str, obj: Any) -> None: 277 | self._dependencies.add(name, obj) 278 | 279 | 280 | class Context: 281 | def __init__(self, id: str, cid: str, info: Info, registry: Registry, dependencies: Dependencies, logger: Logger) -> None: 282 | self._id = id 283 | self._cid = cid 284 | self._info = info 285 | self._registry = registry 286 | self._dependencies = dependencies 287 | self._logger = logger 288 | self._random = Random(self) 289 | self._time = Time(self) 290 | self._counter = 0 291 | 292 | def __repr__(self) -> str: 293 | return f"Context(id={self._id}, cid={self._cid}, info={self._info})" 294 | 295 | @property 296 | def id(self) -> str: 297 | return self._id 298 | 299 | @property 300 | def info(self) -> Info: 301 | return self._info 302 | 303 | @property 304 | def logger(self) -> Logger: 305 | return self._logger 306 | 307 | @property 308 | def random(self) -> Random: 309 | return self._random 310 | 311 | @property 312 | def time(self) -> Time: 313 | return self._time 314 | 315 | def get_dependency[T](self, key: str, default: T = None) -> Any | T: 316 | return self._dependencies.get(key, default) 317 | 318 | def lfi[**P, R]( 319 | self, 320 | func: Callable[Concatenate[Context, P], Generator[Any, Any, R] | R], 321 | *args: P.args, 322 | **kwargs: P.kwargs, 323 | ) -> LFI[R]: 324 | if isinstance(func, Function): 325 | func = func.func 326 | 327 | if not inspect.isfunction(func): 328 | msg = "provided callable must be a function" 329 | raise ValueError(msg) 330 | 331 | opts = Options(version=self._registry.latest(func)) 332 | return LFI(Local(self._next(), self._cid, self._id, opts), func, args, kwargs, opts) 333 | 334 | def lfc[**P, R]( 335 | self, 336 | func: Callable[Concatenate[Context, P], Generator[Any, Any, R] | R], 337 | *args: P.args, 338 | **kwargs: P.kwargs, 339 | ) -> LFC[R]: 340 | if isinstance(func, Function): 341 | func = func.func 342 | 343 | if not inspect.isfunction(func): 344 | msg = "provided callable must be a function" 345 | raise ValueError(msg) 346 | 347 | opts = Options(version=self._registry.latest(func)) 348 | return LFC(Local(self._next(), self._cid, self._id, opts), func, args, kwargs, opts) 349 | 350 | @overload 351 | def rfi[**P, R]( 352 | self, 353 | func: Callable[Concatenate[Context, P], Generator[Any, Any, R] | R], 354 | *args: P.args, 355 | **kwargs: P.kwargs, 356 | ) -> RFI[R]: ... 357 | @overload 358 | def rfi( 359 | self, 360 | func: str, 361 | *args: Any, 362 | **kwargs: Any, 363 | ) -> RFI: ... 364 | def rfi( 365 | self, 366 | func: Callable | str, 367 | *args: Any, 368 | **kwargs: Any, 369 | ) -> RFI: 370 | name, _, version = (func, None, self._registry.latest(func)) if isinstance(func, str) else self._registry.get(func) 371 | return RFI(Remote(self._next(), self._cid, self._id, name, args, kwargs, Options(version=version))) 372 | 373 | @overload 374 | def rfc[**P, R]( 375 | self, 376 | func: Callable[Concatenate[Context, P], Generator[Any, Any, R] | R], 377 | *args: P.args, 378 | **kwargs: P.kwargs, 379 | ) -> RFC[R]: ... 380 | @overload 381 | def rfc( 382 | self, 383 | func: str, 384 | *args: Any, 385 | **kwargs: Any, 386 | ) -> RFC: ... 387 | def rfc( 388 | self, 389 | func: Callable | str, 390 | *args: Any, 391 | **kwargs: Any, 392 | ) -> RFC: 393 | name, _, version = (func, None, self._registry.latest(func)) if isinstance(func, str) else self._registry.get(func) 394 | return RFC(Remote(self._next(), self._cid, self._id, name, args, kwargs, Options(version=version))) 395 | 396 | @overload 397 | def detached[**P, R]( 398 | self, 399 | func: Callable[Concatenate[Context, P], Generator[Any, Any, R] | R], 400 | *args: P.args, 401 | **kwargs: P.kwargs, 402 | ) -> RFI[R]: ... 403 | @overload 404 | def detached( 405 | self, 406 | func: str, 407 | *args: Any, 408 | **kwargs: Any, 409 | ) -> RFI: ... 410 | def detached( 411 | self, 412 | func: Callable | str, 413 | *args: Any, 414 | **kwargs: Any, 415 | ) -> RFI: 416 | name, _, version = (func, None, self._registry.latest(func)) if isinstance(func, str) else self._registry.get(func) 417 | return RFI(Remote(self._next(), self._cid, self._id, name, args, kwargs, Options(version=version)), mode="detached") 418 | 419 | @overload 420 | def typesafe[T](self, cmd: LFI[T] | RFI[T]) -> Generator[LFI[T] | RFI[T], Promise[T], Promise[T]]: ... 421 | @overload 422 | def typesafe[T](self, cmd: LFC[T] | RFC[T] | Promise[T]) -> Generator[LFC[T] | RFC[T] | Promise[T], T, T]: ... 423 | def typesafe(self, cmd: LFI | RFI | LFC | RFC | Promise) -> Generator[LFI | RFI | LFC | RFC | Promise, Any, Any]: 424 | return (yield cmd) 425 | 426 | def sleep(self, secs: float) -> RFC[None]: 427 | return RFC(Sleep(self._next(), secs)) 428 | 429 | def promise( 430 | self, 431 | *, 432 | id: str | None = None, 433 | timeout: float | None = None, 434 | idempotency_key: str | None = None, 435 | data: Any = None, 436 | tags: dict[str, str] | None = None, 437 | ) -> RFI: 438 | default_id = self._next() 439 | id = id or default_id 440 | 441 | return RFI( 442 | Base( 443 | id, 444 | timeout or 31536000, 445 | idempotency_key or id, 446 | data, 447 | tags, 448 | ), 449 | ) 450 | 451 | def _next(self) -> str: 452 | self._counter += 1 453 | return f"{self._id}.{self._counter}" 454 | 455 | 456 | class Time: 457 | def __init__(self, ctx: Context) -> None: 458 | self._ctx = ctx 459 | 460 | def strftime(self, format: str) -> LFC[str]: 461 | return self._ctx.lfc(lambda _: self._ctx.get_dependency("resonate:time", time).strftime(format)) 462 | 463 | def time(self) -> LFC[float]: 464 | return self._ctx.lfc(lambda _: self._ctx.get_dependency("resonate:time", time).time()) 465 | 466 | 467 | class Random: 468 | def __init__(self, ctx: Context) -> None: 469 | self._ctx = ctx 470 | 471 | def betavariate(self, alpha: float, beta: float) -> LFC[float]: 472 | return self._ctx.lfc(lambda _: self._ctx.get_dependency("resonate:random", random).betavariate(alpha, beta)) 473 | 474 | def choice[T](self, seq: Sequence[T]) -> LFC[T]: 475 | return self._ctx.lfc(lambda _: self._ctx.get_dependency("resonate:random", random).choice(seq)) 476 | 477 | def expovariate(self, lambd: float = 1) -> LFC[float]: 478 | return self._ctx.lfc(lambda _: self._ctx.get_dependency("resonate:random", random).expovariate(lambd)) 479 | 480 | def getrandbits(self, k: int) -> LFC[int]: 481 | return self._ctx.lfc(lambda _: self._ctx.get_dependency("resonate:random", random).getrandbits(k)) 482 | 483 | def randint(self, a: int, b: int) -> LFC[int]: 484 | return self._ctx.lfc(lambda _: self._ctx.get_dependency("resonate:random", random).randint(a, b)) 485 | 486 | def random(self) -> LFC[float]: 487 | return self._ctx.lfc(lambda _: self._ctx.get_dependency("resonate:random", random).random()) 488 | 489 | def randrange(self, start: int, stop: int | None = None, step: int = 1) -> LFC[int]: 490 | return self._ctx.lfc(lambda _: self._ctx.get_dependency("resonate:random", random).randrange(start, stop, step)) 491 | 492 | def triangular(self, low: float = 0, high: float = 1, mode: float | None = None) -> LFC[float]: 493 | return self._ctx.lfc(lambda _: self._ctx.get_dependency("resonate:random", random).triangular(low, high, mode)) 494 | 495 | 496 | class Function[**P, R]: 497 | __name__: str 498 | __type_params__: tuple[TypeVar | ParamSpec | TypeVarTuple, ...] = () 499 | 500 | def __init__(self, resonate: Resonate, name: str, func: Callable[Concatenate[Context, P], R], opts: Options) -> None: 501 | # updates the following attributes: 502 | # __module__ 503 | # __name__ 504 | # __qualname__ 505 | # __doc__ 506 | # __annotations__ 507 | # __type_params__ 508 | # __dict__ 509 | functools.update_wrapper(self, func) 510 | 511 | self._resonate = resonate 512 | self._name = name 513 | self._func = func 514 | self._opts = opts 515 | 516 | @property 517 | def name(self) -> str: 518 | return self._name 519 | 520 | @property 521 | def func(self) -> Callable[Concatenate[Context, P], R]: 522 | return self._func 523 | 524 | def __call__(self, ctx: Context, *args: P.args, **kwargs: P.kwargs) -> R: 525 | return self._func(ctx, *args, **kwargs) 526 | 527 | def __eq__(self, other: object) -> bool: 528 | if isinstance(other, Function): 529 | return self._func == other._func 530 | if callable(other): 531 | return self._func == other 532 | return NotImplemented 533 | 534 | def __hash__(self) -> int: 535 | # Helpful for ensuring proper registry lookups, a function and an instance of Function 536 | # that wraps the same function has the same identity. 537 | return self._func.__hash__() 538 | 539 | def options( 540 | self, 541 | *, 542 | encoder: Encoder[Any, str | None] | None = None, 543 | idempotency_key: str | Callable[[str], str] | None = None, 544 | retry_policy: RetryPolicy | Callable[[Callable], RetryPolicy] | None = None, 545 | tags: dict[str, str] | None = None, 546 | target: str | None = None, 547 | timeout: float | None = None, 548 | version: int | None = None, 549 | ) -> Function[P, R]: 550 | self._opts = self._opts.merge( 551 | encoder=encoder, 552 | idempotency_key=idempotency_key, 553 | retry_policy=retry_policy, 554 | tags=tags, 555 | target=target, 556 | timeout=timeout, 557 | version=version, 558 | ) 559 | return self 560 | 561 | def run[T](self: Function[P, Generator[Any, Any, T] | T], id: str, *args: P.args, **kwargs: P.kwargs) -> Handle[T]: 562 | resonate = self._resonate.options( 563 | encoder=self._opts.encoder, 564 | idempotency_key=self._opts.idempotency_key, 565 | retry_policy=self._opts.retry_policy, 566 | tags=self._opts.tags, 567 | target=self._opts.target, 568 | timeout=self._opts.timeout, 569 | version=self._opts.version, 570 | ) 571 | return resonate.run(id, self._func, *args, **kwargs) 572 | 573 | def rpc[T](self: Function[P, Generator[Any, Any, T] | T], id: str, *args: P.args, **kwargs: P.kwargs) -> Handle[T]: 574 | resonate = self._resonate.options( 575 | encoder=self._opts.encoder, 576 | idempotency_key=self._opts.idempotency_key, 577 | retry_policy=self._opts.retry_policy, 578 | tags=self._opts.tags, 579 | target=self._opts.target, 580 | timeout=self._opts.timeout, 581 | version=self._opts.version, 582 | ) 583 | return resonate.rpc(id, self._func, *args, **kwargs) 584 | -------------------------------------------------------------------------------- /resonate/retry_policies/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .constant import Constant 4 | from .exponential import Exponential 5 | from .linear import Linear 6 | from .never import Never 7 | 8 | __all__ = ["Constant", "Exponential", "Linear", "Never"] 9 | -------------------------------------------------------------------------------- /resonate/retry_policies/constant.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from dataclasses import dataclass 5 | from typing import final 6 | 7 | 8 | @final 9 | @dataclass(frozen=True) 10 | class Constant: 11 | delay: float = 1 12 | max_retries: int = sys.maxsize 13 | 14 | def next(self, attempt: int) -> float | None: 15 | assert attempt >= 0, "attempt must be greater than or equal to 0" 16 | 17 | if attempt > self.max_retries: 18 | return None 19 | 20 | if attempt == 0: 21 | return 0 22 | 23 | return self.delay 24 | -------------------------------------------------------------------------------- /resonate/retry_policies/exponential.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from dataclasses import dataclass 5 | from typing import final 6 | 7 | 8 | @final 9 | @dataclass(frozen=True) 10 | class Exponential: 11 | delay: float = 1 12 | max_retries: int = sys.maxsize 13 | factor: float = 2 14 | max_delay: float = 30 15 | 16 | def next(self, attempt: int) -> float | None: 17 | assert attempt >= 0, "attempt must be greater than or equal to 0" 18 | 19 | if attempt > self.max_retries: 20 | return None 21 | 22 | if attempt == 0: 23 | return 0 24 | 25 | return min(self.delay * (self.factor**attempt), self.max_delay) 26 | -------------------------------------------------------------------------------- /resonate/retry_policies/linear.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from dataclasses import dataclass 5 | from typing import final 6 | 7 | 8 | @final 9 | @dataclass(frozen=True) 10 | class Linear: 11 | delay: float = 1 12 | max_retries: int = sys.maxsize 13 | 14 | def next(self, attempt: int) -> float | None: 15 | assert attempt >= 0, "attempt must be greater than or equal to 0" 16 | 17 | if attempt > self.max_retries: 18 | return None 19 | 20 | return self.delay * attempt 21 | -------------------------------------------------------------------------------- /resonate/retry_policies/never.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import final 5 | 6 | 7 | @final 8 | @dataclass(frozen=True) 9 | class Never: 10 | def next(self, attempt: int) -> float | None: 11 | assert attempt >= 0, "attempt must be greater than or equal to 0" 12 | return 0 if attempt == 0 else None 13 | -------------------------------------------------------------------------------- /resonate/routers/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .tag import TagRouter 4 | 5 | __all__ = ["TagRouter"] 6 | -------------------------------------------------------------------------------- /resonate/routers/tag.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | if TYPE_CHECKING: 6 | from resonate.stores.local import DurablePromiseRecord 7 | 8 | 9 | class TagRouter: 10 | def __init__(self, tag: str = "resonate:invoke") -> None: 11 | self.tag = tag 12 | 13 | def route(self, promise: DurablePromiseRecord) -> Any: 14 | return (promise.tags or {}).get(self.tag) 15 | -------------------------------------------------------------------------------- /resonate/stores/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .local import LocalStore 4 | from .remote import RemoteStore 5 | 6 | __all__ = ["LocalStore", "RemoteStore"] 7 | -------------------------------------------------------------------------------- /resonate/stores/remote.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import time 5 | from typing import TYPE_CHECKING, Any 6 | 7 | import requests 8 | from requests import PreparedRequest, Request, Session 9 | 10 | from resonate.encoders import Base64Encoder 11 | from resonate.errors import ResonateStoreError 12 | from resonate.models.callback import Callback 13 | from resonate.models.durable_promise import DurablePromise 14 | from resonate.models.task import Task 15 | from resonate.retry_policies import Constant 16 | 17 | if TYPE_CHECKING: 18 | from resonate.models.encoder import Encoder 19 | from resonate.models.retry_policy import RetryPolicy 20 | 21 | 22 | class RemoteStore: 23 | def __init__( 24 | self, 25 | host: str | None = None, 26 | port: str | None = None, 27 | encoder: Encoder[str | None, str | None] | None = None, 28 | timeout: float | tuple[float, float] = 5, 29 | retry_policy: RetryPolicy | None = None, 30 | ) -> None: 31 | self._host = host or os.getenv("RESONATE_HOST_STORE", os.getenv("RESONATE_HOST", "http://localhost")) 32 | self._port = port or os.getenv("RESONATE_PORT_STORE", "8001") 33 | self._encoder = encoder or Base64Encoder() 34 | self._timeout = timeout 35 | self._retry_policy = retry_policy or Constant(delay=1, max_retries=3) 36 | 37 | self._promises = RemotePromiseStore(self) 38 | self._tasks = RemoteTaskStore(self) 39 | 40 | @property 41 | def url(self) -> str: 42 | return f"{self._host}:{self._port}" 43 | 44 | @property 45 | def encoder(self) -> Encoder[str | None, str | None]: 46 | return self._encoder 47 | 48 | @property 49 | def promises(self) -> RemotePromiseStore: 50 | return self._promises 51 | 52 | @property 53 | def tasks(self) -> RemoteTaskStore: 54 | return self._tasks 55 | 56 | def call(self, req: PreparedRequest) -> Any: 57 | attempt = 0 58 | 59 | with Session() as s: 60 | while True: 61 | delay = self._retry_policy.next(attempt) 62 | attempt += 1 63 | 64 | try: 65 | res = s.send(req, timeout=self._timeout) 66 | res.raise_for_status() 67 | data = res.json() 68 | except requests.exceptions.HTTPError as e: 69 | try: 70 | error = e.response.json()["error"] 71 | except Exception: 72 | error = {"message": e.response.text, "code": 0} 73 | 74 | # Only a 500 response code should be retried 75 | if delay is None or e.response.status_code != 500: 76 | mesg = error.get("message", "Unknown exception") 77 | code = error.get("code", 0) 78 | details = error.get("details", None) 79 | raise ResonateStoreError(mesg=mesg, code=code, details=details) from e 80 | except requests.exceptions.Timeout as e: 81 | if delay is None: 82 | raise ResonateStoreError(mesg="Request timed out", code=0) from e 83 | except requests.exceptions.ConnectionError as e: 84 | if delay is None: 85 | raise ResonateStoreError(mesg="Failed to connect", code=0) from e 86 | except Exception as e: 87 | if delay is None: 88 | raise ResonateStoreError(mesg="Unknown exception", code=0) from e 89 | else: 90 | return data 91 | 92 | time.sleep(delay) 93 | 94 | 95 | class RemotePromiseStore: 96 | def __init__(self, store: RemoteStore) -> None: 97 | self._store = store 98 | 99 | def _headers(self, *, strict: bool, ikey: str | None) -> dict[str, str]: 100 | headers: dict[str, str] = {"strict": str(strict)} 101 | if ikey is not None: 102 | headers["idempotency-Key"] = ikey 103 | return headers 104 | 105 | def get(self, id: str) -> DurablePromise: 106 | req = Request( 107 | method="get", 108 | url=f"{self._store.url}/promises/{id}", 109 | ) 110 | 111 | res = self._store.call(req.prepare()) 112 | return DurablePromise.from_dict(self._store, res) 113 | 114 | def create( 115 | self, 116 | id: str, 117 | timeout: int, 118 | *, 119 | ikey: str | None = None, 120 | strict: bool = False, 121 | headers: dict[str, str] | None = None, 122 | data: str | None = None, 123 | tags: dict[str, str] | None = None, 124 | ) -> DurablePromise: 125 | param = {} 126 | if headers is not None: 127 | param["headers"] = headers 128 | if data is not None: 129 | param["data"] = self._store.encoder.encode(data) 130 | 131 | req = Request( 132 | method="post", 133 | url=f"{self._store.url}/promises", 134 | headers=self._headers(strict=strict, ikey=ikey), 135 | json={ 136 | "id": id, 137 | "param": param, 138 | "timeout": timeout, 139 | "tags": tags or {}, 140 | }, 141 | ) 142 | res = self._store.call(req.prepare()) 143 | return DurablePromise.from_dict(self._store, res) 144 | 145 | def create_with_task( 146 | self, 147 | id: str, 148 | timeout: int, 149 | pid: str, 150 | ttl: int, 151 | *, 152 | ikey: str | None = None, 153 | strict: bool = False, 154 | headers: dict[str, str] | None = None, 155 | data: str | None = None, 156 | tags: dict[str, str] | None = None, 157 | ) -> tuple[DurablePromise, Task | None]: 158 | param = {} 159 | if headers is not None: 160 | param["headers"] = headers 161 | if data is not None: 162 | param["data"] = self._store.encoder.encode(data) 163 | 164 | req = Request( 165 | method="post", 166 | url=f"{self._store.url}/promises/task", 167 | headers=self._headers(strict=strict, ikey=ikey), 168 | json={ 169 | "promise": { 170 | "id": id, 171 | "param": param, 172 | "timeout": timeout, 173 | "tags": tags or {}, 174 | }, 175 | "task": { 176 | "processId": pid, 177 | "ttl": ttl, 178 | }, 179 | }, 180 | ) 181 | 182 | res = self._store.call(req.prepare()) 183 | promise = res["promise"] 184 | task = res.get("task") 185 | 186 | return ( 187 | DurablePromise.from_dict(self._store, promise), 188 | Task.from_dict(self._store, task) if task else None, 189 | ) 190 | 191 | def resolve( 192 | self, 193 | id: str, 194 | *, 195 | ikey: str | None = None, 196 | strict: bool = False, 197 | headers: dict[str, str] | None = None, 198 | data: str | None = None, 199 | ) -> DurablePromise: 200 | value = {} 201 | if headers is not None: 202 | value["headers"] = headers 203 | if data is not None: 204 | value["data"] = self._store.encoder.encode(data) 205 | 206 | req = Request( 207 | method="patch", 208 | url=f"{self._store.url}/promises/{id}", 209 | headers=self._headers(strict=strict, ikey=ikey), 210 | json={ 211 | "state": "RESOLVED", 212 | "value": value, 213 | }, 214 | ) 215 | 216 | res = self._store.call(req.prepare()) 217 | return DurablePromise.from_dict(self._store, res) 218 | 219 | def reject( 220 | self, 221 | id: str, 222 | *, 223 | ikey: str | None = None, 224 | strict: bool = False, 225 | headers: dict[str, str] | None = None, 226 | data: str | None = None, 227 | ) -> DurablePromise: 228 | value = {} 229 | if headers is not None: 230 | value["headers"] = headers 231 | if data is not None: 232 | value["data"] = self._store.encoder.encode(data) 233 | 234 | req = Request( 235 | method="patch", 236 | url=f"{self._store.url}/promises/{id}", 237 | headers=self._headers(strict=strict, ikey=ikey), 238 | json={ 239 | "state": "REJECTED", 240 | "value": value, 241 | }, 242 | ) 243 | 244 | res = self._store.call(req.prepare()) 245 | return DurablePromise.from_dict(self._store, res) 246 | 247 | def cancel( 248 | self, 249 | id: str, 250 | *, 251 | ikey: str | None = None, 252 | strict: bool = False, 253 | headers: dict[str, str] | None = None, 254 | data: str | None = None, 255 | ) -> DurablePromise: 256 | value = {} 257 | if headers is not None: 258 | value["headers"] = headers 259 | if data is not None: 260 | value["data"] = self._store.encoder.encode(data) 261 | 262 | req = Request( 263 | method="patch", 264 | url=f"{self._store.url}/promises/{id}", 265 | headers=self._headers(strict=strict, ikey=ikey), 266 | json={ 267 | "state": "REJECTED_CANCELED", 268 | "value": value, 269 | }, 270 | ) 271 | 272 | res = self._store.call(req.prepare()) 273 | return DurablePromise.from_dict(self._store, res) 274 | 275 | def callback( 276 | self, 277 | promise_id: str, 278 | root_promise_id: str, 279 | recv: str, 280 | timeout: int, 281 | ) -> tuple[DurablePromise, Callback | None]: 282 | req = Request( 283 | method="post", 284 | url=f"{self._store.url}/callbacks", 285 | json={ 286 | "id": " ", # TODO(dfarr): remove eventually, for backwards compatibility 287 | "promiseId": promise_id, 288 | "rootPromiseId": root_promise_id, 289 | "timeout": timeout, 290 | "recv": recv, 291 | }, 292 | ) 293 | 294 | res = self._store.call(req.prepare()) 295 | promise = res["promise"] 296 | callback = res.get("callback") 297 | 298 | return ( 299 | DurablePromise.from_dict(self._store, promise), 300 | Callback.from_dict(callback) if callback else None, 301 | ) 302 | 303 | def subscribe( 304 | self, 305 | id: str, 306 | promise_id: str, 307 | recv: str, 308 | timeout: int, 309 | ) -> tuple[DurablePromise, Callback | None]: 310 | req = Request( 311 | method="post", 312 | url=f"{self._store.url}/subscriptions", 313 | json={ 314 | "id": id, 315 | "promiseId": promise_id, 316 | "timeout": timeout, 317 | "recv": recv, 318 | }, 319 | ) 320 | 321 | res = self._store.call(req.prepare()) 322 | promise = res["promise"] 323 | callback = res.get("callback") 324 | 325 | return ( 326 | DurablePromise.from_dict(self._store, promise), 327 | Callback.from_dict(callback) if callback else None, 328 | ) 329 | 330 | 331 | class RemoteTaskStore: 332 | def __init__(self, store: RemoteStore) -> None: 333 | self._store = store 334 | 335 | def claim( 336 | self, 337 | id: str, 338 | counter: int, 339 | pid: str, 340 | ttl: int, 341 | ) -> tuple[DurablePromise, DurablePromise | None]: 342 | req = Request( 343 | method="post", 344 | url=f"{self._store.url}/tasks/claim", 345 | json={ 346 | "id": id, 347 | "counter": counter, 348 | "processId": pid, 349 | "ttl": ttl, 350 | }, 351 | ) 352 | 353 | res = self._store.call(req.prepare()) 354 | root = res["promises"]["root"]["data"] 355 | leaf = res["promises"].get("leaf", {}).get("data") 356 | 357 | return ( 358 | DurablePromise.from_dict(self._store, root), 359 | DurablePromise.from_dict(self._store, leaf) if leaf else None, 360 | ) 361 | 362 | def complete( 363 | self, 364 | id: str, 365 | counter: int, 366 | ) -> bool: 367 | req = Request( 368 | method="post", 369 | url=f"{self._store.url}/tasks/complete", 370 | json={ 371 | "id": id, 372 | "counter": counter, 373 | }, 374 | ) 375 | 376 | self._store.call(req.prepare()) 377 | return True 378 | 379 | def heartbeat( 380 | self, 381 | pid: str, 382 | ) -> int: 383 | req = Request( 384 | method="post", 385 | url=f"{self._store.url}/tasks/heartbeat", 386 | json={ 387 | "processId": pid, 388 | }, 389 | ) 390 | 391 | res = self._store.call(req.prepare()) 392 | return res["tasksAffected"] 393 | -------------------------------------------------------------------------------- /resonate/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import os 5 | import threading 6 | import traceback 7 | import urllib.parse 8 | from functools import wraps 9 | from importlib.metadata import version 10 | from typing import TYPE_CHECKING, Any 11 | 12 | if TYPE_CHECKING: 13 | from collections.abc import Callable 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def exit_on_exception[**P, R](func: Callable[P, R]) -> Callable[P, R]: 19 | @wraps(func) 20 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: 21 | try: 22 | return func(*args, **kwargs) 23 | except Exception as e: 24 | body = f""" 25 | An exception occurred in the resonate python sdk. 26 | 27 | **Version** 28 | ``` 29 | {resonate_version()} 30 | ``` 31 | 32 | **Thread** 33 | ``` 34 | {threading.current_thread().name} 35 | ``` 36 | 37 | **Exception** 38 | ``` 39 | {e!r} 40 | ``` 41 | 42 | **Stacktrace** 43 | ``` 44 | {traceback.format_exc()} 45 | ``` 46 | 47 | **Additional context** 48 | Please provide any additional context that might help us debug this issue. 49 | """ 50 | 51 | format = """ 52 | Resonate encountered an unexpected exception and had to shut down. 53 | 54 | 📦 Version: %s 55 | 🧵 Thread: %s 56 | ❌ Exception: %s 57 | 📄 Stacktrace: 58 | ───────────────────────────────────────────────────────────────────── 59 | %s 60 | ───────────────────────────────────────────────────────────────────── 61 | 62 | 🔗 Please help us make resonate better by reporting this issue: 63 | https://github.com/resonatehq/resonate-sdk-py/issues/new?body=%s 64 | """ 65 | logger.critical( 66 | format, 67 | resonate_version(), 68 | threading.current_thread().name, 69 | repr(e), 70 | traceback.format_exc(), 71 | urllib.parse.quote(body), 72 | ) 73 | 74 | # Exit the process with a non-zero exit code, this kills all 75 | # threads 76 | os._exit(1) 77 | 78 | return wrapper 79 | 80 | 81 | def resonate_version() -> str: 82 | try: 83 | return version("resonate-sdk") 84 | except Exception: 85 | return "unknown" 86 | 87 | 88 | def format_args_and_kwargs(args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: 89 | parts = [repr(arg) for arg in args] 90 | parts += [f"{k}={v!r}" for k, v in kwargs.items()] 91 | return ", ".join(parts) 92 | 93 | 94 | def truncate(s: str, n: int) -> str: 95 | if len(s) > n: 96 | return s[:n] + "..." 97 | return s 98 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 200 2 | 3 | [lint] 4 | ignore = [ 5 | "A001", 6 | "A002", 7 | "A005", 8 | "A006", 9 | "ANN401", 10 | "ARG001", 11 | "ARG002", 12 | "BLE001", 13 | "C901", 14 | "COM812", 15 | "D100", 16 | "D101", 17 | "D102", 18 | "D103", 19 | "D104", 20 | "D105", 21 | "D107", 22 | "D203", 23 | "D211", 24 | "D213", 25 | "E501", 26 | "E741", 27 | "ERA001", 28 | "FBT001", 29 | "FIX002", 30 | "INP001", 31 | "PLR0911", 32 | "PLR0912", 33 | "PLR0913", 34 | "PLR0915", 35 | "PLR2004", 36 | "S101", 37 | "S311", 38 | "TD003", 39 | ] 40 | select = ["ALL"] 41 | 42 | [lint.isort] 43 | combine-as-imports = true 44 | required-imports = ["from __future__ import annotations"] 45 | -------------------------------------------------------------------------------- /scripts/new-release.py: -------------------------------------------------------------------------------- 1 | """New release.""" 2 | 3 | from __future__ import annotations 4 | 5 | import pathlib 6 | import tomllib 7 | import webbrowser 8 | from typing import Any 9 | from urllib.parse import urlencode 10 | 11 | 12 | def project_project(cwd: pathlib.Path) -> dict[str, Any]: 13 | """Read `pyproject.toml` project.""" 14 | pyproject = tomllib.loads( 15 | cwd.joinpath( 16 | "pyproject.toml", 17 | ).read_text(encoding="utf-8"), 18 | ) 19 | 20 | return pyproject["project"] 21 | 22 | 23 | def main() -> None: 24 | """Prepare new release.""" 25 | cwd = pathlib.Path().cwd() 26 | project = project_project(cwd=cwd) 27 | version = project["version"] 28 | source = project["urls"]["Source"] 29 | params = urlencode( 30 | query={ 31 | "title": f"v{version}", 32 | "tag": f"v{version}", 33 | }, 34 | ) 35 | webbrowser.open_new_tab(url=f"{source}/releases/new?{params}") 36 | 37 | 38 | if __name__ == "__main__": 39 | main() 40 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resonatehq/resonate-sdk-py/d31ee539a944e8c3d6931ea063772a60b9d0bfbc/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import os 5 | import random 6 | import sys 7 | from typing import TYPE_CHECKING 8 | 9 | import pytest 10 | 11 | from resonate.message_sources import Poller 12 | from resonate.stores import LocalStore, RemoteStore 13 | 14 | if TYPE_CHECKING: 15 | from collections.abc import Generator 16 | 17 | from resonate.models.message_source import MessageSource 18 | from resonate.models.store import Store 19 | 20 | 21 | def pytest_configure() -> None: 22 | logging.basicConfig(level=logging.ERROR) # set log levels very high for tests 23 | 24 | 25 | def pytest_addoption(parser: pytest.Parser) -> None: 26 | parser.addoption("--seed", action="store") 27 | parser.addoption("--steps", action="store") 28 | 29 | 30 | # DST fixtures 31 | 32 | 33 | @pytest.fixture 34 | def seed(request: pytest.FixtureRequest) -> str: 35 | seed = request.config.getoption("--seed") 36 | 37 | if not isinstance(seed, str): 38 | return str(random.randint(0, sys.maxsize)) 39 | 40 | return seed 41 | 42 | 43 | @pytest.fixture 44 | def steps(request: pytest.FixtureRequest) -> int: 45 | steps = request.config.getoption("--steps") 46 | 47 | if isinstance(steps, str): 48 | try: 49 | return int(steps) 50 | except ValueError: 51 | pass 52 | 53 | return 10000 54 | 55 | 56 | @pytest.fixture 57 | def log_level(request: pytest.FixtureRequest) -> int: 58 | level = request.config.getoption("--log-level") 59 | 60 | if isinstance(level, str) and level.isdigit(): 61 | return int(level) 62 | 63 | match str(level).lower(): 64 | case "critical": 65 | return logging.CRITICAL 66 | case "error": 67 | return logging.ERROR 68 | case "warning" | "warn": 69 | return logging.WARNING 70 | case "info": 71 | return logging.INFO 72 | case "debug": 73 | return logging.DEBUG 74 | case _: 75 | return logging.NOTSET 76 | 77 | 78 | # Store fixtures 79 | 80 | 81 | @pytest.fixture( 82 | scope="module", 83 | params=[LocalStore, RemoteStore] if "RESONATE_HOST" in os.environ else [LocalStore], 84 | ) 85 | def store(request: pytest.FixtureRequest) -> Store: 86 | return request.param() 87 | 88 | 89 | @pytest.fixture 90 | def message_source(store: Store) -> Generator[MessageSource]: 91 | match store: 92 | case LocalStore(): 93 | ms = store.message_source(group="default", id="test") 94 | case _: 95 | assert isinstance(store, RemoteStore) 96 | ms = Poller(group="default", id="test") 97 | 98 | # start the message source 99 | ms.start() 100 | 101 | yield ms 102 | 103 | # stop the message source 104 | ms.stop() 105 | -------------------------------------------------------------------------------- /tests/runners.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import sys 5 | import time 6 | import uuid 7 | from concurrent.futures import Future 8 | from inspect import isgeneratorfunction 9 | from typing import TYPE_CHECKING, Any, Protocol 10 | 11 | from resonate.conventions import Base, Local 12 | from resonate.coroutine import LFC, LFI, RFC, RFI 13 | from resonate.encoders import JsonEncoder, NoopEncoder, PairEncoder 14 | from resonate.models.commands import ( 15 | CancelPromiseReq, 16 | CancelPromiseRes, 17 | Command, 18 | CreateCallbackReq, 19 | CreatePromiseReq, 20 | CreatePromiseRes, 21 | CreatePromiseWithTaskReq, 22 | CreatePromiseWithTaskRes, 23 | Function, 24 | Invoke, 25 | Network, 26 | Receive, 27 | RejectPromiseReq, 28 | RejectPromiseRes, 29 | ResolvePromiseReq, 30 | ResolvePromiseRes, 31 | Resume, 32 | Return, 33 | ) 34 | from resonate.models.result import Ko, Ok 35 | from resonate.models.task import Task 36 | from resonate.options import Options 37 | from resonate.resonate import Remote 38 | from resonate.scheduler import Scheduler 39 | from resonate.stores import LocalStore 40 | 41 | if TYPE_CHECKING: 42 | from collections.abc import Callable 43 | 44 | from resonate.models.context import Info 45 | from resonate.models.logger import Logger 46 | from resonate.registry import Registry 47 | 48 | 49 | # Context 50 | class Context: 51 | def __init__(self, id: str, cid: str) -> None: 52 | self.id = id 53 | self.cid = cid 54 | 55 | @property 56 | def info(self) -> Info: 57 | raise NotImplementedError 58 | 59 | @property 60 | def logger(self) -> Logger: 61 | return logging.getLogger("test") 62 | 63 | def get_dependency(self, key: str, default: Any = None) -> Any: 64 | return default 65 | 66 | def lfi(self, func: str | Callable, *args: Any, **kwargs: Any) -> LFI: 67 | assert not isinstance(func, str) 68 | return LFI(Local(uuid.uuid4().hex, self.cid, self.id), func, args, kwargs) 69 | 70 | def lfc(self, func: str | Callable, *args: Any, **kwargs: Any) -> LFC: 71 | assert not isinstance(func, str) 72 | return LFC(Local(uuid.uuid4().hex, self.cid, self.id), func, args, kwargs) 73 | 74 | def rfi(self, func: str | Callable, *args: Any, **kwargs: Any) -> RFI: 75 | assert not isinstance(func, str) 76 | return RFI(Remote(uuid.uuid4().hex, self.cid, self.id, func.__name__, args, kwargs)) 77 | 78 | def rfc(self, func: str | Callable, *args: Any, **kwargs: Any) -> RFC: 79 | assert not isinstance(func, str) 80 | return RFC(Remote(uuid.uuid4().hex, self.cid, self.id, func.__name__, args, kwargs)) 81 | 82 | def detached(self, func: str | Callable, *args: Any, **kwargs: Any) -> RFI: 83 | assert not isinstance(func, str) 84 | return RFI(Remote(uuid.uuid4().hex, self.cid, self.id, func.__name__, args, kwargs), mode="detached") 85 | 86 | 87 | class LocalContext: 88 | def __init__(self, id: str, cid: str) -> None: 89 | self.id = id 90 | self.cid = cid 91 | 92 | @property 93 | def info(self) -> Info: 94 | raise NotImplementedError 95 | 96 | @property 97 | def logger(self) -> Logger: 98 | return logging.getLogger("test") 99 | 100 | def get_dependency(self, key: str, default: Any = None) -> Any: 101 | return default 102 | 103 | def lfi(self, func: str | Callable, *args: Any, **kwargs: Any) -> LFI: 104 | assert not isinstance(func, str) 105 | return LFI(Local(uuid.uuid4().hex, self.cid, self.id), func, args, kwargs) 106 | 107 | def lfc(self, func: str | Callable, *args: Any, **kwargs: Any) -> LFC: 108 | assert not isinstance(func, str) 109 | return LFC(Local(uuid.uuid4().hex, self.cid, self.id), func, args, kwargs) 110 | 111 | def rfi(self, func: str | Callable, *args: Any, **kwargs: Any) -> LFI: 112 | assert not isinstance(func, str) 113 | return LFI(Local(uuid.uuid4().hex, self.cid, self.id), func, args, kwargs) 114 | 115 | def rfc(self, func: str | Callable, *args: Any, **kwargs: Any) -> LFC: 116 | assert not isinstance(func, str) 117 | return LFC(Local(uuid.uuid4().hex, self.cid, self.id), func, args, kwargs) 118 | 119 | def detached(self, func: str | Callable, *args: Any, **kwargs: Any) -> LFI: 120 | assert not isinstance(func, str) 121 | return LFI(Local(uuid.uuid4().hex, self.cid, self.id), func, args, kwargs) 122 | 123 | 124 | class RemoteContext: 125 | def __init__(self, id: str, cid: str) -> None: 126 | self.id = id 127 | self.cid = cid 128 | 129 | @property 130 | def info(self) -> Info: 131 | raise NotImplementedError 132 | 133 | @property 134 | def logger(self) -> Logger: 135 | return logging.getLogger("test") 136 | 137 | def get_dependency(self, key: str, default: Any = None) -> Any: 138 | return default 139 | 140 | def lfi(self, func: str | Callable, *args: Any, **kwargs: Any) -> RFI: 141 | assert not isinstance(func, str) 142 | return RFI(Remote(uuid.uuid4().hex, self.cid, self.id, func.__name__, args, kwargs)) 143 | 144 | def lfc(self, func: str | Callable, *args: Any, **kwargs: Any) -> RFC: 145 | assert not isinstance(func, str) 146 | return RFC(Remote(uuid.uuid4().hex, self.cid, self.id, func.__name__, args, kwargs)) 147 | 148 | def rfi(self, func: str | Callable, *args: Any, **kwargs: Any) -> RFI: 149 | assert not isinstance(func, str) 150 | return RFI(Remote(uuid.uuid4().hex, self.cid, self.id, func.__name__, args, kwargs)) 151 | 152 | def rfc(self, func: str | Callable, *args: Any, **kwargs: Any) -> RFC: 153 | assert not isinstance(func, str) 154 | return RFC(Remote(uuid.uuid4().hex, self.cid, self.id, func.__name__, args, kwargs)) 155 | 156 | def detached(self, func: str | Callable, *args: Any, **kwargs: Any) -> RFI: 157 | assert not isinstance(func, str) 158 | return RFI(Remote(uuid.uuid4().hex, self.cid, self.id, func.__name__, args, kwargs), mode="detached") 159 | 160 | 161 | # Runners 162 | 163 | 164 | class Runner(Protocol): 165 | def run[**P, R](self, id: str, func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R: ... 166 | 167 | 168 | class SimpleRunner: 169 | def run[**P, R](self, id: str, func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R: 170 | return self._run(id, func, args, kwargs) 171 | 172 | def _run(self, id: str, func: Callable, args: tuple, kwargs: dict) -> Any: 173 | if not isgeneratorfunction(func): 174 | return func(None, *args, **kwargs) 175 | 176 | g = func(LocalContext(id, id), *args, **kwargs) 177 | v = None 178 | 179 | try: 180 | while True: 181 | match g.send(v): 182 | case LFI(conv, func, args, kwargs): 183 | v = (conv.id, func, args, kwargs) 184 | case LFC(conv, func, args, kwargs): 185 | v = self._run(conv.id, func, args, kwargs) 186 | case (id, func, args, kwargs): 187 | v = self._run(id, func, args, kwargs) 188 | except StopIteration as e: 189 | return e.value 190 | 191 | 192 | class ResonateRunner: 193 | def __init__(self, registry: Registry) -> None: 194 | # registry 195 | self.registry = registry 196 | 197 | # store 198 | self.store = LocalStore() 199 | 200 | # encoder 201 | self.encoder = PairEncoder(NoopEncoder(), JsonEncoder()) 202 | 203 | # create scheduler and connect store 204 | self.scheduler = Scheduler(ctx=lambda id, cid, *_: Context(id, cid)) 205 | 206 | def run[**P, R](self, id: str, func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R: 207 | cmds: list[Command] = [] 208 | init = True 209 | conv = Remote(id, id, id, func.__name__, args, kwargs) 210 | future = Future[R]() 211 | 212 | headers, data = self.encoder.encode(conv.data) 213 | assert headers is None 214 | 215 | promise, _ = self.store.promises.create_with_task( 216 | id=conv.id, 217 | ikey=conv.idempotency_key, 218 | timeout=int((time.time() + conv.timeout) * 1000), 219 | data=data, 220 | tags=conv.tags, 221 | pid=self.scheduler.pid, 222 | ttl=sys.maxsize, 223 | ) 224 | 225 | cmds.append(Invoke(id, conv, promise.abs_timeout, func, args, kwargs, promise=promise)) 226 | 227 | while cmds: 228 | next = self.scheduler.step(cmds.pop(0), future if init else None) 229 | init = False 230 | 231 | for req in next.reqs: 232 | match req: 233 | case Function(_id, cid, f): 234 | try: 235 | r = Ok(f()) 236 | except Exception as e: 237 | r = Ko(e) 238 | cmds.append(Return(_id, cid, r)) 239 | 240 | case Network(_id, cid, CreatePromiseReq(id, timeout, ikey, strict, headers, data, tags)): 241 | promise = self.store.promises.create( 242 | id=id, 243 | timeout=timeout, 244 | ikey=ikey, 245 | strict=strict, 246 | headers=headers, 247 | data=data, 248 | tags=tags, 249 | ) 250 | cmds.append(Receive(_id, cid, CreatePromiseRes(promise))) 251 | 252 | case Network(_id, cid, CreatePromiseWithTaskReq(id, timeout, pid, ttl, ikey, strict, headers, data, tags)): 253 | promise, task = self.store.promises.create_with_task( 254 | id=id, 255 | timeout=timeout, 256 | pid=pid, 257 | ttl=ttl, 258 | ikey=ikey, 259 | strict=strict, 260 | headers=headers, 261 | data=data, 262 | tags=tags, 263 | ) 264 | cmds.append(Receive(_id, cid, CreatePromiseWithTaskRes(promise, task))) 265 | 266 | case Network(_id, cid, ResolvePromiseReq(id, ikey, strict, headers, data)): 267 | promise = self.store.promises.resolve( 268 | id=id, 269 | ikey=ikey, 270 | strict=strict, 271 | headers=headers, 272 | data=data, 273 | ) 274 | cmds.append(Receive(_id, cid, ResolvePromiseRes(promise))) 275 | 276 | case Network(_id, cid, RejectPromiseReq(id, ikey, strict, headers, data)): 277 | promise = self.store.promises.reject( 278 | id=id, 279 | ikey=ikey, 280 | strict=strict, 281 | headers=headers, 282 | data=data, 283 | ) 284 | cmds.append(Receive(_id, cid, RejectPromiseRes(promise))) 285 | 286 | case Network(_id, cid, CancelPromiseReq(id, ikey, strict, headers, data)): 287 | promise = self.store.promises.cancel( 288 | id=id, 289 | ikey=ikey, 290 | strict=strict, 291 | headers=headers, 292 | data=data, 293 | ) 294 | cmds.append(Receive(_id, cid, CancelPromiseRes(promise))) 295 | 296 | case Network(_id, cid, CreateCallbackReq(promise_id, root_promise_id, timeout, recv)): 297 | promise, callback = self.store.promises.callback( 298 | promise_id=promise_id, 299 | root_promise_id=root_promise_id, 300 | recv=recv, 301 | timeout=timeout, 302 | ) 303 | if promise.completed: 304 | assert not callback 305 | cmds.append(Resume(_id, cid, promise)) 306 | 307 | case _: 308 | raise NotImplementedError 309 | 310 | for _, msg in self.store.step(): 311 | match msg: 312 | case {"type": "invoke", "task": {"id": id, "counter": counter}}: 313 | task = Task(id=id, counter=counter, store=self.store) 314 | root, leaf = task.claim(pid=self.scheduler.pid, ttl=sys.maxsize) 315 | assert root.pending 316 | assert not leaf 317 | 318 | data = self.encoder.decode(root.param.to_tuple()) 319 | assert isinstance(data, dict) 320 | assert "func" in data 321 | assert "args" in data 322 | assert "kwargs" in data 323 | 324 | _, func, version = self.registry.get(data["func"]) 325 | 326 | cmds.append( 327 | Invoke( 328 | root.id, 329 | Base( 330 | root.id, 331 | root.rel_timeout, 332 | root.ikey_for_create, 333 | root.param.data, 334 | root.tags, 335 | ), 336 | root.abs_timeout, 337 | func, 338 | data["args"], 339 | data["kwargs"], 340 | Options(version=version), 341 | root, 342 | ) 343 | ) 344 | 345 | case {"type": "resume", "task": {"id": id, "counter": counter}}: 346 | task = Task(id=id, counter=counter, store=self.store) 347 | root, leaf = task.claim(pid=self.scheduler.pid, ttl=sys.maxsize) 348 | assert root.pending 349 | assert leaf 350 | assert leaf.completed 351 | 352 | cmds.append( 353 | Resume( 354 | id=leaf.id, 355 | cid=root.id, 356 | promise=leaf, 357 | ) 358 | ) 359 | 360 | case _: 361 | raise NotImplementedError 362 | 363 | return future.result() 364 | 365 | 366 | class ResonateLFXRunner(ResonateRunner): 367 | def __init__(self, registry: Registry) -> None: 368 | self.registry = registry 369 | 370 | # create store 371 | self.store = LocalStore() 372 | 373 | # create encoder 374 | self.encoder = PairEncoder(NoopEncoder(), JsonEncoder()) 375 | 376 | # create scheduler 377 | self.scheduler = Scheduler(ctx=lambda id, cid, *_: LocalContext(id, cid)) 378 | 379 | 380 | class ResonateRFXRunner(ResonateRunner): 381 | def __init__(self, registry: Registry) -> None: 382 | self.registry = registry 383 | 384 | # create store 385 | self.store = LocalStore() 386 | 387 | # create encoder 388 | self.encoder = PairEncoder(NoopEncoder(), JsonEncoder()) 389 | 390 | # create scheduler and connect store 391 | self.scheduler = Scheduler(ctx=lambda id, cid, *_: RemoteContext(id, cid)) 392 | -------------------------------------------------------------------------------- /tests/test_bridge.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | import threading 5 | import time 6 | import uuid 7 | from typing import TYPE_CHECKING, Any, Literal 8 | from unittest.mock import patch 9 | 10 | import pytest 11 | 12 | from resonate.errors import ResonateShutdownError, ResonateStoreError 13 | from resonate.resonate import Resonate 14 | from resonate.retry_policies import Constant, Never 15 | from resonate.stores import LocalStore 16 | 17 | if TYPE_CHECKING: 18 | from collections.abc import Generator 19 | 20 | from resonate.coroutine import Yieldable 21 | from resonate.models.message_source import MessageSource 22 | from resonate.models.store import Store 23 | from resonate.resonate import Context 24 | 25 | 26 | def foo_lfi(ctx: Context) -> Generator: 27 | p = yield ctx.lfi(bar_lfi) 28 | v = yield p 29 | return v 30 | 31 | 32 | def bar_lfi(ctx: Context) -> Generator: 33 | p = yield ctx.lfi(baz) 34 | v = yield p 35 | return v 36 | 37 | 38 | def foo_rfi(ctx: Context) -> Generator: 39 | p = yield ctx.rfi(bar_rfi) 40 | v = yield p 41 | return v 42 | 43 | 44 | def bar_rfi(ctx: Context) -> Generator: 45 | p = yield ctx.rfi(baz) 46 | v = yield p 47 | return v 48 | 49 | 50 | def baz(ctx: Context) -> str: 51 | return "baz" 52 | 53 | 54 | def fib_lfi(ctx: Context, n: int) -> Generator[Any, Any, int]: 55 | if n <= 1: 56 | return n 57 | 58 | p1 = yield ctx.lfi(fib_lfi, n - 1).options(id=f"fli{n - 1}") 59 | p2 = yield ctx.lfi(fib_lfi, n - 2).options(id=f"fli{n - 2}") 60 | 61 | v1 = yield p1 62 | v2 = yield p2 63 | 64 | return v1 + v2 65 | 66 | 67 | def fib_lfc(ctx: Context, n: int) -> Generator[Any, Any, int]: 68 | if n <= 1: 69 | return n 70 | 71 | v1 = yield ctx.lfc(fib_lfc, n - 1).options(id=f"flc{n - 1}") 72 | v2 = yield ctx.lfc(fib_lfc, n - 2).options(id=f"flc{n - 2}") 73 | 74 | return v1 + v2 75 | 76 | 77 | def fib_rfi(ctx: Context, n: int) -> Generator[Any, Any, int]: 78 | if n <= 1: 79 | return n 80 | 81 | p1 = yield ctx.rfi(fib_rfi, n - 1).options(id=f"fri{n - 1}") 82 | p2 = yield ctx.rfi(fib_rfi, n - 2).options(id=f"fri{n - 2}") 83 | 84 | v1 = yield p1 85 | v2 = yield p2 86 | 87 | return v1 + v2 88 | 89 | 90 | def fib_rfc(ctx: Context, n: int) -> Generator[Any, Any, int]: 91 | if n <= 1: 92 | return n 93 | 94 | v1 = yield ctx.rfc(fib_rfc, n - 1).options(id=f"frc{n - 1}") 95 | v2 = yield ctx.rfc(fib_rfc, n - 2).options(id=f"frc{n - 2}") 96 | 97 | return v1 + v2 98 | 99 | 100 | def sleep(ctx: Context, n: int) -> Generator[Yieldable, Any, int]: 101 | yield ctx.sleep(n) 102 | return 1 103 | 104 | 105 | def add_one(ctx: Context, n: int) -> int: 106 | return n + 1 107 | 108 | 109 | def get_dependency(ctx: Context) -> int: 110 | dep = ctx.get_dependency("foo") 111 | assert dep is not None 112 | return dep + 1 113 | 114 | 115 | def rfi_add_one_by_name(ctx: Context, n: int) -> Generator[Any, Any, int]: 116 | v = yield ctx.rfc("add_one", n) 117 | return v 118 | 119 | 120 | def hitl(ctx: Context, id: str | None) -> Generator[Yieldable, Any, int]: 121 | if id: 122 | p = yield ctx.promise().options(id=id) 123 | else: 124 | p = yield ctx.promise() 125 | v = yield p 126 | return v 127 | 128 | 129 | def random_generation(ctx: Context) -> Generator[Yieldable, Any, float]: 130 | return (yield ctx.random.randint(0, 10)) 131 | 132 | 133 | def info1(ctx: Context, idempotency_key: str, tags: dict[str, str], version: int) -> None: 134 | assert ctx.info.attempt == 1 135 | assert ctx.info.idempotency_key == idempotency_key 136 | assert ctx.info.tags == tags 137 | assert ctx.info.version == version 138 | 139 | 140 | def info2(ctx: Context, *args: Any, **kwargs: Any) -> Generator[Yieldable, Any, None]: 141 | info1(ctx, *args, **kwargs) 142 | yield ctx.lfc(info1, f"{ctx.id}.1", {"resonate:root": ctx.id, "resonate:parent": ctx.id, "resonate:scope": "local"}, 1) 143 | yield ctx.rfc(info1, f"{ctx.id}.2", {"resonate:root": ctx.id, "resonate:parent": ctx.id, "resonate:scope": "global", "resonate:invoke": "default"}, 1) 144 | yield (yield ctx.lfi(info1, f"{ctx.id}.3", {"resonate:root": ctx.id, "resonate:parent": ctx.id, "resonate:scope": "local"}, 1)) 145 | yield (yield ctx.rfi(info1, f"{ctx.id}.4", {"resonate:root": ctx.id, "resonate:parent": ctx.id, "resonate:scope": "global", "resonate:invoke": "default"}, 1)) 146 | yield (yield ctx.detached(info1, f"{ctx.id}.5", {"resonate:root": ctx.id, "resonate:parent": ctx.id, "resonate:scope": "global", "resonate:invoke": "default"}, 1)) 147 | 148 | 149 | def parent_bound(ctx: Context, child_timeout_rel: float, mode: Literal["rfc", "lfc"]) -> Generator[Yieldable, Any, None]: 150 | match mode: 151 | case "lfc": 152 | yield ctx.lfc(child_bounded, ctx.info.timeout).options(timeout=child_timeout_rel) 153 | case "rfc": 154 | yield ctx.rfc(child_bounded, ctx.info.timeout).options(timeout=child_timeout_rel) 155 | 156 | 157 | def child_bounded(ctx: Context, parent_timeout_abs: float) -> None: 158 | assert not (ctx.info.timeout > parent_timeout_abs) # child timeout never exceeds parent timeout 159 | 160 | 161 | def unbound_detached( 162 | ctx: Context, 163 | parent_timeout_rel: float, 164 | child_timeout_rel: float, 165 | ) -> Generator[Yieldable, Any, None]: 166 | p = yield ctx.detached(child_unbounded, parent_timeout_rel, child_timeout_rel, ctx.info.timeout).options(timeout=child_timeout_rel) 167 | yield p 168 | 169 | 170 | def child_unbounded(ctx: Context, parent_timeout_rel: float, child_timeout_rel: float, parent_timeout_abs: float) -> None: 171 | if parent_timeout_rel < child_timeout_rel: 172 | assert ctx.info.timeout > parent_timeout_abs 173 | elif parent_timeout_rel > child_timeout_rel: 174 | assert ctx.info.timeout < parent_timeout_abs 175 | else: 176 | assert pytest.approx(ctx.info.timeout) == parent_timeout_abs 177 | 178 | 179 | def wkflw(ctx: Context, durable: bool) -> Generator[Yieldable, Any, None]: 180 | yield ctx.lfc(failure_fn).options(timeout=1, retry_policy=Constant(delay=10, max_retries=1_000_000), durable=durable) 181 | 182 | 183 | def failure_fn(ctx: Context) -> None: 184 | raise RuntimeError 185 | 186 | 187 | def failure_wkflw(ctx: Context) -> Generator[Yieldable, Any, None]: 188 | yield ctx.lfc(add_one, 1) 189 | raise RuntimeError 190 | 191 | 192 | @pytest.fixture 193 | def resonate(store: Store, message_source: MessageSource) -> Generator[Resonate, None, None]: 194 | resonate = Resonate(store=store, message_source=message_source) 195 | resonate.register(foo_lfi) 196 | resonate.register(bar_lfi) 197 | resonate.register(foo_rfi) 198 | resonate.register(bar_rfi) 199 | resonate.register(baz) 200 | resonate.register(fib_lfi) 201 | resonate.register(fib_lfc) 202 | resonate.register(fib_rfi) 203 | resonate.register(fib_rfc) 204 | resonate.register(sleep) 205 | resonate.register(add_one) 206 | resonate.register(rfi_add_one_by_name) 207 | resonate.register(get_dependency) 208 | resonate.register(hitl) 209 | resonate.register(random_generation) 210 | resonate.register(info1, name="info", version=1) 211 | resonate.register(info2, name="info", version=2) 212 | resonate.register(parent_bound) 213 | resonate.register(child_bounded) 214 | resonate.register(unbound_detached) 215 | resonate.register(child_unbounded) 216 | resonate.register(wkflw) 217 | resonate.register(failure_wkflw) 218 | 219 | # start resonate (this startes the bridge) 220 | resonate.start() 221 | 222 | yield resonate 223 | 224 | # stop resonate (and the bridge) 225 | resonate.stop() 226 | 227 | 228 | def test_local_invocations_with_registered_functions(resonate: Resonate) -> None: 229 | @resonate.register 230 | def recursive(ctx: Context, n: int) -> Generator[Yieldable, Any, int]: 231 | if n == 1: 232 | return 1 233 | elif n % 2 == 0: 234 | return (yield ctx.lfc(recursive, n - 1)) 235 | else: 236 | return (yield (yield ctx.lfi(recursive, n - 1))) 237 | 238 | assert recursive.run("recursive", 5).result() == 1 239 | 240 | 241 | @pytest.mark.parametrize("durable", [True, False]) 242 | def test_fail_immediately_fn(resonate: Resonate, durable: bool) -> None: 243 | with pytest.raises(RuntimeError): 244 | resonate.run(f"fail-immediately-fn-{uuid.uuid4()}", wkflw, durable).result() 245 | 246 | 247 | def test_fail_immediately_coro(resonate: Resonate) -> None: 248 | with pytest.raises(RuntimeError): 249 | resonate.options(timeout=1, retry_policy=Constant(delay=10, max_retries=1_000_000)).run(f"fail-immediately-coro-{uuid.uuid4()}", failure_wkflw).result() 250 | 251 | 252 | @pytest.mark.parametrize("mode", ["rfc", "lfc"]) 253 | @pytest.mark.parametrize(("parent_timeout", "child_timeout"), [(1100, 10), (10, 1100), (10, 10), (10, 11), (11, 10)]) 254 | def test_timeout_bound_by_parent(resonate: Resonate, mode: Literal["rfc", "lfc"], parent_timeout: float, child_timeout: float) -> None: 255 | resonate.options(timeout=parent_timeout).run(f"parent-bound-timeout-{uuid.uuid4()}", parent_bound, child_timeout, mode).result() 256 | 257 | 258 | @pytest.mark.parametrize( 259 | ("parent_timeout", "child_timeout"), 260 | [ 261 | (1100, 10), 262 | (10, 1100), 263 | (10, 10), 264 | (10, 11), 265 | (11, 10), 266 | ], 267 | ) 268 | def test_timeout_unbound_by_parent_detached(resonate: Resonate, parent_timeout: float, child_timeout: float) -> None: 269 | resonate.options(timeout=parent_timeout).run(f"parent-bound-timeout-{uuid.uuid4()}", unbound_detached, parent_timeout, child_timeout).result() 270 | 271 | 272 | def test_random_generation(resonate: Resonate) -> None: 273 | timestamp = int(time.time()) 274 | handle = resonate.run(f"random-gen-{timestamp}", random_generation) 275 | v = handle.result() 276 | assert v == resonate.run(f"random-gen-{timestamp}", random_generation).result() 277 | 278 | 279 | @pytest.mark.parametrize("id", ["foo", None]) 280 | def test_hitl(resonate: Resonate, id: str | None) -> None: 281 | uid = uuid.uuid4() 282 | handle = resonate.run(f"hitl-{uid}", hitl, id) 283 | time.sleep(1) 284 | resonate.promises.resolve(id=id or f"hitl-{uid}.1", data="1") 285 | assert handle.result() == 1 286 | 287 | 288 | def test_get_dependency(resonate: Resonate) -> None: 289 | timestamp = int(time.time()) 290 | resonate.set_dependency("foo", 1) 291 | handle = resonate.run(f"get-dependency-{timestamp}", get_dependency) 292 | assert handle.result() == 2 293 | 294 | 295 | def test_basic_lfi(resonate: Resonate) -> None: 296 | timestamp = int(time.time()) 297 | handle = resonate.run(f"foo-lfi-{timestamp}", foo_lfi) 298 | assert handle.result() == "baz" 299 | 300 | 301 | def test_basic_rfi(resonate: Resonate) -> None: 302 | timestamp = int(time.time()) 303 | handle = resonate.run(f"foo-rfi-{timestamp}", foo_rfi) 304 | assert handle.result() == "baz" 305 | 306 | 307 | def test_rfi_by_name(resonate: Resonate) -> None: 308 | timestamp = int(time.time()) 309 | handle = resonate.rpc(f"add_one_by_name_rfi-{timestamp}", "rfi_add_one_by_name", 42) 310 | assert handle.result() == 43 311 | 312 | 313 | def test_fib_lfi(resonate: Resonate) -> None: 314 | timestamp = int(time.time()) 315 | handle = resonate.run(f"fib_lfi-{timestamp}", fib_lfi, 10) 316 | fib_10 = 55 317 | assert handle.result() == fib_10 318 | 319 | 320 | def test_fib_rfi(resonate: Resonate) -> None: 321 | timestamp = int(time.time()) 322 | handle = resonate.run(f"fib_rfi-{timestamp}", fib_rfi, 10) 323 | fib_10 = 55 324 | assert handle.result() == fib_10 325 | 326 | 327 | def test_fib_lfc(resonate: Resonate) -> None: 328 | timestamp = int(time.time()) 329 | handle = resonate.run(f"fib_lfc-{timestamp}", fib_lfc, 10) 330 | fib_10 = 55 331 | assert handle.result() == fib_10 332 | 333 | 334 | def test_fib_rfc(resonate: Resonate) -> None: 335 | timestamp = int(time.time()) 336 | handle = resonate.run(f"fib_rfc-{timestamp}", fib_rfc, 10) 337 | fib_10 = 55 338 | assert handle.result() == fib_10 339 | 340 | 341 | def test_sleep(resonate: Resonate) -> None: 342 | timestamp = int(time.time()) 343 | handle = resonate.run(f"sleep-{timestamp}", sleep, 0) 344 | assert handle.result() == 1 345 | 346 | 347 | def test_handle_timeout(resonate: Resonate) -> None: 348 | timestamp = int(time.time()) 349 | handle = resonate.run(f"handle-timeout-{timestamp}", sleep, 1) 350 | with pytest.raises(TimeoutError): 351 | handle.result(timeout=0.1) 352 | assert handle.result() == 1 353 | 354 | 355 | def test_basic_retries() -> None: 356 | # Use a different instance that only do local store 357 | resonate = Resonate() 358 | 359 | def retriable(ctx: Context) -> int: 360 | if ctx.info.attempt == 4: 361 | return ctx.info.attempt 362 | raise RuntimeError 363 | 364 | f = resonate.register(retriable) 365 | resonate.start() 366 | 367 | start_time = time.time() 368 | handle = f.options(retry_policy=Constant(delay=1, max_retries=3)).run(f"retriable-{int(start_time)}") 369 | result = handle.result() 370 | end_time = time.time() 371 | 372 | assert result == 4 373 | delta = end_time - start_time 374 | assert delta >= 3.0 375 | assert delta < 4.0 # This is kind of arbitrary, if it is failing feel free to increase the number 376 | 377 | resonate.stop() 378 | 379 | 380 | def test_listen(resonate: Resonate) -> None: 381 | timestamp = int(time.time()) 382 | handle = resonate.rpc(f"add_one_{timestamp}", "add_one", 42) 383 | assert handle.result() == 43 384 | 385 | 386 | def test_implicit_resonate_start() -> None: 387 | resonate = Resonate() 388 | 389 | def f(ctx: Context, n: int) -> Generator[Any, Any, int]: 390 | if n == 0: 391 | return 1 392 | 393 | v = yield ctx.rfc(f, n - 1) 394 | return v + n 395 | 396 | r = resonate.register(f) 397 | 398 | timestamp = int(time.time()) 399 | handle = r.run(f"r-implicit-start-{timestamp}", 1) 400 | result = handle.result() 401 | assert result == 2 402 | 403 | 404 | @pytest.mark.parametrize("idempotency_key", ["foo", None]) 405 | @pytest.mark.parametrize("tags", [{"foo": "bar"}, None]) 406 | @pytest.mark.parametrize("target", ["foo", "bar", None]) 407 | @pytest.mark.parametrize("version", [1, 2]) 408 | def test_info( 409 | idempotency_key: str | None, 410 | resonate: Resonate, 411 | tags: dict[str, str] | None, 412 | target: str | None, 413 | version: int, 414 | ) -> None: 415 | id = f"info-{uuid.uuid4()}" 416 | 417 | resonate = resonate.options( 418 | idempotency_key=idempotency_key, 419 | retry_policy=Never(), 420 | tags=tags, 421 | target=target, 422 | timeout=10, 423 | version=version, 424 | ) 425 | 426 | handle = resonate.run( 427 | id, 428 | "info", 429 | idempotency_key or id, 430 | {**(tags or {}), "resonate:root": id, "resonate:parent": id, "resonate:scope": "global", "resonate:invoke": target or "default"}, 431 | version, 432 | ) 433 | 434 | handle.result() 435 | 436 | 437 | def test_resonate_get(resonate: Resonate) -> None: 438 | def resolve_promise_slow(id: str) -> None: 439 | time.sleep(1) 440 | resonate.promises.resolve(id=id, data="42") 441 | 442 | timestamp = int(time.time()) 443 | id = f"get.{timestamp}" 444 | resonate.promises.create(id=id, timeout=sys.maxsize) 445 | thread = threading.Thread(target=resolve_promise_slow, args=(id,), daemon=True) # Do this in a different thread to simulate concurrency 446 | 447 | handle = resonate.get(id) 448 | 449 | thread.start() 450 | res = handle.result() 451 | assert res == 42 452 | thread.join() 453 | 454 | 455 | def test_resonate_platform_errors() -> None: 456 | # If you look at this test and you think: "This is horrible" 457 | # You are right, this test is cursed. But it needed to be done. 458 | local_store = LocalStore() 459 | resonate = Resonate( 460 | store=local_store, 461 | message_source=local_store.message_source("default", "default"), 462 | ) 463 | 464 | original_transition = local_store.promises.transition 465 | raise_flag = [False] # Use mutable container for flag 466 | 467 | def side_effect(*args: Any, **kwargs: Any) -> Any: 468 | if raise_flag[0]: 469 | msg = "Got an error from server" 470 | raise ResonateStoreError(msg, 0) 471 | 472 | return original_transition(*args[1:], **kwargs) 473 | 474 | def g(_: Context) -> int: 475 | return 42 476 | 477 | def f(ctx: Context, flag: bool) -> Generator[Any, Any, None]: 478 | raise_flag[0] = flag # Update mutable flag 479 | val = yield ctx.rfc(g) 480 | return val 481 | 482 | with patch.object( 483 | local_store.promises, 484 | "transition", 485 | side_effect=side_effect, 486 | ): 487 | resonate.register(f) 488 | resonate.register(g) 489 | 490 | # First test normal behavior 491 | handle = resonate.run("f-no-err", f, flag=False) 492 | assert handle.result() == 42 493 | 494 | # Now trigger errors 495 | handle = resonate.run("f-err", f, flag=True) 496 | with pytest.raises(ResonateShutdownError): 497 | handle.result() 498 | -------------------------------------------------------------------------------- /tests/test_delay_q.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from resonate.delay_q import DelayQ 4 | 5 | 6 | def test_delay_queue() -> None: 7 | dq = DelayQ[int]() 8 | dq.add(1, 10) 9 | dq.add(1, 10) 10 | dq.add(1, 10) 11 | assert dq.get(11)[0] == [1, 1, 1] 12 | assert dq.get(11)[0] == [] 13 | dq.add(1, 2) 14 | dq.add(2, 1) 15 | item, next_time = dq.get(1) 16 | assert item == [2] 17 | assert next_time == 2 18 | 19 | item, next_time = dq.get(2) 20 | assert item == [1] 21 | assert next_time == 0 22 | 23 | dq.add(1, 2) 24 | dq.add(1, 2) 25 | dq.add(1, 2) 26 | assert dq.get(2)[0] == [1, 1, 1] 27 | -------------------------------------------------------------------------------- /tests/test_dst.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import logging 5 | import random 6 | import re 7 | from typing import TYPE_CHECKING, Any 8 | 9 | from tabulate import tabulate 10 | 11 | from resonate.clocks import StepClock 12 | from resonate.conventions import Remote 13 | from resonate.dependencies import Dependencies 14 | from resonate.models.commands import Invoke, Listen 15 | from resonate.models.result import Ko, Ok, Result 16 | from resonate.options import Options 17 | from resonate.registry import Registry 18 | from resonate.scheduler import Coro, Init, Lfnc, Rfnc 19 | from resonate.simulator import Server, Simulator, Worker 20 | 21 | if TYPE_CHECKING: 22 | from collections.abc import Generator 23 | 24 | from resonate import Context 25 | from resonate.coroutine import Promise 26 | from resonate.models.context import Info 27 | 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | 32 | def foo(ctx: Context) -> Generator[Any, Any, Any]: 33 | p1 = yield ctx.lfi(bar) 34 | p2 = yield ctx.rfi(bar) 35 | yield ctx.lfi(bar) 36 | yield ctx.rfi(bar) 37 | yield ctx.lfc(bar) 38 | yield ctx.rfc(bar) 39 | yield ctx.detached(bar) 40 | 41 | return (yield p1), (yield p2) 42 | 43 | 44 | def bar(ctx: Context) -> Generator[Any, Any, Any]: 45 | p1 = yield ctx.lfi(baz) 46 | p2 = yield ctx.rfi(baz) 47 | yield ctx.lfi(baz) 48 | yield ctx.rfi(baz) 49 | yield ctx.lfc(baz) 50 | yield ctx.rfc(baz) 51 | yield ctx.detached(baz) 52 | 53 | return (yield p1), (yield p2) 54 | 55 | 56 | def baz(ctx: Context) -> str: 57 | return "baz" 58 | 59 | 60 | def foo_lfi(ctx: Context) -> Generator[Any, Any, Any]: 61 | p = yield ctx.lfi(bar_lfi) 62 | v = yield p 63 | return v 64 | 65 | 66 | def bar_lfi(ctx: Context) -> Generator[Any, Any, Any]: 67 | p = yield ctx.lfi(baz) 68 | v = yield p 69 | return v 70 | 71 | 72 | def foo_lfc(ctx: Context) -> Generator[Any, Any, Any]: 73 | v = yield ctx.lfc(bar_lfc) 74 | return v 75 | 76 | 77 | def bar_lfc(ctx: Context) -> Generator[Any, Any, Any]: 78 | v = yield ctx.lfc(baz) 79 | return v 80 | 81 | 82 | def foo_rfi(ctx: Context) -> Generator[Any, Any, Any]: 83 | p = yield ctx.rfi(bar_rfi) 84 | v = yield p 85 | return v 86 | 87 | 88 | def bar_rfi(ctx: Context) -> Generator[Any, Any, Any]: 89 | p = yield ctx.rfi(baz) 90 | v = yield p 91 | return v 92 | 93 | 94 | def foo_rfc(ctx: Context) -> Generator[Any, Any, Any]: 95 | v = yield ctx.lfc(bar_rfc) 96 | return v 97 | 98 | 99 | def bar_rfc(ctx: Context) -> Generator[Any, Any, Any]: 100 | v = yield ctx.rfc(baz) 101 | return v 102 | 103 | 104 | def foo_detached(ctx: Context) -> Generator[Any, Any, Any]: 105 | p = yield ctx.detached(bar_detached) 106 | return p.id 107 | 108 | 109 | def bar_detached(ctx: Context) -> Generator[Any, Any, Any]: 110 | p = yield ctx.detached(baz) 111 | return p.id 112 | 113 | 114 | def structured_concurrency_lfi(ctx: Context) -> Generator[Any, Any, Any]: 115 | p1 = yield ctx.lfi(baz) 116 | p2 = yield ctx.lfi(baz) 117 | p3 = yield ctx.lfi(baz) 118 | return p1.id, p2.id, p3.id 119 | 120 | 121 | def structured_concurrency_rfi(ctx: Context) -> Generator[Any, Any, Any]: 122 | p1 = yield ctx.rfi(baz) 123 | p2 = yield ctx.rfi(baz) 124 | p3 = yield ctx.rfi(baz) 125 | return p1.id, p2.id, p3.id 126 | 127 | 128 | def same_p_lfi(ctx: Context, n: int) -> Generator[Any, Any, None]: 129 | for _ in range(n): 130 | yield ctx.lfi(_same_p_lfi, f"{ctx.id}:common") 131 | 132 | 133 | def _same_p_lfi(ctx: Context, id: str) -> Generator[Any, Any, None]: 134 | yield ctx.lfi(baz).options(id=id) 135 | yield ctx.lfi(baz) 136 | 137 | 138 | def same_p_rfi(ctx: Context, n: int) -> Generator[Any, Any, None]: 139 | for _ in range(n): 140 | yield ctx.lfi(_same_p_rfi, f"{ctx.id}:common") 141 | 142 | 143 | def _same_p_rfi(ctx: Context, id: str) -> Generator[Any, Any, None]: 144 | yield ctx.rfi(baz).options(id=id) 145 | yield ctx.lfi(baz) 146 | 147 | 148 | def same_v_lfi(ctx: Context, n: int) -> Generator[Any, Any, None]: 149 | p = yield ctx.lfi(baz) 150 | for _ in range(n): 151 | yield ctx.lfi(_same_v, p) 152 | 153 | 154 | def same_v_rfi(ctx: Context, n: int) -> Generator[Any, Any, None]: 155 | p = yield ctx.rfi(baz) 156 | for _ in range(n): 157 | yield ctx.lfi(_same_v, p) 158 | 159 | 160 | def _same_v(ctx: Context, p: Promise) -> Generator[Any, Any, None]: 161 | yield p 162 | yield ctx.lfi(baz) 163 | 164 | 165 | def fail_25(ctx: Context) -> str: 166 | r = ctx.get_dependency("resonate:random") 167 | assert isinstance(r, random.Random) 168 | 169 | if r.random() < 0.25: 170 | msg = f"ko — {ctx.info.attempt} attempt(s)" 171 | raise RuntimeError(msg) 172 | 173 | return f"ok — {ctx.info.attempt} attempt(s)" 174 | 175 | 176 | def fail_50(ctx: Context) -> str: 177 | r = ctx.get_dependency("resonate:random") 178 | assert isinstance(r, random.Random) 179 | 180 | if r.random() < 0.50: 181 | msg = f"ko — {ctx.info.attempt} attempt(s)" 182 | raise RuntimeError(msg) 183 | 184 | return f"ok — {ctx.info.attempt} attempt(s)" 185 | 186 | 187 | def fail_75(ctx: Context) -> str: 188 | r = ctx.get_dependency("resonate:random") 189 | assert isinstance(r, random.Random) 190 | 191 | if r.random() < 0.75: 192 | msg = f"ko — {ctx.info.attempt} attempt(s)" 193 | raise RuntimeError(msg) 194 | 195 | return f"ok — {ctx.info.attempt} attempt(s)" 196 | 197 | 198 | def fail_99(ctx: Context) -> str: 199 | r = ctx.get_dependency("resonate:random") 200 | assert isinstance(r, random.Random) 201 | 202 | if r.random() < 0.99: 203 | msg = f"ko — {ctx.info.attempt} attempt(s)" 204 | raise RuntimeError(msg) 205 | 206 | return f"ok — {ctx.info.attempt} attempt(s)" 207 | 208 | 209 | def fib(ctx: Context, n: int) -> Generator[Any, Any, int]: 210 | if n <= 1: 211 | return n 212 | 213 | ps = [] 214 | vs = [] 215 | for i in range(1, 3): 216 | match (yield ctx.random.choice(["lfi", "rfi", "lfc", "rfc"]).options(id=f"fib:{n - i}")): 217 | case "lfi": 218 | p = yield ctx.lfi(fib, n - i).options(id=f"fib-{n - i}") 219 | ps.append(p) 220 | case "rfi": 221 | p = yield ctx.rfi(fib, n - i).options(id=f"fib-{n - i}") 222 | ps.append(p) 223 | case "lfc": 224 | v = yield ctx.lfc(fib, n - i).options(id=f"fib-{n - i}") 225 | vs.append(v) 226 | case "rfc": 227 | v = yield ctx.rfc(fib, n - i).options(id=f"fib-{n - i}") 228 | vs.append(v) 229 | 230 | for p in ps: 231 | v = yield p 232 | vs.append(v) 233 | 234 | assert len(vs) == 2 235 | return sum(vs) 236 | 237 | 238 | def test_dst(seed: str, steps: int, log_level: int) -> None: 239 | logger.setLevel(log_level or logging.INFO) # if log level is not set use INFO for dst 240 | logger.info("DST(seed=%s, steps=%s, log_level=%s)", seed, steps, log_level) 241 | 242 | # create seeded random number generator 243 | r = random.Random(seed) 244 | 245 | # create a step clock 246 | clock = StepClock() 247 | 248 | # create a registry 249 | registry = Registry() 250 | registry.add(foo, "foo") 251 | registry.add(bar, "bar") 252 | registry.add(baz, "baz") 253 | registry.add(foo_lfi, "foo_lfi") 254 | registry.add(bar_lfi, "bar_lfi") 255 | registry.add(foo_lfc, "foo_lfc") 256 | registry.add(bar_lfc, "bar_lfc") 257 | registry.add(foo_rfi, "foo_rfi") 258 | registry.add(bar_rfi, "bar_rfi") 259 | registry.add(foo_rfc, "foo_rfc") 260 | registry.add(bar_rfc, "bar_rfc") 261 | registry.add(foo_detached, "foo_detached") 262 | registry.add(bar_detached, "bar_detached") 263 | registry.add(structured_concurrency_lfi, "structured_concurrency_lfi") 264 | registry.add(structured_concurrency_rfi, "structured_concurrency_rfi") 265 | registry.add(same_p_lfi, "same_p_lfi") 266 | registry.add(same_p_rfi, "same_p_rfi") 267 | registry.add(same_v_lfi, "same_v_lfi") 268 | registry.add(same_v_rfi, "same_v_rfi") 269 | registry.add(fail_25, "fail_25") 270 | registry.add(fail_50, "fail_50") 271 | registry.add(fail_75, "fail_75") 272 | registry.add(fail_99, "fail_99") 273 | registry.add(fib, "fib") 274 | 275 | # create dependencies 276 | dependencies = Dependencies() 277 | dependencies.add("resonate:random", r) 278 | dependencies.add("resonate:time", clock) 279 | 280 | # create a simulator 281 | sim = Simulator(r, clock) 282 | servers: list[Server] = [ 283 | Server( 284 | r, 285 | "sim://uni@server", 286 | "sim://any@server", 287 | clock=clock, 288 | ), 289 | ] 290 | workers: list[Worker] = [ 291 | Worker( 292 | r, 293 | f"sim://uni@default/{n}", 294 | f"sim://any@default/{n}", 295 | clock=clock, 296 | registry=registry, 297 | dependencies=dependencies, 298 | log_level=log_level, 299 | ) 300 | for n in range(10) 301 | ] 302 | 303 | # add components to simlator 304 | for c in servers + workers: 305 | sim.add_component(c) 306 | 307 | # step the simlator 308 | for _ in range(steps): 309 | # step 310 | sim.step() 311 | 312 | # only generate command 10% of the time 313 | if r.random() > 0.1: 314 | continue 315 | 316 | # id set 317 | n = r.randint(0, 99) 318 | id = str(n) 319 | 320 | # opts 321 | opts = Options( 322 | timeout=r.randint(0, steps), 323 | ) 324 | 325 | # generate commands 326 | match r.randint(0, 24): 327 | case 0: 328 | sim.send_msg("sim://any@default", Listen(id)) 329 | case 1: 330 | conv = Remote(id, id, id, "foo", opts=opts) 331 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, foo)) 332 | case 2: 333 | conv = Remote(id, id, id, "bar", opts=opts) 334 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, bar)) 335 | case 3: 336 | conv = Remote(id, id, id, "baz", opts=opts) 337 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, baz)) 338 | case 4: 339 | conv = Remote(id, id, id, "foo_lfi", opts=opts) 340 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, foo_lfi)) 341 | case 5: 342 | conv = Remote(id, id, id, "bar_lfi", opts=opts) 343 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, bar_lfi)) 344 | case 6: 345 | conv = Remote(id, id, id, "foo_lfc", opts=opts) 346 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, foo_lfc)) 347 | case 7: 348 | conv = Remote(id, id, id, "bar_lfc", opts=opts) 349 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, bar_lfc)) 350 | case 8: 351 | conv = Remote(id, id, id, "foo_rfi", opts=opts) 352 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, foo_rfi)) 353 | case 9: 354 | conv = Remote(id, id, id, "bar_rfi", opts=opts) 355 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, bar_rfi)) 356 | case 10: 357 | conv = Remote(id, id, id, "foo_rfc", opts=opts) 358 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, foo_rfc)) 359 | case 11: 360 | conv = Remote(id, id, id, "bar_rfc", opts=opts) 361 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, bar_rfc)) 362 | case 12: 363 | conv = Remote(id, id, id, "foo_detached", opts=opts) 364 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, foo_detached)) 365 | case 13: 366 | conv = Remote(id, id, id, "bar_detached", opts=opts) 367 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, bar_detached)) 368 | case 14: 369 | conv = Remote(id, id, id, "structured_concurrency_lfi", opts=opts) 370 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, structured_concurrency_lfi)) 371 | case 15: 372 | conv = Remote(id, id, id, "same_p_lfi", (n,), opts=opts) 373 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, same_p_lfi, (n,))) 374 | case 16: 375 | conv = Remote(id, id, id, "same_p_rfi", (n,), opts=opts) 376 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, same_p_rfi, (n,))) 377 | case 17: 378 | conv = Remote(id, id, id, "same_v_lfi", (n,), opts=opts) 379 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, same_v_lfi, (n,))) 380 | case 18: 381 | conv = Remote(id, id, id, "same_v_rfi", (n,), opts=opts) 382 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, same_v_rfi, (n,))) 383 | case 19: 384 | conv = Remote(id, id, id, "structured_concurrency_rfi", opts=opts) 385 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, structured_concurrency_rfi)) 386 | case 20: 387 | conv = Remote(id, id, id, "fail_25", opts=opts) 388 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, fail_25)) 389 | case 21: 390 | conv = Remote(id, id, id, "fail_50", opts=opts) 391 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, fail_50)) 392 | case 22: 393 | conv = Remote(id, id, id, "fail_75", opts=opts) 394 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, fail_75)) 395 | case 23: 396 | conv = Remote(id, id, id, "fail_99", opts=opts) 397 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, fail_99)) 398 | case 24: 399 | conv = Remote(id, id, id, "fib", (n,), opts=opts) 400 | sim.send_msg("sim://any@default", Invoke(id, conv, 0, fib, (n,))) 401 | 402 | # log 403 | for log in sim.logs: 404 | logger.info(log) 405 | 406 | print_worker_computations(workers) 407 | 408 | 409 | def print_worker_computations(workers: list[Worker]) -> None: 410 | head = ["id", "worker", "func", "result", "attempts", "timeout"] 411 | data = sorted( 412 | [ 413 | [c.id, w.uni, func(c.graph.root.value.func), traffic_light(c.result()), info(c.graph.root.value.func).attempt, f"{info(c.graph.root.value.func).timeout:,.0f}"] 414 | for w in workers 415 | for c in w.scheduler.computations.values() 416 | ], 417 | key=lambda row: natsort(row[0]), # sort by computation id 418 | ) 419 | logger.debug("\n%s", tabulate(data, head, tablefmt="outline", colalign=("left", "left", "left", "left", "right", "right"))) 420 | 421 | 422 | def func(f: Init | Lfnc | Rfnc | Coro | None) -> str: 423 | match f: 424 | case Init(next): 425 | return func(next) 426 | case Lfnc(func=fn) | Coro(func=fn): 427 | return fn.__name__ 428 | case Rfnc(conv=conv): 429 | return json.loads(conv.data)["func"] 430 | case None: 431 | return "" 432 | 433 | 434 | def info(f: Init | Lfnc | Rfnc | Coro | None) -> Info: 435 | class InfoPlaceholder: 436 | attempt = 0 437 | idempotency_key = None 438 | tags = None 439 | timeout = 0 440 | version = 0 441 | 442 | match f: 443 | case Lfnc(ctx=ctx) | Coro(ctx=ctx): 444 | return ctx.info 445 | case _: 446 | return InfoPlaceholder() 447 | 448 | 449 | def traffic_light(r: Result | None) -> str: 450 | match r: 451 | case Ok(v): 452 | return f"🟢 {v}" 453 | case None: 454 | return "🟡" 455 | case Ko(e): 456 | return f"🔴 {e}" 457 | 458 | 459 | def natsort(s: str | int) -> list[str | int]: 460 | return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", str(s))] 461 | -------------------------------------------------------------------------------- /tests/test_encoders.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from types import NoneType 4 | from typing import Any 5 | 6 | import pytest 7 | 8 | from resonate.encoders import Base64Encoder, HeaderEncoder, JsonEncoder, JsonPickleEncoder, PairEncoder 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "value", 13 | ["", "foo", "bar", "baz", None], 14 | ) 15 | def test_base64_enconder(value: str) -> None: 16 | encoder = Base64Encoder() 17 | encoded = encoder.encode(value) 18 | assert isinstance(encoded, (str, NoneType)) 19 | 20 | decoded = encoder.decode(encoded) 21 | assert type(decoded) is type(value) 22 | assert decoded == value 23 | 24 | 25 | @pytest.mark.parametrize( 26 | "value", 27 | [ 28 | "", 29 | "foo", 30 | "bar", 31 | "baz", 32 | 0, 33 | 1, 34 | 2, 35 | [], 36 | ["foo", "bar", "baz"], 37 | [0, 1, 2], 38 | {}, 39 | {"foo": "foo", "bar": "bar", "baz": "baz"}, 40 | {"foo": 0, "bar": 1, "baz": 2}, 41 | None, 42 | (), 43 | ("foo", "bar", "baz"), 44 | (0, 1, 2), 45 | Exception("foo"), 46 | ValueError("bar"), 47 | BaseException("baz"), 48 | ], 49 | ) 50 | def test_json_encoder(value: Any) -> None: 51 | encoder = JsonEncoder() 52 | encoded = encoder.encode(value) 53 | assert isinstance(encoded, (str, NoneType)) 54 | 55 | match decoded := encoder.decode(encoded): 56 | case BaseException() as e: 57 | # all exceptions are flattened to Exception 58 | assert isinstance(decoded, Exception) 59 | assert str(e) == str(value) 60 | case list(items): 61 | # tuples are converted to lists 62 | assert isinstance(value, (list, tuple)) 63 | assert items == list(value) 64 | case _: 65 | assert type(decoded) is type(value) 66 | assert decoded == value 67 | 68 | 69 | @pytest.mark.parametrize( 70 | "value", 71 | [ 72 | "", 73 | "foo", 74 | "bar", 75 | "baz", 76 | 0, 77 | 1, 78 | 2, 79 | [], 80 | ["foo", "bar", "baz"], 81 | [0, 1, 2], 82 | {}, 83 | {"foo": "foo", "bar": "bar", "baz": "baz"}, 84 | {"foo": 0, "bar": 1, "baz": 2}, 85 | None, 86 | (), 87 | ("foo", "bar", "baz"), 88 | (0, 1, 2), 89 | Exception("foo"), 90 | ValueError("bar"), 91 | BaseException("baz"), 92 | ], 93 | ) 94 | def test_jsonpickle_encoder(value: Any) -> None: 95 | encoder = JsonPickleEncoder() 96 | encoded = encoder.encode(value) 97 | assert isinstance(encoded, (str, NoneType)) 98 | 99 | decoded = encoder.decode(encoded) 100 | assert type(decoded) is type(value) 101 | 102 | if not isinstance(value, BaseException): 103 | assert decoded == value 104 | 105 | 106 | @pytest.mark.parametrize( 107 | "value", 108 | [ 109 | "", 110 | "foo", 111 | "bar", 112 | "baz", 113 | 0, 114 | 1, 115 | 2, 116 | [], 117 | ["foo", "bar", "baz"], 118 | [0, 1, 2], 119 | {}, 120 | {"foo": "foo", "bar": "bar", "baz": "baz"}, 121 | {"foo": 0, "bar": 1, "baz": 2}, 122 | None, 123 | (), 124 | ("foo", "bar", "baz"), 125 | (0, 1, 2), 126 | Exception("foo"), 127 | ValueError("bar"), 128 | ], 129 | ) 130 | def test_default_encoder(value: Any) -> None: 131 | encoder = PairEncoder(HeaderEncoder("resonate:format-py", JsonPickleEncoder()), JsonEncoder()) 132 | headers, encoded = encoder.encode(value) 133 | assert isinstance(headers, dict) 134 | assert "resonate:format-py" in headers 135 | assert isinstance(headers["resonate:format-py"], str) 136 | assert isinstance(encoded, (str, NoneType)) 137 | 138 | # primary decoder 139 | for decoded in (encoder.decode((headers, encoded)), JsonPickleEncoder().decode(headers["resonate:format-py"])): 140 | assert type(decoded) is type(value) 141 | if not isinstance(value, Exception): 142 | assert decoded == value 143 | 144 | # backup decoder 145 | match decoded := JsonEncoder().decode(encoded): 146 | case BaseException() as e: 147 | # all exceptions are flattened to Exception 148 | assert isinstance(decoded, Exception) 149 | assert str(e) == str(value) 150 | case list(items): 151 | # tuples are converted to lists 152 | assert isinstance(value, (list, tuple)) 153 | assert items == list(value) 154 | case _: 155 | assert type(decoded) is type(value) 156 | assert decoded == value 157 | -------------------------------------------------------------------------------- /tests/test_equivalencies.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import itertools 4 | from typing import TYPE_CHECKING, Any 5 | 6 | import pytest 7 | 8 | from resonate.registry import Registry 9 | from tests.runners import LocalContext, RemoteContext, ResonateLFXRunner, ResonateRFXRunner, ResonateRunner, Runner, SimpleRunner 10 | 11 | if TYPE_CHECKING: 12 | from collections.abc import Callable, Generator 13 | 14 | 15 | # Functions 16 | 17 | 18 | def fib(ctx: LocalContext | RemoteContext, n: int) -> Generator[Any, Any, int]: 19 | if n <= 1: 20 | return n 21 | 22 | p1 = yield ctx.rfi(fib, n - 1).options(id=f"fib({n - 1})") 23 | p2 = yield ctx.rfi(fib, n - 2).options(id=f"fib({n - 2})") 24 | 25 | v1 = yield p1 26 | v2 = yield p2 27 | 28 | return v1 + v2 29 | 30 | 31 | def fac(ctx: LocalContext | RemoteContext, n: int) -> Generator[Any, Any, int]: 32 | if n <= 1: 33 | return 1 34 | 35 | return n * (yield ctx.rfc(fac, n - 1).options(id=f"fac({n - 1})")) 36 | 37 | 38 | def gcd(ctx: LocalContext | RemoteContext, a: int, b: int) -> Generator[Any, Any, int]: 39 | if b == 0: 40 | return a 41 | 42 | return (yield ctx.rfc(gcd, b, a % b).options(id=f"gcd({b},{a % b})")) 43 | 44 | 45 | def rpt(ctx: LocalContext | RemoteContext, s: str, n: int) -> Generator[Any, Any, Any]: 46 | v = "" 47 | p = yield ctx.lfi(idv, s) 48 | 49 | for _ in range(n): 50 | v += yield ctx.lfc(idp, p) 51 | 52 | return v 53 | 54 | 55 | def idv(c: LocalContext | RemoteContext, x: Any) -> Any: 56 | return x 57 | 58 | 59 | def idp(c: LocalContext | RemoteContext, p: Any) -> Any: 60 | return (yield p) 61 | 62 | 63 | # Tests 64 | 65 | 66 | @pytest.fixture(scope="module") 67 | def registry() -> Registry: 68 | registry = Registry() 69 | registry.add(fib, "fib") 70 | registry.add(fac, "fac") 71 | registry.add(gcd, "gcd") 72 | registry.add(rpt, "rpt") 73 | registry.add(idv, "idv") 74 | registry.add(idp, "idp") 75 | 76 | return registry 77 | 78 | 79 | @pytest.fixture 80 | def runners(registry: Registry) -> tuple[Runner, ...]: 81 | return ( 82 | SimpleRunner(), 83 | ResonateRunner(registry), 84 | ResonateLFXRunner(registry), 85 | ResonateRFXRunner(registry), 86 | ) 87 | 88 | 89 | @pytest.mark.parametrize( 90 | ("id", "func", "args", "kwargs"), 91 | [ 92 | *((f"fib({n})", fib, (n,), {}) for n in range(20)), 93 | *((f"fac({n})", fac, (n,), {}) for n in range(20)), 94 | *((f"gcd({a},{b})", gcd, (a, b), {}) for a, b in itertools.product(range(10), repeat=2)), 95 | *((f"rpt({s},{n})", rpt, (s, n), {}) for s, n in itertools.product("abc", range(10))), 96 | ], 97 | ) 98 | def test_equivalencies(runners: tuple[Runner, ...], id: str, func: Callable, args: Any, kwargs: Any) -> None: 99 | # a promise is not json serializable so we must skip ResonateRFXRunner and rpt 100 | results = [r.run(id, func, *args, **kwargs) for r in runners if not (isinstance(r, ResonateRFXRunner) and func == rpt)] 101 | assert all(x == results[0] for x in results[1:]) 102 | -------------------------------------------------------------------------------- /tests/test_retries.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import TYPE_CHECKING 5 | 6 | import pytest 7 | 8 | from resonate.conventions import Base 9 | from resonate.dependencies import Dependencies 10 | from resonate.loggers import ContextLogger 11 | from resonate.models.commands import Delayed, Function, Invoke, Retry, Return 12 | from resonate.models.result import Ko, Ok 13 | from resonate.options import Options 14 | from resonate.registry import Registry 15 | from resonate.resonate import Context 16 | from resonate.retry_policies import Constant, Exponential, Linear, Never 17 | from resonate.scheduler import Done, More, Scheduler 18 | 19 | if TYPE_CHECKING: 20 | from resonate.models.retry_policy import RetryPolicy 21 | 22 | 23 | def foo(ctx: Context): # noqa: ANN201 24 | return "foo" 25 | 26 | 27 | def bar_ok(ctx: Context): # noqa: ANN201 28 | return "bar" 29 | yield ctx.lfi(foo) 30 | 31 | 32 | def bar_ko(ctx: Context): # noqa: ANN201 33 | raise ValueError 34 | yield ctx.lfi(foo) 35 | 36 | 37 | @pytest.fixture 38 | def scheduler() -> Scheduler: 39 | return Scheduler(lambda id, cid, info: Context(id, cid, info, Registry(), Dependencies(), ContextLogger("f", "f"))) 40 | 41 | 42 | @pytest.mark.parametrize( 43 | "retry_policy", 44 | [ 45 | Never(), 46 | Constant(), 47 | Linear(), 48 | Exponential(), 49 | ], 50 | ) 51 | def test_function_happy_path(scheduler: Scheduler, retry_policy: RetryPolicy) -> None: 52 | next = scheduler.step(Invoke("foo", Base("foo", sys.maxsize), sys.maxsize, foo, opts=Options(durable=False, retry_policy=retry_policy))) 53 | assert isinstance(next, More) 54 | assert len(next.reqs) == 1 55 | req = next.reqs[0] 56 | assert isinstance(req, Function) 57 | next = scheduler.step(Return("foo", "foo", Ok("foo"))) 58 | assert isinstance(next, Done) 59 | assert scheduler.computations["foo"].result() == Ok("foo") 60 | 61 | 62 | @pytest.mark.parametrize( 63 | ("retry_policy", "retries"), 64 | [ 65 | (Never(), 0), 66 | (Constant(max_retries=2), 2), 67 | (Linear(max_retries=3), 3), 68 | (Exponential(max_retries=2), 2), 69 | ], 70 | ) 71 | def test_function_sad_path(scheduler: Scheduler, retry_policy: RetryPolicy, retries: int) -> None: 72 | e = ValueError("something went wrong") 73 | 74 | next = scheduler.step(Invoke("foo", Base("foo", sys.maxsize), sys.maxsize, foo, opts=Options(durable=False, retry_policy=retry_policy))) 75 | assert isinstance(next, More) 76 | assert len(next.reqs) == 1 77 | req = next.reqs[0] 78 | assert isinstance(req, Function) 79 | 80 | for _ in range(retries): 81 | next = scheduler.step(Return("foo", "foo", Ko(e))) 82 | assert isinstance(next, More) 83 | assert len(next.reqs) == 1 84 | req = next.reqs[0] 85 | assert isinstance(req, Delayed) 86 | 87 | next = scheduler.step(Return("foo", "foo", Ko(e))) 88 | assert isinstance(next, Done) 89 | assert scheduler.computations["foo"].result() == Ko(e) 90 | 91 | 92 | @pytest.mark.parametrize( 93 | "retry_policy", 94 | [ 95 | Never(), 96 | Constant(), 97 | Linear(), 98 | Exponential(), 99 | ], 100 | ) 101 | def test_generator_happy_path(scheduler: Scheduler, retry_policy: RetryPolicy) -> None: 102 | next = scheduler.step(Invoke("bar", Base("bar", sys.maxsize), sys.maxsize, bar_ok, opts=Options(durable=False, retry_policy=retry_policy))) 103 | assert isinstance(next, Done) 104 | assert scheduler.computations["bar"].result() == Ok("bar") 105 | 106 | 107 | @pytest.mark.parametrize( 108 | ("retry_policy", "retries"), 109 | [ 110 | (Never(), 0), 111 | (Constant(max_retries=2), 2), 112 | (Linear(max_retries=3), 3), 113 | (Exponential(max_retries=1), 1), 114 | ], 115 | ) 116 | def test_generator_sad_path(scheduler: Scheduler, retry_policy: RetryPolicy, retries: int) -> None: 117 | next = scheduler.step(Invoke("bar", Base("bar", sys.maxsize), sys.maxsize, bar_ko, opts=Options(durable=False, retry_policy=retry_policy))) 118 | 119 | for _ in range(retries): 120 | assert isinstance(next, More) 121 | assert len(next.reqs) == 1 122 | req = next.reqs[0] 123 | assert isinstance(req, Delayed) 124 | assert isinstance(req.item, Retry) 125 | next = scheduler.step(req.item) 126 | 127 | assert isinstance(next, Done) 128 | assert isinstance(scheduler.computations["bar"].result(), Ko) 129 | 130 | 131 | @pytest.mark.parametrize( 132 | ("retry_policy", "non_retryable_exceptions"), 133 | [ 134 | (Never(), (ValueError,)), 135 | (Constant(max_retries=2), (ValueError,)), 136 | (Linear(max_retries=3), (ValueError,)), 137 | (Exponential(max_retries=1), (ValueError,)), 138 | ], 139 | ) 140 | def test_non_retriable_errors(scheduler: Scheduler, retry_policy: RetryPolicy, non_retryable_exceptions: tuple[type[Exception], ...]) -> None: 141 | opts = Options(durable=False, retry_policy=retry_policy, non_retryable_exceptions=non_retryable_exceptions) 142 | next = scheduler.step(Invoke("bar", Base("bar", sys.maxsize), sys.maxsize, bar_ko, opts=opts)) 143 | assert isinstance(next, Done) 144 | assert isinstance(scheduler.computations["bar"].result(), Ko) 145 | 146 | 147 | @pytest.mark.parametrize( 148 | "retry_policy", 149 | [ 150 | Never(), 151 | Constant(max_retries=2), 152 | Linear(max_retries=3), 153 | Exponential(max_retries=1), 154 | ], 155 | ) 156 | def test_timeout_over_delay(scheduler: Scheduler, retry_policy: RetryPolicy) -> None: 157 | opts = Options(durable=False, retry_policy=retry_policy) 158 | next = scheduler.step(Invoke("bar", Base("bar", 0), 0, bar_ko, opts=opts)) 159 | assert isinstance(next, Done) 160 | assert isinstance(scheduler.computations["bar"].result(), Ko) 161 | -------------------------------------------------------------------------------- /tests/test_retry_policies.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from resonate.retry_policies import Constant, Exponential, Linear, Never 8 | 9 | if TYPE_CHECKING: 10 | from resonate.models.retry_policy import RetryPolicy 11 | 12 | 13 | @pytest.mark.parametrize( 14 | ("policy", "progression"), 15 | [ 16 | (Never(), None), 17 | ( 18 | Constant(delay=1, max_retries=2), 19 | [1, 1, None], 20 | ), 21 | ( 22 | Linear(delay=1, max_retries=2), 23 | [1, 2, None], 24 | ), 25 | ( 26 | Exponential(delay=1, factor=2, max_retries=5, max_delay=8), 27 | [2, 4, 8, 8, 8, None], 28 | ), 29 | ], 30 | ) 31 | def test_delay_progression(policy: RetryPolicy, progression: list[float | None] | None) -> None: 32 | if isinstance(policy, Never): 33 | return 34 | 35 | i: int = 1 36 | delays: list[float | None] = [] 37 | while True: 38 | delays.append(policy.next(i)) 39 | i += 1 40 | if delays[-1] is None: 41 | break 42 | 43 | assert delays == progression 44 | -------------------------------------------------------------------------------- /tests/test_store_task.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | import time 5 | from typing import TYPE_CHECKING 6 | 7 | import pytest 8 | 9 | from resonate.errors import ResonateStoreError 10 | 11 | if TYPE_CHECKING: 12 | from collections.abc import Generator 13 | 14 | from resonate.models.message import TaskMesg 15 | from resonate.models.message_source import MessageSource 16 | from resonate.models.store import Store 17 | 18 | TICK_TIME = 1 19 | COUNTER = 0 20 | 21 | 22 | @pytest.fixture 23 | def task(store: Store, message_source: MessageSource) -> Generator[TaskMesg]: 24 | global COUNTER # noqa: PLW0603 25 | 26 | id = f"tid{COUNTER}" 27 | COUNTER += 1 28 | 29 | store.promises.create( 30 | id=id, 31 | timeout=sys.maxsize, 32 | tags={"resonate:invoke": "default"}, 33 | ) 34 | 35 | mesg = message_source.next() 36 | assert mesg 37 | assert mesg["type"] == "invoke" 38 | 39 | yield mesg["task"] 40 | store.promises.resolve(id=id) 41 | 42 | 43 | def test_case_5_transition_from_enqueue_to_claimed_via_claim(store: Store, task: TaskMesg) -> None: 44 | store.tasks.claim(id=task["id"], counter=task["counter"], pid="task5", ttl=sys.maxsize) 45 | 46 | 47 | def test_case_6_transition_from_enqueue_to_enqueue_via_claim(store: Store, task: TaskMesg) -> None: 48 | with pytest.raises(ResonateStoreError): 49 | store.tasks.claim( 50 | id=task["id"], 51 | counter=task["counter"] + 1, 52 | pid="task6", 53 | ttl=sys.maxsize, 54 | ) 55 | 56 | 57 | def test_case_8_transition_from_enqueue_to_enqueue_via_complete(store: Store, task: TaskMesg) -> None: 58 | with pytest.raises(ResonateStoreError): 59 | store.tasks.complete( 60 | id=task["id"], 61 | counter=task["counter"], 62 | ) 63 | 64 | 65 | def test_case_10_transition_from_enqueue_to_enqueue_via_hearbeat(store: Store, task: TaskMesg) -> None: 66 | assert store.tasks.heartbeat(pid="task10") == 0 67 | 68 | 69 | def test_case_12_transition_from_claimed_to_claimed_via_claim(store: Store, task: TaskMesg) -> None: 70 | store.tasks.claim(id=task["id"], counter=task["counter"], pid="task12", ttl=sys.maxsize) 71 | with pytest.raises(ResonateStoreError): 72 | store.tasks.claim( 73 | id=task["id"], 74 | counter=task["counter"], 75 | pid="task12", 76 | ttl=sys.maxsize, 77 | ) 78 | 79 | 80 | def test_case_13_transition_from_claimed_to_init_via_claim(store: Store, task: TaskMesg) -> None: 81 | store.tasks.claim(id=task["id"], counter=task["counter"], pid="task13", ttl=0) 82 | with pytest.raises(ResonateStoreError): 83 | store.tasks.claim( 84 | id=task["id"], 85 | counter=task["counter"], 86 | pid="task12", 87 | ttl=sys.maxsize, 88 | ) 89 | 90 | 91 | def test_case_14_transition_from_claimed_to_completed_via_complete(store: Store, task: TaskMesg) -> None: 92 | store.tasks.claim(id=task["id"], counter=task["counter"], pid="task14", ttl=sys.maxsize) 93 | store.tasks.complete(id=task["id"], counter=task["counter"]) 94 | 95 | 96 | def test_case_15_transition_from_claimed_to_init_via_complete(store: Store, task: TaskMesg) -> None: 97 | store.tasks.claim(id=task["id"], counter=task["counter"], pid="task15", ttl=0) 98 | time.sleep(TICK_TIME) 99 | with pytest.raises(ResonateStoreError): 100 | store.tasks.complete(id=task["id"], counter=task["counter"]) 101 | 102 | 103 | def test_case_16_transition_from_claimed_to_claimed_via_complete(store: Store, task: TaskMesg) -> None: 104 | store.tasks.claim(id=task["id"], counter=task["counter"], pid="task16", ttl=sys.maxsize) 105 | with pytest.raises(ResonateStoreError): 106 | store.tasks.complete(id=task["id"], counter=task["counter"] + 1) 107 | 108 | 109 | def test_case_17_transition_from_claimed_to_init_via_complete(store: Store, task: TaskMesg) -> None: 110 | store.tasks.claim(id=task["id"], counter=task["counter"], pid="task17", ttl=0) 111 | time.sleep(TICK_TIME) 112 | with pytest.raises(ResonateStoreError): 113 | store.tasks.complete(id=task["id"], counter=task["counter"]) 114 | 115 | 116 | def test_case_18_transition_from_claimed_to_claimed_via_heartbeat(store: Store, task: TaskMesg) -> None: 117 | store.tasks.claim(id=task["id"], counter=task["counter"], pid="task18", ttl=sys.maxsize) 118 | assert store.tasks.heartbeat(pid="task18") == 1 119 | 120 | 121 | def test_case_19_transition_from_claimed_to_init_via_heartbeat(store: Store, task: TaskMesg) -> None: 122 | store.tasks.claim(id=task["id"], counter=task["counter"], pid="task19", ttl=0) 123 | assert store.tasks.heartbeat(pid="task19") == 1 124 | 125 | 126 | def test_case_20_transition_from_completed_to_completed_via_claim(store: Store, task: TaskMesg) -> None: 127 | store.tasks.claim(id=task["id"], counter=task["counter"], pid="task20", ttl=sys.maxsize) 128 | store.tasks.complete(id=task["id"], counter=task["counter"]) 129 | with pytest.raises(ResonateStoreError): 130 | store.tasks.claim(id=task["id"], counter=task["counter"], pid="task20", ttl=0) 131 | 132 | 133 | def test_case_21_transition_from_completed_to_completed_via_complete(store: Store, task: TaskMesg) -> None: 134 | store.tasks.claim(id=task["id"], counter=task["counter"], pid="task21", ttl=sys.maxsize) 135 | store.tasks.complete(id=task["id"], counter=task["counter"]) 136 | store.tasks.complete(id=task["id"], counter=task["counter"]) 137 | 138 | 139 | def test_case_22_transition_from_completed_to_completed_via_heartbeat(store: Store, task: TaskMesg) -> None: 140 | store.tasks.claim(id=task["id"], counter=task["counter"], pid="task22", ttl=sys.maxsize) 141 | store.tasks.complete(id=task["id"], counter=task["counter"]) 142 | assert store.tasks.heartbeat(pid="task22") == 0 143 | --------------------------------------------------------------------------------