Skip to content

Commit bb76d90

Browse files
feat: Implement SinglePartitionSubscriber. (#22)
* feat: Implement SinglePartitionSubscriber. This handles mapping a single partition to a Cloud Pub/Sub Like asynchronous subscriber. * feat: Add DefaultNackHandler. * docs: add comments explaining asyncio weirdness.
1 parent 58fda6f commit bb76d90

File tree

10 files changed

+401
-1
lines changed

10 files changed

+401
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from typing import NamedTuple
2+
3+
4+
class FlowControlSettings(NamedTuple):
5+
messages_outstanding: int
6+
bytes_outstanding: int
7+
8+
9+
_MAX_INT64 = 0x7FFFFFFFFFFFFFFF
10+
11+
DISABLED_FLOW_CONTROL = FlowControlSettings(_MAX_INT64, _MAX_INT64)

google/cloud/pubsublite/cloudpubsub/internal/ack_set_tracker_impl.py

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class AckSetTrackerImpl(AckSetTracker):
1515
_acks: "queue.PriorityQueue[int]"
1616

1717
def __init__(self, committer: Committer):
18+
super().__init__()
1819
self._committer = committer
1920
self._receipts = deque()
2021
self._acks = queue.PriorityQueue()

google/cloud/pubsublite/cloudpubsub/internal/async_publisher_impl.py

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class AsyncPublisherImpl(AsyncPublisher):
1111
_publisher: Publisher
1212

1313
def __init__(self, publisher: Publisher):
14+
super().__init__()
1415
self._publisher = publisher
1516

1617
async def publish(self, data: bytes, ordering_key: str = "", **attrs: Mapping[str, str]) -> str:

google/cloud/pubsublite/cloudpubsub/internal/publisher_impl.py

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class PublisherImpl(Publisher):
1010
_underlying: AsyncPublisher
1111

1212
def __init__(self, underlying: AsyncPublisher):
13+
super().__init__()
1314
self._managed_loop = ManagedEventLoop()
1415
self._underlying = underlying
1516

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import asyncio
2+
from typing import Union, Dict, NamedTuple
3+
import queue
4+
5+
from google.api_core.exceptions import FailedPrecondition, GoogleAPICallError
6+
from google.cloud.pubsub_v1.subscriber.message import Message
7+
from google.pubsub_v1 import PubsubMessage
8+
9+
from google.cloud.pubsublite.cloudpubsub.flow_control_settings import FlowControlSettings
10+
from google.cloud.pubsublite.cloudpubsub.internal.ack_set_tracker import AckSetTracker
11+
from google.cloud.pubsublite.cloudpubsub.message_transformer import MessageTransformer
12+
from google.cloud.pubsublite.cloudpubsub.nack_handler import NackHandler
13+
from google.cloud.pubsublite.cloudpubsub.subscriber import AsyncSubscriber
14+
from google.cloud.pubsublite.internal.wire.permanent_failable import PermanentFailable
15+
from google.cloud.pubsublite.internal.wire.subscriber import Subscriber
16+
from google.cloud.pubsublite_v1 import FlowControlRequest, SequencedMessage
17+
from google.cloud.pubsub_v1.subscriber._protocol import requests
18+
19+
20+
class _SizedMessage(NamedTuple):
21+
message: PubsubMessage
22+
size_bytes: int
23+
24+
25+
class SinglePartitionSubscriber(PermanentFailable, AsyncSubscriber):
26+
_underlying: Subscriber
27+
_flow_control_settings: FlowControlSettings
28+
_ack_set_tracker: AckSetTracker
29+
_nack_handler: NackHandler
30+
_transformer: MessageTransformer
31+
32+
_queue: queue.Queue
33+
_messages_by_offset: Dict[int, _SizedMessage]
34+
_looper_future: asyncio.Future
35+
36+
def __init__(self, underlying: Subscriber, flow_control_settings: FlowControlSettings, ack_set_tracker: AckSetTracker,
37+
nack_handler: NackHandler, transformer: MessageTransformer):
38+
super().__init__()
39+
self._underlying = underlying
40+
self._flow_control_settings = flow_control_settings
41+
self._ack_set_tracker = ack_set_tracker
42+
self._nack_handler = nack_handler
43+
self._transformer = transformer
44+
45+
self._queue = queue.Queue()
46+
self._messages_by_offset = {}
47+
48+
async def read(self) -> Message:
49+
message: SequencedMessage = await self.await_unless_failed(self._underlying.read())
50+
try:
51+
cps_message = self._transformer.transform(message)
52+
offset = message.cursor.offset
53+
self._ack_set_tracker.track(offset)
54+
self._messages_by_offset[offset] = _SizedMessage(cps_message, message.size_bytes)
55+
wrapped_message = Message(cps_message._pb, ack_id=str(offset), delivery_attempt=0, request_queue=self._queue)
56+
return wrapped_message
57+
except GoogleAPICallError as e:
58+
self.fail(e)
59+
raise e
60+
61+
async def _handle_ack(self, message: requests.AckRequest):
62+
offset = int(message.ack_id)
63+
await self._underlying.allow_flow(
64+
FlowControlRequest(allowed_messages=1, allowed_bytes=self._messages_by_offset[offset].size_bytes))
65+
del self._messages_by_offset[offset]
66+
try:
67+
await self._ack_set_tracker.ack(offset)
68+
except GoogleAPICallError as e:
69+
self.fail(e)
70+
71+
def _handle_nack(self, message: requests.NackRequest):
72+
offset = int(message.ack_id)
73+
sized_message = self._messages_by_offset[offset]
74+
try:
75+
# Put the ack request back into the queue since the callback may be called from another thread.
76+
self._nack_handler.on_nack(sized_message.message,
77+
lambda: self._queue.put(requests.AckRequest(
78+
ack_id=message.ack_id,
79+
byte_size=0, # Ignored
80+
time_to_ack=0, # Ignored
81+
ordering_key="" # Ignored
82+
)))
83+
except GoogleAPICallError as e:
84+
self.fail(e)
85+
86+
async def _handle_queue_message(self, message: Union[
87+
requests.AckRequest, requests.DropRequest, requests.ModAckRequest, requests.NackRequest]):
88+
if isinstance(message, requests.DropRequest) or isinstance(message, requests.ModAckRequest):
89+
self.fail(FailedPrecondition("Called internal method of google.cloud.pubsub_v1.subscriber.message.Message "
90+
f"Pub/Sub Lite does not support: {message}"))
91+
elif isinstance(message, requests.AckRequest):
92+
await self._handle_ack(message)
93+
else:
94+
self._handle_nack(message)
95+
96+
async def _looper(self):
97+
while True:
98+
try:
99+
# This is not an asyncio.Queue, and therefore we cannot do `await self._queue.get()`.
100+
# A blocking wait would block the event loop, this needs to be a queue.Queue for
101+
# compatibility with the Cloud Pub/Sub Message's requirements.
102+
queue_message = self._queue.get_nowait()
103+
await self._handle_queue_message(queue_message)
104+
except queue.Empty:
105+
await asyncio.sleep(.1)
106+
107+
async def __aenter__(self):
108+
await self._ack_set_tracker.__aenter__()
109+
await self._underlying.__aenter__()
110+
self._looper_future = asyncio.ensure_future(self._looper())
111+
await self._underlying.allow_flow(FlowControlRequest(
112+
allowed_messages=self._flow_control_settings.messages_outstanding,
113+
allowed_bytes=self._flow_control_settings.bytes_outstanding))
114+
return self
115+
116+
async def __aexit__(self, exc_type, exc_value, traceback):
117+
self._looper_future.cancel()
118+
try:
119+
await self._looper_future
120+
except asyncio.CancelledError:
121+
pass
122+
await self._underlying.__aexit__(exc_type, exc_value, traceback)
123+
await self._ack_set_tracker.__aexit__(exc_type, exc_value, traceback)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from abc import ABC, abstractmethod
2+
from collections import Callable
3+
4+
from google.pubsub_v1 import PubsubMessage
5+
6+
from google.cloud.pubsublite.cloudpubsub.message_transforms import to_cps_subscribe_message
7+
from google.cloud.pubsublite_v1 import SequencedMessage
8+
9+
10+
class MessageTransformer(ABC):
11+
"""
12+
A MessageTransformer turns Pub/Sub Lite message protos into Pub/Sub message protos.
13+
"""
14+
15+
@abstractmethod
16+
def transform(self, source: SequencedMessage) -> PubsubMessage:
17+
"""Transform a SequencedMessage to a PubsubMessage.
18+
19+
Args:
20+
source: The message to transform.
21+
22+
Raises:
23+
GoogleAPICallError: To fail the client if raised inline.
24+
"""
25+
pass
26+
27+
28+
class DefaultMessageTransformer(MessageTransformer):
29+
def transform(self, source: SequencedMessage) -> PubsubMessage:
30+
return to_cps_subscribe_message(source)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from abc import ABC, abstractmethod
2+
from typing import Callable
3+
4+
from google.api_core.exceptions import FailedPrecondition
5+
from google.pubsub_v1 import PubsubMessage
6+
7+
8+
class NackHandler(ABC):
9+
"""
10+
A NackHandler handles calls to the nack() method which is not expressible in Pub/Sub Lite.
11+
"""
12+
13+
@abstractmethod
14+
def on_nack(self, message: PubsubMessage, ack: Callable[[], None]):
15+
"""Handle a negative acknowledgement. ack must eventually be called.
16+
17+
Args:
18+
message: The nacked message.
19+
ack: A callable to acknowledge the underlying message. This must eventually be called.
20+
21+
Raises:
22+
GoogleAPICallError: To fail the client if raised inline.
23+
"""
24+
pass
25+
26+
27+
class DefaultNackHandler(NackHandler):
28+
def on_nack(self, message: PubsubMessage, ack: Callable[[], None]):
29+
raise FailedPrecondition(
30+
"You may not nack messages by default when using a PubSub Lite client. See NackHandler for how to customize"
31+
" this.")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from abc import abstractmethod
2+
from typing import AsyncContextManager
3+
4+
from google.cloud.pubsub_v1.subscriber.message import Message
5+
6+
7+
class AsyncSubscriber(AsyncContextManager):
8+
"""
9+
A Cloud Pub/Sub asynchronous subscriber.
10+
"""
11+
@abstractmethod
12+
async def read(self) -> Message:
13+
"""
14+
Read the next message off of the stream.
15+
16+
Returns:
17+
The next message. ack() or nack() must eventually be called exactly once.
18+
19+
Pub/Sub Lite does not support nack() by default- if you do call nack(), it will immediately fail the client
20+
unless you have a NackHandler installed.
21+
22+
Raises:
23+
GoogleAPICallError: On a permanent error.
24+
"""
25+
raise NotImplementedError()

setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"google-api-core >= 1.22.0",
3232
"absl-py >= 0.9.0",
3333
"proto-plus >= 0.4.0",
34-
"google-cloud-pubsub >= 1.7.0",
34+
"google-cloud-pubsub >= 2.1.0",
3535
"grpcio",
3636
"setuptools"
3737
]

0 commit comments

Comments
 (0)