taskferry-cloudtasks 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- taskferry_cloudtasks-0.2.0/.gitignore +34 -0
- taskferry_cloudtasks-0.2.0/CHANGELOG.md +12 -0
- taskferry_cloudtasks-0.2.0/LICENSE +201 -0
- taskferry_cloudtasks-0.2.0/PKG-INFO +120 -0
- taskferry_cloudtasks-0.2.0/README.md +96 -0
- taskferry_cloudtasks-0.2.0/pyproject.toml +41 -0
- taskferry_cloudtasks-0.2.0/src/taskferry_cloudtasks/__init__.py +54 -0
- taskferry_cloudtasks-0.2.0/src/taskferry_cloudtasks/backend.py +250 -0
- taskferry_cloudtasks-0.2.0/src/taskferry_cloudtasks/py.typed +0 -0
- taskferry_cloudtasks-0.2.0/src/taskferry_cloudtasks/receiver.py +126 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
|
|
7
|
+
# Build artifacts
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
*.whl
|
|
11
|
+
*.tar.gz
|
|
12
|
+
|
|
13
|
+
# Environments
|
|
14
|
+
.venv/
|
|
15
|
+
.venv-*/
|
|
16
|
+
.env
|
|
17
|
+
|
|
18
|
+
# Tooling caches
|
|
19
|
+
.mypy_cache/
|
|
20
|
+
.ruff_cache/
|
|
21
|
+
.pytest_cache/
|
|
22
|
+
.coverage
|
|
23
|
+
htmlcov/
|
|
24
|
+
coverage.xml
|
|
25
|
+
node_modules/
|
|
26
|
+
|
|
27
|
+
# uv
|
|
28
|
+
uv.lock
|
|
29
|
+
|
|
30
|
+
# OS / editors
|
|
31
|
+
.DS_Store
|
|
32
|
+
.idea/
|
|
33
|
+
.vscode/
|
|
34
|
+
site/
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `taskferry-cloudtasks` are documented here.
|
|
4
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
|
5
|
+
this project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.2.0] — 2026-07-26
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Initial release, extracted from the Django-coupled backends of `taskferry-django` 0.1
|
|
12
|
+
and rebuilt against the framework-agnostic Taskferry ports.
|
|
@@ -0,0 +1,201 @@
|
|
|
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 Derivative
|
|
95
|
+
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.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: taskferry-cloudtasks
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Google Cloud Tasks backend for Taskferry — push-based serverless tasks.
|
|
5
|
+
Project-URL: Homepage, https://github.com/xiidigital/taskferry
|
|
6
|
+
Project-URL: Documentation, https://taskferry.dev
|
|
7
|
+
Project-URL: Source, https://github.com/xiidigital/taskferry
|
|
8
|
+
Author: Taskferry authors
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cloud-tasks,gcp,serverless,taskferry,tasks
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Requires-Dist: taskferry<0.3,>=0.2
|
|
21
|
+
Provides-Extra: gcp
|
|
22
|
+
Requires-Dist: google-cloud-tasks>=2.16; extra == 'gcp'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# taskferry-cloudtasks
|
|
26
|
+
|
|
27
|
+
Run [Taskferry](https://github.com/xiidigital/taskferry) tasks on **Google Cloud
|
|
28
|
+
Tasks** — push-based, serverless, no worker process.
|
|
29
|
+
|
|
30
|
+
```mermaid
|
|
31
|
+
sequenceDiagram
|
|
32
|
+
participant App
|
|
33
|
+
participant TP as Taskferry
|
|
34
|
+
participant CT as Cloud Tasks
|
|
35
|
+
participant SVC as your service
|
|
36
|
+
|
|
37
|
+
App->>TP: tasks.submit("myapp:send_email", 42)
|
|
38
|
+
TP->>CT: create_task(http_request)
|
|
39
|
+
CT->>SVC: POST /_taskferry/execute
|
|
40
|
+
SVC->>SVC: handle_request(body)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Cloud Tasks is architecturally the opposite of Procrastinate — nothing polls, no
|
|
44
|
+
worker exists, Google pushes an HTTP request at your service. The application
|
|
45
|
+
code does not change:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
runtime.tasks.submit("myapp.tasks:send_email", 42, queue="email")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install 'taskferry-cloudtasks[gcp]'
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Configure
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
runtime = Taskferry.from_mapping(
|
|
61
|
+
{
|
|
62
|
+
"backends": {
|
|
63
|
+
"push": {
|
|
64
|
+
"factory": "cloudtasks",
|
|
65
|
+
"project": "my-project",
|
|
66
|
+
"location": "europe-west1",
|
|
67
|
+
"url": "https://my-service.run.app/_taskferry/execute",
|
|
68
|
+
"service_account_email": "runner@my-project.iam.gserviceaccount.com",
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"defaults": {"task": "push"},
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Receive
|
|
77
|
+
|
|
78
|
+
`handle_request` takes bytes, so it works with any framework:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
# Django
|
|
82
|
+
from taskferry import FunctionRegistry
|
|
83
|
+
from taskferry_cloudtasks import handle_request
|
|
84
|
+
|
|
85
|
+
REGISTRY = FunctionRegistry(allowed_modules=["myapp"])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def taskferry_execute(request):
|
|
89
|
+
handle_request(request.body, dict(request.headers), registry=REGISTRY)
|
|
90
|
+
return HttpResponse(status=204)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
# FastAPI
|
|
95
|
+
@app.post("/_taskferry/execute")
|
|
96
|
+
async def execute(request: Request):
|
|
97
|
+
handle_request(await request.body(), dict(request.headers), registry=REGISTRY)
|
|
98
|
+
return Response(status_code=204)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**Authenticate the endpoint.** Verify the OIDC token Cloud Tasks sends, or put
|
|
102
|
+
the service behind IAM. Taskferry never sees your framework's request object —
|
|
103
|
+
that is what makes it portable, and it is why this part is yours.
|
|
104
|
+
|
|
105
|
+
**Be idempotent.** Cloud Tasks delivery is at-least-once.
|
|
106
|
+
|
|
107
|
+
## Capabilities
|
|
108
|
+
|
|
109
|
+
| Capability | Supported | Why |
|
|
110
|
+
| ---------- | :-------: | --- |
|
|
111
|
+
| `SUBMIT` · `DELAY` · `RETRY` | yes | `create_task`, `schedule_time`, queue retry config |
|
|
112
|
+
| `DEDUPLICATION` | yes | task names are unique per queue for a bounded window |
|
|
113
|
+
| `STATE` · `RESULT` | **no** | Cloud Tasks reports nothing per task after creation |
|
|
114
|
+
|
|
115
|
+
A handle refuses `status()` here instead of returning a plausible `UNKNOWN`
|
|
116
|
+
forever. Record what you need in your own database, where it is actually true.
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
Apache-2.0.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# taskferry-cloudtasks
|
|
2
|
+
|
|
3
|
+
Run [Taskferry](https://github.com/xiidigital/taskferry) tasks on **Google Cloud
|
|
4
|
+
Tasks** — push-based, serverless, no worker process.
|
|
5
|
+
|
|
6
|
+
```mermaid
|
|
7
|
+
sequenceDiagram
|
|
8
|
+
participant App
|
|
9
|
+
participant TP as Taskferry
|
|
10
|
+
participant CT as Cloud Tasks
|
|
11
|
+
participant SVC as your service
|
|
12
|
+
|
|
13
|
+
App->>TP: tasks.submit("myapp:send_email", 42)
|
|
14
|
+
TP->>CT: create_task(http_request)
|
|
15
|
+
CT->>SVC: POST /_taskferry/execute
|
|
16
|
+
SVC->>SVC: handle_request(body)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Cloud Tasks is architecturally the opposite of Procrastinate — nothing polls, no
|
|
20
|
+
worker exists, Google pushes an HTTP request at your service. The application
|
|
21
|
+
code does not change:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
runtime.tasks.submit("myapp.tasks:send_email", 42, queue="email")
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install 'taskferry-cloudtasks[gcp]'
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Configure
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
runtime = Taskferry.from_mapping(
|
|
37
|
+
{
|
|
38
|
+
"backends": {
|
|
39
|
+
"push": {
|
|
40
|
+
"factory": "cloudtasks",
|
|
41
|
+
"project": "my-project",
|
|
42
|
+
"location": "europe-west1",
|
|
43
|
+
"url": "https://my-service.run.app/_taskferry/execute",
|
|
44
|
+
"service_account_email": "runner@my-project.iam.gserviceaccount.com",
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"defaults": {"task": "push"},
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Receive
|
|
53
|
+
|
|
54
|
+
`handle_request` takes bytes, so it works with any framework:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
# Django
|
|
58
|
+
from taskferry import FunctionRegistry
|
|
59
|
+
from taskferry_cloudtasks import handle_request
|
|
60
|
+
|
|
61
|
+
REGISTRY = FunctionRegistry(allowed_modules=["myapp"])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def taskferry_execute(request):
|
|
65
|
+
handle_request(request.body, dict(request.headers), registry=REGISTRY)
|
|
66
|
+
return HttpResponse(status=204)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
# FastAPI
|
|
71
|
+
@app.post("/_taskferry/execute")
|
|
72
|
+
async def execute(request: Request):
|
|
73
|
+
handle_request(await request.body(), dict(request.headers), registry=REGISTRY)
|
|
74
|
+
return Response(status_code=204)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Authenticate the endpoint.** Verify the OIDC token Cloud Tasks sends, or put
|
|
78
|
+
the service behind IAM. Taskferry never sees your framework's request object —
|
|
79
|
+
that is what makes it portable, and it is why this part is yours.
|
|
80
|
+
|
|
81
|
+
**Be idempotent.** Cloud Tasks delivery is at-least-once.
|
|
82
|
+
|
|
83
|
+
## Capabilities
|
|
84
|
+
|
|
85
|
+
| Capability | Supported | Why |
|
|
86
|
+
| ---------- | :-------: | --- |
|
|
87
|
+
| `SUBMIT` · `DELAY` · `RETRY` | yes | `create_task`, `schedule_time`, queue retry config |
|
|
88
|
+
| `DEDUPLICATION` | yes | task names are unique per queue for a bounded window |
|
|
89
|
+
| `STATE` · `RESULT` | **no** | Cloud Tasks reports nothing per task after creation |
|
|
90
|
+
|
|
91
|
+
A handle refuses `status()` here instead of returning a plausible `UNKNOWN`
|
|
92
|
+
forever. Record what you need in your own database, where it is actually true.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
Apache-2.0.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "taskferry-cloudtasks"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Google Cloud Tasks backend for Taskferry — push-based serverless tasks."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Taskferry authors" }]
|
|
14
|
+
keywords = ["taskferry", "gcp", "cloud-tasks", "tasks", "serverless"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: Apache Software License",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = ["taskferry>=0.2,<0.3"]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
gcp = ["google-cloud-tasks>=2.16"]
|
|
28
|
+
|
|
29
|
+
[project.entry-points."taskferry.backends"]
|
|
30
|
+
cloudtasks = "taskferry_cloudtasks:make_backend"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/xiidigital/taskferry"
|
|
34
|
+
Documentation = "https://taskferry.dev"
|
|
35
|
+
Source = "https://github.com/xiidigital/taskferry"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/taskferry_cloudtasks"]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.sdist]
|
|
41
|
+
include = ["src", "README.md", "CHANGELOG.md", "LICENSE"]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Taskferry on Google Cloud Tasks — push-based, serverless task execution.
|
|
2
|
+
|
|
3
|
+
```mermaid
|
|
4
|
+
sequenceDiagram
|
|
5
|
+
participant App
|
|
6
|
+
participant TP as Taskferry
|
|
7
|
+
participant AD as taskferry_cloudtasks
|
|
8
|
+
participant CT as Cloud Tasks
|
|
9
|
+
participant SVC as your HTTP service
|
|
10
|
+
|
|
11
|
+
App->>TP: tasks.submit("myapp:send_email", 42)
|
|
12
|
+
TP->>AD: TaskSpec
|
|
13
|
+
AD->>CT: create_task(http_request)
|
|
14
|
+
CT->>SVC: POST /_taskferry/execute
|
|
15
|
+
SVC->>AD: handle_request(body)
|
|
16
|
+
AD->>SVC: run the function
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The second task engine, and the reason the first one had to be an adapter. Cloud
|
|
20
|
+
Tasks is architecturally the opposite of Procrastinate — nothing polls, no worker
|
|
21
|
+
process exists, Google pushes an HTTP request at your service and scale-to-zero
|
|
22
|
+
is the normal state — and the *application code does not change*:
|
|
23
|
+
|
|
24
|
+
runtime.tasks.submit("myapp.tasks:send_email", 42, queue="email")
|
|
25
|
+
|
|
26
|
+
The queue moves from PostgreSQL to Cloud Tasks by editing configuration. That is
|
|
27
|
+
the entire promise of a portability layer, and this package is where it either
|
|
28
|
+
holds or does not.
|
|
29
|
+
|
|
30
|
+
What genuinely differs, and is therefore visible in the capabilities: Cloud Tasks
|
|
31
|
+
does not report per-task state after creation and stores no results, so ``STATE``
|
|
32
|
+
and ``RESULT`` are not advertised and a handle refuses to fake them. It does
|
|
33
|
+
support scheduling and native retries, and those are advertised.
|
|
34
|
+
|
|
35
|
+
The receiving side is framework-agnostic — :func:`handle_request` takes bytes and
|
|
36
|
+
returns whatever the task returned, so it plugs into Django, FastAPI, Flask or a
|
|
37
|
+
bare WSGI app in four lines. See :mod:`taskferry_cloudtasks.receiver`.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
from .backend import CLOUD_TASKS_CAPABILITIES, CloudTasksBackend, make_backend
|
|
43
|
+
from .receiver import handle_request, parse_request
|
|
44
|
+
|
|
45
|
+
__version__ = "0.2.0"
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"CLOUD_TASKS_CAPABILITIES",
|
|
49
|
+
"CloudTasksBackend",
|
|
50
|
+
"__version__",
|
|
51
|
+
"handle_request",
|
|
52
|
+
"make_backend",
|
|
53
|
+
"parse_request",
|
|
54
|
+
]
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""The Cloud Tasks `TaskBackend`.
|
|
2
|
+
|
|
3
|
+
Capabilities, and the two that are missing
|
|
4
|
+
------------------------------------------
|
|
5
|
+
|
|
6
|
+
Advertised: ``SUBMIT``, ``DELAY`` (``schedule_time``), ``RETRY`` (the queue's own
|
|
7
|
+
retry configuration), ``DEDUPLICATION`` (a task *name* is unique per queue, which
|
|
8
|
+
is real deduplication with a documented window).
|
|
9
|
+
|
|
10
|
+
Not advertised: ``STATE`` and ``RESULT``. Cloud Tasks is fire-and-forget — once a
|
|
11
|
+
task is created there is no per-task status to read and no place a return value
|
|
12
|
+
is kept. A handle from this backend therefore raises
|
|
13
|
+
:class:`~taskferry.errors.UnsupportedCapability` on ``status()`` rather than
|
|
14
|
+
returning a plausible-looking ``UNKNOWN`` forever, and an application that needs
|
|
15
|
+
to know whether the work happened records that itself, in its own database, where
|
|
16
|
+
it is actually true.
|
|
17
|
+
|
|
18
|
+
That asymmetry with Procrastinate is not a defect in the abstraction; it is the
|
|
19
|
+
abstraction working. Both engines run the same task code, and the difference in
|
|
20
|
+
what you can *ask* afterwards is visible, checkable and impossible to trip over
|
|
21
|
+
by accident.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
from datetime import UTC, datetime
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from taskferry.capabilities import Capability, CapabilitySet
|
|
31
|
+
from taskferry.core.correlation import Correlation
|
|
32
|
+
from taskferry.core.provider import ProviderMetadata
|
|
33
|
+
from taskferry.envelope import ENVELOPE_VERSION, Envelope, build_envelope
|
|
34
|
+
from taskferry.errors import ConfigurationError, SubmissionError
|
|
35
|
+
from taskferry.execution import (
|
|
36
|
+
Execution,
|
|
37
|
+
ExecutionKind,
|
|
38
|
+
ExecutionState,
|
|
39
|
+
new_execution_id,
|
|
40
|
+
)
|
|
41
|
+
from taskferry.ports import BaseBackend
|
|
42
|
+
from taskferry.specs import ExecutionSpec, TaskSpec
|
|
43
|
+
|
|
44
|
+
MESSAGE_VERSION = ENVELOPE_VERSION
|
|
45
|
+
"""Alias of the shared envelope version; the format is the core's, not ours."""
|
|
46
|
+
|
|
47
|
+
CLOUD_TASKS_CAPABILITIES = frozenset(
|
|
48
|
+
{
|
|
49
|
+
Capability.SUBMIT,
|
|
50
|
+
Capability.DELAY,
|
|
51
|
+
Capability.RETRY,
|
|
52
|
+
Capability.DEDUPLICATION,
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
CloudTasksMessage = Envelope
|
|
58
|
+
"""The JSON body Cloud Tasks POSTs to your service — the shared envelope."""
|
|
59
|
+
|
|
60
|
+
build_message = build_envelope
|
|
61
|
+
"""Serialize a spec into the HTTP body the receiver will decode."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class CloudTasksBackend(BaseBackend):
|
|
65
|
+
"""Creates HTTP-target tasks on a Cloud Tasks queue.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
project: GCP project id.
|
|
69
|
+
location: Queue region.
|
|
70
|
+
queue: Default queue name. ``TaskSpec.queue`` overrides it, which is what
|
|
71
|
+
makes ``queue="email"`` mean a real Cloud Tasks queue.
|
|
72
|
+
url: The endpoint Cloud Tasks will POST to. Your service routes it to
|
|
73
|
+
:func:`taskferry_cloudtasks.handle_request`.
|
|
74
|
+
service_account_email: Identity for the OIDC token, so the endpoint can
|
|
75
|
+
require authentication instead of being open to the internet.
|
|
76
|
+
audience: OIDC audience, when it differs from ``url``.
|
|
77
|
+
client: Injected ``tasks_v2.CloudTasksClient`` for testing.
|
|
78
|
+
|
|
79
|
+
Backend options, under the ``"cloudtasks"`` namespace: ``dispatch_deadline``,
|
|
80
|
+
``headers``, and ``queue`` for a one-off override.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
*,
|
|
86
|
+
project: str | None = None,
|
|
87
|
+
location: str | None = None,
|
|
88
|
+
queue: str = "default",
|
|
89
|
+
url: str | None = None,
|
|
90
|
+
service_account_email: str | None = None,
|
|
91
|
+
audience: str | None = None,
|
|
92
|
+
client: Any = None,
|
|
93
|
+
name: str = "cloudtasks",
|
|
94
|
+
) -> None:
|
|
95
|
+
missing = [
|
|
96
|
+
key
|
|
97
|
+
for key, value in (("project", project), ("location", location), ("url", url))
|
|
98
|
+
if not value
|
|
99
|
+
]
|
|
100
|
+
if missing:
|
|
101
|
+
raise ConfigurationError(
|
|
102
|
+
f"CloudTasksBackend needs {', '.join(missing)}; 'url' is the endpoint in your "
|
|
103
|
+
"service that Cloud Tasks will POST each task to"
|
|
104
|
+
)
|
|
105
|
+
assert project and location and url # narrowed by the check above
|
|
106
|
+
self._project = project
|
|
107
|
+
self._location = location
|
|
108
|
+
self._queue = queue
|
|
109
|
+
self._url = url
|
|
110
|
+
self._service_account_email = service_account_email
|
|
111
|
+
self._audience = audience
|
|
112
|
+
self._client = client
|
|
113
|
+
self._name = name
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def name(self) -> str:
|
|
117
|
+
return self._name
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def kind(self) -> ExecutionKind:
|
|
121
|
+
return ExecutionKind.TASK
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def capabilities(self) -> CapabilitySet:
|
|
125
|
+
return CapabilitySet(CLOUD_TASKS_CAPABILITIES, provider=self._name)
|
|
126
|
+
|
|
127
|
+
def _tasks(self) -> Any:
|
|
128
|
+
if self._client is None:
|
|
129
|
+
try:
|
|
130
|
+
from google.cloud import tasks_v2
|
|
131
|
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
|
132
|
+
raise ConfigurationError(
|
|
133
|
+
"the Cloud Tasks backend needs the Google SDK: "
|
|
134
|
+
"pip install 'taskferry-cloudtasks[gcp]'"
|
|
135
|
+
) from exc
|
|
136
|
+
self._client = tasks_v2.CloudTasksClient()
|
|
137
|
+
return self._client
|
|
138
|
+
|
|
139
|
+
def queue_path(self, queue: str) -> str:
|
|
140
|
+
client = self._tasks()
|
|
141
|
+
builder = getattr(client, "queue_path", None)
|
|
142
|
+
if callable(builder):
|
|
143
|
+
return str(builder(self._project, self._location, queue))
|
|
144
|
+
return f"projects/{self._project}/locations/{self._location}/queues/{queue}"
|
|
145
|
+
|
|
146
|
+
# -- submission -------------------------------------------------------------- #
|
|
147
|
+
def _submit(self, spec: ExecutionSpec) -> Execution:
|
|
148
|
+
assert isinstance(spec, TaskSpec)
|
|
149
|
+
options = spec.options_for("cloudtasks")
|
|
150
|
+
queue = str(options.get("queue") or spec.queue or self._queue)
|
|
151
|
+
|
|
152
|
+
http_request: dict[str, Any] = {
|
|
153
|
+
"http_method": "POST",
|
|
154
|
+
"url": self._url,
|
|
155
|
+
"headers": {
|
|
156
|
+
"Content-Type": "application/json",
|
|
157
|
+
**self._correlation_headers(spec.correlation),
|
|
158
|
+
**dict(options.get("headers") or {}), # type: ignore[arg-type]
|
|
159
|
+
},
|
|
160
|
+
"body": json.dumps(build_message(spec)).encode("utf-8"),
|
|
161
|
+
}
|
|
162
|
+
if self._service_account_email:
|
|
163
|
+
oidc: dict[str, str] = {"service_account_email": self._service_account_email}
|
|
164
|
+
if self._audience or self._url:
|
|
165
|
+
oidc["audience"] = self._audience or self._url
|
|
166
|
+
http_request["oidc_token"] = oidc
|
|
167
|
+
|
|
168
|
+
task: dict[str, Any] = {"http_request": http_request}
|
|
169
|
+
scheduled_for = spec.scheduled_for()
|
|
170
|
+
if scheduled_for is not None:
|
|
171
|
+
task["schedule_time"] = scheduled_for
|
|
172
|
+
if "dispatch_deadline" in options:
|
|
173
|
+
task["dispatch_deadline"] = options["dispatch_deadline"]
|
|
174
|
+
if spec.idempotency_key is not None:
|
|
175
|
+
# A named task is refused if the name was used recently — Cloud Tasks'
|
|
176
|
+
# own, real, time-bounded deduplication. The window is Google's, not
|
|
177
|
+
# ours, and this is not an exactly-once promise.
|
|
178
|
+
task["name"] = f"{self.queue_path(queue)}/tasks/{_safe_name(spec.idempotency_key)}"
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
created = self._tasks().create_task(
|
|
182
|
+
request={"parent": self.queue_path(queue), "task": task}
|
|
183
|
+
)
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
raise SubmissionError(
|
|
186
|
+
f"Cloud Tasks could not create a task for {spec.task!r} on queue {queue!r}: {exc}",
|
|
187
|
+
backend=self._name,
|
|
188
|
+
) from exc
|
|
189
|
+
|
|
190
|
+
external_id = getattr(created, "name", None)
|
|
191
|
+
return Execution(
|
|
192
|
+
id=new_execution_id(ExecutionKind.TASK),
|
|
193
|
+
kind=ExecutionKind.TASK,
|
|
194
|
+
backend=self._name,
|
|
195
|
+
# QUEUED is the last thing this backend can honestly observe: Cloud
|
|
196
|
+
# Tasks will not tell us anything after creation.
|
|
197
|
+
state=ExecutionState.QUEUED,
|
|
198
|
+
name=spec.name,
|
|
199
|
+
created_at=datetime.now(UTC),
|
|
200
|
+
external_id=str(external_id) if external_id else None,
|
|
201
|
+
correlation=spec.correlation,
|
|
202
|
+
provider_metadata=ProviderMetadata(
|
|
203
|
+
provider="gcp",
|
|
204
|
+
provider_id=str(external_id) if external_id else None,
|
|
205
|
+
region=self._location,
|
|
206
|
+
resource=self.queue_path(queue),
|
|
207
|
+
labels=dict(spec.labels),
|
|
208
|
+
),
|
|
209
|
+
metadata={"queue": queue, "url": self._url},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
@staticmethod
|
|
213
|
+
def _correlation_headers(correlation: Correlation | None) -> dict[str, str]:
|
|
214
|
+
"""Propagate correlation and trace context as HTTP headers.
|
|
215
|
+
|
|
216
|
+
This is how a trace survives the hop through Google's infrastructure: the
|
|
217
|
+
receiver rebuilds the correlation from these headers, so one flow stays
|
|
218
|
+
followable from the web request through the queue into the task.
|
|
219
|
+
"""
|
|
220
|
+
return correlation.to_headers() if correlation is not None else {}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _safe_name(key: str) -> str:
|
|
224
|
+
"""Reduce an idempotency key to the characters Cloud Tasks allows in a name."""
|
|
225
|
+
cleaned = "".join(char if char.isalnum() or char in "-_" else "-" for char in key)
|
|
226
|
+
return cleaned[:500] or "taskferry"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def make_backend(**options: Any) -> CloudTasksBackend:
|
|
230
|
+
"""Entry point for ``{"factory": "cloudtasks", ...}`` configuration."""
|
|
231
|
+
return CloudTasksBackend(
|
|
232
|
+
project=options.get("project"),
|
|
233
|
+
location=options.get("location"),
|
|
234
|
+
queue=str(options.get("queue", "default")),
|
|
235
|
+
url=options.get("url"),
|
|
236
|
+
service_account_email=options.get("service_account_email"),
|
|
237
|
+
audience=options.get("audience"),
|
|
238
|
+
client=options.get("client"),
|
|
239
|
+
name=str(options.get("name", "cloudtasks")),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
__all__ = [
|
|
244
|
+
"CLOUD_TASKS_CAPABILITIES",
|
|
245
|
+
"MESSAGE_VERSION",
|
|
246
|
+
"CloudTasksBackend",
|
|
247
|
+
"CloudTasksMessage",
|
|
248
|
+
"build_message",
|
|
249
|
+
"make_backend",
|
|
250
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""The receiving side — bytes in, task executed.
|
|
2
|
+
|
|
3
|
+
Cloud Tasks pushes an HTTP request; something in your service has to turn that
|
|
4
|
+
back into a function call. That something is :func:`handle_request`, and it takes
|
|
5
|
+
**bytes**, not a Django ``HttpRequest``, not a Starlette ``Request``.
|
|
6
|
+
|
|
7
|
+
```mermaid
|
|
8
|
+
flowchart LR
|
|
9
|
+
CT["Cloud Tasks"]
|
|
10
|
+
V["your view / route<br/>(4 lines, any framework)"]
|
|
11
|
+
H["handle_request(body, headers)"]
|
|
12
|
+
FN["package.module:function"]
|
|
13
|
+
|
|
14
|
+
CT -->|"POST JSON"| V --> H --> FN
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Django::
|
|
18
|
+
|
|
19
|
+
from django.http import HttpResponse
|
|
20
|
+
from taskferry_cloudtasks import handle_request
|
|
21
|
+
|
|
22
|
+
def taskferry_execute(request):
|
|
23
|
+
handle_request(request.body, dict(request.headers), registry=REGISTRY)
|
|
24
|
+
return HttpResponse(status=204)
|
|
25
|
+
|
|
26
|
+
FastAPI::
|
|
27
|
+
|
|
28
|
+
@app.post("/_taskferry/execute")
|
|
29
|
+
async def execute(request: Request):
|
|
30
|
+
handle_request(await request.body(), dict(request.headers), registry=REGISTRY)
|
|
31
|
+
return Response(status_code=204)
|
|
32
|
+
|
|
33
|
+
Keeping the framework out of this module is what lets one adapter serve all of
|
|
34
|
+
them, and is why ``taskferry-cloudtasks`` depends on neither Django nor FastAPI.
|
|
35
|
+
|
|
36
|
+
Two things your endpoint must do
|
|
37
|
+
--------------------------------
|
|
38
|
+
|
|
39
|
+
**Authenticate.** Anyone who finds the URL can POST to it. Require the OIDC token
|
|
40
|
+
Cloud Tasks sends (verify it with ``google.oauth2.id_token``), or put the service
|
|
41
|
+
behind IAM. Taskferry cannot do this for you: it never sees your framework's
|
|
42
|
+
request object, which is the same reason it is portable.
|
|
43
|
+
|
|
44
|
+
**Be idempotent.** Cloud Tasks retries, so a task can arrive more than once.
|
|
45
|
+
Delivery is at-least-once; nothing here changes that and nothing can.
|
|
46
|
+
|
|
47
|
+
Returning 2xx acknowledges the task; raising (or returning 5xx) tells Cloud Tasks
|
|
48
|
+
to retry according to the queue's retry configuration. Let exceptions propagate
|
|
49
|
+
and the queue does the right thing.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import json
|
|
55
|
+
from collections.abc import Mapping
|
|
56
|
+
from typing import Any
|
|
57
|
+
|
|
58
|
+
from taskferry.core.correlation import Correlation, use_correlation
|
|
59
|
+
from taskferry.envelope import read_envelope
|
|
60
|
+
from taskferry.errors import SerializationError
|
|
61
|
+
from taskferry.functions import FunctionRegistry, is_async_callable
|
|
62
|
+
|
|
63
|
+
from .backend import CloudTasksMessage
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_request(body: bytes | str) -> CloudTasksMessage:
|
|
67
|
+
"""Decode and validate the request body.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
SerializationError: when the body is not a Taskferry message of a version
|
|
71
|
+
this code understands. Failing loudly is deliberate: a malformed body
|
|
72
|
+
means a misconfigured endpoint or a version skew, and quietly doing
|
|
73
|
+
nothing would hide both.
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
payload = json.loads(body)
|
|
77
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
78
|
+
raise SerializationError(f"Cloud Tasks body is not valid JSON: {exc}") from exc
|
|
79
|
+
return read_envelope(payload)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def handle_request(
|
|
83
|
+
body: bytes | str,
|
|
84
|
+
headers: Mapping[str, str] | None = None,
|
|
85
|
+
*,
|
|
86
|
+
registry: FunctionRegistry | None = None,
|
|
87
|
+
) -> Any:
|
|
88
|
+
"""Run the task described by a Cloud Tasks push request.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
body: The raw request body.
|
|
92
|
+
headers: Request headers, used to restore correlation and trace context
|
|
93
|
+
so the task's spans join the flow that enqueued it.
|
|
94
|
+
registry: Where task names resolve. **Give it an allowlist**: the task
|
|
95
|
+
name arrives over the network, and an unrestricted resolver on a
|
|
96
|
+
public endpoint is a remote-import primitive.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Whatever the task returned. Cloud Tasks discards it; it is returned so
|
|
100
|
+
the value is available to your view for logging or a response body.
|
|
101
|
+
"""
|
|
102
|
+
message = parse_request(body)
|
|
103
|
+
resolver = registry if registry is not None else FunctionRegistry()
|
|
104
|
+
func = resolver.resolve(str(message["task"]))
|
|
105
|
+
args = list(message.get("args") or [])
|
|
106
|
+
kwargs = dict(message.get("kwargs") or {})
|
|
107
|
+
|
|
108
|
+
with use_correlation(_correlation_from(message, headers)):
|
|
109
|
+
if is_async_callable(func):
|
|
110
|
+
import asyncio
|
|
111
|
+
|
|
112
|
+
return asyncio.run(func(*args, **kwargs))
|
|
113
|
+
return func(*args, **kwargs)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _correlation_from(message: CloudTasksMessage, headers: Mapping[str, str] | None) -> Correlation:
|
|
117
|
+
"""Prefer the headers (they carry live trace context), fall back to the body."""
|
|
118
|
+
if headers:
|
|
119
|
+
normalised = {key.lower(): value for key, value in headers.items()}
|
|
120
|
+
if "taskferry-correlation-id" in normalised:
|
|
121
|
+
return Correlation.from_headers(normalised)
|
|
122
|
+
embedded = message.get("correlation")
|
|
123
|
+
return Correlation.from_headers(embedded) if embedded else Correlation.start()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
__all__ = ["handle_request", "parse_request"]
|