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.
Files changed (35) hide show
  1. beanqueue-1.2.0/README.md → beanqueue-2.0.0rc0/PKG-INFO +119 -0
  2. beanqueue-1.2.0/PKG-INFO → beanqueue-2.0.0rc0/README.md +100 -16
  3. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/app.py +20 -100
  4. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/config.py +16 -1
  5. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/events.py +2 -0
  6. beanqueue-2.0.0rc0/bq/metrics.py +171 -0
  7. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/pyproject.toml +10 -1
  8. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/.gitignore +0 -0
  9. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/LICENSE +0 -0
  10. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/__init__.py +0 -0
  11. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/cmds/__init__.py +0 -0
  12. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/cmds/cli.py +0 -0
  13. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/cmds/create_tables.py +0 -0
  14. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/cmds/environment.py +0 -0
  15. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/cmds/main.py +0 -0
  16. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/cmds/process.py +0 -0
  17. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/cmds/submit.py +0 -0
  18. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/cmds/utils.py +0 -0
  19. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/constants.py +0 -0
  20. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/db/__init__.py +0 -0
  21. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/db/base.py +0 -0
  22. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/db/session.py +0 -0
  23. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/models/__init__.py +0 -0
  24. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/models/event.py +0 -0
  25. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/models/helpers.py +0 -0
  26. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/models/task.py +0 -0
  27. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/models/worker.py +0 -0
  28. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/processors/__init__.py +0 -0
  29. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/processors/processor.py +0 -0
  30. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/processors/registry.py +0 -0
  31. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/processors/retry_policies.py +0 -0
  32. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/services/__init__.py +0 -0
  33. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/services/dispatch.py +0 -0
  34. {beanqueue-1.2.0 → beanqueue-2.0.0rc0}/bq/services/worker.py +0 -0
  35. {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 [![CircleCI](https://dl.circleci.com/status-badge/img/gh/LaunchPlatform/bq/tree/master.svg?style=svg)](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 [![CircleCI](https://dl.circleci.com/status-badge/img/gh/LaunchPlatform/bq/tree/master.svg?style=svg)](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
- # noop if metrics thread is not started yet, shutdown if it is started
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 = self.config.MAX_WORKER_THREADS if self.config.MAX_WORKER_THREADS > 0 else 10
80
- pool_size = max_workers + 5 # Extra connections for main thread and worker update thread
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), len(running_futures), capacity
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
- WSGIRequestHandlerWithLogger.logger.setLevel(
482
- self.config.METRICS_HTTP_SERVER_LOG_LEVEL
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(max_workers=max_workers, thread_name_prefix="task_worker")
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 metrics_server_thread is not None:
552
- # set a threading event, waits until server is shutdown
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 = True
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
@@ -3,3 +3,5 @@ import blinker
3
3
  worker_init = blinker.signal("worker-init")
4
4
 
5
5
  task_failure = blinker.signal("task-failure")
6
+
7
+ healthz_check = blinker.signal("healthz-check")
@@ -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 = "1.2.0"
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