Skip to content

Commit 55da950

Browse files
feat!: introduce ReadSession with caught-up state tracking (#88)
Co-authored-by: quettabit <27509167+quettabit@users.noreply.github.com>
1 parent 746b20e commit 55da950

11 files changed

Lines changed: 569 additions & 76 deletions

File tree

docs/source/api-reference.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
.. autoclass:: BatchSubmitTicket()
2323
:members:
2424
25+
.. autoclass:: ReadSession()
26+
:members:
27+
2528
.. autoclass:: Producer()
2629
:members:
2730

s2-specs

src/s2_sdk/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
)
1515
from s2_sdk._ops import S2, S2Basin, S2Stream
1616
from s2_sdk._producer import Producer, RecordSubmitTicket
17+
from s2_sdk._read_session import ReadSession
1718
from s2_sdk._types import (
1819
AccessTokenInfo,
1920
AccessTokenScope,
@@ -98,6 +99,7 @@
9899
"Timestamp",
99100
"TailOffset",
100101
"ReadBatch",
102+
"ReadSession",
101103
"ReadLimit",
102104
"SequencedRecord",
103105
"Page",

src/s2_sdk/_append_session.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ class AppendSession:
2828
Supports pipelining multiple :class:`AppendInput`\\ s while preserving
2929
submission order.
3030
31+
Use it as an async context manager, or call :meth:`close` explicitly to close the session.
32+
3133
Caution:
3234
Returned by :meth:`S2Stream.append_session`. Do not instantiate directly.
3335
"""

src/s2_sdk/_client.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import ssl
66
import time
77
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Iterable
8-
from contextlib import asynccontextmanager
8+
from contextlib import asynccontextmanager, suppress
99
from dataclasses import dataclass, field
1010
from importlib.metadata import version
1111
from typing import Any, NamedTuple
@@ -423,10 +423,8 @@ async def close(self) -> None:
423423
self._closed = True
424424
if self._reaper_task is not None:
425425
self._reaper_task.cancel()
426-
try:
426+
with suppress(asyncio.CancelledError):
427427
await self._reaper_task
428-
except asyncio.CancelledError:
429-
pass
430428
for conns in self._hosts.values():
431429
for pc in conns:
432430
await pc.close()

src/s2_sdk/_ops.py

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import uuid
33
from collections.abc import AsyncIterator
44
from datetime import datetime
5-
from typing import Any, AsyncIterable, Self
5+
from typing import Any, Self
66
from urllib.parse import quote
77

88
import s2_sdk._generated.s2.v1.s2_pb2 as pb
@@ -33,6 +33,7 @@
3333
tail_from_json,
3434
)
3535
from s2_sdk._producer import Producer
36+
from s2_sdk._read_session import ReadSession
3637
from s2_sdk._retrier import Retrier, http_retry_on, is_safe_to_retry_unary
3738
from s2_sdk._s2s._read_session import run_read_session
3839
from s2_sdk._types import (
@@ -1172,7 +1173,7 @@ async def read(
11721173
return batch
11731174

11741175
@fallible
1175-
async def read_session(
1176+
def read_session(
11761177
self,
11771178
*,
11781179
start: types.SeqNum | types.Timestamp | types.TailOffset,
@@ -1181,7 +1182,7 @@ async def read_session(
11811182
clamp_to_tail: bool = False,
11821183
wait: int | None = None,
11831184
ignore_command_records: bool = False,
1184-
) -> AsyncIterable[types.ReadBatch]:
1185+
) -> ReadSession:
11851186
"""Read batches of records from a stream continuously.
11861187
11871188
Args:
@@ -1196,9 +1197,8 @@ async def read_session(
11961197
reached.
11971198
ignore_command_records: Filter out command records from batches.
11981199
1199-
Yields:
1200-
:class:`ReadBatch` — each containing a batch of records and an
1201-
optional tail position.
1200+
Returns:
1201+
A :class:`ReadSession` that yields batches of records.
12021202
12031203
Note:
12041204
Sessions without bounds (no ``limit`` or ``until_timestamp``) default
@@ -1207,19 +1207,20 @@ async def read_session(
12071207
``wait`` makes a bounded session wait up to that many seconds for
12081208
new records before ending.
12091209
"""
1210-
async for batch in run_read_session(
1211-
self._client,
1212-
self.name,
1213-
start,
1214-
limit,
1215-
until_timestamp,
1216-
clamp_to_tail,
1217-
wait,
1218-
ignore_command_records,
1219-
retry=self._retry,
1220-
encryption_key=self._encryption_key,
1221-
):
1222-
yield batch
1210+
return ReadSession(
1211+
run_read_session(
1212+
self._client,
1213+
self.name,
1214+
start,
1215+
limit,
1216+
until_timestamp,
1217+
clamp_to_tail,
1218+
wait,
1219+
retry=self._retry,
1220+
encryption_key=self._encryption_key,
1221+
),
1222+
ignore_command_records=ignore_command_records,
1223+
)
12231224

12241225

12251226
def _s2_request_token() -> str:

src/s2_sdk/_producer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ class Producer:
3333
Handles batching into :class:`AppendInput` automatically and uses an
3434
append session internally.
3535
36+
Use it as an async context manager, or call :meth:`close` explicitly to close the producer.
37+
3638
Caution:
3739
Returned by :meth:`S2Stream.producer`. Do not instantiate directly.
3840
"""

src/s2_sdk/_read_session.py

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable
5+
from contextlib import suppress
6+
from dataclasses import dataclass, field
7+
from typing import Self
8+
9+
from s2_sdk._exceptions import S2ClientError, normalize_exception
10+
from s2_sdk._types import ReadBatch, StreamPosition
11+
12+
13+
class _ReadSessionRetrying:
14+
pass
15+
16+
17+
@dataclass(frozen=True, slots=True)
18+
class _ReadSessionHeartbeat:
19+
tail: StreamPosition
20+
21+
22+
@dataclass(frozen=True, slots=True)
23+
class _ReadSessionBatch:
24+
batch: ReadBatch
25+
26+
27+
_ReadSessionEvent = _ReadSessionRetrying | _ReadSessionHeartbeat | _ReadSessionBatch
28+
29+
30+
@dataclass(slots=True)
31+
class _ReadDelivery:
32+
batch: ReadBatch
33+
caught_up_tail: StreamPosition | None
34+
delivered: asyncio.Event = field(default_factory=asyncio.Event)
35+
36+
37+
class ReadSession(AsyncIterator[ReadBatch]):
38+
"""Async iterator that yields :class:`ReadBatch` values and tracks caught-up state.
39+
40+
Use it as an async context manager, or call :meth:`close` explicitly to close the session.
41+
42+
Caution:
43+
Returned by :meth:`S2Stream.read_session`. Do not instantiate directly.
44+
"""
45+
46+
__slots__ = (
47+
"_caught_up_tail",
48+
"_closed",
49+
"_delivery_queue",
50+
"_error",
51+
"_ignore_command_records",
52+
"_task",
53+
"_events",
54+
"_caught_up_futs",
55+
)
56+
57+
def __init__(
58+
self,
59+
events: AsyncGenerator[_ReadSessionEvent, None],
60+
*,
61+
ignore_command_records: bool = False,
62+
) -> None:
63+
self._events = events
64+
self._ignore_command_records = ignore_command_records
65+
self._delivery_queue: asyncio.Queue[_ReadDelivery | BaseException | None] = (
66+
asyncio.Queue(maxsize=1)
67+
)
68+
self._task: asyncio.Task[None] | None = None
69+
self._closed = False
70+
self._error: BaseException | None = None
71+
self._caught_up_tail: StreamPosition | None = None
72+
self._caught_up_futs: set[asyncio.Future[StreamPosition]] = set()
73+
74+
def is_caught_up(self) -> bool:
75+
"""Whether this session has yielded all records with sequence numbers less than
76+
the sequence number of the last observed tail position.
77+
78+
The stream's tail may have advanced since this session last observed it.
79+
Use :meth:`S2Stream.check_tail` if you need the current tail.
80+
"""
81+
return self._caught_up_tail is not None
82+
83+
def caught_up(self) -> Awaitable[StreamPosition]:
84+
"""Get an awaitable that resolves to a tail position once this session is caught up to it.
85+
86+
See :meth:`is_caught_up` for the semantics of caught up.
87+
88+
This awaitable only signals that the session is caught up. To yield batches, continue
89+
iterating over the session.
90+
"""
91+
loop = asyncio.get_running_loop()
92+
caught_up_fut: asyncio.Future[StreamPosition] = loop.create_future()
93+
if self._caught_up_tail is not None:
94+
caught_up_fut.set_result(_copy_position(self._caught_up_tail))
95+
elif self._closed:
96+
caught_up_fut.set_exception(
97+
self._error if self._error is not None else _session_closed_error()
98+
)
99+
else:
100+
self._caught_up_futs.add(caught_up_fut)
101+
caught_up_fut.add_done_callback(self._caught_up_futs.discard)
102+
self._ensure_started()
103+
return caught_up_fut
104+
105+
async def close(self) -> None:
106+
"""Close the session."""
107+
if self._closed:
108+
return
109+
if self._task is None:
110+
self._finish()
111+
return
112+
current = asyncio.current_task()
113+
cancellation_count = current.cancelling() if current is not None else 0
114+
self._task.cancel()
115+
with suppress(asyncio.CancelledError):
116+
await self._task
117+
if not self._closed:
118+
self._finish()
119+
if current is not None and current.cancelling() > cancellation_count:
120+
raise asyncio.CancelledError
121+
122+
def __aiter__(self) -> Self:
123+
self._ensure_started()
124+
return self
125+
126+
async def __anext__(self) -> ReadBatch:
127+
session = self.__aiter__()
128+
try:
129+
delivery = await session._delivery_queue.get()
130+
except asyncio.CancelledError:
131+
await self.close()
132+
raise
133+
134+
if delivery is None:
135+
self._delivery_queue.put_nowait(None)
136+
raise StopAsyncIteration
137+
if isinstance(delivery, BaseException):
138+
self._delivery_queue.put_nowait(None)
139+
raise delivery
140+
141+
if delivery.caught_up_tail is not None:
142+
self._mark_caught_up(delivery.caught_up_tail)
143+
delivery.delivered.set()
144+
return delivery.batch
145+
146+
async def __aenter__(self) -> Self:
147+
return self.__aiter__()
148+
149+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool:
150+
await self.close()
151+
return False
152+
153+
def _ensure_started(self) -> None:
154+
if self._task is None and not self._closed:
155+
self._task = asyncio.get_running_loop().create_task(self._run())
156+
157+
async def _run(self) -> None:
158+
error: BaseException | None = None
159+
cancelled = False
160+
events = self._events
161+
try:
162+
async for event in events:
163+
await self._handle_event(event)
164+
except asyncio.CancelledError:
165+
cancelled = True
166+
except BaseException as exc:
167+
error = normalize_exception(exc)
168+
finally:
169+
try:
170+
await events.aclose()
171+
except BaseException as exc:
172+
if error is None and not cancelled:
173+
error = normalize_exception(exc)
174+
self._finish(error)
175+
176+
async def _handle_event(self, event: _ReadSessionEvent) -> None:
177+
if isinstance(event, _ReadSessionRetrying):
178+
self._mark_not_caught_up()
179+
elif isinstance(event, _ReadSessionHeartbeat):
180+
self._mark_caught_up(event.tail)
181+
else:
182+
batch = event.batch
183+
caught_up_tail = (
184+
batch.tail
185+
if batch.tail is not None
186+
and batch.records[-1].seq_num + 1 == batch.tail.seq_num
187+
else None
188+
)
189+
190+
if self._ignore_command_records:
191+
batch = ReadBatch(
192+
records=[r for r in batch.records if not r.is_command_record()],
193+
tail=batch.tail,
194+
)
195+
196+
if batch.records:
197+
self._mark_not_caught_up()
198+
delivery = _ReadDelivery(batch, caught_up_tail)
199+
await self._delivery_queue.put(delivery)
200+
await delivery.delivered.wait()
201+
elif caught_up_tail is not None:
202+
self._mark_caught_up(caught_up_tail)
203+
else:
204+
self._mark_not_caught_up()
205+
206+
def _mark_caught_up(self, tail: StreamPosition) -> None:
207+
if self._closed:
208+
return
209+
self._caught_up_tail = _copy_position(tail)
210+
caught_up_futs = tuple(self._caught_up_futs)
211+
self._caught_up_futs.clear()
212+
for caught_up_fut in caught_up_futs:
213+
if not caught_up_fut.done():
214+
caught_up_fut.set_result(_copy_position(tail))
215+
216+
def _mark_not_caught_up(self) -> None:
217+
if not self._closed:
218+
self._caught_up_tail = None
219+
220+
def _finish(self, error: BaseException | None = None) -> None:
221+
if self._closed:
222+
return
223+
self._closed = True
224+
self._error = error
225+
if self._error is not None:
226+
self._caught_up_tail = None
227+
caught_up_futs = tuple(self._caught_up_futs)
228+
self._caught_up_futs.clear()
229+
for caught_up_fut in caught_up_futs:
230+
if not caught_up_fut.done():
231+
caught_up_fut.set_exception(
232+
self._error if self._error is not None else _session_closed_error()
233+
)
234+
235+
while not self._delivery_queue.empty():
236+
self._delivery_queue.get_nowait()
237+
self._delivery_queue.put_nowait(self._error)
238+
239+
240+
def _copy_position(position: StreamPosition) -> StreamPosition:
241+
return StreamPosition(position.seq_num, position.timestamp)
242+
243+
244+
def _session_closed_error() -> S2ClientError:
245+
return S2ClientError("ReadSession is closed")

0 commit comments

Comments
 (0)