Skip to content

Commit 268fe24

Browse files
feat(asyncio): add Reader API (#309)
1 parent 5483ca9 commit 268fe24

4 files changed

Lines changed: 320 additions & 0 deletions

File tree

pulsar/asyncio.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,107 @@ def consumer_name(self) -> str:
449449
"""
450450
return self._consumer.consumer_name()
451451

452+
class Reader:
453+
"""
454+
The Pulsar topic reader, used to read messages from a topic.
455+
"""
456+
457+
def __init__(self, reader: _pulsar.Reader, schema: pulsar.schema.Schema) -> None:
458+
"""
459+
Create the reader.
460+
Users should not call this constructor directly. Instead, create the
461+
reader via ``Client.create_reader``.
462+
463+
Parameters
464+
----------
465+
reader: _pulsar.Reader
466+
The underlying Reader object from the C extension.
467+
schema: pulsar.schema.Schema
468+
The schema of the data that will be received by this reader.
469+
"""
470+
self._reader = reader
471+
self._schema = schema
472+
473+
async def read_next(self) -> pulsar.Message:
474+
"""
475+
Read a single message asynchronously.
476+
477+
Returns
478+
-------
479+
pulsar.Message
480+
The message received.
481+
482+
Raises
483+
------
484+
PulsarException
485+
"""
486+
future = asyncio.get_running_loop().create_future()
487+
self._reader.read_next_async(functools.partial(_set_future, future))
488+
msg = await future
489+
m = pulsar.Message()
490+
m._message = msg
491+
m._schema = self._schema
492+
return m
493+
494+
async def has_message_available(self) -> bool:
495+
"""
496+
Check if there is any message available to read from the current
497+
position.
498+
"""
499+
future = asyncio.get_running_loop().create_future()
500+
self._reader.has_message_available_async(functools.partial(_set_future, future))
501+
return await future
502+
503+
async def seek(self, messageid: Union[pulsar.MessageId, int]) -> None:
504+
"""
505+
Reset this reader to a specific message id or publish timestamp
506+
asynchronously.
507+
508+
Parameters
509+
----------
510+
messageid : MessageId or int
511+
The message id for seek, OR an integer event time (timestamp) to
512+
seek to.
513+
514+
Raises
515+
------
516+
PulsarException
517+
"""
518+
future = asyncio.get_running_loop().create_future()
519+
if isinstance(messageid, pulsar.MessageId):
520+
msg_id = messageid._msg_id
521+
elif isinstance(messageid, int):
522+
msg_id = messageid
523+
else:
524+
raise ValueError(f"invalid messageid type {type(messageid)}")
525+
self._reader.seek_async(msg_id, functools.partial(_set_future, future, value=None))
526+
await future
527+
528+
async def close(self) -> None:
529+
"""
530+
Close the reader asynchronously.
531+
532+
Raises
533+
------
534+
PulsarException
535+
"""
536+
future = asyncio.get_running_loop().create_future()
537+
self._reader.close_async(functools.partial(_set_future, future, value=None))
538+
await future
539+
540+
def topic(self) -> str:
541+
"""
542+
Return the topic this reader is reading from.
543+
"""
544+
return self._reader.topic()
545+
546+
def is_connected(self) -> bool:
547+
"""
548+
Check if the reader is connected or not.
549+
"""
550+
return self._reader.is_connected()
551+
552+
452553
class Client:
453554
"""
454555
The asynchronous version of `pulsar.Client`.
@@ -777,6 +878,93 @@ async def subscribe(self, topic: Union[str, List[str]],
777878
schema.attach_client(self._client)
778879
return Consumer(await future, schema)
779880

881+
# pylint: disable=too-many-arguments,too-many-locals,too-many-positional-arguments
882+
async def create_reader(self, topic: str,
883+
start_message_id: Union[pulsar.MessageId, _pulsar.MessageId],
884+
schema: pulsar.schema.Schema | None = None,
885+
receiver_queue_size: int = 1000,
886+
reader_name: str | None = None,
887+
subscription_role_prefix: str | None = None,
888+
is_read_compacted: bool = False,
889+
crypto_key_reader: pulsar.CryptoKeyReader | None = None,
890+
start_message_id_inclusive: bool = False,
891+
crypto_failure_action: ConsumerCryptoFailureAction =
892+
ConsumerCryptoFailureAction.FAIL,
893+
) -> Reader:
894+
"""
895+
Create a reader on a particular topic.
896+
897+
Parameters
898+
----------
899+
topic: str
900+
The name of the topic.
901+
start_message_id: MessageId or _pulsar.MessageId
902+
The initial reader positioning is done by specifying a message id.
903+
The options are:
904+
905+
* ``MessageId.earliest``: Start reading from the earliest message
906+
available in the topic.
907+
* ``MessageId.latest``: Start reading from the end topic, only
908+
getting messages published after the reader was created.
909+
* ``MessageId``: When passing a particular message id, the reader
910+
will position itself on that specific position.
911+
schema: pulsar.schema.Schema | None, default=None
912+
Define the schema of the data that will be received by this reader.
913+
receiver_queue_size: int, default=1000
914+
Sets the size of the reader receive queue.
915+
reader_name: str | None, default=None
916+
Sets the reader name.
917+
subscription_role_prefix: str | None, default=None
918+
Sets the subscription role prefix.
919+
is_read_compacted: bool, default=False
920+
Selects whether to read the compacted version of the topic.
921+
crypto_key_reader: pulsar.CryptoKeyReader | None, default=None
922+
Symmetric encryption class implementation.
923+
start_message_id_inclusive: bool, default=False
924+
Set the reader to include the startMessageId or given position of
925+
any reset operation like Reader.seek.
926+
crypto_failure_action: ConsumerCryptoFailureAction, \
927+
default=ConsumerCryptoFailureAction.FAIL
928+
Set the behavior when the decryption fails.
929+
930+
Returns
931+
-------
932+
Reader
933+
The reader created
934+
935+
Raises
936+
------
937+
PulsarException
938+
"""
939+
if schema is None:
940+
schema = pulsar.schema.BytesSchema()
941+
942+
if isinstance(start_message_id, pulsar.MessageId):
943+
start_message_id = start_message_id._msg_id
944+
945+
_check_type(_pulsar.MessageId, start_message_id, 'start_message_id')
946+
947+
conf = _pulsar.ReaderConfiguration()
948+
conf.receiver_queue_size(receiver_queue_size)
949+
if reader_name is not None:
950+
conf.reader_name(reader_name)
951+
if subscription_role_prefix is not None:
952+
conf.subscription_role_prefix(subscription_role_prefix)
953+
conf.schema(schema.schema_info())
954+
conf.read_compacted(is_read_compacted)
955+
if crypto_key_reader is not None:
956+
conf.crypto_key_reader(crypto_key_reader.cryptoKeyReader)
957+
conf.start_message_id_inclusive(start_message_id_inclusive)
958+
conf.crypto_failure_action(crypto_failure_action)
959+
960+
future = asyncio.get_running_loop().create_future()
961+
self._client.create_reader_async_v2(
962+
topic, start_message_id, conf, functools.partial(_set_future_v2, future)
963+
)
964+
reader = await future
965+
schema.attach_client(self._client)
966+
return Reader(reader, schema)
967+
780968
def shutdown(self) -> None:
781969
"""
782970
Shutdown the client and all the associated producers and consumers

src/client.cc

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,18 @@ Reader Client_createReader(Client& client, const std::string& topic, const Messa
118118
[&](ReaderCallback callback) { client.createReaderAsync(topic, startMessageId, conf, callback); });
119119
}
120120

121+
void Client_createReaderAsync(Client& client, const std::string& topic, const MessageId& startMessageId,
122+
ReaderConfiguration conf, ReaderCallback callback) {
123+
py::gil_scoped_release release;
124+
client.createReaderAsync(topic, startMessageId, conf, callback);
125+
}
126+
127+
void Client_createReaderAsyncV2(Client& client, const std::string& topic, const MessageId& startMessageId,
128+
ReaderConfiguration conf, ReaderV2Callback callback) {
129+
py::gil_scoped_release release;
130+
client.createReaderAsyncV2(topic, startMessageId, conf, std::move(callback));
131+
}
132+
121133
std::vector<std::string> Client_getTopicPartitions(Client& client, const std::string& topic) {
122134
return waitForAsyncValue<std::vector<std::string>>(
123135
[&](GetPartitionsCallback callback) { client.getPartitionsForTopicAsync(topic, callback); });
@@ -204,6 +216,8 @@ void export_client(py::module_& m) {
204216
.def("subscribe_topics", &Client_subscribe_topics)
205217
.def("subscribe_pattern", &Client_subscribe_pattern)
206218
.def("create_reader", &Client_createReader)
219+
.def("create_reader_async", &Client_createReaderAsync)
220+
.def("create_reader_async_v2", &Client_createReaderAsyncV2)
207221
.def("create_table_view",
208222
[](Client& client, const std::string& topic, const TableViewConfiguration& config) {
209223
return waitForAsyncValue<TableView>([&](TableViewCallback callback) {

src/reader.cc

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
* under the License.
1818
*/
1919
#include "utils.h"
20+
#include <pybind11/functional.h>
2021
#include <pybind11/pybind11.h>
2122

2223
namespace py = pybind11;
@@ -54,16 +55,46 @@ void Reader_seek_timestamp(Reader& reader, uint64_t timestamp) {
5455

5556
bool Reader_is_connected(Reader& reader) { return reader.isConnected(); }
5657

58+
void Reader_readNextAsync(Reader& reader, ReadNextCallback callback) {
59+
py::gil_scoped_release release;
60+
reader.readNextAsync(callback);
61+
}
62+
63+
void Reader_closeAsync(Reader& reader, ResultCallback callback) {
64+
py::gil_scoped_release release;
65+
reader.closeAsync(callback);
66+
}
67+
68+
void Reader_seekAsync(Reader& reader, const MessageId& msgId, ResultCallback callback) {
69+
py::gil_scoped_release release;
70+
reader.seekAsync(msgId, callback);
71+
}
72+
73+
void Reader_seekAsync_timestamp(Reader& reader, uint64_t timestamp, ResultCallback callback) {
74+
py::gil_scoped_release release;
75+
reader.seekAsync(timestamp, callback);
76+
}
77+
78+
void Reader_hasMessageAvailableAsync(Reader& reader, HasMessageAvailableCallback callback) {
79+
py::gil_scoped_release release;
80+
reader.hasMessageAvailableAsync(callback);
81+
}
82+
5783
void export_reader(py::module_& m) {
5884
using namespace py;
5985

6086
class_<Reader>(m, "Reader")
6187
.def("topic", &Reader::getTopic, return_value_policy::copy)
6288
.def("read_next", &Reader_readNext)
6389
.def("read_next", &Reader_readNextTimeout)
90+
.def("read_next_async", &Reader_readNextAsync)
6491
.def("has_message_available", &Reader_hasMessageAvailable)
92+
.def("has_message_available_async", &Reader_hasMessageAvailableAsync)
6593
.def("close", &Reader_close)
94+
.def("close_async", &Reader_closeAsync)
6695
.def("seek", &Reader_seek)
6796
.def("seek", &Reader_seek_timestamp)
97+
.def("seek_async", &Reader_seekAsync)
98+
.def("seek_async", &Reader_seekAsync_timestamp)
6899
.def("is_connected", &Reader_is_connected);
69100
}

tests/asyncio_test.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
Consumer,
4040
Producer,
4141
PulsarException,
42+
Reader,
4243
_set_future,
4344
)
4445
from pulsar.schema import ( # pylint: disable=import-error
@@ -465,6 +466,86 @@ async def test_seek_timestamp(self):
465466
msg = await consumer.receive()
466467
self.assertEqual(msg.data(), b'msg-3')
467468

469+
async def test_reader_simple(self):
470+
topic = f'asyncio-test-reader-simple-{time.time()}'
471+
reader = await self._client.create_reader(topic, pulsar.MessageId.earliest)
472+
self.assertTrue(reader.is_connected())
473+
self.assertEqual(reader.topic(), f'persistent://public/default/{topic}')
474+
475+
producer = await self._client.create_producer(topic)
476+
await producer.send(b'hello')
477+
msg = await reader.read_next()
478+
self.assertEqual(msg.data(), b'hello')
479+
with self.assertRaises(asyncio.TimeoutError):
480+
await asyncio.wait_for(reader.read_next(), 1)
481+
await reader.close()
482+
self.assertFalse(reader.is_connected())
483+
484+
async def test_reader_on_last_message(self):
485+
topic = f'asyncio-test-reader-on-last-message-{time.time()}'
486+
producer = await self._client.create_producer(topic)
487+
for i in range(10):
488+
await producer.send(f'hello-{i}'.encode())
489+
reader = await self._client.create_reader(topic, pulsar.MessageId.latest)
490+
for i in range(10, 20):
491+
await producer.send(f'hello-{i}'.encode())
492+
for i in range(10, 20):
493+
msg = await reader.read_next()
494+
self.assertEqual(msg.data(), f'hello-{i}'.encode())
495+
await reader.close()
496+
497+
async def test_reader_on_specific_message(self):
498+
topic = f'asyncio-test-reader-on-specific-msg-{time.time()}'
499+
producer = await self._client.create_producer(topic)
500+
msg_ids = []
501+
for i in range(10):
502+
msg_id = await producer.send(f'hello-{i}'.encode())
503+
msg_ids.append(msg_id)
504+
reader1 = await self._client.create_reader(topic, pulsar.MessageId.earliest)
505+
for i in range(5):
506+
msg = await reader1.read_next()
507+
self.assertEqual(msg.data(), f'hello-{i}'.encode())
508+
last_msg_id = msg_ids[4]
509+
reader2 = await self._client.create_reader(topic, last_msg_id)
510+
for i in range(5, 10):
511+
msg = await reader2.read_next()
512+
self.assertEqual(msg.data(), f'hello-{i}'.encode())
513+
await reader1.close()
514+
await reader2.close()
515+
516+
async def test_reader_has_message_available(self):
517+
topic = f'asyncio-test-reader-has-message-available-{time.time()}'
518+
producer = await self._client.create_producer(topic)
519+
reader = await self._client.create_reader(topic, pulsar.MessageId.latest)
520+
self.assertFalse(await reader.has_message_available())
521+
for i in range(10):
522+
await producer.send(f'hello-{i}'.encode())
523+
for _ in range(10):
524+
self.assertTrue(await reader.has_message_available())
525+
await reader.read_next()
526+
self.assertFalse(await reader.has_message_available())
527+
await reader.close()
528+
529+
async def test_reader_seek(self):
530+
topic = f'asyncio-test-reader-seek-{time.time()}'
531+
producer = await self._client.create_producer(topic)
532+
msg_ids = []
533+
for i in range(10):
534+
msg_id = await producer.send(f'msg-{i}'.encode())
535+
msg_ids.append(msg_id)
536+
reader = await self._client.create_reader(topic, pulsar.MessageId.latest,
537+
start_message_id_inclusive=False)
538+
await reader.seek(msg_ids[2])
539+
msg = await reader.read_next()
540+
self.assertEqual(msg.data(), b'msg-3')
541+
await reader.close()
542+
reader_inclusive = await self._client.create_reader(topic, pulsar.MessageId.latest,
543+
start_message_id_inclusive=True)
544+
await reader_inclusive.seek(msg_ids[2])
545+
msg = await reader_inclusive.read_next()
546+
self.assertEqual(msg.data(), b'msg-2')
547+
await reader_inclusive.close()
548+
468549
async def test_schema(self):
469550
class ExampleRecord(Record): # pylint: disable=too-few-public-methods
470551
"""Example record schema for testing."""
@@ -507,6 +588,12 @@ def raise_exception():
507588
self.assertEqual(e.exception.error(), pulsar.Result.AuthenticationError)
508589
# TODO: we should fix the error message not included in pattern subscription case
509590

591+
with self.assertRaises(PulsarException) as e:
592+
await client.create_reader("private/auth/asyncio-test-token-auth-reader",
593+
pulsar.MessageId.earliest)
594+
self.assertEqual(e.exception.error(), pulsar.Result.AuthenticationError)
595+
self.assertIn("token supplier failed", str(e.exception))
596+
510597
await client.close()
511598

512599

0 commit comments

Comments
 (0)