wexample-queue 1.1.0__tar.gz → 2.0.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: wexample-queue
3
- Version: 1.1.0
3
+ Version: 2.0.0
4
4
  Summary: Manage Queuing
5
5
  Author-Email: weeger <contact@wexample.com>
6
6
  License: MIT
@@ -9,8 +9,8 @@ Classifier: License :: OSI Approved :: MIT License
9
9
  Classifier: Operating System :: OS Independent
10
10
  Requires-Python: >=3.10
11
11
  Requires-Dist: pika>=1.3.0
12
- Requires-Dist: wexample-app>=19.2.0
13
- Requires-Dist: wexample-helpers>=20.0.0
12
+ Requires-Dist: wexample-app>=19.3.0
13
+ Requires-Dist: wexample-helpers>=20.1.0
14
14
  Provides-Extra: dev
15
15
  Requires-Dist: pytest; extra == "dev"
16
16
  Requires-Dist: pytest-cov; extra == "dev"
@@ -18,7 +18,7 @@ Description-Content-Type: text/markdown
18
18
 
19
19
  # queue
20
20
 
21
- Version: 1.1.0
21
+ Version: 2.0.0
22
22
 
23
23
  `wexample-queue` is what a worker needs to live on a RabbitMQ queue and nothing more: a connector holding the broker connection, and a service base whose whole subject is one queue. A subclass writes the name of the queue and what to do with a message; connecting, declaring, decoding, acknowledging and giving up are the same for every worker and are already written.
24
24
 
@@ -203,8 +203,8 @@ Visit the [Wexample Suite documentation](https://docs.wexample.com) for the comp
203
203
  ## Dependencies
204
204
 
205
205
  - pika: >=1.3.0
206
- - wexample-app: >=19.2.0
207
- - wexample-helpers: >=20.0.0
206
+ - wexample-app: >=19.3.0
207
+ - wexample-helpers: >=20.1.0
208
208
 
209
209
  ## Versioning & Compatibility Policy
210
210
 
@@ -1,6 +1,6 @@
1
1
  # queue
2
2
 
3
- Version: 1.1.0
3
+ Version: 2.0.0
4
4
 
5
5
  `wexample-queue` is what a worker needs to live on a RabbitMQ queue and nothing more: a connector holding the broker connection, and a service base whose whole subject is one queue. A subclass writes the name of the queue and what to do with a message; connecting, declaring, decoding, acknowledging and giving up are the same for every worker and are already written.
6
6
 
@@ -185,8 +185,8 @@ Visit the [Wexample Suite documentation](https://docs.wexample.com) for the comp
185
185
  ## Dependencies
186
186
 
187
187
  - pika: >=1.3.0
188
- - wexample-app: >=19.2.0
189
- - wexample-helpers: >=20.0.0
188
+ - wexample-app: >=19.3.0
189
+ - wexample-helpers: >=20.1.0
190
190
 
191
191
  ## Versioning & Compatibility Policy
192
192
 
@@ -6,7 +6,7 @@ build-backend = "pdm.backend"
6
6
 
7
7
  [project]
8
8
  name = "wexample-queue"
9
- version = "1.1.0"
9
+ version = "2.0.0"
10
10
  description = "Manage Queuing"
11
11
  authors = [
12
12
  { name = "weeger", email = "contact@wexample.com" },
@@ -19,8 +19,8 @@ classifiers = [
19
19
  ]
20
20
  dependencies = [
21
21
  "pika>=1.3.0",
22
- "wexample-app>=19.2.0",
23
- "wexample-helpers>=20.0.0",
22
+ "wexample-app>=19.3.0",
23
+ "wexample-helpers>=20.1.0",
24
24
  ]
25
25
 
26
26
  [project.readme]
@@ -10,10 +10,9 @@ from wexample_helpers.classes.field import public_field
10
10
  from wexample_helpers.decorator.base_class import base_class
11
11
 
12
12
  if TYPE_CHECKING:
13
- from collections.abc import Callable
14
-
15
13
  from wexample_helpers.const.types import StringsList
16
14
 
15
+ RABBITMQ_ENV_KEY_HEARTBEAT = "RABBITMQ_HEARTBEAT"
17
16
  RABBITMQ_ENV_KEY_HOST = "RABBITMQ_HOST"
18
17
  RABBITMQ_ENV_KEY_PASSWORD = "RABBITMQ_PASSWORD"
19
18
  RABBITMQ_ENV_KEY_PORT = "RABBITMQ_PORT"
@@ -45,11 +44,27 @@ class RabbitmqExternalConnector(AbstractExternalConnector):
45
44
  default="rabbitmq",
46
45
  description="Name this connector answers to",
47
46
  )
47
+ thread_id: int | None = public_field(
48
+ default=None,
49
+ description="The thread that opened the connection, the only one allowed to use it",
50
+ )
51
+
52
+ def ack(self, delivery_tag: int) -> None:
53
+ self.channel.basic_ack(delivery_tag)
48
54
 
49
55
  def connect(self) -> None:
50
56
  if self.connected:
51
57
  return
52
58
 
59
+ import os
60
+ import threading
61
+
62
+ parameters: dict[str, Any] = {}
63
+ # Seconds between two signs of life; the broker's proposal when unset.
64
+ heartbeat = os.environ.get(RABBITMQ_ENV_KEY_HEARTBEAT)
65
+ if heartbeat:
66
+ parameters["heartbeat"] = int(heartbeat)
67
+
53
68
  self.connection = pika.BlockingConnection(
54
69
  pika.ConnectionParameters(
55
70
  host=self._env(RABBITMQ_ENV_KEY_HOST),
@@ -58,17 +73,12 @@ class RabbitmqExternalConnector(AbstractExternalConnector):
58
73
  self._env(RABBITMQ_ENV_KEY_USER),
59
74
  self._env(RABBITMQ_ENV_KEY_PASSWORD),
60
75
  ),
76
+ **parameters,
61
77
  )
62
78
  )
63
79
  self.channel = self.connection.channel()
64
80
  self.connected = True
65
-
66
- def consume(self, queue_name: str, callback: Callable) -> None:
67
- """Hands the channel over to the callback until consumption is stopped."""
68
- self.channel.basic_consume(
69
- queue=queue_name, on_message_callback=callback
70
- )
71
- self.channel.start_consuming()
81
+ self.thread_id = threading.get_ident()
72
82
 
73
83
  def declare_queue(self, queue_name: str) -> None:
74
84
  """Makes sure the queue is there, durable, whoever gets there first.
@@ -83,11 +93,26 @@ class RabbitmqExternalConnector(AbstractExternalConnector):
83
93
  if not self.connected:
84
94
  return
85
95
 
86
- self.connection.close()
96
+ # A connection the broker already dropped cannot be closed again.
97
+ if self.connection.is_open:
98
+ self.connection.close()
87
99
  self.connection = None
88
100
  self.channel = None
89
101
  self.connected = False
90
102
 
103
+ def get(self, queue_name: str) -> tuple[int, bytes] | None:
104
+ """Takes the next message off the queue, or nothing when it is empty.
105
+
106
+ Pulled one at a time rather than pushed: a message the broker pushes
107
+ while the previous one is still being worked on would wait unacknowledged
108
+ in the client, and be dropped by the broker's delivery timeout long
109
+ before its turn came.
110
+ """
111
+ method, _properties, body = self.channel.basic_get(queue=queue_name)
112
+ if method is None:
113
+ return None
114
+ return method.delivery_tag, body
115
+
91
116
  def get_expected_env_keys(self) -> StringsList:
92
117
  return [
93
118
  RABBITMQ_ENV_KEY_HOST,
@@ -96,13 +121,29 @@ class RabbitmqExternalConnector(AbstractExternalConnector):
96
121
  RABBITMQ_ENV_KEY_PASSWORD,
97
122
  ]
98
123
 
124
+ def nack(self, delivery_tag: int) -> None:
125
+ """Gives a message up, without putting it back on the queue."""
126
+ self.channel.basic_nack(delivery_tag, requeue=False)
127
+
99
128
  def publish(self, queue_name: str, payload: dict[str, Any]) -> None:
100
129
  """Puts one message on the queue, marked to survive a broker restart.
101
130
 
102
131
  It goes through the default exchange, whose routing key is the queue
103
132
  name — which is also where a Symfony Messenger transport declaring that
104
133
  queue binds its own exchange, so both land in the same place.
134
+
135
+ Callable from any thread: a connection belongs to the thread that opened
136
+ it, so from another one the publication is handed over to that thread,
137
+ which sends it the next time it tends the connection.
105
138
  """
139
+ import threading
140
+
141
+ if threading.get_ident() != self.thread_id:
142
+ self.connection.add_callback_threadsafe(
143
+ lambda: self.publish(queue_name, payload)
144
+ )
145
+ return
146
+
106
147
  import json
107
148
 
108
149
  self.channel.basic_publish(
@@ -112,8 +153,10 @@ class RabbitmqExternalConnector(AbstractExternalConnector):
112
153
  properties=pika.BasicProperties(delivery_mode=2),
113
154
  )
114
155
 
115
- def stop_consuming(self) -> None:
116
- self.channel.stop_consuming()
156
+ def sleep(self, seconds: float) -> None:
157
+ """Waits while keeping the connection alive — heartbeats answered, and
158
+ what other threads handed over sent."""
159
+ self.connection.sleep(seconds)
117
160
 
118
161
  def _env(self, key: str) -> str:
119
162
  """One of the four keys, from the process environment or from wex's own.
@@ -0,0 +1,197 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, ClassVar
4
+
5
+ from wexample_app.service.abstract_service import AbstractService
6
+ from wexample_helpers.classes.abstract_method import abstract_method
7
+ from wexample_helpers.classes.field import public_field
8
+ from wexample_helpers.decorator.base_class import base_class
9
+
10
+ if TYPE_CHECKING:
11
+ from wexample_queue.connector.rabbitmq_external_connector import (
12
+ RabbitmqExternalConnector,
13
+ )
14
+
15
+
16
+ @base_class
17
+ class AbstractQueueService(AbstractService):
18
+ """A service whose work is whatever arrives on one queue.
19
+
20
+ All a subclass writes is the name of the queue and what to do with a
21
+ message. Connecting, taking, decoding, acknowledging, keeping the connection
22
+ alive and coming back after losing it are the same for every worker, so they
23
+ are here.
24
+
25
+ A message may take hours. It is worked on in a thread of its own while this
26
+ one tends the connection — answering the broker's heartbeats, sending what
27
+ the work publishes — so the broker never takes a long job for a dead worker.
28
+ """
29
+
30
+ ACKNOWLEDGE_ON_RECEIPT: ClassVar[bool] = False
31
+ """Acknowledge a message as soon as it is taken, rather than once done.
32
+
33
+ For work longer than the broker allows a message to stay unacknowledged
34
+ (`consumer_timeout`, 30 minutes by default), and whose outcome is recorded
35
+ somewhere else: a message acknowledged at once is never delivered twice,
36
+ and nothing the work does can make the broker put it back.
37
+ """
38
+ POLL_INTERVAL: ClassVar[float] = 1.0
39
+ """Seconds between two looks at an empty queue."""
40
+ RECONNECT_DELAY: ClassVar[float] = 5.0
41
+ """Seconds before trying again once the connection is lost."""
42
+ connector: RabbitmqExternalConnector | None = public_field(
43
+ default=None,
44
+ description="The broker connection, opened when the service starts",
45
+ )
46
+ queue_name: str = public_field(
47
+ description="Name of the queue this service consumes",
48
+ )
49
+
50
+ def on_idle(self) -> None:
51
+ """Called each time the queue is found empty. Nothing by default."""
52
+
53
+ def on_tick(self, message: dict[str, Any]) -> None:
54
+ """Called about once a second while a message is being worked on, from the
55
+ thread that tends the connection. Nothing by default."""
56
+
57
+ @abstract_method
58
+ def process_message(self, message: dict[str, Any]) -> None:
59
+ """What to do with one message, already decoded — in a thread of its own.
60
+
61
+ Returning means the message is acknowledged. Raising means it is given
62
+ up on — so a subclass that wants a message tried again has to say so by
63
+ putting it back itself. With `ACKNOWLEDGE_ON_RECEIPT`, it is acknowledged
64
+ before this is called, whatever comes of it.
65
+ """
66
+
67
+ def start(self) -> None:
68
+ self._connect()
69
+
70
+ super().start()
71
+
72
+ def stop(self) -> None:
73
+ if self.connector:
74
+ self.connector.disconnect()
75
+
76
+ super().stop()
77
+
78
+ def _connect(self) -> None:
79
+ from wexample_queue.connector.rabbitmq_external_connector import (
80
+ RabbitmqExternalConnector,
81
+ )
82
+
83
+ self.connector = RabbitmqExternalConnector(kernel=self.kernel)
84
+ self.connector.connect()
85
+ self.connector.declare_queue(self.queue_name)
86
+
87
+ def _decode(self, body: bytes) -> dict[str, Any] | None:
88
+ import json
89
+
90
+ try:
91
+ return json.loads(body.decode())
92
+ except json.JSONDecodeError as exception:
93
+ self.io.error(
94
+ f"[{self.get_snake_short_class_name()}] Message is not JSON: {exception}",
95
+ fatal=False,
96
+ )
97
+ return None
98
+
99
+ def _handle(self, delivery_tag: int, body: bytes) -> None:
100
+ """Works one message through, keeping the connection alive meanwhile.
101
+
102
+ A message that cannot be read, or that the service could not get
103
+ through, is rejected without being put back: requeueing it would be
104
+ asking for the same failure at once and for ever.
105
+ """
106
+ import threading
107
+
108
+ message = self._decode(body)
109
+ if message is None:
110
+ self.connector.nack(delivery_tag)
111
+ return
112
+
113
+ if self.ACKNOWLEDGE_ON_RECEIPT:
114
+ self.connector.ack(delivery_tag)
115
+
116
+ outcome: dict[str, Any] = {}
117
+
118
+ def work() -> None:
119
+ try:
120
+ self.process_message(message)
121
+ except Exception as exception:
122
+ import traceback
123
+
124
+ outcome["error"] = exception
125
+ self.io.debug(traceback.format_exc())
126
+
127
+ import time
128
+
129
+ from pika.exceptions import AMQPError
130
+
131
+ worker = threading.Thread(target=work, name=f"{self.queue_name}-message")
132
+ worker.start()
133
+
134
+ # Losing the connection does not stop the work: it is let finish, the
135
+ # connection being opened again afterwards, and nothing else is taken
136
+ # meanwhile.
137
+ lost: AMQPError | None = None
138
+ while worker.is_alive():
139
+ if lost is None:
140
+ try:
141
+ self.connector.sleep(self.POLL_INTERVAL)
142
+ except AMQPError as exception:
143
+ lost = exception
144
+ else:
145
+ time.sleep(self.POLL_INTERVAL)
146
+ self.on_tick(message)
147
+
148
+ if lost is not None:
149
+ # An unacknowledged message is given back by the broker with the
150
+ # connection it was delivered on; one acknowledged on receipt is done.
151
+ raise lost
152
+
153
+ if "error" in outcome:
154
+ self.io.error(
155
+ f"[{self.get_snake_short_class_name()}] Message failed: {outcome['error']}",
156
+ fatal=False,
157
+ )
158
+
159
+ if not self.ACKNOWLEDGE_ON_RECEIPT:
160
+ if "error" in outcome:
161
+ self.connector.nack(delivery_tag)
162
+ else:
163
+ self.connector.ack(delivery_tag)
164
+
165
+ def _run(self) -> None:
166
+ """One turn: the next message worked through, or a short wait.
167
+
168
+ A lost connection is opened again rather than ending the service: a
169
+ worker left running for a night outlives the odd network hiccup, and a
170
+ broker restarted under it.
171
+ """
172
+ from pika.exceptions import AMQPError
173
+
174
+ try:
175
+ if not self.connector or not self.connector.connected:
176
+ self._connect()
177
+ self.log(f"Consuming messages on queue « {self.queue_name} »...")
178
+
179
+ delivery = self.connector.get(self.queue_name)
180
+ if delivery is None:
181
+ self.on_idle()
182
+ self.connector.sleep(self.POLL_INTERVAL)
183
+ return
184
+
185
+ self._handle(*delivery)
186
+ except AMQPError as exception:
187
+ self.io.error(
188
+ f"[{self.get_snake_short_class_name()}] Connection lost: "
189
+ f"{type(exception).__name__}: {exception}. Reconnecting in "
190
+ f"{self.RECONNECT_DELAY:.0f}s.",
191
+ fatal=False,
192
+ )
193
+ if self.connector:
194
+ self.connector.connected = False
195
+ import time
196
+
197
+ time.sleep(self.RECONNECT_DELAY)
@@ -1,99 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from typing import TYPE_CHECKING, Any
4
-
5
- from wexample_app.service.abstract_service import AbstractService
6
- from wexample_helpers.classes.abstract_method import abstract_method
7
- from wexample_helpers.classes.field import public_field
8
- from wexample_helpers.decorator.base_class import base_class
9
-
10
- if TYPE_CHECKING:
11
- from wexample_queue.connector.rabbitmq_external_connector import (
12
- RabbitmqExternalConnector,
13
- )
14
-
15
-
16
- @base_class
17
- class AbstractQueueService(AbstractService):
18
- """A service whose work is whatever arrives on one queue.
19
-
20
- All a subclass writes is the name of the queue and what to do with a
21
- message. Connecting, declaring, decoding, acknowledging and giving up are
22
- the same for every worker, so they are here.
23
- """
24
-
25
- connector: RabbitmqExternalConnector | None = public_field(
26
- default=None,
27
- description="The broker connection, opened when the service starts",
28
- )
29
- queue_name: str = public_field(
30
- description="Name of the queue this service consumes",
31
- )
32
-
33
- @abstract_method
34
- def process_message(self, message: dict[str, Any]) -> None:
35
- """What to do with one message, already decoded.
36
-
37
- Returning means the message is acknowledged. Raising means it is given
38
- up on — so a subclass that wants a message tried again has to say so by
39
- putting it back itself.
40
- """
41
-
42
- def start(self) -> None:
43
- from wexample_queue.connector.rabbitmq_external_connector import (
44
- RabbitmqExternalConnector,
45
- )
46
-
47
- self.connector = RabbitmqExternalConnector(kernel=self.kernel)
48
- self.connector.connect()
49
- self.connector.declare_queue(self.queue_name)
50
-
51
- super().start()
52
-
53
- def stop(self) -> None:
54
- if self.connector:
55
- self.connector.stop_consuming()
56
- self.connector.disconnect()
57
-
58
- super().stop()
59
-
60
- def _on_message(self, channel, method_frame, header_frame, body) -> None:
61
- """Decodes one delivery and answers the broker for it.
62
-
63
- A message that cannot be read, or that the service could not get
64
- through, is rejected without being put back: requeueing it would be
65
- asking for the same failure at once and for ever. Where it goes then is
66
- the broker's business — a dead letter exchange if the queue has one,
67
- nowhere if it has not.
68
- """
69
- import json
70
-
71
- try:
72
- message = json.loads(body.decode())
73
- except json.JSONDecodeError as exception:
74
- self.io.error(
75
- f"[{self.get_snake_short_class_name()}] Message is not JSON: {exception}",
76
- fatal=False,
77
- )
78
- channel.basic_nack(method_frame.delivery_tag, requeue=False)
79
- return
80
-
81
- try:
82
- self.process_message(message)
83
- except Exception as exception:
84
- import traceback
85
-
86
- self.io.error(
87
- f"[{self.get_snake_short_class_name()}] Message failed: {exception}",
88
- fatal=False,
89
- )
90
- self.io.debug(traceback.format_exc())
91
- channel.basic_nack(method_frame.delivery_tag, requeue=False)
92
- return
93
-
94
- channel.basic_ack(method_frame.delivery_tag)
95
-
96
- def _run(self) -> None:
97
- self.log(f"Consuming messages on queue « {self.queue_name} »...")
98
-
99
- self.connector.consume(self.queue_name, self._on_message)