remoulade 7.0.0.dev0__tar.gz → 7.0.0.dev2__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 (113) hide show
  1. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/PKG-INFO +1 -1
  2. remoulade-7.0.0.dev2/remoulade/api/main.py +29 -0
  3. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/brokers/__init__.py +20 -2
  4. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/brokers/pgmq.py +164 -44
  5. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade.egg-info/PKG-INFO +1 -1
  6. remoulade-7.0.0.dev0/remoulade/api/main.py +0 -175
  7. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/CONTRIBUTING.md +0 -0
  8. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/CONTRIBUTORS.md +0 -0
  9. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/COPYING +0 -0
  10. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/COPYING.LESSER +0 -0
  11. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/MANIFEST.in +0 -0
  12. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/README.md +0 -0
  13. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/bin/remoulade-gevent +0 -0
  14. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/local_pgmq_broker.py +0 -0
  15. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/local_pgmq_consumer.py +0 -0
  16. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/pyproject.toml +0 -0
  17. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/__init__.py +0 -0
  18. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/__main__.py +0 -0
  19. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/actor.py +0 -0
  20. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/api/__init__.py +0 -0
  21. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/api/apispec.py +0 -0
  22. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/api/scheduler.py +0 -0
  23. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/api/state.py +0 -0
  24. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/broker.py +0 -0
  25. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/brokers/local.py +0 -0
  26. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/brokers/rabbitmq.py +0 -0
  27. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/brokers/stub.py +0 -0
  28. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cancel/__init__.py +0 -0
  29. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cancel/backend.py +0 -0
  30. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cancel/backends/__init__.py +0 -0
  31. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cancel/backends/redis.py +0 -0
  32. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cancel/backends/stub.py +0 -0
  33. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cancel/errors.py +0 -0
  34. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cancel/middleware.py +0 -0
  35. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cli/__init__.py +0 -0
  36. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cli/remoulade_ls.py +0 -0
  37. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cli/remoulade_run.py +0 -0
  38. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cli/remoulade_scheduler.py +0 -0
  39. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/collection_results.py +0 -0
  40. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/common.py +0 -0
  41. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/composition.py +0 -0
  42. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/concurrent/__init__.py +0 -0
  43. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/concurrent/backend.py +0 -0
  44. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/concurrent/backends/__init__.py +0 -0
  45. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/concurrent/backends/redis.py +0 -0
  46. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/concurrent/backends/stub.py +0 -0
  47. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/concurrent/errors.py +0 -0
  48. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/concurrent/lease.py +0 -0
  49. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/concurrent/middleware.py +0 -0
  50. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/encoder.py +0 -0
  51. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/errors.py +0 -0
  52. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/generic.py +0 -0
  53. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/helpers/__init__.py +0 -0
  54. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/helpers/actor_arguments.py +0 -0
  55. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/helpers/backoff.py +0 -0
  56. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/helpers/queues.py +0 -0
  57. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/helpers/redis_client.py +0 -0
  58. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/helpers/reduce.py +0 -0
  59. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/logging.py +0 -0
  60. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/message.py +0 -0
  61. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/__init__.py +0 -0
  62. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/age_limit.py +0 -0
  63. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/catch_error.py +0 -0
  64. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/current_message.py +0 -0
  65. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/heartbeat.py +0 -0
  66. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/logging_metadata.py +0 -0
  67. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/max_memory.py +0 -0
  68. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/max_tasks.py +0 -0
  69. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/middleware.py +0 -0
  70. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/pipelines.py +0 -0
  71. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/prometheus.py +0 -0
  72. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/retries.py +0 -0
  73. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/shutdown.py +0 -0
  74. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/threading.py +0 -0
  75. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/time_limit.py +0 -0
  76. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/tracing.py +0 -0
  77. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/worker_thread_logging.py +0 -0
  78. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/py.typed +0 -0
  79. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/rate_limits/__init__.py +0 -0
  80. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/rate_limits/backend.py +0 -0
  81. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/rate_limits/backends/__init__.py +0 -0
  82. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/rate_limits/backends/redis.py +0 -0
  83. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/rate_limits/backends/stub.py +0 -0
  84. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/rate_limits/backends/utils.py +0 -0
  85. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/rate_limits/errors.py +0 -0
  86. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/rate_limits/middleware.py +0 -0
  87. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/result.py +0 -0
  88. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/results/__init__.py +0 -0
  89. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/results/backend.py +0 -0
  90. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/results/backends/__init__.py +0 -0
  91. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/results/backends/local.py +0 -0
  92. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/results/backends/redis.py +0 -0
  93. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/results/backends/stub.py +0 -0
  94. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/results/errors.py +0 -0
  95. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/results/middleware.py +0 -0
  96. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/scheduler/__init__.py +0 -0
  97. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/scheduler/scheduler.py +0 -0
  98. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/state/__init__.py +0 -0
  99. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/state/backend.py +0 -0
  100. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/state/backends/__init__.py +0 -0
  101. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/state/backends/postgres.py +0 -0
  102. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/state/backends/redis.py +0 -0
  103. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/state/backends/stub.py +0 -0
  104. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/state/errors.py +0 -0
  105. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/state/middleware.py +0 -0
  106. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/utils.py +0 -0
  107. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/worker.py +0 -0
  108. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade.egg-info/SOURCES.txt +0 -0
  109. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade.egg-info/dependency_links.txt +0 -0
  110. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade.egg-info/entry_points.txt +0 -0
  111. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade.egg-info/requires.txt +0 -0
  112. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade.egg-info/top_level.txt +0 -0
  113. {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoulade
3
- Version: 7.0.0.dev0
3
+ Version: 7.0.0.dev2
4
4
  Summary: Background Processing for Python 3.
5
5
  Author-email: Wiremind <dev@wiremind.io>
6
6
  License-Expression: LGPL-3.0-or-later
@@ -0,0 +1,29 @@
1
+ """This file describe the API to get the state of messages"""
2
+
3
+ from flask import Flask
4
+ from marshmallow import ValidationError
5
+ from werkzeug.exceptions import HTTPException
6
+
7
+ from remoulade.errors import RemouladeError
8
+
9
+ from .apispec import add_swagger
10
+
11
+ app = Flask(__name__)
12
+
13
+
14
+ @app.errorhandler(RemouladeError)
15
+ def remoulade_exception(e):
16
+ return {"error": str(e)}, 500
17
+
18
+
19
+ @app.errorhandler(HTTPException)
20
+ def http_exception(e):
21
+ return {"error": str(e)}, e.code
22
+
23
+
24
+ @app.errorhandler(ValidationError)
25
+ def validation_error(e):
26
+ return {"error": e.normalized_messages()}, 400
27
+
28
+
29
+ add_swagger(app, {"main": []})
@@ -15,9 +15,27 @@
15
15
  # You should have received a copy of the GNU Lesser General Public License
16
16
  # along with this program. If not, see <http://www.gnu.org/licenses/>.
17
17
 
18
+ import warnings
19
+
18
20
  from .local import LocalBroker
19
- from .pgmq import PgmqBroker
20
- from .rabbitmq import RabbitmqBroker
21
21
  from .stub import StubBroker
22
22
 
23
23
  __all__ = ["LocalBroker", "PgmqBroker", "RabbitmqBroker", "StubBroker"]
24
+
25
+ try:
26
+ from .pgmq import PgmqBroker
27
+ except ImportError: # pragma: no cover
28
+ warnings.warn(
29
+ "PgmqBroker is not available. Run `pip install remoulade[pgmq]` to add support for that broker.",
30
+ ImportWarning,
31
+ stacklevel=2,
32
+ )
33
+
34
+ try:
35
+ from .rabbitmq import RabbitmqBroker
36
+ except ImportError: # pragma: no cover
37
+ warnings.warn(
38
+ "RabbitmqBroker is not available. Run `pip install remoulade[rabbitmq]` to add support for that broker.",
39
+ ImportWarning,
40
+ stacklevel=2,
41
+ )
@@ -1,6 +1,6 @@
1
1
  # This file is a part of Remoulade.
2
2
  #
3
- # Copyright (C) 2017,2018 CLEARTYPE SRL <bogdan@cleartype.io>
3
+ # Copyright (C) 2017,2018 WIREMIND SAS <dev@wiremind.fr>
4
4
  #
5
5
  # Remoulade is free software; you can redistribute it and/or modify it
6
6
  # under the terms of the GNU Lesser General Public License as published by
@@ -15,40 +15,31 @@
15
15
  # You should have received a copy of the GNU Lesser General Public License
16
16
  # along with this program. If not, see <http://www.gnu.org/licenses/>.
17
17
  import json
18
+ import time
18
19
  from collections import deque
19
20
  from collections.abc import Iterable, Iterator
20
21
  from contextlib import contextmanager
21
22
  from datetime import UTC, datetime, timedelta
22
- from threading import Event, Thread, local
23
+ from threading import Event, Lock, Thread, local
23
24
  from typing import TYPE_CHECKING, Any
24
25
 
25
26
  import psycopg
26
27
  from pgmq import SQLAlchemyPGMQueue
28
+ from pgmq.messages import Message as PgmqQueueMessage
27
29
  from psycopg import sql as psycopg_sql
28
- from pydantic import BaseModel, ConfigDict, ValidationError
29
- from sqlalchemy import bindparam, create_engine, text
30
+ from sqlalchemy import Column, Integer, MetaData, Table, bindparam, create_engine, func, select, text
30
31
  from sqlalchemy.dialects.postgresql import JSONB
31
- from sqlalchemy.engine import make_url
32
32
  from sqlalchemy.orm import sessionmaker as sqlalchemy_sessionmaker
33
33
 
34
34
  from ..broker import Broker, Consumer, MessageProxy
35
- from ..errors import QueueNotFound, UnsupportedMessageEncoding
35
+ from ..errors import QueueJoinTimeout, QueueNotFound, UnsupportedMessageEncoding
36
36
  from ..message import Message
37
37
 
38
38
  if TYPE_CHECKING:
39
39
  from ..middleware import Middleware
40
40
 
41
-
42
41
  PgmqPayload = dict[str, Any]
43
42
 
44
-
45
- class _PgmqQueueMessage(BaseModel):
46
- model_config = ConfigDict(extra="ignore", from_attributes=True)
47
-
48
- msg_id: int
49
- message: PgmqPayload
50
-
51
-
52
43
  DELAYED_SEND_SQL = text(
53
44
  """
54
45
  SELECT *
@@ -75,8 +66,31 @@ class PgmqBroker(Broker):
75
66
  archive_partition_interval: int | str = "1 day",
76
67
  archive_retention_interval: int | str = "7 days",
77
68
  listen_notify_enabled: bool = True,
69
+ visibility_timeout: int = 30,
70
+ heartbeat_interval: float = 10.0,
78
71
  ) -> None:
72
+ """Initialize a PostgreSQL-backed broker using the PGMQ extension.
73
+
74
+ Parameters:
75
+ url(str): PostgreSQL URL in plain format (`postgresql://...`), used both by SQLAlchemy and psycopg.
76
+ middleware(list[Middleware] | None): Middleware stack applied to this broker.
77
+ group_transaction(bool): If True, wraps group and pipeline operations in a single transaction.
78
+ partition_archive_on_queue_init(bool): If True, creates partitioned queues when declaring queues.
79
+ archive_partition_interval(int | str): Partition interval passed to PGMQ when creating partitioned queues.
80
+ archive_retention_interval(int | str): Retention interval passed to PGMQ for archive partitions.
81
+ listen_notify_enabled(bool): If True, enables LISTEN/NOTIFY to wake consumers when new messages arrive.
82
+ visibility_timeout(int): Message visibility timeout in seconds after read; must be greater than 0.
83
+ heartbeat_interval(float): Heartbeat interval in seconds used to extend in-flight message visibility;
84
+ must be greater than 0 and lower than visibility_timeout.
85
+
86
+ Raises:
87
+ ValueError: If visibility_timeout or heartbeat_interval values are invalid.
88
+ """
79
89
  super().__init__(middleware=middleware)
90
+ if visibility_timeout <= 0:
91
+ raise ValueError("visibility_timeout must be greater than 0")
92
+ if heartbeat_interval <= 0 or heartbeat_interval >= visibility_timeout:
93
+ raise ValueError("heartbeat_interval must be greater than 0 and lower than visibility_timeout")
80
94
 
81
95
  self.url = url
82
96
  self.state = local()
@@ -85,12 +99,14 @@ class PgmqBroker(Broker):
85
99
  self.archive_partition_interval = archive_partition_interval
86
100
  self.archive_retention_interval = archive_retention_interval
87
101
  self.listen_notify_enabled = listen_notify_enabled
102
+ self.visibility_timeout = visibility_timeout
103
+ self.heartbeat_interval = heartbeat_interval
88
104
 
89
105
  self.engine = create_engine(self.url, pool_pre_ping=True)
90
106
  self.sessionmaker = sqlalchemy_sessionmaker(bind=self.engine)
91
107
  self.supports_native_delay = True
92
108
 
93
- self.client = SQLAlchemyPGMQueue(engine=self.engine, init_extension=False)
109
+ self.client = SQLAlchemyPGMQueue(engine=self.engine, init_extension=True, vt=self.visibility_timeout)
94
110
 
95
111
  @contextmanager
96
112
  def tx(self) -> Iterator[None]:
@@ -107,10 +123,6 @@ class PgmqBroker(Broker):
107
123
  def _current_connection(self) -> Any | None:
108
124
  return getattr(self.state, "transaction_connection", None)
109
125
 
110
- def _build_listener_conninfo(self) -> str:
111
- # psycopg expects a plain PostgreSQL URL and rejects SQLAlchemy-style driver suffixes.
112
- return make_url(self.url).set(drivername="postgresql").render_as_string(hide_password=False)
113
-
114
126
  def _try_enable_notify(self, queue_name: str) -> None:
115
127
  if not self.listen_notify_enabled:
116
128
  return
@@ -208,26 +220,92 @@ class PgmqBroker(Broker):
208
220
  for queue_name in self.queues:
209
221
  self.flush(queue_name)
210
222
 
211
- def join(self, queue_name: str, *, timeout: int | None = None) -> None:
212
- raise NotImplementedError("PgmqBroker.join() will be implemented in jalon 3.")
223
+ def _count_enqueued_messages(self, queue_name: str) -> int:
224
+ queue_table = Table(f"q_{queue_name}", MetaData(), Column("msg_id", Integer), schema="pgmq")
225
+ count_query = select(func.count()).select_from(queue_table)
226
+
227
+ with self.sessionmaker.begin() as session:
228
+ return session.execute(count_query).scalar_one()
229
+
230
+ def join(
231
+ self,
232
+ queue_name: str,
233
+ min_successes: int = 10,
234
+ idle_time: int = 100,
235
+ *,
236
+ timeout: int | None = None,
237
+ ) -> None:
238
+ """Wait for all the messages on the given queue to be processed.
239
+
240
+ This method checks the full PGMQ queue table and therefore waits for
241
+ all states: visible messages, invisible in-flight messages and native
242
+ delayed messages.
243
+
244
+ Parameters:
245
+ queue_name(str): The queue to wait on.
246
+ min_successes(int): The minimum number of times the queue should be
247
+ observed as empty.
248
+ idle_time(int): The number of milliseconds to wait between checks.
249
+ timeout(Optional[int]): The max amount of time, in milliseconds, to
250
+ wait on this queue.
251
+
252
+ Raises:
253
+ QueueNotFound: If the given queue was never declared.
254
+ QueueJoinTimeout: When the timeout elapses.
255
+ """
256
+ if queue_name not in self.queues:
257
+ raise QueueNotFound(queue_name)
258
+
259
+ deadline = time.monotonic() + timeout / 1000 if timeout is not None else None
260
+ successes = 0
261
+
262
+ while successes < min_successes:
263
+ if deadline and time.monotonic() >= deadline:
264
+ raise QueueJoinTimeout(queue_name)
265
+
266
+ total_messages = self._count_enqueued_messages(queue_name)
267
+ if total_messages == 0:
268
+ successes += 1
269
+ if successes >= min_successes:
270
+ return
271
+ else:
272
+ successes = 0
273
+
274
+ time.sleep(idle_time / 1000)
213
275
 
214
276
 
215
277
  class _PgmqConsumer(Consumer):
216
278
  def __init__(self, broker: PgmqBroker, *, queue_name: str, prefetch: int, timeout: int) -> None:
279
+ """Initialize a consumer for a PGMQ queue.
280
+
281
+ Parameters:
282
+ broker(PgmqBroker): Broker instance that owns the queue and database client.
283
+ queue_name(str): Name of the declared queue to consume from.
284
+ prefetch(int): Maximum number of messages fetched per read call; values lower than 1 are coerced to 1.
285
+ timeout(int): Idle wait timeout in milliseconds when polling for messages; values lower than 0 are coerced to
286
+ 0. A value of 0 performs non-blocking reads.
287
+ """
217
288
  self.broker = broker
218
289
  self.client = broker.client
219
290
  self.queue_name = queue_name
220
291
  self.prefetch = max(prefetch, 1)
221
292
  self.timeout = max(timeout, 0)
222
- self.messages: deque[_PgmqQueueMessage] = deque()
293
+ self.visibility_timeout = broker.visibility_timeout
294
+ self.heartbeat_interval = broker.heartbeat_interval
295
+ self.messages: deque[PgmqQueueMessage] = deque()
223
296
  self._notify_event = Event()
224
297
  self._listener_stop = Event()
225
298
  self._listener_thread: Thread | None = None
226
299
  self._listener_connection: psycopg.Connection[Any] | None = None
227
300
  self._listener_available = False
301
+ self._heartbeat_stop = Event()
302
+ self._heartbeat_thread: Thread | None = None
303
+ self._heartbeat_msg_ids_lock = Lock()
304
+ self._heartbeat_msg_ids: set[int] = set()
228
305
 
229
306
  if self.broker.listen_notify_enabled:
230
307
  self._start_listener()
308
+ self._start_heartbeat()
231
309
 
232
310
  @property
233
311
  def max_poll_seconds(self) -> int:
@@ -241,38 +319,66 @@ class _PgmqConsumer(Consumer):
241
319
  def wait_timeout_seconds(self) -> float:
242
320
  return self.timeout / 1000 if self.timeout > 0 else 0
243
321
 
244
- def _normalize_messages(self, messages: Any | list[Any] | None) -> list[_PgmqQueueMessage]:
322
+ def _normalize_messages(self, messages: PgmqQueueMessage | list[PgmqQueueMessage] | None) -> list[PgmqQueueMessage]:
245
323
  if messages is None:
246
324
  return []
247
- raw_messages = messages if isinstance(messages, list) else [messages]
248
- normalized_messages: list[_PgmqQueueMessage] = []
249
- for message in raw_messages:
250
- try:
251
- normalized_messages.append(_PgmqQueueMessage.model_validate(message, from_attributes=True))
252
- except ValidationError as exc:
253
- raise UnsupportedMessageEncoding(
254
- "PGMQ messages must expose 'msg_id' and a JSONB-serializable object in 'message'."
255
- ) from exc
256
- return normalized_messages
325
+ return [messages] if not isinstance(messages, list) else messages
257
326
 
258
- def _read_immediate(self) -> list[_PgmqQueueMessage]:
259
- return self._normalize_messages(self.client.read(self.queue_name, qty=self.prefetch))
327
+ def _read_immediate(self) -> list[PgmqQueueMessage]:
328
+ return self._normalize_messages(
329
+ self.client.read(
330
+ self.queue_name,
331
+ vt=self.visibility_timeout,
332
+ qty=self.prefetch,
333
+ )
334
+ )
260
335
 
261
- def _read_with_poll(self) -> list[_PgmqQueueMessage]:
336
+ def _read_with_poll(self) -> list[PgmqQueueMessage]:
262
337
  return self._normalize_messages(
263
338
  self.client.read_with_poll(
264
339
  self.queue_name,
340
+ vt=self.visibility_timeout,
265
341
  qty=self.prefetch,
266
342
  max_poll_seconds=self.max_poll_seconds,
267
343
  poll_interval_ms=self.poll_interval_ms,
268
344
  )
269
345
  )
270
346
 
347
+ def _start_heartbeat(self) -> None:
348
+ self._heartbeat_thread = Thread(
349
+ target=self._run_heartbeat,
350
+ name=f"pgmq-heartbeat-{self.queue_name}",
351
+ daemon=True,
352
+ )
353
+ self._heartbeat_thread.start()
354
+
355
+ def _run_heartbeat(self) -> None:
356
+ while not self._heartbeat_stop.wait(self.heartbeat_interval):
357
+ with self._heartbeat_msg_ids_lock:
358
+ msg_ids = list(self._heartbeat_msg_ids)
359
+ if not msg_ids:
360
+ continue
361
+ try:
362
+ self.client.set_vt(self.queue_name, msg_ids, self.visibility_timeout)
363
+ except Exception:
364
+ self.broker.logger.warning(
365
+ "Failed to extend visibility timeout heartbeat for queue %s.",
366
+ self.queue_name,
367
+ exc_info=True,
368
+ )
369
+
370
+ def _register_heartbeat_msg(self, message: "_PgmqMessage") -> None:
371
+ with self._heartbeat_msg_ids_lock:
372
+ self._heartbeat_msg_ids.add(message._pgmq_message.msg_id)
373
+
374
+ def _unregister_heartbeat_msg_id(self, msg_id: int) -> None:
375
+ with self._heartbeat_msg_ids_lock:
376
+ self._heartbeat_msg_ids.discard(msg_id)
377
+
271
378
  def _start_listener(self) -> None:
272
379
  channel = f"pgmq.q_{self.queue_name}.INSERT"
273
380
  try:
274
- conninfo = self.broker._build_listener_conninfo()
275
- self._listener_connection = psycopg.connect(conninfo, autocommit=True)
381
+ self._listener_connection = psycopg.connect(self.broker.url, autocommit=True)
276
382
  self._listener_connection.execute(psycopg_sql.SQL("LISTEN {}").format(psycopg_sql.Identifier(channel)))
277
383
  self._listener_available = True
278
384
  except Exception:
@@ -316,21 +422,30 @@ class _PgmqConsumer(Consumer):
316
422
  def ack(self, message: "MessageProxy") -> None:
317
423
  if not isinstance(message, _PgmqMessage):
318
424
  raise ValueError("It must be a PgmqMessage")
319
- self.client.delete(self.queue_name, message._pgmq_message.msg_id)
425
+ self._unregister_heartbeat_msg_id(message._pgmq_message.msg_id)
426
+ self.client.archive(self.queue_name, message._pgmq_message.msg_id)
320
427
 
321
428
  def nack(self, message: "MessageProxy") -> None:
322
429
  if not isinstance(message, _PgmqMessage):
323
430
  raise ValueError("It must be a PgmqMessage")
431
+ self._unregister_heartbeat_msg_id(message._pgmq_message.msg_id)
324
432
  self.client.archive(self.queue_name, message._pgmq_message.msg_id)
325
433
 
326
434
  def requeue(self, messages: Iterable["MessageProxy"]) -> None:
327
435
  msg_ids = [message._pgmq_message.msg_id for message in messages if isinstance(message, _PgmqMessage)]
328
436
  if msg_ids:
437
+ for msg_id in msg_ids:
438
+ self._unregister_heartbeat_msg_id(msg_id)
329
439
  self.client.set_vt(self.queue_name, msg_ids, 0)
330
440
 
441
+ def _build_message(self, pgmq_message: PgmqQueueMessage) -> "_PgmqMessage":
442
+ message = _PgmqMessage(pgmq_message)
443
+ self._register_heartbeat_msg(message)
444
+ return message
445
+
331
446
  def __next__(self) -> "_PgmqMessage | None":
332
447
  if self.messages:
333
- return _PgmqMessage(self.messages.popleft())
448
+ return self._build_message(self.messages.popleft())
334
449
 
335
450
  if self._listener_available:
336
451
  self._notify_event.clear()
@@ -345,10 +460,11 @@ class _PgmqConsumer(Consumer):
345
460
  return None
346
461
 
347
462
  self.messages.extend(messages)
348
- return _PgmqMessage(self.messages.popleft())
463
+ return self._build_message(self.messages.popleft())
349
464
 
350
465
  def close(self) -> None:
351
466
  self._listener_stop.set()
467
+ self._heartbeat_stop.set()
352
468
  self._notify_event.set()
353
469
  if self._listener_connection is not None:
354
470
  try:
@@ -359,17 +475,21 @@ class _PgmqConsumer(Consumer):
359
475
  self._listener_connection = None
360
476
  if self._listener_thread is not None:
361
477
  self._listener_thread.join(timeout=1)
478
+ if self._heartbeat_thread is not None:
479
+ self._heartbeat_thread.join(timeout=1)
362
480
  return
363
481
 
364
482
 
365
483
  class _PgmqMessage(MessageProxy):
366
- def __init__(self, pgmq_message: _PgmqQueueMessage) -> None:
484
+ def __init__(self, pgmq_message: PgmqQueueMessage) -> None:
367
485
  payload = pgmq_message.message
368
486
  if not isinstance(payload, dict):
369
487
  raise UnsupportedMessageEncoding("PGMQ messages must contain JSON objects.")
370
488
 
371
489
  try:
372
- message: Message = Message(**payload)
490
+ # Re-run the global message decoder so custom encoders (e.g. PydanticEncoder)
491
+ # can rehydrate actor args/kwargs to their typed schemas.
492
+ message = Message.decode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
373
493
  except TypeError as exc:
374
494
  raise UnsupportedMessageEncoding("PGMQ message payload is not a valid Remoulade message envelope.") from exc
375
495
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoulade
3
- Version: 7.0.0.dev0
3
+ Version: 7.0.0.dev2
4
4
  Summary: Background Processing for Python 3.
5
5
  Author-email: Wiremind <dev@wiremind.io>
6
6
  License-Expression: LGPL-3.0-or-later
@@ -1,175 +0,0 @@
1
- """This file describe the API to get the state of messages"""
2
-
3
- import sys
4
- from typing import Any, TypedDict
5
-
6
- from flask import Flask
7
- from flask_apispec import marshal_with
8
- from marshmallow import Schema, ValidationError, fields, validate, validates_schema
9
- from werkzeug.exceptions import HTTPException
10
-
11
- import remoulade
12
- from remoulade import get_broker
13
- from remoulade.errors import NoResultBackend, NoStateBackend, RemouladeError
14
- from remoulade.result import Result
15
- from remoulade.results import ResultMissing
16
-
17
- from .apispec import add_swagger, validate_schema
18
- from .scheduler import scheduler_bp, scheduler_routes
19
- from .state import messages_bp, messages_routes
20
-
21
- app = Flask(__name__)
22
- app.register_blueprint(scheduler_bp)
23
- app.register_blueprint(messages_bp)
24
-
25
-
26
- class MessageSchema(Schema):
27
- """
28
- Class to validate post data in /messages
29
- """
30
-
31
- actor_name = fields.Str(validate=validate.Length(min=1), required=True)
32
- args = fields.List(fields.Raw(), allow_none=True)
33
- kwargs = fields.Dict(allow_none=True)
34
- options = fields.Dict(allow_none=True)
35
- delay = fields.Float(validate=validate.Range(min=1), allow_none=True)
36
-
37
- @validates_schema
38
- def validate_actor_name(self, data, **kwargs):
39
- actor_name = data.get("actor_name")
40
- if actor_name not in remoulade.get_broker().actors:
41
- raise ValidationError(f"No actor named {actor_name} exists")
42
-
43
-
44
- class ResponseSchema(Schema):
45
- result = fields.Raw(allow_none=True)
46
- error = fields.Str(allow_none=True)
47
-
48
-
49
- class ArgsSchema(Schema):
50
- name = fields.Str()
51
- type = fields.Str()
52
- default = fields.Str(allow_none=True)
53
-
54
-
55
- class ActorSchema(Schema):
56
- name = fields.Str()
57
- queue_name = fields.Str()
58
- alternative_queues = fields.List(fields.Str())
59
- priority = fields.Int()
60
- args = fields.List(fields.Nested(ArgsSchema))
61
-
62
-
63
- class ActorResponseSchema(Schema):
64
- result = fields.List(fields.Nested(ActorSchema))
65
-
66
-
67
- class OptionsResponseSchema(Schema):
68
- options = fields.List(fields.Str())
69
-
70
-
71
- @app.route("/messages/cancel/<message_id>", methods=["POST"])
72
- @marshal_with(ResponseSchema)
73
- def cancel_message(message_id):
74
- broker = get_broker()
75
- cancel_backend = broker.get_cancel_backend()
76
- try:
77
- # If a single message in a composition is canceled, we cancel the whole composition
78
- state_backend = broker.get_state_backend()
79
- states_count = state_backend.get_states_count(selected_composition_ids=[message_id])
80
- state = state_backend.get_state(message_id)
81
- if states_count == 0 and state is None:
82
- raise ValidationError(f"This message id or composition id {message_id} does not exist.")
83
- if state and state.composition_id:
84
- message_id = state.composition_id
85
- except NoStateBackend:
86
- pass
87
- cancel_backend.cancel([message_id])
88
- return {"result": "ok"}
89
-
90
-
91
- @app.route("/messages/requeue/<message_id>", methods=["POST"])
92
- @marshal_with(ResponseSchema)
93
- def requeue_message(message_id):
94
- broker = get_broker()
95
- backend = broker.get_state_backend()
96
- state = backend.get_state(message_id)
97
- if state is None:
98
- raise ValidationError(f"No message with id {message_id} exists")
99
- actor = broker.get_actor(state.actor_name)
100
- payload = {"args": state.args, "kwargs": state.kwargs}
101
- pipe_target = state.options.get("pipe_target")
102
- if pipe_target is None:
103
- actor.send_with_options(**payload, **state.options)
104
- return {"result": "ok"}
105
- else:
106
- return {"error": "requeue message in a pipeline not supported"}, 400
107
-
108
-
109
- @app.route("/messages/result/<message_id>")
110
- @marshal_with(ResponseSchema)
111
- def get_results(message_id):
112
- from ..message import get_encoder
113
-
114
- max_size = 1e4
115
- try:
116
- result = Result[Any](message_id=message_id).get()
117
- encoded_result = get_encoder().encode(result).decode("utf-8")
118
- size_result = sys.getsizeof(encoded_result)
119
- if size_result >= max_size:
120
- encoded_result = f"The result is too big {size_result / 1e6}M"
121
- return {"result": encoded_result}
122
- except ResultMissing:
123
- return {"result": "result is missing"}
124
- except NoResultBackend:
125
- return {"result": "no result backend"}
126
- except (UnicodeDecodeError, TypeError):
127
- return {"result": "non serializable result"}
128
-
129
-
130
- @app.route("/messages", methods=["POST"])
131
- @marshal_with(ResponseSchema)
132
- @validate_schema(MessageSchema)
133
- def enqueue_message(**kwargs):
134
- actor = get_broker().get_actor(kwargs.pop("actor_name"))
135
- options = kwargs.pop("options") or {}
136
- actor.send_with_options(**kwargs, **options)
137
- return {"result": "ok"}
138
-
139
-
140
- class GroupMessagesT(TypedDict):
141
- group_id: str
142
- messages: list[dict]
143
-
144
-
145
- @app.route("/actors")
146
- @marshal_with(ActorResponseSchema)
147
- def get_actors():
148
- return {"result": [actor.as_dict() for actor in get_broker().actors.values()]}
149
-
150
-
151
- @app.route("/options")
152
- @marshal_with(OptionsResponseSchema)
153
- def get_options():
154
- broker = get_broker()
155
- return {"options": list(broker.actor_options)}
156
-
157
-
158
- @app.errorhandler(RemouladeError)
159
- def remoulade_exception(e):
160
- return {"error": str(e)}, 500
161
-
162
-
163
- @app.errorhandler(HTTPException)
164
- def http_exception(e):
165
- return {"error": str(e)}, e.code
166
-
167
-
168
- @app.errorhandler(ValidationError)
169
- def validation_error(e):
170
- return {"error": e.normalized_messages()}, 400
171
-
172
-
173
- routes = [cancel_message, requeue_message, get_results, enqueue_message, get_actors, get_options]
174
-
175
- add_swagger(app, {"main": routes, "scheduler": scheduler_routes, "messages": messages_routes})
File without changes
File without changes
File without changes