beanqueue 1.2.0__tar.gz → 2.0.0rc0__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.
- beanqueue-1.2.0/README.md → beanqueue-2.0.0rc0/PKG-INFO +119 -0
- beanqueue-1.2.0/PKG-INFO → beanqueue-2.0.0rc0/README.md +100 -16
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/app.py +20 -100
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/config.py +16 -1
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/events.py +2 -0
- beanqueue-2.0.0rc0/bq/metrics.py +171 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/pyproject.toml +10 -1
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/.gitignore +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/LICENSE +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/__init__.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/cmds/__init__.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/cmds/cli.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/cmds/create_tables.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/cmds/environment.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/cmds/main.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/cmds/process.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/cmds/submit.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/cmds/utils.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/constants.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/db/__init__.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/db/base.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/db/session.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/models/__init__.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/models/event.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/models/helpers.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/models/task.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/models/worker.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/processors/__init__.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/processors/processor.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/processors/registry.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/processors/retry_policies.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/services/__init__.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/services/dispatch.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/services/worker.py +0 -0
- {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/utils.py +0 -0
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: beanqueue
|
|
3
|
+
Version: 2.0.0rc0
|
|
4
|
+
Summary: BeanQueue or BQ for short, PostgreSQL SKIP LOCK and SQLAlchemy based worker queue library
|
|
5
|
+
Author-email: Fang-Pen Lin <fangpen@launchplatform.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: <4,>=3.11
|
|
9
|
+
Requires-Dist: blinker<2,>=1.8.2
|
|
10
|
+
Requires-Dist: click<9,>=8.1.7
|
|
11
|
+
Requires-Dist: pydantic-settings<3,>=2.2.1
|
|
12
|
+
Requires-Dist: rich<14,>=13.7.1
|
|
13
|
+
Requires-Dist: sqlalchemy<3,>=2.0.30
|
|
14
|
+
Requires-Dist: venusian<4,>=3.1.0
|
|
15
|
+
Provides-Extra: metrics
|
|
16
|
+
Requires-Dist: starlette<2,>=0.27; extra == 'metrics'
|
|
17
|
+
Requires-Dist: uvicorn<1,>=0.30.0; extra == 'metrics'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
1
20
|
# BeanQueue [](https://dl.circleci.com/status-badge/redirect/gh/LaunchPlatform/bq/tree/master)
|
|
2
21
|
|
|
3
22
|
BeanQueue, a lightweight Python task queue framework based on [SQLAlchemy](https://www.sqlalchemy.org/), PostgreSQL [SKIP LOCKED queries](https://www.2ndquadrant.com/en/blog/what-is-select-skip-locked-for-in-postgresql-9-5/) and [NOTIFY](https://www.postgresql.org/docs/current/sql-notify.html) / [LISTEN](https://www.postgresql.org/docs/current/sql-listen.html) statements.
|
|
@@ -14,6 +33,7 @@ BeanQueue, a lightweight Python task queue framework based on [SQLAlchemy](https
|
|
|
14
33
|
- **Retry**: Built-in and customizable retry policies
|
|
15
34
|
- **Schedule**: Schedule tasks to run later
|
|
16
35
|
- **Worker heartbeat and auto-reschedule**: Each worker keeps updating heartbeat, if one is found dead, the others will reschedule the tasks
|
|
36
|
+
- **Custom health checks**: Optional HTTP `/healthz` endpoint with pluggable checks via Blinker signals
|
|
17
37
|
- **Customizable**: Custom Task, Worker and Event models. Use it as a library and build your own work queue
|
|
18
38
|
|
|
19
39
|
## Install
|
|
@@ -22,6 +42,20 @@ BeanQueue, a lightweight Python task queue framework based on [SQLAlchemy](https
|
|
|
22
42
|
pip install beanqueue
|
|
23
43
|
```
|
|
24
44
|
|
|
45
|
+
To enable the optional metrics HTTP server (currently `/healthz` only), install with the `metrics` extra:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install "beanqueue[metrics]"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Upgrading to 2.0
|
|
52
|
+
|
|
53
|
+
BeanQueue 2.0 includes breaking changes around the metrics HTTP server and custom health checks:
|
|
54
|
+
|
|
55
|
+
- **`METRICS_HTTP_SERVER_ENABLED` defaults to `False`** (it was `True` in 1.x). Set `BQ_METRICS_HTTP_SERVER_ENABLED=true` to turn the server back on.
|
|
56
|
+
- **The metrics server requires optional dependencies.** Install `beanqueue[metrics]` (`starlette` and `uvicorn`). Without them, enabling the server raises an error at startup.
|
|
57
|
+
- **Custom health checks use the `healthz_check` event** (`bq.events.healthz_check`) instead of a `healthz_check` argument on `bq.BeanQueue`. Connect sync or async receivers to the signal.
|
|
58
|
+
|
|
25
59
|
## Usage
|
|
26
60
|
|
|
27
61
|
You can define a basic task processor like this
|
|
@@ -216,6 +250,91 @@ Or if you prefer to define your own process command, you can also call `process_
|
|
|
216
250
|
app.process_tasks(channels=("images",))
|
|
217
251
|
```
|
|
218
252
|
|
|
253
|
+
### Health check and metrics HTTP server
|
|
254
|
+
|
|
255
|
+
When enabled, each worker starts a small HTTP server (Starlette + Uvicorn) for operational endpoints.
|
|
256
|
+
Today this only exposes `GET /healthz`, which returns `{"status": "ok"}` by default.
|
|
257
|
+
|
|
258
|
+
Enable it with the `metrics` extra installed and configuration:
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
pip install "beanqueue[metrics]"
|
|
262
|
+
BQ_METRICS_HTTP_SERVER_ENABLED=true bq process images
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Relevant settings (see [bq/config.py](bq/config.py)):
|
|
266
|
+
|
|
267
|
+
| Setting | Env var | Default |
|
|
268
|
+
| --- | --- | --- |
|
|
269
|
+
| `METRICS_HTTP_SERVER_ENABLED` | `BQ_METRICS_HTTP_SERVER_ENABLED` | `False` |
|
|
270
|
+
| `METRICS_HTTP_SERVER_INTERFACE` | `BQ_METRICS_HTTP_SERVER_INTERFACE` | `""` (all interfaces) |
|
|
271
|
+
| `METRICS_HTTP_SERVER_PORT` | `BQ_METRICS_HTTP_SERVER_PORT` | `8000` |
|
|
272
|
+
| `METRICS_HTTP_SERVER_LOG_LEVEL` | `BQ_METRICS_HTTP_SERVER_LOG_LEVEL` | `30` (`WARNING`) |
|
|
273
|
+
|
|
274
|
+
Access requests are logged at INFO via `uvicorn.access` (visible even when `METRICS_HTTP_SERVER_LOG_LEVEL` is `WARNING`).
|
|
275
|
+
BeanQueue also uses a `metrics_server` logger for its own messages.
|
|
276
|
+
Override the entire logging setup by passing a [logging.config](https://docs.python.org/3/library/logging.config.html#logging-config-dictschema) dict via `METRICS_HTTP_SERVER_LOG_CONFIG` (or `BQ_METRICS_HTTP_SERVER_LOG_CONFIG` as JSON):
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
import bq
|
|
280
|
+
|
|
281
|
+
config = bq.Config(
|
|
282
|
+
METRICS_HTTP_SERVER_ENABLED=True,
|
|
283
|
+
METRICS_HTTP_SERVER_LOG_CONFIG={
|
|
284
|
+
"version": 1,
|
|
285
|
+
"disable_existing_loggers": False,
|
|
286
|
+
"handlers": {
|
|
287
|
+
"default": {
|
|
288
|
+
"class": "logging.StreamHandler",
|
|
289
|
+
"formatter": "default",
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
"formatters": {
|
|
293
|
+
"default": {
|
|
294
|
+
"format": "%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
"loggers": {
|
|
298
|
+
"metrics_server": {"handlers": ["default"], "level": "INFO"},
|
|
299
|
+
"uvicorn.access": {"handlers": ["default"], "level": "INFO"},
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
)
|
|
303
|
+
app = bq.BeanQueue(config=config)
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
#### Custom health checks
|
|
307
|
+
|
|
308
|
+
Register additional checks by connecting receivers to `bq.events.healthz_check`.
|
|
309
|
+
If no receivers are connected, `/healthz` returns OK without touching the database.
|
|
310
|
+
|
|
311
|
+
With receivers connected, BeanQueue loads the current worker and passes a database `session` to each check.
|
|
312
|
+
Receivers may be synchronous or asynchronous; both can be mixed on the same signal.
|
|
313
|
+
|
|
314
|
+
```python
|
|
315
|
+
from sqlalchemy import text
|
|
316
|
+
from sqlalchemy.orm import Session
|
|
317
|
+
|
|
318
|
+
import bq
|
|
319
|
+
from bq import events
|
|
320
|
+
|
|
321
|
+
app = bq.BeanQueue()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@events.healthz_check.connect
|
|
325
|
+
def check_database(sender: bq.BeanQueue, worker, session: Session):
|
|
326
|
+
session.execute(text("SELECT 1"))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@events.healthz_check.connect
|
|
330
|
+
async def check_external_service(sender: bq.BeanQueue, worker, session: Session):
|
|
331
|
+
# async HTTP call, etc.
|
|
332
|
+
...
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Receiver signature must accept the keyword arguments you need, for example `(sender, worker, session)`, or use `(sender, **kwargs)`.
|
|
336
|
+
If a check raises an exception, `/healthz` responds with HTTP 500 and a JSON body containing the error message.
|
|
337
|
+
|
|
219
338
|
### Define your own tables
|
|
220
339
|
|
|
221
340
|
BeanQueue is designed to be as customizable as much as possible.
|
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: beanqueue
|
|
3
|
-
Version: 1.2.0
|
|
4
|
-
Summary: BeanQueue or BQ for short, PostgreSQL SKIP LOCK and SQLAlchemy based worker queue library
|
|
5
|
-
Author-email: Fang-Pen Lin <fangpen@launchplatform.com>
|
|
6
|
-
License-Expression: MIT
|
|
7
|
-
License-File: LICENSE
|
|
8
|
-
Requires-Python: <4,>=3.11
|
|
9
|
-
Requires-Dist: blinker<2,>=1.8.2
|
|
10
|
-
Requires-Dist: click<9,>=8.1.7
|
|
11
|
-
Requires-Dist: pydantic-settings<3,>=2.2.1
|
|
12
|
-
Requires-Dist: rich<14,>=13.7.1
|
|
13
|
-
Requires-Dist: sqlalchemy<3,>=2.0.30
|
|
14
|
-
Requires-Dist: venusian<4,>=3.1.0
|
|
15
|
-
Description-Content-Type: text/markdown
|
|
16
|
-
|
|
17
1
|
# BeanQueue [](https://dl.circleci.com/status-badge/redirect/gh/LaunchPlatform/bq/tree/master)
|
|
18
2
|
|
|
19
3
|
BeanQueue, a lightweight Python task queue framework based on [SQLAlchemy](https://www.sqlalchemy.org/), PostgreSQL [SKIP LOCKED queries](https://www.2ndquadrant.com/en/blog/what-is-select-skip-locked-for-in-postgresql-9-5/) and [NOTIFY](https://www.postgresql.org/docs/current/sql-notify.html) / [LISTEN](https://www.postgresql.org/docs/current/sql-listen.html) statements.
|
|
@@ -30,6 +14,7 @@ BeanQueue, a lightweight Python task queue framework based on [SQLAlchemy](https
|
|
|
30
14
|
- **Retry**: Built-in and customizable retry policies
|
|
31
15
|
- **Schedule**: Schedule tasks to run later
|
|
32
16
|
- **Worker heartbeat and auto-reschedule**: Each worker keeps updating heartbeat, if one is found dead, the others will reschedule the tasks
|
|
17
|
+
- **Custom health checks**: Optional HTTP `/healthz` endpoint with pluggable checks via Blinker signals
|
|
33
18
|
- **Customizable**: Custom Task, Worker and Event models. Use it as a library and build your own work queue
|
|
34
19
|
|
|
35
20
|
## Install
|
|
@@ -38,6 +23,20 @@ BeanQueue, a lightweight Python task queue framework based on [SQLAlchemy](https
|
|
|
38
23
|
pip install beanqueue
|
|
39
24
|
```
|
|
40
25
|
|
|
26
|
+
To enable the optional metrics HTTP server (currently `/healthz` only), install with the `metrics` extra:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install "beanqueue[metrics]"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Upgrading to 2.0
|
|
33
|
+
|
|
34
|
+
BeanQueue 2.0 includes breaking changes around the metrics HTTP server and custom health checks:
|
|
35
|
+
|
|
36
|
+
- **`METRICS_HTTP_SERVER_ENABLED` defaults to `False`** (it was `True` in 1.x). Set `BQ_METRICS_HTTP_SERVER_ENABLED=true` to turn the server back on.
|
|
37
|
+
- **The metrics server requires optional dependencies.** Install `beanqueue[metrics]` (`starlette` and `uvicorn`). Without them, enabling the server raises an error at startup.
|
|
38
|
+
- **Custom health checks use the `healthz_check` event** (`bq.events.healthz_check`) instead of a `healthz_check` argument on `bq.BeanQueue`. Connect sync or async receivers to the signal.
|
|
39
|
+
|
|
41
40
|
## Usage
|
|
42
41
|
|
|
43
42
|
You can define a basic task processor like this
|
|
@@ -232,6 +231,91 @@ Or if you prefer to define your own process command, you can also call `process_
|
|
|
232
231
|
app.process_tasks(channels=("images",))
|
|
233
232
|
```
|
|
234
233
|
|
|
234
|
+
### Health check and metrics HTTP server
|
|
235
|
+
|
|
236
|
+
When enabled, each worker starts a small HTTP server (Starlette + Uvicorn) for operational endpoints.
|
|
237
|
+
Today this only exposes `GET /healthz`, which returns `{"status": "ok"}` by default.
|
|
238
|
+
|
|
239
|
+
Enable it with the `metrics` extra installed and configuration:
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
pip install "beanqueue[metrics]"
|
|
243
|
+
BQ_METRICS_HTTP_SERVER_ENABLED=true bq process images
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Relevant settings (see [bq/config.py](bq/config.py)):
|
|
247
|
+
|
|
248
|
+
| Setting | Env var | Default |
|
|
249
|
+
| --- | --- | --- |
|
|
250
|
+
| `METRICS_HTTP_SERVER_ENABLED` | `BQ_METRICS_HTTP_SERVER_ENABLED` | `False` |
|
|
251
|
+
| `METRICS_HTTP_SERVER_INTERFACE` | `BQ_METRICS_HTTP_SERVER_INTERFACE` | `""` (all interfaces) |
|
|
252
|
+
| `METRICS_HTTP_SERVER_PORT` | `BQ_METRICS_HTTP_SERVER_PORT` | `8000` |
|
|
253
|
+
| `METRICS_HTTP_SERVER_LOG_LEVEL` | `BQ_METRICS_HTTP_SERVER_LOG_LEVEL` | `30` (`WARNING`) |
|
|
254
|
+
|
|
255
|
+
Access requests are logged at INFO via `uvicorn.access` (visible even when `METRICS_HTTP_SERVER_LOG_LEVEL` is `WARNING`).
|
|
256
|
+
BeanQueue also uses a `metrics_server` logger for its own messages.
|
|
257
|
+
Override the entire logging setup by passing a [logging.config](https://docs.python.org/3/library/logging.config.html#logging-config-dictschema) dict via `METRICS_HTTP_SERVER_LOG_CONFIG` (or `BQ_METRICS_HTTP_SERVER_LOG_CONFIG` as JSON):
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
import bq
|
|
261
|
+
|
|
262
|
+
config = bq.Config(
|
|
263
|
+
METRICS_HTTP_SERVER_ENABLED=True,
|
|
264
|
+
METRICS_HTTP_SERVER_LOG_CONFIG={
|
|
265
|
+
"version": 1,
|
|
266
|
+
"disable_existing_loggers": False,
|
|
267
|
+
"handlers": {
|
|
268
|
+
"default": {
|
|
269
|
+
"class": "logging.StreamHandler",
|
|
270
|
+
"formatter": "default",
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
"formatters": {
|
|
274
|
+
"default": {
|
|
275
|
+
"format": "%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
"loggers": {
|
|
279
|
+
"metrics_server": {"handlers": ["default"], "level": "INFO"},
|
|
280
|
+
"uvicorn.access": {"handlers": ["default"], "level": "INFO"},
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
)
|
|
284
|
+
app = bq.BeanQueue(config=config)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
#### Custom health checks
|
|
288
|
+
|
|
289
|
+
Register additional checks by connecting receivers to `bq.events.healthz_check`.
|
|
290
|
+
If no receivers are connected, `/healthz` returns OK without touching the database.
|
|
291
|
+
|
|
292
|
+
With receivers connected, BeanQueue loads the current worker and passes a database `session` to each check.
|
|
293
|
+
Receivers may be synchronous or asynchronous; both can be mixed on the same signal.
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
from sqlalchemy import text
|
|
297
|
+
from sqlalchemy.orm import Session
|
|
298
|
+
|
|
299
|
+
import bq
|
|
300
|
+
from bq import events
|
|
301
|
+
|
|
302
|
+
app = bq.BeanQueue()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@events.healthz_check.connect
|
|
306
|
+
def check_database(sender: bq.BeanQueue, worker, session: Session):
|
|
307
|
+
session.execute(text("SELECT 1"))
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@events.healthz_check.connect
|
|
311
|
+
async def check_external_service(sender: bq.BeanQueue, worker, session: Session):
|
|
312
|
+
# async HTTP call, etc.
|
|
313
|
+
...
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Receiver signature must accept the keyword arguments you need, for example `(sender, worker, session)`, or use `(sender, **kwargs)`.
|
|
317
|
+
If a check raises an exception, `/healthz` responds with HTTP 500 and a JSON body containing the error message.
|
|
318
|
+
|
|
235
319
|
### Define your own tables
|
|
236
320
|
|
|
237
321
|
BeanQueue is designed to be as customizable as much as possible.
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import functools
|
|
2
2
|
import importlib
|
|
3
|
-
import json
|
|
4
3
|
import logging
|
|
5
4
|
import platform
|
|
6
5
|
import sys
|
|
@@ -11,15 +10,12 @@ from concurrent.futures import ThreadPoolExecutor
|
|
|
11
10
|
from concurrent.futures import wait as futures_wait
|
|
12
11
|
from importlib.metadata import PackageNotFoundError
|
|
13
12
|
from importlib.metadata import version
|
|
14
|
-
from wsgiref.simple_server import make_server
|
|
15
|
-
from wsgiref.simple_server import WSGIRequestHandler
|
|
16
13
|
|
|
17
14
|
import venusian
|
|
18
15
|
from sqlalchemy import func
|
|
19
16
|
from sqlalchemy.engine import create_engine
|
|
20
17
|
from sqlalchemy.engine import Engine
|
|
21
18
|
from sqlalchemy.orm import Session as DBSession
|
|
22
|
-
from sqlalchemy.pool import NullPool
|
|
23
19
|
from sqlalchemy.pool import QueuePool
|
|
24
20
|
from sqlalchemy.pool import SingletonThreadPool
|
|
25
21
|
|
|
@@ -28,6 +24,7 @@ from . import events
|
|
|
28
24
|
from . import models
|
|
29
25
|
from .config import Config
|
|
30
26
|
from .db.session import SessionMaker
|
|
27
|
+
from .metrics import MetricsServer
|
|
31
28
|
from .processors.processor import Processor
|
|
32
29
|
from .processors.processor import ProcessorHelper
|
|
33
30
|
from .processors.registry import collect
|
|
@@ -38,21 +35,6 @@ from .utils import load_module_var
|
|
|
38
35
|
logger = logging.getLogger(__name__)
|
|
39
36
|
|
|
40
37
|
|
|
41
|
-
class WSGIRequestHandlerWithLogger(WSGIRequestHandler):
|
|
42
|
-
logger = logging.getLogger("metrics_server")
|
|
43
|
-
|
|
44
|
-
def log_message(self, format, *args):
|
|
45
|
-
message = format % args
|
|
46
|
-
self.logger.info(
|
|
47
|
-
"%s - - [%s] %s\n"
|
|
48
|
-
% (
|
|
49
|
-
self.address_string(),
|
|
50
|
-
self.log_date_time_string(),
|
|
51
|
-
message.translate(self._control_char_table),
|
|
52
|
-
)
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
|
|
56
38
|
class BeanQueue:
|
|
57
39
|
def __init__(
|
|
58
40
|
self,
|
|
@@ -68,16 +50,21 @@ class BeanQueue:
|
|
|
68
50
|
self.dispatch_service_cls = dispatch_service_cls
|
|
69
51
|
self._engine = engine
|
|
70
52
|
self._worker_update_shutdown_event: threading.Event = threading.Event()
|
|
71
|
-
|
|
72
|
-
self._metrics_server_shutdown: typing.Callable[[], None] = lambda: None
|
|
53
|
+
self._metrics_server: MetricsServer | None = None
|
|
73
54
|
|
|
74
55
|
def create_default_engine(self):
|
|
75
56
|
# Use thread-safe connection pool when thread pool executor is enabled
|
|
76
57
|
if self.config.MAX_WORKER_THREADS != 1:
|
|
77
58
|
# QueuePool is thread-safe and suitable for multi-threaded usage
|
|
78
59
|
# Configure pool size based on number of worker threads
|
|
79
|
-
max_workers =
|
|
80
|
-
|
|
60
|
+
max_workers = (
|
|
61
|
+
self.config.MAX_WORKER_THREADS
|
|
62
|
+
if self.config.MAX_WORKER_THREADS > 0
|
|
63
|
+
else 10
|
|
64
|
+
)
|
|
65
|
+
pool_size = (
|
|
66
|
+
max_workers + 5
|
|
67
|
+
) # Extra connections for main thread and worker update thread
|
|
81
68
|
return create_engine(
|
|
82
69
|
str(self.config.DATABASE_URL),
|
|
83
70
|
poolclass=QueuePool,
|
|
@@ -215,66 +202,6 @@ class BeanQueue:
|
|
|
215
202
|
db.add(current_worker)
|
|
216
203
|
db.commit()
|
|
217
204
|
|
|
218
|
-
def _serve_http_request(
|
|
219
|
-
self, worker_id: typing.Any, environ: dict, start_response: typing.Callable
|
|
220
|
-
) -> list[bytes]:
|
|
221
|
-
path = environ["PATH_INFO"]
|
|
222
|
-
if path == "/healthz":
|
|
223
|
-
db = self.make_session()
|
|
224
|
-
worker_service = self._make_worker_service(db)
|
|
225
|
-
worker = worker_service.get_worker(worker_id)
|
|
226
|
-
if worker is not None and worker.state == models.WorkerState.RUNNING:
|
|
227
|
-
start_response(
|
|
228
|
-
"200 OK",
|
|
229
|
-
[
|
|
230
|
-
("Content-Type", "application/json"),
|
|
231
|
-
],
|
|
232
|
-
)
|
|
233
|
-
return [
|
|
234
|
-
json.dumps(dict(status="ok", worker_id=str(worker_id))).encode(
|
|
235
|
-
"utf8"
|
|
236
|
-
)
|
|
237
|
-
]
|
|
238
|
-
else:
|
|
239
|
-
logger.warning("Bad worker %s state %s", worker_id, worker.state)
|
|
240
|
-
start_response(
|
|
241
|
-
"500 Internal Server Error",
|
|
242
|
-
[
|
|
243
|
-
("Content-Type", "application/json"),
|
|
244
|
-
],
|
|
245
|
-
)
|
|
246
|
-
return [
|
|
247
|
-
json.dumps(
|
|
248
|
-
dict(
|
|
249
|
-
status="internal error",
|
|
250
|
-
worker_id=str(worker_id),
|
|
251
|
-
state=str(worker.state),
|
|
252
|
-
)
|
|
253
|
-
).encode("utf8")
|
|
254
|
-
]
|
|
255
|
-
# TODO: add other metrics endpoints
|
|
256
|
-
start_response(
|
|
257
|
-
"404 NOT FOUND",
|
|
258
|
-
[
|
|
259
|
-
("Content-Type", "application/json"),
|
|
260
|
-
],
|
|
261
|
-
)
|
|
262
|
-
return [json.dumps(dict(status="not found")).encode("utf8")]
|
|
263
|
-
|
|
264
|
-
def run_metrics_http_server(self, worker_id: typing.Any):
|
|
265
|
-
host = self.config.METRICS_HTTP_SERVER_INTERFACE
|
|
266
|
-
port = self.config.METRICS_HTTP_SERVER_PORT
|
|
267
|
-
with make_server(
|
|
268
|
-
host,
|
|
269
|
-
port,
|
|
270
|
-
functools.partial(self._serve_http_request, worker_id),
|
|
271
|
-
handler_class=WSGIRequestHandlerWithLogger,
|
|
272
|
-
) as httpd:
|
|
273
|
-
# expose graceful shutdown to the main thread
|
|
274
|
-
self._metrics_server_shutdown = httpd.shutdown
|
|
275
|
-
logger.info("Run metrics HTTP server on %s:%s", host, port)
|
|
276
|
-
httpd.serve_forever()
|
|
277
|
-
|
|
278
205
|
def _process_task_in_thread(
|
|
279
206
|
self,
|
|
280
207
|
task_id: typing.Any,
|
|
@@ -397,7 +324,9 @@ class BeanQueue:
|
|
|
397
324
|
if tasks:
|
|
398
325
|
logger.debug(
|
|
399
326
|
"Dispatching %d tasks (running=%d, capacity=%d)",
|
|
400
|
-
len(tasks),
|
|
327
|
+
len(tasks),
|
|
328
|
+
len(running_futures),
|
|
329
|
+
capacity,
|
|
401
330
|
)
|
|
402
331
|
|
|
403
332
|
for task in tasks:
|
|
@@ -476,17 +405,9 @@ class BeanQueue:
|
|
|
476
405
|
dispatch_service.listen(channels)
|
|
477
406
|
db.commit()
|
|
478
407
|
|
|
479
|
-
metrics_server_thread = None
|
|
480
408
|
if self.config.METRICS_HTTP_SERVER_ENABLED:
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
)
|
|
484
|
-
metrics_server_thread = threading.Thread(
|
|
485
|
-
target=self.run_metrics_http_server,
|
|
486
|
-
args=(worker.id,),
|
|
487
|
-
)
|
|
488
|
-
metrics_server_thread.daemon = True
|
|
489
|
-
metrics_server_thread.start()
|
|
409
|
+
self._metrics_server = MetricsServer(self, worker.id)
|
|
410
|
+
self._metrics_server.start()
|
|
490
411
|
|
|
491
412
|
logger.info("Created worker %s, name=%s", worker.id, worker.name)
|
|
492
413
|
events.worker_init.send(self, worker=worker)
|
|
@@ -513,7 +434,9 @@ class BeanQueue:
|
|
|
513
434
|
# Create thread pool executor for concurrent task processing
|
|
514
435
|
executor = None
|
|
515
436
|
if max_workers != 1:
|
|
516
|
-
executor = ThreadPoolExecutor(
|
|
437
|
+
executor = ThreadPoolExecutor(
|
|
438
|
+
max_workers=max_workers, thread_name_prefix="task_worker"
|
|
439
|
+
)
|
|
517
440
|
logger.info("Created thread pool executor with max_workers=%s", max_workers)
|
|
518
441
|
|
|
519
442
|
try:
|
|
@@ -548,11 +471,8 @@ class BeanQueue:
|
|
|
548
471
|
|
|
549
472
|
self._worker_update_shutdown_event.set()
|
|
550
473
|
worker_update_thread.join(5)
|
|
551
|
-
if
|
|
552
|
-
|
|
553
|
-
# serve the ongoing requests
|
|
554
|
-
self._metrics_server_shutdown()
|
|
555
|
-
metrics_server_thread.join(1)
|
|
474
|
+
if self._metrics_server is not None:
|
|
475
|
+
self._metrics_server.shutdown()
|
|
556
476
|
|
|
557
477
|
worker.state = models.WorkerState.SHUTDOWN
|
|
558
478
|
db.add(worker)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import typing
|
|
2
3
|
|
|
3
4
|
from pydantic import Field
|
|
@@ -40,7 +41,7 @@ class Config(BaseSettings):
|
|
|
40
41
|
EVENT_MODEL: str | None = "bq.Event"
|
|
41
42
|
|
|
42
43
|
# Enable metrics HTTP server
|
|
43
|
-
METRICS_HTTP_SERVER_ENABLED: bool =
|
|
44
|
+
METRICS_HTTP_SERVER_ENABLED: bool = False
|
|
44
45
|
|
|
45
46
|
# the metrics http server interface to listen
|
|
46
47
|
METRICS_HTTP_SERVER_INTERFACE: str = ""
|
|
@@ -51,6 +52,11 @@ class Config(BaseSettings):
|
|
|
51
52
|
# default log level for metrics http server
|
|
52
53
|
METRICS_HTTP_SERVER_LOG_LEVEL: int = 30
|
|
53
54
|
|
|
55
|
+
# Optional logging.config dict for the metrics HTTP server (uvicorn).
|
|
56
|
+
# When unset, a default config is used. Pass a dict programmatically or
|
|
57
|
+
# JSON via BQ_METRICS_HTTP_SERVER_LOG_CONFIG.
|
|
58
|
+
METRICS_HTTP_SERVER_LOG_CONFIG: dict[str, typing.Any] | None = None
|
|
59
|
+
|
|
54
60
|
POSTGRES_SERVER: str = "localhost"
|
|
55
61
|
POSTGRES_USER: str = "bq"
|
|
56
62
|
POSTGRES_PASSWORD: str = ""
|
|
@@ -58,6 +64,15 @@ class Config(BaseSettings):
|
|
|
58
64
|
# The URL of postgresql database to connect
|
|
59
65
|
DATABASE_URL: typing.Optional[PostgresDsn] = None
|
|
60
66
|
|
|
67
|
+
@field_validator("METRICS_HTTP_SERVER_LOG_CONFIG", mode="before")
|
|
68
|
+
@classmethod
|
|
69
|
+
def parse_metrics_log_config(cls, v: typing.Any) -> typing.Any:
|
|
70
|
+
if v is None or isinstance(v, dict):
|
|
71
|
+
return v
|
|
72
|
+
if isinstance(v, str):
|
|
73
|
+
return json.loads(v)
|
|
74
|
+
raise ValueError("Unexpected METRICS_HTTP_SERVER_LOG_CONFIG type")
|
|
75
|
+
|
|
61
76
|
@field_validator("DATABASE_URL", mode="before")
|
|
62
77
|
def assemble_db_connection(
|
|
63
78
|
cls, v: typing.Optional[str], info: ValidationInfo
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import logging.config
|
|
5
|
+
import threading
|
|
6
|
+
import typing
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from collections.abc import Coroutine
|
|
9
|
+
from importlib.util import find_spec
|
|
10
|
+
|
|
11
|
+
from sqlalchemy.orm import Session as DBSession
|
|
12
|
+
|
|
13
|
+
from . import events
|
|
14
|
+
from . import models
|
|
15
|
+
|
|
16
|
+
if typing.TYPE_CHECKING:
|
|
17
|
+
from .app import BeanQueue
|
|
18
|
+
from .config import Config
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
METRICS_EXTRA = "metrics"
|
|
23
|
+
METRICS_SERVER_LOGGER = "metrics_server"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _healthz_sync_wrapper(
|
|
27
|
+
func: Callable[..., typing.Any],
|
|
28
|
+
) -> Callable[..., Coroutine[typing.Any, typing.Any, typing.Any]]:
|
|
29
|
+
async def wrapper(sender: typing.Any, **kwargs: typing.Any) -> typing.Any:
|
|
30
|
+
return func(sender, **kwargs)
|
|
31
|
+
|
|
32
|
+
return wrapper
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MetricsExtrasNotInstalledError(ImportError):
|
|
36
|
+
"""Raised when metrics optional dependencies are not installed."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def require_metrics_extras() -> None:
|
|
40
|
+
missing = []
|
|
41
|
+
if find_spec("starlette") is None:
|
|
42
|
+
missing.append("starlette")
|
|
43
|
+
if find_spec("uvicorn") is None:
|
|
44
|
+
missing.append("uvicorn")
|
|
45
|
+
if missing:
|
|
46
|
+
raise MetricsExtrasNotInstalledError(
|
|
47
|
+
"Health check and metrics HTTP server require optional dependencies "
|
|
48
|
+
f"({', '.join(missing)}). "
|
|
49
|
+
f"Install them with: pip install beanqueue[{METRICS_EXTRA}]"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def default_metrics_log_config(log_level: int) -> dict[str, typing.Any]:
|
|
54
|
+
from uvicorn.config import LOGGING_CONFIG
|
|
55
|
+
|
|
56
|
+
level_name = logging.getLevelName(log_level)
|
|
57
|
+
info_level = logging.getLevelName(logging.INFO)
|
|
58
|
+
log_config = copy.deepcopy(LOGGING_CONFIG)
|
|
59
|
+
log_config["handlers"]["access"]["stream"] = "ext://sys.stderr"
|
|
60
|
+
log_config["loggers"]["uvicorn"]["level"] = level_name
|
|
61
|
+
log_config["loggers"]["uvicorn.error"]["level"] = level_name
|
|
62
|
+
# Access lines are logged at INFO by uvicorn regardless of server log level.
|
|
63
|
+
log_config["loggers"]["uvicorn.access"]["level"] = info_level
|
|
64
|
+
log_config["loggers"][METRICS_SERVER_LOGGER] = {
|
|
65
|
+
"handlers": ["default"],
|
|
66
|
+
"level": info_level,
|
|
67
|
+
"propagate": False,
|
|
68
|
+
}
|
|
69
|
+
return log_config
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def resolve_metrics_log_config(config: Config) -> dict[str, typing.Any]:
|
|
73
|
+
if config.METRICS_HTTP_SERVER_LOG_CONFIG is not None:
|
|
74
|
+
return config.METRICS_HTTP_SERVER_LOG_CONFIG
|
|
75
|
+
return default_metrics_log_config(config.METRICS_HTTP_SERVER_LOG_LEVEL)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class MetricsServer:
|
|
79
|
+
def __init__(self, bq: BeanQueue, worker_id: typing.Any):
|
|
80
|
+
require_metrics_extras()
|
|
81
|
+
self._bq = bq
|
|
82
|
+
self._worker_id = worker_id
|
|
83
|
+
self._server = None
|
|
84
|
+
self._thread: threading.Thread | None = None
|
|
85
|
+
|
|
86
|
+
def _has_custom_health_checks(self) -> bool:
|
|
87
|
+
return bool(events.healthz_check.receivers)
|
|
88
|
+
|
|
89
|
+
async def _run_healthz_checks(
|
|
90
|
+
self, worker: models.Worker, session: DBSession, body: dict[str, typing.Any]
|
|
91
|
+
) -> bool:
|
|
92
|
+
try:
|
|
93
|
+
await events.healthz_check.send_async(
|
|
94
|
+
self._bq,
|
|
95
|
+
_sync_wrapper=_healthz_sync_wrapper,
|
|
96
|
+
worker=worker,
|
|
97
|
+
session=session,
|
|
98
|
+
)
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
logger.exception("Custom healthz check failed")
|
|
101
|
+
body["error"] = str(exc)
|
|
102
|
+
return False
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
async def check_healthz(self) -> tuple[bool, dict[str, typing.Any]]:
|
|
106
|
+
body: dict[str, typing.Any] = {"status": "ok"}
|
|
107
|
+
|
|
108
|
+
if not self._has_custom_health_checks():
|
|
109
|
+
return True, body
|
|
110
|
+
|
|
111
|
+
with self._bq.make_session() as db:
|
|
112
|
+
worker_service = self._bq._make_worker_service(db)
|
|
113
|
+
worker = worker_service.get_worker(self._worker_id)
|
|
114
|
+
body["worker_id"] = str(self._worker_id)
|
|
115
|
+
|
|
116
|
+
if not await self._run_healthz_checks(worker, db, body):
|
|
117
|
+
body["status"] = "internal error"
|
|
118
|
+
return False, body
|
|
119
|
+
return True, body
|
|
120
|
+
|
|
121
|
+
def create_app(self):
|
|
122
|
+
from starlette.applications import Starlette
|
|
123
|
+
from starlette.responses import JSONResponse
|
|
124
|
+
from starlette.routing import Route
|
|
125
|
+
|
|
126
|
+
async def healthz(_request):
|
|
127
|
+
ok, body = await self.check_healthz()
|
|
128
|
+
return JSONResponse(body, status_code=200 if ok else 500)
|
|
129
|
+
|
|
130
|
+
return Starlette(
|
|
131
|
+
routes=[
|
|
132
|
+
Route("/healthz", healthz),
|
|
133
|
+
]
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def start(self) -> None:
|
|
137
|
+
import uvicorn
|
|
138
|
+
|
|
139
|
+
require_metrics_extras()
|
|
140
|
+
host = self._bq.config.METRICS_HTTP_SERVER_INTERFACE
|
|
141
|
+
port = self._bq.config.METRICS_HTTP_SERVER_PORT
|
|
142
|
+
log_config = resolve_metrics_log_config(self._bq.config)
|
|
143
|
+
logging.config.dictConfig(log_config)
|
|
144
|
+
|
|
145
|
+
app = self.create_app()
|
|
146
|
+
# log_level is intentionally omitted: uvicorn would override logger levels
|
|
147
|
+
# from log_config (including uvicorn.access) after configure_logging().
|
|
148
|
+
config = uvicorn.Config(
|
|
149
|
+
app,
|
|
150
|
+
host=host,
|
|
151
|
+
port=port,
|
|
152
|
+
log_config=log_config,
|
|
153
|
+
access_log=True,
|
|
154
|
+
)
|
|
155
|
+
self._server = uvicorn.Server(config)
|
|
156
|
+
|
|
157
|
+
def run() -> None:
|
|
158
|
+
logging.getLogger(METRICS_SERVER_LOGGER).info(
|
|
159
|
+
"Run metrics HTTP server on %s:%s", host, port
|
|
160
|
+
)
|
|
161
|
+
self._server.run()
|
|
162
|
+
|
|
163
|
+
self._thread = threading.Thread(target=run, name="metrics_server")
|
|
164
|
+
self._thread.daemon = True
|
|
165
|
+
self._thread.start()
|
|
166
|
+
|
|
167
|
+
def shutdown(self) -> None:
|
|
168
|
+
if self._server is not None:
|
|
169
|
+
self._server.should_exit = True
|
|
170
|
+
if self._thread is not None:
|
|
171
|
+
self._thread.join(1)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "beanqueue"
|
|
3
|
-
version = "
|
|
3
|
+
version = "2.0.0rc0"
|
|
4
4
|
description = "BeanQueue or BQ for short, PostgreSQL SKIP LOCK and SQLAlchemy based worker queue library"
|
|
5
5
|
authors = [{ name = "Fang-Pen Lin", email = "fangpen@launchplatform.com" }]
|
|
6
6
|
requires-python = ">=3.11,<4"
|
|
@@ -18,10 +18,19 @@ dependencies = [
|
|
|
18
18
|
[project.scripts]
|
|
19
19
|
bq = "bq.cmds.main:cli"
|
|
20
20
|
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
metrics = [
|
|
23
|
+
"starlette>=0.27,<2",
|
|
24
|
+
"uvicorn>=0.30.0,<1",
|
|
25
|
+
]
|
|
26
|
+
|
|
21
27
|
[dependency-groups]
|
|
22
28
|
dev = [
|
|
23
29
|
"psycopg2-binary>=2.9.10,<3",
|
|
24
30
|
"pytest-factoryboy>=2.7.0,<3",
|
|
31
|
+
"starlette>=0.27,<2",
|
|
32
|
+
"uvicorn>=0.30.0,<1",
|
|
33
|
+
"httpx>=0.27.0,<1",
|
|
25
34
|
]
|
|
26
35
|
|
|
27
36
|
[tool.hatch.build.targets.sdist]
|
|
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
|