pgtask 0.1.3__tar.gz → 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.
- {pgtask-0.1.3 → pgtask-0.2.0}/Cargo.lock +11 -9
- {pgtask-0.1.3 → pgtask-0.2.0}/Cargo.toml +1 -1
- pgtask-0.2.0/PKG-INFO +107 -0
- pgtask-0.2.0/README.md +88 -0
- pgtask-0.2.0/crates/pgtask/Cargo.toml +23 -0
- pgtask-0.2.0/crates/pgtask/README.md +100 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask/src/lib.rs +1 -1
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-core/Cargo.toml +1 -0
- pgtask-0.2.0/crates/pgtask-core/README.md +13 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-otel/Cargo.toml +1 -0
- pgtask-0.2.0/crates/pgtask-otel/README.md +13 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-postgres/Cargo.toml +3 -2
- pgtask-0.2.0/crates/pgtask-postgres/README.md +15 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-worker/Cargo.toml +4 -3
- pgtask-0.2.0/crates/pgtask-worker/README.md +13 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/python/pgtask/__init__.py +2 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/python/pgtask/client.py +15 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/sdks/python/Cargo.toml +1 -1
- pgtask-0.2.0/sdks/python/README.md +88 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/sdks/python/tests/test_python_sdk.py +40 -0
- pgtask-0.1.3/PKG-INFO +0 -42
- pgtask-0.1.3/README.md +0 -23
- pgtask-0.1.3/crates/pgtask/Cargo.toml +0 -20
- pgtask-0.1.3/sdks/python/README.md +0 -23
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask/tests/public_api.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-core/src/identifier.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-core/src/lib.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-core/src/retry.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-core/src/schedule.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-core/src/task.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-core/tests/schedule_clock.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-core/tests/types.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-otel/src/lib.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-otel/src/metrics.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-otel/src/propagation.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-otel/tests/propagation.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-postgres/build.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-postgres/migrations/0001_initial.sql +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-postgres/src/lib.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-postgres/src/store.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-postgres/tests/corruption.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-postgres/tests/queue.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-postgres/tests/results.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-postgres/tests/schedule.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-postgres/tests/signals.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-postgres/tests/sql_surface.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-postgres/tests/sql_surface.txt +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-postgres/tests/sql_surface_baseline.txt +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-worker/src/health.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-worker/src/lib.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-worker/src/registry.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-worker/src/runtime.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-worker/tests/durable_restarts.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/crates/pgtask-worker/tests/worker.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/pyproject.toml +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/python/pgtask/_native.pyi +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/python/pgtask/py.typed +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/sdks/python/src/lib.rs +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/sdks/python/tests/__init__.py +0 -0
- {pgtask-0.1.3 → pgtask-0.2.0}/sdks/python/uv.lock +0 -0
|
@@ -1162,18 +1162,20 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|
|
1162
1162
|
|
|
1163
1163
|
[[package]]
|
|
1164
1164
|
name = "pgtask"
|
|
1165
|
-
version = "0.
|
|
1165
|
+
version = "0.2.0"
|
|
1166
1166
|
dependencies = [
|
|
1167
1167
|
"pgtask-core",
|
|
1168
1168
|
"pgtask-otel",
|
|
1169
1169
|
"pgtask-postgres",
|
|
1170
1170
|
"pgtask-worker",
|
|
1171
1171
|
"serde_json",
|
|
1172
|
+
"tokio",
|
|
1173
|
+
"tokio-util",
|
|
1172
1174
|
]
|
|
1173
1175
|
|
|
1174
1176
|
[[package]]
|
|
1175
1177
|
name = "pgtask-bench"
|
|
1176
|
-
version = "0.
|
|
1178
|
+
version = "0.2.0"
|
|
1177
1179
|
dependencies = [
|
|
1178
1180
|
"chrono",
|
|
1179
1181
|
"pgtask",
|
|
@@ -1186,7 +1188,7 @@ dependencies = [
|
|
|
1186
1188
|
|
|
1187
1189
|
[[package]]
|
|
1188
1190
|
name = "pgtask-cli"
|
|
1189
|
-
version = "0.
|
|
1191
|
+
version = "0.2.0"
|
|
1190
1192
|
dependencies = [
|
|
1191
1193
|
"clap",
|
|
1192
1194
|
"pgtask",
|
|
@@ -1196,7 +1198,7 @@ dependencies = [
|
|
|
1196
1198
|
|
|
1197
1199
|
[[package]]
|
|
1198
1200
|
name = "pgtask-core"
|
|
1199
|
-
version = "0.
|
|
1201
|
+
version = "0.2.0"
|
|
1200
1202
|
dependencies = [
|
|
1201
1203
|
"chrono",
|
|
1202
1204
|
"cron",
|
|
@@ -1209,7 +1211,7 @@ dependencies = [
|
|
|
1209
1211
|
|
|
1210
1212
|
[[package]]
|
|
1211
1213
|
name = "pgtask-otel"
|
|
1212
|
-
version = "0.
|
|
1214
|
+
version = "0.2.0"
|
|
1213
1215
|
dependencies = [
|
|
1214
1216
|
"opentelemetry",
|
|
1215
1217
|
"opentelemetry_sdk",
|
|
@@ -1220,7 +1222,7 @@ dependencies = [
|
|
|
1220
1222
|
|
|
1221
1223
|
[[package]]
|
|
1222
1224
|
name = "pgtask-postgres"
|
|
1223
|
-
version = "0.
|
|
1225
|
+
version = "0.2.0"
|
|
1224
1226
|
dependencies = [
|
|
1225
1227
|
"chrono",
|
|
1226
1228
|
"pgtask-core",
|
|
@@ -1235,7 +1237,7 @@ dependencies = [
|
|
|
1235
1237
|
|
|
1236
1238
|
[[package]]
|
|
1237
1239
|
name = "pgtask-python"
|
|
1238
|
-
version = "0.
|
|
1240
|
+
version = "0.2.0"
|
|
1239
1241
|
dependencies = [
|
|
1240
1242
|
"chrono",
|
|
1241
1243
|
"pgtask",
|
|
@@ -1252,7 +1254,7 @@ dependencies = [
|
|
|
1252
1254
|
|
|
1253
1255
|
[[package]]
|
|
1254
1256
|
name = "pgtask-web"
|
|
1255
|
-
version = "0.
|
|
1257
|
+
version = "0.2.0"
|
|
1256
1258
|
dependencies = [
|
|
1257
1259
|
"axum",
|
|
1258
1260
|
"chrono",
|
|
@@ -1271,7 +1273,7 @@ dependencies = [
|
|
|
1271
1273
|
|
|
1272
1274
|
[[package]]
|
|
1273
1275
|
name = "pgtask-worker"
|
|
1274
|
-
version = "0.
|
|
1276
|
+
version = "0.2.0"
|
|
1275
1277
|
dependencies = [
|
|
1276
1278
|
"axum",
|
|
1277
1279
|
"chrono",
|
pgtask-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pgtask
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
5
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
6
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
11
|
+
Classifier: Programming Language :: Rust
|
|
12
|
+
Requires-Dist: opentelemetry-api>=1.30
|
|
13
|
+
Requires-Dist: psycopg>=3.2
|
|
14
|
+
Summary: PostgreSQL-native durable tasks and workflows
|
|
15
|
+
License-Expression: MIT
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
18
|
+
|
|
19
|
+
# pgtask
|
|
20
|
+
|
|
21
|
+
Durable tasks and workflows that live entirely in PostgreSQL.
|
|
22
|
+
|
|
23
|
+
There is no broker, no coordinator, and no extension to install. If your database is up, your queue is up.
|
|
24
|
+
|
|
25
|
+
> [!WARNING]
|
|
26
|
+
> `pgtask` is under active development. It is not ready for production use.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```console
|
|
31
|
+
pip install pgtask
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Define a task and run a worker
|
|
35
|
+
|
|
36
|
+
A registry maps a task name to the function that runs it. A worker claims work for that queue, one lease
|
|
37
|
+
at a time, so a worker that dies hands its tasks back instead of taking them with it.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import asyncio
|
|
43
|
+
import os
|
|
44
|
+
|
|
45
|
+
from pgtask import Client, Task, TaskRegistry, Worker
|
|
46
|
+
|
|
47
|
+
tasks = TaskRegistry(queue_name="reports")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@tasks.task("reports.render")
|
|
51
|
+
async def render(task: Task, payload: dict) -> dict:
|
|
52
|
+
return {"report_id": payload["report_id"], "attempt": task.attempt}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def main() -> None:
|
|
56
|
+
database_url = os.environ["PGTASK_DATABASE_URL"]
|
|
57
|
+
client = await Client.connect(database_url)
|
|
58
|
+
await client.migrate()
|
|
59
|
+
await Worker(database_url, tasks).run()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
asyncio.run(main())
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Delivery is at least once, so write handlers that are safe to run again.
|
|
66
|
+
|
|
67
|
+
## Enqueue a task
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from __future__ import annotations
|
|
71
|
+
|
|
72
|
+
import asyncio
|
|
73
|
+
import os
|
|
74
|
+
|
|
75
|
+
from pgtask import Client, EnqueueRequest
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def main() -> None:
|
|
79
|
+
client = await Client.connect(os.environ["PGTASK_DATABASE_URL"])
|
|
80
|
+
handle = await client.enqueue(
|
|
81
|
+
EnqueueRequest("reports.render", {"report_id": "report-123"}, queue_name="reports")
|
|
82
|
+
)
|
|
83
|
+
print(await handle.result(timeout=30.0))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
asyncio.run(main())
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Enqueue inside your transaction
|
|
90
|
+
|
|
91
|
+
This is the reason to keep the queue in the database. Pass your own connection and the task commits with
|
|
92
|
+
your data or not at all. Roll back and the task never existed, which is what an outbox table is usually
|
|
93
|
+
for.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
async with connection.transaction():
|
|
97
|
+
await connection.execute("INSERT INTO reports (id) VALUES (%s)", ("report-123",))
|
|
98
|
+
await Client.enqueue_on(connection, request)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`connection` is your `psycopg` connection, not one of ours.
|
|
102
|
+
|
|
103
|
+
## Documentation
|
|
104
|
+
|
|
105
|
+
[kludex.github.io/pgtask](https://kludex.github.io/pgtask/) covers workers, durable execution, signals,
|
|
106
|
+
cancellation, scheduling, and OpenTelemetry propagation.
|
|
107
|
+
|
pgtask-0.2.0/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# pgtask
|
|
2
|
+
|
|
3
|
+
Durable tasks and workflows that live entirely in PostgreSQL.
|
|
4
|
+
|
|
5
|
+
There is no broker, no coordinator, and no extension to install. If your database is up, your queue is up.
|
|
6
|
+
|
|
7
|
+
> [!WARNING]
|
|
8
|
+
> `pgtask` is under active development. It is not ready for production use.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```console
|
|
13
|
+
pip install pgtask
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Define a task and run a worker
|
|
17
|
+
|
|
18
|
+
A registry maps a task name to the function that runs it. A worker claims work for that queue, one lease
|
|
19
|
+
at a time, so a worker that dies hands its tasks back instead of taking them with it.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import os
|
|
26
|
+
|
|
27
|
+
from pgtask import Client, Task, TaskRegistry, Worker
|
|
28
|
+
|
|
29
|
+
tasks = TaskRegistry(queue_name="reports")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@tasks.task("reports.render")
|
|
33
|
+
async def render(task: Task, payload: dict) -> dict:
|
|
34
|
+
return {"report_id": payload["report_id"], "attempt": task.attempt}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def main() -> None:
|
|
38
|
+
database_url = os.environ["PGTASK_DATABASE_URL"]
|
|
39
|
+
client = await Client.connect(database_url)
|
|
40
|
+
await client.migrate()
|
|
41
|
+
await Worker(database_url, tasks).run()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
asyncio.run(main())
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Delivery is at least once, so write handlers that are safe to run again.
|
|
48
|
+
|
|
49
|
+
## Enqueue a task
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import asyncio
|
|
55
|
+
import os
|
|
56
|
+
|
|
57
|
+
from pgtask import Client, EnqueueRequest
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def main() -> None:
|
|
61
|
+
client = await Client.connect(os.environ["PGTASK_DATABASE_URL"])
|
|
62
|
+
handle = await client.enqueue(
|
|
63
|
+
EnqueueRequest("reports.render", {"report_id": "report-123"}, queue_name="reports")
|
|
64
|
+
)
|
|
65
|
+
print(await handle.result(timeout=30.0))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
asyncio.run(main())
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Enqueue inside your transaction
|
|
72
|
+
|
|
73
|
+
This is the reason to keep the queue in the database. Pass your own connection and the task commits with
|
|
74
|
+
your data or not at all. Roll back and the task never existed, which is what an outbox table is usually
|
|
75
|
+
for.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
async with connection.transaction():
|
|
79
|
+
await connection.execute("INSERT INTO reports (id) VALUES (%s)", ("report-123",))
|
|
80
|
+
await Client.enqueue_on(connection, request)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`connection` is your `psycopg` connection, not one of ours.
|
|
84
|
+
|
|
85
|
+
## Documentation
|
|
86
|
+
|
|
87
|
+
[kludex.github.io/pgtask](https://kludex.github.io/pgtask/) covers workers, durable execution, signals,
|
|
88
|
+
cancellation, scheduling, and OpenTelemetry propagation.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "pgtask"
|
|
3
|
+
description = "PostgreSQL-native durable tasks and workflows"
|
|
4
|
+
edition.workspace = true
|
|
5
|
+
license.workspace = true
|
|
6
|
+
repository.workspace = true
|
|
7
|
+
rust-version.workspace = true
|
|
8
|
+
version.workspace = true
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
|
|
11
|
+
[dependencies]
|
|
12
|
+
pgtask-core = { path = "../pgtask-core", version = "0.2.0" }
|
|
13
|
+
pgtask-otel = { path = "../pgtask-otel", version = "0.2.0" }
|
|
14
|
+
pgtask-postgres = { path = "../pgtask-postgres", version = "0.2.0" }
|
|
15
|
+
pgtask-worker = { path = "../pgtask-worker", version = "0.2.0" }
|
|
16
|
+
|
|
17
|
+
[dev-dependencies]
|
|
18
|
+
serde_json.workspace = true
|
|
19
|
+
tokio.workspace = true
|
|
20
|
+
tokio-util.workspace = true
|
|
21
|
+
|
|
22
|
+
[lints]
|
|
23
|
+
workspace = true
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# pgtask
|
|
2
|
+
|
|
3
|
+
Durable tasks and workflows that live entirely in PostgreSQL.
|
|
4
|
+
|
|
5
|
+
There is no broker, no coordinator, and no extension to install. If your database is up, your queue is up.
|
|
6
|
+
|
|
7
|
+
> [!WARNING]
|
|
8
|
+
> `pgtask` is under active development. It is not ready for production use.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```console
|
|
13
|
+
cargo add pgtask tokio tokio-util serde_json
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Run a worker
|
|
17
|
+
|
|
18
|
+
A worker declares which tasks it can handle and claims work from a queue. Every claim takes a lease, so
|
|
19
|
+
when a worker dies its tasks return to the queue instead of disappearing with it.
|
|
20
|
+
|
|
21
|
+
```rust,no_run
|
|
22
|
+
use std::error::Error;
|
|
23
|
+
|
|
24
|
+
use pgtask::{
|
|
25
|
+
core::{HandlerVersion, QueueName, RetryPolicy, TaskName},
|
|
26
|
+
postgres::Store,
|
|
27
|
+
worker::{HandlerRegistry, Worker, WorkerConfig},
|
|
28
|
+
};
|
|
29
|
+
use serde_json::json;
|
|
30
|
+
use tokio_util::sync::CancellationToken;
|
|
31
|
+
|
|
32
|
+
#[tokio::main]
|
|
33
|
+
async fn main() -> Result<(), Box<dyn Error>> {
|
|
34
|
+
let store = Store::connect(&std::env::var("PGTASK_DATABASE_URL")?).await?;
|
|
35
|
+
store.migrate().await?;
|
|
36
|
+
|
|
37
|
+
let mut registry = HandlerRegistry::new();
|
|
38
|
+
registry.register(
|
|
39
|
+
TaskName::new("reports.render")?,
|
|
40
|
+
HandlerVersion::default(),
|
|
41
|
+
RetryPolicy::Never,
|
|
42
|
+
|task| async move { Ok(json!({ "attempt": task.attempt })) },
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
let config = WorkerConfig::new(QueueName::new("reports")?);
|
|
46
|
+
Worker::new(store, registry, config)?.run(CancellationToken::new()).await?;
|
|
47
|
+
Ok(())
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Cancel the token to shut down. The worker stops claiming, finishes what it is already running, and lets
|
|
52
|
+
the rest expire back to the queue.
|
|
53
|
+
|
|
54
|
+
## Enqueue inside your transaction
|
|
55
|
+
|
|
56
|
+
This is the reason to keep the queue in the database. You pass your own transaction, so the task commits
|
|
57
|
+
with your data or not at all. Roll back and the task never existed.
|
|
58
|
+
|
|
59
|
+
```rust,no_run
|
|
60
|
+
use std::error::Error;
|
|
61
|
+
|
|
62
|
+
use pgtask::{
|
|
63
|
+
core::{EnqueueRequest, QueueName, TaskName},
|
|
64
|
+
postgres::Store,
|
|
65
|
+
};
|
|
66
|
+
use serde_json::json;
|
|
67
|
+
|
|
68
|
+
#[tokio::main]
|
|
69
|
+
async fn main() -> Result<(), Box<dyn Error>> {
|
|
70
|
+
let store = Store::connect(&std::env::var("PGTASK_DATABASE_URL")?).await?;
|
|
71
|
+
|
|
72
|
+
let mut request = EnqueueRequest::new(TaskName::new("reports.render")?, json!({"report_id": "report-123"}));
|
|
73
|
+
request.queue_name = QueueName::new("reports")?;
|
|
74
|
+
|
|
75
|
+
let mut transaction = store.pool().begin().await?;
|
|
76
|
+
// Your own writes belong here, in the same transaction.
|
|
77
|
+
let result = Store::enqueue_on(&mut transaction, &request).await?;
|
|
78
|
+
transaction.commit().await?;
|
|
79
|
+
|
|
80
|
+
println!("enqueued {}", result.task_id);
|
|
81
|
+
Ok(())
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Without a transaction of your own, `store.enqueue(&request)` does the same on its own connection.
|
|
86
|
+
|
|
87
|
+
## Crates
|
|
88
|
+
|
|
89
|
+
This crate re-exports the others. Depend on it unless you need one layer on its own.
|
|
90
|
+
|
|
91
|
+
| Crate | What it holds |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `pgtask-core` | Task, queue, schedule, and retry types shared by every layer |
|
|
94
|
+
| `pgtask-postgres` | The storage protocol: migrate, enqueue, claim, complete |
|
|
95
|
+
| `pgtask-worker` | The worker runtime, scheduler, and retention loop |
|
|
96
|
+
| `pgtask-otel` | OpenTelemetry spans, metrics, and trace propagation |
|
|
97
|
+
|
|
98
|
+
## Documentation
|
|
99
|
+
|
|
100
|
+
[kludex.github.io/pgtask](https://kludex.github.io/pgtask/)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# pgtask-core
|
|
2
|
+
|
|
3
|
+
The types every layer of `pgtask` agrees on: tasks, queues, schedules, retry policies, and the states a
|
|
4
|
+
task can be in.
|
|
5
|
+
|
|
6
|
+
There is no database access and no runtime here. The crate exists so the storage layer, the worker, and
|
|
7
|
+
the telemetry layer share one definition of a task instead of three that drift apart.
|
|
8
|
+
|
|
9
|
+
You probably want [`pgtask`](https://crates.io/crates/pgtask), which re-exports this as `pgtask::core`.
|
|
10
|
+
|
|
11
|
+
## Documentation
|
|
12
|
+
|
|
13
|
+
[kludex.github.io/pgtask](https://kludex.github.io/pgtask/)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# pgtask-otel
|
|
2
|
+
|
|
3
|
+
OpenTelemetry for `pgtask`: span names and attributes, queue and worker metrics, and trace context carried
|
|
4
|
+
inside task headers.
|
|
5
|
+
|
|
6
|
+
Because the context travels with the task, a worker span is a child of the request that enqueued it, even
|
|
7
|
+
though the two ran in different processes minutes apart. Without that, a queue is where your traces end.
|
|
8
|
+
|
|
9
|
+
You probably want [`pgtask`](https://crates.io/crates/pgtask), which re-exports this as `pgtask::otel`.
|
|
10
|
+
|
|
11
|
+
## Documentation
|
|
12
|
+
|
|
13
|
+
[kludex.github.io/pgtask](https://kludex.github.io/pgtask/)
|
|
@@ -6,11 +6,12 @@ license.workspace = true
|
|
|
6
6
|
repository.workspace = true
|
|
7
7
|
rust-version.workspace = true
|
|
8
8
|
version.workspace = true
|
|
9
|
+
readme = "README.md"
|
|
9
10
|
|
|
10
11
|
[dependencies]
|
|
11
12
|
chrono.workspace = true
|
|
12
|
-
pgtask-core = { path = "../pgtask-core", version = "0.
|
|
13
|
-
pgtask-otel = { path = "../pgtask-otel", version = "0.
|
|
13
|
+
pgtask-core = { path = "../pgtask-core", version = "0.2.0" }
|
|
14
|
+
pgtask-otel = { path = "../pgtask-otel", version = "0.2.0" }
|
|
14
15
|
serde_json.workspace = true
|
|
15
16
|
sqlx.workspace = true
|
|
16
17
|
thiserror.workspace = true
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# pgtask-postgres
|
|
2
|
+
|
|
3
|
+
The PostgreSQL storage layer for `pgtask`: migrations, enqueue, claim, complete, schedules, and the views
|
|
4
|
+
you read from.
|
|
5
|
+
|
|
6
|
+
Every operation is a call to a versioned SQL function, so the database is the contract rather than this
|
|
7
|
+
crate. Clients in other languages call the same functions, and both the schema and each client declare a
|
|
8
|
+
storage protocol range that has to overlap before a worker will start.
|
|
9
|
+
|
|
10
|
+
Depend on it directly when you produce tasks but never run a worker. Otherwise take
|
|
11
|
+
[`pgtask`](https://crates.io/crates/pgtask), which re-exports this as `pgtask::postgres`.
|
|
12
|
+
|
|
13
|
+
## Documentation
|
|
14
|
+
|
|
15
|
+
[kludex.github.io/pgtask](https://kludex.github.io/pgtask/)
|
|
@@ -6,14 +6,15 @@ license.workspace = true
|
|
|
6
6
|
repository.workspace = true
|
|
7
7
|
rust-version.workspace = true
|
|
8
8
|
version.workspace = true
|
|
9
|
+
readme = "README.md"
|
|
9
10
|
|
|
10
11
|
[dependencies]
|
|
11
12
|
axum.workspace = true
|
|
12
13
|
chrono.workspace = true
|
|
13
14
|
futures.workspace = true
|
|
14
|
-
pgtask-core = { path = "../pgtask-core", version = "0.
|
|
15
|
-
pgtask-otel = { path = "../pgtask-otel", version = "0.
|
|
16
|
-
pgtask-postgres = { path = "../pgtask-postgres", version = "0.
|
|
15
|
+
pgtask-core = { path = "../pgtask-core", version = "0.2.0" }
|
|
16
|
+
pgtask-otel = { path = "../pgtask-otel", version = "0.2.0" }
|
|
17
|
+
pgtask-postgres = { path = "../pgtask-postgres", version = "0.2.0" }
|
|
17
18
|
serde_json.workspace = true
|
|
18
19
|
thiserror.workspace = true
|
|
19
20
|
tokio.workspace = true
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# pgtask-worker
|
|
2
|
+
|
|
3
|
+
The runtime that claims tasks, runs your handlers, and keeps their leases alive.
|
|
4
|
+
|
|
5
|
+
It also brings the parts you would otherwise write yourself: retries with backoff, durable execution with
|
|
6
|
+
checkpoints, a scheduler that needs no leader, retention, graceful shutdown, and a health endpoint.
|
|
7
|
+
|
|
8
|
+
You probably want [`pgtask`](https://crates.io/crates/pgtask), which re-exports this as `pgtask::worker`
|
|
9
|
+
and shows a complete worker in a few lines.
|
|
10
|
+
|
|
11
|
+
## Documentation
|
|
12
|
+
|
|
13
|
+
[kludex.github.io/pgtask](https://kludex.github.io/pgtask/)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from collections.abc import Awaitable, Callable, Sequence
|
|
4
|
+
from contextvars import ContextVar
|
|
4
5
|
from dataclasses import dataclass, field
|
|
5
6
|
from datetime import datetime
|
|
6
7
|
from typing import Any, Generic, TypeVar, cast
|
|
@@ -211,6 +212,18 @@ class Task:
|
|
|
211
212
|
return cast(JSONValue, await self._context.wait_for_result(step_name, occurrence, task_id, timeout))
|
|
212
213
|
|
|
213
214
|
|
|
215
|
+
_current_task: ContextVar[Task | None] = ContextVar("pgtask_current_task", default=None)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def get_current_task() -> Task | None:
|
|
219
|
+
"""The task running in this call chain, or `None` outside a handler.
|
|
220
|
+
|
|
221
|
+
Reach for this when a frame between your handler and the code that needs the task cannot pass it
|
|
222
|
+
down, which is the usual shape when a framework calls you back.
|
|
223
|
+
"""
|
|
224
|
+
return _current_task.get()
|
|
225
|
+
|
|
226
|
+
|
|
214
227
|
@dataclass(frozen=True)
|
|
215
228
|
class TaskHandle(Generic[ResultT]):
|
|
216
229
|
id: str
|
|
@@ -348,9 +361,11 @@ class Worker:
|
|
|
348
361
|
) -> JSONValue:
|
|
349
362
|
task = Task.from_native(value, context)
|
|
350
363
|
token = attach(extract(cast(dict[str, str], task.headers)))
|
|
364
|
+
current = _current_task.set(task)
|
|
351
365
|
try:
|
|
352
366
|
return cast(JSONValue, await registered.handler(task, task.payload))
|
|
353
367
|
finally:
|
|
368
|
+
_current_task.reset(current)
|
|
354
369
|
detach(token)
|
|
355
370
|
|
|
356
371
|
self._native.register(
|
|
@@ -14,7 +14,7 @@ crate-type = ["cdylib", "rlib"]
|
|
|
14
14
|
|
|
15
15
|
[dependencies]
|
|
16
16
|
chrono.workspace = true
|
|
17
|
-
pgtask = { path = "../../crates/pgtask", version = "0.
|
|
17
|
+
pgtask = { path = "../../crates/pgtask", version = "0.2.0" }
|
|
18
18
|
pyo3.workspace = true
|
|
19
19
|
pyo3-async-runtimes = { workspace = true, features = ["tokio-runtime"] }
|
|
20
20
|
pythonize.workspace = true
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# pgtask
|
|
2
|
+
|
|
3
|
+
Durable tasks and workflows that live entirely in PostgreSQL.
|
|
4
|
+
|
|
5
|
+
There is no broker, no coordinator, and no extension to install. If your database is up, your queue is up.
|
|
6
|
+
|
|
7
|
+
> [!WARNING]
|
|
8
|
+
> `pgtask` is under active development. It is not ready for production use.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```console
|
|
13
|
+
pip install pgtask
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Define a task and run a worker
|
|
17
|
+
|
|
18
|
+
A registry maps a task name to the function that runs it. A worker claims work for that queue, one lease
|
|
19
|
+
at a time, so a worker that dies hands its tasks back instead of taking them with it.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import os
|
|
26
|
+
|
|
27
|
+
from pgtask import Client, Task, TaskRegistry, Worker
|
|
28
|
+
|
|
29
|
+
tasks = TaskRegistry(queue_name="reports")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@tasks.task("reports.render")
|
|
33
|
+
async def render(task: Task, payload: dict) -> dict:
|
|
34
|
+
return {"report_id": payload["report_id"], "attempt": task.attempt}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def main() -> None:
|
|
38
|
+
database_url = os.environ["PGTASK_DATABASE_URL"]
|
|
39
|
+
client = await Client.connect(database_url)
|
|
40
|
+
await client.migrate()
|
|
41
|
+
await Worker(database_url, tasks).run()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
asyncio.run(main())
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Delivery is at least once, so write handlers that are safe to run again.
|
|
48
|
+
|
|
49
|
+
## Enqueue a task
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import asyncio
|
|
55
|
+
import os
|
|
56
|
+
|
|
57
|
+
from pgtask import Client, EnqueueRequest
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def main() -> None:
|
|
61
|
+
client = await Client.connect(os.environ["PGTASK_DATABASE_URL"])
|
|
62
|
+
handle = await client.enqueue(
|
|
63
|
+
EnqueueRequest("reports.render", {"report_id": "report-123"}, queue_name="reports")
|
|
64
|
+
)
|
|
65
|
+
print(await handle.result(timeout=30.0))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
asyncio.run(main())
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Enqueue inside your transaction
|
|
72
|
+
|
|
73
|
+
This is the reason to keep the queue in the database. Pass your own connection and the task commits with
|
|
74
|
+
your data or not at all. Roll back and the task never existed, which is what an outbox table is usually
|
|
75
|
+
for.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
async with connection.transaction():
|
|
79
|
+
await connection.execute("INSERT INTO reports (id) VALUES (%s)", ("report-123",))
|
|
80
|
+
await Client.enqueue_on(connection, request)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`connection` is your `psycopg` connection, not one of ours.
|
|
84
|
+
|
|
85
|
+
## Documentation
|
|
86
|
+
|
|
87
|
+
[kludex.github.io/pgtask](https://kludex.github.io/pgtask/) covers workers, durable execution, signals,
|
|
88
|
+
cancellation, scheduling, and OpenTelemetry propagation.
|
|
@@ -40,6 +40,7 @@ def test_public_python_contract() -> None:
|
|
|
40
40
|
"TaskState",
|
|
41
41
|
"TransactionConnection",
|
|
42
42
|
"Worker",
|
|
43
|
+
"get_current_task",
|
|
43
44
|
]
|
|
44
45
|
assert tuple(inspect.signature(TaskRegistry.task).parameters) == (
|
|
45
46
|
"self",
|
|
@@ -405,3 +406,42 @@ async def test_python_handler_uses_durable_workflow_operations() -> None:
|
|
|
405
406
|
assert step_calls == 1
|
|
406
407
|
worker.shutdown()
|
|
407
408
|
await running
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@pytest.mark.anyio
|
|
412
|
+
async def test_ambient_task_is_reachable_from_any_frame_below_the_handler() -> None:
|
|
413
|
+
database_url = os.environ["PGTASK_DATABASE_URL"]
|
|
414
|
+
client = await Client.connect(database_url)
|
|
415
|
+
await client.migrate()
|
|
416
|
+
queue_name = f"python-{os.urandom(8).hex()}"
|
|
417
|
+
registry = TaskRegistry(queue_name)
|
|
418
|
+
started = asyncio.Event()
|
|
419
|
+
observed: dict[str, tuple[str, str]] = {}
|
|
420
|
+
|
|
421
|
+
async def deep_frame() -> str:
|
|
422
|
+
ambient = pgtask.get_current_task()
|
|
423
|
+
assert ambient is not None
|
|
424
|
+
return ambient.id
|
|
425
|
+
|
|
426
|
+
@registry.task("python.ambient")
|
|
427
|
+
async def ambient(task: Task, payload: dict[str, JSONValue]) -> JSONValue:
|
|
428
|
+
if payload["wait"]:
|
|
429
|
+
started.set()
|
|
430
|
+
await asyncio.sleep(0.2)
|
|
431
|
+
observed[task.id] = (await deep_frame(), await task.step("inside-step", deep_frame))
|
|
432
|
+
return {"id": task.id}
|
|
433
|
+
|
|
434
|
+
assert pgtask.get_current_task() is None
|
|
435
|
+
worker = Worker(database_url, registry, concurrency=2, poll_interval=30.0)
|
|
436
|
+
slow = await client.enqueue(ambient.request({"wait": True}))
|
|
437
|
+
running = asyncio.create_task(worker.run())
|
|
438
|
+
await asyncio.wait_for(started.wait(), timeout=5)
|
|
439
|
+
fast = await client.enqueue(ambient.request({"wait": False}))
|
|
440
|
+
for handle in (fast, slow):
|
|
441
|
+
result = await handle.result(timeout=10)
|
|
442
|
+
assert result is not None
|
|
443
|
+
assert result.state == "succeeded"
|
|
444
|
+
assert observed == {fast.id: (fast.id, fast.id), slow.id: (slow.id, slow.id)}
|
|
445
|
+
assert pgtask.get_current_task() is None
|
|
446
|
+
worker.shutdown()
|
|
447
|
+
await running
|
pgtask-0.1.3/PKG-INFO
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: pgtask
|
|
3
|
-
Version: 0.1.3
|
|
4
|
-
Classifier: Development Status :: 2 - Pre-Alpha
|
|
5
|
-
Classifier: Programming Language :: Python :: 3 :: Only
|
|
6
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
7
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
8
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
9
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
10
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
11
|
-
Classifier: Programming Language :: Rust
|
|
12
|
-
Requires-Dist: opentelemetry-api>=1.30
|
|
13
|
-
Requires-Dist: psycopg>=3.2
|
|
14
|
-
Summary: PostgreSQL-native durable tasks and workflows
|
|
15
|
-
License-Expression: MIT
|
|
16
|
-
Requires-Python: >=3.10
|
|
17
|
-
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
18
|
-
|
|
19
|
-
# `pgtask`
|
|
20
|
-
|
|
21
|
-
```python
|
|
22
|
-
from __future__ import annotations
|
|
23
|
-
|
|
24
|
-
import asyncio
|
|
25
|
-
import os
|
|
26
|
-
|
|
27
|
-
from pgtask import Client, EnqueueRequest
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
async def main() -> None:
|
|
31
|
-
client = await Client.connect(os.environ["PGTASK_DATABASE_URL"])
|
|
32
|
-
task = await client.enqueue(EnqueueRequest("reports.render", {"report_id": "report-123"}))
|
|
33
|
-
print(await task.result(timeout=30.0))
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
asyncio.run(main())
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
This package provides the Python producer client and worker runtime for `pgtask`. See the
|
|
40
|
-
[Python SDK documentation](https://github.com/Kludex/pgtask/blob/main/docs/sdk/python.md) for workers, transactions,
|
|
41
|
-
durable execution, signals, cancellation, and OpenTelemetry propagation.
|
|
42
|
-
|
pgtask-0.1.3/README.md
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
# `pgtask`
|
|
2
|
-
|
|
3
|
-
```python
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import asyncio
|
|
7
|
-
import os
|
|
8
|
-
|
|
9
|
-
from pgtask import Client, EnqueueRequest
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
async def main() -> None:
|
|
13
|
-
client = await Client.connect(os.environ["PGTASK_DATABASE_URL"])
|
|
14
|
-
task = await client.enqueue(EnqueueRequest("reports.render", {"report_id": "report-123"}))
|
|
15
|
-
print(await task.result(timeout=30.0))
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
asyncio.run(main())
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
This package provides the Python producer client and worker runtime for `pgtask`. See the
|
|
22
|
-
[Python SDK documentation](https://github.com/Kludex/pgtask/blob/main/docs/sdk/python.md) for workers, transactions,
|
|
23
|
-
durable execution, signals, cancellation, and OpenTelemetry propagation.
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
[package]
|
|
2
|
-
name = "pgtask"
|
|
3
|
-
description = "PostgreSQL-native durable tasks and workflows"
|
|
4
|
-
edition.workspace = true
|
|
5
|
-
license.workspace = true
|
|
6
|
-
repository.workspace = true
|
|
7
|
-
rust-version.workspace = true
|
|
8
|
-
version.workspace = true
|
|
9
|
-
|
|
10
|
-
[dependencies]
|
|
11
|
-
pgtask-core = { path = "../pgtask-core", version = "0.1.3" }
|
|
12
|
-
pgtask-otel = { path = "../pgtask-otel", version = "0.1.3" }
|
|
13
|
-
pgtask-postgres = { path = "../pgtask-postgres", version = "0.1.3" }
|
|
14
|
-
pgtask-worker = { path = "../pgtask-worker", version = "0.1.3" }
|
|
15
|
-
|
|
16
|
-
[dev-dependencies]
|
|
17
|
-
serde_json.workspace = true
|
|
18
|
-
|
|
19
|
-
[lints]
|
|
20
|
-
workspace = true
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
# `pgtask`
|
|
2
|
-
|
|
3
|
-
```python
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import asyncio
|
|
7
|
-
import os
|
|
8
|
-
|
|
9
|
-
from pgtask import Client, EnqueueRequest
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
async def main() -> None:
|
|
13
|
-
client = await Client.connect(os.environ["PGTASK_DATABASE_URL"])
|
|
14
|
-
task = await client.enqueue(EnqueueRequest("reports.render", {"report_id": "report-123"}))
|
|
15
|
-
print(await task.result(timeout=30.0))
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
asyncio.run(main())
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
This package provides the Python producer client and worker runtime for `pgtask`. See the
|
|
22
|
-
[Python SDK documentation](https://github.com/Kludex/pgtask/blob/main/docs/sdk/python.md) for workers, transactions,
|
|
23
|
-
durable execution, signals, cancellation, and OpenTelemetry propagation.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|