Skip to content

Commit fd947e2

Browse files
ShivamShivam
authored andcommitted
Harden Redis batch publishing lifecycle
1 parent a8ede3a commit fd947e2

8 files changed

Lines changed: 393 additions & 37 deletions

File tree

docs/reference/kombu.exceptions.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,4 @@
1515
.. autoexception:: LimitExceeded
1616
.. autoexception:: ConnectionLimitExceeded
1717
.. autoexception:: ChannelLimitExceeded
18-
18+
.. autoexception:: BatchPublishError

docs/reference/kombu.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,9 @@
162162
.. autoattribute:: auto_declare
163163
.. autoattribute:: on_return
164164
.. autoattribute:: connection
165+
.. autoattribute:: supports_batch_publish
165166

167+
.. automethod:: batch
166168
.. automethod:: declare
167169
.. automethod:: maybe_declare
168170
.. automethod:: publish

docs/userguide/producers.rst

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ empty string, and set the routing key to be the name of the queue:
107107
Batch publishing
108108
----------------
109109

110+
.. versionadded:: 5.7
111+
110112
:meth:`~kombu.Producer.batch` groups normal :meth:`~kombu.Producer.publish`
111113
calls so a supporting transport can send their final broker operations
112114
together:
@@ -172,7 +174,11 @@ The standard Redis, Redis TLS, and Redis Sentinel transports share this batch
172174
implementation. Standard Redis is covered by integration tests. TLS and
173175
Sentinel use the same channel implementation but are not covered by
174176
real-service batch integration tests. Redis Cluster is not currently an
175-
upstream Kombu transport and is not supported or tested by this API.
177+
upstream Kombu transport and is not supported or tested by this API. Redis
178+
publishing also remains immediate when the underlying connection pool enables
179+
automatic retries, including ``retry_on_timeout`` or a custom retry policy,
180+
because replaying a pipeline after an ambiguous failure can publish duplicate
181+
messages.
176182

177183
Serialization
178184
=============

kombu/messaging.py

Lines changed: 46 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -22,41 +22,25 @@
2222
DEFAULT_BATCH_SIZE = 1000
2323

2424

25-
class _ImmediatePublishBatch:
26-
"""Transport-neutral batch that retains immediate publication."""
27-
28-
def __init__(self, channel):
29-
self.channel = channel
30-
31-
def publish(self, message, **kwargs):
32-
return self.channel.basic_publish(message, **kwargs)
33-
34-
def flush(self):
35-
"""Flush buffered messages (there are none)."""
36-
37-
def discard(self):
38-
"""Discard buffered messages (there are none)."""
39-
40-
def close(self):
41-
"""Release batch resources (there are none)."""
42-
43-
4425
class _ProducerBatchState:
4526
"""State shared by nested batch contexts in one producer thread."""
4627

4728
def __init__(self, max_size):
4829
self.max_size = max_size
4930
self.aborted = False
5031
self.session = None
32+
self.immediate = False
5133

5234
def _get_session(self, channel):
35+
if self.immediate:
36+
return None
5337
if self.session is None:
5438
create_batch = getattr(channel, 'create_publish_batch', None)
55-
self.session = (
56-
create_batch(max_size=self.max_size)
57-
if create_batch is not None
58-
else _ImmediatePublishBatch(channel)
59-
)
39+
supports_batch = getattr(channel, 'supports_batch_publish', True)
40+
if create_batch is None or not supports_batch:
41+
self.immediate = True
42+
return None
43+
self.session = create_batch(max_size=self.max_size)
6044
elif self.session.channel is not channel:
6145
raise RuntimeError(
6246
'Producer channel changed while a publish batch was active',
@@ -66,7 +50,10 @@ def _get_session(self, channel):
6650
def publish(self, channel, message, **kwargs):
6751
if self.aborted:
6852
raise RuntimeError('Cannot publish through an aborted batch')
69-
return self._get_session(channel).publish(message, **kwargs)
53+
session = self._get_session(channel)
54+
if session is None:
55+
return channel.basic_publish(message, **kwargs)
56+
return session.publish(message, **kwargs)
7057

7158
def flush(self):
7259
if self.aborted:
@@ -116,19 +103,33 @@ def __exit__(self, exc_type, exc_value, traceback):
116103
if state is None:
117104
return None
118105

106+
cleanup_error = None
119107
try:
120-
if exc_type is not None:
121-
state.abort()
122-
123108
if self.is_outermost:
124109
del self.producer._batch_local.current
110+
111+
if exc_type is not None:
125112
try:
126-
if exc_type is None and not state.aborted:
113+
state.abort()
114+
except BaseException as exc:
115+
cleanup_error = exc
116+
117+
if self.is_outermost:
118+
if exc_type is None and not state.aborted:
119+
try:
127120
state.flush()
128-
finally:
121+
except BaseException as exc:
122+
cleanup_error = exc
123+
try:
129124
state.close()
125+
except BaseException as exc:
126+
if cleanup_error is None:
127+
cleanup_error = exc
130128
finally:
131129
self.active = False
130+
131+
if exc_type is None and cleanup_error is not None:
132+
raise cleanup_error
132133
return None
133134

134135
def flush(self):
@@ -233,9 +234,23 @@ def supports_batch_publish(self):
233234
if connection is None:
234235
return False
235236
try:
236-
return connection.transport.implements.batch_publish
237+
transport_support = connection.transport.implements.batch_publish
237238
except AttributeError:
238239
return False
240+
channel = self._channel
241+
if channel is not None and not isinstance(channel, ChannelPromise):
242+
configured_support = getattr(
243+
channel,
244+
'supports_batch_publish',
245+
transport_support,
246+
)
247+
else:
248+
configured_support = getattr(
249+
connection.transport,
250+
'supports_batch_publish',
251+
transport_support,
252+
)
253+
return bool(transport_support and configured_support)
239254

240255
def batch(self, max_size=DEFAULT_BATCH_SIZE):
241256
"""Group normal :meth:`publish` calls into transport-owned batches.

kombu/transport/redis.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,15 @@ def fds(self):
693693
return self._fd_to_chan
694694

695695

696+
def _batch_publish_retry_safe(connection_kwargs):
697+
"""Return whether Redis will execute a pipeline without retrying it."""
698+
return not (
699+
connection_kwargs.get('retry_on_timeout')
700+
or connection_kwargs.get('retry_on_error')
701+
or connection_kwargs.get('retry') is not None
702+
)
703+
704+
696705
class PublishBatch:
697706
"""Buffer Redis publish commands in a non-transactional pipeline."""
698707

@@ -832,6 +841,14 @@ class Channel(virtual.Channel):
832841
max_connections = 10
833842
health_check_interval = DEFAULT_HEALTH_CHECK_INTERVAL
834843
client_name = None
844+
845+
@property
846+
def supports_batch_publish(self):
847+
"""Return whether pipelines can run without ambiguous write replay."""
848+
return (
849+
not self.retry_on_timeout
850+
and _batch_publish_retry_safe(self.pool.connection_kwargs)
851+
)
835852
#: Transport option to disable fanout keyprefix.
836853
#: Can also be string, in which case it changes the default
837854
#: prefix ('/{db}.') into to something else. The prefix must
@@ -1752,6 +1769,17 @@ class Transport(virtual.Transport):
17521769
exchange_type=frozenset(['direct', 'topic', 'fanout']),
17531770
)
17541771

1772+
@property
1773+
def supports_batch_publish(self):
1774+
"""Return whether configured timeout handling permits safe batching."""
1775+
if not _batch_publish_retry_safe(self.client.transport_options):
1776+
return False
1777+
hostname = self.client.hostname or ''
1778+
if '://' not in hostname:
1779+
return True
1780+
*_, query = _parse_url(hostname)
1781+
return _batch_publish_retry_safe(query)
1782+
17551783
if redis:
17561784
connection_errors, channel_errors = get_redis_error_classes()
17571785

t/integration/test_redis.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,57 @@ def callback(body, message):
222222
@pytest.mark.flaky(reruns=5, reruns_delay=2)
223223
class test_RedisPublishBatch:
224224

225+
def test_retry_on_timeout_keeps_publication_immediate(self, connection):
226+
connection.transport_options = {
227+
**connection.transport_options,
228+
'retry_on_timeout': True,
229+
}
230+
queue = kombu.Queue('batch_retry_on_timeout_queue')
231+
232+
with connection as conn:
233+
with conn.channel() as channel:
234+
producer = kombu.Producer(channel, serializer='json')
235+
236+
assert producer.supports_batch_publish is False
237+
with producer.batch():
238+
producer.publish(
239+
{'delivery': 'immediate'},
240+
exchange='',
241+
routing_key=queue.name,
242+
declare=[queue],
243+
)
244+
message = queue(channel).get(no_ack=True)
245+
246+
assert message.payload == {'delivery': 'immediate'}
247+
248+
def test_manual_flush_sends_and_batch_continues(self, connection):
249+
queue = kombu.Queue('batch_manual_flush_queue')
250+
251+
with connection as conn:
252+
with conn.channel() as channel:
253+
producer = kombu.Producer(channel, serializer='json')
254+
bound_queue = queue(channel)
255+
256+
with producer.batch() as batch:
257+
producer.publish(
258+
{'position': 'first'},
259+
exchange='',
260+
routing_key=queue.name,
261+
declare=[queue],
262+
)
263+
batch.flush()
264+
first = bound_queue.get(no_ack=True)
265+
producer.publish(
266+
{'position': 'second'},
267+
exchange='',
268+
routing_key=queue.name,
269+
)
270+
271+
second = bound_queue.get(no_ack=True)
272+
273+
assert first.payload == {'position': 'first'}
274+
assert second.payload == {'position': 'second'}
275+
225276
def test_direct_priority_and_fifo(self, connection):
226277
exchange = kombu.Exchange('batch_direct_exchange', type='direct')
227278
queue = kombu.Queue(

0 commit comments

Comments
 (0)