dbworker 0.0.1__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.
- dbworker-0.0.1/LICENSE +21 -0
- dbworker-0.0.1/PKG-INFO +223 -0
- dbworker-0.0.1/README.md +205 -0
- dbworker-0.0.1/poetry.lock +599 -0
- dbworker-0.0.1/pyproject.toml +22 -0
- dbworker-0.0.1/src/dbworker.py +467 -0
dbworker-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ziyang Song
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
dbworker-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dbworker
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Database-backed coordination for process workers.
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: Ziyang Song
|
|
8
|
+
Requires-Python: >=3.12,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Requires-Dist: sqlalchemy (>=2.0,<3.0)
|
|
15
|
+
Project-URL: Repository, https://github.com/zysilm/dbworker
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# dbworker
|
|
19
|
+
|
|
20
|
+
Portable database-backed work coordination with SQLAlchemy and process workers. A single-script Redis + Celery alternative that uses your existing database.
|
|
21
|
+
|
|
22
|
+
Requires Python 3.12+. Install into your application:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
poetry add /path/to/dbworker
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
Use your existing SQLAlchemy `session_factory` and database URL:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from dbworker import Coordinator
|
|
34
|
+
|
|
35
|
+
coordinator = Coordinator(session_factory, database_url=database_url)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
You write a **handler**: a Python function containing the work you want to run. DBWorker runs handlers in separate processes.
|
|
39
|
+
|
|
40
|
+
Before calling your handler, DBWorker chooses the next task and marks it as “being worked on” in the database. This step is called a **claim**. It lets several processes share the tasks without choosing the same task at the same time.
|
|
41
|
+
|
|
42
|
+
DBWorker keeps the claim active until your handler finishes. If the process crashes, the claim expires so another process can take over the task.
|
|
43
|
+
|
|
44
|
+
`source` is the database table that supplies input to your handler, specified as a SQLAlchemy model. It can store user requests, or any entries whose creation should automatically start a procedure. Each new entry gives DBWorker new work to run, and the selected entry is passed to your handler. Its primary key identifies that work so DBWorker can track its completion; the model must have a single primary-key column.
|
|
45
|
+
|
|
46
|
+
`eligible` controls which inputs can be claimed next. Without it, any new entry can be picked up. Add it when work should wait for a condition or run in a particular order. In the example below, `YourModel` stands for your model. The `enabled` filter and ordering by `id` illustrate a selection rule; replace them with your own conditions and ordering.
|
|
47
|
+
|
|
48
|
+
Decorate your handler like this. The comments describe where your application logic goes:
|
|
49
|
+
|
|
50
|
+
**Complete the work in one invocation:**
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from sqlalchemy import select
|
|
54
|
+
from sqlalchemy.orm import Session
|
|
55
|
+
from dbworker import Finished
|
|
56
|
+
|
|
57
|
+
@coordinator.transactional_worker(
|
|
58
|
+
name="process",
|
|
59
|
+
source=YourModel,
|
|
60
|
+
eligible=lambda: (
|
|
61
|
+
select(YourModel)
|
|
62
|
+
.where(YourModel.enabled.is_(True))
|
|
63
|
+
.order_by(YourModel.id)
|
|
64
|
+
),
|
|
65
|
+
concurrency=4,
|
|
66
|
+
)
|
|
67
|
+
def process(source: YourModel, session: Session) -> Finished:
|
|
68
|
+
# Your procedure goes here.
|
|
69
|
+
# Return Finished() when the procedure is complete.
|
|
70
|
+
return Finished()
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- `name` identifies the worker for status queries.
|
|
74
|
+
- `concurrency=4` allows four handlers to run in child processes.
|
|
75
|
+
- `source` is the selected instance of your source model.
|
|
76
|
+
- `session` is a normal SQLAlchemy session supplied by DBWorker. The handler can leave either argument unused.
|
|
77
|
+
|
|
78
|
+
Before each claim, DBWorker calls `eligible` and uses its query to select the next item. If nothing can be claimed, it waits and checks again. If your application later enables an item, a subsequent check can select it. Finished, failed, and currently claimed items are excluded automatically; you do not need to put those checks in your query. Omitting `eligible` lets DBWorker select from all entries in `source`.
|
|
79
|
+
|
|
80
|
+
**Process part of the work, then continue in another invocation:**
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from dbworker import Finished, Outcome, Unfinished
|
|
84
|
+
|
|
85
|
+
@coordinator.transactional_worker(
|
|
86
|
+
name="process_in_steps",
|
|
87
|
+
source=YourModel,
|
|
88
|
+
eligible=lambda: (
|
|
89
|
+
select(YourModel)
|
|
90
|
+
.where(YourModel.enabled.is_(True))
|
|
91
|
+
.order_by(YourModel.id)
|
|
92
|
+
),
|
|
93
|
+
concurrency=4,
|
|
94
|
+
)
|
|
95
|
+
def process_in_steps(source: YourModel, session: Session) -> Outcome:
|
|
96
|
+
# Your procedure goes here.
|
|
97
|
+
# Return Unfinished() if it needs another invocation to continue.
|
|
98
|
+
# Return Finished() instead when it is complete.
|
|
99
|
+
return Unfinished()
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`Finished()` means the work item is complete. `Unfinished()` means this invocation is done, but more work remains. Both save the execution status and commit any writes made through the supplied session; the handler does not have to make any database writes. After `Unfinished()`, DBWorker can invoke the handler again when eligible. Each invocation starts the function from the beginning. Your procedure decides how to continue; DBWorker does not save its position in the function. Use `Outcome` as the return annotation when a handler can return either result.
|
|
103
|
+
|
|
104
|
+
If the handler raises an exception, DBWorker rolls back its transaction and marks the work failed. If your procedure has prerequisites that can be checked in the database, `eligible` can delay claiming until they are met.
|
|
105
|
+
|
|
106
|
+
DBWorker owns the handler's commit and session cleanup. Do not commit or close this session yourself. For long computations, you can release a read transaction with `session.rollback()` first. Copy needed values before rollback, and do this before making writes you want to keep.
|
|
107
|
+
|
|
108
|
+
### Start and stop
|
|
109
|
+
|
|
110
|
+
Register handlers at module scope in an importable module. In your worker service, initialize DBWorker's tables and start the coordinator:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
coordinator.create_worker_tables()
|
|
115
|
+
coordinator.start()
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`start()` returns while workers keep running. When a source entry matches `eligible`, DBWorker can claim its work and call the handler—there is no enqueue call.
|
|
119
|
+
|
|
120
|
+
On service shutdown, call:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
coordinator.stop()
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`stop()` stops new claims and waits for active handlers to finish. Your API can run independently, using the same database. See the [complete example](examples/imagededup_system_dbwork/README.md) for runnable API and worker commands with shutdown handling.
|
|
127
|
+
|
|
128
|
+
## Track progress and coordinate dependent work
|
|
129
|
+
|
|
130
|
+
Your application may need to show whether work is running or complete. Another worker may also depend on that information: one procedure prepares something, and a second can begin only after preparation finishes. If preparation fails, the application may need to report that failure instead of continuing.
|
|
131
|
+
|
|
132
|
+
DBWorker tracks execution separately for each worker and source entry. Access it through the coordinator using the worker's registered name and the source entry's primary key; you do not need to manage or query DBWorker's internal tables yourself.
|
|
133
|
+
|
|
134
|
+
Use `execution_status()` to get the current status in your API or handler:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from dbworker import ExecutionStatus
|
|
138
|
+
|
|
139
|
+
with session_factory() as session:
|
|
140
|
+
status = coordinator.execution_status(
|
|
141
|
+
session, worker="process", source_id=source_id,
|
|
142
|
+
)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
This returns `None` before the first claim, or an `ExecutionStatus` enum: `WORKING`, `UNFINISHED`, `FINISHED`, or `FAILED`.
|
|
146
|
+
|
|
147
|
+
Sometimes you need to select inputs based on the status of their work—for example, list only inputs whose processing has finished. Calling `execution_status()` for each input would mean checking them individually.
|
|
148
|
+
|
|
149
|
+
`has_execution_status()` lets you include that check in a database query. It returns a SQLAlchemy condition meaning: **“Does this worker have one of these execution statuses for this input?”** It does not run a query or return a Python `True` or `False` when called.
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
finished = coordinator.has_execution_status(
|
|
153
|
+
worker="process",
|
|
154
|
+
source_id=YourModel.id,
|
|
155
|
+
statuses=(ExecutionStatus.FINISHED,),
|
|
156
|
+
)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
- `worker` names the registered worker whose status you want to check.
|
|
160
|
+
- `source_id` identifies its input. Using `YourModel.id` checks the corresponding input for each entry considered by the query.
|
|
161
|
+
- `statuses` contains the acceptable statuses. The condition matches if any one applies; an input with no execution record does not match.
|
|
162
|
+
|
|
163
|
+
Use the condition in an ordinary SQLAlchemy query:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
query = select(YourModel).where(finished)
|
|
167
|
+
|
|
168
|
+
with session_factory() as session:
|
|
169
|
+
inputs = session.scalars(query).all()
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
This returns inputs whose work under `process` is finished. The database checks their statuses as part of this query.
|
|
173
|
+
|
|
174
|
+
The same condition can be combined with other query filters, including in an `eligible` query. Register the named worker before calling `has_execution_status()`.
|
|
175
|
+
|
|
176
|
+
## Understand failures and retry work
|
|
177
|
+
|
|
178
|
+
When a handler raises an exception, DBWorker records the error and marks the work as `FAILED`. It will not automatically run that work again. Your application may need to show what went wrong and let someone retry after correcting the cause.
|
|
179
|
+
|
|
180
|
+
`state()` reads the execution details for one input, giving you more information than its status alone:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
with session_factory() as session:
|
|
184
|
+
state = coordinator.workers["process"].state(session, source_id)
|
|
185
|
+
error = state["error"] if state is not None else None
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`"process"` is the worker's registered name, and `source_id` is the input's primary key. The result is a mapping containing `execution_status`, the recorded `error`, and `lease_expires_at` (the claim's expiration time). It returns `None` if that worker has never claimed this input. You can use the error to explain the failure in your API or logs.
|
|
189
|
+
|
|
190
|
+
After correcting the cause, use `reset_failed()` to allow another attempt:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
with session_factory.begin() as session:
|
|
194
|
+
reset = coordinator.workers["process"].reset_failed(session, source_id)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`reset_failed()` changes a failed execution to `UNFINISHED` and clears its recorded error and claim. It returns `True` if it reset a failed execution, or `False` if there was no failed execution to reset. The `begin()` block commits this change.
|
|
198
|
+
|
|
199
|
+
Resetting does not call the handler immediately. The running coordinator can claim the work again when it matches `eligible`. The handler starts from the beginning; resetting does not delete application results or progress. If the handler has effects outside the database transaction, make them safe to repeat.
|
|
200
|
+
|
|
201
|
+
## Configuration
|
|
202
|
+
|
|
203
|
+
| `Coordinator` argument | Purpose | Default |
|
|
204
|
+
|---|---|---|
|
|
205
|
+
| `session_factory` | SQLAlchemy session factory used for coordination. | Required |
|
|
206
|
+
| `database_url` | Database URL used by child processes; use the same database. | Required |
|
|
207
|
+
| `engine_options` | SQLAlchemy `create_engine()` options for child processes. | `None` |
|
|
208
|
+
| `lease_seconds` | Claim lifetime without renewal. Active claims are renewed automatically. | `30` |
|
|
209
|
+
| `poll_seconds` | Initial wait after finding no claimable work. | `0.25` |
|
|
210
|
+
| `max_poll_seconds` | Maximum wait after exponential backoff. Successful claims continue without waiting. | `10` |
|
|
211
|
+
|
|
212
|
+
For SQLite, use a file-backed database. Worker names must start with a lowercase letter and contain only lowercase letters, digits and underscores.
|
|
213
|
+
|
|
214
|
+
## Examples
|
|
215
|
+
|
|
216
|
+
- [Image deduplication](examples/imagededup_system_dbwork/README.md): independent FastAPI and worker services, artifact building, paged comparisons, and worker dependencies.
|
|
217
|
+
- [Redis + Celery equivalent](examples/imagededup_system_redis_celery/README.md).
|
|
218
|
+
- [Benchmarks](benchmarks/imagededup_benckmark/README.md) with structured JSON results.
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
[MIT](LICENSE) © 2026 Ziyang Song.
|
|
223
|
+
|
dbworker-0.0.1/README.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# dbworker
|
|
2
|
+
|
|
3
|
+
Portable database-backed work coordination with SQLAlchemy and process workers. A single-script Redis + Celery alternative that uses your existing database.
|
|
4
|
+
|
|
5
|
+
Requires Python 3.12+. Install into your application:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
poetry add /path/to/dbworker
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
Use your existing SQLAlchemy `session_factory` and database URL:
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from dbworker import Coordinator
|
|
17
|
+
|
|
18
|
+
coordinator = Coordinator(session_factory, database_url=database_url)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
You write a **handler**: a Python function containing the work you want to run. DBWorker runs handlers in separate processes.
|
|
22
|
+
|
|
23
|
+
Before calling your handler, DBWorker chooses the next task and marks it as “being worked on” in the database. This step is called a **claim**. It lets several processes share the tasks without choosing the same task at the same time.
|
|
24
|
+
|
|
25
|
+
DBWorker keeps the claim active until your handler finishes. If the process crashes, the claim expires so another process can take over the task.
|
|
26
|
+
|
|
27
|
+
`source` is the database table that supplies input to your handler, specified as a SQLAlchemy model. It can store user requests, or any entries whose creation should automatically start a procedure. Each new entry gives DBWorker new work to run, and the selected entry is passed to your handler. Its primary key identifies that work so DBWorker can track its completion; the model must have a single primary-key column.
|
|
28
|
+
|
|
29
|
+
`eligible` controls which inputs can be claimed next. Without it, any new entry can be picked up. Add it when work should wait for a condition or run in a particular order. In the example below, `YourModel` stands for your model. The `enabled` filter and ordering by `id` illustrate a selection rule; replace them with your own conditions and ordering.
|
|
30
|
+
|
|
31
|
+
Decorate your handler like this. The comments describe where your application logic goes:
|
|
32
|
+
|
|
33
|
+
**Complete the work in one invocation:**
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from sqlalchemy import select
|
|
37
|
+
from sqlalchemy.orm import Session
|
|
38
|
+
from dbworker import Finished
|
|
39
|
+
|
|
40
|
+
@coordinator.transactional_worker(
|
|
41
|
+
name="process",
|
|
42
|
+
source=YourModel,
|
|
43
|
+
eligible=lambda: (
|
|
44
|
+
select(YourModel)
|
|
45
|
+
.where(YourModel.enabled.is_(True))
|
|
46
|
+
.order_by(YourModel.id)
|
|
47
|
+
),
|
|
48
|
+
concurrency=4,
|
|
49
|
+
)
|
|
50
|
+
def process(source: YourModel, session: Session) -> Finished:
|
|
51
|
+
# Your procedure goes here.
|
|
52
|
+
# Return Finished() when the procedure is complete.
|
|
53
|
+
return Finished()
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- `name` identifies the worker for status queries.
|
|
57
|
+
- `concurrency=4` allows four handlers to run in child processes.
|
|
58
|
+
- `source` is the selected instance of your source model.
|
|
59
|
+
- `session` is a normal SQLAlchemy session supplied by DBWorker. The handler can leave either argument unused.
|
|
60
|
+
|
|
61
|
+
Before each claim, DBWorker calls `eligible` and uses its query to select the next item. If nothing can be claimed, it waits and checks again. If your application later enables an item, a subsequent check can select it. Finished, failed, and currently claimed items are excluded automatically; you do not need to put those checks in your query. Omitting `eligible` lets DBWorker select from all entries in `source`.
|
|
62
|
+
|
|
63
|
+
**Process part of the work, then continue in another invocation:**
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from dbworker import Finished, Outcome, Unfinished
|
|
67
|
+
|
|
68
|
+
@coordinator.transactional_worker(
|
|
69
|
+
name="process_in_steps",
|
|
70
|
+
source=YourModel,
|
|
71
|
+
eligible=lambda: (
|
|
72
|
+
select(YourModel)
|
|
73
|
+
.where(YourModel.enabled.is_(True))
|
|
74
|
+
.order_by(YourModel.id)
|
|
75
|
+
),
|
|
76
|
+
concurrency=4,
|
|
77
|
+
)
|
|
78
|
+
def process_in_steps(source: YourModel, session: Session) -> Outcome:
|
|
79
|
+
# Your procedure goes here.
|
|
80
|
+
# Return Unfinished() if it needs another invocation to continue.
|
|
81
|
+
# Return Finished() instead when it is complete.
|
|
82
|
+
return Unfinished()
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`Finished()` means the work item is complete. `Unfinished()` means this invocation is done, but more work remains. Both save the execution status and commit any writes made through the supplied session; the handler does not have to make any database writes. After `Unfinished()`, DBWorker can invoke the handler again when eligible. Each invocation starts the function from the beginning. Your procedure decides how to continue; DBWorker does not save its position in the function. Use `Outcome` as the return annotation when a handler can return either result.
|
|
86
|
+
|
|
87
|
+
If the handler raises an exception, DBWorker rolls back its transaction and marks the work failed. If your procedure has prerequisites that can be checked in the database, `eligible` can delay claiming until they are met.
|
|
88
|
+
|
|
89
|
+
DBWorker owns the handler's commit and session cleanup. Do not commit or close this session yourself. For long computations, you can release a read transaction with `session.rollback()` first. Copy needed values before rollback, and do this before making writes you want to keep.
|
|
90
|
+
|
|
91
|
+
### Start and stop
|
|
92
|
+
|
|
93
|
+
Register handlers at module scope in an importable module. In your worker service, initialize DBWorker's tables and start the coordinator:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
coordinator.create_worker_tables()
|
|
98
|
+
coordinator.start()
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`start()` returns while workers keep running. When a source entry matches `eligible`, DBWorker can claim its work and call the handler—there is no enqueue call.
|
|
102
|
+
|
|
103
|
+
On service shutdown, call:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
coordinator.stop()
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`stop()` stops new claims and waits for active handlers to finish. Your API can run independently, using the same database. See the [complete example](examples/imagededup_system_dbwork/README.md) for runnable API and worker commands with shutdown handling.
|
|
110
|
+
|
|
111
|
+
## Track progress and coordinate dependent work
|
|
112
|
+
|
|
113
|
+
Your application may need to show whether work is running or complete. Another worker may also depend on that information: one procedure prepares something, and a second can begin only after preparation finishes. If preparation fails, the application may need to report that failure instead of continuing.
|
|
114
|
+
|
|
115
|
+
DBWorker tracks execution separately for each worker and source entry. Access it through the coordinator using the worker's registered name and the source entry's primary key; you do not need to manage or query DBWorker's internal tables yourself.
|
|
116
|
+
|
|
117
|
+
Use `execution_status()` to get the current status in your API or handler:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from dbworker import ExecutionStatus
|
|
121
|
+
|
|
122
|
+
with session_factory() as session:
|
|
123
|
+
status = coordinator.execution_status(
|
|
124
|
+
session, worker="process", source_id=source_id,
|
|
125
|
+
)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
This returns `None` before the first claim, or an `ExecutionStatus` enum: `WORKING`, `UNFINISHED`, `FINISHED`, or `FAILED`.
|
|
129
|
+
|
|
130
|
+
Sometimes you need to select inputs based on the status of their work—for example, list only inputs whose processing has finished. Calling `execution_status()` for each input would mean checking them individually.
|
|
131
|
+
|
|
132
|
+
`has_execution_status()` lets you include that check in a database query. It returns a SQLAlchemy condition meaning: **“Does this worker have one of these execution statuses for this input?”** It does not run a query or return a Python `True` or `False` when called.
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
finished = coordinator.has_execution_status(
|
|
136
|
+
worker="process",
|
|
137
|
+
source_id=YourModel.id,
|
|
138
|
+
statuses=(ExecutionStatus.FINISHED,),
|
|
139
|
+
)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- `worker` names the registered worker whose status you want to check.
|
|
143
|
+
- `source_id` identifies its input. Using `YourModel.id` checks the corresponding input for each entry considered by the query.
|
|
144
|
+
- `statuses` contains the acceptable statuses. The condition matches if any one applies; an input with no execution record does not match.
|
|
145
|
+
|
|
146
|
+
Use the condition in an ordinary SQLAlchemy query:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
query = select(YourModel).where(finished)
|
|
150
|
+
|
|
151
|
+
with session_factory() as session:
|
|
152
|
+
inputs = session.scalars(query).all()
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
This returns inputs whose work under `process` is finished. The database checks their statuses as part of this query.
|
|
156
|
+
|
|
157
|
+
The same condition can be combined with other query filters, including in an `eligible` query. Register the named worker before calling `has_execution_status()`.
|
|
158
|
+
|
|
159
|
+
## Understand failures and retry work
|
|
160
|
+
|
|
161
|
+
When a handler raises an exception, DBWorker records the error and marks the work as `FAILED`. It will not automatically run that work again. Your application may need to show what went wrong and let someone retry after correcting the cause.
|
|
162
|
+
|
|
163
|
+
`state()` reads the execution details for one input, giving you more information than its status alone:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
with session_factory() as session:
|
|
167
|
+
state = coordinator.workers["process"].state(session, source_id)
|
|
168
|
+
error = state["error"] if state is not None else None
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`"process"` is the worker's registered name, and `source_id` is the input's primary key. The result is a mapping containing `execution_status`, the recorded `error`, and `lease_expires_at` (the claim's expiration time). It returns `None` if that worker has never claimed this input. You can use the error to explain the failure in your API or logs.
|
|
172
|
+
|
|
173
|
+
After correcting the cause, use `reset_failed()` to allow another attempt:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
with session_factory.begin() as session:
|
|
177
|
+
reset = coordinator.workers["process"].reset_failed(session, source_id)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`reset_failed()` changes a failed execution to `UNFINISHED` and clears its recorded error and claim. It returns `True` if it reset a failed execution, or `False` if there was no failed execution to reset. The `begin()` block commits this change.
|
|
181
|
+
|
|
182
|
+
Resetting does not call the handler immediately. The running coordinator can claim the work again when it matches `eligible`. The handler starts from the beginning; resetting does not delete application results or progress. If the handler has effects outside the database transaction, make them safe to repeat.
|
|
183
|
+
|
|
184
|
+
## Configuration
|
|
185
|
+
|
|
186
|
+
| `Coordinator` argument | Purpose | Default |
|
|
187
|
+
|---|---|---|
|
|
188
|
+
| `session_factory` | SQLAlchemy session factory used for coordination. | Required |
|
|
189
|
+
| `database_url` | Database URL used by child processes; use the same database. | Required |
|
|
190
|
+
| `engine_options` | SQLAlchemy `create_engine()` options for child processes. | `None` |
|
|
191
|
+
| `lease_seconds` | Claim lifetime without renewal. Active claims are renewed automatically. | `30` |
|
|
192
|
+
| `poll_seconds` | Initial wait after finding no claimable work. | `0.25` |
|
|
193
|
+
| `max_poll_seconds` | Maximum wait after exponential backoff. Successful claims continue without waiting. | `10` |
|
|
194
|
+
|
|
195
|
+
For SQLite, use a file-backed database. Worker names must start with a lowercase letter and contain only lowercase letters, digits and underscores.
|
|
196
|
+
|
|
197
|
+
## Examples
|
|
198
|
+
|
|
199
|
+
- [Image deduplication](examples/imagededup_system_dbwork/README.md): independent FastAPI and worker services, artifact building, paged comparisons, and worker dependencies.
|
|
200
|
+
- [Redis + Celery equivalent](examples/imagededup_system_redis_celery/README.md).
|
|
201
|
+
- [Benchmarks](benchmarks/imagededup_benckmark/README.md) with structured JSON results.
|
|
202
|
+
|
|
203
|
+
## License
|
|
204
|
+
|
|
205
|
+
[MIT](LICENSE) © 2026 Ziyang Song.
|