Skip to content

Commit 27faf86

Browse files
committed
feat: publish TOMBSTONE explicitly, None encodes normally
Consumers already told None apart from TOMBSTONE. Publish did not, so there was no way to send a real b"null". Now None goes through the codec like any other value, and TOMBSTONE is the only way to send a real Kafka tombstone. aiokafka requires a key for a tombstone, confluent does not enforce it but a key is still recommended.
1 parent 0714213 commit 27faf86

10 files changed

Lines changed: 169 additions & 22 deletions

File tree

docs/docs/en/confluent/message.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ This object serves as a unified **FastStream** wrapper around the native broker
2929
!!! note
3030
A `None` value() is a Kafka tombstone, the delete marker on a compacted topic. FastStream represents it as `faststream.message.TOMBSTONE`, kept distinct from an empty payload (`b""`).
3131

32+
To publish a tombstone, pass `faststream.message.TOMBSTONE` as the message body, along with a key. `broker.publish(TOMBSTONE, key=b"...")`
33+
3234
For example, if you would like to access the headers of an incoming message, you would do so like this:
3335

3436
```python hl_lines="1 6"

docs/docs/en/kafka/message.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ This object serves as a unified **FastStream** wrapper around the native broker
3434
!!! note
3535
A `None` value is a Kafka tombstone, the delete marker on a compacted topic. FastStream represents it as `faststream.message.TOMBSTONE`, kept distinct from an empty payload (`b""`).
3636

37+
To publish a tombstone, pass `faststream.message.TOMBSTONE` as the message body and a key. `broker.publish(TOMBSTONE, key=b"...")`
38+
3739
For example, if you would like to access the headers of an incoming message, you would do so like this:
3840

3941
```python hl_lines="1 6"

faststream/confluent/publisher/producer.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from faststream.confluent.parser import AsyncConfluentParser
1010
from faststream.confluent.response import KafkaPublishCommand
1111
from faststream.exceptions import FeatureNotSupportedException
12+
from faststream.message import TOMBSTONE
1213

1314
from .state import EmptyProducerState, ProducerState, RealProducer
1415

@@ -139,7 +140,10 @@ async def publish(
139140
cmd: "KafkaPublishCommand",
140141
) -> "asyncio.Future[Message | None] | Message | None":
141142
"""Publish a message to a topic."""
142-
if cmd.body is None:
143+
if cmd.body is TOMBSTONE:
144+
# None goes through the codec like any other value now (it can
145+
# encode to a real b"null"). TOMBSTONE is the explicit way to
146+
# send a real Kafka tombstone.
143147
message, content_type = None, None
144148
else:
145149
message, content_type = await self.codec.encode(cmd.body, self.serializer)

faststream/confluent/testing.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from faststream.confluent.subscriber.usecase import BatchSubscriber
2323
from faststream.exceptions import SubscriberNotFound
2424
from faststream.message import gen_cor_id
25+
from faststream.message.utils import _Tombstone
2526

2627
if TYPE_CHECKING:
2728
from fast_depends.library.serializer import SerializerProto
@@ -279,7 +280,7 @@ async def _execute_handler(
279280
class MockConfluentMessage:
280281
def __init__(
281282
self,
282-
raw_msg: bytes,
283+
raw_msg: bytes | None,
283284
topic: str,
284285
key: bytes | str,
285286
headers: list[tuple[str, bytes]],
@@ -304,7 +305,7 @@ def __init__(
304305
self._timestamp = (timestamp_type, timestamp_ms)
305306

306307
def len(self) -> int:
307-
return len(self._raw_msg)
308+
return 0 if self._raw_msg is None else len(self._raw_msg)
308309

309310
def error(self) -> str | None:
310311
return self._error
@@ -327,12 +328,12 @@ def timestamp(self) -> tuple[int, int]:
327328
def topic(self) -> str:
328329
return self._topic
329330

330-
def value(self) -> bytes:
331+
def value(self) -> bytes | None:
331332
return self._raw_msg
332333

333334

334335
async def build_message(
335-
message: "SendableMessage",
336+
message: "SendableMessage | _Tombstone",
336337
topic: str,
337338
*,
338339
correlation_id: str | None = None,
@@ -345,8 +346,11 @@ async def build_message(
345346
codec: Optional["CodecProto"] = None,
346347
) -> MockConfluentMessage:
347348
"""Build a mock confluent_kafka.Message for a sendable message."""
348-
codec_instance = codec or DefaultCodec()
349-
msg, content_type = await codec_instance.encode(message, serializer)
349+
if isinstance(message, _Tombstone):
350+
msg, content_type = None, None
351+
else:
352+
codec_instance = codec or DefaultCodec()
353+
msg, content_type = await codec_instance.encode(message, serializer)
350354
k = key or b""
351355
headers = {
352356
"content-type": content_type or "",

faststream/kafka/publisher/producer.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
from faststream._internal.endpoint.utils import ParserComposition
77
from faststream._internal.parser import BatchCodecProto, DefaultCodec
88
from faststream._internal.producer import ProducerProto
9-
from faststream.exceptions import FeatureNotSupportedException
9+
from faststream.exceptions import FeatureNotSupportedException, SetupError
1010
from faststream.kafka.exceptions import BatchBufferOverflowException
1111
from faststream.kafka.message import KafkaMessage
1212
from faststream.kafka.parser import AioKafkaParser
1313
from faststream.kafka.response import KafkaPublishCommand
14+
from faststream.message import TOMBSTONE
1415

1516
from .state import EmptyProducerState, ProducerState, RealProducer
1617

@@ -110,9 +111,14 @@ async def publish(
110111
cmd: "KafkaPublishCommand",
111112
) -> Union["asyncio.Future[RecordMetadata]", "RecordMetadata"]:
112113
"""Publish a message to a topic."""
113-
if cmd.body is None and cmd.key is not None:
114-
# keyed None is a tombstone: aiokafka requires at least key or value,
115-
# so a keyless None still goes through the codec as b""
114+
if cmd.body is TOMBSTONE:
115+
# None now goes through the codec like any other value.
116+
# TOMBSTONE is the explicit way to send a real Kafka tombstone.
117+
# aiokafka requires at least a key or value, so a tombstone
118+
# needs a key.
119+
if cmd.key is None:
120+
msg = "a Kafka tombstone requires a key"
121+
raise SetupError(msg)
116122
message, content_type = None, None
117123
else:
118124
message, content_type = await self.codec.encode(cmd.body, self.serializer)

faststream/kafka/testing.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
TestBroker,
1717
change_producer,
1818
)
19-
from faststream.exceptions import SubscriberNotFound
19+
from faststream.exceptions import SetupError, SubscriberNotFound
2020
from faststream.kafka import TopicPartition
2121
from faststream.kafka.broker import KafkaBroker
2222
from faststream.kafka.message import KafkaMessage
@@ -25,6 +25,7 @@
2525
from faststream.kafka.publisher.usecase import BatchPublisher
2626
from faststream.kafka.subscriber.usecase import BatchSubscriber
2727
from faststream.message import gen_cor_id
28+
from faststream.message.utils import _Tombstone
2829

2930
if TYPE_CHECKING:
3031
from fast_depends.library.serializer import SerializerProto
@@ -296,7 +297,7 @@ async def _execute_handler(
296297

297298

298299
async def build_message(
299-
message: "SendableMessage",
300+
message: "SendableMessage | _Tombstone",
300301
topic: str,
301302
partition: int | None = None,
302303
timestamp_ms: int | None = None,
@@ -309,7 +310,13 @@ async def build_message(
309310
codec: Optional["CodecProto"] = None,
310311
) -> "ConsumerRecord":
311312
"""Build a Kafka ConsumerRecord for a sendable message."""
312-
msg, content_type = await (codec or DefaultCodec()).encode(message, serializer)
313+
if isinstance(message, _Tombstone):
314+
if key is None:
315+
msg_text = "a Kafka tombstone requires a key"
316+
raise SetupError(msg_text)
317+
msg, content_type = None, None
318+
else:
319+
msg, content_type = await (codec or DefaultCodec()).encode(message, serializer)
313320

314321
k = key or b""
315322

@@ -328,8 +335,8 @@ async def build_message(
328335
partition=partition or 0,
329336
key=k,
330337
serialized_key_size=len(k),
331-
serialized_value_size=len(msg),
332-
checksum=sum(msg),
338+
serialized_value_size=0 if msg is None else len(msg),
339+
checksum=0 if msg is None else sum(msg),
333340
offset=0,
334341
headers=[(i, j.encode()) for i, j in headers.items()],
335342
timestamp_type=1,

tests/brokers/confluent/test_publish.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from faststream import Context
88
from faststream.confluent import KafkaPublishMessage, KafkaResponse
9+
from faststream.message import TOMBSTONE
910
from tests.brokers.base.publish import BrokerPublishTestcase
1011

1112
from .basic import ConfluentTestcaseConfig
@@ -301,7 +302,7 @@ async def handler(msg: Any, raw_msg=Context("message")) -> None:
301302
assert messages_queue.empty()
302303

303304
@pytest.mark.asyncio()
304-
async def test_publish_none_sends_a_real_tombstone(self, queue: str) -> None:
305+
async def test_publish_none_encodes_normally(self, queue: str) -> None:
305306
pub_broker = self.get_broker(apply_types=True)
306307

307308
values: asyncio.Queue[bytes | None] = asyncio.Queue()
@@ -314,7 +315,26 @@ async def handler(msg: Any = Context("message")) -> None:
314315

315316
async with self.patch_broker(pub_broker) as br:
316317
await br.start()
317-
await br.publish(None, queue, key=b"tombstone-key")
318+
await br.publish(None, queue)
319+
value = await asyncio.wait_for(values.get(), timeout=self.timeout)
320+
321+
assert value == b""
322+
323+
@pytest.mark.asyncio()
324+
async def test_publish_tombstone_sends_a_real_tombstone(self, queue: str) -> None:
325+
pub_broker = self.get_broker(apply_types=True)
326+
327+
values: asyncio.Queue[bytes | None] = asyncio.Queue()
328+
329+
args, kwargs = self.get_subscriber_params(queue)
330+
331+
@pub_broker.subscriber(*args, **kwargs)
332+
async def handler(msg: Any = Context("message")) -> None:
333+
await values.put(msg.raw_message.value())
334+
335+
async with self.patch_broker(pub_broker) as br:
336+
await br.start()
337+
await br.publish(TOMBSTONE, queue, key=b"tombstone-key")
318338
value = await asyncio.wait_for(values.get(), timeout=self.timeout)
319339

320340
assert value is None

tests/brokers/confluent/test_test_client.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33

44
import pytest
55

6-
from faststream import AckPolicy, BaseMiddleware
6+
from faststream import AckPolicy, BaseMiddleware, Context
77
from faststream.confluent.annotations import KafkaMessage
88
from faststream.confluent.message import FAKE_CONSUMER
99
from faststream.confluent.testing import FakeProducer
10+
from faststream.message import TOMBSTONE
1011
from tests.brokers.base.testclient import BrokerTestclientTestcase
1112
from tests.tools import spy_decorator
1213

@@ -288,3 +289,37 @@ async def test_publisher_without_destination(self) -> None:
288289

289290
await another_publisher.publish(None, topic="new-key")
290291
another_publisher.mock.assert_called_once()
292+
293+
async def test_publish_none_encodes_normally(self, queue: str) -> None:
294+
broker = self.get_broker(apply_types=True)
295+
296+
values: asyncio.Queue[bytes | None] = asyncio.Queue()
297+
298+
args, kwargs = self.get_subscriber_params(queue)
299+
300+
@broker.subscriber(*args, **kwargs)
301+
async def handler(msg=Context("message")) -> None:
302+
await values.put(msg.raw_message.value())
303+
304+
async with self.patch_broker(broker) as br:
305+
await br.publish(None, queue)
306+
value = await asyncio.wait_for(values.get(), timeout=3)
307+
308+
assert value == b""
309+
310+
async def test_publish_tombstone_sends_a_real_tombstone(self, queue: str) -> None:
311+
broker = self.get_broker(apply_types=True)
312+
313+
values: asyncio.Queue[bytes | None] = asyncio.Queue()
314+
315+
args, kwargs = self.get_subscriber_params(queue)
316+
317+
@broker.subscriber(*args, **kwargs)
318+
async def handler(msg=Context("message")) -> None:
319+
await values.put(msg.raw_message.value())
320+
321+
async with self.patch_broker(broker) as br:
322+
await br.publish(TOMBSTONE, queue, key=b"tombstone-key")
323+
value = await asyncio.wait_for(values.get(), timeout=3)
324+
325+
assert value is None

tests/brokers/kafka/test_publish.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
from aiokafka.structs import RecordMetadata
77

88
from faststream import Context
9+
from faststream.exceptions import SetupError
910
from faststream.kafka import KafkaPublishMessage, KafkaResponse
1011
from faststream.kafka.exceptions import BatchBufferOverflowException
12+
from faststream.message import TOMBSTONE
1113
from tests.brokers.base.publish import BrokerPublishTestcase
1214

1315
from .basic import KafkaTestcaseConfig
@@ -353,7 +355,7 @@ async def handler(msg: Any, raw_msg=Context("message")) -> None:
353355
assert messages_queue.empty()
354356

355357
@pytest.mark.asyncio()
356-
async def test_publish_none_sends_a_real_tombstone(self, queue: str) -> None:
358+
async def test_publish_none_encodes_normally(self, queue: str) -> None:
357359
pub_broker = self.get_broker(apply_types=True)
358360

359361
values: asyncio.Queue[bytes | None] = asyncio.Queue()
@@ -364,7 +366,33 @@ async def handler(msg: Any = Context("message")) -> None:
364366

365367
async with self.patch_broker(pub_broker) as br:
366368
await br.start()
367-
await br.publish(None, queue, key=b"tombstone-key")
369+
await br.publish(None, queue)
370+
value = await asyncio.wait_for(values.get(), timeout=self.timeout)
371+
372+
assert value == b""
373+
374+
@pytest.mark.asyncio()
375+
async def test_publish_tombstone_sends_a_real_tombstone(self, queue: str) -> None:
376+
pub_broker = self.get_broker(apply_types=True)
377+
378+
values: asyncio.Queue[bytes | None] = asyncio.Queue()
379+
380+
@pub_broker.subscriber(queue)
381+
async def handler(msg: Any = Context("message")) -> None:
382+
await values.put(msg.raw_message.value)
383+
384+
async with self.patch_broker(pub_broker) as br:
385+
await br.start()
386+
await br.publish(TOMBSTONE, queue, key=b"tombstone-key")
368387
value = await asyncio.wait_for(values.get(), timeout=self.timeout)
369388

370389
assert value is None
390+
391+
@pytest.mark.asyncio()
392+
async def test_publish_tombstone_without_key_raises(self, queue: str) -> None:
393+
pub_broker = self.get_broker(apply_types=True)
394+
395+
async with self.patch_broker(pub_broker) as br:
396+
await br.start()
397+
with pytest.raises(SetupError):
398+
await br.publish(TOMBSTONE, queue)

tests/brokers/kafka/test_test_client.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33

44
import pytest
55

6-
from faststream import AckPolicy, BaseMiddleware
6+
from faststream import AckPolicy, BaseMiddleware, Context
7+
from faststream.exceptions import SetupError
78
from faststream.kafka import TopicPartition
89
from faststream.kafka.annotations import KafkaMessage
910
from faststream.kafka.message import FAKE_CONSUMER
1011
from faststream.kafka.testing import FakeProducer
12+
from faststream.message import TOMBSTONE
1113
from tests.brokers.base.testclient import BrokerTestclientTestcase
1214
from tests.tools import spy_decorator
1315

@@ -350,3 +352,40 @@ async def test_publisher_without_destination(self) -> None:
350352

351353
await another_publisher.publish(None, topic="new-key")
352354
another_publisher.mock.assert_called_once()
355+
356+
async def test_publish_none_encodes_normally(self, queue: str) -> None:
357+
broker = self.get_broker(apply_types=True)
358+
359+
values: asyncio.Queue[bytes | None] = asyncio.Queue()
360+
361+
@broker.subscriber(queue)
362+
async def handler(msg=Context("message")) -> None:
363+
await values.put(msg.raw_message.value)
364+
365+
async with self.patch_broker(broker) as br:
366+
await br.publish(None, queue)
367+
value = await asyncio.wait_for(values.get(), timeout=3)
368+
369+
assert value == b""
370+
371+
async def test_publish_tombstone_sends_a_real_tombstone(self, queue: str) -> None:
372+
broker = self.get_broker(apply_types=True)
373+
374+
values: asyncio.Queue[bytes | None] = asyncio.Queue()
375+
376+
@broker.subscriber(queue)
377+
async def handler(msg=Context("message")) -> None:
378+
await values.put(msg.raw_message.value)
379+
380+
async with self.patch_broker(broker) as br:
381+
await br.publish(TOMBSTONE, queue, key=b"tombstone-key")
382+
value = await asyncio.wait_for(values.get(), timeout=3)
383+
384+
assert value is None
385+
386+
async def test_publish_tombstone_without_key_raises(self, queue: str) -> None:
387+
broker = self.get_broker(apply_types=True)
388+
389+
async with self.patch_broker(broker) as br:
390+
with pytest.raises(SetupError):
391+
await br.publish(TOMBSTONE, queue)

0 commit comments

Comments
 (0)