├── .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 | [](https://github.com/resonatehq/resonate-sdk-py/actions/workflows/ci.yml)
10 | [](https://github.com/resonatehq/resonate-sdk-py/actions/workflows/dst.yml)
11 | [](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 | |
| https://github.com/resonatehq/resonate-sdk-py | [pypi](https://pypi.org/project/resonate-sdk/) | [docs](https://docs.resonatehq.io/develop/python) |
47 | |
| 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 |
--------------------------------------------------------------------------------