dbworker 0.0.1__py3-none-any.whl

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.
@@ -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
+
@@ -0,0 +1,5 @@
1
+ dbworker.py,sha256=ebmKFpTs6NACY-fP9YrlRgClNhY74L1nP4pysxWxSBA,20878
2
+ dbworker-0.0.1.dist-info/METADATA,sha256=SvXkUZgRrP7xNOwEJNtBTc3OCc4_moAbG4G6QPByqno,11460
3
+ dbworker-0.0.1.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
4
+ dbworker-0.0.1.dist-info/licenses/LICENSE,sha256=ReFQTfmGpZE0yiEVGrv-i8eSDDemttgydXblWgoB4UM,1068
5
+ dbworker-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.4.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.py ADDED
@@ -0,0 +1,467 @@
1
+ """Database-owned work, independent of application models and result storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import multiprocessing
7
+ import pickle
8
+ import re
9
+ import threading
10
+ import time
11
+ import uuid
12
+ from collections.abc import Callable, Iterable
13
+ from concurrent.futures import Future, ProcessPoolExecutor
14
+ from dataclasses import dataclass
15
+ from functools import cached_property
16
+ from enum import StrEnum
17
+ from datetime import datetime, timedelta, timezone
18
+ from typing import Any, TypeAlias, TypeVar, cast
19
+ from multiprocessing.util import Finalize
20
+
21
+ from sqlalchemy import Column, DateTime, Enum, ForeignKey, String, Table, Text, and_, create_engine, event, exists, inspect, insert, or_, select, update
22
+ from sqlalchemy.exc import IntegrityError
23
+ from sqlalchemy.engine import CursorResult, RowMapping, URL, make_url
24
+ from sqlalchemy.orm import DeclarativeBase, Mapper, Session, sessionmaker
25
+ from sqlalchemy.sql import ColumnElement, Select
26
+ from sqlalchemy.sql.selectable import Exists
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ def now() -> datetime:
32
+ # Database columns use naive UTC on every supported backend.
33
+ return datetime.now(timezone.utc).replace(tzinfo=None)
34
+
35
+
36
+ class ExecutionStatus(StrEnum):
37
+ WORKING = "working"
38
+ UNFINISHED = "unfinished"
39
+ FINISHED = "finished"
40
+ FAILED = "failed"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Finished:
45
+ pass
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Unfinished:
50
+ pass
51
+
52
+
53
+ Outcome: TypeAlias = Finished | Unfinished
54
+ Handler: TypeAlias = Callable[[Any, Session], Outcome]
55
+ _Function = TypeVar("_Function", bound=Callable[..., Any])
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Claim:
60
+ source_id: object
61
+ token: str
62
+
63
+
64
+ class LostClaim(Exception):
65
+ """The result belongs to an execution whose ownership has been replaced."""
66
+
67
+
68
+ class _Worker:
69
+ def __init__(
70
+ self,
71
+ *,
72
+ name: str,
73
+ source: type[DeclarativeBase],
74
+ handler: Handler,
75
+ eligible: Callable[[], Select[tuple[Any]]] | None = None,
76
+ concurrency: int = 1,
77
+ ) -> None:
78
+ if not re.fullmatch(r"[a-z][a-z0-9_]*", name):
79
+ raise ValueError("Worker name must contain lowercase letters, digits, and underscores")
80
+ if concurrency < 1:
81
+ raise ValueError("Worker concurrency must be positive")
82
+ self.name = name
83
+ self.source = source
84
+ self.handler = handler
85
+ self.eligible = eligible or (lambda: select(source))
86
+ self.concurrency = concurrency
87
+ mapper = cast(Mapper[Any], inspect(source))
88
+ if len(mapper.primary_key) != 1:
89
+ raise ValueError("Worker sources must have one primary-key column")
90
+ self.source_key = cast(Column[Any], mapper.primary_key[0])
91
+ self.source_table = cast(Table, mapper.local_table)
92
+ metadata = self.source_table.metadata
93
+ table_name = f"{name}_work"
94
+ if table_name in metadata.tables:
95
+ self.table = metadata.tables[table_name]
96
+ references = list(self.table.c.source_id.foreign_keys)
97
+ if len(references) != 1 or references[0].column is not self.source_key:
98
+ raise ValueError(f"Worker table {table_name} references another source")
99
+ else:
100
+ self.table = Table(
101
+ table_name, metadata,
102
+ Column("source_id", self.source_key.type.copy(), ForeignKey(self.source_key), primary_key=True),
103
+ Column("execution_status", Enum(
104
+ ExecutionStatus, native_enum=False,
105
+ values_callable=lambda enum: [member.value for member in enum],
106
+ validate_strings=True,
107
+ ), nullable=False),
108
+ Column("claim_token", String(36)),
109
+ Column("lease_expires_at", DateTime, index=True),
110
+ Column("error", Text),
111
+ )
112
+
113
+ def state(self, session: Session, source_id: object) -> RowMapping | None:
114
+ return session.execute(select(self.table).where(self.table.c.source_id == source_id)).mappings().first()
115
+
116
+ def reset_failed(self, session: Session, source_id: object) -> bool:
117
+ result = cast(CursorResult[Any], session.execute(
118
+ update(self.table)
119
+ .where(self.table.c.source_id == source_id, self.table.c.execution_status == ExecutionStatus.FAILED)
120
+ .values(execution_status=ExecutionStatus.UNFINISHED, error=None, claim_token=None, lease_expires_at=None)
121
+ ))
122
+ return result.rowcount == 1
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class _WorkerDefinition:
127
+ """Importable callables and model classes, never live database resources."""
128
+
129
+ name: str
130
+ source: type[DeclarativeBase]
131
+ handler: Handler
132
+
133
+
134
+ _process_worker: _Worker | None = None
135
+ _process_session_factory: sessionmaker[Session] | None = None
136
+
137
+
138
+ def _initialize_process(
139
+ database_url: str | URL,
140
+ engine_options: dict[str, Any],
141
+ definitions: tuple[_WorkerDefinition, ...],
142
+ worker_name: str,
143
+ ) -> None:
144
+ global _process_worker, _process_session_factory
145
+ engine = create_engine(database_url, **engine_options)
146
+ _process_session_factory = sessionmaker(engine, expire_on_commit=False)
147
+ Finalize(None, engine.dispose, exitpriority=10)
148
+ # Rebuild registered work-table metadata locally, including dependencies used
149
+ # by application queries. Eligibility callbacks stay in the parent process.
150
+ for definition in definitions:
151
+ worker = _Worker(name=definition.name, source=definition.source, handler=definition.handler)
152
+ if definition.name == worker_name:
153
+ _process_worker = worker
154
+
155
+
156
+ def _execute_in_process(claim: Claim) -> Outcome:
157
+ if _process_worker is None or _process_session_factory is None:
158
+ raise RuntimeError("Handler process has not been initialized")
159
+ return _execute_claim(_process_worker, claim, _process_session_factory)
160
+
161
+
162
+ def _execute_claim(worker: _Worker, claim: Claim, session_factory: sessionmaker[Session]) -> Outcome:
163
+ """Run a whole handler and atomically persist its writes and outcome."""
164
+ with session_factory() as session:
165
+ def prevent_handler_commit(session: Session) -> None:
166
+ raise RuntimeError("The worker commits the handler session; return Finished() or Unfinished() instead")
167
+
168
+ event.listen(session, "before_commit", prevent_handler_commit)
169
+ try:
170
+ source = session.get(worker.source, claim.source_id)
171
+ if source is None:
172
+ raise ValueError("Worker source no longer exists")
173
+ outcome = worker.handler(source, session)
174
+ if not isinstance(outcome, (Finished, Unfinished)):
175
+ raise TypeError("A worker handler must return Finished() or Unfinished()")
176
+ # All handler writes, including already-flushed SQL, are still in
177
+ # this transaction. Rejecting ownership rolls them all back.
178
+ with session.no_autoflush:
179
+ result = cast(CursorResult[Any], session.execute(
180
+ update(worker.table).where(
181
+ worker.table.c.source_id == claim.source_id,
182
+ worker.table.c.execution_status == ExecutionStatus.WORKING,
183
+ worker.table.c.claim_token == claim.token,
184
+ ).values(execution_status=ExecutionStatus.FINISHED if isinstance(outcome, Finished) else ExecutionStatus.UNFINISHED,
185
+ claim_token=None, lease_expires_at=None, error=None)
186
+ ))
187
+ if result.rowcount != 1:
188
+ raise LostClaim()
189
+ finally:
190
+ event.remove(session, "before_commit", prevent_handler_commit)
191
+ session.commit()
192
+ return outcome
193
+
194
+
195
+ class Coordinator:
196
+ """Parent-process claiming, scheduling and lease renewal.
197
+
198
+ Entire handlers execute in spawned processes with their own engines/sessions.
199
+ database_url and engine_options configure those independent child engines.
200
+ """
201
+
202
+ def __init__(
203
+ self,
204
+ session_factory: sessionmaker[Session],
205
+ *,
206
+ database_url: str | URL,
207
+ engine_options: dict[str, Any] | None = None,
208
+ lease_seconds: float = 30,
209
+ poll_seconds: float = 0.25,
210
+ max_poll_seconds: float = 10,
211
+ ) -> None:
212
+ if lease_seconds <= 0 or poll_seconds <= 0:
213
+ raise ValueError("Lease and poll intervals must be positive")
214
+ if max_poll_seconds < poll_seconds:
215
+ raise ValueError("Maximum poll interval must be at least the initial interval")
216
+ self.database_url = database_url
217
+ self.engine_options = dict(engine_options or {})
218
+ self.session_factory = session_factory
219
+ self.lease_seconds = lease_seconds
220
+ self.poll_seconds = poll_seconds
221
+ self.max_poll_seconds = max_poll_seconds
222
+ self.workers: dict[str, _Worker] = {}
223
+ self._running: dict[str, tuple[threading.Thread, ProcessPoolExecutor]] = {}
224
+ self._stop = threading.Event()
225
+ self._wakeups: dict[str, threading.Event] = {}
226
+
227
+ @cached_property
228
+ def _supports_skip_locked(self) -> bool:
229
+ # Resolve on the first claim so importing decorated handlers in spawned
230
+ # processes does not open a parent-coordinator database connection.
231
+ with self.session_factory() as session:
232
+ dialect = session.connection().dialect
233
+ version = dialect.server_version_info or ()
234
+ return (
235
+ dialect.name == "postgresql" and version >= (9, 5)
236
+ or dialect.name == "mysql" and not getattr(dialect, "is_mariadb", False) and version >= (8, 0, 1)
237
+ or dialect.name in ("mysql", "mariadb") and getattr(dialect, "is_mariadb", False) and version >= (10, 6)
238
+ )
239
+
240
+ def transactional_worker(
241
+ self,
242
+ *,
243
+ name: str,
244
+ source: type[DeclarativeBase],
245
+ eligible: Callable[[], Select[tuple[Any]]] | None = None,
246
+ concurrency: int = 1,
247
+ ) -> Callable[[_Function], _Function]:
248
+ """Register a handler whose session and final commit belong to the runtime.
249
+
250
+ Registration creates worker metadata, but starts no processing. The
251
+ original function is returned unchanged; direct calls are not managed.
252
+ """
253
+ def decorate(handler: _Function) -> _Function:
254
+ if self._running:
255
+ raise RuntimeError("Register workers before starting the coordinator")
256
+ if name in self.workers:
257
+ raise ValueError(f"Duplicate worker name: {name}")
258
+ self.workers[name] = _Worker(
259
+ name=name, source=source, handler=handler,
260
+ eligible=eligible, concurrency=concurrency,
261
+ )
262
+ return handler
263
+
264
+ return decorate
265
+
266
+ def execution_status(
267
+ self, session: Session, *, worker: str, source_id: object,
268
+ ) -> ExecutionStatus | None:
269
+ """Return the recorded execution status, or None if never claimed.
270
+
271
+ Lease expiry does not change the recorded status. Unknown worker names
272
+ raise KeyError, rather than being confused with an unclaimed source.
273
+ """
274
+ table = self.workers[worker].table
275
+ value = session.scalar(select(table.c.execution_status).where(table.c.source_id == source_id))
276
+ return ExecutionStatus(value) if value is not None else None
277
+
278
+ def has_execution_status(
279
+ self, *, worker: str, source_id: object, statuses: Iterable[ExecutionStatus],
280
+ ) -> Exists:
281
+ """Build a SQL predicate for a source ID or SQLAlchemy source-ID expression.
282
+
283
+ An unclaimed source matches no status; an empty status set matches none.
284
+ This only constructs SQL and does not open a database connection.
285
+ """
286
+ table = self.workers[worker].table
287
+ return exists().where(
288
+ table.c.source_id == source_id,
289
+ table.c.execution_status.in_(tuple(statuses)),
290
+ ).correlate_except(table)
291
+
292
+ def create_worker_tables(self) -> None:
293
+ with self.session_factory() as session:
294
+ for worker in self.workers.values():
295
+ worker.table.create(session.get_bind(), checkfirst=True)
296
+
297
+ def _available(self, table: Table, timestamp: datetime) -> ColumnElement[bool]:
298
+ return or_(
299
+ table.c.execution_status == ExecutionStatus.UNFINISHED,
300
+ and_(table.c.execution_status == ExecutionStatus.WORKING, table.c.lease_expires_at < timestamp),
301
+ )
302
+
303
+ def _candidate(self, worker: _Worker, timestamp: datetime) -> Select[tuple[Any]]:
304
+ table = worker.table
305
+ # Keep availability separate from application eligibility. Joining work
306
+ # state onto the eligibility query can make SQLite evaluate expensive
307
+ # correlated predicates for every finished source before rejecting it.
308
+ available_ids = (
309
+ select(worker.source_key)
310
+ .outerjoin(table, table.c.source_id == worker.source_key)
311
+ .where(or_(table.c.source_id.is_(None), self._available(table, timestamp)))
312
+ )
313
+ return (
314
+ worker.eligible()
315
+ .with_only_columns(worker.source_key, maintain_column_froms=True)
316
+ .where(worker.source_key.in_(available_ids))
317
+ .limit(1)
318
+ )
319
+
320
+ def _record_claim(
321
+ self, session: Session, worker: _Worker, source_id: object | None, timestamp: datetime,
322
+ ) -> Claim | None:
323
+ if source_id is None:
324
+ return None
325
+ table = worker.table
326
+ token = str(uuid.uuid4())
327
+ values: dict[str, object] = dict(execution_status=ExecutionStatus.WORKING, claim_token=token,
328
+ lease_expires_at=now() + timedelta(seconds=self.lease_seconds), error=None)
329
+ existing = session.scalar(select(table.c.source_id).where(table.c.source_id == source_id))
330
+ if existing is None:
331
+ # The primary key arbitrates simultaneous first claims. A savepoint
332
+ # lets us inspect a uniqueness conflict without poisoning the session.
333
+ try:
334
+ with session.begin_nested():
335
+ session.execute(insert(table).values(source_id=source_id, **values))
336
+ except IntegrityError as exc:
337
+ code = getattr(exc.orig, "sqlite_errorcode", None)
338
+ sqlstate = getattr(exc.orig, "sqlstate", None) or getattr(exc.orig, "pgcode", None)
339
+ mysql_code = exc.orig.args[0] if exc.orig is not None and exc.orig.args else None
340
+ if code not in (1555, 2067) and sqlstate != "23505" and mysql_code != 1062:
341
+ raise
342
+ return None
343
+ else:
344
+ result = cast(CursorResult[Any], session.execute(update(table).where(
345
+ table.c.source_id == source_id, self._available(table, timestamp),
346
+ ).values(**values)))
347
+ if result.rowcount != 1:
348
+ return None
349
+ return Claim(source_id, token)
350
+
351
+ def _claim_with_db_lock(self, worker: _Worker) -> Claim | None:
352
+ timestamp = now()
353
+ with self.session_factory.begin() as session:
354
+ # Lock the source row, which exists even before the first work row.
355
+ source_id = session.scalar(self._candidate(worker, timestamp).with_for_update(
356
+ skip_locked=True, of=worker.source_table,
357
+ ))
358
+ return self._record_claim(session, worker, source_id, timestamp)
359
+
360
+ def _claim_with_conditional_update(self, worker: _Worker) -> Claim | None:
361
+ timestamp = now()
362
+ with self.session_factory.begin() as session:
363
+ source_id = session.scalar(self._candidate(worker, timestamp))
364
+ return self._record_claim(session, worker, source_id, timestamp)
365
+
366
+ def claim(self, worker: _Worker) -> Claim | None:
367
+ if self._supports_skip_locked:
368
+ return self._claim_with_db_lock(worker)
369
+ return self._claim_with_conditional_update(worker)
370
+
371
+ def renew(self, worker: _Worker, claims: Iterable[Claim]) -> None:
372
+ table = worker.table
373
+ with self.session_factory.begin() as session:
374
+ for claim in claims:
375
+ session.execute(update(table).where(
376
+ table.c.source_id == claim.source_id, table.c.execution_status == ExecutionStatus.WORKING,
377
+ table.c.claim_token == claim.token,
378
+ ).values(lease_expires_at=now() + timedelta(seconds=self.lease_seconds)))
379
+
380
+ def _fail(self, worker: _Worker, claim: Claim, error: Exception) -> None:
381
+ table = worker.table
382
+ with self.session_factory.begin() as session:
383
+ session.execute(update(table).where(
384
+ table.c.source_id == claim.source_id, table.c.execution_status == ExecutionStatus.WORKING,
385
+ table.c.claim_token == claim.token,
386
+ ).values(execution_status=ExecutionStatus.FAILED, error=str(error), claim_token=None, lease_expires_at=None))
387
+
388
+ def _run(
389
+ self, worker: _Worker, handlers: ProcessPoolExecutor,
390
+ wakeup: threading.Event,
391
+ ) -> None:
392
+ active: dict[Future[Outcome], Claim] = {}
393
+ next_renewal = time.monotonic()
394
+ next_claim_at = 0.0
395
+ delay = self.poll_seconds
396
+ while not self._stop.is_set() or active:
397
+ # Clear before inspecting futures so a completion during this iteration
398
+ # still wakes the wait below.
399
+ wakeup.clear()
400
+ for future in list(active):
401
+ if future.done():
402
+ claim = active.pop(future)
403
+ try:
404
+ future.result()
405
+ except LostClaim:
406
+ logger.info("Discarded stale result for %s:%s", worker.name, claim.source_id)
407
+ except Exception as exc:
408
+ logger.exception("Worker %s failed for %s", worker.name, claim.source_id)
409
+ self._fail(worker, claim, exc)
410
+ if time.monotonic() >= next_renewal:
411
+ if active:
412
+ self.renew(worker, active.values())
413
+ next_renewal = time.monotonic() + self.lease_seconds / 3
414
+ if not self._stop.is_set() and time.monotonic() >= next_claim_at:
415
+ while len(active) < worker.concurrency and not self._stop.is_set():
416
+ next_claim = self.claim(worker)
417
+ if next_claim is None:
418
+ next_claim_at = time.monotonic() + delay
419
+ delay = min(delay * 2, self.max_poll_seconds)
420
+ break
421
+ delay = self.poll_seconds
422
+ next_claim_at = 0.0
423
+ future = handlers.submit(_execute_in_process, next_claim)
424
+ active[future] = next_claim
425
+ future.add_done_callback(lambda completed: wakeup.set())
426
+ deadlines: list[float] = []
427
+ if active:
428
+ deadlines.append(next_renewal)
429
+ if not self._stop.is_set() and len(active) < worker.concurrency:
430
+ deadlines.append(next_claim_at)
431
+ if not deadlines:
432
+ break
433
+ wakeup.wait(max(0.0, min(deadlines) - time.monotonic()))
434
+
435
+ def start(self) -> None:
436
+ if self._running:
437
+ raise RuntimeError("Coordinator already started")
438
+ url = make_url(self.database_url)
439
+ if url.get_backend_name() == "sqlite" and (url.database in (None, "", ":memory:") or url.query.get("mode") == "memory"):
440
+ raise ValueError("Handler processes require file-backed SQLite, not an in-memory database")
441
+ definitions = tuple(_WorkerDefinition(w.name, w.source, w.handler) for w in self.workers.values())
442
+ try:
443
+ pickle.dumps((self.database_url, self.engine_options, definitions))
444
+ except (TypeError, AttributeError, pickle.PicklingError) as exc:
445
+ raise ValueError("Handlers and models must be importable; bound handler arguments and engine options must be serializable") from exc
446
+ self._stop.clear()
447
+ for worker in self.workers.values():
448
+ handlers = ProcessPoolExecutor(
449
+ max_workers=worker.concurrency, mp_context=multiprocessing.get_context("spawn"),
450
+ initializer=_initialize_process,
451
+ initargs=(self.database_url, self.engine_options, definitions, worker.name),
452
+ )
453
+ wakeup = threading.Event()
454
+ self._wakeups[worker.name] = wakeup
455
+ thread = threading.Thread(target=self._run, args=(worker, handlers, wakeup), name=worker.name, daemon=True)
456
+ self._running[worker.name] = (thread, handlers)
457
+ thread.start()
458
+
459
+ def stop(self) -> None:
460
+ self._stop.set()
461
+ for wakeup in self._wakeups.values():
462
+ wakeup.set()
463
+ for thread, handlers in self._running.values():
464
+ thread.join()
465
+ handlers.shutdown(wait=True)
466
+ self._running.clear()
467
+ self._wakeups.clear()